From bb588c93cee4a798eaa098483381e0184964fe47 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 1 Apr 2026 13:08:45 -0600 Subject: [PATCH] chore: massive repo reorganization --- .cargo/config.toml | 6 + .gitignore | 5 + Cargo.lock | 2003 ++++++++++++++++++++++++++++++++ Cargo.toml | 105 ++ PROTOCOL.md | 291 +++++ README.md | 33 + build.rs | 66 ++ justfile | 173 +++ mise.toml | 8 + src/board/PORTING.md | 92 ++ src/board/esp32s3.rs | 150 +++ src/board/heltec_v3.rs | 50 + src/board/heltec_v4.rs | 61 + src/board/mod.rs.j2 | 15 + src/board/rak_wisblock_4631.rs | 145 +++ src/board/traits.rs | 31 + src/channel.rs | 67 ++ src/display/mod.rs | 5 + src/display/render.rs | 398 +++++++ src/display/task.rs | 239 ++++ src/main.rs | 99 ++ src/protocol.rs | 661 +++++++++++ src/radio/mod.rs | 4 + src/radio/task.rs | 376 ++++++ src/usb/mod.rs | 4 + src/usb/task.rs | 234 ++++ 26 files changed, 5321 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PROTOCOL.md create mode 100644 README.md create mode 100644 build.rs create mode 100644 justfile create mode 100644 mise.toml create mode 100644 src/board/PORTING.md create mode 100644 src/board/esp32s3.rs create mode 100644 src/board/heltec_v3.rs create mode 100644 src/board/heltec_v4.rs create mode 100644 src/board/mod.rs.j2 create mode 100644 src/board/rak_wisblock_4631.rs create mode 100644 src/board/traits.rs create mode 100644 src/channel.rs create mode 100644 src/display/mod.rs create mode 100644 src/display/render.rs create mode 100644 src/display/task.rs create mode 100644 src/main.rs create mode 100644 src/protocol.rs create mode 100644 src/radio/mod.rs create mode 100644 src/radio/task.rs create mode 100644 src/usb/mod.rs create mode 100644 src/usb/task.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0ed5317 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[target.'cfg(target_arch = "xtensa")'] +rustflags = [ + "-C", "link-arg=-Wl,-Tlinkall.x", + "-C", "link-arg=-Wl,-Tdefmt.x", + "-C", "link-arg=-nostartfiles", +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96aeed3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +/vendor +/builds +/.hypothesis +src/board/mod.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2b818bd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2003 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitfield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield 0.13.2", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "display-interface" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba2aab1ef3793e6f7804162debb5ac5edb93b3d650fbcc5aeb72fcd0e6c03a0" + +[[package]] +name = "display-interface-i2c" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d964fa85bbbb5a6ecd06e58699407ac5dc3e3ad72dac0ab7e6b0d00a1cd262d" +dependencies = [ + "display-interface", + "embedded-hal 1.0.0", + "embedded-hal-async", +] + +[[package]] +name = "display-interface-spi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9ec30048b1955da2038fcc3c017f419ab21bb0001879d16c0a3749dc6b7a" +dependencies = [ + "byte-slice-cast", + "display-interface", + "embedded-hal 1.0.0", + "embedded-hal-async", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "donglora" +version = "0.1.0" +dependencies = [ + "cfg-if", + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "display-interface", + "embassy-embedded-hal 0.6.0", + "embassy-executor", + "embassy-futures", + "embassy-nrf", + "embassy-sync 0.8.0", + "embassy-time", + "embassy-usb", + "embassy-usb-driver", + "embedded-graphics", + "embedded-hal-async", + "embedded-io-async 0.7.0", + "esp-backtrace", + "esp-hal", + "esp-println", + "esp-rtos", + "heapless 0.9.2", + "lora-phy", + "minijinja", + "owo-colors", + "panic-probe", + "ssd1306", + "static_cell", + "ucobs", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8" +dependencies = [ + "embassy-futures", + "embassy-hal-internal 0.3.0", + "embassy-sync 0.7.2", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "defmt 1.0.1", + "embassy-futures", + "embassy-hal-internal 0.4.0", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b" +dependencies = [ + "cortex-m", + "critical-section", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568659fc53866d3d85c60fa33723fb751aa69e71507634fc2c19e7649432fb75" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "num-traits", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" + +[[package]] +name = "embassy-net-driver-channel" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d2eb9f05a6fc876500949856ea1be40773d866d8cb99384f72d0ae4568c16" +dependencies = [ + "embassy-futures", + "embassy-net-driver", + "embassy-sync 0.8.0", +] + +[[package]] +name = "embassy-nrf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b715380574504335c40ec3016bc44598fac3c385f82959b26c41b25c30b7648d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-embedded-hal 0.6.0", + "embassy-futures", + "embassy-hal-internal 0.5.0", + "embassy-sync 0.8.0", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embedded-storage", + "embedded-storage-async", + "fixed", + "nrf-pac", + "rand_core 0.6.4", + "rand_core 0.9.5", +] + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-sink", + "futures-util", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.2", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-usb" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f" +dependencies = [ + "bitflags 2.11.0", + "defmt 1.0.1", + "embassy-futures", + "embassy-net-driver-channel", + "embassy-sync 0.8.0", + "embassy-time", + "embassy-usb-driver", + "embedded-io-async 0.7.0", + "heapless 0.9.2", + "ssmarshal", + "usbd-hid", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a" +dependencies = [ + "defmt 1.0.1", + "embedded-io-async 0.6.1", +] + +[[package]] +name = "embassy-usb-synopsys-otg" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbe46f4083109c7ea12a03ca61095d1e87c76fec52c7ca9ee06a42935606dacb" +dependencies = [ + "critical-section", + "embassy-sync 0.8.0", + "embassy-usb-driver", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-graphics" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8da660bb0c829b34a56a965490597f82a55e767b91f9543be80ce8ccb416fe" +dependencies = [ + "az", + "byteorder", + "embedded-graphics-core", + "float-cmp", + "micromath", +] + +[[package]] +name = "embedded-graphics-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95743bef3ff70fcba3930246c4e6872882bbea0dcc6da2ca860112e0cd4bd09f" +dependencies = [ + "az", + "byteorder", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-backtrace" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3318413fb566c7227387f67736cf70cd74d80a11f2bb31c7b95a9eb48d079669" +dependencies = [ + "cfg-if", + "defmt 1.0.1", + "document-features", + "esp-config", + "esp-metadata-generated", + "esp-println", + "heapless 0.9.2", + "riscv", + "semihosting", + "xtensa-lx", +] + +[[package]] +name = "esp-config" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102871054f8dd98202177b9890cb4b71d0c6fe1f1413b7a379a8e0841fc2473c" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54786287c0a61ca0f78cb0c338a39427551d1be229103b4444591796c579e093" +dependencies = [ + "bitfield 0.19.4", + "bitflags 2.11.0", + "bytemuck", + "cfg-if", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal 0.5.0", + "embassy-futures", + "embassy-sync 0.7.2", + "embassy-usb-driver", + "embassy-usb-synopsys-otg", + "embedded-can", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "enumset", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-riscv-rt", + "esp-rom-sys", + "esp-sync", + "esp-synopsys-usb-otg", + "esp32", + "esp32c2", + "esp32c3", + "esp32c6", + "esp32h2", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.6.4", + "rand_core 0.9.5", + "riscv", + "strum", + "ufmt-write", + "xtensa-lx", + "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", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a93e39c8ad8d390d248dc7b9f4b59a873f313bf535218b8e2351356972399e3" + +[[package]] +name = "esp-println" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a30e6c9fbcc01c348d46706fef8131c7775ab84c254a3cd65d0cd3f6414d592" +dependencies = [ + "defmt 1.0.1", + "document-features", + "esp-metadata-generated", + "esp-sync", + "log", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502744a5b1e7268d27fd2a4e56ad45efe42ead517d6c517a6961540de949b0ee" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd66cccc6dd2d13e9f33668a57717ab14a6d217180ec112e6be533de93e7ecbf" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", +] + +[[package]] +name = "esp-rtos" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ec711c8d06e79c67b75d01595539e86b0aac209643af98ca87a12250428b3" +dependencies = [ + "cfg-if", + "document-features", + "embassy-executor", + "embassy-sync 0.7.2", + "embassy-time-driver", + "embassy-time-queue-utils", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-sync", + "portable-atomic", +] + +[[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", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-synopsys-usb-otg" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8938451cb19032f13365328ea66ab38c8d16deecdf322067442297110eb74468" +dependencies = [ + "critical-section", + "embedded-hal 0.2.7", + "ral-registers", + "usb-device", + "vcell", +] + +[[package]] +name = "esp32" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b76170a463d18f888a1ad258031901036fd827a9ef126733053ba5f8739fb0c8" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62cf8932966b8d445b6f1832977b468178f0a84effb2e9fda89f60c24d45aa3" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356af3771d0d6536c735bf71136594f4d1cbb506abf6e0c51a6639e9bf4e7988" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f5e511df672d79cd63365c92045135e01ba952b6bddd25b660baff5e1110f6b" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a50bbd1380931e095e0973b9b12f782a9c481f2edf1f7c42e7eb4ff736d6d" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98574d4c577fbe888fe3e6df7fc80d25a05624d9998f7d7de1500ae21fcca78f" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1810d8ee4845ef87542af981e38eb80ab531d0ef1061e1486014ab7af74c337a" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "fixed" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707070ccf8c4173548210893a0186e29c266901b71ed20cd9e2ca0193dfe95c3" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lora-modulation" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c96c4a41a1f1ad04de765306e1829c7007a37898fe4033204fa82cf2e055624" + +[[package]] +name = "lora-phy" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61471c3b2909789e3332083577f6cf6c41a4fcf37674ef15156bcbb20504ac65" +dependencies = [ + "defmt 0.3.100", + "embedded-hal 1.0.0", + "embedded-hal-async", + "lora-modulation", + "num-traits", +] + +[[package]] +name = "maybe-async-cfg" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e083394889336bc66a4eaf1011ffbfa74893e910f902a9f271fa624c61e1b2" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "pulldown-cmark", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "minijinja" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d" +dependencies = [ + "memo-map", + "serde", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nrf-pac" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddd547fab92b0553ff37032034fd04ce8b8b60d3f9a83f89ea815dfd0400336" +dependencies = [ + "cortex-m", + "cortex-m-rt", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt 1.0.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ral-registers" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46b71a9d9206e8b46714c74255adcaea8b11e0350c1d8456165073c3f75fc81a" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "riscv-rt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" +dependencies = [ + "riscv", + "riscv-pac", + "riscv-rt-macros", + "riscv-target-parser", +] + +[[package]] +name = "riscv-rt-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semihosting" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e4abf97879f4e80db69a9fba7bd64998e9bdad25f58ef045a778e191172fd4" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "somni-expr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +dependencies = [ + "somni-parser", +] + +[[package]] +name = "somni-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" + +[[package]] +name = "ssd1306" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea6aac2d078bbc71d9b8ac3f657335311f3b6625e9a1a96ccc29f5abfa77c56" +dependencies = [ + "display-interface", + "display-interface-i2c", + "display-interface-spi", + "embedded-graphics-core", + "embedded-hal 1.0.0", + "embedded-hal-async", + "maybe-async-cfg", +] + +[[package]] +name = "ssmarshal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e6ad23b128192ed337dfa4f1b8099ced0c2bf30d61e551b65fda5916dbb850" +dependencies = [ + "encode_unicode", + "serde", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucobs" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fbab511bafd13c109aff41f67d4c4426bf919a6670647e69f0d2af95ac01d" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "usb-device" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" +dependencies = [ + "heapless 0.8.0", + "portable-atomic", +] + +[[package]] +name = "usbd-hid" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68beab087e4971a2fe76f631478b0e91d39593f58efd2775026ce6dc07a7bac6" +dependencies = [ + "usb-device", + "usbd-hid-macros", +] + +[[package]] +name = "usbd-hid-descriptors" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b297f021719c4308d5d0c61b6c1e7c6b3ba383deba774b49aa5484f996bdb8f1" +dependencies = [ + "bitfield 0.14.0", +] + +[[package]] +name = "usbd-hid-macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011a3219e0933f5b3ad7dc90d9a66541a967d084c98c067deed1cd608e557ed7" +dependencies = [ + "byteorder", + "hashbrown 0.13.2", + "log", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "usbd-hid-descriptors", +] + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "xtensa-lx" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8709f037fb123fe7ff146d2bce86f9dc0dfc53045c016bfd9d703317b6502845" +dependencies = [ + "document-features", + "xtensa-lx", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c282f68 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,105 @@ +[workspace] +members = ["."] + +[package] +name = "donglora" +version = "0.1.0" +edition = "2021" +build = "build.rs" +authors = ["Stephen Waits "] +license = "MIT" +description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host" +repository = "https://github.com/swaits/donglora" +keywords = ["lora", "radio", "embedded", "usb", "firmware"] +categories = ["embedded", "hardware-support", "no-std"] + +# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93 +# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not +# support the required language features or pass MSRV gating. Revisit when +# espup catches up. Check: rustup run esp rustc --version +[dependencies] +cfg-if = "1.0" +critical-section = "1.2" +defmt = "1.0" +embedded-hal-async = "1.0" +embassy-embedded-hal = "0.6" +embassy-executor = "0.9" +embassy-sync = "0.8" +embassy-time = "0.5" +embassy-usb = { version = "0.6", features = ["defmt"] } +embassy-usb-driver = "0.2" +ucobs = { version = "0.3", features = ["legacy-msrv"] } +heapless = "0.9" +lora-phy = "3.0" +static_cell = "2.1" + +# Display rendering +display-interface = "0.5" +embedded-graphics = "0.8" +ssd1306 = { version = "0.10", features = ["async"] } + +# Async IO traits and combinators +embedded-io-async = "0.7" +embassy-futures = "0.1" + +# ESP32-S3 (Heltec V3) — requires esp toolchain (espup) +esp-backtrace = { version = "0.18", optional = true, features = [ + "esp32s3", + "panic-handler", + "defmt", +] } +esp-hal = { version = "1.0", optional = true, features = [ + "esp32s3", + "unstable", +] } +esp-println = { version = "0.16", optional = true, features = [ + "esp32s3", + "defmt-espflash", +] } +esp-rtos = { version = "0.2", optional = true, features = [ + "esp32s3", + "embassy", +] } + +# nRF52840 (RAK 4631) +cortex-m-rt = { version = "0.7", optional = true } +embassy-nrf = { version = "0.10", optional = true, features = [ + "nrf52840", + "gpiote", + "time-driver-rtc1", + "defmt", +] } +panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] } + +[build-dependencies] +minijinja = "2.18" +owo-colors = "4.3" + +[features] +heltec_v3 = [ + "dep:esp-backtrace", + "dep:esp-hal", + "dep:esp-println", + "dep:esp-rtos", +] +heltec_v4 = [ + "dep:esp-backtrace", + "dep:esp-hal", + "dep:esp-println", + "dep:esp-rtos", +] +rak_wisblock_4631 = [ + "dep:cortex-m-rt", + "dep:embassy-nrf", + "dep:panic-probe", + "embassy-executor/arch-cortex-m", + "embassy-executor/executor-thread", +] + +[lints.clippy] +undocumented_unsafe_blocks = "deny" + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded) diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..5a5f999 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,291 @@ +# DongLoRa USB Protocol + +DongLoRa communicates over USB CDC-ACM (virtual serial port). +No baud rate configuration needed. + +**USB identifiers:** VID `1209`, PID `5741` + +## Framing + +Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded +and terminated with a `0x00` sentinel byte: + +``` +[COBS-encoded bytes] [0x00] +``` + +COBS guarantees `0x00` never appears in the encoded data, so the +sentinel unambiguously marks frame boundaries. USB packets are +chunked at 64 bytes. + +## Encoding + +All integers are **fixed-size little-endian**. No variable-length +encoding. Every field has a known size at a known offset. + +| Type | Size | Encoding | +|------|------|----------| +| `u8` | 1 | Raw byte | +| `i8` | 1 | Raw byte (two's complement) | +| `u16` | 2 | Little-endian | +| `i16` | 2 | Little-endian (two's complement) | +| `u32` | 4 | Little-endian | + +## RadioConfig (13 bytes) + +| Offset | Size | Field | Type | Valid range | +|--------|------|-------|------|-------------| +| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 | +| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) | +| 5 | 1 | `sf` | u8 | 5–12 | +| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) | +| 7 | 2 | `sync_word` | u16 LE | any | +| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max | +| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) | +| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled | + +Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum +TX power. The firmware resolves this to the actual hardware max. + +Set `preamble_len` to `0` to use the firmware default of 16 symbols. +Longer preambles improve reliability in mesh/noisy environments at +the cost of slightly longer air time. + +Set `cad` to `1` (or any non-zero value) to enable Channel Activity +Detection (listen-before-talk) before each transmission. The radio +checks for channel activity and waits for a clear channel before +transmitting. Set to `0` to disable. Default: enabled. + +## Commands (Host → Firmware) + +Each command is a tag byte followed by fixed-size fields. + +| Tag | Command | Payload | +|-----|---------|---------| +| 0 | Ping | — | +| 1 | GetConfig | — | +| 2 | SetConfig | RadioConfig (10 bytes) | +| 3 | StartRx | — | +| 4 | StopRx | — | +| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload | +| 6 | DisplayOn | — | +| 7 | DisplayOff | — | +| 8 | GetMac | — | + +## Responses (Firmware → Host) + +| Tag | Response | Payload | +|-----|----------|---------| +| 0 | Pong | — | +| 1 | Config | RadioConfig (10 bytes) | +| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload | +| 3 | TxDone | — | +| 4 | Ok | — | +| 5 | Error | code (u8) | +| 6 | MacAddress | 6 raw bytes (board's MAC/device address) | + +## Bandwidth + +| Value | Bandwidth | +|-------|-----------| +| 0 | 7.8 kHz | +| 1 | 10.4 kHz | +| 2 | 15.6 kHz | +| 3 | 20.8 kHz | +| 4 | 31.25 kHz | +| 5 | 41.7 kHz | +| 6 | 62.5 kHz | +| 7 | 125 kHz | +| 8 | 250 kHz | +| 9 | 500 kHz | + +## Error Codes + +| Value | Error | Meaning | +|-------|-------|---------| +| 0 | InvalidConfig | Radio config validation failed | +| 1 | RadioBusy | Radio busy (e.g. RX restart failed) | +| 2 | TxTimeout | Transmission timed out | +| 3 | CrcError | CRC error on received packet | +| 4 | NotConfigured | SetConfig required first | +| 5 | NoDisplay | No display attached | + +## Worked Example + +Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on: + +### 1. Build the bytes + +```python +import struct +config = struct.pack(" 32: + return INVALID # bad metadata, discard or flag + + # Step 2: check against demodulation floor + min_snr = -2.5 * (sf - 4) + margin = snr - min_snr # dB above the floor + + if margin < 0: + return UNRELIABLE # below floor, likely corrupt + if margin < 3: + return MARGINAL # near floor, may contain errors + return GOOD # comfortable margin +``` + +**What to do with each grade:** + +- **GOOD** — process normally. +- **MARGINAL** — process, but prefer application-layer integrity + checks (checksums, sequence numbers, known-format validation) + before acting on the payload. Log for diagnostics. +- **UNRELIABLE** — log for diagnostics but do not trust the payload + for routing decisions, command execution, or retransmission. + Applications may still display the data with a warning. +- **INVALID** — discard. The SNR metadata itself is corrupt. + +### Application-layer defenses + +For safety-critical or command-and-control applications, do not rely +solely on the radio CRC. Add your own integrity checks: + +- An application-layer CRC or hash over the payload +- Sequence numbers to detect duplicates and missing packets +- Known-format validation (magic bytes, expected lengths) +- Require acknowledgement before acting on commands + +### Radio config impact + +Wider bandwidth and lower spreading factor increase throughput but +reduce sensitivity, making low-SNR packets more common in marginal +links. If your application sees frequent MARGINAL/UNRELIABLE packets, +consider increasing SF or decreasing BW to improve link margin at the +cost of air time. + +## Host Implementation Checklist + +1. Open USB serial port (find by VID:PID `1209:5741`) +2. Implement COBS encode/decode (libraries exist for every language) +3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE +4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles) +5. Send `SetConfig`, read back `Ok` +6. Send `StartRx`, read back `Ok` +7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets) +8. To transmit: send `Transmit`, read back `TxDone` + +See `examples/` for working Python implementations. diff --git a/README.md b/README.md new file mode 100644 index 0000000..419f276 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# DongLoRa Firmware + +Embedded Rust firmware for DongLoRa boards. Exposes the LoRa radio over +USB CDC-ACM using a COBS-framed binary protocol. + +## Building + +```sh +just setup # one-time toolchain install +just build heltec_v4 # build for a specific board +just flash heltec_v4 # build + flash +just check-all # compile-check all boards +just clippy heltec_v4 # lint +just test # host-side protocol unit tests +``` + +All commands are run from the `firmware/` directory. + +## Supported Boards + +| Board | MCU | Radio | Target | +|-------|-----|-------|--------| +| Heltec V3 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf | +| Heltec V4 | ESP32-S3 | SX1262 | xtensa-esp32s3-none-elf | +| RAK WisBlock 4631 | nRF52840 | SX1262 | thumbv7em-none-eabihf | + +## Adding a Board + +See [src/board/PORTING.md](src/board/PORTING.md). + +## Protocol + +See [PROTOCOL.md](PROTOCOL.md) for the complete wire format specification. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..87bdc4c --- /dev/null +++ b/build.rs @@ -0,0 +1,66 @@ +use minijinja::{context, Environment}; +use owo_colors::OwoColorize; +use std::fs; + +fn main() { + // Protocol tests run on the host without a board feature. Cargo doesn't + // expose `cfg(test)` to build scripts, so we rely on an env var set by + // the justfile `test` recipe. See: `just test` + if std::env::var_os("DONGLORA_HOST_TEST").is_some() { + return; + } + + let board_dir = "src/board"; + let boards: Vec = fs::read_dir(board_dir) + .expect("failed to read src/board directory") + .filter_map(|e| { + let p = e.ok()?.path(); + let stem = p.file_stem()?.to_str()?; + // Exclude non-board files: mod.rs, traits.rs, and platform helpers (esp32s3.rs). + if p.is_file() + && p.extension()? == "rs" + && !matches!(stem, "mod" | "traits" | "esp32s3") + { + return Some(p.file_stem()?.to_str()?.to_string()); + } + None + }) + .collect(); + + let enabled: Vec<_> = std::env::vars() + .filter(|(k, _)| k.starts_with("CARGO_FEATURE_")) + .map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase()) + .filter(|f| boards.contains(f)) + .collect(); + + match enabled.len() { + 0 => panic!( + "\n\n{}\n", + format!("No board selected! Found: {:?}", boards) + .red() + .bold() + ), + 1 => println!("cargo:rustc-cfg=board_selected"), + _ => panic!( + "\n\n{}\n", + format!("Conflict: Multiple boards enabled {:?}", enabled) + .yellow() + .bold() + ), + } + + let mut env = Environment::new(); + let template = + fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2"); + env.add_template("mod", &template) + .expect("failed to parse board template"); + let rendered = env + .get_template("mod") + .expect("template not found") + .render(context!(boards => boards)) + .expect("failed to render board module"); + fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs"); + + println!("cargo:rerun-if-changed=src/board/mod.rs.j2"); + println!("cargo:rerun-if-changed={}", board_dir); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..3f2e1b3 --- /dev/null +++ b/justfile @@ -0,0 +1,173 @@ +set shell := ["bash", "-c"] + +# Board definitions: feature target chip +# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core. +# Tool versions are pinned in mise.toml; run `just setup` to install everything. +heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3" +heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3" +rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA" + +builds_dir := "builds" + +# All known boards +boards := "heltec_v3 heltec_v4 rak_wisblock_4631" + +# Install all required tools and toolchains +setup: + mise trust --yes + mise install + @# ESP Xtensa toolchain (not managed by mise) + @if ! rustup toolchain list | grep -q "^esp"; then \ + echo "ESP toolchain not found, installing via espup (this may take a while)..."; \ + espup install || { echo "error: espup install failed" >&2; exit 1; }; \ + fi + @# nightly rust-src for -Zbuild-std + @if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \ + rustup component add rust-src --toolchain nightly; \ + fi + @# ARM target + @rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf + +# Build release firmware for all boards, installing toolchains as needed +build-all: + @trap 'exit 130' INT; \ + for board in {{boards}}; do \ + if just _can_build $board; then \ + echo "── building $board ──"; \ + just build $board || exit $?; \ + else \ + echo "── skipping $board (toolchain install failed) ──"; \ + fi; \ + done + +# Check all boards compile, installing toolchains as needed +check-all: + @trap 'exit 130' INT; \ + for board in {{boards}}; do \ + if just _can_build $board; then \ + echo "── checking $board ──"; \ + just check $board || exit $?; \ + else \ + echo "── skipping $board (toolchain install failed) ──"; \ + fi; \ + done + +# Check a single board compiles +check board: + @just _cargo {{board}} check + +# Run clippy on a single board +clippy board: + @just _cargo {{board}} "clippy" "-- -D warnings" + +# Build release firmware and copy to builds/ with a readable name +build board profile="release": + @just _cargo {{board}} "build --{{profile}}" + @just _copy_firmware {{board}} {{profile}} + +# Build and flash a board (espflash for Xtensa, probe-rs for ARM) +flash board: + @just _ensure_tools + @just build {{board}} release + @read -r feat target chip <<< "$(just _info {{board}})"; \ + case "$target" in \ + xtensa-*) \ + port=$(just _find_port "303a:1001" "1209:5741"); \ + if [ -n "$port" ]; then \ + echo "Using $port"; \ + espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-release.elf"; \ + else \ + echo "No port found, falling back to espflash auto-detection..." >&2; \ + espflash flash "{{builds_dir}}/donglora-{{board}}-release.elf"; \ + fi ;; \ + *) probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-release.elf" ;; \ + esac + +# Show binary size for a release build +size board: + @just _cargo {{board}} "size --release" + +# Run host-side protocol unit tests +test: + DONGLORA_HOST_TEST=1 cargo test + +# Check for outdated dependencies +outdated: + @# embassy-executor 0.10 renamed arch-* → platform-* features. + @# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade. + @# cargo-outdated crashes trying to resolve 0.10 with the old feature name. + cargo outdated --exclude embassy-executor + +# ── Private helpers ─────────────────────────────────────────────────── + +# Install mise-managed tools if any are missing (fast no-op when current) +[private] +_ensure_tools: + @mise trust --yes . 2>/dev/null; mise install --quiet + +# Run a cargo command for a board. +# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core +[private] +_cargo board cmd extra_args="": + @just _ensure_tools + @read -r feat target chip <<< "$(just _info {{board}})"; \ + env=""; extra=""; \ + case "$target" in xtensa-*) \ + just _require_esp_toolchain; \ + [ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \ + env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \ + esac; \ + eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}} + +# Ensure a board's toolchain is available, auto-installing if needed +[private] +_can_build board: + @read -r feat target chip <<< "$(just _info {{board}})"; \ + case "$target" in \ + xtensa-*) just _require_esp_toolchain ;; \ + *) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \ + esac + +# Ensure the ESP Xtensa toolchain is installed (espup installed via mise) +[private] +_require_esp_toolchain: + @just _ensure_tools + @if ! rustup toolchain list | grep -q "^esp"; then \ + echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \ + espup install || { echo "error: espup install failed" >&2; exit 1; }; \ + fi; \ + if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \ + echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \ + rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \ + fi + +# Find a serial port matching any of the given VID:PID pairs (checked in order) +[private] +_find_port +vid_pids: + @for vidpid in {{vid_pids}}; do \ + vid="${vidpid%%:*}"; pid="${vidpid##*:}"; \ + for dev in /dev/ttyACM* /dev/ttyUSB*; do \ + [ -e "$dev" ] || continue; \ + info=$(udevadm info --query=property --name="$dev" 2>/dev/null) || continue; \ + dev_vid=$(echo "$info" | sed -n 's/^ID_VENDOR_ID=//p' | tr '[:upper:]' '[:lower:]'); \ + dev_pid=$(echo "$info" | sed -n 's/^ID_MODEL_ID=//p' | tr '[:upper:]' '[:lower:]'); \ + if [ "$dev_vid" = "$vid" ] && [ "$dev_pid" = "$pid" ]; then \ + echo "$dev"; exit 0; \ + fi; \ + done; \ + done + +[private] +_copy_firmware board profile: + @mkdir -p {{builds_dir}} + @read -r feat target chip <<< "$(just _info {{board}})"; \ + src="target/$target/{{profile}}/donglora"; \ + dst="{{builds_dir}}/donglora-{{board}}-{{profile}}"; \ + if [ -f "$src" ]; then cp "$src" "$dst.elf"; echo "→ $dst.elf"; fi + +[private] +_info name: + @if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \ + elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \ + elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \ + else echo "Unknown board: {{name}}" >&2; exit 1; fi diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..43fd002 --- /dev/null +++ b/mise.toml @@ -0,0 +1,8 @@ +[tools] +rust = "1.93" +just = "latest" + +"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc. +"cargo:espup" = "0.16" +"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust +"cargo:probe-rs-tools" = "0.31" diff --git a/src/board/PORTING.md b/src/board/PORTING.md new file mode 100644 index 0000000..caaab6d --- /dev/null +++ b/src/board/PORTING.md @@ -0,0 +1,92 @@ +# Porting DongLoRa to a New Board + +## Steps + +### 1. Create the board file + +Create `src/board/your_board.rs`. Use an existing board as a template: +- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `esp32s3.rs`) +- nRF52840 boards: copy `rak_wisblock_4631.rs` + +ESP32-S3 boards share common type aliases, peripheral bundles, and init helpers +via `src/board/esp32s3.rs`. Your board file only needs the trait impl and +board-specific init (Vext GPIO, display reset). If you add a new ESP32-S3 board, +add its feature to the `#[cfg(any(...))]` gate on `mod esp32s3` in +`src/board/mod.rs.j2`. + +### 2. Define concrete types + +Your board must export these type aliases: + +```rust +pub type RadioDriver = ...; // Must work with lora_phy::LoRa +pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static> +pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c +``` + +### 3. Define peripheral bundles + +```rust +pub struct RadioParts { + pub driver: RadioDriver, + pub delay: embassy_time::Delay, +} + +pub struct UsbParts { + pub driver: UsbDriver, +} + +pub struct DisplayParts { + pub i2c: DisplayI2c, + pub mac: [u8; 6], +} +``` + +### 4. Implement the LoRaBoard trait + +```rust +use super::traits::LoRaBoard; + +impl LoRaBoard for Board { + const NAME: &'static str = "Your Board Name"; + const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA + fn init() -> Self { ... } + fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc. +} +``` + +The compiler will refuse to build if any of these are missing. + +### 5. Implement `Board::into_parts()` + +```rust +impl Board { + pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + // Initialize SPI, DMA, radio, USB, I2C + // Return peripheral bundles + } +} +``` + +### 6. Add Cargo feature + +In `firmware/Cargo.toml`, add a feature with your board's HAL dependencies: + +```toml +[features] +your_board = ["dep:your-hal", ...] +``` + +### 7. Add to justfile + +Add your board's feature/target/chip to the `boards` list at the top of `justfile`. + +### 8. Build and test + +```sh +just check your_board # Must compile +just clippy your_board # Must be clean +just build your_board # Produces firmware +``` + +`build.rs` auto-discovers your `.rs` file — no template editing needed. diff --git a/src/board/esp32s3.rs b/src/board/esp32s3.rs new file mode 100644 index 0000000..6d8249e --- /dev/null +++ b/src/board/esp32s3.rs @@ -0,0 +1,150 @@ +//! Shared types and initialization helpers for ESP32-S3 boards. +//! +//! All ESP32-S3 boards in this project share the same SPI/DMA, USB OTG, +//! and I2C peripheral wiring. This module provides the common type aliases, +//! peripheral bundle structs, and init functions. Each board file re-exports +//! these types and calls the helpers from its own `Board::into_parts()`. + +use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_time::Delay; +use esp_hal::dma::{DmaRxBuf, DmaTxBuf}; +use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull}; +use esp_hal::i2c::master::{Config as I2cConfig, I2c}; +use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus}; +use esp_hal::spi::Mode as SpiMode; +use esp_hal::time::Rate; +use esp_hal::timer::timg::TimerGroup; +use static_cell::StaticCell; + +use lora_phy::iv::GenericSx126xInterfaceVariant; +use lora_phy::sx126x::{self, Sx1262, Sx126x}; + +// ── Concrete peripheral types ──────────────────────────────────────── + +type SpiBus = SpiDmaBus<'static, esp_hal::Async>; +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; +pub type RadioDriver = Sx126x; + +pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>; + +pub type DisplayI2c = I2c<'static, esp_hal::Async>; + +// ── Peripheral bundles ─────────────────────────────────────────────── + +pub struct RadioParts { + pub driver: RadioDriver, + pub delay: Delay, +} + +pub struct UsbParts { + pub driver: UsbDriver, +} + +pub struct DisplayParts { + pub i2c: DisplayI2c, + pub mac: [u8; 6], +} + +// ── Shared initialization helpers ─────────────────────────────────── + +/// Start the ESP-RTOS scheduler and Embassy time driver. +pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) { + let timg0 = TimerGroup::new(timg0); + esp_rtos::start(timg0.timer0); +} + +/// Initialize SPI + DMA + SX1262 radio. +/// +/// All current ESP32-S3 boards use the same SPI/radio pin mapping: +/// SCK=GPIO9, MOSI=GPIO10, MISO=GPIO11, NSS=GPIO8, +/// Reset=GPIO12, DIO1=GPIO14, BUSY=GPIO13. +#[allow(clippy::too_many_arguments)] +pub fn init_radio( + spi2: esp_hal::peripherals::SPI2<'static>, + dma_ch0: esp_hal::peripherals::DMA_CH0<'static>, + sck: esp_hal::peripherals::GPIO9<'static>, + mosi: esp_hal::peripherals::GPIO10<'static>, + miso: esp_hal::peripherals::GPIO11<'static>, + nss_pin: esp_hal::peripherals::GPIO8<'static>, + reset_pin: esp_hal::peripherals::GPIO12<'static>, + dio1_pin: esp_hal::peripherals::GPIO14<'static>, + busy_pin: esp_hal::peripherals::GPIO13<'static>, +) -> RadioParts { + let spi = Spi::new( + spi2, + SpiConfig::default() + .with_frequency(Rate::from_mhz(1)) + .with_mode(SpiMode::_0), + ) + .expect("SPI init") + .with_sck(sck) + .with_mosi(mosi) + .with_miso(miso) + .with_dma(dma_ch0); + + let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256); + let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf"); + let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf"); + + let spi = spi.with_buffers(dma_rx, dma_tx).into_async(); + + static SPI_BUS: StaticCell> = + StaticCell::new(); + let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)); + + let nss = Output::new(nss_pin, Level::High, OutputConfig::default()); + let spi_device = SpiDevice::new(spi_bus, nss); + + let reset = Output::new(reset_pin, Level::High, OutputConfig::default()); + let dio1 = Input::new(dio1_pin, InputConfig::default().with_pull(Pull::Down)); + let busy = Input::new(busy_pin, InputConfig::default().with_pull(Pull::Down)); + + let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None) + .expect("SX1262 interface init"); + + let sx_config = sx126x::Config { + chip: Sx1262, + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + use_dcdc: true, + rx_boost: false, + }; + + RadioParts { + driver: Sx126x::new(spi_device, iv, sx_config), + delay: Delay, + } +} + +/// Initialize USB OTG CDC-ACM driver. +pub fn init_usb( + usb0: esp_hal::peripherals::USB0<'static>, + dp: esp_hal::peripherals::GPIO20<'static>, + dm: esp_hal::peripherals::GPIO19<'static>, +) -> UsbParts { + let usb_inst = esp_hal::otg_fs::Usb::new(usb0, dp, dm); + static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new(); + let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]); + UsbParts { + driver: esp_hal::otg_fs::asynch::Driver::new( + usb_inst, + ep_out_buf, + esp_hal::otg_fs::asynch::Config::default(), + ), + } +} + +/// Initialize I2C bus for display. +pub fn init_display_i2c( + i2c0: esp_hal::peripherals::I2C0<'static>, + sda: esp_hal::peripherals::GPIO17<'static>, + scl: esp_hal::peripherals::GPIO18<'static>, +) -> DisplayI2c { + I2c::new(i2c0, I2cConfig::default()) + .expect("I2C init") + .with_sda(sda) + .with_scl(scl) + .into_async() +} diff --git a/src/board/heltec_v3.rs b/src/board/heltec_v3.rs new file mode 100644 index 0000000..5a55fb3 --- /dev/null +++ b/src/board/heltec_v3.rs @@ -0,0 +1,50 @@ +use esp_hal::gpio::{Level, Output, OutputConfig}; + +use super::esp32s3; +use super::traits::LoRaBoard; + +#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks) +pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts}; + +// ── Board init ─────────────────────────────────────────────────────── + +pub struct Board { + p: esp_hal::peripherals::Peripherals, +} + +impl LoRaBoard for Board { + const NAME: &'static str = "Heltec V3"; + const TX_POWER_RANGE: (i8, i8) = (-9, 22); + + fn init() -> Self { + let p = esp_hal::init(esp_hal::Config::default()); + Self { p } + } + + fn mac_address() -> [u8; 6] { + esp_hal::efuse::Efuse::mac_address() + } +} + +impl Board { + pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + let p = self.p; + + esp32s3::start_timer(p.TIMG0); + + // Vext power: GPIO21, active LOW to enable OLED + let vext = Output::new(p.GPIO21, Level::Low, OutputConfig::default()); + core::mem::forget(vext); // hold pin low permanently; drop would reset it + + let radio = esp32s3::init_radio( + p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14, + p.GPIO13, + ); + let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19); + let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18); + let mac = Self::mac_address(); + let display = Some(DisplayParts { i2c, mac }); + + (radio, usb, display) + } +} diff --git a/src/board/heltec_v4.rs b/src/board/heltec_v4.rs new file mode 100644 index 0000000..95c1b6b --- /dev/null +++ b/src/board/heltec_v4.rs @@ -0,0 +1,61 @@ +use esp_hal::gpio::{Level, Output, OutputConfig}; + +use super::esp32s3; +use super::traits::LoRaBoard; + +#[allow(unused_imports)] // Re-exported for other modules (display, radio, usb tasks) +pub use super::esp32s3::{DisplayI2c, DisplayParts, RadioDriver, RadioParts, UsbDriver, UsbParts}; + +// ── Board implementation ──────────────────────────────────────────── + +pub struct Board { + p: esp_hal::peripherals::Peripherals, +} + +impl LoRaBoard for Board { + const NAME: &'static str = "Heltec V4"; + const TX_POWER_RANGE: (i8, i8) = (-9, 22); + + fn init() -> Self { + let p = esp_hal::init(esp_hal::Config::default()); + Self { p } + } + + fn mac_address() -> [u8; 6] { + esp_hal::efuse::Efuse::mac_address() + } +} + +impl Board { + pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + let p = self.p; + + esp32s3::start_timer(p.TIMG0); + + // Vext power: GPIO36, active LOW to enable peripherals + let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default()); + core::mem::forget(vext); // hold pin low permanently; drop would reset it + + let radio = esp32s3::init_radio( + p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11, p.GPIO8, p.GPIO12, p.GPIO14, + p.GPIO13, + ); + + // Note: switches internal USB PHY from Serial-JTAG to OTG. + // espflash --monitor will stop working after this point. + let usb = esp32s3::init_usb(p.USB0, p.GPIO20, p.GPIO19); + + // SSD1315 display reset: pulse GPIO21 low->high before I2C init + let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default()); + esp_hal::delay::Delay::new().delay_millis(10); + display_rst.set_high(); + esp_hal::delay::Delay::new().delay_millis(10); + core::mem::forget(display_rst); // hold reset high permanently + + let i2c = esp32s3::init_display_i2c(p.I2C0, p.GPIO17, p.GPIO18); + let mac = Self::mac_address(); + let display = Some(DisplayParts { i2c, mac }); + + (radio, usb, display) + } +} diff --git a/src/board/mod.rs.j2 b/src/board/mod.rs.j2 new file mode 100644 index 0000000..063145d --- /dev/null +++ b/src/board/mod.rs.j2 @@ -0,0 +1,15 @@ +// @generated by build.rs — DO NOT EDIT +pub mod traits; +pub use traits::*; + +#[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] +mod esp32s3; + +cfg_if::cfg_if! { + {%- for board in boards %} + {% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] { + mod {{ board }}; + pub use {{ board }}::*; + } + {%- endfor %} +} diff --git a/src/board/rak_wisblock_4631.rs b/src/board/rak_wisblock_4631.rs new file mode 100644 index 0000000..eb8cab5 --- /dev/null +++ b/src/board/rak_wisblock_4631.rs @@ -0,0 +1,145 @@ +use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice; +use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull}; +use embassy_nrf::spim::{self, Spim}; +use embassy_nrf::twim::{self, Twim}; +use embassy_nrf::usb::Driver; +use embassy_nrf::{bind_interrupts, Peripherals}; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_time::Delay; +use static_cell::StaticCell; + +use lora_phy::iv::GenericSx126xInterfaceVariant; +use lora_phy::sx126x::{self, Sx1262, Sx126x}; + +use super::traits::LoRaBoard; + +bind_interrupts!(struct Irqs { + USBD => embassy_nrf::usb::InterruptHandler; + SPIM3 => embassy_nrf::spim::InterruptHandler; + TWISPI0 => embassy_nrf::twim::InterruptHandler; +}); + +// ── Concrete peripheral types ──────────────────────────────────────── + +type SpiBus = Spim<'static>; +type Nss = Output<'static>; +type Iv = GenericSx126xInterfaceVariant, Input<'static>>; +type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; +/// SX126x driver (pre-LoRa construction — LoRa::new is async). +pub type RadioDriver = Sx126x; + +/// USB driver for CDC-ACM. +pub type UsbDriver = Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>; + +/// I2C bus for an optional SSD1306 OLED. +pub type DisplayI2c = Twim<'static>; + +// ── Peripheral bundles ─────────────────────────────────────────────── + +pub struct RadioParts { + pub driver: RadioDriver, + pub delay: Delay, +} + +pub struct UsbParts { + pub driver: UsbDriver, +} + +pub struct DisplayParts { + pub i2c: DisplayI2c, + pub mac: [u8; 6], +} + +// ── Board init ─────────────────────────────────────────────────────── + +pub struct Board { + p: Peripherals, +} + +impl super::traits::LoRaBoard for Board { + const NAME: &'static str = "RAK WisBlock 4631"; + const TX_POWER_RANGE: (i8, i8) = (-9, 22); + + fn init() -> Self { + let p = embassy_nrf::init(Default::default()); + Self { p } + } + + fn mac_address() -> [u8; 6] { + // SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration + // Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and + // DEVICEADDR[1] registers containing the factory-programmed device address. + // read_volatile is correct for memory-mapped hardware registers. + unsafe { + let ficr = 0x10000000 as *const u32; + let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4)); + let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8)); + [ + addr0 as u8, + (addr0 >> 8) as u8, + (addr0 >> 16) as u8, + (addr0 >> 24) as u8, + addr1 as u8, + (addr1 >> 8) as u8, + ] + } + } +} + +impl Board { + + pub fn into_parts(self) -> (RadioParts, UsbParts, Option) { + let p = self.p; + + // ── SPI bus for SX1262 ─────────────────────────────────── + let mut spi_cfg = spim::Config::default(); + spi_cfg.frequency = spim::Frequency::M1; + let spi = Spim::new(p.SPI3, Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg); + + static SPI_BUS: StaticCell> = + StaticCell::new(); + let spi_bus = SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)); + + let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard); + let spi_device = SpiDevice::new(spi_bus, nss); + + // ── SX1262 control pins ────────────────────────────────── + let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard); + let dio1 = Input::new(p.P1_15, Pull::Down); + let busy = Input::new(p.P1_14, Pull::Down); + + let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None).expect("SX1262 interface init"); + + let sx_config = sx126x::Config { + chip: Sx1262, + tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V7), + use_dcdc: true, + rx_boost: false, + }; + + let radio = RadioParts { + driver: Sx126x::new(spi_device, iv, sx_config), + delay: Delay, + }; + + // ── USB ────────────────────────────────────────────────── + static VBUS: StaticCell = + StaticCell::new(); + let vbus = VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new(true, false)); + let usb = UsbParts { + driver: Driver::new(p.USBD, Irqs, vbus), + }; + + // ── Display (optional RAK1921 SSD1306 OLED on I2C) ────── + // Always provide the I2C bus; display_task detects presence + // via SSD1306 init (fails gracefully if no display attached). + static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + let twim_buf = TWIM_BUF.init([0u8; 256]); + let i2c_cfg = twim::Config::default(); + let i2c = Twim::new(p.TWISPI0, Irqs, p.P0_13, p.P0_14, i2c_cfg, twim_buf); + let mac = Self::mac_address(); + let display = Some(DisplayParts { i2c, mac }); + + (radio, usb, display) + } +} diff --git a/src/board/traits.rs b/src/board/traits.rs new file mode 100644 index 0000000..f1f6114 --- /dev/null +++ b/src/board/traits.rs @@ -0,0 +1,31 @@ +//! Board abstraction trait. +//! +//! Every board must implement [`LoRaBoard`]. The compiler enforces completeness — +//! missing constants or methods are compile errors, not runtime surprises. +//! +//! Board modules also export concrete types (`RadioParts`, `UsbParts`, +//! `DisplayParts`, `RadioDriver`, `UsbDriver`, `DisplayI2c`) that the tasks +//! use directly. The trait verifies metadata and initialization; the types +//! verify hardware compatibility. +//! +//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board. + +/// Compile-time contract for a DongLoRa board. +/// +/// Every board's `Board` struct must implement this trait. It verifies that +/// the board provides all required metadata and initialization methods. +/// The tasks use the board's concrete types directly (not the trait's +/// associated types) because Embassy tasks must be concrete. +pub trait LoRaBoard { + /// Human-readable board name (shown on display splash screen). + const NAME: &'static str; + + /// TX power range in dBm (min, max) for this board's radio + PA. + const TX_POWER_RANGE: (i8, i8); + + /// Initialize the board hardware. + fn init() -> Self; + + /// Read the board's unique hardware address (MAC, device ID, etc.). + fn mac_address() -> [u8; 6]; +} diff --git a/src/channel.rs b/src/channel.rs new file mode 100644 index 0000000..1eda48c --- /dev/null +++ b/src/channel.rs @@ -0,0 +1,67 @@ +//! Inter-task communication channels. +//! +//! Each channel connects exactly two tasks: +//! - [`CommandChannel`]: USB → Radio (host commands) +//! - [`ResponseChannel`]: Radio → USB (firmware responses) +//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset) +//! - [`StatusWatch`]: Radio → Display (observable radio state) + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use embassy_sync::watch::Watch; + +use crate::protocol::{Command, RadioConfig, Response}; + +/// Display commands routed from usb_task to display_task. +#[derive(Debug, Clone, Copy, defmt::Format)] +pub enum DisplayCommand { + On, + Off, + Reset, +} + +/// USB-to-display command channel (depth 4: display commands are rare events). +pub type DisplayCommandChannel = Channel; + +/// Host-to-radio command channel (depth 16: buffer bursty host command sequences). +pub type CommandChannel = Channel; + +/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes). +pub type ResponseChannel = Channel; + +/// Observable radio status for the display task (2 receivers max). +/// Display always sees the latest state — intermediate updates may be +/// skipped, which is fine for a status display. +pub type StatusWatch = Watch; + +/// Current radio state exposed to observers (e.g. display). +#[derive(Debug, Clone, defmt::Format)] +pub struct RadioStatus { + pub state: RadioState, + pub config: Option, + pub rx_count: u32, + pub tx_count: u32, + pub last_rssi: Option, + pub last_snr: Option, +} + +/// Radio state machine states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)] +pub enum RadioState { + Idle, + Receiving, + Transmitting, +} + +impl Default for RadioStatus { + fn default() -> Self { + Self { + state: RadioState::Idle, + config: None, + rx_count: 0, + tx_count: 0, + last_rssi: None, + last_snr: None, + } + } +} diff --git a/src/display/mod.rs b/src/display/mod.rs new file mode 100644 index 0000000..50b4bdf --- /dev/null +++ b/src/display/mod.rs @@ -0,0 +1,5 @@ +//! OLED display driver with radio status dashboard. + +mod render; +mod task; +pub use task::display_task; diff --git a/src/display/render.rs b/src/display/render.rs new file mode 100644 index 0000000..034e027 --- /dev/null +++ b/src/display/render.rs @@ -0,0 +1,398 @@ +use core::fmt::Write; + +use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD}; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::BinaryColor; +use embedded_graphics::prelude::*; +use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle}; +use embedded_graphics::text::renderer::CharacterStyle; +use embedded_graphics::text::{Alignment, Text}; +use heapless::String; + +use crate::channel::{RadioState, RadioStatus}; +use crate::protocol::Bandwidth; + +/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar). +pub const RSSI_HISTORY_LEN: usize = 128; + +// Font metrics (FONT_6X10) +const CHAR_W: i32 = 6; +const FONT_H: i32 = 10; + +// Mode box: 2 chars + 1px padding each side +const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px + +// RSSI mapping range (dBm) +const RSSI_MIN: i16 = -120; +const RSSI_MAX: i16 = 0; + +/// Static board identity info for display rendering. +pub struct BoardInfo<'a> { + pub name: &'a str, + pub version: &'a str, + pub mac: &'a str, +} + +/// Render the active dashboard (shown when radio is in RX or TX mode). +pub fn dashboard( + target: &mut impl DrawTarget, + status: &RadioStatus, + rssi_history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], + rssi_count: usize, + current_slot_rssi: i16, + current_slot_tx: bool, + board: &BoardInfo, +) { + let bb = target.bounding_box(); + let w = bb.size.width as i32; + let h = bb.size.height as i32; + let title_x = MODE_BOX_W + 1; + let title_w = w - title_x; + let header_h = 2 * FONT_H; + let sep1_y = header_h + 3; // 3px gap below header text (clears descenders) + let info_y = sep1_y + 1; // 1px line, info text tight below separator + let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders) + let spark_top = sep2_y + 2; // 1px line + 1px gap above graph + let spark_h = h - spark_top; + let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN); + + let _ = target.clear(BinaryColor::Off); + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + let mut buf: String<32> = String::new(); + + // ── Mode box (left column, 2 rows tall) ───────────────────────── + let fill = PrimitiveStyle::with_fill(BinaryColor::On); + let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off); + inv_style.set_background_color(Some(BinaryColor::On)); + + match status.state { + RadioState::Receiving => { + // "RX" inverted on row 0 + Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32)) + .into_styled(fill) + .draw(target) + .ok(); + Text::new("RX", Point::new(1, FONT_H - 1), inv_style) + .draw(target) + .ok(); + } + RadioState::Transmitting => { + // "TX" inverted on row 1 + Rectangle::new( + Point::new(0, FONT_H), + Size::new(MODE_BOX_W as u32, FONT_H as u32), + ) + .into_styled(fill) + .draw(target) + .ok(); + Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style) + .draw(target) + .ok(); + } + RadioState::Idle => {} // Not shown on active screen + } + + // Vertical separator between mode box and title box + Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Title box (right of mode box) ─────────────────────────────── + let title_center_x = title_x + title_w / 2; + + // Row 0: "DongLoRa v{version}" + buf.clear(); + let _ = write!(buf, "DongLoRa v{}", board.version); + Text::with_alignment( + &buf, + Point::new(title_center_x, FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 1: radio settings "{freq}/{bw}/{sf}/{cr}" + if let Some(cfg) = status.config { + buf.clear(); + let freq_mhz = cfg.freq_hz / 1_000_000; + let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; + let bw_str = match cfg.bw { + Bandwidth::Khz7 => "7.8", + Bandwidth::Khz10 => "10.4", + Bandwidth::Khz15 => "15.6", + Bandwidth::Khz20 => "20.8", + Bandwidth::Khz31 => "31.2", + Bandwidth::Khz41 => "41.7", + Bandwidth::Khz62 => "62.5", + Bandwidth::Khz125 => "125", + Bandwidth::Khz250 => "250", + Bandwidth::Khz500 => "500", + }; + let _ = write!( + buf, + "{}.{:03}/{}/{}/{}", + freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr + ); + Text::with_alignment( + &buf, + Point::new(title_center_x, 2 * FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + } + + // ── First separator line ──────────────────────────────────────── + Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Info rows (centered) ──────────────────────────────────────── + let center_x = w / 2; + + // Row 2: packet counters + buf.clear(); + let _ = write!( + buf, + "RX:{} TX:{}", + compact_count(status.rx_count), + compact_count(status.tx_count) + ); + Text::with_alignment( + &buf, + Point::new(center_x, info_y + FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 3: RSSI + SNR + buf.clear(); + match (status.last_rssi, status.last_snr) { + (Some(rssi), Some(snr)) => { + let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr); + } + (Some(rssi), None) => { + let _ = write!(buf, "RSSI:{}dBm", rssi); + } + _ => { + let _ = write!(buf, "No signal"); + } + } + Text::with_alignment( + &buf, + Point::new(center_x, info_y + 2 * FONT_H - 1), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // ── Second separator line ─────────────────────────────────────── + Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(target) + .ok(); + + // ── Sparkline bar graph ───────────────────────────────────────── + if spark_h > 0 { + rssi_sparkline( + target, + rssi_history, + tx_history, + rssi_count, + current_slot_rssi, + current_slot_tx, + spark_top, + spark_h, + visible_bars, + ); + } +} + +/// Render the splash/waiting screen (shown when idle or no config). +pub fn splash( + target: &mut impl DrawTarget, + board: &BoardInfo, +) { + let bb = target.bounding_box(); + let w = bb.size.width as i32; + + let _ = target.clear(BinaryColor::Off); + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); + let mut buf: String<32> = String::new(); + + let center_x = w / 2; + + // Row 1: "DongLoRa" bold left, version small right + Text::new("DongLoRa", Point::new(4, 15), title_style) + .draw(target) + .ok(); + buf.clear(); + let _ = write!(buf, "v{}", board.version); + Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right) + .draw(target) + .ok(); + + // Row 2: board name + Text::with_alignment( + board.name, + Point::new(center_x, 28), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 3: MAC address + Text::with_alignment( + board.mac, + Point::new(center_x, 41), + style, + Alignment::Center, + ) + .draw(target) + .ok(); + + // Row 4: status + Text::with_alignment( + "Waiting for host...", + Point::new(center_x, 54), + style, + Alignment::Center, + ) + .draw(target) + .ok(); +} + +/// Format a u32 count compactly for display. +fn compact_count(n: u32) -> String<10> { + let mut s: String<10> = String::new(); + if n > 9_999_999 { + let _ = write!(s, "{}M", n / 1_000_000); + } else if n > 999_999 { + let _ = write!(s, "{}k", n / 1_000); + } else { + let _ = write!(s, "{}", n); + } + s +} + +/// Render the RSSI history as a bar-chart sparkline. +/// +/// The current (uncommitted) slot is rendered at the rightmost position +/// so that incoming packets appear on the graph immediately. +fn rssi_sparkline( + target: &mut impl DrawTarget, + history: &[i16; RSSI_HISTORY_LEN], + tx_history: &[bool; RSSI_HISTORY_LEN], + count: usize, + current_rssi: i16, + current_tx: bool, + spark_top: i32, + spark_h: i32, + visible_bars: usize, +) { + let live = current_rssi > RSSI_MIN || current_tx; + let committed = count.min(RSSI_HISTORY_LEN); + let hist_slots = if live { + committed.min(visible_bars.saturating_sub(1)) + } else { + committed.min(visible_bars) + }; + let total = hist_slots + if live { 1 } else { 0 }; + + if total == 0 { + return; + } + + let fill = PrimitiveStyle::with_fill(BinaryColor::On); + + // Draw committed history + for i in 0..hist_slots { + let idx = if count <= RSSI_HISTORY_LEN { + // Buffer hasn't wrapped: slots occupy indices 0..committed-1. + // Skip oldest entries that don't fit on screen. + let effective_bars = if live { visible_bars - 1 } else { visible_bars }; + i + committed.saturating_sub(effective_bars) + } else { + let start = count - RSSI_HISTORY_LEN; + let skip = RSSI_HISTORY_LEN.saturating_sub(if live { + visible_bars - 1 + } else { + visible_bars + }); + (start + skip + i) % RSSI_HISTORY_LEN + }; + let is_tx = tx_history[idx]; + let rssi = history[idx]; + + if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) { + let x = (visible_bars - total + i) as i32 * 2; + draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill); + } + } + + // Draw live (current) bar at the rightmost position + if live { + if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) { + let x = (visible_bars - 1) as i32 * 2; + draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill); + } + } +} + +/// Compute the pixel height for a sparkline bar, or None to skip. +fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option { + let h = if rssi <= RSSI_MIN { + if is_tx { + spark_h / 3 + } else { + return None; + } + } else { + let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); + ((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32 + }; + if h == 0 { None } else { Some(h) } +} + +/// Draw a single sparkline bar (solid for RX, dotted for TX). +fn draw_bar( + target: &mut impl DrawTarget, + x: i32, + bar_h: i32, + is_tx: bool, + spark_top: i32, + spark_h: i32, + fill: &PrimitiveStyle, +) { + let y = spark_top + spark_h - bar_h; + if is_tx { + for row in 0..bar_h { + if row % 2 == 0 { + Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) + .into_styled(*fill) + .draw(target) + .ok(); + } + } + } else { + Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) + .into_styled(*fill) + .draw(target) + .ok(); + } +} + +/// Clear the display (display-off state). +pub fn blank(target: &mut impl DrawTarget) { + let _ = target.clear(BinaryColor::Off); +} diff --git a/src/display/task.rs b/src/display/task.rs new file mode 100644 index 0000000..2abfc32 --- /dev/null +++ b/src/display/task.rs @@ -0,0 +1,239 @@ +use embassy_executor::task; +use embassy_futures::select::{select3, Either3}; +use embassy_time::Timer; +use ssd1306::mode::DisplayConfigAsync; +use ssd1306::prelude::DisplayRotation; +use ssd1306::size::DisplaySize128x64; +use ssd1306::{I2CDisplayInterface, Ssd1306Async}; + +use crate::board::DisplayParts; +use crate::channel::{ + DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch, +}; + +use crate::board::{Board, LoRaBoard}; + +use super::render::{self, RSSI_HISTORY_LEN}; + +const BOARD_NAME: &str = Board::NAME; + +/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history. +const SPARK_SLOT_MS: u64 = 1000; + +/// Sentinel: no packet received in this slot. Below SX1262 sensitivity +/// floor (-120 dBm), so it cannot be confused with a real RSSI value. +const NO_SIGNAL: i16 = -121; + +struct DisplayState { + rssi_history: [i16; RSSI_HISTORY_LEN], + tx_history: [bool; RSSI_HISTORY_LEN], + rssi_count: usize, + current_slot_rssi: i16, + current_slot_tx: bool, + display_on: bool, + disconnected: bool, + last_status: RadioStatus, +} + +impl DisplayState { + fn new() -> Self { + Self { + rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], + tx_history: [false; RSSI_HISTORY_LEN], + rssi_count: 0, + current_slot_rssi: NO_SIGNAL, + current_slot_tx: false, + display_on: true, + disconnected: true, + last_status: RadioStatus::default(), + } + } + + /// Record an RSSI sample in the current time slot (keep best). + fn record_rssi(&mut self, rssi: i16) { + if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi { + self.current_slot_rssi = rssi; + } + } + + /// Mark the current time slot as having a transmit. + fn record_tx(&mut self) { + self.current_slot_tx = true; + } + + /// Advance to the next time slot, committing the current slot's data. + fn advance_slot(&mut self) { + let idx = self.rssi_count % RSSI_HISTORY_LEN; + self.rssi_history[idx] = self.current_slot_rssi; + self.tx_history[idx] = self.current_slot_tx; + self.rssi_count += 1; + self.current_slot_rssi = NO_SIGNAL; + self.current_slot_tx = false; + } + + /// Whether the active dashboard should be shown (RX or TX mode). + fn is_active(&self) -> bool { + matches!( + self.last_status.state, + RadioState::Receiving | RadioState::Transmitting + ) + } +} + +#[task] +pub async fn display_task( + parts: DisplayParts, + status: &'static StatusWatch, + display_commands: &'static DisplayCommandChannel, +) { + // Format MAC address as "XX:XX:XX:XX:XX:XX" + let mut mac_str: heapless::String<18> = heapless::String::new(); + let m = parts.mac; + let _ = core::fmt::Write::write_fmt( + &mut mac_str, + format_args!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + m[0], m[1], m[2], m[3], m[4], m[5] + ), + ); + + let board_info = render::BoardInfo { + name: BOARD_NAME, + version: env!("CARGO_PKG_VERSION"), + mac: &mac_str, + }; + + let interface = I2CDisplayInterface::new(parts.i2c); + let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0) + .into_buffered_graphics_mode(); + + if display.init().await.is_err() { + defmt::error!("SSD1306 init failed"); + return; + } + if display + .set_brightness(ssd1306::prelude::Brightness::BRIGHTEST) + .await + .is_err() + { + defmt::warn!("display brightness set failed"); + } + + let mut state = DisplayState::new(); + let Some(mut receiver) = status.receiver() else { + defmt::error!("no watch receiver available for display"); + return; + }; + + // Show splash/waiting screen + render_splash(&mut display, &board_info).await; + + loop { + match select3( + receiver.changed(), + display_commands.receive(), + Timer::after_millis(SPARK_SLOT_MS), + ) + .await + { + Either3::First(radio_status) => { + if state.disconnected { + continue; + } + if let Some(rssi) = radio_status.last_rssi { + if radio_status.rx_count != state.last_status.rx_count { + state.record_rssi(rssi); + } + } + if radio_status.tx_count != state.last_status.tx_count { + state.record_tx(); + } + state.last_status = radio_status; + + if state.display_on { + render_current(&mut display, &state, &board_info).await; + } + } + Either3::Second(cmd) => match cmd { + DisplayCommand::Off => { + state.disconnected = false; + state.display_on = false; + render::blank(&mut display); + let _ = display.flush().await; + } + DisplayCommand::On => { + state.disconnected = false; + state.display_on = true; + if let Some(s) = receiver.try_get() { + state.last_status = s; + } + render_current(&mut display, &state, &board_info).await; + } + DisplayCommand::Reset => { + state = DisplayState::new(); + render_splash(&mut display, &board_info).await; + } + }, + Either3::Third(()) => { + // Timer tick: advance sparkline slot + state.advance_slot(); + if state.display_on && state.is_active() { + render_dashboard(&mut display, &state, &board_info).await; + } + } + } + } +} + +// The concrete display type is verbose but avoids making this generic +// over both DrawTarget and the SSD1306-specific flush method. +type Display = ssd1306::Ssd1306Async< + I, + DisplaySize128x64, + ssd1306::mode::BufferedGraphicsModeAsync, +>; + +/// Render the appropriate screen (active dashboard or splash) and flush. +async fn render_current( + display: &mut Display, + state: &DisplayState, + board: &render::BoardInfo<'_>, +) where + I: display_interface::AsyncWriteOnlyDataCommand, +{ + if state.is_active() { + render_dashboard(display, state, board).await; + } else { + render_splash(display, board).await; + } +} + +/// Render the active dashboard and flush. +async fn render_dashboard( + display: &mut Display, + state: &DisplayState, + board: &render::BoardInfo<'_>, +) where + I: display_interface::AsyncWriteOnlyDataCommand, +{ + render::dashboard( + display, + &state.last_status, + &state.rssi_history, + &state.tx_history, + state.rssi_count, + state.current_slot_rssi, + state.current_slot_tx, + board, + ); + let _ = display.flush().await; +} + +/// Render the splash/waiting screen and flush. +async fn render_splash(display: &mut Display, board: &render::BoardInfo<'_>) +where + I: display_interface::AsyncWriteOnlyDataCommand, +{ + render::splash(display, board); + let _ = display.flush().await; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..86163ee --- /dev/null +++ b/src/main.rs @@ -0,0 +1,99 @@ +//! DongLoRa firmware — transparent LoRa radio over USB. +//! +//! Three async tasks communicate via static channels: +//! +//! ```text +//! usb_task ──Command──► radio_task ──► SX1262 +//! ◄──Response── │ +//! StatusWatch +//! ▼ +//! display_task (optional) +//! ``` +//! +//! The host drives everything. The radio idles until commanded. + +#![cfg_attr(not(test), no_std)] +#![cfg_attr(not(test), no_main)] + +#[cfg(not(test))] +mod board; +#[cfg(not(test))] +mod channel; +#[cfg(not(test))] +mod display; +mod protocol; +#[cfg(not(test))] +mod radio; +#[cfg(not(test))] +mod usb; + +#[cfg(not(test))] +use embassy_executor::Spawner; + +#[cfg(not(test))] +use crate::board::LoRaBoard; +#[cfg(not(test))] +use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch}; + +#[cfg(not(test))] +cfg_if::cfg_if! { + if #[cfg(feature = "rak_wisblock_4631")] { + use panic_probe as _; + } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] { + use esp_backtrace as _; + use esp_println as _; + } +} + +#[cfg(not(test))] +static COMMANDS: CommandChannel = CommandChannel::new(); +#[cfg(not(test))] +static RESPONSES: ResponseChannel = ResponseChannel::new(); +#[cfg(not(test))] +static STATUS: StatusWatch = StatusWatch::new(); +#[cfg(not(test))] +static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new(); + +#[cfg(not(test))] +cfg_if::cfg_if! { + if #[cfg(feature = "rak_wisblock_4631")] { + #[embassy_executor::main] + async fn main(spawner: Spawner) { + run(spawner).await; + } + } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v4"))] { + #[esp_rtos::main] + async fn main(spawner: Spawner) { + run(spawner).await; + } + } +} + +#[cfg(not(test))] +async fn run(spawner: Spawner) { + let board = ::init(); + let mac = board::Board::mac_address(); + let (radio, usb, display) = board.into_parts(); + + let has_display = display.is_some(); + + spawner + .spawn(radio::radio_task(radio, &COMMANDS, &RESPONSES, &STATUS)) + .expect("spawn radio_task"); + spawner + .spawn(usb::usb_task( + usb, + &COMMANDS, + &RESPONSES, + &DISPLAY_COMMANDS, + has_display, + mac, + )) + .expect("spawn usb_task"); + + if let Some(dp) = display { + spawner + .spawn(display::display_task(dp, &STATUS, &DISPLAY_COMMANDS)) + .expect("spawn display_task"); + } +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..f62c9eb --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,661 @@ +//! Wire protocol types and fixed-size little-endian serialization. +//! +//! Every integer is fixed-width LE. No varints, no zigzag. +//! See `docs/PROTOCOL.md` for the complete specification. + +use heapless::Vec; + +/// Maximum LoRa payload size in bytes. +pub const MAX_PAYLOAD: usize = 256; + +/// RadioConfig wire size (fixed). +pub const RADIO_CONFIG_SIZE: usize = 13; + +/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power. +pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire + +/// Sentinel value for `preamble_len`: use the firmware default (16 symbols). +pub const PREAMBLE_DEFAULT: u16 = 0; + + +/// LoRa signal bandwidth. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(test), derive(defmt::Format))] +#[repr(u8)] +pub enum Bandwidth { + Khz7 = 0, + Khz10 = 1, + Khz15 = 2, + Khz20 = 3, + Khz31 = 4, + Khz41 = 5, + Khz62 = 6, + Khz125 = 7, + Khz250 = 8, + Khz500 = 9, +} + +impl Bandwidth { + fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Khz7), + 1 => Some(Self::Khz10), + 2 => Some(Self::Khz15), + 3 => Some(Self::Khz20), + 4 => Some(Self::Khz31), + 5 => Some(Self::Khz41), + 6 => Some(Self::Khz62), + 7 => Some(Self::Khz125), + 8 => Some(Self::Khz250), + 9 => Some(Self::Khz500), + _ => None, + } + } +} + +/// Complete LoRa radio configuration. +/// +/// Wire layout (13 bytes, all little-endian): +/// ```text +/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1] +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(test), derive(defmt::Format))] +pub struct RadioConfig { + /// Frequency in Hz (150_000_000 - 960_000_000 for SX1262). + pub freq_hz: u32, + pub bw: Bandwidth, + /// Spreading factor (5-12). + pub sf: u8, + /// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8. + pub cr: u8, + pub sync_word: u16, + /// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the + /// board's maximum. + pub tx_power_dbm: i8, + /// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for + /// the firmware default (16 symbols). Valid range: 6–65535. + pub preamble_len: u16, + /// Channel Activity Detection (listen-before-talk) before TX. + /// 0 = disabled, non-zero = enabled. Default: 1 (enabled). + pub cad: u8, +} + +impl RadioConfig { + /// Validate fields against hardware limits. + pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> { + if !(150_000_000..=960_000_000).contains(&self.freq_hz) { + return Err("frequency out of range (150-960 MHz)"); + } + if !(5..=12).contains(&self.sf) { + return Err("spreading factor out of range (5-12)"); + } + if !(5..=8).contains(&self.cr) { + return Err("coding rate out of range (5-8)"); + } + if self.tx_power_dbm != TX_POWER_MAX + && !(power_range.0..=power_range.1).contains(&self.tx_power_dbm) + { + return Err("TX power out of range for this board"); + } + if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 { + return Err("preamble length too short (min 6)"); + } + Ok(()) + } + + /// Resolve sentinel values to concrete defaults. + pub fn resolve(mut self, power_range: (i8, i8)) -> Self { + if self.tx_power_dbm == TX_POWER_MAX { + self.tx_power_dbm = power_range.1; + } + if self.preamble_len == PREAMBLE_DEFAULT { + self.preamble_len = 16; + } + self + } + + /// Whether CAD (listen-before-talk) is enabled. + pub fn cad_enabled(&self) -> bool { + self.cad != 0 + } + + /// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13). + pub fn write_to(self, buf: &mut [u8]) -> usize { + buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes()); + buf[4] = self.bw as u8; + buf[5] = self.sf; + buf[6] = self.cr; + buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes()); + buf[9] = self.tx_power_dbm as u8; + buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes()); + buf[12] = self.cad; + RADIO_CONFIG_SIZE + } + + /// Deserialize from fixed-size LE bytes. + pub fn from_bytes(buf: &[u8]) -> Option { + if buf.len() < RADIO_CONFIG_SIZE { + return None; + } + Some(Self { + freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?), + bw: Bandwidth::from_u8(buf[4])?, + sf: buf[5], + cr: buf[6], + sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?), + tx_power_dbm: buf[9] as i8, + preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?), + cad: buf[12], + }) + } +} + +/// Host → firmware commands. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq)] +pub enum Command { + Ping, + GetConfig, + SetConfig(RadioConfig), + StartRx, + StopRx, + Transmit { + config: Option, + payload: Vec, + }, + DisplayOn, + DisplayOff, + GetMac, +} + +impl Command { + /// Deserialize from a COBS-decoded frame. + pub fn from_bytes(buf: &[u8]) -> Option { + let tag = *buf.first()?; + let rest = &buf[1..]; + match tag { + 0 => Some(Self::Ping), + 1 => Some(Self::GetConfig), + 2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)), + 3 => Some(Self::StartRx), + 4 => Some(Self::StopRx), + 5 => { + // Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload + if rest.is_empty() { + return None; + } + let (config, pos) = if rest[0] == 0 { + (None, 1) + } else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE { + (Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE) + } else { + return None; + }; + if rest.len() < pos + 2 { + return None; + } + let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize; + let data_start = pos + 2; + if rest.len() < data_start + len { + return None; + } + let mut payload = Vec::new(); + let _ = payload.extend_from_slice(&rest[data_start..data_start + len]); + Some(Self::Transmit { config, payload }) + } + 6 => Some(Self::DisplayOn), + 7 => Some(Self::DisplayOff), + 8 => Some(Self::GetMac), + _ => None, + } + } +} + +/// Firmware → host responses. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq)] +pub enum Response { + Pong, + Config(RadioConfig), + RxPacket { + rssi: i16, + snr: i16, + payload: Vec, + }, + TxDone, + Ok, + Error(ErrorCode), + MacAddress([u8; 6]), +} + +impl Response { + /// Serialize to fixed-size LE bytes. Returns number of bytes written. + pub fn write_to(self, buf: &mut [u8]) -> usize { + match self { + Self::Pong => { + buf[0] = 0; + 1 + } + Self::Config(cfg) => { + buf[0] = 1; + 1 + cfg.write_to(&mut buf[1..]) + } + Self::RxPacket { rssi, snr, payload } => { + buf[0] = 2; + buf[1..3].copy_from_slice(&rssi.to_le_bytes()); + buf[3..5].copy_from_slice(&snr.to_le_bytes()); + buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes()); + buf[7..7 + payload.len()].copy_from_slice(&payload); + 7 + payload.len() + } + Self::TxDone => { + buf[0] = 3; + 1 + } + Self::Ok => { + buf[0] = 4; + 1 + } + Self::Error(code) => { + buf[0] = 5; + buf[1] = code as u8; + 2 + } + Self::MacAddress(mac) => { + buf[0] = 6; + buf[1..7].copy_from_slice(&mac); + 7 + } + } + } +} + +/// Error codes reported to the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(test), derive(defmt::Format))] +#[repr(u8)] +#[allow(dead_code)] // CrcError reserved per PROTOCOL.md (error code 3) +pub enum ErrorCode { + InvalidConfig = 0, + RadioBusy = 1, + TxTimeout = 2, + CrcError = 3, + NotConfigured = 4, + NoDisplay = 5, +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config() -> RadioConfig { + RadioConfig { + freq_hz: 915_000_000, + bw: Bandwidth::Khz125, + sf: 7, + cr: 5, + sync_word: 0x3444, + tx_power_dbm: 22, + preamble_len: 16, + cad: 1, + } + } + + // ── RadioConfig roundtrip ─────────────────────────────────────── + + #[test] + fn radio_config_roundtrip() { + let cfg = make_config(); + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + let n = cfg.write_to(&mut buf); + assert_eq!(n, RADIO_CONFIG_SIZE); + assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); + } + + #[test] + fn radio_config_roundtrip_all_bandwidths() { + for bw_val in 0u8..=9 { + let bw = Bandwidth::from_u8(bw_val).unwrap(); + let cfg = RadioConfig { + freq_hz: 433_000_000, + bw, + sf: 12, + cr: 8, + sync_word: 0x1234, + tx_power_dbm: -9, + }; + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + cfg.write_to(&mut buf); + assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); + } + } + + #[test] + fn radio_config_roundtrip_negative_power() { + let cfg = RadioConfig { + tx_power_dbm: TX_POWER_MAX, + ..make_config() + }; + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + cfg.write_to(&mut buf); + assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); + } + + #[test] + fn radio_config_from_short_buffer() { + let buf = [0u8; RADIO_CONFIG_SIZE - 1]; + assert!(RadioConfig::from_bytes(&buf).is_none()); + } + + #[test] + fn radio_config_from_empty_buffer() { + assert!(RadioConfig::from_bytes(&[]).is_none()); + } + + #[test] + fn radio_config_invalid_bandwidth() { + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + make_config().write_to(&mut buf); + buf[4] = 255; // invalid bandwidth + assert!(RadioConfig::from_bytes(&buf).is_none()); + } + + // ── RadioConfig::validate ─────────────────────────────────────── + + #[test] + fn validate_freq_boundaries() { + let power_range = (-9, 22); + let base = make_config(); + + let mut cfg = RadioConfig { freq_hz: 150_000_000, ..base }; + assert!(cfg.validate(power_range).is_ok()); + + cfg.freq_hz = 960_000_000; + assert!(cfg.validate(power_range).is_ok()); + + cfg.freq_hz = 149_999_999; + assert!(cfg.validate(power_range).is_err()); + + cfg.freq_hz = 960_000_001; + assert!(cfg.validate(power_range).is_err()); + } + + #[test] + fn validate_sf_boundaries() { + let power_range = (-9, 22); + let base = make_config(); + + for sf in 5..=12 { + assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok()); + } + assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err()); + assert!(RadioConfig { sf: 13, ..base }.validate(power_range).is_err()); + } + + #[test] + fn validate_cr_boundaries() { + let power_range = (-9, 22); + let base = make_config(); + + for cr in 5..=8 { + assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok()); + } + assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err()); + assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err()); + } + + #[test] + fn validate_tx_power_max_sentinel() { + let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() }; + assert!(cfg.validate((-9, 22)).is_ok()); + } + + #[test] + fn validate_tx_power_out_of_range() { + let cfg = RadioConfig { tx_power_dbm: 23, ..make_config() }; + assert!(cfg.validate((-9, 22)).is_err()); + + let cfg = RadioConfig { tx_power_dbm: -10, ..make_config() }; + assert!(cfg.validate((-9, 22)).is_err()); + } + + #[test] + fn validate_preamble_default_sentinel() { + let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() }; + assert!(cfg.validate((-9, 22)).is_ok()); + } + + #[test] + fn validate_preamble_boundaries() { + let power_range = (-9, 22); + let base = make_config(); + + assert!(RadioConfig { preamble_len: 6, ..base }.validate(power_range).is_ok()); + assert!(RadioConfig { preamble_len: 5, ..base }.validate(power_range).is_err()); + } + + // ── RadioConfig::resolve ────────────────────────────────────── + + #[test] + fn resolve_power_max_sentinel() { + let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() }; + assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22); + } + + #[test] + fn resolve_power_explicit_unchanged() { + let cfg = RadioConfig { tx_power_dbm: 10, ..make_config() }; + assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10); + } + + #[test] + fn resolve_preamble_default() { + let cfg = RadioConfig { preamble_len: PREAMBLE_DEFAULT, ..make_config() }; + assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16); + } + + #[test] + fn resolve_preamble_explicit_unchanged() { + let cfg = RadioConfig { preamble_len: 32, ..make_config() }; + assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32); + } + + // ── Command::from_bytes ───────────────────────────────────────── + + #[test] + fn command_ping() { + assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping)); + } + + #[test] + fn command_get_config() { + assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig)); + } + + #[test] + fn command_set_config() { + let cfg = make_config(); + let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE]; + buf[0] = 2; + cfg.write_to(&mut buf[1..]); + assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg))); + } + + #[test] + fn command_start_stop_rx() { + assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx)); + assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx)); + } + + #[test] + fn command_transmit_no_config() { + let payload = b"hello"; + let mut buf = [0u8; 64]; + buf[0] = 5; // tag + buf[1] = 0; // has_config = false + buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes()); + buf[4..9].copy_from_slice(payload); + + match Command::from_bytes(&buf[..9]).unwrap() { + Command::Transmit { config, payload: p } => { + assert!(config.is_none()); + assert_eq!(p.as_slice(), b"hello"); + } + _ => panic!("expected Transmit"), + } + } + + #[test] + fn command_transmit_with_config() { + let cfg = make_config(); + let mut buf = [0u8; 64]; + buf[0] = 5; // tag + buf[1] = 1; // has_config = true + cfg.write_to(&mut buf[2..]); + let payload = b"test"; + let pos = 2 + RADIO_CONFIG_SIZE; + buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes()); + buf[pos + 2..pos + 6].copy_from_slice(payload); + + match Command::from_bytes(&buf[..pos + 6]).unwrap() { + Command::Transmit { config, payload: p } => { + assert_eq!(config, Some(cfg)); + assert_eq!(p.as_slice(), b"test"); + } + _ => panic!("expected Transmit"), + } + } + + #[test] + fn command_transmit_empty_payload() { + let mut buf = [0u8; 4]; + buf[0] = 5; // tag + buf[1] = 0; // has_config = false + buf[2..4].copy_from_slice(&0u16.to_le_bytes()); + + match Command::from_bytes(&buf).unwrap() { + Command::Transmit { config, payload } => { + assert!(config.is_none()); + assert!(payload.is_empty()); + } + _ => panic!("expected Transmit"), + } + } + + #[test] + fn command_transmit_truncated() { + // Tag only — missing has_config byte + assert!(Command::from_bytes(&[5]).is_none()); + + // has_config=1 but no config bytes + assert!(Command::from_bytes(&[5, 1]).is_none()); + + // has_config=0 but no length bytes + assert!(Command::from_bytes(&[5, 0]).is_none()); + + // has_config=0, length says 5 but only 2 bytes of payload + let mut buf = [0u8; 6]; + buf[0] = 5; + buf[1] = 0; + buf[2..4].copy_from_slice(&5u16.to_le_bytes()); + buf[4] = 0xAA; + buf[5] = 0xBB; + assert!(Command::from_bytes(&buf).is_none()); + } + + #[test] + fn command_display_and_mac() { + assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn)); + assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff)); + assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac)); + } + + #[test] + fn command_invalid_tag() { + assert!(Command::from_bytes(&[9]).is_none()); + assert!(Command::from_bytes(&[255]).is_none()); + } + + #[test] + fn command_empty_buffer() { + assert!(Command::from_bytes(&[]).is_none()); + } + + // ── Response::write_to ────────────────────────────────────────── + + #[test] + fn response_pong() { + let mut buf = [0u8; 1]; + assert_eq!(Response::Pong.write_to(&mut buf), 1); + assert_eq!(buf[0], 0); + } + + #[test] + fn response_config() { + let cfg = make_config(); + let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE]; + let n = Response::Config(cfg).write_to(&mut buf); + assert_eq!(n, 1 + RADIO_CONFIG_SIZE); + assert_eq!(buf[0], 1); + assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg)); + } + + #[test] + fn response_rx_packet() { + let mut payload = Vec::new(); + let _ = payload.extend_from_slice(b"data"); + let mut buf = [0u8; 64]; + let n = Response::RxPacket { rssi: -80, snr: 10, payload }.write_to(&mut buf); + assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4) + assert_eq!(buf[0], 2); + assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80); + assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10); + assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4); + assert_eq!(&buf[7..11], b"data"); + } + + #[test] + fn response_tx_done() { + let mut buf = [0u8; 1]; + assert_eq!(Response::TxDone.write_to(&mut buf), 1); + assert_eq!(buf[0], 3); + } + + #[test] + fn response_ok() { + let mut buf = [0u8; 1]; + assert_eq!(Response::Ok.write_to(&mut buf), 1); + assert_eq!(buf[0], 4); + } + + #[test] + fn response_error_codes() { + let mut buf = [0u8; 2]; + for (code, val) in [ + (ErrorCode::InvalidConfig, 0), + (ErrorCode::RadioBusy, 1), + (ErrorCode::TxTimeout, 2), + (ErrorCode::CrcError, 3), + (ErrorCode::NotConfigured, 4), + (ErrorCode::NoDisplay, 5), + ] { + let n = Response::Error(code).write_to(&mut buf); + assert_eq!(n, 2); + assert_eq!(buf[0], 5); + assert_eq!(buf[1], val); + } + } + + #[test] + fn response_mac_address() { + let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + let mut buf = [0u8; 7]; + let n = Response::MacAddress(mac).write_to(&mut buf); + assert_eq!(n, 7); + assert_eq!(buf[0], 6); + assert_eq!(&buf[1..7], &mac); + } +} diff --git a/src/radio/mod.rs b/src/radio/mod.rs new file mode 100644 index 0000000..6e5a859 --- /dev/null +++ b/src/radio/mod.rs @@ -0,0 +1,4 @@ +//! LoRa radio control via SX1262. + +mod task; +pub use task::radio_task; diff --git a/src/radio/task.rs b/src/radio/task.rs new file mode 100644 index 0000000..ed2b8f9 --- /dev/null +++ b/src/radio/task.rs @@ -0,0 +1,376 @@ +//! LoRa radio task: SX1262 state machine driven by host commands. +//! +//! Owns the radio peripheral exclusively. Receives [`Command`]s from the USB +//! task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s back. +//! Publishes [`RadioStatus`] to the display task via a watch channel. +//! +//! # State machine +//! +//! ```text +//! Idle ──StartRx──► Receiving ──StopRx──► Idle +//! │ │ +//! └──Transmit──► Transmitting ──TxDone──► (previous state) +//! ``` +//! +//! # Invariants +//! +//! - This task never panics. All errors are reported to the host or logged. +//! - Config is validated before use (see [`RadioConfig::validate`]). + +use defmt::{error, info, warn}; +use embassy_executor::task; +use embassy_futures::select::{select, Either}; +use embassy_time::Delay; +use lora_phy::mod_params::RadioError; +use lora_phy::{LoRa, RxMode}; + +use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts}; +use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch}; +use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response}; + +const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD; +// TX power range comes from the board trait: Board::TX_POWER_RANGE + +type Radio = LoRa; + +// ── Fixed LoRa radio parameters ───────────────────────────────────── +// +// These are intentionally not host-configurable. They are sensible +// defaults for a general-purpose LoRa pipe. Making them configurable +// would add protocol complexity for marginal benefit. + +/// Explicit header mode (variable-length packets). +const IMPLICIT_HEADER: bool = false; + +/// Enable CRC on received/transmitted packets. +const CRC_ON: bool = true; + +/// Standard IQ polarity (not inverted). +const IQ_INVERTED: bool = false; + +// ── Task entry point ──────────────────────────────────────────────── + +#[task] +pub async fn radio_task( + parts: RadioParts, + commands: &'static CommandChannel, + responses: &'static ResponseChannel, + status: &'static StatusWatch, +) { + let mut state = RadioStatus::default(); + status.sender().send(state.clone()); + + let mut lora = match LoRa::new(parts.driver, false, parts.delay).await { + Ok(l) => l, + Err(e) => { + error!("radio init failed: {}", e); + responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + // Radio is non-functional — respond to commands but can't do RF. + loop { + let cmd = commands.receive().await; + if let Command::Ping = cmd { + responses.send(Response::Pong).await; + } else { + responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + } + } + } + }; + + info!("radio initialized"); + let mut rx_buf = [0u8; MAX_PAYLOAD]; + + loop { + if state.state == RadioState::Receiving { + // Defensive: config must be Some when Receiving. If not, recover. + let cfg = match state.config { + Some(c) => c, + None => { + warn!("BUG: receiving without config, returning to idle"); + state.state = RadioState::Idle; + status.sender().send(state.clone()); + continue; + } + }; + + match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await { + Either::First(rx_result) => match rx_result { + Ok((len, pkt_status)) => { + state.rx_count = state.rx_count.wrapping_add(1); + state.last_rssi = Some(pkt_status.rssi); + state.last_snr = Some(pkt_status.snr); + status.sender().send(state.clone()); + + let copy_len = (len as usize).min(MAX_PAYLOAD); + if (len as usize) > copy_len { + warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD); + } + let mut payload = heapless::Vec::new(); + // copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail. + let _ = payload.extend_from_slice(&rx_buf[..copy_len]); + + responses + .send(Response::RxPacket { + rssi: pkt_status.rssi, + snr: pkt_status.snr, + payload, + }) + .await; + + if let Err(e) = start_rx(&mut lora, &cfg).await { + warn!("restart RX failed: {}", e); + state.state = RadioState::Idle; + status.sender().send(state.clone()); + responses + .send(Response::Error(ErrorCode::RadioBusy)) + .await; + } + } + Err(e) => { + warn!("RX error: {}", e); + if start_rx(&mut lora, &cfg).await.is_err() { + state.state = RadioState::Idle; + status.sender().send(state.clone()); + responses + .send(Response::Error(ErrorCode::RadioBusy)) + .await; + } + } + }, + Either::Second(cmd) => { + handle_cmd(cmd, &mut lora, &mut state, responses, status).await; + } + } + } else { + let cmd = commands.receive().await; + handle_cmd(cmd, &mut lora, &mut state, responses, status).await; + } + } +} + +// ── Command handler ───────────────────────────────────────────────── + +async fn handle_cmd( + cmd: Command, + lora: &mut Radio, + state: &mut RadioStatus, + responses: &ResponseChannel, + status: &StatusWatch, +) { + match cmd { + Command::Ping => { + responses.send(Response::Pong).await; + } + Command::GetConfig => { + if let Some(cfg) = state.config { + responses.send(Response::Config(cfg)).await; + } else { + responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + } + } + Command::SetConfig(cfg) => { + if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { + warn!("SetConfig rejected: {}", reason); + responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + } else { + // Resolve TX_POWER_MAX sentinel to board's actual max + state.config = Some(cfg.resolve(Board::TX_POWER_RANGE)); + status.sender().send(state.clone()); + responses.send(Response::Ok).await; + } + } + Command::StartRx => { + if let Some(cfg) = state.config { + match start_rx(lora, &cfg).await { + Ok(()) => { + state.state = RadioState::Receiving; + status.sender().send(state.clone()); + responses.send(Response::Ok).await; + } + Err(e) => { + warn!("StartRx failed: {}", e); + responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + } + } + } else { + responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + } + } + Command::StopRx => { + // Best-effort: if standby fails, radio is in unknown state + // but there's nothing useful we can do except continue. + let _ = lora.enter_standby().await; + state.state = RadioState::Idle; + status.sender().send(state.clone()); + responses.send(Response::Ok).await; + } + Command::DisplayOn | Command::DisplayOff | Command::GetMac => {} + Command::Transmit { config, payload } => { + let tx_config = config.map(|c| c.resolve(Board::TX_POWER_RANGE)).or(state.config); + if let Some(cfg) = tx_config { + if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { + warn!("TX config rejected: {}", reason); + responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + return; + } + + let was_receiving = state.state == RadioState::Receiving; + state.state = RadioState::Transmitting; + status.sender().send(state.clone()); + + match do_tx(lora, &cfg, &payload).await { + Ok(()) => { + state.tx_count = state.tx_count.wrapping_add(1); + responses.send(Response::TxDone).await; + } + Err(e) => { + warn!("TX failed: {}", e); + responses + .send(Response::Error(ErrorCode::TxTimeout)) + .await; + } + } + + // Restore previous state. If we were receiving, restart RX + // using the stored config — NOT the per-packet TX override. + state.state = if was_receiving { + // state.config is always Some when was_receiving (invariant). + let rx_cfg = state.config.unwrap_or(cfg); + match start_rx(lora, &rx_cfg).await { + Ok(()) => RadioState::Receiving, + Err(e) => { + warn!("post-TX RX restart failed: {}", e); + RadioState::Idle + } + } + } else { + RadioState::Idle + }; + status.sender().send(state.clone()); + } else { + responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + } + } + } +} + +// ── LoRa helpers ──────────────────────────────────────────────────── + +fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth { + use lora_phy::mod_params::Bandwidth::*; + match bw { + protocol::Bandwidth::Khz7 => _7KHz, + protocol::Bandwidth::Khz10 => _10KHz, + protocol::Bandwidth::Khz15 => _15KHz, + protocol::Bandwidth::Khz20 => _20KHz, + protocol::Bandwidth::Khz31 => _31KHz, + protocol::Bandwidth::Khz41 => _41KHz, + protocol::Bandwidth::Khz62 => _62KHz, + protocol::Bandwidth::Khz125 => _125KHz, + protocol::Bandwidth::Khz250 => _250KHz, + protocol::Bandwidth::Khz500 => _500KHz, + } +} + +fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor { + use lora_phy::mod_params::SpreadingFactor::*; + match sf { + 5 => _5, + 6 => _6, + 7 => _7, + 8 => _8, + 9 => _9, + 10 => _10, + 11 => _11, + 12 => _12, + _ => { + warn!("BUG: invalid SF {} passed validation", sf); + _7 + } + } +} + +fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate { + use lora_phy::mod_params::CodingRate::*; + match cr { + 5 => _4_5, + 6 => _4_6, + 7 => _4_7, + 8 => _4_8, + _ => { + warn!("BUG: invalid CR {} passed validation", cr); + _4_5 + } + } +} + +/// Create modulation parameters from a validated config. +fn modulation_params( + lora: &mut Radio, + cfg: &RadioConfig, +) -> Result { + lora.create_modulation_params(to_sf(cfg.sf), to_bw(cfg.bw), to_cr(cfg.cr), cfg.freq_hz) +} + +async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> { + let mdltn = modulation_params(lora, cfg)?; + let pkt = lora.create_rx_packet_params( + cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn, + )?; + lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await +} + +async fn rx_once( + lora: &mut Radio, + cfg: &RadioConfig, + buf: &mut [u8], +) -> Result<(u8, lora_phy::mod_params::PacketStatus), RadioError> { + let mdltn = modulation_params(lora, cfg)?; + let pkt = lora.create_rx_packet_params( + cfg.preamble_len, IMPLICIT_HEADER, MAX_PAYLOAD as u8, CRC_ON, IQ_INVERTED, &mdltn, + )?; + lora.rx(&pkt, buf).await +} + +/// Maximum CAD attempts before transmitting anyway. +const CAD_MAX_RETRIES: u8 = 10; + +async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> { + let mdltn = modulation_params(lora, cfg)?; + + // Listen-before-talk: check channel is clear before transmitting. + if cfg.cad_enabled() { + lora.prepare_for_cad(&mdltn).await?; + for _ in 0..CAD_MAX_RETRIES { + if !lora.cad(&mdltn).await? { + break; // Channel is clear + } + // Channel busy — wait briefly and retry + embassy_time::Timer::after_millis(10).await; + lora.prepare_for_cad(&mdltn).await?; + } + } + + let mut tx_pkt = lora.create_tx_packet_params( + cfg.preamble_len, IMPLICIT_HEADER, CRC_ON, IQ_INVERTED, &mdltn, + )?; + lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload) + .await?; + lora.tx().await +} diff --git a/src/usb/mod.rs b/src/usb/mod.rs new file mode 100644 index 0000000..60d99c5 --- /dev/null +++ b/src/usb/mod.rs @@ -0,0 +1,4 @@ +//! USB CDC-ACM interface with COBS-framed command/response protocol. + +mod task; +pub use task::usb_task; diff --git a/src/usb/task.rs b/src/usb/task.rs new file mode 100644 index 0000000..e005581 --- /dev/null +++ b/src/usb/task.rs @@ -0,0 +1,234 @@ +//! USB CDC-ACM task: COBS-framed fixed-size LE command/response protocol. +//! +//! Reads COBS frames from USB, decodes them, parses commands, routes them +//! to the radio or display task, and sends COBS-framed responses back. + +use defmt::warn; +use embassy_executor::task; +use embassy_futures::join::join; +use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State}; +use embassy_usb::Builder; +use static_cell::StaticCell; + +use crate::channel::{ + CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel, +}; +use crate::protocol::{Command, ErrorCode, Response}; + +const MAX_FRAME: usize = 512; +const MAX_PACKET: usize = 64; + +// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes. +// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room. +const _: () = assert!( + MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64, + "MAX_FRAME too small for max payload + COBS overhead" +); + +#[task] +pub async fn usb_task( + parts: crate::board::UsbParts, + commands: &'static CommandChannel, + responses: &'static ResponseChannel, + display_commands: &'static DisplayCommandChannel, + has_display: bool, + mac: [u8; 6], +) { + // ── USB device configuration ──────────────────────────────────── + // VID 0x1209: pid.codes open-source USB vendor ID + // PID 0x5741: DongLoRa product ID + // Class 0xEF/0x02/0x01: Miscellaneous / Interface Association Descriptor + let mut config = embassy_usb::Config::new(0x1209, 0x5741); + config.manufacturer = Some("DongLoRa"); + config.product = Some("DongLoRa LoRa Radio"); + config.device_class = 0xEF; + config.device_sub_class = 0x02; + config.device_protocol = 0x01; + config.composite_with_iads = true; + + // Use MAC address as USB serial number for unique device identification. + static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new(); + let serial_buf = SERIAL_BUF.init([0u8; 12]); + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for (i, &byte) in mac.iter().enumerate() { + serial_buf[i * 2] = HEX[(byte >> 4) as usize]; + serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize]; + } + config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8")); + + static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new(); + static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new(); + static CDC_STATE: StaticCell = StaticCell::new(); + + let desc_buf = DESC_BUF.init([0; 256]); + let conf_buf = CONF_BUF.init([0; 256]); + let bos_buf = BOS_BUF.init([0; 256]); + let ctrl_buf = CTRL_BUF.init([0; 64]); + let cdc_state = CDC_STATE.init(State::new()); + + let mut builder = Builder::new( + parts.driver, + config, + desc_buf, + conf_buf, + bos_buf, + ctrl_buf, + ); + + let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16); + let mut usb_dev = builder.build(); + let (sender, receiver) = class.split(); + + // ── Run USB device + protocol loop concurrently ──────────────── + join( + usb_dev.run(), + protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac), + ) + .await; +} + +async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( + mut sender: Sender<'d, D>, + mut receiver: Receiver<'d, D>, + commands: &'static CommandChannel, + responses: &'static ResponseChannel, + display_commands: &'static DisplayCommandChannel, + has_display: bool, + mac: [u8; 6], +) { + use embassy_futures::select::select3; + use embassy_futures::select::Either3; + + let mut read_buf = [0u8; MAX_PACKET]; + let mut write_buf = [0u8; MAX_FRAME]; + let mut cobs_encode_buf = [0u8; MAX_FRAME]; + + // COBS accumulator: collect bytes until 0x00 sentinel + let mut frame_buf = [0u8; MAX_FRAME]; + let mut frame_len: usize = 0; + + let mut was_connected = false; + + loop { + match select3( + receiver.read_packet(&mut read_buf), + responses.receive(), + embassy_time::Timer::after_millis(250), // DTR poll interval for disconnect detection + ) + .await + { + Either3::First(result) => { + let n = match result { + Ok(0) => continue, + Ok(n) => n, + Err(_) => { + frame_len = 0; // reset accumulator + embassy_time::Timer::after_millis(100).await; + continue; + } + }; + + // Feed bytes into the COBS accumulator + for &byte in &read_buf[..n] { + if byte == 0x00 { + // End of COBS frame — decode and parse + if frame_len > 0 { + let mut decode_buf = [0u8; MAX_FRAME]; + if let Some(decoded_len) = + ucobs::decode(&frame_buf[..frame_len], &mut decode_buf) + { + if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) { + route_command( + cmd, commands, responses, display_commands, has_display, mac, + ) + .await; + } + } + } + frame_len = 0; + } else if frame_len < MAX_FRAME { + frame_buf[frame_len] = byte; + frame_len += 1; + } else { + // Frame too large — discard + frame_len = 0; + } + } + } + Either3::Second(response) => { + // Serialize response to fixed-size LE bytes, then COBS encode + let raw_len = response.write_to(&mut write_buf); + let encoded_len = ucobs::encode(&write_buf[..raw_len], &mut cobs_encode_buf) + .unwrap_or(0); + // Append 0x00 sentinel + if encoded_len < cobs_encode_buf.len() { + cobs_encode_buf[encoded_len] = 0x00; + let frame = &cobs_encode_buf[..encoded_len + 1]; + for chunk in frame.chunks(MAX_PACKET) { + if sender.write_packet(chunk).await.is_err() { + warn!("USB write failed, response dropped"); + break; + } + } + } else { + warn!("COBS encode buffer overflow"); + } + } + Either3::Third(()) => { + // Poll DTR every 250ms to detect host disconnect + } + } + + // Check DTR after every select wake + let connected = receiver.dtr(); + if was_connected && !connected && has_display { + display_commands.send(DisplayCommand::Reset).await; + } + if !was_connected && connected && has_display { + display_commands.send(DisplayCommand::On).await; + } + was_connected = connected; + } +} + +/// Route a parsed command to the appropriate handler. +/// +/// Display and MAC commands are handled locally (response sent immediately). +/// All other commands are forwarded to the radio task (response sent later). +/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command +/// rule in PROTOCOL.md is required to keep solicited responses ordered. +async fn route_command( + cmd: Command, + commands: &CommandChannel, + responses: &ResponseChannel, + display_commands: &DisplayCommandChannel, + has_display: bool, + mac: [u8; 6], +) { + match cmd { + Command::DisplayOn => { + if has_display { + display_commands.send(DisplayCommand::On).await; + responses.send(Response::Ok).await; + } else { + responses.send(Response::Error(ErrorCode::NoDisplay)).await; + } + } + Command::DisplayOff => { + if has_display { + display_commands.send(DisplayCommand::Off).await; + responses.send(Response::Ok).await; + } else { + responses.send(Response::Error(ErrorCode::NoDisplay)).await; + } + } + Command::GetMac => { + responses.send(Response::MacAddress(mac)).await; + } + other => { + commands.send(other).await; + } + } +}