Bump kinda to 0.1.1 with WiFi and UI features

This commit is contained in:
Pat Nakajima 2026-06-16 15:03:38 -07:00
parent 548636779e
commit 6c3da38fb9
28 changed files with 14383 additions and 102 deletions

View File

@ -1,6 +1,6 @@
[target.riscv32imac-unknown-none-elf] [target.riscv32imac-unknown-none-elf]
# runner = "probe-rs run --chip=esp32c6 --always-print-stacktrace --no-location --catch-hardfault" # runner = "probe-rs run --chip=esp32c6 --always-print-stacktrace --no-location --catch-hardfault"
runner = "espflash flash --monitor -L defmt --no-skip --chip esp32c6" runner = "espflash flash --monitor -L defmt --no-skip --chip esp32c6 --partition-table partitions.csv"
rustflags = ["-C", "force-frame-pointers"] rustflags = ["-C", "force-frame-pointers"]
[env] [env]

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
# will have compiled files and executables # will have compiled files and executables
debug/ debug/
target/ target/
web/pkg/
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk

412
Cargo.lock generated
View File

@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aead"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -76,6 +85,36 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bt-hci"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211713d2e9fb4793ce4360a712c0764264aff6be48932ccf02ca2a331c0436a9"
dependencies = [
"btuuid",
"defmt 1.0.1",
"embassy-sync 0.8.0",
"embedded-io 0.7.1",
"embedded-io-async 0.7.0",
"futures-intrusive",
"heapless 0.9.3",
]
[[package]]
name = "btuuid"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f48f1e9b0aad0a4f05d17bdeae0fa20ff798e272a03a6940ca27ad9c5a6ae7"
dependencies = [
"defmt 0.3.100",
]
[[package]]
name = "byte"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.0"
@ -98,12 +137,33 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "ccm"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317"
dependencies = [
"aead",
"cipher",
"ctr",
"subtle",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cipher"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "const-default" name = "const-default"
version = "1.0.0" version = "1.0.0"
@ -136,6 +196,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -301,6 +370,26 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "docsplay"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4"
dependencies = [
"docsplay-macros",
]
[[package]]
name = "docsplay-macros"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.11" version = "0.2.11"
@ -668,7 +757,7 @@ dependencies = [
"document-features", "document-features",
"enumset", "enumset",
"esp-config", "esp-config",
"esp-sync", "esp-sync 0.2.1",
"linked_list_allocator", "linked_list_allocator",
"rlsf", "rlsf",
] ]
@ -683,7 +772,7 @@ dependencies = [
"defmt 1.0.1", "defmt 1.0.1",
"document-features", "document-features",
"esp-config", "esp-config",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp-println", "esp-println",
"heapless 0.9.3", "heapless 0.9.3",
"riscv", "riscv",
@ -700,8 +789,8 @@ dependencies = [
"document-features", "document-features",
"embedded-storage", "embedded-storage",
"esp-config", "esp-config",
"esp-hal-procmacros", "esp-hal-procmacros 0.22.0",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp-rom-sys", "esp-rom-sys",
"jiff", "jiff",
"strum", "strum",
@ -714,7 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80"
dependencies = [ dependencies = [
"document-features", "document-features",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"serde", "serde",
"serde_yaml", "serde_yaml",
"somni-expr", "somni-expr",
@ -747,11 +836,11 @@ dependencies = [
"embedded-io-async 0.7.0", "embedded-io-async 0.7.0",
"enumset", "enumset",
"esp-config", "esp-config",
"esp-hal-procmacros", "esp-hal-procmacros 0.22.0",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp-riscv-rt", "esp-riscv-rt",
"esp-rom-sys", "esp-rom-sys",
"esp-sync", "esp-sync 0.2.1",
"esp32", "esp32",
"esp32c2", "esp32c2",
"esp32c3", "esp32c3",
@ -776,6 +865,20 @@ dependencies = [
"xtensa-lx-rt", "xtensa-lx-rt",
] ]
[[package]]
name = "esp-hal-procmacros"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e025a7a7a0affdb4ff913b5c4494aef96ee03d085bf83c27453ae3a71d50da6"
dependencies = [
"document-features",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.101",
"termcolor",
]
[[package]] [[package]]
name = "esp-hal-procmacros" name = "esp-hal-procmacros"
version = "0.22.0" version = "0.22.0"
@ -791,12 +894,36 @@ dependencies = [
"termcolor", "termcolor",
] ]
[[package]]
name = "esp-metadata-generated"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a93e39c8ad8d390d248dc7b9f4b59a873f313bf535218b8e2351356972399e3"
[[package]] [[package]]
name = "esp-metadata-generated" name = "esp-metadata-generated"
version = "0.4.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a"
[[package]]
name = "esp-phy"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228"
dependencies = [
"cfg-if",
"defmt 1.0.1",
"document-features",
"embassy-sync 0.8.0",
"esp-config",
"esp-hal",
"esp-metadata-generated 0.4.0",
"esp-sync 0.2.1",
"esp-wifi-sys-esp32c6",
"esp32c6",
]
[[package]] [[package]]
name = "esp-println" name = "esp-println"
version = "0.17.0" version = "0.17.0"
@ -805,12 +932,64 @@ checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6"
dependencies = [ dependencies = [
"defmt 1.0.1", "defmt 1.0.1",
"document-features", "document-features",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp-sync", "esp-sync 0.2.1",
"log", "log",
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "esp-radio"
version = "1.0.0-beta.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f25cc4e3ce27476b42c4a68943f10a92f9dec3c24bb001269958f0318fef02c"
dependencies = [
"allocator-api2",
"bt-hci",
"cfg-if",
"defmt 1.0.1",
"docsplay",
"document-features",
"embassy-net-driver",
"embassy-sync 0.8.0",
"enumset",
"esp-alloc",
"esp-config",
"esp-hal",
"esp-hal-procmacros 0.22.0",
"esp-metadata-generated 0.4.0",
"esp-phy",
"esp-radio-rtos-driver",
"esp-rom-sys",
"esp-sync 0.2.1",
"esp-wifi-sys-esp32",
"esp-wifi-sys-esp32c2",
"esp-wifi-sys-esp32c3",
"esp-wifi-sys-esp32c6",
"esp-wifi-sys-esp32h2",
"esp-wifi-sys-esp32s2",
"esp-wifi-sys-esp32s3",
"esp32c6",
"heapless 0.9.3",
"ieee802154",
"instability",
"num-derive",
"num-traits",
"portable-atomic",
"portable_atomic_enum",
]
[[package]]
name = "esp-radio-rtos-driver"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494"
dependencies = [
"cfg-if",
"esp-sync 0.2.1",
"portable-atomic",
]
[[package]] [[package]]
name = "esp-riscv-rt" name = "esp-riscv-rt"
version = "0.14.0" version = "0.14.0"
@ -831,7 +1010,7 @@ checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"document-features", "document-features",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp32c6", "esp32c6",
] ]
@ -841,6 +1020,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712" checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712"
dependencies = [ dependencies = [
"allocator-api2",
"cfg-if", "cfg-if",
"defmt 1.0.1", "defmt 1.0.1",
"document-features", "document-features",
@ -850,15 +1030,47 @@ dependencies = [
"embassy-time-queue-utils", "embassy-time-queue-utils",
"esp-config", "esp-config",
"esp-hal", "esp-hal",
"esp-hal-procmacros", "esp-hal-procmacros 0.22.0",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"esp-radio-rtos-driver",
"esp-rom-sys", "esp-rom-sys",
"esp-sync", "esp-sync 0.2.1",
"portable-atomic", "portable-atomic",
"riscv", "riscv",
"xtensa-lx", "xtensa-lx",
] ]
[[package]]
name = "esp-storage"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1495fc1f5549bdd840b52d9ceb201746200e1620d2636f46958c11e765623b80"
dependencies = [
"defmt 1.0.1",
"document-features",
"embedded-storage",
"esp-hal",
"esp-hal-procmacros 0.21.0",
"esp-metadata-generated 0.3.0",
"esp-rom-sys",
"esp-sync 0.1.1",
]
[[package]]
name = "esp-sync"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d44974639b4e88914f83fe60d2832c00276657d7d857628fdfc966cc7302e8a8"
dependencies = [
"cfg-if",
"document-features",
"embassy-sync 0.6.2",
"embassy-sync 0.7.2",
"esp-metadata-generated 0.3.0",
"riscv",
"xtensa-lx",
]
[[package]] [[package]]
name = "esp-sync" name = "esp-sync"
version = "0.2.1" version = "0.2.1"
@ -871,11 +1083,74 @@ dependencies = [
"embassy-sync 0.6.2", "embassy-sync 0.6.2",
"embassy-sync 0.7.2", "embassy-sync 0.7.2",
"embassy-sync 0.8.0", "embassy-sync 0.8.0",
"esp-metadata-generated", "esp-metadata-generated 0.4.0",
"riscv", "riscv",
"xtensa-lx", "xtensa-lx",
] ]
[[package]]
name = "esp-wifi-sys-esp32"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2556f38f5292d9735d4e156e276815fc001c9a0a2be0544a575c5fb867129d24"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32c2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4b3eb3435dae84de611d4384639e61eed862818223e93fa70c525cb6a70127f"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32c3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b290846b53db9a3965866964260220b67f2c41cc2dbc0b377e7136239fe168a7"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32c6"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32h2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cf1be2e311f4b4e75b5507c6b007ffc3fcb8c6cb57f83cc6a9569ecdcf58484"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32s2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a9a7f1bc51e7026c3012cda4f11d8799e5e0c0bb3be85797462219def6c26e"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "esp-wifi-sys-esp32s3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d8321f44b57d8112cbd8607cc83b85783b9195289acb45532728e3e229e7786"
dependencies = [
"defmt 1.0.1",
]
[[package]] [[package]]
name = "esp32" name = "esp32"
version = "0.40.2" version = "0.40.2"
@ -1003,6 +1278,16 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@ -1058,6 +1343,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "hash32" name = "hash32"
version = "0.3.1" version = "0.3.1"
@ -1067,6 +1361,17 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "hash32-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@ -1079,7 +1384,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [ dependencies = [
"hash32", "hash32 0.3.1",
"stable_deref_trait", "stable_deref_trait",
] ]
@ -1089,7 +1394,8 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [ dependencies = [
"hash32", "defmt 1.0.1",
"hash32 0.3.1",
"stable_deref_trait", "stable_deref_trait",
] ]
@ -1105,6 +1411,20 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "ieee802154"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5"
dependencies = [
"byte",
"ccm",
"cipher",
"defmt 0.3.100",
"hash32 0.2.1",
"hash32-derive",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@ -1166,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "kinda" name = "kinda"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"critical-section", "critical-section",
"defmt 1.0.1", "defmt 1.0.1",
@ -1178,12 +1498,15 @@ dependencies = [
"embedded-hal 1.0.0", "embedded-hal 1.0.0",
"embedded-io 0.6.1", "embedded-io 0.6.1",
"embedded-io-async 0.6.1", "embedded-io-async 0.6.1",
"embedded-storage",
"esp-alloc", "esp-alloc",
"esp-backtrace", "esp-backtrace",
"esp-bootloader-esp-idf", "esp-bootloader-esp-idf",
"esp-hal", "esp-hal",
"esp-println", "esp-println",
"esp-radio",
"esp-rtos", "esp-rtos",
"esp-storage",
"heapless 0.8.0", "heapless 0.8.0",
"lm3630a", "lm3630a",
"smoltcp", "smoltcp",
@ -1225,6 +1548,15 @@ dependencies = [
"embedded-hal-async", "embedded-hal-async",
] ]
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.27" version = "0.4.27"
@ -1295,6 +1627,17 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -1352,6 +1695,27 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "portable_atomic_enum"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885"
dependencies = [
"portable-atomic",
"portable_atomic_enum_macros",
]
[[package]]
name = "portable_atomic_enum_macros"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -1527,6 +1891,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -1671,6 +2041,12 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "subtle"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]] [[package]]
name = "svgbobdoc" name = "svgbobdoc"
version = "0.3.0" version = "0.3.0"

View File

@ -1,14 +1,29 @@
[package] [package]
edition = "2021" edition = "2021"
name = "kinda" name = "kinda"
version = "0.1.0" version = "0.1.1"
default-run = "kinda"
[features]
default = []
web-sim = []
[[bin]] [[bin]]
name = "kinda" name = "kinda"
path = "./src/bin/main.rs" path = "./src/bin/main.rs"
[[bin]]
name = "kinda-web-sim"
path = "./src/bin/web_sim.rs"
required-features = ["web-sim"]
[dependencies] [dependencies]
ssd1677 = { path = "../forks/SSD1677" } embedded-graphics = "0.8.1"
heapless = { version = "0.8.0", default-features = false }
[target.'cfg(target_arch = "riscv32")'.dependencies]
embedded-graphics = { version = "0.8.1", features = ["defmt"] }
ssd1677 = { path = "../ssd1677" }
defmt = "1.0.1" defmt = "1.0.1"
embassy-net = { version = "0.6.0", features = [ embassy-net = { version = "0.6.0", features = [
"dhcpv4", "dhcpv4",
@ -19,11 +34,7 @@ embassy-net = { version = "0.6.0", features = [
embedded-io = "0.6.1" embedded-io = "0.6.1"
embedded-io-async = "0.6.1" embedded-io-async = "0.6.1"
esp-alloc = "0.10.0" esp-alloc = "0.10.0"
esp-hal = { version = "1.1.1", features = [ esp-hal = { version = "1.1.1", features = ["defmt", "esp32c6", "unstable"] }
"defmt",
"esp32c6",
"unstable",
] }
smoltcp = { version = "0.12.0", default-features = false, features = [ smoltcp = { version = "0.12.0", default-features = false, features = [
"proto-dhcpv4", "proto-dhcpv4",
"proto-dns", "proto-dns",
@ -38,19 +49,32 @@ smoltcp = { version = "0.12.0", default-features = false, features = [
critical-section = "1.2.0" critical-section = "1.2.0"
embassy-executor = { version = "0.10.0", features = ["defmt", "nightly"] } embassy-executor = { version = "0.10.0", features = ["defmt", "nightly"] }
embassy-time = { version = "0.5.0", features = ["generic-queue-8"] } embassy-time = { version = "0.5.0", features = ["generic-queue-8"] }
esp-rtos = { version = "0.3.0", features = ["embassy", "defmt", "esp32c6"] } esp-rtos = { version = "0.3.0", features = [
"embassy",
heapless = { version = "0.8.0", default-features = false } "defmt",
"esp-radio",
"esp32c6",
] }
esp-radio = { version = "1.0.0-beta.0", features = [
"defmt",
"esp32c6",
"wifi",
] }
static_cell = { version = "2.1.0", features = ["nightly"] } static_cell = { version = "2.1.0", features = ["nightly"] }
embedded-hal = { version = "1.0.0", features = ["defmt-03"] } embedded-hal = { version = "1.0.0", features = ["defmt-03"] }
embedded-storage = "0.3.1"
esp-storage = { version = "0.8.1", features = ["defmt", "esp32c6"] }
esp-println = { version = "0.17.0", features = [ esp-println = { version = "0.17.0", features = [
"defmt-espflash", "defmt-espflash",
"esp32c6", "esp32c6",
"log-04", "log-04",
] } ] }
esp-backtrace = { version = "0.19.0", features = ["panic-handler", "esp32c6", "defmt"] } esp-backtrace = { version = "0.19.0", features = [
"panic-handler",
"esp32c6",
"defmt",
] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6"] } esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6"] }
embedded-graphics = { version = "0.8.1", features = ["defmt"] }
embassy-futures = { version = "0.1.1", features = ["defmt"] } embassy-futures = { version = "0.1.1", features = ["defmt"] }
lm3630a = { version = "0.1.1", features = ["async"] } lm3630a = { version = "0.1.1", features = ["async"] }
@ -59,6 +83,9 @@ lm3630a = { version = "0.1.1", features = ["async"] }
# For debug builds always builds with some optimization # For debug builds always builds with some optimization
opt-level = "s" opt-level = "s"
[profile.dev.package.esp-radio]
opt-level = 3
[profile.release] [profile.release]
codegen-units = 1 # LLVM can perform better optimizations using a single thread codegen-units = 1 # LLVM can perform better optimizations using a single thread
debug = 2 debug = 2

View File

@ -1,4 +1,9 @@
fn main() { fn main() {
let target = std::env::var("TARGET").unwrap_or_default();
if target != "riscv32imac-unknown-none-elf" {
return;
}
linker_be_nice(); linker_be_nice();
println!("cargo:rustc-link-arg=-Tdefmt.x"); println!("cargo:rustc-link-arg=-Tdefmt.x");
// make sure linkall.x is the last linker script (otherwise might cause problems with flip-link) // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link)

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"private": true,
"type": "module",
"scripts": {
"sim:build": "./scripts/build-web-sim.sh",
"sim:dev": "npm run sim:build && python3 -m http.server 5174 --directory web"
}
}

3
partitions.csv Normal file
View File

@ -0,0 +1,3 @@
# Name, Type, SubType, Offset, Size, Flags
wifi,data,nvs,0x9000,0x7000,
factory,app,factory,0x10000,0x3F0000,
1 # Name Type SubType Offset Size Flags
2 wifi data nvs 0x9000 0x7000
3 factory app factory 0x10000 0x3F0000

11
scripts/build-web-sim.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=--no-entry -C link-arg=--export=kinda_sim_init -C link-arg=--export=kinda_sim_input -C link-arg=--export=kinda_sim_width -C link-arg=--export=kinda_sim_height -C link-arg=--export=kinda_sim_pixels -C link-arg=--export=kinda_sim_pixel_len -C link-arg=--export=memory"
cargo build --bin kinda-web-sim --features web-sim --target wasm32-unknown-unknown --release
mkdir -p web/pkg
target_dir="${CARGO_TARGET_DIR:-target}"
cp "$target_dir/wasm32-unknown-unknown/release/kinda-web-sim.wasm" web/pkg/kinda.wasm

View File

@ -0,0 +1,299 @@
// Generate embedded 4-bit-alpha font tables from FreeType's native monochrome renderer.
//
// Build/run example:
// cc scripts/generate-fonts-ftmono.c $(freetype-config --cflags --libs) -o /tmp/kinda-fontgen-ftmono
// /tmp/kinda-fontgen-ftmono /System/Library/Fonts/Supplemental/Verdana.ttf src/ui/generated_fonts.rs
//
// This path is intended for 1-bit e-paper output. It preserves TrueType mono
// hinting instead of thresholding an antialiased outline after rasterization.
#include <ft2build.h>
#include FT_FREETYPE_H
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct FontSize {
const char *name;
int pixels;
};
struct Glyph {
uint32_t codepoint;
uint8_t width;
uint8_t height;
uint32_t bitmap_offset;
};
struct Buffer {
uint8_t *data;
size_t len;
size_t cap;
};
static const struct FontSize SIZES[] = {
{"QUATTRO_SMALL", 24},
{"QUATTRO_BODY", 30},
{"QUATTRO_READER", 34},
{"QUATTRO_TITLE", 38},
};
static const uint32_t EXTRA_CODEPOINTS[] = {
0x00A0,
0x2018,
0x2019,
0x201C,
0x201D,
0x2013,
0x2014,
0x2026,
};
static int div_ceil_int(int value, int divisor) {
return (value + divisor - 1) / divisor;
}
static int round_26_6(FT_Pos value) {
if (value >= 0) {
return (int)((value + 32) >> 6);
}
return -(int)(((-value) + 32) >> 6);
}
static int ceil_26_6(FT_Pos value) {
if (value >= 0) {
return (int)((value + 63) >> 6);
}
return -(int)((-value) >> 6);
}
static void buffer_push(struct Buffer *buffer, uint8_t value) {
if (buffer->len == buffer->cap) {
size_t next_cap = buffer->cap == 0 ? 4096 : buffer->cap * 2;
uint8_t *next = realloc(buffer->data, next_cap);
if (next == NULL) {
fprintf(stderr, "out of memory\n");
exit(2);
}
buffer->data = next;
buffer->cap = next_cap;
}
buffer->data[buffer->len++] = value;
}
static int mono_bitmap_on(const FT_Bitmap *bitmap, unsigned int x, unsigned int y) {
int pitch = bitmap->pitch;
const uint8_t *row;
if (pitch < 0) {
row = bitmap->buffer + (bitmap->rows - 1 - y) * (unsigned int)(-pitch);
} else {
row = bitmap->buffer + y * (unsigned int)pitch;
}
return (row[x / 8] & (0x80 >> (x & 7))) != 0;
}
static void set_alpha4(uint8_t *cell, int width, int x, int y, uint8_t alpha) {
int pixel = y * width + x;
int byte = pixel / 2;
if ((pixel & 1) == 0) {
cell[byte] = (uint8_t)((cell[byte] & 0x0F) | (alpha << 4));
} else {
cell[byte] = (uint8_t)((cell[byte] & 0xF0) | alpha);
}
}
static int load_codepoints(uint32_t *codepoints, int max_codepoints) {
int count = 0;
for (uint32_t codepoint = 32; codepoint < 127; codepoint++) {
if (count >= max_codepoints) {
return count;
}
codepoints[count++] = codepoint;
}
for (size_t i = 0; i < sizeof(EXTRA_CODEPOINTS) / sizeof(EXTRA_CODEPOINTS[0]); i++) {
if (count >= max_codepoints) {
return count;
}
codepoints[count++] = EXTRA_CODEPOINTS[i];
}
return count;
}
static void emit_bytes(FILE *out, const struct Buffer *bitmap) {
for (size_t i = 0; i < bitmap->len; i++) {
if (i % 16 == 0) {
fputs(" ", out);
}
fprintf(out, "0x%02x,", bitmap->data[i]);
if (i % 16 == 15 || i + 1 == bitmap->len) {
fputc('\n', out);
} else {
fputc(' ', out);
}
}
}
static void emit_font(FILE *out, FT_Face face, const char *name, int pixels, const uint32_t *codepoints, int codepoint_count) {
if (FT_Set_Pixel_Sizes(face, 0, (FT_UInt)pixels) != 0) {
fprintf(stderr, "could not set pixel size %d\n", pixels);
exit(3);
}
int cell_height = ceil_26_6(face->size->metrics.height);
int baseline = ceil_26_6(face->size->metrics.ascender);
if (cell_height <= 0) {
cell_height = pixels;
}
struct Glyph *glyphs = calloc((size_t)codepoint_count, sizeof(struct Glyph));
if (glyphs == NULL) {
fprintf(stderr, "out of memory\n");
exit(2);
}
struct Buffer bitmap = {0};
int glyph_count = 0;
for (int i = 0; i < codepoint_count; i++) {
uint32_t codepoint = codepoints[i];
FT_UInt glyph_index = FT_Get_Char_Index(face, codepoint);
if (glyph_index == 0) {
continue;
}
if (FT_Load_Glyph(face, glyph_index, FT_LOAD_RENDER | FT_LOAD_TARGET_MONO) != 0) {
continue;
}
FT_GlyphSlot slot = face->glyph;
FT_Bitmap *source = &slot->bitmap;
int advance = round_26_6(slot->advance.x);
int left = slot->bitmap_left;
int top = slot->bitmap_top;
int right = left + (int)source->width;
int cell_width = advance;
if (right > cell_width) {
cell_width = right;
}
if (cell_width <= 0) {
cell_width = (int)source->width;
}
if (cell_width <= 0) {
cell_width = 1;
}
if (cell_width > 255 || cell_height > 255) {
fprintf(stderr, "glyph U+%04X too large at %dpx\n", codepoint, pixels);
exit(4);
}
int cell_len = div_ceil_int(cell_width * cell_height, 2);
uint8_t *cell = calloc((size_t)cell_len, 1);
if (cell == NULL) {
fprintf(stderr, "out of memory\n");
exit(2);
}
int x_origin = left < 0 ? 0 : left;
int y_origin = baseline - top;
for (unsigned int y = 0; y < source->rows; y++) {
int target_y = y_origin + (int)y;
if (target_y < 0 || target_y >= cell_height) {
continue;
}
for (unsigned int x = 0; x < source->width; x++) {
int target_x = x_origin + (int)x;
if (target_x < 0 || target_x >= cell_width) {
continue;
}
if (mono_bitmap_on(source, x, y)) {
set_alpha4(cell, cell_width, target_x, target_y, 15);
}
}
}
uint32_t offset = (uint32_t)bitmap.len;
for (int j = 0; j < cell_len; j++) {
buffer_push(&bitmap, cell[j]);
}
free(cell);
glyphs[glyph_count++] = (struct Glyph){
.codepoint = codepoint,
.width = (uint8_t)cell_width,
.height = (uint8_t)cell_height,
.bitmap_offset = offset,
};
}
fprintf(out, "// %d px; cell height %d px; %d glyphs; %zu mono alpha bytes.\n", pixels, cell_height, glyph_count, bitmap.len);
fprintf(out, "pub(crate) static %s: BitmapFont = BitmapFont {\n", name);
fprintf(out, " cell_height: %d,\n", cell_height);
fprintf(out, " glyphs: &%s_GLYPHS,\n", name);
fprintf(out, " bitmap: &%s_BITMAP,\n", name);
fprintf(out, "};\n\n");
fprintf(out, "#[rustfmt::skip]\n");
fprintf(out, "static %s_GLYPHS: [Glyph; %d] = [\n", name, glyph_count);
for (int i = 0; i < glyph_count; i++) {
fprintf(out, " Glyph { codepoint: 0x%04x, width: %u, height: %u, bitmap_offset: %u },\n",
glyphs[i].codepoint, glyphs[i].width, glyphs[i].height, glyphs[i].bitmap_offset);
}
fprintf(out, "];\n\n");
fprintf(out, "#[rustfmt::skip]\n");
fprintf(out, "static %s_BITMAP: [u8; %zu] = [\n", name, bitmap.len);
emit_bytes(out, &bitmap);
fprintf(out, "];\n\n");
free(glyphs);
free(bitmap.data);
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s FONT.ttf OUTPUT.rs\n", argv[0]);
return 2;
}
FT_Library library;
FT_Face face;
if (FT_Init_FreeType(&library) != 0) {
fprintf(stderr, "could not init freetype\n");
return 3;
}
if (FT_New_Face(library, argv[1], 0, &face) != 0) {
fprintf(stderr, "could not open font %s\n", argv[1]);
return 3;
}
uint32_t codepoints[128];
int codepoint_count = load_codepoints(codepoints, 128);
FILE *out = fopen(argv[2], "w");
if (out == NULL) {
perror("fopen");
return 3;
}
fprintf(out, "// @generated by scripts/generate-fonts-ftmono.c. Do not edit by hand.\n");
fprintf(out, "// Source font: %s\n", argv[1]);
fprintf(out, "// Rasterizer: FreeType mono hinting\n\n");
fprintf(out, "use crate::ui::typography::{BitmapFont, Glyph};\n\n");
for (size_t i = 0; i < sizeof(SIZES) / sizeof(SIZES[0]); i++) {
emit_font(out, face, SIZES[i].name, SIZES[i].pixels, codepoints, codepoint_count);
}
fclose(out);
FT_Done_Face(face);
FT_Done_FreeType(library);
return 0;
}

205
scripts/generate-fonts.py Normal file
View File

@ -0,0 +1,205 @@
# Generate embedded 1-bit bitmap fonts from a TrueType/OpenType font.
#
# FontForge's direct monochrome XBM export is too thin for this display, especially
# at footer sizes. This exports antialiased BMP glyphs and packs 4-bit alpha.
#
# Run with FontForge, not python3:
# fontforge -lang=py -script scripts/generate-fonts.py \
# '/Volumes/Drawer/Fonts/iAFonts/iA Writer Quattro/Static/iAWriterQuattroS-Regular.ttf' \
# src/ui/generated_fonts.rs
import os
import struct
import sys
import tempfile
import fontforge
SIZES = [
("QUATTRO_SMALL", 24),
("QUATTRO_BODY", 30),
("QUATTRO_READER", 34),
("QUATTRO_TITLE", 38),
]
CODEPOINTS = list(range(32, 127)) + [
0x00A0, # no-break space
0x2018, # left single quotation mark
0x2019, # right single quotation mark
0x201C, # left double quotation mark
0x201D, # right double quotation mark
0x2013, # en dash
0x2014, # em dash
0x2026, # ellipsis
]
def parse_bmp(path):
with open(path, "rb") as file:
data = file.read()
if data[0:2] != b"BM":
raise RuntimeError("not a BMP file: {}".format(path))
pixel_offset = struct.unpack_from("<I", data, 10)[0]
header_size = struct.unpack_from("<I", data, 14)[0]
width = struct.unpack_from("<i", data, 18)[0]
height = struct.unpack_from("<i", data, 22)[0]
planes = struct.unpack_from("<H", data, 26)[0]
bits_per_pixel = struct.unpack_from("<H", data, 28)[0]
compression = struct.unpack_from("<I", data, 30)[0]
if width <= 0 or height == 0 or planes != 1 or bits_per_pixel != 8 or compression != 0:
raise RuntimeError("unsupported BMP format: {}".format(path))
palette_start = 14 + header_size
palette = []
for index in range(256):
offset = palette_start + index * 4
blue, green, red, _alpha = data[offset : offset + 4]
palette.append((int(red) + int(green) + int(blue)) // 3)
top_down = height < 0
height = abs(height)
bmp_row_bytes = ((width * bits_per_pixel + 31) // 32) * 4
packed = [0] * ((width * height + 1) // 2)
for row in range(height):
source_row = row if top_down else height - 1 - row
source_offset = pixel_offset + source_row * bmp_row_bytes
for column in range(width):
palette_index = data[source_offset + column]
alpha = (255 - palette[palette_index] + 8) // 17
alpha = min(15, max(0, alpha))
pixel_index = row * width + column
byte_index = pixel_index // 2
if pixel_index % 2 == 0:
packed[byte_index] |= alpha << 4
else:
packed[byte_index] |= alpha
return width, height, packed
def has_codepoint(font, codepoint):
try:
glyph = font[codepoint]
except Exception:
return False
return glyph is not None and glyph.isWorthOutputting()
def rust_array(values, indent=" ", per_line=16):
lines = []
for index in range(0, len(values), per_line):
chunk = values[index : index + per_line]
lines.append(indent + ", ".join("0x{:02x}".format(value) for value in chunk) + ",")
return "\n".join(lines)
def generate_font(font, temp_dir, name, pixelsize):
glyphs = []
bitmap = []
cell_height = 0
for codepoint in CODEPOINTS:
if not has_codepoint(font, codepoint):
continue
glyph = font[codepoint]
path = os.path.join(temp_dir, "glyph_{:04x}_{}.bmp".format(codepoint, pixelsize))
glyph.export(path, pixelsize=pixelsize)
width, height, data = parse_bmp(path)
cell_height = max(cell_height, height)
glyphs.append((codepoint, width, height, len(bitmap)))
bitmap.extend(data)
if cell_height == 0:
raise RuntimeError("no glyphs generated for {}".format(name))
return {
"name": name,
"pixelsize": pixelsize,
"cell_height": cell_height,
"glyphs": glyphs,
"bitmap": bitmap,
}
def emit_font(output, generated):
name = generated["name"]
glyphs = generated["glyphs"]
bitmap = generated["bitmap"]
output.write(
"pub(crate) static {name}: BitmapFont = BitmapFont {{\n"
" cell_height: {cell_height},\n"
" glyphs: &{name}_GLYPHS,\n"
" bitmap: &{name}_BITMAP,\n"
"}};\n\n".format(name=name, cell_height=generated["cell_height"])
)
output.write("#[rustfmt::skip]\n")
output.write(
"static {name}_GLYPHS: [Glyph; {count}] = [\n".format(
name=name, count=len(glyphs)
)
)
for codepoint, width, height, offset in glyphs:
output.write(
" Glyph {{ codepoint: 0x{codepoint:04x}, width: {width}, height: {height}, bitmap_offset: {offset} }},\n".format(
codepoint=codepoint, width=width, height=height, offset=offset
)
)
output.write("];\n\n")
output.write("#[rustfmt::skip]\n")
output.write(
"static {name}_BITMAP: [u8; {count}] = [\n".format(
name=name, count=len(bitmap)
)
)
if bitmap:
output.write(rust_array(bitmap))
output.write("\n")
output.write("];\n\n")
def main():
if len(sys.argv) != 3:
print("usage: fontforge -lang=py -script scripts/generate-fonts.py FONT.ttf OUTPUT.rs", file=sys.stderr)
return 2
font_path = sys.argv[1]
output_path = sys.argv[2]
font = fontforge.open(font_path)
with tempfile.TemporaryDirectory() as temp_dir:
generated_fonts = [
generate_font(font, temp_dir, name, pixelsize) for name, pixelsize in SIZES
]
with open(output_path, "w", encoding="utf-8") as output:
output.write("// @generated by scripts/generate-fonts.py. Do not edit by hand.\n")
output.write("// Source font: {}\n\n".format(font_path))
output.write("use crate::ui::typography::{BitmapFont, Glyph};\n\n")
for generated in generated_fonts:
output.write(
"// {} px; cell height {} px; {} glyphs; {} alpha bytes.\n".format(
generated["pixelsize"],
generated["cell_height"],
len(generated["glyphs"]),
len(generated["bitmap"]),
)
)
emit_font(output, generated)
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

289
src/bin/web_sim.rs Normal file
View File

@ -0,0 +1,289 @@
#![no_std]
#![no_main]
use core::{cell::UnsafeCell, convert::Infallible, panic::PanicInfo};
use embedded_graphics::{
pixelcolor::{Gray8, GrayColor},
prelude::*,
primitives::Rectangle,
Pixel,
};
use kinda::{
App, AppCommand, AppUpdate, InputEvent, WifiAccessPoint, WifiCredentials, WifiEvent,
WifiNetworkList, WifiSecurity, WifiSsid,
};
const WIDTH: u32 = 800;
const HEIGHT: u32 = 480;
const PIXEL_COUNT: usize = (WIDTH * HEIGHT) as usize;
const EVENT_UP: u32 = 0;
const EVENT_DOWN: u32 = 1;
const EVENT_SELECT: u32 = 2;
const EVENT_BACK: u32 = 3;
const EVENT_SELECT_LONG: u32 = 4;
const EVENT_BACK_LONG: u32 = 5;
struct Shared<T> {
value: UnsafeCell<T>,
}
unsafe impl<T> Sync for Shared<T> {}
impl<T> Shared<T> {
const fn new(value: T) -> Self {
Self {
value: UnsafeCell::new(value),
}
}
fn get(&self) -> &mut T {
unsafe { &mut *self.value.get() }
}
}
struct WebSimulator {
app: Option<App>,
}
impl WebSimulator {
const fn new() -> Self {
Self { app: None }
}
fn reset(&mut self) {
self.app = Some(App::new());
self.render();
}
fn input(&mut self, event: InputEvent) {
let update = self.app().update(event);
self.apply_update(update);
}
fn app(&mut self) -> &mut App {
self.app.get_or_insert_with(App::new)
}
fn apply_update(&mut self, update: AppUpdate) {
let mut dirty = update.dirty;
let mut command = update.command;
while let Some(next_command) = command.take() {
let wifi_event = self.handle_command(next_command);
let next_update = self.app().handle_wifi_event(wifi_event);
dirty |= next_update.dirty;
command = next_update.command;
}
if dirty {
self.render();
}
}
fn handle_command(&self, command: AppCommand) -> WifiEvent {
match command {
AppCommand::ScanWifi => WifiEvent::ScanFinished(Ok(self.mock_networks())),
AppCommand::JoinWifi {
access_point,
password: _,
} => WifiEvent::JoinFinished(Ok(access_point.ssid)),
AppCommand::JoinSavedWifi { credentials } => self.join_saved(credentials),
}
}
fn join_saved(&self, credentials: WifiCredentials) -> WifiEvent {
WifiEvent::JoinFinished(Ok(credentials.ssid))
}
fn mock_networks(&self) -> WifiNetworkList {
let mut networks = WifiNetworkList::new();
self.push_mock_network(
&mut networks,
"kinda-lab",
[0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
6,
-42,
WifiSecurity::Wpa2Personal,
);
self.push_mock_network(
&mut networks,
"open-books",
[0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
11,
-64,
WifiSecurity::Open,
);
self.push_mock_network(
&mut networks,
"reader-test",
[0x02, 0x00, 0x00, 0x00, 0x00, 0x03],
1,
-76,
WifiSecurity::Wpa2Wpa3Personal,
);
networks
}
fn push_mock_network(
&self,
networks: &mut WifiNetworkList,
name: &str,
bssid: [u8; 6],
channel: u8,
signal_strength: i8,
security: WifiSecurity,
) {
let mut ssid = WifiSsid::new();
if ssid.push_str(name).is_err() {
return;
}
let _ = networks.push(WifiAccessPoint {
ssid,
bssid,
channel,
signal_strength,
security,
});
}
fn render(&mut self) {
let app = self.app();
let mut display = PixelDisplay::new(PIXELS.get());
match app.render(&mut display) {
Ok(()) => {}
Err(error) => match error {},
}
}
}
struct PixelDisplay<'a> {
pixels: &'a mut [u8; PIXEL_COUNT],
}
impl<'a> PixelDisplay<'a> {
fn new(pixels: &'a mut [u8; PIXEL_COUNT]) -> Self {
Self { pixels }
}
fn set_pixel(&mut self, point: Point, color: Gray8) {
if point.x < 0 || point.y < 0 {
return;
}
let x = point.x as u32;
let y = point.y as u32;
if x >= WIDTH || y >= HEIGHT {
return;
}
self.pixels[(y * WIDTH + x) as usize] = color.luma();
}
}
impl DrawTarget for PixelDisplay<'_> {
type Color = Gray8;
type Error = Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(point, color) in pixels {
self.set_pixel(point, color);
}
Ok(())
}
fn fill_solid(&mut self, area: &Rectangle, color: Self::Color) -> Result<(), Self::Error> {
let bounds = Rectangle::new(Point::new(0, 0), self.size());
let area = area.intersection(&bounds);
if area.size.width == 0 || area.size.height == 0 {
return Ok(());
}
let value = color.luma();
let x_start = area.top_left.x as u32;
let y_start = area.top_left.y as u32;
let x_end = x_start + area.size.width;
let y_end = y_start + area.size.height;
for y in y_start..y_end {
let row_start = (y * WIDTH) as usize;
for x in x_start..x_end {
self.pixels[row_start + x as usize] = value;
}
}
Ok(())
}
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
self.pixels.fill(color.luma());
Ok(())
}
}
impl OriginDimensions for PixelDisplay<'_> {
fn size(&self) -> Size {
Size::new(WIDTH, HEIGHT)
}
}
static SIMULATOR: Shared<WebSimulator> = Shared::new(WebSimulator::new());
static PIXELS: Shared<[u8; PIXEL_COUNT]> = Shared::new([0; PIXEL_COUNT]);
#[no_mangle]
pub extern "C" fn kinda_sim_init() {
SIMULATOR.get().reset();
}
#[no_mangle]
pub extern "C" fn kinda_sim_input(event: u32) {
let Some(event) = input_event(event) else {
return;
};
SIMULATOR.get().input(event);
}
#[no_mangle]
pub extern "C" fn kinda_sim_width() -> u32 {
WIDTH
}
#[no_mangle]
pub extern "C" fn kinda_sim_height() -> u32 {
HEIGHT
}
#[no_mangle]
pub extern "C" fn kinda_sim_pixels() -> *const u8 {
PIXELS.get().as_ptr()
}
#[no_mangle]
pub extern "C" fn kinda_sim_pixel_len() -> usize {
PIXEL_COUNT
}
fn input_event(event: u32) -> Option<InputEvent> {
match event {
EVENT_UP => Some(InputEvent::Up),
EVENT_DOWN => Some(InputEvent::Down),
EVENT_SELECT => Some(InputEvent::Select),
EVENT_BACK => Some(InputEvent::Back),
EVENT_SELECT_LONG => Some(InputEvent::SelectLong),
EVENT_BACK_LONG => Some(InputEvent::BackLong),
_ => None,
}
}
#[panic_handler]
fn panic(_info: &PanicInfo<'_>) -> ! {
loop {}
}

9
src/input.rs Normal file
View File

@ -0,0 +1,9 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InputEvent {
Up,
Down,
Select,
Back,
SelectLong,
BackLong,
}

View File

@ -1 +1,12 @@
#![no_std] #![no_std]
pub mod input;
pub mod ui;
pub mod wifi;
pub use input::InputEvent;
pub use ui::{App, AppCommand, AppUpdate, RefreshHint};
pub use wifi::{
WifiAccessPoint, WifiCredentials, WifiEvent, WifiFailure, WifiNetworkList, WifiPassword,
WifiSecurity, WifiSsid, MAX_NETWORKS, WIFI_CREDENTIAL_RECORD_BYTES,
};

165
src/ui/app.rs Normal file
View File

@ -0,0 +1,165 @@
use embedded_graphics::{
pixelcolor::Gray8,
prelude::{DrawTarget, OriginDimensions},
};
use crate::{input::InputEvent, wifi::WifiEvent, WifiAccessPoint, WifiCredentials, WifiPassword};
use super::{
frame::Frame,
screens::{
library::{LibraryAction, LibraryScreen},
onboarding::{OnboardingAction, OnboardingScreen},
reader::{ReaderAction, ReaderScreen},
},
RefreshHint,
};
pub struct App {
route: Route,
pending_command: Option<AppCommand>,
}
enum Route {
Onboarding(OnboardingScreen),
Library(LibraryScreen),
Reader(ReaderScreen),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppUpdate {
pub dirty: bool,
pub refresh: RefreshHint,
pub command: Option<AppCommand>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AppCommand {
ScanWifi,
JoinWifi {
access_point: WifiAccessPoint,
password: WifiPassword,
},
JoinSavedWifi {
credentials: WifiCredentials,
},
}
impl App {
pub fn new() -> Self {
Self {
route: Route::Onboarding(OnboardingScreen::new()),
pending_command: None,
}
}
pub fn new_with_saved_wifi(credentials: Option<WifiCredentials>) -> Self {
let Some(credentials) = credentials else {
return Self::new();
};
let ssid = credentials.ssid.clone();
Self {
route: Route::Onboarding(OnboardingScreen::new_connecting_saved(ssid)),
pending_command: Some(AppCommand::JoinSavedWifi { credentials }),
}
}
pub fn take_pending_command(&mut self) -> Option<AppCommand> {
self.pending_command.take()
}
pub fn update(&mut self, event: InputEvent) -> AppUpdate {
match &mut self.route {
Route::Onboarding(screen) => {
let update = screen.update(event);
match update.action {
OnboardingAction::None => {
AppUpdate::new(update.dirty, update.refresh, update.command)
}
OnboardingAction::OpenLibrary => {
self.route = Route::Library(LibraryScreen::new());
AppUpdate::new(true, RefreshHint::Full, None)
}
}
}
Route::Library(screen) => {
let update = screen.update(event);
match update.action {
LibraryAction::None => {
AppUpdate::new(update.dirty, update.refresh, update.command)
}
LibraryAction::OpenOnboarding => {
self.route = Route::Onboarding(OnboardingScreen::new());
AppUpdate::new(true, RefreshHint::Full, None)
}
LibraryAction::OpenReader { book_index } => {
self.route = Route::Reader(ReaderScreen::new(book_index));
AppUpdate::new(true, RefreshHint::Full, None)
}
}
}
Route::Reader(screen) => {
let update = screen.update(event);
match update.action {
ReaderAction::None => {
AppUpdate::new(update.dirty, update.refresh, update.command)
}
ReaderAction::OpenLibrary { selected_book } => {
self.route =
Route::Library(LibraryScreen::new_with_selected(selected_book));
AppUpdate::new(true, RefreshHint::Full, None)
}
}
}
}
}
pub fn handle_wifi_event(&mut self, event: WifiEvent) -> AppUpdate {
match &mut self.route {
Route::Onboarding(screen) => {
let update = screen.handle_wifi_event(event);
match update.action {
OnboardingAction::None => {
AppUpdate::new(update.dirty, update.refresh, update.command)
}
OnboardingAction::OpenLibrary => {
self.route = Route::Library(LibraryScreen::new());
AppUpdate::new(true, RefreshHint::Full, None)
}
}
}
_ => AppUpdate::new(false, RefreshHint::None, None),
}
}
pub fn render<D>(&self, target: &mut D) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let mut frame = Frame::new(target);
frame.clear()?;
match &self.route {
Route::Onboarding(screen) => screen.render(&mut frame),
Route::Library(screen) => screen.render(&mut frame),
Route::Reader(screen) => screen.render(&mut frame),
}
}
}
impl AppUpdate {
pub const fn new(dirty: bool, refresh: RefreshHint, command: Option<AppCommand>) -> Self {
Self {
dirty,
refresh,
command,
}
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}

39
src/ui/books.rs Normal file
View File

@ -0,0 +1,39 @@
pub(crate) struct Book {
pub title: &'static str,
pub author: &'static str,
pub pages: &'static [&'static str],
}
const BOOK_ONE_PAGES: [&str; 3] = [
"The first screen of a reader should make the page feel stable.\n\nButtons can move slowly, but the page should not jump around.\n\nDown turns the page. Up goes back.",
"A first draft can use prepared page text.\n\nLater the book service can replace this with real layout output.\n\nThe reader screen should not care where the page came from.",
"This is the last mock page.\n\nBack returns to the library. Select can become the reader menu later.",
];
const BOOK_TWO_PAGES: [&str; 2] = [
"WiFi setup is part of the device, not a separate utility.\n\nThe network list and password editor are real UI screens.",
"Password entry uses a single active character.\n\nUp and down change it, select appends it, and back deletes it.",
];
const BOOK_THREE_PAGES: [&str; 2] = [
"E-paper refresh is a UI concern.\n\nFast updates are useful while moving around. Full updates are useful when changing screens.",
"Fonts can wait until the flow is right.\n\nOnce the screens feel correct, custom bitmap fonts can replace the built-in ones.",
];
pub(crate) const BOOKS: [Book; 3] = [
Book {
title: "Reader Notes",
author: "Kinda",
pages: &BOOK_ONE_PAGES,
},
Book {
title: "Onboarding Plan",
author: "Kinda",
pages: &BOOK_TWO_PAGES,
},
Book {
title: "Display Notes",
author: "Kinda",
pages: &BOOK_THREE_PAGES,
},
];

106
src/ui/frame.rs Normal file
View File

@ -0,0 +1,106 @@
use embedded_graphics::{
geometry::{Point, Size},
pixelcolor::Gray8,
prelude::{DrawTarget, OriginDimensions},
primitives::Rectangle,
};
use crate::ui::{
generated_fonts,
typography::{TextRenderer, TextStyle},
};
pub(crate) struct Frame<'a, D>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
target: &'a mut D,
}
impl<'a, D> Frame<'a, D>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
pub fn new(target: &'a mut D) -> Self {
Self { target }
}
pub fn clear(&mut self) -> Result<(), D::Error> {
self.target.clear(Gray8::new(255))
}
pub fn title(&mut self, text: &str) -> Result<(), D::Error> {
self.text(text, Point::new(40, 18), Self::title_style())
}
pub fn body(&mut self, y: i32, text: &str) -> Result<(), D::Error> {
self.text(text, Point::new(48, y), Self::body_style())
}
pub fn small(&mut self, y: i32, text: &str) -> Result<(), D::Error> {
self.text(text, Point::new(48, y), Self::small_style())
}
pub fn footer(&mut self, text: &str) -> Result<(), D::Error> {
let size = self.target.size();
let body_style = Self::body_style();
let small_style = Self::small_style();
let max_width = size.width as i32 - 64;
if body_style.measure_width(text) <= max_width {
let y = size.height as i32 - i32::from(body_style.line_height()) - 6;
return self.text(text, Point::new(32, y), body_style);
}
let y = if small_style.measure_width(text) <= max_width {
size.height as i32 - i32::from(small_style.line_height()) - 6
} else {
size.height as i32 - i32::from(small_style.line_height()) * 2 - 6
};
let bounds = Rectangle::new(
Point::new(32, y),
Size::new(max_width as u32, u32::from(small_style.line_height()) * 2),
);
self.wrapped_text(text, bounds, small_style)
}
pub fn paragraph(&mut self, y: i32, text: &str) -> Result<(), D::Error> {
let size = self.target.size();
let x = 72;
let bottom_margin = 88;
let width = size.width.saturating_sub((x * 2) as u32);
let height = size.height.saturating_sub(y as u32 + bottom_margin);
let bounds = Rectangle::new(Point::new(x, y), Size::new(width, height));
self.wrapped_text(text, bounds, Self::reader_style())
}
fn text(&mut self, text: &str, position: Point, style: TextStyle) -> Result<(), D::Error> {
TextRenderer::new(self.target).draw_text(text, position, style)
}
fn wrapped_text(
&mut self,
text: &str,
bounds: Rectangle,
style: TextStyle,
) -> Result<(), D::Error> {
TextRenderer::new(self.target).draw_wrapped_text(text, bounds, style)
}
fn title_style() -> TextStyle {
TextStyle::new(&generated_fonts::QUATTRO_TITLE, Gray8::new(0), 46)
}
fn body_style() -> TextStyle {
TextStyle::new(&generated_fonts::QUATTRO_BODY, Gray8::new(0), 38)
}
fn reader_style() -> TextStyle {
TextStyle::new(&generated_fonts::QUATTRO_READER, Gray8::new(0), 43)
}
fn small_style() -> TextStyle {
TextStyle::new(&generated_fonts::QUATTRO_SMALL, Gray8::new(0), 30)
}
}

10153
src/ui/generated_fonts.rs Normal file

File diff suppressed because it is too large Load Diff

15
src/ui/mod.rs Normal file
View File

@ -0,0 +1,15 @@
mod app;
mod books;
mod frame;
mod generated_fonts;
mod screens;
mod typography;
pub use app::{App, AppCommand, AppUpdate};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefreshHint {
None,
Fast,
Full,
}

86
src/ui/screens/library.rs Normal file
View File

@ -0,0 +1,86 @@
use core::fmt::Write;
use embedded_graphics::{
pixelcolor::Gray8,
prelude::{DrawTarget, OriginDimensions},
};
use heapless::String;
use crate::input::InputEvent;
use super::ScreenUpdate;
use crate::ui::{books::BOOKS, frame::Frame, RefreshHint};
pub(crate) struct LibraryScreen {
selected: usize,
}
#[derive(Clone, Copy)]
pub(crate) enum LibraryAction {
None,
OpenOnboarding,
OpenReader { book_index: usize },
}
impl LibraryScreen {
pub fn new() -> Self {
Self { selected: 0 }
}
pub fn new_with_selected(selected: usize) -> Self {
Self {
selected: selected.min(BOOKS.len() - 1),
}
}
pub fn update(&mut self, event: InputEvent) -> ScreenUpdate<LibraryAction> {
match event {
InputEvent::Up => {
self.selected = if self.selected == 0 {
BOOKS.len() - 1
} else {
self.selected - 1
};
ScreenUpdate::new(true, RefreshHint::Fast, LibraryAction::None)
}
InputEvent::Down => {
self.selected = (self.selected + 1) % BOOKS.len();
ScreenUpdate::new(true, RefreshHint::Fast, LibraryAction::None)
}
InputEvent::Select => ScreenUpdate::new(
true,
RefreshHint::Full,
LibraryAction::OpenReader {
book_index: self.selected,
},
),
InputEvent::Back => {
ScreenUpdate::new(true, RefreshHint::Full, LibraryAction::OpenOnboarding)
}
_ => ScreenUpdate::new(false, RefreshHint::None, LibraryAction::None),
}
}
pub fn render<D>(&self, frame: &mut Frame<'_, D>) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
frame.title("Library")?;
frame.small(58, "Mock book list")?;
let mut y = 100;
for (index, book) in BOOKS.iter().enumerate() {
let marker = if index == self.selected { ">" } else { " " };
let mut row = String::<96>::new();
let _ = write!(row, "{} {} - {}", marker, book.title, book.author);
frame.body(y, row.as_str())?;
y += 38;
}
frame.small(
274,
"Book sync and storage will plug in behind this screen.",
)?;
frame.footer("Up/Down move Select open Back WiFi")
}
}

32
src/ui/screens/mod.rs Normal file
View File

@ -0,0 +1,32 @@
pub(crate) mod library;
pub(crate) mod onboarding;
pub(crate) mod reader;
use super::{app::AppCommand, RefreshHint};
pub(crate) struct ScreenUpdate<A> {
pub dirty: bool,
pub refresh: RefreshHint,
pub action: A,
pub command: Option<AppCommand>,
}
impl<A> ScreenUpdate<A> {
pub fn new(dirty: bool, refresh: RefreshHint, action: A) -> Self {
Self {
dirty,
refresh,
action,
command: None,
}
}
pub fn with_command(dirty: bool, refresh: RefreshHint, action: A, command: AppCommand) -> Self {
Self {
dirty,
refresh,
action,
command: Some(command),
}
}
}

View File

@ -0,0 +1,495 @@
use core::fmt::Write;
use embedded_graphics::{
pixelcolor::Gray8,
prelude::{DrawTarget, OriginDimensions},
};
use heapless::String;
use crate::{
input::InputEvent,
ui::app::AppCommand,
wifi::{WifiAccessPoint, WifiEvent, WifiNetworkList, WifiPassword, WifiSsid},
};
use super::ScreenUpdate;
use crate::ui::{frame::Frame, RefreshHint};
const PASSWORD_CHARS: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .-_!?@#$%&*+/=:";
pub(crate) struct OnboardingScreen {
state: OnboardingState,
}
enum OnboardingState {
ScanIntro { notice: &'static str },
Scanning,
NetworkList(NetworkListState),
PasswordEntry(PasswordEntryState),
Joining(JoiningState),
SavedWifiConnecting { ssid: WifiSsid },
Joined { ssid: WifiSsid },
}
struct NetworkListState {
networks: WifiNetworkList,
selected: usize,
notice: &'static str,
}
struct PasswordEntryState {
networks: WifiNetworkList,
selected: usize,
password: WifiPassword,
char_index: usize,
notice: &'static str,
}
struct JoiningState {
networks: WifiNetworkList,
selected: usize,
password: WifiPassword,
}
#[derive(Clone, Copy)]
pub(crate) enum OnboardingAction {
None,
OpenLibrary,
}
impl OnboardingScreen {
pub fn new() -> Self {
Self {
state: OnboardingState::ScanIntro { notice: "" },
}
}
pub fn new_connecting_saved(ssid: WifiSsid) -> Self {
Self {
state: OnboardingState::SavedWifiConnecting { ssid },
}
}
pub fn update(&mut self, event: InputEvent) -> ScreenUpdate<OnboardingAction> {
match &mut self.state {
OnboardingState::ScanIntro { .. } => match event {
InputEvent::Select => {
self.state = OnboardingState::Scanning;
ScreenUpdate::with_command(
true,
RefreshHint::Full,
OnboardingAction::None,
AppCommand::ScanWifi,
)
}
_ => ScreenUpdate::new(false, RefreshHint::None, OnboardingAction::None),
},
OnboardingState::Scanning => {
ScreenUpdate::new(false, RefreshHint::None, OnboardingAction::None)
}
OnboardingState::NetworkList(state) => match event {
InputEvent::Up => {
state.selected = if state.selected == 0 {
state.networks.len() - 1
} else {
state.selected - 1
};
state.notice = "";
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::Down => {
state.selected = (state.selected + 1) % state.networks.len();
state.notice = "";
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::Select => {
let access_point = state.networks[state.selected].clone();
if access_point.is_locked() {
self.state = OnboardingState::PasswordEntry(PasswordEntryState::new(
state.networks.clone(),
state.selected,
));
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
} else {
let password = WifiPassword::new();
self.state = OnboardingState::Joining(JoiningState {
networks: state.networks.clone(),
selected: state.selected,
password: password.clone(),
});
ScreenUpdate::with_command(
true,
RefreshHint::Full,
OnboardingAction::None,
AppCommand::JoinWifi {
access_point,
password,
},
)
}
}
InputEvent::Back | InputEvent::SelectLong => {
self.state = OnboardingState::Scanning;
ScreenUpdate::with_command(
true,
RefreshHint::Full,
OnboardingAction::None,
AppCommand::ScanWifi,
)
}
_ => ScreenUpdate::new(false, RefreshHint::None, OnboardingAction::None),
},
OnboardingState::PasswordEntry(state) => match event {
InputEvent::Up => {
state.previous_char();
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::Down => {
state.next_char();
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::Select => {
state.append_char();
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::Back => {
state.delete_char();
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
InputEvent::SelectLong => {
if state.can_join() {
let access_point = state.access_point().clone();
let password = state.password.clone();
self.state = OnboardingState::Joining(JoiningState {
networks: state.networks.clone(),
selected: state.selected,
password: password.clone(),
});
ScreenUpdate::with_command(
true,
RefreshHint::Full,
OnboardingAction::None,
AppCommand::JoinWifi {
access_point,
password,
},
)
} else {
state.notice = "Password must be 8-63 characters";
ScreenUpdate::new(true, RefreshHint::Fast, OnboardingAction::None)
}
}
InputEvent::BackLong => {
self.state = OnboardingState::NetworkList(NetworkListState {
networks: state.networks.clone(),
selected: state.selected,
notice: "",
});
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
}
},
OnboardingState::Joining(_) | OnboardingState::SavedWifiConnecting { .. } => {
ScreenUpdate::new(false, RefreshHint::None, OnboardingAction::None)
}
OnboardingState::Joined { .. } => match event {
InputEvent::Select => {
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::OpenLibrary)
}
InputEvent::Back => {
self.state = OnboardingState::ScanIntro { notice: "" };
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
}
_ => ScreenUpdate::new(false, RefreshHint::None, OnboardingAction::None),
},
}
}
pub fn handle_wifi_event(&mut self, event: WifiEvent) -> ScreenUpdate<OnboardingAction> {
match event {
WifiEvent::ScanFinished(Ok(networks)) => {
if networks.is_empty() {
self.state = OnboardingState::ScanIntro {
notice: "No networks found",
};
} else {
self.state = OnboardingState::NetworkList(NetworkListState {
networks,
selected: 0,
notice: "",
});
}
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
}
WifiEvent::ScanFinished(Err(error)) => {
self.state = OnboardingState::ScanIntro {
notice: error.message(),
};
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
}
WifiEvent::JoinFinished(Ok(ssid)) => {
let action = if matches!(self.state, OnboardingState::SavedWifiConnecting { .. }) {
OnboardingAction::OpenLibrary
} else {
OnboardingAction::None
};
self.state = OnboardingState::Joined { ssid };
ScreenUpdate::new(true, RefreshHint::Full, action)
}
WifiEvent::JoinFinished(Err(error)) => {
let previous =
core::mem::replace(&mut self.state, OnboardingState::ScanIntro { notice: "" });
self.state = match previous {
OnboardingState::Joining(state) => {
OnboardingState::PasswordEntry(PasswordEntryState {
networks: state.networks,
selected: state.selected,
password: state.password,
char_index: 0,
notice: error.message(),
})
}
_ => OnboardingState::ScanIntro {
notice: error.message(),
},
};
ScreenUpdate::new(true, RefreshHint::Full, OnboardingAction::None)
}
}
}
pub fn render<D>(&self, frame: &mut Frame<'_, D>) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
match &self.state {
OnboardingState::ScanIntro { notice } => self.render_scan_intro(frame, notice),
OnboardingState::Scanning => self.render_scanning(frame),
OnboardingState::NetworkList(state) => self.render_network_list(frame, state),
OnboardingState::PasswordEntry(state) => self.render_password_entry(frame, state),
OnboardingState::Joining(state) => self.render_joining(frame, state),
OnboardingState::SavedWifiConnecting { ssid } => {
self.render_saved_wifi_connecting(frame, ssid)
}
OnboardingState::Joined { ssid } => self.render_joined(frame, ssid),
}
}
fn render_scan_intro<D>(
&self,
frame: &mut Frame<'_, D>,
notice: &'static str,
) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
frame.title("WiFi setup")?;
frame.body(86, "Connect this device before loading books.")?;
frame.body(122, "Press select to scan for nearby networks.")?;
if !notice.is_empty() {
frame.small(174, notice)?;
}
frame.footer("Select: scan")
}
fn render_scanning<D>(&self, frame: &mut Frame<'_, D>) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
frame.title("Scanning")?;
frame.body(96, "Looking for WiFi networks...")?;
frame.small(148, "This can take a few seconds.")?;
frame.footer("Please wait")
}
fn render_network_list<D>(
&self,
frame: &mut Frame<'_, D>,
state: &NetworkListState,
) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
frame.title("Choose network")?;
let mut count_line = String::<48>::new();
let _ = write!(count_line, "{} networks found", state.networks.len());
frame.small(58, count_line.as_str())?;
let visible_rows = 8;
let first_row = if state.selected >= visible_rows {
state.selected + 1 - visible_rows
} else {
0
};
let last_row = (first_row + visible_rows).min(state.networks.len());
let mut y = 100;
for index in first_row..last_row {
let network = &state.networks[index];
let marker = if index == state.selected { ">" } else { " " };
let mut row = String::<128>::new();
let _ = write!(
row,
"{} {} {} {}",
marker,
network.ssid.as_str(),
network.signal_label(),
network.security.label()
);
frame.body(y, row.as_str())?;
y += 34;
}
if state.networks.len() > visible_rows {
let mut range_line = String::<48>::new();
let _ = write!(range_line, "Showing {}-{}", first_row + 1, last_row);
frame.small(380, range_line.as_str())?;
}
if !state.notice.is_empty() {
frame.small(400, state.notice)?;
}
frame.footer("Up/Down move Select choose Back rescan")
}
fn render_password_entry<D>(
&self,
frame: &mut Frame<'_, D>,
state: &PasswordEntryState,
) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let access_point = state.access_point();
let mut network_line = String::<96>::new();
let _ = write!(network_line, "Network: {}", access_point.ssid.as_str());
frame.title("WiFi password")?;
frame.body(66, network_line.as_str())?;
frame.body(112, "Password:")?;
if state.password.is_empty() {
frame.body(146, "<empty>")?;
} else {
frame.body(146, state.password.as_str())?;
}
frame.body(204, "Current char:")?;
let mut char_line = String::<24>::new();
if state.current_char() == ' ' {
let _ = write!(char_line, "[ space ]");
} else {
let _ = write!(char_line, "[ {} ]", state.current_char());
}
frame.body(238, char_line.as_str())?;
if !state.notice.is_empty() {
frame.small(292, state.notice)?;
}
frame.footer("Up/Down char Select add Back del Hold Select join")
}
fn render_joining<D>(
&self,
frame: &mut Frame<'_, D>,
state: &JoiningState,
) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let network = &state.networks[state.selected];
let mut network_line = String::<96>::new();
let _ = write!(network_line, "Joining {}", network.ssid.as_str());
frame.title("Joining")?;
frame.body(92, network_line.as_str())?;
frame.body(136, "Waiting for the access point...")?;
frame.footer("Please wait")
}
fn render_saved_wifi_connecting<D>(
&self,
frame: &mut Frame<'_, D>,
ssid: &WifiSsid,
) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let mut network_line = String::<96>::new();
let _ = write!(network_line, "Connecting to {}", ssid.as_str());
frame.title("WiFi")?;
frame.body(92, network_line.as_str())?;
frame.body(136, "Using saved credentials...")?;
frame.footer("Please wait")
}
fn render_joined<D>(&self, frame: &mut Frame<'_, D>, ssid: &WifiSsid) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let mut network_line = String::<96>::new();
let _ = write!(network_line, "Connected to {}", ssid.as_str());
frame.title("Connected")?;
frame.body(92, network_line.as_str())?;
frame.body(136, "Next screen: library.")?;
frame.footer("Select continue Back scan again")
}
}
impl PasswordEntryState {
fn new(networks: WifiNetworkList, selected: usize) -> Self {
Self {
networks,
selected,
password: WifiPassword::new(),
char_index: 0,
notice: "",
}
}
fn access_point(&self) -> &WifiAccessPoint {
&self.networks[self.selected]
}
fn previous_char(&mut self) {
self.notice = "";
self.char_index = if self.char_index == 0 {
PASSWORD_CHARS.len() - 1
} else {
self.char_index - 1
};
}
fn next_char(&mut self) {
self.notice = "";
self.char_index = (self.char_index + 1) % PASSWORD_CHARS.len();
}
fn append_char(&mut self) {
self.notice = "";
if self.password.push(self.current_char()).is_err() {
self.notice = "Password is full";
}
}
fn delete_char(&mut self) {
self.notice = "";
if self.password.pop().is_none() {
self.notice = "Nothing to delete";
}
}
fn can_join(&self) -> bool {
!self.access_point().is_locked() || (self.password.len() >= 8 && self.password.len() <= 63)
}
fn current_char(&self) -> char {
char::from(PASSWORD_CHARS[self.char_index])
}
}

98
src/ui/screens/reader.rs Normal file
View File

@ -0,0 +1,98 @@
use core::fmt::Write;
use embedded_graphics::{
pixelcolor::Gray8,
prelude::{DrawTarget, OriginDimensions},
};
use heapless::String;
use crate::input::InputEvent;
use super::ScreenUpdate;
use crate::ui::{books::BOOKS, frame::Frame, RefreshHint};
pub(crate) struct ReaderScreen {
book_index: usize,
page_index: usize,
show_help: bool,
}
#[derive(Clone, Copy)]
pub(crate) enum ReaderAction {
None,
OpenLibrary { selected_book: usize },
}
impl ReaderScreen {
pub fn new(book_index: usize) -> Self {
Self {
book_index,
page_index: 0,
show_help: false,
}
}
pub fn update(&mut self, event: InputEvent) -> ScreenUpdate<ReaderAction> {
match event {
InputEvent::Up => {
if self.page_index > 0 {
self.page_index -= 1;
self.show_help = false;
ScreenUpdate::new(true, RefreshHint::Fast, ReaderAction::None)
} else {
ScreenUpdate::new(false, RefreshHint::None, ReaderAction::None)
}
}
InputEvent::Down => {
let last_page = BOOKS[self.book_index].pages.len() - 1;
if self.page_index < last_page {
self.page_index += 1;
self.show_help = false;
ScreenUpdate::new(true, RefreshHint::Fast, ReaderAction::None)
} else {
ScreenUpdate::new(false, RefreshHint::None, ReaderAction::None)
}
}
InputEvent::Select => {
self.show_help = !self.show_help;
ScreenUpdate::new(true, RefreshHint::Fast, ReaderAction::None)
}
InputEvent::Back => ScreenUpdate::new(
true,
RefreshHint::Full,
ReaderAction::OpenLibrary {
selected_book: self.book_index,
},
),
_ => ScreenUpdate::new(false, RefreshHint::None, ReaderAction::None),
}
}
pub fn render<D>(&self, frame: &mut Frame<'_, D>) -> Result<(), D::Error>
where
D: DrawTarget<Color = Gray8> + OriginDimensions,
{
let book = &BOOKS[self.book_index];
let mut heading = String::<96>::new();
let _ = write!(heading, "{} - {}", book.title, book.author);
frame.title("Reader")?;
frame.small(58, heading.as_str())?;
frame.paragraph(100, book.pages[self.page_index])?;
let mut page_line = String::<48>::new();
let _ = write!(
page_line,
"Page {} of {}",
self.page_index + 1,
book.pages.len()
);
frame.small(392, page_line.as_str())?;
if self.show_help {
frame.small(346, "Controls: Up prev, Down next, Back library")?;
}
frame.footer("Up/Down page Select controls Back library")
}
}

384
src/ui/typography.rs Normal file
View File

@ -0,0 +1,384 @@
use embedded_graphics::{
geometry::Point,
pixelcolor::{Gray8, GrayColor},
prelude::{DrawTarget, Pixel},
primitives::Rectangle,
};
#[derive(Clone, Copy)]
pub(crate) struct Glyph {
pub codepoint: u32,
pub width: u8,
pub height: u8,
pub bitmap_offset: u32,
}
pub(crate) struct BitmapFont {
pub cell_height: u8,
pub glyphs: &'static [Glyph],
pub bitmap: &'static [u8],
}
#[derive(Clone, Copy)]
pub(crate) struct TextStyle {
font: &'static BitmapFont,
color: Gray8,
line_height: u8,
}
pub(crate) struct TextRenderer<'a, D>
where
D: DrawTarget<Color = Gray8>,
{
target: &'a mut D,
}
struct WrapCursor {
left: i32,
right: i32,
bottom: i32,
x: i32,
y: i32,
has_text: bool,
pending_space: bool,
}
struct GlyphPixels<'a> {
bitmap: &'a [u8],
origin: Point,
color: Gray8,
width: usize,
height: usize,
next_pixel: usize,
}
impl TextStyle {
pub const fn new(font: &'static BitmapFont, color: Gray8, line_height: u8) -> Self {
Self {
font,
color,
line_height,
}
}
fn char_width(&self, character: char) -> i32 {
if character == '\t' {
return self.space_width() * 4;
}
self.font
.glyph_for(character)
.map(|glyph| i32::from(glyph.width))
.unwrap_or(0)
}
pub(crate) fn measure_width(&self, text: &str) -> i32 {
text.chars()
.map(|character| self.char_width(character))
.sum()
}
pub(crate) fn line_height(&self) -> u8 {
self.line_height
}
fn space_width(&self) -> i32 {
self.char_width(' ')
}
}
impl BitmapFont {
fn glyph_for(&self, character: char) -> Option<&Glyph> {
let codepoint = match character {
'\u{00a0}' => ' ' as u32,
_ => character as u32,
};
self.glyph(codepoint).or_else(|| self.glyph('?' as u32))
}
fn glyph(&self, codepoint: u32) -> Option<&Glyph> {
for glyph in self.glyphs {
if glyph.codepoint == codepoint {
return Some(glyph);
}
if glyph.codepoint > codepoint {
return None;
}
}
None
}
}
impl<'a, D> TextRenderer<'a, D>
where
D: DrawTarget<Color = Gray8>,
{
pub fn new(target: &'a mut D) -> Self {
Self { target }
}
pub fn draw_text(
&mut self,
text: &str,
position: Point,
style: TextStyle,
) -> Result<(), D::Error> {
let mut x = position.x;
let mut y = position.y;
for character in text.chars() {
match character {
'\r' => {}
'\n' => {
x = position.x;
y += i32::from(style.line_height);
}
'\t' => {
x += style.space_width() * 4;
}
' ' | '\u{00a0}' => {
x += style.space_width();
}
_ => {
if let Some(glyph) = style.font.glyph_for(character) {
self.draw_glyph(glyph, Point::new(x, y), style)?;
x += i32::from(glyph.width);
}
}
}
}
Ok(())
}
pub fn draw_wrapped_text(
&mut self,
text: &str,
bounds: Rectangle,
style: TextStyle,
) -> Result<(), D::Error> {
let mut cursor = WrapCursor::new(bounds);
let mut index = 0;
while index < text.len() {
let Some(character) = text[index..].chars().next() else {
break;
};
if character == '\r' {
index += character.len_utf8();
continue;
}
if character == '\n' {
cursor.new_line(style);
index += character.len_utf8();
continue;
}
if character.is_whitespace() {
cursor.mark_space();
index += character.len_utf8();
while index < text.len() {
let Some(next) = text[index..].chars().next() else {
break;
};
if next == '\n' || !next.is_whitespace() {
break;
}
index += next.len_utf8();
}
continue;
}
let word_start = index;
while index < text.len() {
let Some(next) = text[index..].chars().next() else {
break;
};
if next.is_whitespace() {
break;
}
index += next.len_utf8();
}
let word = &text[word_start..index];
self.draw_wrapped_word(word, &mut cursor, style)?;
}
Ok(())
}
fn draw_wrapped_word(
&mut self,
word: &str,
cursor: &mut WrapCursor,
style: TextStyle,
) -> Result<(), D::Error> {
let word_width = style.measure_width(word);
let space_width = cursor.pending_space_width(style);
if cursor.has_text && cursor.x + space_width + word_width > cursor.right {
cursor.new_line(style);
}
if word_width <= cursor.line_width() {
if !cursor.can_draw(style) {
return Ok(());
}
cursor.apply_pending_space(style);
self.draw_text(word, Point::new(cursor.x, cursor.y), style)?;
cursor.x += word_width;
cursor.has_text = true;
cursor.pending_space = false;
return Ok(());
}
cursor.apply_pending_space(style);
for character in word.chars() {
let character_width = style.char_width(character);
if cursor.has_text && cursor.x + character_width > cursor.right {
cursor.new_line(style);
}
if !cursor.can_draw(style) {
return Ok(());
}
if let Some(glyph) = style.font.glyph_for(character) {
self.draw_glyph(glyph, Point::new(cursor.x, cursor.y), style)?;
}
cursor.x += character_width;
cursor.has_text = true;
}
cursor.pending_space = false;
Ok(())
}
fn draw_glyph(
&mut self,
glyph: &Glyph,
position: Point,
style: TextStyle,
) -> Result<(), D::Error> {
if glyph.width == 0 || glyph.height == 0 {
return Ok(());
}
let bitmap_offset = glyph.bitmap_offset as usize;
let bitmap_len = (usize::from(glyph.width) * usize::from(glyph.height)).div_ceil(2);
let bitmap = &style.font.bitmap[bitmap_offset..bitmap_offset + bitmap_len];
self.target.draw_iter(GlyphPixels::new(
bitmap,
position,
style.color,
glyph.width,
glyph.height,
))
}
}
impl WrapCursor {
fn new(bounds: Rectangle) -> Self {
let left = bounds.top_left.x;
let right = left + bounds.size.width as i32;
let bottom = bounds.top_left.y + bounds.size.height as i32;
Self {
left,
right,
bottom,
x: left,
y: bounds.top_left.y,
has_text: false,
pending_space: false,
}
}
fn line_width(&self) -> i32 {
self.right - self.left
}
fn can_draw(&self, style: TextStyle) -> bool {
self.y + i32::from(style.font.cell_height) <= self.bottom
}
fn new_line(&mut self, style: TextStyle) {
self.x = self.left;
self.y += i32::from(style.line_height);
self.has_text = false;
self.pending_space = false;
}
fn mark_space(&mut self) {
if self.has_text {
self.pending_space = true;
}
}
fn pending_space_width(&self, style: TextStyle) -> i32 {
if self.has_text && self.pending_space {
style.space_width()
} else {
0
}
}
fn apply_pending_space(&mut self, style: TextStyle) {
self.x += self.pending_space_width(style);
self.pending_space = false;
}
}
impl<'a> GlyphPixels<'a> {
fn new(bitmap: &'a [u8], origin: Point, color: Gray8, width: u8, height: u8) -> Self {
Self {
bitmap,
origin,
color,
width: usize::from(width),
height: usize::from(height),
next_pixel: 0,
}
}
fn blend_luma(color: Gray8, alpha: u8) -> u8 {
let foreground = u16::from(color.luma());
let alpha = u16::from(alpha) * 17;
let luma = (foreground * alpha + 255 * (255 - alpha)) / 255;
luma as u8
}
}
impl Iterator for GlyphPixels<'_> {
type Item = Pixel<Gray8>;
fn next(&mut self) -> Option<Self::Item> {
while self.next_pixel < self.width * self.height {
let pixel = self.next_pixel;
self.next_pixel += 1;
let x = pixel % self.width;
let y = pixel / self.width;
let byte = self.bitmap[pixel / 2];
let alpha = if pixel % 2 == 0 {
byte >> 4
} else {
byte & 0x0F
};
if alpha != 0 {
return Some(Pixel(
Point::new(self.origin.x + x as i32, self.origin.y + y as i32),
Gray8::new(Self::blend_luma(self.color, alpha)),
));
}
}
None
}
}

219
src/wifi.rs Normal file
View File

@ -0,0 +1,219 @@
use heapless::{String, Vec};
pub const MAX_NETWORKS: usize = 12;
pub const WIFI_CREDENTIAL_RECORD_BYTES: usize = 112;
const WIFI_CREDENTIAL_MAGIC: &[u8; 8] = b"KINDAWF1";
const WIFI_CREDENTIAL_CHECKSUM_OFFSET: usize = 8;
const WIFI_CREDENTIAL_SECURITY_OFFSET: usize = 12;
const WIFI_CREDENTIAL_SSID_LEN_OFFSET: usize = 13;
const WIFI_CREDENTIAL_PASSWORD_LEN_OFFSET: usize = 14;
const WIFI_CREDENTIAL_SSID_OFFSET: usize = 16;
const WIFI_CREDENTIAL_PASSWORD_OFFSET: usize = WIFI_CREDENTIAL_SSID_OFFSET + 32;
pub type WifiNetworkList = Vec<WifiAccessPoint, MAX_NETWORKS>;
pub type WifiPassword = String<64>;
pub type WifiSsid = String<32>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WifiAccessPoint {
pub ssid: WifiSsid,
pub bssid: [u8; 6],
pub channel: u8,
pub signal_strength: i8,
pub security: WifiSecurity,
}
impl WifiAccessPoint {
pub fn is_locked(&self) -> bool {
self.security.is_locked()
}
pub fn signal_label(&self) -> &'static str {
match self.signal_strength {
-50..=i8::MAX => "strong",
-70..=-51 => "medium",
_ => "weak",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WifiSecurity {
Open,
Wep,
Wpa,
Wpa2Personal,
WpaWpa2Personal,
Wpa2Wpa3Personal,
Wpa3Personal,
Other,
}
impl WifiSecurity {
pub fn is_locked(self) -> bool {
!matches!(self, Self::Open)
}
pub fn label(self) -> &'static str {
match self {
Self::Open => "open",
Self::Wep => "wep",
Self::Wpa => "wpa",
Self::Wpa2Personal => "wpa2",
Self::WpaWpa2Personal => "wpa/wpa2",
Self::Wpa2Wpa3Personal => "wpa2/wpa3",
Self::Wpa3Personal => "wpa3",
Self::Other => "locked",
}
}
fn to_storage_byte(self) -> u8 {
match self {
Self::Open => 0,
Self::Wep => 1,
Self::Wpa => 2,
Self::Wpa2Personal => 3,
Self::WpaWpa2Personal => 4,
Self::Wpa2Wpa3Personal => 5,
Self::Wpa3Personal => 6,
Self::Other => 7,
}
}
fn from_storage_byte(value: u8) -> Option<Self> {
match value {
0 => Some(Self::Open),
1 => Some(Self::Wep),
2 => Some(Self::Wpa),
3 => Some(Self::Wpa2Personal),
4 => Some(Self::WpaWpa2Personal),
5 => Some(Self::Wpa2Wpa3Personal),
6 => Some(Self::Wpa3Personal),
7 => Some(Self::Other),
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WifiCredentials {
pub ssid: WifiSsid,
pub password: WifiPassword,
pub security: WifiSecurity,
}
impl WifiCredentials {
pub fn new(ssid: WifiSsid, password: WifiPassword, security: WifiSecurity) -> Self {
Self {
ssid,
password,
security,
}
}
pub fn from_access_point(access_point: &WifiAccessPoint, password: WifiPassword) -> Self {
Self::new(access_point.ssid.clone(), password, access_point.security)
}
pub fn encode(&self) -> [u8; WIFI_CREDENTIAL_RECORD_BYTES] {
let mut record = [0u8; WIFI_CREDENTIAL_RECORD_BYTES];
record[..WIFI_CREDENTIAL_MAGIC.len()].copy_from_slice(WIFI_CREDENTIAL_MAGIC);
record[WIFI_CREDENTIAL_SECURITY_OFFSET] = self.security.to_storage_byte();
record[WIFI_CREDENTIAL_SSID_LEN_OFFSET] = self.ssid.len() as u8;
record[WIFI_CREDENTIAL_PASSWORD_LEN_OFFSET] = self.password.len() as u8;
record[WIFI_CREDENTIAL_SSID_OFFSET..][..self.ssid.len()]
.copy_from_slice(self.ssid.as_bytes());
record[WIFI_CREDENTIAL_PASSWORD_OFFSET..][..self.password.len()]
.copy_from_slice(self.password.as_bytes());
let checksum = Self::checksum(&record[WIFI_CREDENTIAL_SECURITY_OFFSET..]);
record[WIFI_CREDENTIAL_CHECKSUM_OFFSET..][..4].copy_from_slice(&checksum.to_le_bytes());
record
}
pub fn decode(record: &[u8]) -> Option<Self> {
if record.len() < WIFI_CREDENTIAL_RECORD_BYTES {
return None;
}
if &record[..WIFI_CREDENTIAL_MAGIC.len()] != WIFI_CREDENTIAL_MAGIC {
return None;
}
let expected_checksum = u32::from_le_bytes(
record[WIFI_CREDENTIAL_CHECKSUM_OFFSET..][..4]
.try_into()
.ok()?,
);
let actual_checksum = Self::checksum(&record[WIFI_CREDENTIAL_SECURITY_OFFSET..]);
if expected_checksum != actual_checksum {
return None;
}
let security = WifiSecurity::from_storage_byte(record[WIFI_CREDENTIAL_SECURITY_OFFSET])?;
let ssid_len = usize::from(record[WIFI_CREDENTIAL_SSID_LEN_OFFSET]);
let password_len = usize::from(record[WIFI_CREDENTIAL_PASSWORD_LEN_OFFSET]);
if ssid_len > 32 || password_len > 64 {
return None;
}
let ssid_text =
core::str::from_utf8(&record[WIFI_CREDENTIAL_SSID_OFFSET..][..ssid_len]).ok()?;
let password_text =
core::str::from_utf8(&record[WIFI_CREDENTIAL_PASSWORD_OFFSET..][..password_len])
.ok()?;
let mut ssid = WifiSsid::new();
ssid.push_str(ssid_text).ok()?;
let mut password = WifiPassword::new();
password.push_str(password_text).ok()?;
Some(Self {
ssid,
password,
security,
})
}
fn checksum(bytes: &[u8]) -> u32 {
let mut hash = 2_166_136_261u32;
for byte in bytes {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(16_777_619);
}
hash
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WifiEvent {
ScanFinished(Result<WifiNetworkList, WifiFailure>),
JoinFinished(Result<WifiSsid, WifiFailure>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WifiFailure {
ScanFailed,
JoinFailed,
InvalidCredentials,
OutOfMemory,
Unsupported,
NotConnected,
}
impl WifiFailure {
pub fn message(self) -> &'static str {
match self {
Self::ScanFailed => "WiFi scan failed",
Self::JoinFailed => "WiFi join failed",
Self::InvalidCredentials => "Invalid WiFi credentials",
Self::OutOfMemory => "WiFi ran out of memory",
Self::Unsupported => "WiFi operation unsupported",
Self::NotConnected => "WiFi is not connected",
}
}
}

26
web/index.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kinda web simulator</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<h1>Kinda web simulator</h1>
<canvas id="display" aria-label="Kinda display simulator"></canvas>
<p id="status">Loading simulator...</p>
<section class="controls" aria-label="Input controls">
<button type="button" data-event="0">Up</button>
<button type="button" data-event="1">Down</button>
<button type="button" data-event="2">Select</button>
<button type="button" data-event="3">Back</button>
<button type="button" data-event="4">Hold select</button>
<button type="button" data-event="5">Hold back</button>
</section>
<p class="help">Keyboard: ArrowUp, ArrowDown, Enter, Escape or Backspace.</p>
</main>
<script type="module" src="./sim.js"></script>
</body>
</html>

107
web/sim.js Normal file
View File

@ -0,0 +1,107 @@
const EVENT_UP = 0;
const EVENT_DOWN = 1;
const EVENT_SELECT = 2;
const EVENT_BACK = 3;
const canvas = document.getElementById('display');
const context = canvas.getContext('2d');
const status = document.getElementById('status');
const buttons = Array.from(document.querySelectorAll('[data-event]'));
let wasm;
async function loadWasm() {
const imports = {};
const response = fetch('./pkg/kinda.wasm');
if ('instantiateStreaming' in WebAssembly) {
try {
return await WebAssembly.instantiateStreaming(response, imports);
} catch (error) {
console.warn('Falling back to ArrayBuffer WebAssembly load', error);
}
}
const bytes = await (await fetch('./pkg/kinda.wasm')).arrayBuffer();
return WebAssembly.instantiate(bytes, imports);
}
function render() {
const width = wasm.kinda_sim_width();
const height = wasm.kinda_sim_height();
const pixelPointer = wasm.kinda_sim_pixels();
const pixelLength = wasm.kinda_sim_pixel_len();
const pixels = new Uint8Array(wasm.memory.buffer, pixelPointer, pixelLength);
canvas.width = width;
canvas.height = height;
const image = context.createImageData(width, height);
for (let source = 0, target = 0; source < pixels.length; source += 1, target += 4) {
const value = Math.round(18 + (pixels[source] * 230) / 255);
image.data[target] = value;
image.data[target + 1] = value;
image.data[target + 2] = value;
image.data[target + 3] = 255;
}
context.putImageData(image, 0, 0);
}
function sendInput(eventCode) {
if (!wasm) {
return;
}
wasm.kinda_sim_input(eventCode);
render();
}
function keyboardEventCode(event) {
if (event.key === 'ArrowUp') {
return EVENT_UP;
}
if (event.key === 'ArrowDown') {
return EVENT_DOWN;
}
if (event.key === 'Enter') {
return EVENT_SELECT;
}
if (event.key === 'Escape' || event.key === 'Backspace') {
return EVENT_BACK;
}
return null;
}
buttons.forEach((button) => {
button.disabled = true;
button.addEventListener('click', () => sendInput(Number(button.dataset.event)));
});
window.addEventListener('keydown', (event) => {
const eventCode = keyboardEventCode(event);
if (eventCode === null) {
return;
}
event.preventDefault();
sendInput(eventCode);
});
try {
const instance = await loadWasm();
wasm = instance.instance.exports;
wasm.kinda_sim_init();
render();
buttons.forEach((button) => {
button.disabled = false;
});
status.textContent = 'Ready. Select scans with mock WiFi results.';
} catch (error) {
console.error(error);
status.textContent = error instanceof Error ? error.message : 'Could not load simulator.';
}

45
web/style.css Normal file
View File

@ -0,0 +1,45 @@
body {
margin: 24px;
background: #eeeeee;
color: #111111;
font-family: system-ui, sans-serif;
}
main {
max-width: 900px;
}
canvas {
display: block;
width: 100%;
max-width: 800px;
height: auto;
border: 1px solid #222222;
background: #f8f8f8;
image-rendering: pixelated;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
button {
padding: 8px 12px;
border: 1px solid #222222;
background: #ffffff;
color: #111111;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
.help,
#status {
margin: 12px 0 0;
}