commit bb588c93cee4a798eaa098483381e0184964fe47 Author: Stephen Waits Date: Wed Apr 1 13:08:45 2026 -0600 chore: massive repo reorganization 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; + } + } +}