diff --git a/Cargo.lock b/Cargo.lock index 7478a43..c30c5a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,8 +5,7 @@ version = 4 [[package]] name = "abstract-bits" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c265591a83d97ca12d32d679e8e0df1b11ff21b333a1679a52ff1bec2e16add" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "abstract-bits-derive", "arbitrary-int 1.3.0", @@ -17,8 +16,7 @@ dependencies = [ [[package]] name = "abstract-bits-derive" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ad589d11a94666dca636f13148a47005575d58034ed0f9d63d24b661c9d622" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -178,6 +176,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "ccm" version = "0.6.0-rc.3" @@ -270,6 +278,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -316,6 +334,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c46c1a17ebeef917714db3ae9a17bd2184f7e9977d8e020c6c8bcf59a28a6f1b" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crypto-common" version = "0.1.7" @@ -344,6 +368,41 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -360,6 +419,15 @@ dependencies = [ "crypto-common 0.1.7", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "educe" version = "0.6.0" @@ -372,6 +440,127 @@ dependencies = [ "syn", ] +[[package]] +name = "embassy-executor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" +dependencies = [ + "cordyceps", + "critical-section", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[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-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless", +] + +[[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", + "embassy-time-queue-utils", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless", +] + +[[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.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -398,6 +587,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -498,6 +699,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -617,6 +833,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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.14.0" @@ -678,6 +900,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -693,6 +921,19 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "mach2" version = "0.4.3" @@ -742,6 +983,21 @@ dependencies = [ "winapi", ] +[[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 = "nix" version = "0.26.4" @@ -1001,6 +1257,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1094,6 +1356,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" @@ -1116,6 +1384,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1130,9 +1404,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.6.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" @@ -1384,6 +1658,12 @@ 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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1470,6 +1750,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1690,8 +1979,13 @@ version = "0.1.0" dependencies = [ "abstract-bits", "arbitrary-int 2.1.1", + "embassy-executor", + "embassy-sync", + "embassy-time", + "futures", "parking_lot", "rand 0.10.1", + "spin", "thiserror", "tokio", "tracing", @@ -1719,7 +2013,6 @@ name = "ziggurat-phy" version = "0.1.0" dependencies = [ "thiserror", - "tokio", "ziggurat-ieee-802154", ] @@ -1749,6 +2042,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ziggurat-driver", + "ziggurat-phy", "ziggurat-phy-spinel", "ziggurat-spinel", "ziggurat-zigbee", @@ -1781,6 +2075,7 @@ dependencies = [ "hex", "hex-literal", "num_enum", + "once_cell", "serde", "subtle", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 09dcb33..18bfc9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,9 @@ [workspace] resolver = "2" members = ["crates/*"] -exclude = ["fuzz"] +# ziggurat-phy-esp and ziggurat-esp only build for an ESP32-C6 (riscv32imac) with esp-hal; +# excluded so host `cargo build` over the workspace doesn't try (and fail) to compile them. +exclude = ["fuzz", "crates/ziggurat-phy-esp", "crates/ziggurat-esp"] [workspace.package] version = "0.1.0" @@ -11,6 +13,7 @@ license = "Apache-2.0" authors = [] repository = "https://github.com/zigpy/ziggurat" + [workspace.dependencies] ziggurat-ieee-802154 = { path = "crates/ziggurat-ieee-802154", version = "0.1.0" } ziggurat-phy = { path = "crates/ziggurat-phy", version = "0.1.0" } diff --git a/README.md b/README.md index 976c286..7312845 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ Ziggurat can be set up as a regular ZHA radio in **Home Assistant 2026.7.0** or 4. When ZHA asks for the radio type, pick **Ziggurat** and migrate your existing network (or set a new one up). 5. Done. +### ESP32-C6 firmware +Ziggurat can also run on-chip on an ESP32-C6, using its built-in 802.15.4 radio and +exposing the same JSON API over USB-Serial-JTAG: no separate RCP radio or host. + +```bash +cd crates/ziggurat-esp +cargo build --release +espflash flash --no-stub --chip esp32c6 --port \ + target/riscv32imac-unknown-none-elf/release/ziggurat-esp +``` + +- USB-Serial-JTAG carries the JSON API; UART0 on GPIO16 @ 460800 carries logs, the debug heartbeat, and panic backtraces. + ### Development Ziggurat aims to implement the portions of a Zigbee stack used by normal Home Assistant users, not the entire binder of Zigbee specification verbatim. It is nearly feature-complete diff --git a/crates/ziggurat-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index ab48650..64973f4 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -13,10 +13,45 @@ ziggurat-ieee-802154.workspace = true ziggurat-phy.workspace = true ziggurat-zigbee.workspace = true -abstract-bits = "0.2.0" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } arbitrary-int = "2.1.1" -tracing = "0.1" -parking_lot = "0.12.4" -rand = "0.10.1" -thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["rt", "macros", "time", "sync", "io-util"] } +futures = { version = "0.3", default-features = false } +tracing = { version = "0.1", default-features = false } +thiserror = { version = "2.0.12", default-features = false } + +# Host (tokio) runtime backend. +parking_lot = { version = "0.12.4", optional = true } +rand = { version = "0.10.1", optional = true } +tokio = { version = "1.43.0", features = [ + "rt", + "macros", + "time", + "sync", + "io-util", +], optional = true } + +# Embassy runtime adapter. The MCU binary provides the executor + arch (via esp-rtos), so +# the base dependency pulls no arch feature; the `embassy-host` feature adds the std +# executor so the same adapter can run on the host bridged to tokio. +embassy-executor = { version = "0.10", optional = true } +embassy-time = { version = "0.5", optional = true } +embassy-sync = { version = "0.8", optional = true } +spin = { version = "0.9", default-features = false, features = [ + "spin_mutex", +], optional = true } + +[features] +default = ["tokio"] +# The host runtime: tokio executor, parking_lot locks, OS randomness. +tokio = ["dep:tokio", "dep:parking_lot", "dep:rand"] +# The embassy runtime adapter (and its no_std-friendly sync primitives), for the MCU. +# Mutually exclusive with `tokio` at the `sync`/`runtime`/`rng` seam. +embassy = ["dep:embassy-executor", "dep:embassy-time", "dep:embassy-sync", "dep:spin"] +# Run the embassy adapter on the host, bridged to a tokio reactor (for tests). +embassy-host = [ + "embassy", + "dep:tokio", + "embassy-executor/platform-std", + "embassy-executor/executor-thread", + "embassy-time/std", +] diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index 7ce51c4..7b4942f 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -1,3 +1,15 @@ +#![no_std] + +extern crate alloc; + +// The host bridge spawns an OS thread for the embassy executor; that path alone needs std. +#[cfg(feature = "embassy-host")] +extern crate std; + +pub mod rng; +pub mod runtime; +pub mod signal; +pub mod sync; pub mod zigbee_stack; pub use ziggurat_ieee_802154; diff --git a/crates/ziggurat-driver/src/rng.rs b/crates/ziggurat-driver/src/rng.rs new file mode 100644 index 0000000..11f2d81 --- /dev/null +++ b/crates/ziggurat-driver/src/rng.rs @@ -0,0 +1,53 @@ +//! Randomness crate. + +/// A uniform `f32` in `[0, 1)`, for jitter scaling. +pub fn random_f32() -> f32 { + let mut bytes = [0u8; 4]; + fill_bytes(&mut bytes); + // 24-bit mantissa worth of entropy mapped into [0, 1) + (u32::from_le_bytes(bytes) >> 8) as f32 / (1u32 << 24) as f32 +} + +/// A uniform `u16`, for stochastic address allocation. +pub fn random_u16() -> u16 { + let mut bytes = [0u8; 2]; + fill_bytes(&mut bytes); + u16::from_le_bytes(bytes) +} + +/// `N` random bytes, for key material. +pub fn random_array() -> [u8; N] { + let mut bytes = [0u8; N]; + fill_bytes(&mut bytes); + bytes +} + +#[cfg(feature = "tokio")] +fn fill_bytes(buf: &mut [u8]) { + use rand::RngExt; + rand::rng().fill(buf); +} + +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +pub use embassy_rng::{fill_bytes, install}; + +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +mod embassy_rng { + use crate::sync::Mutex; + use alloc::boxed::Box; + + type FillFn = Box; + + static FILL: Mutex> = Mutex::new(None); + + /// Install the byte source. The MCU binary backs this with the SoC hardware RNG. + pub fn install(fill: FillFn) { + *FILL.lock() = Some(fill); + } + + pub fn fill_bytes(buf: &mut [u8]) { + let mut guard = FILL.lock(); + let fill = guard.as_mut().expect("rng::install was never called"); + fill(buf); + } +} diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs new file mode 100644 index 0000000..da0fa32 --- /dev/null +++ b/crates/ziggurat-driver/src/runtime.rs @@ -0,0 +1,292 @@ +//! Async runtime abstraction layer. + +use alloc::boxed::Box; +use core::future::Future; +use core::ops::Add; +use core::pin::Pin; +use core::time::Duration; + +/// A detached background task, boxed so one spawn path serves every runtime. +/// +/// Tokio drops it into a tracked `JoinSet`; embassy (later) hands it to a static +/// task-pool runner. Our tasks capture only `Arc` and never hold a +/// `CoreGuard` across an `.await`, so they are genuinely `Send` — no +/// single-threaded-executor `unsafe` is needed. +pub type SpawnedTask = Pin + Send + 'static>>; + +/// Spawns the stack's background tasks. +/// +/// A value, not a static method, because embassy spawning needs its `Spawner` token +/// (which tokio's global spawn doesn't) — so the stack is handed one at construction. +/// Reached via[`Runtime::Spawner`]. +pub trait Spawn: Send + Sync + 'static { + /// Spawn a detached background task. + fn spawn(&self, task: SpawnedTask); + + /// Stop every task spawned through this spawner and wait for them to finish, so a + /// replaced host stack provably stops before its successor runs. A no-op on + /// executors that cannot cancel tasks (embassy). + fn shutdown(&self) -> impl Future + Send; +} + +/// The instant type a [`Runtime`] measures time with. Bounded for exactly the +/// arithmetic the driver performs on deadlines. +pub trait RtInstant: Copy + Send + Sync + 'static + Add { + /// Saturating `self - earlier`, never panicking when `earlier` is in the future. + fn saturating_duration_since(self, earlier: Self) -> Duration; +} + +#[cfg(feature = "tokio")] +impl RtInstant for tokio::time::Instant { + fn saturating_duration_since(self, earlier: Self) -> Duration { + Self::saturating_duration_since(&self, earlier) + } +} + +/// A deadline elapsed before the awaited future completed. Replaces +/// `tokio::time::error::Elapsed` so the stack's error type stays runtime-agnostic. +#[derive(Debug, thiserror::Error)] +#[error("deadline elapsed")] +pub struct Elapsed; + +/// The async runtime the driver runs on. Implemented by [`TokioRuntime`] for the +/// host server and (later) an embassy runtime for the MCU. +pub trait Runtime: Send + Sync + 'static { + type Instant: RtInstant; + + /// Spawns the stack's background tasks; see [`Spawn`]. + type Spawner: Spawn; + + /// The current monotonic instant. + fn now() -> Self::Instant; + + /// Sleep for `duration`. + fn sleep(duration: Duration) -> impl Future + Send; + + /// Sleep until `deadline`. + fn sleep_until(deadline: Self::Instant) -> impl Future + Send; + + /// Run `future`, returning [`Elapsed`] if `duration` passes first. + fn timeout( + duration: Duration, + future: F, + ) -> impl Future> + Send + where + F: Future + Send, + F::Output: Send, + { + async move { + let future = core::pin::pin!(future); + let sleep = core::pin::pin!(Self::sleep(duration)); + match futures::future::select(future, sleep).await { + futures::future::Either::Left((output, _)) => Ok(output), + futures::future::Either::Right(((), _)) => Err(Elapsed), + } + } + } + + /// Run `future`, returning [`Elapsed`] if `deadline` passes first. + fn timeout_at( + deadline: Self::Instant, + future: F, + ) -> impl Future> + Send + where + F: Future + Send, + F::Output: Send, + { + async move { + let future = core::pin::pin!(future); + let sleep = core::pin::pin!(Self::sleep_until(deadline)); + match futures::future::select(future, sleep).await { + futures::future::Either::Left((output, _)) => Ok(output), + futures::future::Either::Right(((), _)) => Err(Elapsed), + } + } + } +} + +/// The tokio runtime: the host server's executor. +#[cfg(feature = "tokio")] +#[derive(Debug, Clone, Copy)] +pub struct TokioRuntime; + +#[cfg(feature = "tokio")] +impl Runtime for TokioRuntime { + type Instant = tokio::time::Instant; + type Spawner = TokioSpawner; + + fn now() -> Self::Instant { + tokio::time::Instant::now() + } + + fn sleep(duration: Duration) -> impl Future + Send { + tokio::time::sleep(duration) + } + + fn sleep_until(deadline: Self::Instant) -> impl Future + Send { + tokio::time::sleep_until(deadline) + } +} + +/// The tokio spawner: tasks go into a `JoinSet` so a replaced stack can abort them. +#[cfg(feature = "tokio")] +#[derive(Default)] +pub struct TokioSpawner { + tasks: parking_lot::Mutex>, +} + +#[cfg(feature = "tokio")] +impl Spawn for TokioSpawner { + fn spawn(&self, task: SpawnedTask) { + let mut tasks = self.tasks.lock(); + + // A completed task's cell lingers until reaped; drain here so the set tracks live + // tasks instead of growing by one dead entry per spawn. + while let Some(result) = tasks.try_join_next() { + if let Err(e) = result + && e.is_panic() + { + tracing::error!("Background task panicked: {e}"); + } + } + + tasks.spawn(task); + } + + async fn shutdown(&self) { + let mut tasks = core::mem::take(&mut *self.tasks.lock()); + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + } +} + +/// The embassy runtime adapter. Drives the MCU directly; host-runnable through +/// `embassy-host` (`arch-std`) so it can stand in for tokio in tests. +#[cfg(feature = "embassy")] +pub use embassy_impl::{EmbassyRuntime, EmbassySpawner}; + +#[cfg(feature = "embassy-host")] +pub use embassy_impl::start_embassy_executor; + +/// The runtime the stack defaults to when no `R` type parameter is given. Resolves to +/// whichever backend feature is enabled. +#[cfg(feature = "tokio")] +pub type DefaultRuntime = TokioRuntime; +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +pub type DefaultRuntime = EmbassyRuntime; + +#[cfg(feature = "embassy")] +mod embassy_impl { + use super::{RtInstant, Runtime, Spawn, SpawnedTask}; + #[cfg(feature = "embassy-host")] + use alloc::boxed::Box; + use core::future::Future; + use core::ops::Add; + use core::time::Duration; + + const fn to_embassy(duration: Duration) -> embassy_time::Duration { + embassy_time::Duration::from_micros(duration.as_micros() as u64) + } + + const fn from_embassy(duration: embassy_time::Duration) -> Duration { + Duration::from_micros(duration.as_micros()) + } + + /// Wraps `embassy_time::Instant` so the trait's `core::time::Duration` arithmetic + /// works against embassy's own `Duration` type. + #[derive(Copy, Clone)] + pub struct EmbassyInstant(embassy_time::Instant); + + impl Add for EmbassyInstant { + type Output = Self; + + fn add(self, rhs: Duration) -> Self { + Self(self.0 + to_embassy(rhs)) + } + } + + impl RtInstant for EmbassyInstant { + fn saturating_duration_since(self, earlier: Self) -> Duration { + from_embassy(self.0.saturating_duration_since(earlier.0)) + } + } + + pub struct EmbassyRuntime; + + impl Runtime for EmbassyRuntime { + type Instant = EmbassyInstant; + type Spawner = EmbassySpawner; + + fn now() -> Self::Instant { + EmbassyInstant(embassy_time::Instant::now()) + } + + fn sleep(duration: Duration) -> impl Future + Send { + embassy_time::Timer::after(to_embassy(duration)) + } + + fn sleep_until(deadline: Self::Instant) -> impl Future + Send { + embassy_time::Timer::at(deadline.0) + } + } + + /// Each detached task runs in one slot of this fixed pool — embassy has no dynamic + /// spawn, so the size bounds the stack's concurrent background tasks (long-lived + /// reactors plus the transient ZDP/indirect/route-request ones). + #[embassy_executor::task(pool_size = 32)] + async fn task_runner(task: SpawnedTask) { + task.await; + } + + /// Spawns into the embassy executor. Holds a [`SendSpawner`](embassy_executor::SendSpawner) + /// so it is `Send + Sync`; obtained from the executor at startup. + #[derive(Clone, Copy)] + pub struct EmbassySpawner(embassy_executor::SendSpawner); + + impl EmbassySpawner { + pub const fn new(spawner: embassy_executor::SendSpawner) -> Self { + Self(spawner) + } + } + + impl Spawn for EmbassySpawner { + fn spawn(&self, task: SpawnedTask) { + // In embassy-executor 0.10 the pool slot is claimed when the token is built, + // so exhaustion surfaces here rather than at `spawn`. + match task_runner(task) { + Ok(token) => self.0.spawn(token), + Err(_) => { + tracing::error!("embassy task pool exhausted; background task dropped"); + } + } + } + + // Embassy cannot cancel spawned tasks; the MCU stack is never replaced, so there + // is nothing to stop. + async fn shutdown(&self) {} + } + + /// Run an embassy `arch-std` executor on a dedicated thread, returning a spawner + /// for it. + #[cfg(feature = "embassy-host")] + pub fn start_embassy_executor(tokio_handle: tokio::runtime::Handle) -> EmbassySpawner { + use std::sync::mpsc; + + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("embassy-executor".into()) + .spawn(move || { + // Held for the executor's (and thread's) entire life, so every poll on this + // thread sees the tokio runtime. + let _enter = tokio_handle.enter(); + let executor: &'static mut embassy_executor::Executor = + Box::leak(Box::new(embassy_executor::Executor::new())); + executor.run(move |spawner| { + let _ = tx.send(spawner.make_send()); + }); + }) + .expect("spawn embassy-executor thread"); + + EmbassySpawner::new(rx.recv().expect("embassy executor failed to start")) + } +} diff --git a/crates/ziggurat-driver/src/signal.rs b/crates/ziggurat-driver/src/signal.rs new file mode 100644 index 0000000..1b6f286 --- /dev/null +++ b/crates/ziggurat-driver/src/signal.rs @@ -0,0 +1,114 @@ +//! `Signal` primitive: effectively a `Mutex` plus a `Notify`. + +use crate::sync::{Mutex, Notify}; +use alloc::sync::Arc; +use core::fmt; + +/// The producer was dropped without ever signalling a value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Closed; + +enum State { + /// No value yet, producer still alive. + Pending, + /// A value was signalled and not yet taken. + Ready(T), + /// The producer was dropped without signalling. + Closed, +} + +struct Inner { + slot: Mutex>, + ready: Notify, +} + +/// The producer half. Signalling (or dropping) it wakes the [`SignalWaiter`]. +pub struct Signal { + inner: Arc>, +} + +/// The consumer half. [`wait`](SignalWaiter::wait) resolves once the producer signals a +/// value or is dropped. +pub struct SignalWaiter { + inner: Arc>, +} + +/// Create a producer/waiter pair sharing a single-value slot. +pub fn channel() -> (Signal, SignalWaiter) { + let inner = Arc::new(Inner { + slot: Mutex::new(State::Pending), + ready: Notify::new(), + }); + ( + Signal { + inner: inner.clone(), + }, + SignalWaiter { inner }, + ) +} + +impl Signal { + /// Hand `value` to the waiter. A dropped waiter just discards it. + pub fn signal(self, value: T) { + *self.inner.slot.lock() = State::Ready(value); + self.inner.ready.notify_one(); + // `self` drops here; `Drop` sees `Ready` and leaves the value in place. + } +} + +impl Drop for Signal { + fn drop(&mut self) { + let closed = { + let mut state = self.inner.slot.lock(); + if matches!(*state, State::Pending) { + *state = State::Closed; + true + } else { + false + } + }; + if closed { + self.inner.ready.notify_one(); + } + } +} + +impl SignalWaiter { + /// Wait for the producer to signal a value, or `Err(Closed)` if it was dropped first. + pub async fn wait(&self) -> Result { + loop { + // `notify_one` stores a permit when no waiter is registered, so a signal that + // lands between the check and the await is not lost. + if let Some(result) = self.take() { + return result; + } + self.inner.ready.notified().await; + } + } + + fn take(&self) -> Option> { + let mut state = self.inner.slot.lock(); + let result = match core::mem::replace(&mut *state, State::Pending) { + State::Pending => None, + State::Ready(value) => Some(Ok(value)), + State::Closed => { + *state = State::Closed; + Some(Err(Closed)) + } + }; + drop(state); + result + } +} + +impl fmt::Debug for Signal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Signal") + } +} + +impl fmt::Debug for SignalWaiter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SignalWaiter") + } +} diff --git a/crates/ziggurat-driver/src/sync.rs b/crates/ziggurat-driver/src/sync.rs new file mode 100644 index 0000000..471f455 --- /dev/null +++ b/crates/ziggurat-driver/src/sync.rs @@ -0,0 +1,53 @@ +//! The synchronization primitives the stack rests on: a blocking [`Mutex`], an async +//! [`AsyncMutex`], and an [`Notify`]. + +#[cfg(feature = "tokio")] +mod imp { + pub use parking_lot::{Mutex, MutexGuard}; + pub use tokio::sync::Mutex as AsyncMutex; + pub use tokio::sync::Notify; +} + +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +mod imp { + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; + + // A spinlock is a guard-returning, `Sync`, no_std mutex; on the cooperative + // single-core MCU executor it never actually spins, and the stack never holds a guard + // across an `.await`, so the lock window is always brief. + pub use spin::{Mutex, MutexGuard}; + + /// The async mutex, for guards held across an `.await` (the radio stream + reset + /// receivers). Pinned to the critical-section raw mutex. + pub type AsyncMutex = embassy_sync::mutex::Mutex; + + /// A parameterless wake matching `tokio::sync::Notify`'s surface. + /// + /// Built over embassy's single-slot [`Signal`](embassy_sync::signal::Signal): + /// `notify_one` stores one permit and coalesces repeats; `notified` consumes it — the + /// same single-waiter contract every wake in the stack relies on. + #[derive(Default)] + pub struct Notify(embassy_sync::signal::Signal); + + impl Notify { + pub const fn new() -> Self { + Self(embassy_sync::signal::Signal::new()) + } + + pub fn notify_one(&self) { + self.0.signal(()); + } + + pub async fn notified(&self) { + self.0.wait().await; + } + } + + impl core::fmt::Debug for Notify { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("Notify") + } + } +} + +pub use imp::*; diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index eda7d91..c7dfe63 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1,26 +1,31 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; +use crate::runtime::{Elapsed, RtInstant, Runtime, Spawn}; +use crate::signal::Signal; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; -use tokio::time::{sleep, timeout}; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{ - ExclusiveRadio, RadioConfig, RadioError, RadioPhy, ResetEvent, RxFrame, TxFrame, TxResult, + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, RxFrame, TxFrame, TxResult, }; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::aps::frame::{ApsAckFrame, ApsFrame, parse_aps_frame}; use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; -use tokio::time::error::Elapsed; -use parking_lot::{Mutex, MutexGuard}; -use std::collections::HashMap; -use std::future::Future; -use std::ops::{Deref, DerefMut}; -use std::sync::{Arc, Weak}; -use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc, oneshot}; -use tokio::task::JoinSet; -use tokio::time::{Duration, Instant}; +use crate::sync::{AsyncMutex, Mutex, MutexGuard, Notify}; +use alloc::boxed::Box; +use alloc::collections::{BTreeMap, BinaryHeap, VecDeque}; +use alloc::string::String; +use alloc::sync::{Arc, Weak}; +use alloc::vec::Vec; +use core::cmp::Ordering; +use core::future::Future; +use core::ops::{Deref, DerefMut}; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering as AtomicOrdering}; +use core::time::Duration; +use ziggurat_zigbee::nwk::frame::{EncryptedNwkFrame, NwkFrame}; mod aps; mod indirect; @@ -31,7 +36,6 @@ mod nwk; mod route; mod zdp; -pub use ziggurat_phy::TxPriority; pub use ziggurat_zigbee::aps::security as aps_security; pub use ziggurat_zigbee::aps::security::{ApsSecurity, TclkSeed}; pub use ziggurat_zigbee::constants::{ @@ -46,9 +50,6 @@ pub use ziggurat_zigbee::nwk::routing::Routing; pub use ziggurat_zigbee::nwk::security::NwkSecurity; pub use ziggurat_zigbee::nwk::{neighbors, routing}; -/// Hard deadline for acquiring a lock. Anything exceeding this is an error. -const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(10); - /// How long the RCP gets to announce itself after a `CMD_RESET` before we resend. const RESET_NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(2); const RESET_ATTEMPTS: u32 = 5; @@ -58,6 +59,13 @@ const RADIO_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(1); /// counter, so that its persisted copy never lags far behind. const FRAME_COUNTER_NOTIFY_INTERVAL: u32 = 100; +/// Upper bound on the unsent notification queue. The drainer stalls whenever the client +/// stops reading the serial/WS link (e.g. a disconnected client), but the stack keeps +/// producing events from live mesh traffic; without a ceiling the queue grows until it +/// exhausts the heap (fatal on the MCU). When full we drop the oldest event: a client +/// that wasn't reading has already missed it and re-syncs on reconnect. +const NOTIFICATION_QUEUE_CAP: usize = 64; + #[derive(Error, Debug)] pub enum ZigbeeStackError { #[error("route discovery timed out")] @@ -66,6 +74,8 @@ pub enum ZigbeeStackError { RouteDiscoveryNoEntry, #[error("route not active after discovery completed")] RouteInactiveAfterDiscovery, + #[error("no route to destination and route discovery is suppressed")] + RouteDiscoverySuppressed, #[error("next hop {next_hop:?} did not ACK")] NwkNoAck { next_hop: Ieee802154Address }, #[error("transmit rejected due to CCA failure")] @@ -84,6 +94,19 @@ pub enum ZigbeeStackError { Radio(#[from] RadioError), } +/// Transmit scheduling priority. Higher transmits first when the radio is contended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct TxPriority(pub i8); + +impl TxPriority { + pub const BACKGROUND: Self = Self(-2); + pub const USER_LOW: Self = Self(-1); + pub const USER_NORMAL: Self = Self(0); + pub const USER_HIGH: Self = Self(1); + pub const USER_CRITICAL: Self = Self(2); + pub const STACK_CRITICAL: Self = Self(3); +} + /// How an outgoing NWK frame is secured. Frames carrying the network key to a joining /// device are sent without NWK security; the APS payload is encrypted instead. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,7 +122,7 @@ pub enum SendMode { /// lookup (and route discovery suppressed). Used for frames to a one-hop neighbor, /// e.g. delivering the network key to a joining device. Direct, - /// Resolve the next hop through the routing layer — the route table or an applicable + /// Resolve the next hop through the routing layer: the route table or an applicable /// source route, discovering a route first if none is known. Route, } @@ -203,11 +226,11 @@ pub struct NetworkConfig { /// cancelled when another device reported the same conflict first. #[derive(Debug, Clone, Copy)] pub struct AddressConflict { - pub handled_at: Instant, + pub handled_at: CoreInstant, pub heard_from_network: bool, } -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct ApsAckData { pub src: Nwk, pub destination_endpoint: Option, @@ -230,17 +253,153 @@ impl ApsAckData { } } -/// Resolves an indirect transaction with its transmit result on extraction, or an -/// error on expiry or drop. -pub type IndirectCompletion = oneshot::Sender>; +/// The pending half of a transmit's outcome. +pub type TxCompletion = Signal>; + +/// The client's request id, supplied to `send_aps` and echoed back in its confirmation. +pub type RequestId = u32; + +/// Where a transmit's terminal outcome is reported. +#[derive(Debug)] +pub enum TxOutcome { + /// Nobody is waiting; a failure is only logged (internal background sends). + Discard, + /// Resolve an awaiting caller's signal (internal awaiters). + Signal(TxCompletion), + /// Confirm an application send by `request_id`. `aps_ack` present means the end-to-end + /// APS ack is the confirmation: this hop succeeding is silent, its failure fails + /// the send; absent means next-hop acceptance is itself the confirmation. + Confirm { + request_id: RequestId, + aps_ack: Option, + }, +} + +/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end +/// ack, confirmed (or timed out) as a [`ZigbeeNotification::SendConfirm`] carrying +/// `request_id`. +#[derive(Debug)] +pub struct PendingApsAck { + pub(crate) request_id: RequestId, + pub(crate) deadline: CoreInstant, +} + +/// A transmit queued for the single sender task ([`ZigbeeStack::sender_task`]). The NWK +/// frame is unencrypted: the sender assigns the frame counter at dequeue. +#[derive(Debug)] +pub struct SendRequest { + seq: u32, + priority: TxPriority, + pub(crate) kind: SendKind, + pub(crate) outcome: TxOutcome, +} -/// The end-to-end delivery confirmation of a transmitted APS frame, pending until the -/// destination's APS ack arrives. Resolved via [`ZigbeeStack::wait_aps_ack`]. #[derive(Debug)] -pub struct ApsAckWaiter { - pub(crate) receiver: oneshot::Receiver<()>, - pub(crate) timeout: Duration, - pub(crate) ack_data: ApsAckData, +pub(crate) enum SendKind { + Unicast { + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + /// NWK-layer retries left after the current attempt. Seeded from + /// `Tunables::unicast_retries`; a failed attempt with retries remaining is + /// re-enqueued (decremented) by the unicast-retry reactor rather than slept on + /// in the sender, so the single sender task never blocks on a retry delay. + attempts_remaining: u8, + }, + Broadcast { + nwk_frame: NwkFrame, + security: NwkSecurityMode, + }, + /// An already-finished 802.15.4 frame (a beacon response, or an indirect poll + /// delivery): transmitted as-is, only the MAC sequence number assigned at dequeue. + Raw { frame: Ieee802154Frame }, +} + +/// A unicast frame queued because its destination has no known route. +/// +/// Held in [`State::pending_routes`] until route discovery resolves. The NWK sequence +/// number is already assigned; the frame counter is still assigned at dequeue. +#[derive(Debug)] +pub struct PendingFrame { + pub(crate) nwk_frame: NwkFrame, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + pub(crate) outcome: TxOutcome, +} + +/// All frames waiting on one destination's route discovery. +/// +/// Discovery is started once per destination and the whole bucket is released or +/// discarded together, so ten frames to one device ride a single discovery. +#[derive(Debug)] +pub struct PendingRoute { + pub(crate) frames: Vec, + /// Discoveries left before the bucket is discarded. Seeded from + /// `Tunables::pending_route_discovery_attempts` and decremented on each timeout. + pub(crate) attempts_remaining: u8, +} + +/// A broadcast awaiting retransmission, held by the broadcast-retransmit reactor. +/// +/// Spec 3.6.6: a broadcast is rebroadcast until its passive-ack quorum is heard or its +/// attempts run out. This holds the frame to retransmit and the schedule; the passive-ack +/// contract itself lives in the sans-io [`Broadcasts`] table. +#[derive(Debug)] +pub struct PendingBroadcast { + pub(crate) nwk_frame: NwkFrame, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + /// Retransmissions left before the broadcast is given up on. + pub(crate) attempts_remaining: u8, + /// When the next retransmission is due, unless the quorum is heard first. + pub(crate) next_attempt: CoreInstant, + /// An application send awaiting confirmation: `SendConfirm { via: Quorum }` when the + /// passive-ack quorum is heard, or `Failed` when attempts run out. + pub(crate) request_id: Option, +} + +/// A unicast awaiting re-transmission after a failed attempt, held by the unicast-retry +/// reactor ([`ZigbeeStack::unicast_retry_task`]). +/// +/// Pulling the NWK retry delay out of the sender task is what keeps the single sender +/// from ever sleeping mid-queue: a failed unicast is parked here and re-enqueued once +/// its delay elapses, so the radio keeps draining other frames in the meantime. The +/// frame is held as plaintext (no aux header / counter yet) so the next attempt earns +/// a fresh frame counter at dequeue, keeping on-air order equal to counter order. The +/// completion rides along and is resolved when the frame finally succeeds or exhausts +/// its attempts. +#[derive(Debug)] +pub struct PendingUnicastRetry { + pub(crate) nwk_frame: NwkFrame, + pub(crate) next_hop: Nwk, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + /// Attempts left after the one that just failed. + pub(crate) attempts_remaining: u8, + /// When the re-enqueue is due. + pub(crate) next_attempt: CoreInstant, + pub(crate) outcome: TxOutcome, +} + +impl PartialEq for SendRequest { + fn eq(&self, other: &Self) -> bool { + self.priority == other.priority && self.seq == other.seq + } +} +impl Eq for SendRequest {} +impl Ord for SendRequest { + fn cmp(&self, other: &Self) -> Ordering { + // Max-heap: higher priority first; within a priority, the earlier (lower) seq + // wins, so equal-priority frames drain in FIFO order. + self.priority + .cmp(&other.priority) + .then_with(|| other.seq.cmp(&self.seq)) + } +} +impl PartialOrd for SendRequest { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } /// The NWK Information Base (spec Table 3-66): the network layer's mutable attributes @@ -282,6 +441,45 @@ pub struct Aib { pub aps_security: ApsSecurity, } +/// A frame queued for indirect delivery to a sleepy device, together with the address it +/// is queued under (802.15.4 spec 6.7.3). +/// +/// `poll_address` is the address the device polls with, which a MAC Data Request is +/// matched against and so the indirect queue's key: the extended address before a device +/// has joined (it has no short address yet), the short address once it has. It is neither +/// the frame's NWK destination (`nwk_header.destination`) nor a [`Deferred`] payload's +/// `next_hop` (the short address used to build the MAC header) — the same device can wear +/// all three. +/// +/// [`Deferred`]: IndirectPayload::Deferred +#[derive(Debug)] +pub struct IndirectFrame { + pub poll_address: Ieee802154Address, + pub payload: IndirectPayload, +} + +/// The payload of an [`IndirectFrame`]. +/// +/// A NWK frame to a sleepy child is queued as [`Deferred`](Self::Deferred): it is +/// finished (encrypted, with a fresh NWK frame counter assigned) only when the child +/// polls, not when it is queued. A transaction can wait in the indirect queue for +/// seconds; assigning the counter at enqueue would let the sender hand out higher +/// counters in the meantime, so the frame would reach the air with a counter below +/// ones already sent and the destination's replay window would reject it. Finishing at +/// delivery keeps the counter in on-air order. +/// +/// Frames with no NWK layer to encrypt (a MAC association response) are queued +/// [`Final`](Self::Final) and transmitted as-is. +#[derive(Debug)] +pub enum IndirectPayload { + Deferred { + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + }, + Final(Ieee802154Frame), +} + /// Host-side mirror of the MAC PIB attributes we drive on the RCP. The MAC sub-layer /// physically lives on the radio coprocessor; these are our authoritative copies. #[derive(Debug)] @@ -291,17 +489,10 @@ pub struct MacState { pub pan_id: PanId, /// Frames awaiting extraction by a polling device. Completions are resolved /// with the transmit result on extraction, or an error on expiry or drop. - pub indirect_queue: IndirectQueue, + pub indirect_queue: IndirectQueue, } /// The driver's unified mutable protocol state, behind a single lock. -/// -/// An operation spanning several layers takes one guard instead of juggling a lock per -/// field (and can never deadlock against itself on lock ordering). This is also the -/// shape the eventual no_std core will own directly — there with no lock, here behind -/// one `Mutex` for the threaded driver. Spec attributes are grouped by their -/// information base ([`Nib`],[`Aib`], [`MacState`]); a field directly on the core is, -/// by that absence, one of our own constructs with no spec information-base home. #[derive(Debug)] pub struct ZigbeeCore { pub nib: Nib, @@ -311,20 +502,22 @@ pub struct ZigbeeCore { /// Deadline until which the coordinator advertises `association_permit` in its /// beacon and accepts direct MAC associations. A deadline rather than a flag lets /// renewals extend the window. `None` or past means direct joins are denied. - pub permitting_joins_until: Option, + pub permitting_joins_until: Option, /// Deadline until which the trust center authorizes new devices joining through a /// router. Opened on every permit, independent of the beacon window, so a steered /// join completes while the coordinator's own beacon stays closed. Rejoins are /// never gated by this. - pub trust_center_joins_until: Option, + pub trust_center_joins_until: Option, + + /// While set and in the future, the beacon-spam reactor sends a beacon every 5 ms + /// (the `hack_beacon_spam_duration` hack). Each beacon request extends it. + pub beacon_spam_until: Option, } -/// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It exists -/// to encode the single-lock discipline in one place: -/// -/// - It is `!Send` so holding it across an `.await` is a compile-time error. -/// - It is acquired with a [`LOCK_ACQUIRE_TIMEOUT`] so we fail at runtime if this lapses. +/// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It encodes +/// the single-lock discipline: it is `!Send`, so holding it across an `.await` is a +/// compile-time error. pub struct CoreGuard<'a>(MutexGuard<'a, ZigbeeCore>); impl Deref for CoreGuard<'_> { @@ -346,18 +539,20 @@ pub struct State { /// All mutable protocol state, behind one lock pub core: Mutex, - /// Async I/O bookkeeping, kept out of the core so transmit completions and client - /// notifications never contend with protocol work: - pub pending_aps_acks: Mutex>>, - pub pending_route_notifications: Mutex>>, - pub address_conflicts: Mutex>, + pub pending_aps_acks: Mutex>, + pub pending_routes: Mutex>, + /// Broadcasts awaiting retransmission, keyed by (source, sequence number). + pub pending_broadcasts: Mutex>, + /// Unicasts awaiting re-transmission after a failed attempt. Unordered: each entry + /// is an independent in-flight frame (no dedup key like broadcasts have), drained + /// by the unicast-retry reactor when due. + pub pending_unicast_retries: Mutex>, + pub address_conflicts: Mutex>, /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with /// the receipt time; an inbound data frame matching a live entry is a retransmission /// to be acknowledged but not delivered to the application a second time. - pub aps_duplicates: Mutex>, - - pub start_time: Instant, + pub aps_duplicates: Mutex>, // We intentionally violate the spec with these options // @@ -373,6 +568,10 @@ pub struct State { /// Instead of caching route information, always perform route discovery. This is /// much slower but ensures that routing logic is always followed. pub hack_force_route_discovery: bool, + /// While permitting joins, spray a beacon every 5 ms for this long after each beacon + /// request, to out-compete neighbouring networks that permanently permit joins. Zero + /// disables it (a single beacon per request, the spec behaviour). + pub hack_beacon_spam_duration: Duration, pub role: NwkDeviceType, pub capability_information: NwkCapabilityInformation, @@ -451,16 +650,19 @@ impl State { }, permitting_joins_until: None, trust_center_joins_until: None, + beacon_spam_until: None, }), - pending_aps_acks: Mutex::new(HashMap::new()), - pending_route_notifications: Mutex::new(HashMap::new()), - address_conflicts: Mutex::new(HashMap::new()), - aps_duplicates: Mutex::new(HashMap::new()), - start_time: Instant::now(), + pending_aps_acks: Mutex::new(BTreeMap::new()), + pending_routes: Mutex::new(BTreeMap::new()), + pending_broadcasts: Mutex::new(BTreeMap::new()), + pending_unicast_retries: Mutex::new(Vec::new()), + address_conflicts: Mutex::new(BTreeMap::new()), + aps_duplicates: Mutex::new(BTreeMap::new()), hack_ignore_broadcast_startup_wait_period: true, hack_disable_tx: false, hack_force_route_discovery: false, + hack_beacon_spam_duration: Duration::from_millis(100), role: config.role, capability_information: NwkCapabilityInformation { @@ -554,6 +756,31 @@ pub enum ZigbeeNotification { frame_counter: u32, key_id: String, }, + SendConfirm { + request_id: RequestId, + result: SendResult, + }, + ApsAckConfirm { + request_id: RequestId, + result: ApsAckResult, + }, +} + +#[derive(Debug, Clone)] +pub enum SendResult { + /// Handed off; `next_hop` is the neighbour it went to, `None` for a broadcast. + Confirmed { + next_hop: Option, + }, + Failed { + reason: String, + }, +} + +#[derive(Debug, Clone)] +pub enum ApsAckResult { + Acked, + Failed { reason: String }, } #[derive(Debug, Clone)] @@ -575,19 +802,26 @@ pub struct NetworkBeacon { } #[derive(Debug)] -pub struct ZigbeeStack { +pub struct ZigbeeStack { self_weak: Weak, + /// The runtime clock baseline. `now` is converted to the sans-io [`CoreInstant`] + /// (microseconds since this instant) at the one boundary that reads the clock. + start_time: R::Instant, + pub state: State, pub config: NetworkConfig, pub tunables: Tunables, pub radio: Arc

, - pub notification_tx: broadcast::Sender, - pub raw_frame_rx: AsyncMutex>, - pub reset_rx: AsyncMutex>, - /// Installed for the duration of a network scan; the receive loop forwards decoded - /// beacons here while it is set. - network_scan_tx: Mutex>>, + pub notifications: Mutex>, + notification_wake: Notify, + pub raw_frame_rx: AsyncMutex, + pub reset_rx: AsyncMutex, + /// Whether a network scan is collecting. The receive loop only queues beacons while + /// this is set, so stray beacons outside a scan are dropped. + scan_active: AtomicBool, + pub scan_beacons: Mutex>, + scan_beacon_wake: Notify, /// Wakes the task that rewrites the RCP source address match table whenever the /// set of devices with queued indirect transactions changes @@ -597,7 +831,7 @@ pub struct ZigbeeStack { pub(crate) src_match_written: Mutex, /// When the last parent announcement was received; ours is deferred to avoid a /// network-wide broadcast storm (spec 2.4.3.1.12.2) - pub(crate) parent_annce_received: Mutex>, + pub(crate) parent_annce_received: Mutex>, /// Wakes the MTORR scheduler before its max interval when accumulated route /// errors or delivery failures cross their thresholds @@ -606,60 +840,152 @@ pub struct ZigbeeStack { /// Signaled whenever a link status command is digested; the MTORR startup wait /// uses it to advertise as soon as a neighbor link is established pub(crate) link_status_received: Notify, - /// Signaled on every recorded broadcast passive ack, so retransmission loops can - /// re-evaluate completeness reactively instead of sleeping out the window - pub(crate) broadcast_acked: Notify, + /// Wakes the broadcast-retransmit reactor: signaled on every recorded passive ack + /// and whenever a broadcast is queued for retransmission. + pub(crate) broadcast_retransmit_wake: Notify, + /// Wakes the unicast-retry reactor whenever a failed unicast is parked for a later + /// re-enqueue. + pub(crate) unicast_retry_wake: Notify, + /// Wakes the APS-ack timeout reactor when a fire-and-forget send registers a pending + /// ack with a deadline. + pub(crate) aps_ack_wake: Notify, + /// Wakes the beacon-spam reactor when a beacon request opens its spray window. + pub(crate) beacon_spam_wake: Notify, /// Wakes the maintenance task when a new indirect transaction or child entry /// could move the earliest expiry deadline closer pub(crate) maintenance_wake: Notify, - /// All tasks spawned by the stack, so that a replaced stack can be fully stopped: - /// a leaked background task would keep the replaced stack processing frames and - /// transmitting alongside its successor - background_tasks: Mutex>, + /// Outgoing frames awaiting the single sender task, ordered by priority then FIFO. + /// The sender encrypts at dequeue, so frame-counter order matches on-air order. + pub send_queue: Mutex>, + /// Wakes the sender task when a frame is enqueued. + pub(crate) send_wake: Notify, + /// Wakes the pending-route reactor when a frame is queued awaiting a route, or when a + /// route is established for a destination with queued frames. + pub(crate) pending_route_wake: Notify, + /// Monotonic tiebreaker giving equal-priority sends FIFO order in `send_queue`. + pub(crate) send_seq: AtomicU32, + + /// Spawns and owns the stack's background tasks, so that a replaced stack can be fully + /// stopped: a leaked background task would keep the replaced stack processing frames + /// and transmitting alongside its successor. + spawner: R::Spawner, + + /// Per-task cancel signals, keyed by task id. + cancels: Mutex>>, + /// Hands each spawned task a unique id for the `cancels` map. + next_task_id: AtomicU32, + /// Woken whenever a task removes itself from `cancels`, so `shutdown` can await the + /// set draining to empty. + tasks_drained: Notify, } -impl ZigbeeStack

{ +impl ZigbeeStack { /// Briefly lock the protocol core. See [`CoreGuard`] for the locking discipline the /// returned guard encodes. fn core(&self) -> CoreGuard<'_> { - CoreGuard(self.state.core.try_lock_for(LOCK_ACQUIRE_TIMEOUT).unwrap()) + CoreGuard(self.state.core.lock()) + } + + /// The sans-io core's clock reads as microseconds since this stack started. This + /// converts the runtime clock to it, at the one boundary where the driver reads the + /// clock; every driver-side deadline is then a [`CoreInstant`] and no reverse + /// conversion is needed (deadlines are slept as a duration-from-now). + fn to_core_instant(&self, t: R::Instant) -> CoreInstant { + let micros = t.saturating_duration_since(self.start_time).as_micros(); + CoreInstant::from_micros(micros as u64) + } + + fn core_now(&self) -> CoreInstant { + self.to_core_instant(R::now()) + } + + /// Sleep until a [`CoreInstant`] deadline, computed as the remaining duration from + /// now. Past deadlines resolve immediately. + async fn sleep_until_core(&self, deadline: CoreInstant) { + R::sleep(deadline.saturating_duration_since(self.core_now())).await; + } + + /// Run `future`, failing with [`Elapsed`] if a [`CoreInstant`] deadline passes first. + async fn timeout_at_core( + &self, + deadline: CoreInstant, + future: F, + ) -> Result + where + F: Future + Send, + F::Output: Send, + { + R::timeout(deadline.saturating_duration_since(self.core_now()), future).await } pub fn new( radio: Arc

, config: NetworkConfig, tunables: Tunables, - ) -> (Arc, broadcast::Receiver) { - let (notification_tx, notification_rx) = broadcast::channel::(32); - - let (raw_frame_tx, raw_frame_rx) = mpsc::channel::(32); - radio.set_rx_sink(raw_frame_tx); + spawner: R::Spawner, + ) -> Arc { + let raw_frame_rx = radio.subscribe_rx(); + let reset_rx = radio.subscribe_reset(); - let (reset_tx, reset_rx) = mpsc::channel::(8); - radio.set_reset_sink(reset_tx); - - let arc_stack = Arc::new_cyclic(|weak_self| Self { + Arc::new_cyclic(|weak_self| Self { self_weak: weak_self.clone(), + start_time: R::now(), state: State::new(&config, &tunables), config, tunables, radio, - notification_tx, + notifications: Mutex::new(VecDeque::new()), + notification_wake: Notify::new(), raw_frame_rx: AsyncMutex::new(raw_frame_rx), reset_rx: AsyncMutex::new(reset_rx), - network_scan_tx: Mutex::new(None), + scan_active: AtomicBool::new(false), + scan_beacons: Mutex::new(VecDeque::new()), + scan_beacon_wake: Notify::new(), src_match_sync: Notify::new(), src_match_written: Mutex::new(SrcMatchTable::default()), parent_annce_received: Mutex::new(None), mtorr_kick: Notify::new(), link_status_received: Notify::new(), - broadcast_acked: Notify::new(), + broadcast_retransmit_wake: Notify::new(), + unicast_retry_wake: Notify::new(), + aps_ack_wake: Notify::new(), + beacon_spam_wake: Notify::new(), maintenance_wake: Notify::new(), - background_tasks: Mutex::new(JoinSet::new()), - }); + send_queue: Mutex::new(BinaryHeap::new()), + send_wake: Notify::new(), + pending_route_wake: Notify::new(), + send_seq: AtomicU32::new(0), + spawner, + cancels: Mutex::new(BTreeMap::new()), + next_task_id: AtomicU32::new(0), + tasks_drained: Notify::new(), + }) + } + + /// Queue a network event and wake the notification drainer. Bounded: when the queue is + /// full (the client isn't draining the link) the oldest event is dropped rather than + /// growing the queue without bound, which would exhaust the heap on the MCU. + pub(crate) fn push_notification(&self, notification: ZigbeeNotification) { + let mut notifications = self.notifications.lock(); + while notifications.len() >= NOTIFICATION_QUEUE_CAP { + notifications.pop_front(); + } + notifications.push_back(notification); + drop(notifications); - (arc_stack, notification_rx) + self.notification_wake.notify_one(); + } + + /// Wait for and take all queued network events. + pub async fn next_notifications(&self) -> Vec { + loop { + let batch: Vec = self.notifications.lock().drain(..).collect(); + if !batch.is_empty() { + return batch; + } + self.notification_wake.notified().await; + } } // This function intentionally holds locks across await points to maintain @@ -727,7 +1053,7 @@ impl ZigbeeStack

{ continue; } Ok(ApsFrame::Command(cmd)) => { - self.handle_aps_command_frame(&nwk_frame, &cmd, None); + self.handle_aps_command_frame(&nwk_frame, &cmd, None, None); continue; } Ok(ApsFrame::EncryptedCommand(encrypted_cmd)) => { @@ -740,7 +1066,7 @@ impl ZigbeeStack

{ } }; - tracing::debug!("Received APS data frame: {aps_frame:?}"); + tracing::trace!("Received APS data frame: {aps_frame:?}"); // Spec 2.2.8.4.2: a retransmission is still acknowledged so the // sender stops, but must not be delivered to the application twice. @@ -777,7 +1103,7 @@ impl ZigbeeStack

{ rssi: packet.rssi, data: aps_frame.asdu.to_vec(), }; - let _ = self.notification_tx.send(notification); + self.push_notification(notification); } ziggurat_ieee_802154::Ieee802154Frame::Ack(_ack_frame) => {} ziggurat_ieee_802154::Ieee802154Frame::Beacon(beacon_frame) => { @@ -806,7 +1132,7 @@ impl ZigbeeStack

{ match Ieee802154Frame::from_bytes_without_fcs(&packet.psdu) { Ok(frame) => { - tracing::debug!("Received 802.15.4 frame: {frame:?}"); + tracing::trace!("Received 802.15.4 frame: {frame:?}"); return (packet, frame); } Err(e) => { @@ -821,6 +1147,73 @@ impl ZigbeeStack

{ self.reset_radio().await?; self.apply_radio_configuration().await?; + // The single sender task drains the transmit queue; it must run before anything + // enqueues a frame (the initial link status broadcast below would otherwise + // block on a completion nobody resolves). + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.sender_task().await; + }); + + // Drains frames queued awaiting route discovery, and discards them when + // discovery is exhausted. Must run before anything can queue one. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.pending_route_task().await; + }); + + // Retransmits broadcasts until their passive-ack quorum is heard or attempts run + // out. Must run before anything can queue a broadcast. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.broadcast_retransmit_task().await; + }); + + // Re-enqueues failed unicasts after their retry delay, so the sender task never + // sleeps mid-queue. Must run before anything can queue a unicast. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.unicast_retry_task().await; + }); + + // Times out fire-and-forget APS sends whose ack never arrived, reporting the + // outcome as a notification. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.aps_ack_timeout_task().await; + }); + + // Sprays beacons while a beacon-spam window is open (the hack_beacon_spam_duration + // hack). Idle unless beacon requests open the window. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.beacon_spam_task().await; + }); + // To kick things off, send a link status broadcast. Silicon Labs routers will // "respond" to empty link status broadcasts proactively, independent of the // link status period @@ -900,7 +1293,7 @@ impl ZigbeeStack

{ for attempt in 1..=RESET_ATTEMPTS { self.radio.reset().await?; - match timeout(RESET_NOTIFICATION_TIMEOUT, reset_rx.recv()).await { + match R::timeout(RESET_NOTIFICATION_TIMEOUT, reset_rx.recv()).await { Ok(Some(event)) => { tracing::info!("Radio reset complete: {:?}", event.reason); return Ok(()); @@ -944,10 +1337,7 @@ impl ZigbeeStack

{ self.radio.reconfigure(&config).await?; - *self - .src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = table; + *self.src_match_written.lock() = table; Ok(()) } @@ -971,15 +1361,16 @@ impl ZigbeeStack

{ while let Err(err) = self.apply_radio_configuration().await { tracing::error!("Failed to reprogram the radio: {err}, retrying"); - sleep(RADIO_RECOVERY_RETRY_INTERVAL).await; + R::sleep(RADIO_RECOVERY_RETRY_INTERVAL).await; } tracing::info!("Radio reprogrammed, resuming normal operation"); } } - /// Decode a received beacon and, if a network scan is in flight, forward it to the - /// scan's collector. Beacons received outside a scan are dropped. + /// Decode a received beacon and, if a network scan is in flight, collect it into + /// the scan's outbox for the collector to drain. Beacons received outside a scan + /// are dropped. fn handle_beacon( &self, beacon: &ziggurat_ieee_802154::Ieee802154BeaconFrame, @@ -987,11 +1378,12 @@ impl ZigbeeStack

{ lqi: u8, rssi: i8, ) { - let Some(tx) = self.network_scan_tx.lock().clone() else { + // Skip the decode entirely when no scan is collecting. + if !self.scan_active.load(AtomicOrdering::Relaxed) { return; - }; + } - let payload = match ZigbeeBeacon::from_abstract_bits(&beacon.beacon_payload) { + let payload = match ZigbeeBeacon::from_abstract_bytes(&beacon.beacon_payload) { Ok(payload) => payload, Err(e) => { tracing::debug!("Ignoring non-Zigbee beacon: {e:?}"); @@ -1007,7 +1399,7 @@ impl ZigbeeStack

{ return; }; - let _ = tx.try_send(NetworkBeacon { + let network_beacon = NetworkBeacon { channel, source, pan_id, @@ -1021,18 +1413,25 @@ impl ZigbeeStack

{ update_id: payload.update_id, lqi, rssi, - }); + }; + + self.scan_beacons.lock().push_back(network_beacon); + self.scan_beacon_wake.notify_one(); + } + + /// Open the beacon-collection window for an active scan. + pub fn begin_network_scan(&self) { + self.scan_beacons.lock().clear(); + self.scan_active.store(true, AtomicOrdering::Relaxed); } - /// Active scan: broadcast a beacon request on each channel and collect the beacons. - pub async fn network_scan( + /// Active scan: broadcast a beacon request on each channel and dwell to collect + /// beacons. + pub async fn run_network_scan( &self, channels: &[u8], duration_per_channel: Duration, - found: mpsc::Sender, ) -> Result<(), ZigbeeStackError> { - *self.network_scan_tx.lock() = Some(found); - let beacon_request = self.beacon_request_psdu(); let home_channel = self.core().mac.channel; @@ -1050,35 +1449,45 @@ impl ZigbeeStack

{ security_processed: true, }) .await?; - sleep(duration_per_channel).await; + R::sleep(duration_per_channel).await; } // Leave the radio on the home channel before releasing it. radio.set_channel(home_channel).await } .await; - *self.network_scan_tx.lock() = None; + // Close the window and wake the drainer so it delivers the last beacons and stops. + self.scan_active.store(false, AtomicOrdering::Relaxed); + self.scan_beacon_wake.notify_one(); result.map_err(Into::into) } - /// Performs an energy detect scan, sending the maximum RSSI seen on each channel to - /// `results` as that channel completes. - pub async fn energy_scan( - &self, - channels: &[u8], - duration_per_channel: Duration, - results: mpsc::Sender<(u8, i8)>, - ) -> Result<(), ZigbeeStackError> { - for &channel in channels { - let max_rssi = self - .radio - .energy_detect(channel, duration_per_channel) - .await?; - let _ = results.send((channel, max_rssi)).await; + /// Wait for and take beacons collected so far by the active scan. Drains any + /// remaining beacons even after the window closes, then returns empty once both + /// the window is closed. + pub async fn next_scan_beacons(&self) -> Vec { + loop { + let batch: Vec = self.scan_beacons.lock().drain(..).collect(); + if !batch.is_empty() { + return batch; + } + if !self.scan_active.load(AtomicOrdering::Relaxed) { + return Vec::new(); + } + self.scan_beacon_wake.notified().await; } + } - Ok(()) + /// One channel of an energy-detect scan: the maximum RSSI seen on `channel`. The + /// manager loops over channels and streams the results; no radio state is held + /// between calls. + pub async fn energy_detect( + &self, + channel: u8, + duration: Duration, + ) -> Result { + Ok(self.radio.energy_detect(channel, duration).await?) } /// Retune the radio to a new channel, the coordinator's half of a network-wide @@ -1096,57 +1505,53 @@ impl ZigbeeStack

{ self.core().nib.update_id = update_id; } - /// Spawns a task tied to the stack's lifetime: it is aborted on `shutdown`. + /// Spawns a task tied to the stack's lifetime: it is stopped on `shutdown`. pub fn spawn_tracked(&self, future: F) where F: Future + Send + 'static, { - let mut tasks = self - .background_tasks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - - // A completed task's entire cell is retained until it is reaped from the - // set: drain here so the set tracks live tasks instead of growing by one - // dead entry per spawn - while let Some(result) = tasks.try_join_next() { - if let Err(e) = result - && e.is_panic() + let id = self.next_task_id.fetch_add(1, AtomicOrdering::Relaxed); + let cancel = Arc::new(Notify::new()); + self.cancels.lock().insert(id, cancel.clone()); + + // Hold a weak handle so a task that outlives `shutdown` (or finishes naturally) can + // still deregister without keeping the stack alive. + let weak = self.self_weak.clone(); + + self.spawner.spawn(Box::pin(async move { + // Run the task until it finishes or `shutdown` cancels it. Dropping the task + // future at an await point is safe: the stack never holds the blocking core + // lock across an await (enforced by `CoreGuard` being `!Send`). { - tracing::error!("Background task panicked: {e}"); + let future = core::pin::pin!(future); + let cancelled = core::pin::pin!(cancel.notified()); + let _ = futures::future::select(future, cancelled).await; } - } - tasks.spawn(future); - } - - /// Spawns a tracked task that needs an owned handle to the stack. - fn spawn_tracked_self(&self, f: F) - where - F: FnOnce(Arc) -> Fut, - Fut: Future + Send + 'static, - { - let this = self - .self_weak - .upgrade() - .expect("stack dropped while running"); - - self.spawn_tracked(f(this)); + if let Some(stack) = weak.upgrade() { + stack.cancels.lock().remove(&id); + stack.tasks_drained.notify_one(); + } + })); } /// Stops all of the stack's tasks and waits for them to terminate, so that a /// replaced stack provably stops processing frames and transmitting before its /// successor takes over the shared Spinel client. pub async fn shutdown(&self) { - let mut tasks = std::mem::take( - &mut *self - .background_tasks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(), - ); - - tasks.abort_all(); - while tasks.join_next().await.is_some() {} + // Cooperatively cancel every tracked task, then wait for each to deregister. This + // is what stops the tasks on embassy, where the spawner cannot abort them. + for cancel in self.cancels.lock().values() { + cancel.notify_one(); + } + + while !self.cancels.lock().is_empty() { + self.tasks_drained.notified().await; + } + + // On executors that can abort (tokio), this also reaps the finished JoinSet + // entries; on embassy it is a no-op. + self.spawner.shutdown().await; } pub fn next_aps_counter(&self) -> u8 { @@ -1160,11 +1565,9 @@ impl ZigbeeStack

{ let advance = self.core().nib.nwk_security.next_outgoing_frame_counter(); if advance.should_persist { - let _ = self - .notification_tx - .send(ZigbeeNotification::FrameCounterUpdate { - frame_counter: advance.value, - }); + self.push_notification(ZigbeeNotification::FrameCounterUpdate { + frame_counter: advance.value, + }); } advance.value diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 839bbe9..a5c2af4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -1,23 +1,28 @@ +use crate::runtime::Runtime; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_zigbee::aps::frame::{ ApsAckFrame, ApsAckFrameControl, ApsDataFrame, ApsDeliveryMode, ApsFrameControl, ApsFrameType, EncryptedApsAckFrame, EncryptedApsDataFrame, }; -use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery}; +use ziggurat_zigbee::nwk::frame::{ + BROADCAST_LOW_POWER_ROUTERS, BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery, +}; -use std::cmp; -use std::collections::hash_map::Entry; -use tokio::sync::oneshot; -use tokio::time::Instant; -use ziggurat_phy::{RadioPhy, TxPriority}; +use alloc::collections::btree_map::Entry; +use alloc::string::ToString; +use alloc::vec::Vec; +use core::cmp; +use core::time::Duration; +use ziggurat_phy::RadioPhy; +use ziggurat_zigbee::Instant as CoreInstant; use super::{ - ApsAck, ApsAckData, ApsAckWaiter, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, ZigbeeStack, - ZigbeeStackError, + ApsAck, ApsAckData, ApsAckResult, NwkSecurityMode, PendingApsAck, RequestId, SendMode, + TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; -impl ZigbeeStack

{ +impl ZigbeeStack { /// The EUI64 an inbound secured APS frame was encrypted by: the auxiliary header's /// extended source when present, otherwise resolved from the NWK frame (spec /// 4.4.1.2 step 2). @@ -74,19 +79,18 @@ impl ZigbeeStack

{ .decrypt_ack(source, frame, &network_key) } - /// Resolve an inbound APS ACK against the pending transmissions waiting for it. + /// Resolve an inbound APS ACK against the pending transmissions waiting for it: wake an + /// awaiting caller, or push the delivery outcome for a fire-and-forget send. pub(super) fn handle_aps_ack(&self, nwk_frame: &NwkFrame, ack: &ApsAckFrame) { let ack_data = ApsAckData::from_aps_ack(nwk_frame.nwk_header.source, ack); - tracing::debug!("Received APS ack: {ack_data:?}"); - - let tx = self - .state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&ack_data); - if let Some(tx) = tx { - let _ = tx.send(()); + tracing::trace!("Received APS ack: {ack_data:?}"); + + let pending = self.state.pending_aps_acks.lock().remove(&ack_data); + if let Some(PendingApsAck { request_id, .. }) = pending { + self.push_notification(ZigbeeNotification::ApsAckConfirm { + request_id, + result: ApsAckResult::Acked, + }); } } @@ -95,15 +99,11 @@ impl ZigbeeStack

{ /// stops retransmitting, but must not reach the application twice. Expired entries /// are swept on each call. pub(super) fn is_duplicate_aps_frame(&self, source: Nwk, counter: u8) -> bool { - let now = Instant::now(); + let now = self.core_now(); let timeout = self.tunables.aps_duplicate_rejection_timeout; - let mut table = self - .state - .aps_duplicates - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - table.retain(|_, seen| now.duration_since(*seen) < timeout); + let mut table = self.state.aps_duplicates.lock(); + table.retain(|_, seen| now.saturating_duration_since(*seen) < timeout); match table.entry((source, counter)) { Entry::Occupied(mut slot) => { @@ -174,16 +174,14 @@ impl ZigbeeStack

{ self.background_send_nwk_frame(aps_ack_frame, NwkSecurityMode::NetworkKey, SendMode::Route); } - /// Send an APS data frame, returning once it has been transmitted (including - /// route discovery and the NWK retry loop; for sleepy children, once the frame is - /// extracted from the indirect queue). When an APS ack was requested, the - /// returned waiter resolves the end-to-end delivery via - /// [`ZigbeeStack::wait_aps_ack`]. + /// Build the NWK frame carrying an APS data frame, plus the ack-correlation data when + /// an end-to-end ack was requested. Shared by the awaiting [`Self::send_aps_command`] + /// and the fire-and-forget [`Self::send_aps`]. /// /// `aps_security` requests APS encryption of the ASDU with the link key shared /// with that device (unicast only: link keys are pairwise). #[allow(clippy::too_many_arguments)] - pub async fn send_aps_command( + pub(super) fn prepare_aps_send( &self, delivery_mode: ApsDeliveryMode, destination: Nwk, @@ -196,8 +194,7 @@ impl ZigbeeStack

{ aps_seq: u8, data: Vec, aps_security: Option, - priority: TxPriority, - ) -> Result, ZigbeeStackError> { + ) -> Result<(NwkFrame, Option), ZigbeeStackError> { let asdu = FrameBytes::from_slice(&data).map_err(|_| ZigbeeStackError::PayloadTooLong)?; let aps_frame = match delivery_mode { @@ -216,7 +213,7 @@ impl ZigbeeStack

{ profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, ApsDeliveryMode::Broadcast => ApsDataFrame { frame_control: ApsFrameControl { @@ -233,7 +230,7 @@ impl ZigbeeStack

{ profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, ApsDeliveryMode::Multicast => ApsDataFrame { frame_control: ApsFrameControl { @@ -250,11 +247,11 @@ impl ZigbeeStack

{ profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, }; - tracing::debug!("Prepared APS frame: {aps_frame:?}"); + tracing::trace!("Prepared APS frame: {aps_frame:?}"); let aps_payload = if let Some(destination_eui64) = aps_security { let encrypted = self @@ -262,7 +259,6 @@ impl ZigbeeStack

{ .aib .aps_security .encrypt_data(destination_eui64, &aps_frame); - match encrypted { Some(encrypted) => encrypted.to_bytes(), None => return Err(ZigbeeStackError::ApsSecurityFailed), @@ -284,84 +280,171 @@ impl ZigbeeStack

{ .with_discover_route(NwkRouteDiscovery::Enable) .with_radius(cmp::max(radius, 1)); - if aps_ack == ApsAck::None { - self.send_nwk_frame( - nwk_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route, - priority, - ) - .await?; - return Ok(None); - } - - let ack_data = ApsAckData { + let ack_data = (aps_ack == ApsAck::Request).then_some(ApsAckData { src: destination, destination_endpoint: Some(src_ep), // These are swapped cluster_id: Some(cluster_id), profile_id: Some(profile_id), source_endpoint: Some(dst_ep), // These are swapped counter: aps_seq, - }; + }); - let (ack_tx, ack_rx) = oneshot::channel(); + Ok((nwk_frame, ack_data)) + } - tracing::debug!("APS ACK requested, waiting for {ack_data:?}"); - { - self.state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .insert(ack_data.clone(), ack_tx); + /// How long to wait for a device's APS ack: longer for a sleepy child, which only + /// sees (and acks) the frame after polling. + fn aps_ack_timeout(&self, destination: Nwk) -> Duration { + if self.sleepy_child_eui64(destination).is_some() { + self.tunables.aps_ack_timeout_indirect + } else { + self.tunables.aps_ack_timeout } + } - if let Err(err) = self - .send_nwk_frame( + /// Build and enqueue the frame, then return an accept or reject. Delivery is + /// confirmed later as a [`ZigbeeNotification::SendConfirm`] carrying `request_id`, + /// triggered by the frame type: passive-ack quorum for a broadcast, next-hop + /// acceptance for a no-ack unicast, or the APS ack for an ack unicast. + #[allow(clippy::too_many_arguments)] + pub fn send_aps( + &self, + delivery_mode: ApsDeliveryMode, + destination: Nwk, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: ApsAck, + radius: u8, + aps_seq: u8, + data: Vec, + aps_security: Option, + priority: TxPriority, + request_id: RequestId, + ) -> Result<(), ZigbeeStackError> { + let (nwk_frame, ack_data) = self.prepare_aps_send( + delivery_mode, + destination, + profile_id, + cluster_id, + src_ep, + dst_ep, + aps_ack, + radius, + aps_seq, + data, + aps_security, + )?; + + // An APS-ack send is confirmed by the end-to-end ack: register it (with the + // deadline the timeout reactor uses) before enqueueing so a fast reply is caught. + if let Some(ack_data) = &ack_data { + let deadline = self.core_now() + self.aps_ack_timeout(destination); + self.state.pending_aps_acks.lock().insert( + ack_data.clone(), + PendingApsAck { + request_id, + deadline, + }, + ); + self.aps_ack_wake.notify_one(); + } + + self.enqueue_aps_frame( + nwk_frame, + priority, + TxOutcome::Confirm { + request_id, + aps_ack: ack_data, + }, + ); + Ok(()) + } + + /// Enqueue a built APS/NWK frame fire-and-forget, routing broadcasts and unicasts like + /// [`send_nwk_frame`](Self::send_nwk_frame). The `outcome` rides the unicast path (the + /// sender confirms next-hop acceptance / failure); a broadcast is confirmed by the + /// retransmit reactor on quorum, so only its `request_id` is carried over. + pub(super) fn enqueue_aps_frame( + &self, + nwk_frame: NwkFrame, + priority: TxPriority, + outcome: TxOutcome, + ) { + if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { + let request_id = match outcome { + TxOutcome::Confirm { request_id, .. } => Some(request_id), + TxOutcome::Discard | TxOutcome::Signal(_) => None, + }; + self.send_broadcast_nwk_frame( + nwk_frame, + NwkSecurityMode::NetworkKey, + priority, + request_id, + ); + } else { + self.originate_unicast( nwk_frame, NwkSecurityMode::NetworkKey, SendMode::Route, priority, - ) - .await - { - self.state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&ack_data); - return Err(err); + outcome, + ); } + } - // A sleepy child only sees the frame (and acks it) after polling - let timeout = if self.sleepy_child_eui64(destination).is_some() { - self.tunables.aps_ack_timeout_indirect - } else { - self.tunables.aps_ack_timeout - }; + /// The APS-ack timeout reactor: sleeps to the earliest pending send's deadline, then + /// fails any whose ack never arrived. + pub(super) async fn aps_ack_timeout_task(&self) { + loop { + match self.earliest_aps_ack_deadline() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.aps_ack_wake.notified()) + .await; + } + None => self.aps_ack_wake.notified().await, + } + + self.expire_aps_acks(); + } + } - Ok(Some(ApsAckWaiter { - receiver: ack_rx, - timeout, - ack_data, - })) + fn earliest_aps_ack_deadline(&self) -> Option { + self.state + .pending_aps_acks + .lock() + .values() + .map(|pending| pending.deadline) + .min() } - /// Wait for the end-to-end APS ack of a previously transmitted frame. - pub async fn wait_aps_ack(&self, waiter: ApsAckWaiter) -> Result<(), ZigbeeStackError> { - match tokio::time::timeout(waiter.timeout, waiter.receiver).await { - Ok(Ok(())) => { - tracing::debug!("APS ACK received"); - Ok(()) - } - Ok(Err(_)) | Err(_) => { - tracing::warn!("APS ACK timed out for {:?}", waiter.ack_data); - self.state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&waiter.ack_data); - Err(ZigbeeStackError::ApsAckTimeout) + fn expire_aps_acks(&self) { + let now = self.core_now(); + + let expired: Vec = { + let mut pending = self.state.pending_aps_acks.lock(); + let due: Vec<(ApsAckData, RequestId)> = pending + .iter() + .filter(|(_, p)| p.deadline <= now) + .map(|(key, p)| (key.clone(), p.request_id)) + .collect(); + for (key, _) in &due { + pending.remove(key); } + drop(pending); + due.into_iter().map(|(_, request_id)| request_id).collect() + }; + + for request_id in expired { + tracing::warn!("APS ack timed out for send {request_id}"); + self.push_notification(ZigbeeNotification::ApsAckConfirm { + request_id, + result: ApsAckResult::Failed { + reason: "APS ack timed out".to_string(), + }, + }); } } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index c8ce6e7..fe77ace 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,53 +1,46 @@ +use crate::runtime::Runtime; +use crate::signal; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; -use tokio::sync::oneshot; -use tokio::time::{Instant, timeout_at}; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; -use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, IndirectCompletion, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, - ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, TxOutcome, + TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; -const fn set_frame_pending(frame: &mut Ieee802154Frame) { - match frame { - Ieee802154Frame::Data(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Ack(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Beacon(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Command(f) => f.header.frame_control.frame_pending = true, +impl ZigbeeStack { + /// Queue a frame for a polling device (802.15.4 spec 6.7.3), under its own + /// `poll_address`. Its `outcome` is resolved when the device extracts the frame, or + /// with an error on expiry or eviction. + pub(super) fn enqueue_indirect_frame(&self, frame: IndirectFrame, outcome: TxOutcome) { + self.core() + .mac + .indirect_queue + .push(frame.poll_address, frame, outcome, self.core_now()); + + self.src_match_sync.notify_one(); + self.maintenance_wake.notify_one(); } -} -impl ZigbeeStack

{ - /// Queue a finished 802.15.4 frame for indirect delivery and wait for the - /// destination to extract it with a MAC Data Request, or for the transaction to - /// expire (802.15.4 spec 6.7.3). There is no retry loop here: the destination - /// re-polling is the retry mechanism, expiry is the failure signal. + /// Queue a frame and await its delivery: resolves once the device extracts it, or + /// with an error on expiry or eviction. A dropped sender (stack shutdown) reads as + /// expiry. pub(super) async fn queue_indirect_frame( &self, - destination: Ieee802154Address, - frame: Ieee802154Frame, + frame: IndirectFrame, ) -> Result<(), ZigbeeStackError> { - let (completion, result_rx) = oneshot::channel(); - - self.core().mac.indirect_queue.push( - destination, - frame, - completion, - Instant::now().into_std(), - ); - - self.src_match_sync.notify_one(); - self.maintenance_wake.notify_one(); - - // Every transaction is eventually resolved by delivery, the expiry sweep, or - // child eviction; a dropped sender means the stack is shutting down - result_rx + let destination = frame.poll_address; + let (completion, waiter) = signal::channel(); + self.enqueue_indirect_frame(frame, TxOutcome::Signal(completion)); + waiter + .wait() .await .unwrap_or(Err(ZigbeeStackError::IndirectExpired { destination })) } @@ -77,7 +70,7 @@ impl ZigbeeStack

{ let known_device = self.core().nib.neighbors.refresh_child_timeout( source_eui64, source_nwk, - Instant::now().into_std(), + self.core_now(), ); // The RCP only told the device to keep listening (frame-pending=1 in the @@ -85,12 +78,8 @@ impl ZigbeeStack

{ // source address match table. If that write is still in flight, the device is // asleep again by now: everything stays queued for the next poll instead of // being transmitted into the void. - let fp_advertised = poll_source.is_some_and(|address| { - self.src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .contains(address) - }); + let fp_advertised = + poll_source.is_some_and(|address| self.src_match_written.lock().contains(address)); let delivered = fp_advertised && self.deliver_indirect_transaction(source_eui64, source_nwk); @@ -113,16 +102,18 @@ impl ZigbeeStack

{ source_eui64: Option, source_nwk: Option, ) -> bool { - let outcome = self.core().mac.indirect_queue.extract( - source_eui64, - source_nwk, - Instant::now().into_std(), - ); + let outcome = + self.core() + .mac + .indirect_queue + .extract(source_eui64, source_nwk, self.core_now()); for (destination, transaction) in outcome.expired { - let _ = transaction - .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + None, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } let Some(delivery) = outcome.delivery else { @@ -146,31 +137,54 @@ impl ZigbeeStack

{ true } - async fn transmit_indirect_transaction(&self, delivery: Delivery) { + async fn transmit_indirect_transaction(&self, delivery: Delivery) { let Delivery { destination, transaction, more_pending, } = delivery; - let mut frame = transaction.frame.clone(); + // Finish a deferred NWK frame now, at delivery time: this is where its frame + // counter is assigned (via `encrypt_nwk_frame`), just before the frame hits the + // air, rather than when it was queued. The transaction is left intact (we clone + // out of it) so a failed transmit can requeue the plaintext and earn a fresh + // counter on the next poll. + let mut frame = match &transaction.frame.payload { + IndirectPayload::Deferred { + nwk_frame, + next_hop, + security, + } => self.finish_unicast_nwk_frame(nwk_frame.clone(), *next_hop, *security), + IndirectPayload::Final(frame) => frame.clone(), + }; + if more_pending { - set_frame_pending(&mut frame); + match frame { + Ieee802154Frame::Data(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Ack(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Beacon(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Command(ref mut f) => f.header.frame_control.frame_pending = true, + } } - // Indirect delivery answers a sleepy child's poll within macResponseWaitTime — a - // deadline-bound path, so it takes the radio ahead of the baseline backlog. + // Indirect delivery answers a sleepy child's poll within `macResponseWaitTime` + let raw_frame = Ieee802154Frame::from_bytes_without_fcs(&frame.to_bytes_without_fcs()) + .expect("a built indirect frame round-trips through bytes"); + match self - .send_802154_frame(frame, TxPriority::STACK_CRITICAL) + .send( + SendKind::Raw { frame: raw_frame }, + TxPriority::STACK_CRITICAL, + ) .await { Ok(()) => { - let _ = transaction.completion.send(Ok(())); + self.resolve_outcome(transaction.completion, None, Ok(())); self.remove_indirect_queue_if_empty(destination); } // 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, // so a failed transmit goes back to the head of the queue for the next poll - Err(err) if Instant::now().into_std() < transaction.expires_at => { + Err(err) if self.core_now() < transaction.expires_at => { tracing::warn!("Indirect transmit to {destination:?} failed ({err}), requeueing"); self.core() .mac @@ -178,7 +192,7 @@ impl ZigbeeStack

{ .requeue(destination, transaction); } Err(err) => { - let _ = transaction.completion.send(Err(err)); + self.resolve_outcome(transaction.completion, None, Err(err)); self.remove_indirect_queue_if_empty(destination); } } @@ -199,9 +213,11 @@ impl ZigbeeStack

{ } for (destination, transaction) in dropped { - let _ = transaction - .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + None, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } self.src_match_sync.notify_one(); @@ -239,10 +255,16 @@ impl ZigbeeStack

{ .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - let frame = - arc_self.finish_unicast_nwk_frame(nwk_frame, nwk, NwkSecurityMode::NetworkKey); - - if let Err(err) = arc_self.queue_indirect_frame(destination, frame).await { + let frame = IndirectFrame { + poll_address: destination, + payload: IndirectPayload::Deferred { + nwk_frame, + next_hop: nwk, + security: NwkSecurityMode::NetworkKey, + }, + }; + + if let Err(err) = arc_self.queue_indirect_frame(frame).await { tracing::debug!("Queued leave to {nwk:?} was not extracted: {err}"); } }); @@ -287,10 +309,7 @@ impl ZigbeeStack

{ .set_frame_pending_table(&short, &extended) .await?; - *self - .src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = table; + *self.src_match_written.lock() = table; Ok(()) } @@ -306,7 +325,9 @@ impl ZigbeeStack

{ match self.next_maintenance_deadline() { Some(deadline) => { - let _ = timeout_at(deadline, self.maintenance_wake.notified()).await; + let _ = self + .timeout_at_core(deadline, self.maintenance_wake.notified()) + .await; } None => self.maintenance_wake.notified().await, } @@ -315,30 +336,15 @@ impl ZigbeeStack

{ /// The earliest deadline the maintenance task has to act on: an indirect /// transaction expiry or a child keepalive timeout. - fn next_maintenance_deadline(&self) -> Option { - let next_expiry = self - .core() - .mac - .indirect_queue - .next_expiry() - .map(Instant::from_std); - - let next_eviction = self - .core() - .nib - .neighbors - .next_child_timeout() - .map(Instant::from_std); + fn next_maintenance_deadline(&self) -> Option { + let next_expiry = self.core().mac.indirect_queue.next_expiry(); + let next_eviction = self.core().nib.neighbors.next_child_timeout(); [next_expiry, next_eviction].into_iter().flatten().min() } fn expire_indirect_transactions(&self) { - let expired = self - .core() - .mac - .indirect_queue - .expire(Instant::now().into_std()); + let expired = self.core().mac.indirect_queue.expire(self.core_now()); if expired.is_empty() { return; @@ -346,9 +352,11 @@ impl ZigbeeStack

{ for (destination, transaction) in expired { tracing::warn!("Indirect transaction to {destination:?} expired without a poll"); - let _ = transaction - .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + None, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } self.src_match_sync.notify_one(); @@ -359,7 +367,7 @@ impl ZigbeeStack

{ .core() .nib .neighbors - .evict_timed_out_children(Instant::now().into_std()); + .evict_timed_out_children(self.core_now()); for (eui64, nwk) in evicted { tracing::warn!("Child {eui64:?} ({nwk:?}) timed out without a keepalive, evicting"); @@ -369,7 +377,7 @@ impl ZigbeeStack

{ self.drop_indirect_transactions(Some(eui64), nwk); self.core().nib.routing.remove_route(nwk); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk, ieee: Some(eui64), reason: DeviceLeaveReason::KeepaliveTimeout, diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 94dea16..110beff 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1,3 +1,4 @@ +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::commands::{ AssociationRequestDeviceType, Ieee802154AssociationRequestCommand, Ieee802154AssociationResponseCommand, @@ -17,11 +18,14 @@ use ziggurat_zigbee::aps::frame::{ ApsTunnelCommandFrame, ApsUpdateDeviceCommandFrame, ApsUpdateDeviceStatus, ApsVerifyKeyCommandFrame, EncryptedApsCommandFrame, }; +use ziggurat_zigbee::aps::security::AppliedKey; use ziggurat_zigbee::nwk::frame::{ - BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, + BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderKeyId, }; -use tokio::time::{Duration, Instant}; +use alloc::format; +use alloc::vec::Vec; +use core::time::Duration; use ziggurat_zigbee::nwk::commands::{ Nwk802154AssociationStatus, NwkCommand, NwkEndDeviceTimeoutRequestCommand, NwkEndDeviceTimeoutResponseCommand, NwkEndDeviceTimeoutResponseStatus, NwkLeaveCommand, @@ -30,11 +34,11 @@ use ziggurat_zigbee::nwk::commands::{ }; use super::{ - AddrConflictSource, DeviceLeaveReason, JoinKind, LOCK_ACQUIRE_TIMEOUT, NwkDeviceType, - NwkSecurityMode, RadioPhy, SendMode, ZigbeeNotification, ZigbeeStack, neighbors, + AddrConflictSource, DeviceLeaveReason, IndirectFrame, IndirectPayload, JoinKind, NwkDeviceType, + NwkSecurityMode, RadioPhy, SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, }; -impl ZigbeeStack

{ +impl ZigbeeStack { #[allow(clippy::significant_drop_tightening)] pub fn process_802154_association_request( &self, @@ -110,7 +114,7 @@ impl ZigbeeStack

{ device_timeout, relationship: neighbors::Relationship::Child, }, - Instant::now().into_std(), + self.core_now(), ); // A new child deadline may precede everything the maintenance task knows @@ -144,10 +148,11 @@ impl ZigbeeStack

{ .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - match arc_self - .queue_indirect_frame(Ieee802154Address::Eui64(eui64), response_frame) - .await - { + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(eui64), + payload: IndirectPayload::Final(response_frame), + }; + match arc_self.queue_indirect_frame(frame).await { Ok(()) => { // Zigbee spec 4.6.3.2: the network key is delivered once the // device has confirmed receipt of its short address @@ -170,7 +175,7 @@ impl ZigbeeStack

{ core.nib.address_map.allocate( eui64, &core.nib.neighbors, - std::iter::repeat_with(|| Nwk(rand::random::())), + core::iter::repeat_with(|| Nwk(crate::rng::random_u16())), ) } @@ -179,7 +184,7 @@ impl ZigbeeStack

{ core.nib.address_map.generate_unused( &core.nib.neighbors, - std::iter::repeat_with(|| Nwk(rand::random::())), + core::iter::repeat_with(|| Nwk(crate::rng::random_u16())), ) } @@ -188,13 +193,9 @@ impl ZigbeeStack

{ /// device children are moved to a fresh address; routers resolve on their own. pub(super) fn handle_address_conflict(&self, address: Nwk, source: AddrConflictSource) { { - let mut conflicts = self - .state - .address_conflicts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut conflicts = self.state.address_conflicts.lock(); - let now = Instant::now(); + let now = self.core_now(); let window = self.tunables.broadcast_delivery_time; // Detection re-triggers on every frame from the conflicted devices, so a @@ -250,19 +251,18 @@ impl ZigbeeStack

{ .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - tokio::time::sleep( + R::sleep( arc_self .tunables .max_broadcast_jitter - .mul_f32(rand::random::()), + .mul_f32(crate::rng::random_f32()), ) .await; let heard_from_network = arc_self .state .address_conflicts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .get(&address) .is_some_and(|conflict| conflict.heard_from_network); @@ -281,10 +281,13 @@ impl ZigbeeStack

{ }), ); - arc_self.background_send_nwk_frame( + // The retransmit reactor owns the rebroadcasts; this task only applies the + // jittered delay and the cancel-if-already-reported check above. + arc_self.send_broadcast_nwk_frame( conflict_frame, NwkSecurityMode::NetworkKey, - SendMode::Route, + TxPriority::USER_NORMAL, + None, ); }); } @@ -373,9 +376,7 @@ impl ZigbeeStack

{ if let Some(key) = provisional_key { tracing::info!("Device {ieee:?} is joining with its provisional link key"); - let _ = self - .notification_tx - .send(ZigbeeNotification::LinkKeyUpdate { ieee, key }); + self.push_notification(ZigbeeNotification::LinkKeyUpdate { ieee, key }); } } @@ -447,7 +448,7 @@ impl ZigbeeStack

{ self.background_send_nwk_frame(nwk_frame, NwkSecurityMode::Unsecured, SendMode::Direct); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: destination, ieee: destination_eui64, parent: self.state.network_address, @@ -475,9 +476,14 @@ impl ZigbeeStack

{ drop(core); match decrypted { - Some(command_frame) => { + Some((command_frame, applied_key)) => { tracing::debug!("Decrypted APS command frame: {command_frame:?}"); - self.handle_aps_command_frame(nwk_frame, &command_frame, Some(extended_source)); + self.handle_aps_command_frame( + nwk_frame, + &command_frame, + Some(extended_source), + Some(applied_key), + ); } None => { tracing::warn!( @@ -485,17 +491,15 @@ impl ZigbeeStack

{ nwk_frame.nwk_header.source, extended_source ); - let _ = self - .notification_tx - .send(ZigbeeNotification::ApsDecryptionFailure { - source: nwk_frame.nwk_header.source, - source_ieee: extended_source, - frame_counter: encrypted_command_frame.aux_header.frame_counter, - key_id: format!( - "{:?}", - encrypted_command_frame.aux_header.security_control.key_id - ), - }); + self.push_notification(ZigbeeNotification::ApsDecryptionFailure { + source: nwk_frame.nwk_header.source, + source_ieee: extended_source, + frame_counter: encrypted_command_frame.aux_header.frame_counter, + key_id: format!( + "{:?}", + encrypted_command_frame.aux_header.security_control.key_id + ), + }); } } } @@ -505,6 +509,8 @@ impl ZigbeeStack

{ nwk_frame: &NwkFrame, command_frame: &ApsCommandFrame, aps_source_ieee: Option, + // Which key decrypted this command, when it was APS-secured. + applied_key: Option, ) { let source = nwk_frame.nwk_header.source; @@ -524,7 +530,13 @@ impl ZigbeeStack

{ } ApsCommandFrameCommand::RequestKey(cmd) => { if self.state.role == NwkDeviceType::Coordinator { - self.handle_request_key(nwk_frame, command_frame, cmd, aps_source_ieee); + self.handle_request_key( + nwk_frame, + command_frame, + cmd, + aps_source_ieee, + applied_key, + ); } else { tracing::debug!("Ignoring request-key from {source:?}: not the trust center"); } @@ -551,7 +563,11 @@ impl ZigbeeStack

{ /// Send a serialized APS frame to an on-network device, with NWK security. Direct /// children do not participate in route discovery, so they are addressed directly. fn send_secured_aps_payload(&self, destination: Nwk, payload: Vec) { - let nwk_frame = self.nwk_data_frame(destination, payload); + // Routed delivery to a non-neighbor must be allowed to discover a route (NWK data + // frames default to suppressing discovery). + let nwk_frame = self + .nwk_data_frame(destination, payload) + .with_discover_route(NwkRouteDiscovery::Enable); self.background_send_nwk_frame( nwk_frame, @@ -565,14 +581,14 @@ impl ZigbeeStack

{ } /// Zigbee spec 4.7.3.8: a device requests a unique trust center link key to replace - /// the well-known key it joined with. The new key is delivered encrypted with the - /// key-load key derived from the device's current link key. + /// the well-known key it joined with. fn handle_request_key( &self, nwk_frame: &NwkFrame, command_frame: &ApsCommandFrame, request: &ApsRequestKeyCommandFrame, aps_source_ieee: Option, + applied_key: Option, ) { if request.key_type != ApsRequestKeyType::TrustCenterLinkKey { tracing::warn!( @@ -614,16 +630,19 @@ impl ZigbeeStack

{ return; } + // The reply reuses whichever key decrypted the request (spec 4.7.3.8 step 2): the + // key the device actually holds. A secured request always has one. + let Some(applied_key) = applied_key else { + return; + }; + tracing::info!("Sending a new trust center link key to {source_ieee:?}"); - // The new key is delivered encrypted with the key it replaces - let mut core = self.core(); - let current_key = core.aib.aps_security.device_link_key(source_ieee); - let new_key = core + let new_key = self + .core() .aib .aps_security - .issue_device_key(source_ieee, Key(rand::random())); - drop(core); + .issue_device_key(source_ieee, Key(crate::rng::random_array())); // The key is persisted only once the device proves possession via Verify-Key // (see `handle_verify_key`); a device that never completes the exchange must not @@ -651,11 +670,19 @@ impl ZigbeeStack

{ }), }; - let encrypted_command = self.core().aib.aps_security.encrypt_command_with_link_key( - ¤t_key, - NwkSecurityHeaderKeyId::KeyLoadKey, - &transport_key_command, - ); + let Some(encrypted_command) = self + .core() + .aib + .aps_security + .encrypt_command_with_applied_key( + source_ieee, + applied_key, + NwkSecurityHeaderKeyId::KeyLoadKey, + &transport_key_command, + ) + else { + return; + }; self.send_secured_aps_payload(nwk_frame.nwk_header.source, encrypted_command.to_bytes()); } @@ -718,12 +745,10 @@ impl ZigbeeStack

{ // Persist only now that the device has proven possession (spec 4.7.3.3): // the pending key has been promoted to the device's active key. let key = self.core().aib.aps_security.device_link_key(source_ieee); - let _ = self - .notification_tx - .send(ZigbeeNotification::LinkKeyUpdate { - ieee: source_ieee, - key, - }); + self.push_notification(ZigbeeNotification::LinkKeyUpdate { + ieee: source_ieee, + key, + }); APS_STATUS_SUCCESS } @@ -845,7 +870,7 @@ impl ZigbeeStack

{ self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); self.send_tunneled_network_key(router_nwk, update.device_address, JoinKind::New); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -866,7 +891,7 @@ impl ZigbeeStack

{ self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); self.send_tunneled_network_key(router_nwk, update.device_address, JoinKind::Rejoin); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -875,7 +900,7 @@ impl ZigbeeStack

{ ApsUpdateDeviceStatus::StandardDeviceSecuredRejoin => { self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -889,7 +914,7 @@ impl ZigbeeStack

{ .source_ieee .or_else(|| self.core().nib.address_map.eui64_for(router_nwk)); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk: update.device_short_address, ieee: Some(update.device_address), reason: DeviceLeaveReason::RouterReported { @@ -1040,7 +1065,7 @@ impl ZigbeeStack

{ neighbors::Relationship::UnauthenticatedChild }, }, - Instant::now().into_std(), + self.core_now(), ); // A new child deadline may precede everything the maintenance task knows @@ -1069,7 +1094,7 @@ impl ZigbeeStack

{ // `send_network_key` also emits the join notification self.send_network_key(assigned_nwk, source_ieee, JoinKind::Rejoin); } else { - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: assigned_nwk, ieee: source_ieee, parent: self.state.network_address, @@ -1139,7 +1164,7 @@ impl ZigbeeStack

{ self.drop_indirect_transactions(source_ieee, source); self.core().nib.routing.remove_route(source); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, ieee: source_ieee, reason: DeviceLeaveReason::Announced { @@ -1177,7 +1202,7 @@ impl ZigbeeStack

{ source, timeout, u16::from(request.end_device_configuration), - Instant::now().into_std(), + self.core_now(), ); // Requests from devices that are not our end device children are dropped @@ -1228,7 +1253,7 @@ impl ZigbeeStack

{ /// direct-association window only follows it when `accept_direct_joins` is set, /// leaving a steered join authorized without advertising us as a parent. pub fn permit_joins(&self, duration: u64, accept_direct_joins: bool) { - let deadline = (duration != 0).then(|| Instant::now() + Duration::from_secs(duration)); + let deadline = (duration != 0).then(|| self.core_now() + Duration::from_secs(duration)); tracing::info!( "Permitting joins for {duration} seconds (accept_direct_joins: {accept_direct_joins})" @@ -1245,13 +1270,13 @@ impl ZigbeeStack

{ pub(super) fn permitting_joins(&self) -> bool { self.core() .permitting_joins_until - .is_some_and(|deadline| deadline > Instant::now()) + .is_some_and(|deadline| deadline > self.core_now()) } /// Whether the trust center authorizes new joins through a router right now. pub(super) fn trust_center_permitting_joins(&self) -> bool { self.core() .trust_center_joins_until - .is_some_and(|deadline| deadline > Instant::now()) + .is_some_and(|deadline| deadline > self.core_now()) } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index e449b2a..b250824 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -1,20 +1,32 @@ +use core::time::Duration; + +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154CommandFrame, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; use abstract_bits::AbstractBits; +use alloc::vec::Vec; use arbitrary_int::u24; use ziggurat_ieee_802154::types::{Nwk, PanId}; -use ziggurat_phy::{RadioPhy, TxFrame, TxPriority, TxResult}; +use ziggurat_phy::{RadioPhy, TxFrame, TxResult}; use ziggurat_zigbee::beacon::{RenamedU24, ZigbeeBeacon}; use ziggurat_zigbee::nwk::frame::{ BROADCAST_ALL_ROUTERS_AND_COORDINATOR, EncryptedNwkFrame, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, NwkSecurityLevel, }; -use super::{NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, ZigbeeStack, ZigbeeStackError}; +use super::{ + NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxOutcome, TxPriority, ZigbeeStack, + ZigbeeStackError, +}; + +/// Spacing between sprayed beacons while a [`hack_beacon_spam_duration`] window is open. +/// +/// [`hack_beacon_spam_duration`]: super::State::hack_beacon_spam_duration +const BEACON_SPAM_INTERVAL: Duration = Duration::from_millis(5); -impl ZigbeeStack

{ +impl ZigbeeStack { pub fn process_802154_command_frame(&self, command_frame: &Ieee802154CommandFrame) { tracing::debug!( "Received 802.15.4 command frame: {:?}", @@ -23,7 +35,16 @@ impl ZigbeeStack

{ match &command_frame.command_payload { ziggurat_ieee_802154::Ieee802154CommandPayload::BeaconRequest(_) => { - self.send_802154_beacon(); + let spam = self.state.hack_beacon_spam_duration; + if spam.is_zero() || !self.permitting_joins() { + self.send_802154_beacon(); + } else { + // Open/extend the spray window and let the beacon-spam reactor drive + // the cadence, so a storm of requests can't exceed one beacon per + // BEACON_SPAM_INTERVAL. + self.core().beacon_spam_until = Some(self.core_now() + spam); + self.beacon_spam_wake.notify_one(); + } } ziggurat_ieee_802154::Ieee802154CommandPayload::AssociationRequest( ieee802154_association_request_command, @@ -48,7 +69,7 @@ impl ZigbeeStack

{ pub fn send_802154_beacon(&self) { let permitting_joins = self.permitting_joins(); - tracing::debug!("Sending 802.15.4 beacon frame (permitting joins: {permitting_joins})"); + tracing::trace!("Sending 802.15.4 beacon frame (permitting joins: {permitting_joins})"); let end_device_capacity = { self.core().nib.neighbors.child_count() } < usize::from(self.tunables.max_children); @@ -104,14 +125,49 @@ impl ZigbeeStack

{ tx_offset: RenamedU24(u24::new(0xFFFFFF)), update_id, } - .to_abstract_bits() + .to_abstract_bytes() .unwrap(), gts_specification: 0x00, pending_address_specification: 0x00, fcs: 0x0000, }); - self.background_send_802154_frame(beacon_frame, TxPriority::USER_NORMAL); + let tx_priority = if permitting_joins { + // We should try to win any beacon races during joins + TxPriority::STACK_CRITICAL + } else { + // Otherwise, unexpected beacon requests should never compete with normal traffic + TxPriority::BACKGROUND + }; + + self.enqueue_send( + SendKind::Raw { + frame: beacon_frame, + }, + tx_priority, + TxOutcome::Discard, + ); + } + + /// The beacon-spam reactor (the [`hack_beacon_spam_duration`] hack): while a spray + /// window is open and joins are still permitting, send a beacon every + /// [`BEACON_SPAM_INTERVAL`], then idle on its wake. A beacon request opens or + /// extends the window; the fixed sleep (rather than racing the wake) is what caps + /// the rate at one beacon per interval no matter how many requests arrive. + /// + /// [`hack_beacon_spam_duration`]: super::State::hack_beacon_spam_duration + pub(super) async fn beacon_spam_task(&self) { + loop { + let until = self.core().beacon_spam_until; + if until.is_some_and(|deadline| deadline > self.core_now()) { + if self.permitting_joins() { + self.send_802154_beacon(); + } + R::sleep(BEACON_SPAM_INTERVAL).await; + } else { + self.beacon_spam_wake.notified().await; + } + } } pub(super) fn beacon_request_psdu(&self) -> Vec { @@ -303,7 +359,6 @@ impl ZigbeeStack

{ pub(super) async fn send_802154_frame( &self, frame: Ieee802154Frame, - priority: TxPriority, ) -> Result<(), ZigbeeStackError> { // Increment the 802.15.4 sequence number let final_frame = if !frame.header().frame_control.sequence_number_suppression { @@ -312,6 +367,7 @@ impl ZigbeeStack

{ let mut core = self.core(); core.mac.ieee802154_sequence_number = core.mac.ieee802154_sequence_number.wrapping_add(1); + core.mac.ieee802154_sequence_number }; @@ -337,7 +393,7 @@ impl ZigbeeStack

{ frame }; - tracing::debug!("Sending 802.15.4 frame: {final_frame:?}"); + tracing::trace!("Sending 802.15.4 frame: {final_frame:?}"); tracing::trace!( "Sending 802.15.4 frame bytes: {:02X?}", final_frame.to_bytes() @@ -351,37 +407,28 @@ impl ZigbeeStack

{ let channel = self.core().mac.channel; let result = self .radio - .transmit( - TxFrame { - psdu: final_frame.to_bytes(), - channel: Some(channel), - csma_ca: true, - max_frame_retries: self.tunables.mac_max_frame_retries, - max_csma_backoffs: self.tunables.mac_max_csma_backoffs, - security_processed: true, - }, - priority, - ) + .transmit(TxFrame { + psdu: final_frame.to_bytes(), + channel: Some(channel), + csma_ca: true, + max_frame_retries: self.tunables.mac_max_frame_retries, + max_csma_backoffs: self.tunables.mac_max_csma_backoffs, + security_processed: true, + }) .await?; match result { TxResult::Acked => Ok(()), - TxResult::NoAck => Err(ZigbeeStackError::NwkNoAck { - next_hop: final_frame.header().dest_address.unwrap(), - }), + // A frame with no destination (e.g. a beacon) never requested an ACK, so + // "no ACK" is the expected outcome, not a failure. + TxResult::NoAck => final_frame + .header() + .dest_address + .map_or(Ok(()), |next_hop| { + Err(ZigbeeStackError::NwkNoAck { next_hop }) + }), TxResult::ChannelAccessFailure => Err(ZigbeeStackError::CcaFailure), other => Err(ZigbeeStackError::TransmitFailed(other)), } } - - pub fn background_send_802154_frame(&self, frame: Ieee802154Frame, priority: TxPriority) { - self.spawn_tracked_self(|arc_self| async move { - arc_self - .send_802154_frame(frame, priority) - .await - .unwrap_or_else(|err| { - tracing::error!("Failed to send 802.15.4 frame: {err}"); - }); - }); - } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index 52fd3e5..16e3335 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -1,16 +1,17 @@ -use tokio::time::Instant; +use crate::runtime::Runtime; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLinkStatusCommand}; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use super::{NwkSecurityMode, ZigbeeStack}; +use super::{NwkSecurityMode, TxPriority, ZigbeeStack}; /// Maximum number of link status entries that can be carried in a single frame. const MAX_LINK_STATUSES: usize = 7; -impl ZigbeeStack

{ +impl ZigbeeStack { pub(super) fn maybe_recompute_lqa(&self, sender_nwk: Nwk, lqi: u8, _rssi: i8) { self.core().nib.neighbors.record_lqa(sender_nwk, lqi); } @@ -44,7 +45,7 @@ impl ZigbeeStack

{ pub(super) fn maybe_age_neighbors(&self) { // TODO: this function should be replaced by real timers - let stale_neighbors = self.core().nib.neighbors.age(Instant::now().into_std()); + let stale_neighbors = self.core().nib.neighbors.age(self.core_now()); for neighbor_nwk in stale_neighbors { self.invalidate_routes_via(neighbor_nwk); @@ -71,7 +72,7 @@ impl ZigbeeStack

{ nwk_frame.nwk_header.source, lqi, &link_status_cmd, - Instant::now().into_std(), + self.core_now(), ); // Spec 3.6.4.4.2: when the outgoing cost collapses to zero the link is @@ -119,7 +120,7 @@ impl ZigbeeStack

{ // repeat one boundary entry (the last of frame N is the first of frame N+1) // so a receiver can stitch the advertised address range together (spec // 3.6.4.4.2). An empty list still emits a single first+last frame. - let end = std::cmp::min(start + MAX_LINK_STATUSES, total); + let end = core::cmp::min(start + MAX_LINK_STATUSES, total); let link_status_frame = self .nwk_command_frame( @@ -159,7 +160,7 @@ impl ZigbeeStack

{ pub async fn periodic_link_status_broadcast_task(&self) { loop { - tokio::time::sleep(self.tunables.link_status_period).await; + R::sleep(self.tunables.link_status_period).await; self.send_link_status_broadcast(false).await; } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index a5c6aab..06b408f 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1,11 +1,17 @@ +use crate::runtime::{Elapsed, Runtime}; +use crate::signal; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; -use tokio::time::{Instant, timeout_at}; +use alloc::string::ToString; +use alloc::vec::Vec; +use core::sync::atomic::Ordering as AtomicOrdering; +use core::time::Duration; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::{RadioPhy, TxResult}; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{ NwkCommand, NwkCommandId, NwkEndDeviceTimeoutResponseStatus, NwkNetworkStatus, NwkNetworkStatusCommand, @@ -17,13 +23,37 @@ use ziggurat_zigbee::nwk::frame::{ NwkSecurityLevel, NwkSourceRoute, }; -use super::routing::Route; +use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, SendMode, ZigbeeStack, - ZigbeeStackError, + AddrConflictSource, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, + PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, RequestId, + SendKind, SendMode, SendRequest, SendResult, TxOutcome, TxPriority, ZigbeeNotification, + ZigbeeStack, ZigbeeStackError, }; -impl ZigbeeStack

{ +/// The outcome of resolving a unicast's MAC next hop without blocking (see +/// [`ZigbeeStack::resolve_next_hop`]). +enum NextHop { + /// Transmit to this next hop now. + Resolved(Nwk), + /// No route known; the frame must wait for route discovery. + NeedDiscovery, + /// No route known and the frame's `discover_route` flag forbids discovering one. + Discard, +} + +/// Where a queued destination's route discovery stands when the reactor inspects it (see +/// [`ZigbeeStack::discovery_state`]). +enum DiscoveryState { + /// A route is active; the queued frames can be sent. + Resolved, + /// Discovery is not progressing: its window elapsed, it failed, or no entry exists. + Lapsed, + /// Discovery is still in flight with time remaining. + InFlight, +} + +impl ZigbeeStack { pub fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); @@ -34,12 +64,12 @@ impl ZigbeeStack

{ /// Filter broadcast frames based on the NWK broadcast transaction table pub fn filter_broadcast(&self, nwk_frame: &NwkFrame, sender_nwk: Nwk) -> bool { - let now = Instant::now(); + let now = self.core_now(); // We cannot handle broadcasts until the network has been running for at least - // the time it takes to deliver one broadcast + // the time it takes to deliver one broadcast (core time starts at zero). if !self.state.hack_ignore_broadcast_startup_wait_period - && (self.state.start_time + self.tunables.broadcast_delivery_time > now) + && (CoreInstant::from_micros(0) + self.tunables.broadcast_delivery_time > now) { tracing::debug!("Filtering broadcast, network started too recently."); return true; @@ -55,40 +85,182 @@ impl ZigbeeStack

{ nwk_frame.nwk_header.sequence_number, sender_nwk, audience, - now.into_std(), + now, ); drop(core); if duplicate { - // A duplicate is its sender's passive ack: retransmission loops - // re-evaluate completeness - self.broadcast_acked.notify_waiters(); + // A duplicate is its sender's passive ack: wake the retransmit reactor so it + // re-evaluates completeness and can drop a now-acknowledged broadcast early + self.broadcast_retransmit_wake.notify_one(); } duplicate } - /// Wait until the broadcast is passively acknowledged or the ack collection - /// window closes, waking on every recorded ack. Returns whether the broadcast - /// is acknowledged. - async fn await_broadcast_passive_acks(&self, key: (Nwk, u8)) -> bool { - let deadline = Instant::now() + self.tunables.passive_ack_timeout; - + /// The broadcast-retransmit reactor: a single long-lived task that owns every + /// in-flight broadcast's retransmission. + pub(super) async fn broadcast_retransmit_task(&self) { loop { + match self.earliest_broadcast_retransmit() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.broadcast_retransmit_wake.notified()) + .await; + } + None => self.broadcast_retransmit_wake.notified().await, + } + + self.drive_broadcast_retransmits(); + } + } + + /// The soonest retransmit deadline across all pending broadcasts, or `None` when none + /// are pending (the reactor then sleeps on its wake signal). + fn earliest_broadcast_retransmit(&self) -> Option { + self.state + .pending_broadcasts + .lock() + .values() + .map(|pending| pending.next_attempt) + .min() + } + + /// One reactor pass: for each pending broadcast, drop it if its quorum is now heard, + /// otherwise retransmit a copy if it is due (and not out of attempts). + #[allow(clippy::significant_drop_tightening)] + fn drive_broadcast_retransmits(&self) { + let keys: Vec<(Nwk, u8)> = self + .state + .pending_broadcasts + .lock() + .keys() + .copied() + .collect(); + + let now = self.core_now(); + + for key in keys { if self.broadcast_passively_acked(key) { - return true; + tracing::debug!("Broadcast {key:?} passively acknowledged"); + let removed = self.state.pending_broadcasts.lock().remove(&key); + if let Some(request_id) = removed.and_then(|broadcast| broadcast.request_id) { + self.push_notification(ZigbeeNotification::SendConfirm { + request_id, + result: SendResult::Confirmed { next_hop: None }, + }); + } + continue; } - if timeout_at(deadline, self.broadcast_acked.notified()) - .await - .is_err() - { - // The window closed; an ack recorded at the boundary still counts - return self.broadcast_passively_acked(key); + // Fresh jitter, computed before taking the lock so nothing non-trivial runs + // under it. + let next_attempt = now + self.tunables.passive_ack_timeout + self.broadcast_jitter(); + + // A single stack local, matched immediately after the lock is released, so the + // size-amplification the lint warns about does not apply. + #[allow(clippy::large_enum_variant)] + enum Next { + Idle, + Retransmit(NwkFrame, NwkSecurityMode, TxPriority), + Exhausted(Option), + } + + // Decide under the lock; if a copy is due, extract it to transmit after release. + let action = { + let mut pending = self.state.pending_broadcasts.lock(); + let Some(broadcast) = pending.get_mut(&key) else { + continue; + }; + + if broadcast.next_attempt > now { + Next::Idle + } else if broadcast.attempts_remaining == 0 { + let request_id = broadcast.request_id; + pending.remove(&key); + Next::Exhausted(request_id) + } else { + broadcast.attempts_remaining -= 1; + broadcast.next_attempt = next_attempt; + Next::Retransmit( + broadcast.nwk_frame.clone(), + broadcast.security, + broadcast.priority, + ) + } + }; + + match action { + Next::Idle => {} + Next::Retransmit(nwk_frame, security, priority) => { + tracing::debug!("Retransmitting broadcast {key:?}"); + self.enqueue_send( + SendKind::Broadcast { + nwk_frame, + security, + }, + priority, + TxOutcome::Discard, + ); + } + Next::Exhausted(request_id) => { + tracing::debug!("Broadcast {key:?} out of retransmit attempts"); + if let Some(request_id) = request_id { + self.push_notification(ZigbeeNotification::SendConfirm { + request_id, + result: SendResult::Failed { + reason: "passive-ack quorum not reached".to_string(), + }, + }); + } + } } } } + /// Insert a broadcast into the pending-retransmit map and wake the reactor. + #[allow(clippy::too_many_arguments)] + fn schedule_broadcast( + &self, + key: (Nwk, u8), + nwk_frame: NwkFrame, + security: NwkSecurityMode, + priority: TxPriority, + first_delay: Duration, + attempts: u8, + request_id: Option, + ) { + // With a request_id we still track the broadcast even at zero retransmits, so the + // reactor can confirm its quorum (or fail it); untracked broadcasts just return. + if attempts == 0 && request_id.is_none() { + return; + } + + self.state.pending_broadcasts.lock().insert( + key, + PendingBroadcast { + nwk_frame, + security, + priority, + attempts_remaining: attempts, + next_attempt: self.core_now() + first_delay, + request_id, + }, + ); + self.broadcast_retransmit_wake.notify_one(); + } + + /// A random retransmission jitter in `[0, max_broadcast_jitter)` (spec 3.6.6). + /// + // TODO: `no_std` randomness source. This and the other `rand::random` sites + // (RREQ relay jitter in route.rs, the address-conflict and parent-annce jitters, + // plus address/key allocation) call the std global thread RNG directly. + fn broadcast_jitter(&self) -> Duration { + self.tunables + .max_broadcast_jitter + .mul_f32(crate::rng::random_f32()) + } + /// Whether the broadcast's passive ack quorum has been heard from the audience /// members that are still live neighbors. fn broadcast_passively_acked(&self, key: (Nwk, u8)) -> bool { @@ -158,7 +330,9 @@ impl ZigbeeStack

{ // reach this point: the send path pre-fills the transaction table. The // frame is discarded instead of relayed (3.6.1.10). if nwk_frame.nwk_header.source == self.state.network_address { - if self.state.start_time + self.tunables.broadcast_delivery_time < Instant::now() { + if CoreInstant::from_micros(0) + self.tunables.broadcast_delivery_time + < self.core_now() + { self.handle_address_conflict( self.state.network_address, AddrConflictSource::Local, @@ -177,7 +351,7 @@ impl ZigbeeStack

{ NwkCommand::LinkStatus(cmd) => self.handle_link_status(nwk_frame, cmd.clone(), lqi), NwkCommand::RouteReply(cmd) => self.handle_route_reply(nwk_frame, cmd.clone()), NwkCommand::RouteRecord(cmd) => { - tracing::debug!("Route record command frame received: {cmd:?}"); + tracing::trace!("Route record command frame received: {cmd:?}"); self.core() .nib .routing @@ -286,34 +460,128 @@ impl ZigbeeStack

{ } } + /// Fire-and-forget originate of a unicast NWK frame at normal priority. Nothing is + /// awaited, so a failed transmit is handled by the sender, not reported back here. + /// Unicast only; broadcasts go through [`Self::send_broadcast_nwk_frame`]. pub fn background_send_nwk_frame( &self, nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, ) { - self.spawn_tracked_self(|arc_self| async move { - arc_self - .send_nwk_frame(nwk_frame, security, route_directly, TxPriority::USER_NORMAL) - .await - .unwrap_or_else(|err| { - tracing::error!("Failed to send NWK frame: {err}"); + debug_assert!( + nwk_frame.nwk_header.destination.as_u16() < BROADCAST_LOW_POWER_ROUTERS.as_u16(), + "background_send_nwk_frame is unicast only; got broadcast {:?}", + nwk_frame.nwk_header.destination + ); + self.originate_unicast( + nwk_frame, + security, + mode, + TxPriority::USER_NORMAL, + TxOutcome::Discard, + ); + } + + /// Originate a unicast: assign its NWK sequence number, resolve a next hop, and + /// either enqueue it, queue it awaiting route discovery, or drop it + /// (discovery suppressed). + pub(super) fn originate_unicast( + &self, + mut nwk_frame: NwkFrame, + security: NwkSecurityMode, + mode: SendMode, + priority: TxPriority, + outcome: TxOutcome, + ) { + let destination = nwk_frame.nwk_header.destination; + nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); + + match self.resolve_next_hop(&mut nwk_frame, mode) { + NextHop::Resolved(next_hop) => { + self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome); + } + NextHop::NeedDiscovery => { + self.enqueue_awaiting_route(nwk_frame, security, priority, outcome) + } + NextHop::Discard => { + tracing::debug!( + "Dropping frame to {destination:?}: no route and discovery suppressed" + ); + self.resolve_outcome( + outcome, + None, + Err(ZigbeeStackError::RouteDiscoverySuppressed), + ); + } + } + } + + /// Resolve the MAC next hop for a unicast without ever blocking. A source-routed + /// result rewrites `nwk_frame`'s header in place (spec 3.6.4.3.1). When no route is + /// known the frame's `discover_route` flag decides between discovery and discard. + fn resolve_next_hop(&self, nwk_frame: &mut NwkFrame, mode: SendMode) -> NextHop { + let destination = nwk_frame.nwk_header.destination; + + if mode == SendMode::Direct { + return NextHop::Resolved(destination); + } + + // End device children never route-discover; their parent delivers directly. + if self.end_device_child_eui64(destination).is_some() { + return NextHop::Resolved(destination); + } + + // A stored source route (concentrator behavior) wins over the routing table. + match self.outbound_route(destination) { + Some(Route::NextHop(next_hop)) => return NextHop::Resolved(next_hop), + Some(Route::SourceRouted(relays)) => { + // Spec 3.6.4.3.1: the MAC destination is the relay closest to us, listed + // last; the relay index starts one below the relay count. + let next_hop = *relays.last().unwrap(); + nwk_frame.nwk_header.frame_control.source_route = true; + nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; + nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { + relay_index: relays.len() as u8 - 1, + relays, }); - }); + return NextHop::Resolved(next_hop); + } + None => {} + } + + // An active ad-hoc route, unless we are deliberately forcing rediscovery. + if !self.state.hack_force_route_discovery { + let core = self.core(); + if core.nib.routing.route_status(destination) == Some(RouteStatus::Active) + && let Some(next_hop) = core.nib.routing.next_hop(destination) + { + return NextHop::Resolved(next_hop); + } + } + + // No usable route. Spec 3.6.3.3: only initiate discovery if the frame allows it. + if nwk_frame.nwk_header.frame_control.discover_route == NwkRouteDiscovery::Suppress { + NextHop::Discard + } else { + NextHop::NeedDiscovery + } } pub async fn send_nwk_frame( &self, nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { - self.send_broadcast_nwk_frame(nwk_frame, security, priority) - .await + // Broadcasts are fire-and-forget: the retransmit reactor owns delivery, and + // there is no end-to-end result to await. + self.send_broadcast_nwk_frame(nwk_frame, security, priority, None); + Ok(()) } else { - self.send_unicast_nwk_frame(nwk_frame, security, route_directly, priority) + self.send_unicast_nwk_frame(nwk_frame, security, mode, priority) .await } } @@ -393,62 +661,29 @@ impl ZigbeeStack

{ .route_to(destination, self.tunables.max_source_route) } + /// Originate a unicast and await its delivery result. The completion resolves once + /// the frame leaves the radio (or, for a sleepy child, once it polls), or with an + /// error on transmit failure, route-discovery failure, or discovery being + /// suppressed. pub async fn send_unicast_nwk_frame( &self, - mut nwk_frame: NwkFrame, + nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { - let destination = nwk_frame.nwk_header.destination; - - // Compute a next-hop address - let next_hop_address = if route_directly == SendMode::Direct { - destination - } else { - match self.outbound_route(destination) { - Some(Route::NextHop(next_hop)) => next_hop, - Some(Route::SourceRouted(relays)) => { - // Spec 3.6.4.3.1: the MAC destination is the relay closest to - // us, which is listed last; the relay index starts at one less - // than the relay count - let next_hop = *relays.last().unwrap(); - nwk_frame.nwk_header.frame_control.source_route = true; - nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; - nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { - relay_index: relays.len() as u8 - 1, - relays, - }); - next_hop - } - None => self.discover_route(destination).await?, - } - }; - - nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); - - let result = self - .transmit_unicast_nwk_frame(nwk_frame, next_hop_address, security, priority) - .await; - - // A dead next hop invalidates every route through it and any stored source - // route to the destination; the next transmission will rediscover - if result.is_err() { - self.invalidate_routes_via(next_hop_address); - - if self.core().nib.routing.remove_route_record(destination) { - tracing::info!("Removed source route to {destination:?} after delivery failure"); - } - - // Failed deliveries push the MTORR scheduler toward an early - // advertisement; expired indirect transactions to our own sleepy - // children are not routing failures - if self.sleepy_child_eui64(next_hop_address).is_none() { - self.note_delivery_failure(); - } - } - - result + let (completion_tx, completion_rx) = signal::channel(); + self.originate_unicast( + nwk_frame, + security, + mode, + priority, + TxOutcome::Signal(completion_tx), + ); + completion_rx + .wait() + .await + .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) } /// Wrap an encrypted NWK payload in a unicast 802.15.4 data frame. The sequence @@ -502,97 +737,556 @@ impl ZigbeeStack

{ self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame) } - /// Encrypt a fully-formed NWK frame and unicast it to the given next hop, with - /// retries. Unlike [`Self::send_unicast_nwk_frame`], the sequence number is not - /// touched: relayed frames keep the originator's sequence number (spec 3.6.4.3). - pub(super) async fn transmit_unicast_nwk_frame( + /// Enqueue a send into the priority queue and wake the sender task. + pub(super) fn enqueue_send(&self, kind: SendKind, priority: TxPriority, outcome: TxOutcome) { + let seq = self.send_seq.fetch_add(1, AtomicOrdering::Relaxed); + self.send_queue.lock().push(SendRequest { + seq, + priority, + kind, + outcome, + }); + self.send_wake.notify_one(); + } + + /// Enqueue a unicast whose next hop is already resolved. A sleepy child goes to the + /// indirect queue. Everything else goes to the sender, which encrypts and retries + /// at dequeue so frame-counter order matches on-air order. The NWK sequence number + /// is left untouched: relayed frames keep the originator's (spec 3.6.4.3). A + /// `completion`, if supplied, is resolved by whichever queue takes the frame: the + /// sender on transmit, or the indirect queue on the child's poll or expiry. + pub(super) fn enqueue_unicast( &self, - mut nwk_frame: NwkFrame, - next_hop_address: Nwk, + nwk_frame: NwkFrame, + next_hop: Nwk, security: NwkSecurityMode, priority: TxPriority, - ) -> Result<(), ZigbeeStackError> { - // Sleepy children cannot hear direct transmissions: the finished frame waits - // in the indirect queue until the child polls for it. No retry loop applies; - // the child re-polling is the retry mechanism and expiry the failure signal. - if let Some(child_eui64) = self.sleepy_child_eui64(next_hop_address) { - let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop_address, security); - + outcome: TxOutcome, + ) { + if let Some(child_eui64) = self.sleepy_child_eui64(next_hop) { + // The frame is left as plaintext and finished (encrypted, counter assigned) + // only when the child polls. See `IndirectFrame`. The NWK sequence number + // is already assigned. + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(child_eui64), + payload: IndirectPayload::Deferred { + nwk_frame, + next_hop, + security, + }, + }; self.increment_tx_total(); - return self - .queue_indirect_frame(Ieee802154Address::Eui64(child_eui64), frame) - .await; + self.enqueue_indirect_frame(frame, outcome); + return; } - self.apply_nwk_aux_header(&mut nwk_frame, security); + self.enqueue_send( + SendKind::Unicast { + nwk_frame, + next_hop, + security, + attempts_remaining: self.tunables.unicast_retries, + }, + priority, + outcome, + ); + } + + /// Push a frame for the sender task and await its transmit result. + pub(super) async fn send( + &self, + kind: SendKind, + priority: TxPriority, + ) -> Result<(), ZigbeeStackError> { + let (completion_tx, completion_rx) = signal::channel(); + self.enqueue_send(kind, priority, TxOutcome::Signal(completion_tx)); + completion_rx + .wait() + .await + .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) + } - for attempt in 0..=self.tunables.unicast_retries { - let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); - let ieee802154_frame = - self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame); + /// Enqueue a unicast awaiting a route and start discovery if necessary. + fn enqueue_awaiting_route( + &self, + nwk_frame: NwkFrame, + security: NwkSecurityMode, + priority: TxPriority, + outcome: TxOutcome, + ) { + let destination = nwk_frame.nwk_header.destination; - // When forwarding packets to another node, update the counters for the neighbor - // TODO: maybe wrap the send state into some sort of struct to avoid - // needing to do this? - { - let mut core = self.core(); - let relaying_ieee = core.nib.address_map.eui64_for(next_hop_address); + let start_discovery = { + let mut pending = self.state.pending_routes.lock(); + let is_new = !pending.contains_key(&destination); + pending + .entry(destination) + .or_insert_with(|| PendingRoute { + frames: Vec::new(), + attempts_remaining: self.tunables.pending_route_discovery_attempts, + }) + .frames + .push(PendingFrame { + nwk_frame, + security, + priority, + outcome, + }); + is_new + }; - if let Some(relaying_ieee) = relaying_ieee { - core.nib.neighbors.record_outbound_activity(relaying_ieee); + if start_discovery { + tracing::debug!("Queuing frame and starting route discovery for {destination:?}"); + self.send_route_discovery(destination); + } + self.pending_route_wake.notify_one(); + } + + /// The pending-route reactor: a single long-lived task that owns every in-flight + /// route discovery. It sleeps until the nearest discovery deadline (or a wake + /// signal), then sends the frames whose route resolved and retries or discards + /// those whose discovery lapsed. + pub(super) async fn pending_route_task(&self) { + loop { + let next_deadline = self.earliest_discovery_deadline(); + + match next_deadline { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.pending_route_wake.notified()) + .await; } + None => self.pending_route_wake.notified().await, + } + + self.drive_pending_routes(); + } + } + + /// The soonest live discovery deadline across all queued destinations, or `None` + /// when nothing is waiting on a deadline (the reactor then sleeps on its wake + /// signal). + fn earliest_discovery_deadline(&self) -> Option { + let destinations: Vec = self.state.pending_routes.lock().keys().copied().collect(); - // And the routing table counters - core.nib + let now = self.core_now(); + let core = self.core(); + destinations + .iter() + .filter_map(|destination| core.nib.routing.discovery_deadline(*destination, now)) + .min() + } + + /// One reactor pass: classify each queued destination and act on it. + fn drive_pending_routes(&self) { + let destinations: Vec = self.state.pending_routes.lock().keys().copied().collect(); + + for destination in destinations { + match self.discovery_state(destination) { + DiscoveryState::Resolved => self.release_queued_frames(destination), + DiscoveryState::Lapsed => self.retry_or_fail_discovery(destination), + DiscoveryState::InFlight => {} + } + } + } + + /// Where `destination`'s route discovery currently stands, read from the routing + /// table. + fn discovery_state(&self, destination: Nwk) -> DiscoveryState { + let now = self.core_now(); + let core = self.core(); + match core.nib.routing.route_status(destination) { + Some(RouteStatus::Active) => DiscoveryState::Resolved, + Some(RouteStatus::DiscoveryUnderway) => { + // `discovery_deadline` only returns a live (future) deadline, so its + // absence means the discovery window has elapsed. + if core + .nib .routing - .record_usage(nwk_frame.nwk_header.destination); + .discovery_deadline(destination, now) + .is_some() + { + DiscoveryState::InFlight + } else { + DiscoveryState::Lapsed + } } + // DiscoveryFailed / Inactive / no entry: nothing in flight. + _ => DiscoveryState::Lapsed, + } + } - self.increment_tx_total(); + /// A route exists: re-resolve each queued frame and enqueue it. A frame whose route + /// vanished in the race is dropped with an error. + fn release_queued_frames(&self, destination: Nwk) { + let bucket = self.state.pending_routes.lock().remove(&destination); - match self.send_802154_frame(ieee802154_frame, priority).await { - Ok(_) => { - break; + let Some(bucket) = bucket else { + return; + }; + + tracing::debug!( + "Releasing {} queued frame(s) to {destination:?}", + bucket.frames.len() + ); + + for queued in bucket.frames { + let PendingFrame { + mut nwk_frame, + security, + priority, + outcome, + } = queued; + + match self.resolve_next_hop(&mut nwk_frame, SendMode::Route) { + NextHop::Resolved(next_hop) => { + self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome); } - Err(e) => { - // Spec Table 3-75: an unacknowledged unicast is a transmit failure - // recorded against the next hop. Counted per MCPS-DATA.request, like - // `nwkTxTotal` above, so the two stay on the same denominator. - if let ZigbeeStackError::NwkNoAck { .. } = e { - let mut core = self.core(); - if let Some(next_hop_eui64) = - core.nib.address_map.eui64_for(next_hop_address) - { - core.nib.neighbors.record_transmit_failure(next_hop_eui64); - } + NextHop::NeedDiscovery | NextHop::Discard => { + self.resolve_outcome( + outcome, + None, + Err(ZigbeeStackError::RouteInactiveAfterDiscovery), + ); + } + } + } + } + + /// A discovery window lapsed: retry the discovery if the destination has attempts + /// left, otherwise mark it failed and discard every frame waiting on it. + fn retry_or_fail_discovery(&self, destination: Nwk) { + let discarded = { + let mut pending = self.state.pending_routes.lock(); + + let Some(bucket) = pending.get_mut(&destination) else { + return; + }; + + bucket.attempts_remaining = bucket.attempts_remaining.saturating_sub(1); + + if bucket.attempts_remaining > 0 { + None + } else { + Some(pending.remove(&destination).unwrap().frames) + } + }; + + match discarded { + None => { + tracing::debug!("Route discovery to {destination:?} timed out, retrying"); + self.send_route_discovery(destination); + self.pending_route_wake.notify_one(); + } + Some(frames) => { + self.core().nib.routing.mark_discovery_failed(destination); + tracing::debug!( + "Route discovery to {destination:?} failed, dropping {} frame(s)", + frames.len() + ); + for PendingFrame { outcome, .. } in frames { + self.resolve_outcome( + outcome, + None, + Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed)), + ); + } + } + } + } + + /// The single transmit task: drains [`send_queue`](ZigbeeStack::send_queue) highest + /// priority first, encrypting each frame as it is sent so frame-counter order + /// always matches on-air order. Serializing all transmits here is what keeps the + /// counter monotonic; concurrent senders would race it and risk replay rejection. + pub(super) async fn sender_task(&self) { + loop { + loop { + let request = self.send_queue.lock().pop(); + + let Some(request) = request else { + break; + }; + + match request.kind { + SendKind::Unicast { + nwk_frame, + next_hop, + security, + attempts_remaining, + } => { + // Owns the outcome: reports it on success or terminal failure, or + // hands it to the retry reactor. + self.attempt_unicast_send( + nwk_frame, + next_hop, + security, + request.priority, + attempts_remaining, + request.outcome, + ) + .await; } + SendKind::Broadcast { + nwk_frame, + security, + } => { + let result = self.process_broadcast_send(nwk_frame, security).await; + self.resolve_outcome(request.outcome, None, result); + } + SendKind::Raw { frame } => { + let result = self.send_802154_frame(frame).await; + self.resolve_outcome(request.outcome, None, result); + } + } + } - tracing::warn!("Failed to send unicast frame: {e}"); + self.send_wake.notified().await; + } + } - if attempt + 1 > self.tunables.unicast_retries { - tracing::error!("Failed to send unicast frame after {attempt} attempts"); - return Err(e); + /// Deliver a transmit's terminal outcome to wherever it is owed: log a dropped + /// background failure, wake an awaiting caller, or confirm an application send. + pub(super) fn resolve_outcome( + &self, + outcome: TxOutcome, + next_hop: Option, + result: Result<(), ZigbeeStackError>, + ) { + match outcome { + TxOutcome::Discard => { + if let Err(err) = result { + tracing::warn!("Background send failed: {err}"); + } + } + TxOutcome::Signal(signal) => signal.signal(result), + TxOutcome::Confirm { + request_id, + aps_ack, + } => match result { + Ok(()) => { + self.push_notification(ZigbeeNotification::SendConfirm { + request_id, + result: SendResult::Confirmed { next_hop }, + }); + } + Err(err) => { + // Drop any pending aps-ack so a late ack can't emit a stray ApsAckConfirm. + if let Some(ack_data) = aps_ack { + self.state.pending_aps_acks.lock().remove(&ack_data); } - tracing::debug!( - "Retrying unicast frame send, attempt {} of {}", - attempt, - self.tunables.unicast_retries - ); + self.push_notification(ZigbeeNotification::SendConfirm { + request_id, + result: SendResult::Failed { + reason: err.to_string(), + }, + }); + } + }, + } + } + + /// One transmit attempt for a dequeued unicast: assign the frame counter, encrypt, + /// and send once. On success (or terminal failure) the completion resolves here; + /// on a failed attempt with retries left, the plaintext frame is parked with the + /// unicast-retry reactor instead of being slept on, so the sender stays free. + async fn attempt_unicast_send( + &self, + mut nwk_frame: NwkFrame, + next_hop_address: Nwk, + security: NwkSecurityMode, + priority: TxPriority, + attempts_remaining: u8, + outcome: TxOutcome, + ) { + self.apply_nwk_aux_header(&mut nwk_frame, security); + let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); + let ieee802154_frame = + self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame); + + // When forwarding packets to another node, update the counters for the neighbor + { + let mut core = self.core(); + let relaying_ieee = core.nib.address_map.eui64_for(next_hop_address); + + if let Some(relaying_ieee) = relaying_ieee { + core.nib.neighbors.record_outbound_activity(relaying_ieee); + } + + // And the routing table counters + core.nib + .routing + .record_usage(nwk_frame.nwk_header.destination); + } + + self.increment_tx_total(); + + let Err(e) = self.send_802154_frame(ieee802154_frame).await else { + self.resolve_outcome(outcome, Some(next_hop_address), Ok(())); + return; + }; - tokio::time::sleep(self.tunables.unicast_retry_delay).await; + // Spec Table 3-75: an unacknowledged unicast is a transmit failure recorded + // against the next hop. Counted per MCPS-DATA.request, like `nwkTxTotal` above, + // so the two stay on the same denominator. + if let ZigbeeStackError::NwkNoAck { .. } = e { + let mut core = self.core(); + if let Some(next_hop_eui64) = core.nib.address_map.eui64_for(next_hop_address) { + core.nib.neighbors.record_transmit_failure(next_hop_eui64); + } + } + + tracing::warn!("Failed to send unicast frame: {e}"); + + if attempts_remaining == 0 { + tracing::error!("Failed to send unicast frame after all attempts"); + self.handle_unicast_send_failure(&nwk_frame, next_hop_address); + self.resolve_outcome(outcome, Some(next_hop_address), Err(e)); + return; + } + + // Park the frame for re-transmission after the retry delay. The plaintext frame + // is re-enqueued (not the ciphertext), so the next attempt earns a fresh counter + // at dequeue and on-air order stays equal to counter order. + tracing::debug!("Scheduling unicast retry, {attempts_remaining} attempt(s) remaining"); + self.schedule_unicast_retry( + nwk_frame, + next_hop_address, + security, + priority, + attempts_remaining - 1, + outcome, + ); + } + + /// Park a failed unicast for re-enqueue after [`unicast_retry_delay`] and wake the + /// retry reactor. + fn schedule_unicast_retry( + &self, + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + priority: TxPriority, + attempts_remaining: u8, + outcome: TxOutcome, + ) { + let delay = self.tunables.unicast_retry_delay; + + // The frame has a random jitter of up to one retry delay period + let jitter = delay.mul_f32(crate::rng::random_f32()); + let next_attempt = self.core_now() + delay + jitter; + + self.state + .pending_unicast_retries + .lock() + .push(PendingUnicastRetry { + nwk_frame, + next_hop, + security, + priority, + attempts_remaining, + next_attempt, + outcome, + }); + self.unicast_retry_wake.notify_one(); + } + + /// The unicast-retry reactor: a single long-lived task that re-enqueues failed + /// unicasts once their retry delay elapses, mirroring the broadcast-retransmit + /// reactor. + pub(super) async fn unicast_retry_task(&self) { + loop { + match self.earliest_unicast_retry() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.unicast_retry_wake.notified()) + .await; + } + None => self.unicast_retry_wake.notified().await, + } + + self.drive_unicast_retries(); + } + } + + /// The soonest re-enqueue deadline across all parked retries, or `None` when none are + /// parked (the reactor then sleeps on its wake signal). + fn earliest_unicast_retry(&self) -> Option { + self.state + .pending_unicast_retries + .lock() + .iter() + .map(|retry| retry.next_attempt) + .min() + } + + /// One reactor pass: re-enqueue every parked retry whose delay has elapsed. The + /// re-enqueued frame competes by its priority and earns a fresh counter at dequeue. + fn drive_unicast_retries(&self) { + let now = self.core_now(); + + let due: Vec = { + let mut pending = self.state.pending_unicast_retries.lock(); + let mut due = Vec::new(); + let mut i = 0; + while i < pending.len() { + if pending[i].next_attempt <= now { + // Order does not matter (the priority queue reorders anyway), so an + // O(1) swap-remove is fine. + due.push(pending.swap_remove(i)); + } else { + i += 1; } } + drop(pending); + + due + }; + + for retry in due { + self.enqueue_send( + SendKind::Unicast { + nwk_frame: retry.nwk_frame, + next_hop: retry.next_hop, + security: retry.security, + attempts_remaining: retry.attempts_remaining, + }, + retry.priority, + retry.outcome, + ); + } + } + + /// A unicast exhausted its retries at the sender. The next hop is dead: invalidate + /// routes through it. A frame we originated also drops any stored source route and + /// pushes the MTORR scheduler; a frame we were relaying reports the failure back + /// to its originator (spec 3.6.4.8.1). + fn handle_unicast_send_failure(&self, nwk_frame: &NwkFrame, next_hop: Nwk) { + if nwk_frame.nwk_header.source != self.state.network_address { + self.handle_relay_failure(nwk_frame, next_hop); + return; + } + + let destination = nwk_frame.nwk_header.destination; + self.invalidate_routes_via(next_hop); + + if self.core().nib.routing.remove_route_record(destination) { + tracing::info!("Removed source route to {destination:?} after delivery failure"); } - Ok(()) + // Expired indirect transactions to our own sleepy children are not routing + // failures, so they do not push the MTORR scheduler. + if self.sleepy_child_eui64(next_hop).is_none() { + self.note_delivery_failure(); + } } /// Spec 3.6.6: a coordinator/router with rx-off end-device children must re-deliver /// every 0xFFFF broadcast to each of them as a MAC unicast through the indirect /// queue, since a sleeping radio never hears the broadcast itself. The NWK source is - /// skipped (it already has the frame). Each copy is queued on its own task: it is - /// only handed to the radio when the child polls, or dropped when it expires. + /// skipped (it already has the frame). Each copy is queued without waiting: it is only + /// handed to the radio when the child polls, or dropped when it expires. fn fan_out_broadcast_to_sleepy_children( &self, nwk_frame: &NwkFrame, @@ -612,34 +1306,35 @@ impl ZigbeeStack

{ .collect(); for (child_eui64, child_nwk) in sleepy_children { - let frame = nwk_frame.clone(); - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { - let finished = arc_self.finish_unicast_nwk_frame(frame, child_nwk, security); - arc_self.increment_tx_total(); - - if let Err(err) = arc_self - .queue_indirect_frame(Ieee802154Address::Eui64(child_eui64), finished) - .await - { - tracing::debug!( - "Broadcast not delivered to sleepy child {child_eui64:?}: {err}" - ); - } - }); + // Finished only when the child polls (see `IndirectFrame`). + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(child_eui64), + payload: IndirectPayload::Deferred { + nwk_frame: nwk_frame.clone(), + next_hop: child_nwk, + security, + }, + }; + self.increment_tx_total(); + + // Fire-and-forget: a broadcast copy has no end-to-end result to await. + self.enqueue_indirect_frame(frame, TxOutcome::Discard); } } - pub async fn send_broadcast_nwk_frame( + /// Originate a broadcast: assign its sequence number, fan it out to sleepy children, + /// form the passive-ack contract, transmit the first copy now, and hand any + /// retransmissions to the broadcast-retransmit reactor (spec 3.6.6). Fire-and-forget: + /// a broadcast has no end-to-end result to await. + pub fn send_broadcast_nwk_frame( &self, mut nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, - ) -> Result<(), ZigbeeStackError> { + // An application send awaiting confirmation on passive-ack quorum; internal + // broadcasts pass `None`. + request_id: Option, + ) { nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); // Sleepy children never hear the over-the-air broadcast; queue a unicast copy @@ -657,62 +1352,56 @@ impl ZigbeeStack

{ let mut core = self.core(); let audience = core.nib.neighbors.expected_broadcast_relayers(); - core.nib.broadcasts.record_transmission( - key.0, - key.1, - audience, - Instant::now().into_std(), - ); + core.nib + .broadcasts + .record_transmission(key.0, key.1, audience, self.core_now()); } - // Spec 3.6.6: retransmit only while the passive ack quorum has not been - // heard within the ack collection window - for attempt in 0..=self.tunables.max_broadcast_retries { - if attempt > 0 { - if self.await_broadcast_passive_acks(key).await { - tracing::debug!("Broadcast {key:?} passively acknowledged"); - return Ok(()); - } - - // Fresh jitter decorrelates the retransmission wave: every router - // that missed its acks hits the same deadline together, preserving - // the relative timing (and collisions) of the original wave - tokio::time::sleep( - self.tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Acks may have trickled in during the jitter sleep - if self.broadcast_passively_acked(key) { - tracing::debug!("Broadcast {key:?} passively acknowledged"); - return Ok(()); - } - - tracing::debug!( - "Broadcast {key:?} is missing passive acks, retransmitting \ - (attempt {attempt} of {})", - self.tunables.max_broadcast_retries, - ); - } - - let _ = self - .transmit_broadcast_nwk_frame(nwk_frame.clone(), security, priority) - .await; - } - - Ok(()) + // Transmit the first copy immediately; the reactor makes any retransmissions, + // each after an ack-collection window plus fresh jitter. + self.enqueue_send( + SendKind::Broadcast { + nwk_frame: nwk_frame.clone(), + security, + }, + priority, + TxOutcome::Discard, + ); + self.schedule_broadcast( + key, + nwk_frame, + security, + priority, + self.tunables.passive_ack_timeout + self.broadcast_jitter(), + self.tunables.max_broadcast_retries, + request_id, + ); } - /// Encrypt a fully-formed NWK frame and broadcast a single copy of it. The sequence - /// number is not touched: relayed broadcasts and route request retries keep their - /// original sequence number. + /// Queue a fully-formed NWK frame for a single broadcast copy, encrypted and sent by + /// the sender task at dequeue. The sequence number is not touched: relayed broadcasts + /// and route request retries keep their original sequence number. pub(super) async fn transmit_broadcast_nwk_frame( &self, - mut nwk_frame: NwkFrame, + nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, + ) -> Result<(), ZigbeeStackError> { + self.send( + SendKind::Broadcast { + nwk_frame, + security, + }, + priority, + ) + .await + } + + /// Encrypt and broadcast a single dequeued copy of a frame. + async fn process_broadcast_send( + &self, + mut nwk_frame: NwkFrame, + security: NwkSecurityMode, ) -> Result<(), ZigbeeStackError> { self.apply_nwk_aux_header(&mut nwk_frame, security); @@ -752,7 +1441,7 @@ impl ZigbeeStack

{ self.increment_tx_total(); - self.send_802154_frame(ieee802154_frame, priority).await + self.send_802154_frame(ieee802154_frame).await } /// Zigbee spec 3.6.4.3: relay a unicast frame addressed to another device. @@ -873,23 +1562,16 @@ impl ZigbeeStack

{ nwk_frame.nwk_header.source ); - self.spawn_tracked_self(|arc_self| async move { - // The originator's sequence number is preserved when relaying - if let Err(err) = arc_self - .transmit_unicast_nwk_frame( - nwk_frame.clone(), - next_hop_address, - NwkSecurityMode::NetworkKey, - TxPriority::USER_NORMAL, - ) - .await - { - tracing::warn!( - "Failed to relay frame to {destination:?} via {next_hop_address:?}: {err}" - ); - arc_self.handle_relay_failure(&nwk_frame, next_hop_address); - } - }); + // The originator's sequence number is preserved when relaying. The transmit and + // any failure handling (route invalidation, the network status back to the + // originator) happen in the sender; nothing is awaited here. + self.enqueue_unicast( + nwk_frame, + next_hop_address, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + TxOutcome::Discard, + ); } /// Zigbee spec 3.6.4.8.1: when relaying fails, the routes through the dead link are @@ -909,6 +1591,8 @@ impl ZigbeeStack

{ NwkNetworkStatus::LinkFailure }; + // The originator may be several hops away with no route cached; allow this + // report to discover one. let network_status_frame = self .nwk_command_frame( source, @@ -917,7 +1601,8 @@ impl ZigbeeStack

{ network_address: nwk_frame.nwk_header.destination, }), ) - .with_destination_ieee(destination_ieee); + .with_destination_ieee(destination_ieee) + .with_discover_route(NwkRouteDiscovery::Enable); self.background_send_nwk_frame( network_status_frame, @@ -926,8 +1611,10 @@ impl ZigbeeStack

{ ); } - /// Zigbee spec 3.6.6: re-broadcast a newly seen broadcast frame after a random - /// jitter, preserving the originator's source address and sequence number. + /// Zigbee spec 3.6.6: re-broadcast a newly seen broadcast frame, preserving the + /// originator's source address and sequence number. The first relay is jittered to + /// decorrelate from the originator's wave; the broadcast-retransmit reactor then + /// retransmits until the passive-ack quorum is heard or attempts run out. fn maybe_relay_broadcast(&self, nwk_frame: &NwkFrame) { // Broadcast NWK commands are not generically relayed: link status and leave // frames have a radius of 1, and route requests accumulate path cost in their @@ -958,58 +1645,18 @@ impl ZigbeeStack

{ relayed_frame.nwk_header.sequence_number, ); - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { - // The relay is jittered to avoid synchronized rebroadcasts (spec 3.6.6) - tokio::time::sleep( - arc_self - .tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Retransmissions follow the same passive acknowledgment rule as our own - // broadcasts; the neighbor we heard the frame from is already counted - for attempt in 0..=arc_self.tunables.max_broadcast_retries { - if attempt > 0 { - if arc_self.await_broadcast_passive_acks(key).await { - tracing::debug!("Relayed broadcast {key:?} passively acknowledged"); - return; - } - - // Fresh jitter decorrelates the retransmission wave, which is - // synchronized by the shared ack deadline - tokio::time::sleep( - arc_self - .tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Acks may have trickled in during the jitter sleep - if arc_self.broadcast_passively_acked(key) { - tracing::debug!("Relayed broadcast {key:?} passively acknowledged"); - return; - } - } - - if let Err(err) = arc_self - .transmit_broadcast_nwk_frame( - relayed_frame.clone(), - NwkSecurityMode::NetworkKey, - TxPriority::USER_NORMAL, - ) - .await - { - tracing::warn!("Failed to relay broadcast: {err}"); - } - } - }); + // Unlike an originated broadcast, the first relay is also scheduled (after jitter) + // rather than sent inline, so the attempt count includes it. The passive-ack + // contract was recorded when we received the frame, so the reactor's quorum check + // already covers relayed broadcasts. + self.schedule_broadcast( + key, + relayed_frame, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + self.broadcast_jitter(), + self.tunables.max_broadcast_retries + 1, + None, + ); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 4aaf7e6..4a701dd 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -1,9 +1,9 @@ -use std::cmp; -use tokio::sync::broadcast; -use tokio::time::{Duration, Instant, timeout, timeout_at}; +use crate::runtime::Runtime; +use core::cmp; +use core::time::Duration; use ziggurat_ieee_802154::types::Nwk; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::nwk::commands::{ NwkCommand, NwkNetworkStatus, NwkNetworkStatusCommand, NwkRouteReplyCommand, @@ -11,37 +11,17 @@ use ziggurat_zigbee::nwk::commands::{ }; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use super::routing::{RouteReplyDisposition, Status}; -use super::{ - AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, ZigbeeStack, - ZigbeeStackError, -}; - -impl ZigbeeStack

{ - fn notify_routing_change(&self, nwk: &Nwk) { - let tx = { - let pending_route_notifications = self - .state - .pending_route_notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - - if !pending_route_notifications.contains_key(nwk) { - return; - } - - pending_route_notifications.get(nwk).unwrap().clone() - }; - let _ = tx.send(()); - } +use super::routing::RouteReplyDisposition; +use super::{AddrConflictSource, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack}; +impl ZigbeeStack { #[allow(clippy::significant_drop_tightening)] pub(super) fn handle_route_reply( &self, nwk_frame: &NwkFrame, route_reply_cmd: NwkRouteReplyCommand, ) { - tracing::debug!("Route reply command frame: {route_reply_cmd:?}"); + tracing::trace!("Route reply command frame: {route_reply_cmd:?}"); // Both `responder_eui64` and `originator_eui64` SHALL be set according to the // R23 spec but real devices do not do this @@ -73,7 +53,7 @@ impl ZigbeeStack

{ let (next_hop_nwk, path_cost) = match disposition { RouteReplyDisposition::Drop => return, RouteReplyDisposition::Established => { - self.notify_routing_change(&route_reply_cmd.responder_nwk); + self.pending_route_wake.notify_one(); return; } RouteReplyDisposition::Relay { @@ -82,7 +62,7 @@ impl ZigbeeStack

{ } => (next_hop, path_cost), }; - self.notify_routing_change(&route_reply_cmd.responder_nwk); + self.pending_route_wake.notify_one(); let next_hop_link = self.core().nib.neighbors.link(next_hop_nwk); @@ -167,14 +147,14 @@ impl ZigbeeStack

{ sender_nwk, updated_path_cost, route_request_cmd.many_to_one, - Instant::now().into_std(), + self.core_now(), ); if !accepted { return; } - self.notify_routing_change(&nwk_frame.nwk_header.source); + self.pending_route_wake.notify_one(); // TODO: what do we do if the address and the EUI64 don't agree? This would be // an error, some device on the network is storing invalid information about @@ -257,7 +237,7 @@ impl ZigbeeStack

{ // Spec 3.6.4.5.1.4: relayed route requests are jittered and retried let jitter = (self.tunables.min_rreq_jitter + (self.tunables.max_rreq_jitter - self.tunables.min_rreq_jitter) - .mul_f32(rand::random::())) + .mul_f32(crate::rng::random_f32())) * 2; self.background_broadcast_route_request( @@ -270,6 +250,12 @@ impl ZigbeeStack

{ /// Broadcast a route request `attempts` times, separated by the RREQ retry /// interval. The frame's sequence number must already be assigned: route request /// retries and relays are not new frames. + /// + // TODO: this is the last per-broadcast spawn. Route requests are a distinct + // retransmit regime from data broadcasts: no passive-ack, a fixed count at a fixed + // interval when originated (spec 3.6.4.5.1.4) and jittered per-retransmission when + // relayed. They were left out of the broadcast-retransmit reactor. Fold them in + // (as a non-passive-ack schedule variant) to remove this spawn. fn background_broadcast_route_request( &self, nwk_frame: NwkFrame, @@ -282,11 +268,11 @@ impl ZigbeeStack

{ .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - tokio::time::sleep(initial_delay).await; + R::sleep(initial_delay).await; for attempt in 0..attempts { if attempt > 0 { - tokio::time::sleep(arc_self.tunables.rreq_retry_interval).await; + R::sleep(arc_self.tunables.rreq_retry_interval).await; } if let Err(err) = arc_self @@ -312,7 +298,7 @@ impl ZigbeeStack

{ .core() .nib .routing - .begin_many_to_one_advertisement(Instant::now().into_std()); + .begin_many_to_one_advertisement(self.core_now()); tracing::debug!("Sending many-to-one route request {route_request_identifier}"); @@ -348,14 +334,15 @@ impl ZigbeeStack

{ // Receivers drop route requests from senders with a zero outgoing cost, so // the first advertisement waits until link status exchanges establish a // neighbor link, bounded by a fixed ceiling in case the network is silent - let startup_deadline = Instant::now() + 2 * self.tunables.link_status_period; + let startup_deadline = self.core_now() + 2 * self.tunables.link_status_period; loop { if self.core().nib.neighbors.any_live_router_link() { break; } - if timeout_at(startup_deadline, self.link_status_received.notified()) + if self + .timeout_at_core(startup_deadline, self.link_status_received.notified()) .await .is_err() { @@ -368,17 +355,19 @@ impl ZigbeeStack

{ self.core().nib.routing.reset_mtorr_triggers(); - let min_deadline = Instant::now() + self.tunables.mtorr_min_interval; - let max_deadline = Instant::now() + self.tunables.mtorr_max_interval; + let min_deadline = self.core_now() + self.tunables.mtorr_min_interval; + let max_deadline = self.core_now() + self.tunables.mtorr_max_interval; - // Avertise every max interval, sooner when accumulated route errors or + // Advertise every max interval, sooner when accumulated route errors or // delivery failures signal that routes toward us have gone bad, but never // within the min interval - tokio::select! { - () = tokio::time::sleep_until(max_deadline) => {} - () = self.mtorr_kick.notified() => { - tokio::time::sleep_until(min_deadline).await; - } + let max_sleep = core::pin::pin!(self.sleep_until_core(max_deadline)); + let kicked = core::pin::pin!(self.mtorr_kick.notified()); + if let futures::future::Either::Right(((), _)) = + futures::future::select(max_sleep, kicked).await + { + // Kicked early: still honor the minimum spacing before re-advertising. + self.sleep_until_core(min_deadline).await; } } } @@ -482,121 +471,19 @@ impl ZigbeeStack

{ } } + /// Begin or restart ad-hoc route discovery toward a destination: the routing entry + /// enters `DiscoveryUnderway` and a route request is broadcast. The waiting is + /// owned by the pending-route reactor, not the caller; this only kicks off the + /// discovery. #[allow(clippy::significant_drop_tightening)] - pub async fn discover_route(&self, destination: Nwk) -> Result { - // End device children do not participate in route discovery (they could never - // answer a route request); their parent always delivers directly - if self.end_device_child_eui64(destination).is_some() { - return Ok(destination); - } - - if self.state.hack_force_route_discovery - || self.core().nib.routing.route_status(destination).is_none() - { - tracing::debug!("Starting route discovery for NWK {destination:?}"); - self.send_route_discovery(destination).await; - } - - // The entry just ensured above can be torn down concurrently (e.g. a - // link-failure network status removing the route), so a missing entry is - // treated like an inactive route and discovery starts over - let route_entry_status = self - .core() - .nib - .routing - .route_status(destination) - .unwrap_or(Status::Inactive); - - tracing::debug!("Routing table status for {destination:?}: {route_entry_status:?}"); - - match route_entry_status { - Status::Active => { - let next_hop = self.core().nib.routing.next_hop(destination); - - // The same concurrent teardown can strike between the two reads - if let Some(next_hop) = next_hop { - tracing::debug!( - "Using existing next hop for NWK {destination:?}: {next_hop:?}" - ); - return Ok(next_hop); - } - - self.send_route_discovery(destination).await; - } - Status::DiscoveryUnderway => { - // Do nothing - } - Status::DiscoveryFailed | Status::Inactive => { - self.send_route_discovery(destination).await; - } - } - - // Create a pending route notification - let mut rx = { - let mut pending_route_notifications = self - .state - .pending_route_notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - let tx = pending_route_notifications - .entry(destination) - .or_insert_with(|| { - let (tx, _) = broadcast::channel(1); - tx - }); - - tx.subscribe() - }; - - // Pull the current route discovery entry for the device to determine the timeout - let discovery_timeout = { - let deadline = self - .core() - .nib - .routing - .discovery_deadline(destination, Instant::now().into_std()); - - // One should exist - match deadline { - Some(deadline) => deadline - Instant::now().into_std(), - None => { - tracing::warn!("No route discovery entry found for {destination:?}"); - return Err(ZigbeeStackError::RouteDiscoveryNoEntry); - } - } - }; - - tracing::debug!( - "Waiting for route discovery notification for NWK {destination:?} with timeout {discovery_timeout:?}" - ); - - match timeout(discovery_timeout, rx.recv()).await { - Ok(_) => { - tracing::debug!("Route discovery completed for NWK {destination:#?}"); - } - Err(err) => { - tracing::debug!("Route discovery timed out"); - self.core().nib.routing.mark_discovery_failed(destination); - return Err(ZigbeeStackError::RouteDiscoveryTimeout(err)); - } - }; - - self.core() - .nib - .routing - .next_hop(destination) - .ok_or(ZigbeeStackError::RouteInactiveAfterDiscovery) - } - - #[allow(clippy::significant_drop_tightening)] - pub async fn send_route_discovery(&self, destination: Nwk) { + pub(super) fn send_route_discovery(&self, destination: Nwk) { tracing::debug!("Sending route discovery for NWK {destination:?}"); let route_request_identifier = self .core() .nib .routing - .begin_discovery(destination, Instant::now().into_std()); + .begin_discovery(destination, self.core_now()); // If we know the EUI64 corresponding to the NWK, use it let destination_eui64 = self.core().nib.address_map.eui64_for(destination); diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 06bac84..64a3fdf 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -1,9 +1,10 @@ +use crate::runtime::Runtime; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode}; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use tokio::time::Instant; use ziggurat_zigbee::zdp::{ DeviceAnnce, MgmtLqiReq, MgmtLqiRsp, MgmtRtgReq, MgmtRtgRsp, NeighborDescriptor, ParentAnnce, ParentAnnceRsp, RoutingDescriptor, ZDP_PROFILE_ID, ZdpAffinity, ZdpClusterId, ZdpCommand, @@ -11,7 +12,7 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkDeviceType, ZigbeeStack, ZigbeeStackError, + ApsAck, MAX_DEPTH, NwkDeviceType, TxOutcome, TxPriority, ZigbeeStack, ZigbeeStackError, neighbors, routing, }; @@ -25,7 +26,7 @@ const MGMT_LQI_DESCRIPTORS_PER_FRAME: usize = 2; /// Routing records per Mgmt_Rtg_rsp, keeping the ASDU within the NWK payload budget. const MGMT_RTG_DESCRIPTORS_PER_FRAME: usize = 10; -impl ZigbeeStack

{ +impl ZigbeeStack { /// Dispatch the ZDP commands the stack itself consumes: the neighbor table they /// maintain lives here. The client still observes the frames. pub(super) fn handle_zdp_frame(&self, nwk_frame: &NwkFrame, aps_frame: &ApsDataFrame) { @@ -113,14 +114,9 @@ impl ZigbeeStack

{ neighbor_table_list: descriptors, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!("Failed to send a neighbor table response to {source:?}: {err}"); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a neighbor table response to {source:?}: {err}"); + } } /// Spec 2.4.4.3.3: answer a routing table query. @@ -175,14 +171,9 @@ impl ZigbeeStack

{ routing_table_list: descriptors, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!("Failed to send a routing table response to {source:?}: {err}"); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a routing table response to {source:?}: {err}"); + } } /// Spec 2.4.3.1.11.2: a (re)joined device announced its address pair. The address @@ -238,10 +229,7 @@ impl ZigbeeStack

{ // Spec 2.4.3.1.12.2: another router's announcement restarts our own pending // announcement countdown to avoid a network-wide broadcast storm - *self - .parent_annce_received - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = Some(Instant::now()); + *self.parent_annce_received.lock() = Some(self.core_now()); let (claimed, removed) = self .core() @@ -267,26 +255,20 @@ impl ZigbeeStack

{ children: claimed, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!( - "Failed to send a parent announcement response to {source:?}: {err}" - ); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a parent announcement response to {source:?}: {err}"); + } } - async fn send_zdp_command( + /// Build and enqueue a ZDP command fire-and-forget. + fn send_zdp_command( &self, destination: Nwk, delivery_mode: ApsDeliveryMode, tsn: u8, command: &T, ) -> Result<(), ZigbeeStackError> { - self.send_aps_command( + let (nwk_frame, _ack) = self.prepare_aps_send( delivery_mode, destination, ZDP_PROFILE_ID, @@ -298,10 +280,10 @@ impl ZigbeeStack

{ self.next_aps_counter(), command.serialize(tsn).unwrap(), None, - TxPriority::USER_NORMAL, - ) - .await - .map(|_| ()) + )?; + + self.enqueue_aps_frame(nwk_frame, TxPriority::USER_NORMAL, TxOutcome::Discard); + Ok(()) } /// Spec 2.4.4.2.22.2: a router answered our parent announcement, claiming @@ -338,16 +320,15 @@ impl ZigbeeStack

{ let jitter = self .tunables .parent_annce_jitter_max - .mul_f32(rand::random::()); - let slept_at = Instant::now(); - tokio::time::sleep(self.tunables.parent_annce_base_timer + jitter).await; + .mul_f32(crate::rng::random_f32()); + let slept_at = self.core_now(); + R::sleep(self.tunables.parent_annce_base_timer + jitter).await; // Spec 2.4.3.1.12.2: an announcement from another router restarts the // countdown if self .parent_annce_received - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .is_some_and(|received_at| received_at > slept_at) { continue; @@ -384,15 +365,12 @@ impl ZigbeeStack

{ let announcement = ParentAnnce { children: chunk }; let tsn = self.next_aps_counter(); - if let Err(err) = self - .send_zdp_command( - BROADCAST_ALL_ROUTERS_AND_COORDINATOR, - ApsDeliveryMode::Broadcast, - tsn, - &announcement, - ) - .await - { + if let Err(err) = self.send_zdp_command( + BROADCAST_ALL_ROUTERS_AND_COORDINATOR, + ApsDeliveryMode::Broadcast, + tsn, + &announcement, + ) { tracing::warn!("Failed to broadcast a parent announcement: {err}"); } diff --git a/crates/ziggurat-esp/.cargo/config.toml b/crates/ziggurat-esp/.cargo/config.toml new file mode 100644 index 0000000..ad57a48 --- /dev/null +++ b/crates/ziggurat-esp/.cargo/config.toml @@ -0,0 +1,15 @@ +[target.riscv32imac-unknown-none-elf] +runner = "espflash flash --monitor --chip esp32c6" + +[build] +rustflags = [ + "-C", "link-arg=-Tlinkall.x", + "-C", "force-frame-pointers", +] +target = "riscv32imac-unknown-none-elf" + +[env] +ESP_LOG = "info" + +[unstable] +build-std = ["core", "alloc"] diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock new file mode 100644 index 0000000..7a74e7a --- /dev/null +++ b/crates/ziggurat-esp/Cargo.lock @@ -0,0 +1,2398 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abstract-bits" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "abstract-bits-derive", + "arbitrary-int 1.3.0", + "bitvec", + "thiserror", +] + +[[package]] +name = "abstract-bits-derive" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[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.118", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "byte" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc" + +[[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 = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317" +dependencies = [ + "aead 0.4.3", + "cipher 0.3.0", + "ctr 0.8.0", + "subtle", +] + +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead 0.6.1", + "cipher 0.5.2", + "ctr 0.10.1", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" +dependencies = [ + "cordyceps", + "critical-section", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[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", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.3", +] + +[[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-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 = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-alloc" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-alloc" +version = "0.10.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "allocator-api2", + "document-features", + "enumset", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-backtrace" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f" +dependencies = [ + "cfg-if", + "document-features", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-println", + "heapless 0.9.3", + "riscv", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "esp-bootloader-esp-idf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c" +dependencies = [ + "cfg-if", + "document-features", + "embedded-storage", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-hal-procmacros 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-rom-sys", + "jiff", + "strum", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +dependencies = [ + "document-features", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "bitfield", + "bitflags 2.13.0", + "bytemuck", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-sync 0.8.0", + "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 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-riscv-rt", + "esp-rom-sys", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp32", + "esp32c2", + "esp32c3", + "esp32c5", + "esp32c6", + "esp32c61", + "esp32h2", + "esp32p4", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_core 0.9.5", + "riscv", + "static_cell", + "strum", + "ufmt-write", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "xtensa-lx-rt", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +dependencies = [ + "document-features", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" + +[[package]] +name = "esp-phy" +version = "0.2.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "embassy-sync 0.8.0", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-hal", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-wifi-sys-esp32c6", + "esp32c6", +] + +[[package]] +name = "esp-println" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6" +dependencies = [ + "document-features", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "log", +] + +[[package]] +name = "esp-radio" +version = "1.0.0-beta.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "allocator-api2", + "byte", + "docsplay", + "document-features", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "esp-alloc 0.10.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-hal", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-phy", + "esp-radio-rtos-driver", + "esp-rom-sys", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-wifi-sys-esp32c6", + "esp32c6", + "heapless 0.9.3", + "ieee802154", + "instability", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp32c6", +] + +[[package]] +name = "esp-rtos" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712" +dependencies = [ + "cfg-if", + "document-features", + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time-driver", + "embassy-time-queue-utils", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-hal", + "esp-hal-procmacros 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-rom-sys", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "portable-atomic", + "riscv", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "riscv", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "riscv", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" + +[[package]] +name = "esp32" +version = "0.40.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c5" +version = "0.2.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c61" +version = "0.3.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32p4" +version = "0.2.0" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-sink", + "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 = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[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 = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ieee802154" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5" +dependencies = [ + "byte", + "ccm 0.4.4", + "cipher 0.3.0", + "hash32 0.2.1", + "hash32-derive", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[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.118", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "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 = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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-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.118", +] + +[[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 = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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.118", +] + +[[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.118", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[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.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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 = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[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.118", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[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.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[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.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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 = "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-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[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" +version = "0.13.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.22.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "document-features", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ziggurat-driver" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "arbitrary-int 2.1.1", + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time", + "futures", + "spin", + "thiserror", + "tracing", + "ziggurat-ieee-802154", + "ziggurat-phy", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-esp" +version = "0.1.0" +dependencies = [ + "embassy-executor", + "embassy-futures", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-io-async 0.7.0", + "esp-alloc 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-backtrace", + "esp-bootloader-esp-idf", + "esp-hal", + "esp-println", + "esp-radio", + "esp-rtos", + "heapless 0.9.3", + "hex", + "serde", + "serde_json", + "subtle", + "tracing", + "ziggurat-driver", + "ziggurat-ieee-802154", + "ziggurat-phy", + "ziggurat-phy-esp", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-ieee-802154" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "educe", + "heapless 0.9.3", + "hex", + "num_enum", + "serde", + "thiserror", +] + +[[package]] +name = "ziggurat-phy" +version = "0.1.0" +dependencies = [ + "thiserror", + "ziggurat-ieee-802154", +] + +[[package]] +name = "ziggurat-phy-esp" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-sync 0.8.0", + "embassy-time", + "esp-hal", + "esp-radio", + "ziggurat-ieee-802154", + "ziggurat-phy", +] + +[[package]] +name = "ziggurat-zigbee" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "aes", + "arbitrary-int 2.1.1", + "ccm 0.6.0-rc.3", + "educe", + "hex", + "num_enum", + "once_cell", + "serde", + "subtle", + "thiserror", + "tracing", + "ziggurat-ieee-802154", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml new file mode 100644 index 0000000..fdc6e5a --- /dev/null +++ b/crates/ziggurat-esp/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "ziggurat-esp" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +description = "ESP32-C6 firmware running the Ziggurat stack with a line-delimited JSON API" +license = "Apache-2.0" + +[[bin]] +name = "ziggurat-esp" +path = "src/main.rs" + +[dependencies] +ziggurat-driver = { path = "../ziggurat-driver", default-features = false, features = [ + "embassy", +] } +ziggurat-phy = { path = "../ziggurat-phy" } +ziggurat-phy-esp = { path = "../ziggurat-phy-esp" } +ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } +ziggurat-zigbee = { path = "../ziggurat-zigbee" } + +esp-hal = { version = "1.1.0", features = ["esp32c6", "unstable"] } +esp-radio = { version = "1.0.0-beta.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-rtos = { version = "0.3.0", features = ["esp32c6", "embassy"] } +esp-alloc = "0.10.0" +esp-backtrace = { version = "0.19.0", features = [ + "esp32c6", + "panic-handler", + "println", +] } +esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6"] } +esp-println = { version = "0.17.0", default-features = false, features = [ + "esp32c6", + "log-04", + "uart", + "critical-section", +] } + +embassy-executor = "0.10" +embassy-time = "0.5" +embassy-sync = "0.8" +embassy-futures = "0.1" +embedded-io-async = "0.7" + +heapless = { version = "0.9", default-features = false } +tracing = { version = "0.1", default-features = false } +serde = { version = "1", default-features = false, features = ["alloc", "derive"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +subtle = { version = "2", default-features = false } + +[profile.release] +opt-level = "s" +debug = true +lto = "fat" +codegen-units = 1 + +# A standalone workspace: this crate only builds for the ESP32-C6 target and is excluded +# from the host workspace. +[workspace] + +# Build against the esp-hal fork so we can patch esp-radio's IEEE 802.15.4 driver to set +# the auto-ACK frame-pending bit (RX_DONE ISR). esp-radio's intra-repo deps are path-based, +# so esp-hal must be patched alongside it to avoid two esp-hal copies; cargo resolves each +# package by name within the git workspace. +[patch.crates-io] +esp-hal = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } +esp-radio = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } +esp-rom-sys = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } diff --git a/crates/ziggurat-esp/rust-toolchain.toml b/crates/ziggurat-esp/rust-toolchain.toml new file mode 100644 index 0000000..dda19b6 --- /dev/null +++ b/crates/ziggurat-esp/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +components = ["rust-src"] +targets = ["riscv32imac-unknown-none-elf"] diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs new file mode 100644 index 0000000..bf9517b --- /dev/null +++ b/crates/ziggurat-esp/src/api.rs @@ -0,0 +1,834 @@ +//! The line-delimited JSON-RPC surface, mirroring the host server's wire protocol. One +//! request per line; each is answered with an `accepted` event then a `response`. +//! Unsolicited `notification` lines carry network events. + +use alloc::boxed::Box; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::time::Duration; + +use embassy_futures::select::{Either, select}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::CaptureStop; + +use ziggurat_driver::runtime::Spawn; +use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; +use ziggurat_driver::zigbee_stack::{ + ApsAck, ApsAckResult, NetworkBeacon, NetworkConfig, NwkDeviceType, RequestId, SendResult, + TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, +}; +use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; +use ziggurat_zigbee::aps::frame::ApsDeliveryMode; + +use crate::{App, OUTBOUND}; + +const PROTOCOL_VERSION: u32 = 1; +const DEFAULT_TX_POWER: i8 = 8; + +/// Queue one JSON object for the serial writer task. +pub async fn emit(value: Value) { + if let Ok(line) = serde_json::to_string(&value) { + push_outbound(line); + } +} + +fn push_outbound(mut line: String) { + loop { + match OUTBOUND.try_send(line) { + Ok(()) => break, + Err(embassy_sync::channel::TrySendError::Full(returned)) => { + line = returned; + let _ = OUTBOUND.try_receive(); + } + } + } +} + +pub fn hello_message(configured: bool) -> Value { + let state = if configured { + "running" + } else { + "awaiting_configuration" + }; + json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state}) +} + +fn event(id: u64, name: &str) -> Value { + json!({"type": "event", "id": id, "event": name}) +} + +fn event_data(id: u64, name: &str, data: Value) -> Value { + json!({"type": "event", "id": id, "event": name, "data": data}) +} + +fn response(id: u64, result: Value) -> Value { + json!({"type": "response", "id": id, "result": result}) +} + +fn error_response(id: u64, code: &str, message: impl ToString) -> Value { + json!({ + "type": "response", "id": id, + "error": {"code": code, "message": message.to_string()}, + }) +} + +fn notification(name: &str, data: Value) -> Value { + json!({"type": "notification", "event": name, "data": data}) +} + +/// Big-endian colon-separated hex, matching the host server / zigpy format. +fn eui64_to_string(eui64: Eui64) -> String { + let mut bytes = eui64.to_bytes(); + bytes.reverse(); + join_hex(&bytes) +} + +fn key_to_string(key: &Key) -> String { + join_hex(&key.to_bytes()) +} + +fn join_hex(bytes: &[u8]) -> String { + let mut out = String::new(); + for (i, byte) in bytes.iter().enumerate() { + if i != 0 { + out.push(':'); + } + out.push_str(&format!("{byte:02x}")); + } + out +} + +#[derive(Deserialize)] +struct Request { + id: u64, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Deserialize, Default, Clone, Copy)] +#[serde(rename_all = "lowercase")] +enum NodeRole { + #[default] + Coordinator, + Router, +} + +impl From for NwkDeviceType { + fn from(role: NodeRole) -> Self { + match role { + NodeRole::Coordinator => Self::Coordinator, + NodeRole::Router => Self::Router, + } + } +} + +#[derive(Deserialize)] +struct ConfigureRequest { + #[serde(default)] + role: NodeRole, + channel: u8, + nwk_update_id: u8, + pan_id: PanId, + extended_pan_id: Eui64, + nwk_address: Nwk, + ieee_address: Eui64, + network_key: Key, + network_key_seq: u8, + network_key_tx_counter: u32, + tc_link_key: Option, + tclk_seed: Option, + tclk_flavor: Option, + #[serde(default)] + key_table: Vec, + #[serde(default)] + source_routing: bool, + tx_power: Option, +} + +#[derive(Deserialize)] +struct KeyTableEntry { + partner_ieee: Eui64, + key: Key, +} + +#[derive(Deserialize)] +struct SendApsRequest { + delivery_mode: ApsDeliveryMode, + destination_eui64: Option, + destination: Option, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: bool, + aps_seq: u8, + radius: u8, + /// Hex-encoded ASDU + data: String, + #[serde(default)] + aps_encryption: bool, + #[serde(default)] + priority: i8, +} + +#[derive(Deserialize)] +struct PermitJoinsRequest { + #[serde(default)] + duration: u64, + #[serde(default = "default_accept_direct_joins")] + accept_direct_joins: bool, +} + +const fn default_accept_direct_joins() -> bool { + true +} + +#[derive(Deserialize)] +struct EnergyScanRequest { + channels: Vec, + duration_per_channel_ms: u16, +} + +#[derive(Deserialize)] +struct NetworkScanRequest { + channels: Vec, + duration_per_channel_ms: u16, +} + +#[derive(Deserialize)] +struct SetChannelRequest { + channel: u8, +} + +#[derive(Deserialize)] +struct SetNwkUpdateIdRequest { + nwk_update_id: u8, +} + +#[derive(Deserialize)] +struct SetProvisionalKeyRequest { + ieee: Eui64, + key: Key, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum ResetType { + /// Stop transient radio activity (packet capture) and return to idle. The configured + /// network, if any, keeps running. Sent by the client on connect as a session reset. + Soft, + /// Reboot the MCU. The serial link drops and the client reconnects. + Hard, +} + +#[derive(Deserialize)] +struct ResetRequest { + reset_type: ResetType, +} + +/// Parse and dispatch one inbound line, emitting the `accepted` event and the response. +pub async fn handle_line(app: &mut App, line: &[u8]) { + let request: Request = match serde_json::from_slice(line) { + Ok(request) => request, + Err(e) => { + emit(error_response(0, "invalid_request", e)).await; + return; + } + }; + + emit(event(request.id, "accepted")).await; + + let Request { id, method, params } = request; + let message = match method.as_str() { + "ping" => response(id, json!({"status": "pong"})), + "reset" => handle_reset(app, id, params), + "configure" => handle_configure(app, id, params).await, + "get_hw_address" => handle_get_hw_address(id), + "get_network_info" => handle_get_network_info(app, id), + "send_aps" => { + // Fire-and-forget: the stack accepts or rejects now, and the terminal outcome + // arrives later as a `send_confirm` notification keyed by the request id. + dispatch_send_aps(app, id, params).await; + return; + } + "energy_scan" => handle_energy_scan(app, id, params).await, + "network_scan" => handle_network_scan(app, id, params).await, + "permit_joins" => handle_permit_joins(app, id, params), + "set_channel" => handle_set_channel(app, id, params).await, + "packet_capture" => { + // Streaming with no terminal response: the spawned capture task emits + // `captured_packet` events until the client disconnects. + handle_packet_capture(app, id, params).await; + return; + } + "packet_capture_change_channel" => { + handle_packet_capture_change_channel(app, id, params).await + } + "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), + "set_provisional_key" => handle_set_provisional_key(app, id, params), + other => error_response(id, "unknown_method", other), + }; + + emit(message).await; +} + +/// Soft or hard reset. Both stop any in-progress packet capture (freeing the radio); a hard +/// reset additionally reboots the MCU. The configured network is left running on a soft +/// reset — it must survive client reconnects. +fn handle_reset(app: &mut App, id: u64, params: Value) -> Value { + let request: ResetRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + if let Some(stop) = app.capture_stop.take() { + stop.signal(()); + } + + if matches!(request.reset_type, ResetType::Hard) { + esp_hal::system::software_reset(); // diverges; the link drops and the client reconnects + } + + response(id, json!({"status": "success"})) +} + +async fn handle_configure(app: &mut App, id: u64, params: Value) -> Value { + let request: ConfigureRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + if let Some(old_stack) = app.stack.take() { + old_stack.shutdown().await; + } + + let tclk_seed = match (request.tclk_seed, request.tclk_flavor) { + (Some(seed), Some(flavor)) => Some(TclkSeed { seed, flavor }), + (None, None) => None, + _ => { + return error_response( + id, + "invalid_request", + "tclk_seed and tclk_flavor must be provided together", + ); + } + }; + + let stack = ZigbeeStack::new( + app.phy.clone(), + NetworkConfig { + role: request.role.into(), + channel: request.channel, + update_id: request.nwk_update_id, + pan_id: request.pan_id, + extended_pan_id: request.extended_pan_id, + network_address: request.nwk_address, + ieee_address: request.ieee_address, + network_key: request.network_key, + network_key_seq_number: request.network_key_seq, + network_key_tx_counter: request.network_key_tx_counter, + tc_link_key: request.tc_link_key.unwrap_or(WELL_KNOWN_LINK_KEY), + tclk_seed, + tx_power: request.tx_power.unwrap_or(DEFAULT_TX_POWER), + source_routing: request.source_routing, + }, + Tunables::new(), + app.spawner, + ); + + if !request.key_table.is_empty() { + let mut core = stack.state.core.lock(); + for entry in request.key_table { + core.aib + .aps_security + .restore_device_key(entry.partner_ieee, entry.key); + } + } + + if let Err(e) = stack.start_network().await { + return error_response(id, "network_start_failed", e); + } + + let run_stack = stack.clone(); + stack.spawn_tracked(async move { + run_stack.run().await; + }); + + // Drain network events to the serial writer. + let notify_stack = stack.clone(); + stack.spawn_tracked(async move { + loop { + for notification_event in notify_stack.next_notifications().await { + emit(notification_to_json(notification_event)).await; + } + } + }); + + app.stack = Some(stack); + response(id, json!({"status": "success"})) +} + +/// The factory IEEE address, derived from the SoC's eFuse base MAC (EUI-48 → EUI-64). +fn handle_get_hw_address(id: u64) -> Value { + let mac = esp_hal::efuse::base_mac_address(); + let mac = mac.as_bytes(); + // Big-endian EUI-64: first 3 MAC bytes, FF FE, last 3 MAC bytes. + let big_endian = [mac[0], mac[1], mac[2], 0xff, 0xfe, mac[3], mac[4], mac[5]]; + let mut le = big_endian; + le.reverse(); + response(id, json!({"ieee_address": eui64_to_string(Eui64(le))})) +} + +fn handle_get_network_info(app: &App, id: u64) -> Value { + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let state = &stack.state; + let core = state.core.lock(); + let nwk_security = &core.nib.nwk_security; + let aps_security = &core.aib.aps_security; + let tclk_seed = &stack.config.tclk_seed; + + response( + id, + json!({ + "channel": core.mac.channel, + "nwk_update_id": core.nib.update_id, + "pan_id": format!("{:04x}", core.mac.pan_id.0), + "extended_pan_id": eui64_to_string(state.extended_pan_id), + "nwk_address": format!("{:04x}", state.network_address.as_u16()), + "ieee_address": eui64_to_string(state.ieee_address), + "network_key": key_to_string(&nwk_security.network_key()), + "network_key_seq": nwk_security.key_seq_number(), + "network_key_tx_counter": nwk_security.outgoing_frame_counter(), + "tc_link_key": key_to_string(&stack.config.tc_link_key), + "tclk_seed": tclk_seed.as_ref().map(|tclk| hex::encode(tclk.seed.to_bytes())), + "tclk_flavor": tclk_seed.as_ref().map(|tclk| match tclk.flavor { + TclkFlavor::ZStack => "zstack", + TclkFlavor::Ezsp => "ezsp", + }), + "key_table": aps_security + .device_keys() + .map(|(partner_ieee, entry)| json!({ + "partner_ieee": eui64_to_string(partner_ieee), + "key": key_to_string(&entry.key), + })) + .collect::>(), + "tx_power": stack.config.tx_power, + }), + ) +} + +/// Fire-and-forget APS send: build and enqueue the frame, then return. No task, no +/// blocking — delivery is driven by the stack's tables. The stack accepts or rejects the +/// frame now (the `accepted`/error response); its terminal outcome arrives later as a +/// `send_confirm` notification keyed by the request id. +async fn dispatch_send_aps(app: &App, id: u64, params: Value) { + let Some(stack) = app.stack.as_ref() else { + emit(error_response(id, "not_configured", "no stack is running")).await; + return; + }; + + let request: SendApsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return emit(error_response(id, "invalid_request", e)).await, + }; + + let destination = match (request.destination_eui64, request.destination) { + (_, Some(nwk)) => nwk, + (Some(eui64), None) => match stack.state.core.lock().nib.address_map.nwk_for(eui64) { + Some(nwk) => nwk, + None => { + return emit(error_response( + id, + "unknown_destination_eui64", + format!("{eui64:?}"), + )) + .await; + } + }, + (None, None) => { + return emit(error_response( + id, + "missing_destination", + "no destination given", + )) + .await; + } + }; + + let asdu = match hex::decode(&request.data) { + Ok(asdu) => asdu, + Err(e) => return emit(error_response(id, "invalid_data", e)).await, + }; + + let aps_security = if request.aps_encryption { + match (request.destination_eui64, request.delivery_mode) { + (Some(eui64), ApsDeliveryMode::Unicast) => Some(eui64), + _ => { + return emit(error_response( + id, + "invalid_request", + "aps_encryption requires a unicast destination_eui64", + )) + .await; + } + } + } else { + None + }; + + let aps_ack = if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None + }; + + let outcome = stack.send_aps( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + aps_ack, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + id as RequestId, + ); + + // The stack accepts the frame for transmission or rejects it now. The terminal + // delivery outcome arrives later as a `send_confirm` notification keyed by this + // request id (the send token). + let message = match outcome { + Ok(()) => response(id, json!({"status": "accepted"})), + Err(e) => error_response(id, "transmit_failed", e), + }; + emit(message).await; +} + +/// Energy scan: per-channel hardware energy detection, streamed as `energy_result` +/// events. The radio's ED is driven directly through its registers (see ziggurat-phy-esp). +async fn handle_energy_scan(app: &App, id: u64, params: Value) -> Value { + let request: EnergyScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + // Energy detect is a radio operation, not a network one. + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + for channel in request.channels { + match app.phy.energy_detect(channel, duration).await { + Ok(rssi) => { + emit(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": rssi}), + )) + .await; + } + Err(e) => return error_response(id, "energy_scan_failed", e), + } + } + + response(id, json!({"status": "complete"})) +} + +fn network_beacon_json(beacon: &NetworkBeacon) -> Value { + json!({ + "channel": beacon.channel, + "source": beacon.source.map(|nwk| format!("{:04x}", nwk.0)), + "pan_id": format!("{:04x}", beacon.pan_id.0), + "extended_pan_id": eui64_to_string(beacon.extended_pan_id), + "permit_joining": beacon.permit_joining, + "stack_profile": beacon.stack_profile, + "protocol_version": beacon.protocol_version, + "router_capacity": beacon.router_capacity, + "end_device_capacity": beacon.end_device_capacity, + "device_depth": beacon.device_depth, + "update_id": beacon.update_id, + "lqi": beacon.lqi, + "rssi": beacon.rssi, + }) +} + +/// Active scan: beacon-request each channel and stream the beacons heard. Runs inline — +/// the receive loop collects beacons concurrently during the per-channel dwell, so they +/// are all queued by the time the scan returns and we drain them. +async fn handle_network_scan(app: &App, id: u64, params: Value) -> Value { + let request: NetworkScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.begin_network_scan(); + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let result = stack.run_network_scan(&request.channels, duration).await; + + loop { + let batch = stack.next_scan_beacons().await; + if batch.is_empty() { + break; + } + for beacon in batch { + emit(event_data( + id, + "network_found", + network_beacon_json(&beacon), + )) + .await; + } + } + + match result { + Ok(()) => response(id, json!({"status": "complete"})), + Err(e) => error_response(id, "network_scan_failed", e), + } +} + +fn handle_permit_joins(app: &App, id: u64, params: Value) -> Value { + let request: PermitJoinsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.permit_joins(request.duration, request.accept_direct_joins); + response(id, json!({"status": "success"})) +} + +async fn handle_set_channel(app: &App, id: u64, params: Value) -> Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + match stack.set_channel(request.channel).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } +} + +/// Radio programming for promiscuous capture: receive every frame on `channel`, no PAN/ +/// address filtering, no network required. Dummy addresses since nothing is addressed to us. +const fn capture_config(channel: u8) -> RadioConfig { + RadioConfig { + channel, + tx_power: DEFAULT_TX_POWER, + short_address: Nwk(0xFFFF), + extended_address: Eui64([0; 8]), + pan_id: PanId(0xFFFF), + promiscuous: true, + rx_on_when_idle: true, + frame_pending_short: Vec::new(), + frame_pending_extended: Vec::new(), + } +} + +/// Put the radio in promiscuous mode and stream every received frame as a `captured_packet` +/// event. No configured stack needed (only `connect()`). Singleton: a second call just +/// retunes. Stopped by `reset` (the client sends a soft reset on connect, clearing any stale +/// capture). There is no terminal response. +async fn handle_packet_capture(app: &mut App, id: u64, params: Value) { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => { + emit(error_response(id, "invalid_request", e)).await; + return; + } + }; + + if let Err(e) = app.phy.reconfigure(&capture_config(request.channel)).await { + emit(error_response(id, "packet_capture_failed", e)).await; + return; + } + + // Already capturing: the reconfigure above retuned it; don't spawn a second task. + if app.capture_stop.is_some() { + return; + } + + let stop = Arc::new(CaptureStop::new()); + app.capture_stop = Some(stop.clone()); + + let phy = app.phy.clone(); + app.spawner.spawn(Box::pin(async move { + let mut rx = phy.subscribe_rx(); + loop { + match select(rx.recv(), stop.wait()).await { + Either::First(Some(frame)) => { + if let Ok(line) = serde_json::to_string(&event_data( + id, + "captured_packet", + json!({ + "channel": frame.channel, + "rssi": frame.rssi, + "lqi": frame.lqi, + "data": hex::encode(&frame.psdu), + }), + )) { + // Drop on a full queue: a sniffer must not block (or back up stale + // frames for a reconnecting client). + let _ = OUTBOUND.try_send(line); + } + } + // RX stream closed or `reset` signalled the stop. + _ => break, + } + } + })); +} + +async fn handle_packet_capture_change_channel(app: &App, id: u64, params: Value) -> Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + match app.phy.reconfigure(&capture_config(request.channel)).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } +} + +fn handle_set_nwk_update_id(app: &App, id: u64, params: Value) -> Value { + let request: SetNwkUpdateIdRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_nwk_update_id(request.nwk_update_id); + response(id, json!({"status": "success"})) +} + +fn handle_set_provisional_key(app: &App, id: u64, params: Value) -> Value { + let request: SetProvisionalKeyRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_provisional_key(request.ieee, request.key); + response(id, json!({"status": "success"})) +} + +fn notification_to_json(notification_event: ZigbeeNotification) -> Value { + match notification_event { + ZigbeeNotification::ReceivedApsCommand { + source, + destination, + group, + profile_id, + cluster_id, + src_ep, + dst_ep, + lqi, + rssi, + data, + } => notification( + "received_aps_command", + json!({ + "source": hex::encode(source.to_bytes()), + "destination": hex::encode(destination.to_bytes()), + "group": group, + "profile_id": profile_id, + "cluster_id": cluster_id, "src_ep": src_ep, "dst_ep": dst_ep, + "lqi": lqi, "rssi": rssi, "data": hex::encode(data), + }), + ), + ZigbeeNotification::FrameCounterUpdate { frame_counter } => notification( + "frame_counter_update", + json!({"frame_counter": frame_counter}), + ), + ZigbeeNotification::LinkKeyUpdate { ieee, key } => notification( + "link_key_update", + json!({"ieee": eui64_to_string(ieee), "key": key_to_string(&key)}), + ), + ZigbeeNotification::DeviceJoined { nwk, ieee, parent } => notification( + "device_joined", + json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": eui64_to_string(ieee), + "parent": hex::encode(parent.to_bytes()), + }), + ), + ZigbeeNotification::DeviceLeft { nwk, ieee, .. } => notification( + "device_left", + json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": ieee.map(eui64_to_string), + }), + ), + ZigbeeNotification::ApsDecryptionFailure { + source, + source_ieee, + frame_counter, + key_id, + } => notification( + "aps_decryption_failure", + json!({ + "source": hex::encode(source.to_bytes()), + "source_ieee": eui64_to_string(source_ieee), + "frame_counter": frame_counter, + "key_id": key_id, + }), + ), + ZigbeeNotification::SendConfirm { request_id, result } => notification( + "send_confirm", + match result { + SendResult::Confirmed { next_hop } => json!({ + "id": request_id, + "status": "confirmed", + "next_hop": next_hop.map(|nwk| format!("{:04x}", nwk.0)), + }), + SendResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), + ZigbeeNotification::ApsAckConfirm { request_id, result } => notification( + "aps_ack_confirm", + match result { + ApsAckResult::Acked => json!({ + "id": request_id, + "status": "confirmed", + }), + ApsAckResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), + } +} diff --git a/crates/ziggurat-esp/src/hw_crypto.rs b/crates/ziggurat-esp/src/hw_crypto.rs new file mode 100644 index 0000000..9f93809 --- /dev/null +++ b/crates/ziggurat-esp/src/hw_crypto.rs @@ -0,0 +1,240 @@ +//! Hardware crypto backend for the ESP32-C6. + +use core::cell::RefCell; + +use embassy_sync::blocking_mutex::Mutex; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use esp_hal::aes::cipher_modes::{Cbc, Ctr}; +use esp_hal::aes::dma::{AesDma, AesDmaChannel, DmaCipherState}; +use esp_hal::aes::{Aes, Operation}; +use esp_hal::dma::aligned::DmaAlignedMut; +use esp_hal::dma::{DmaRxBuf, DmaTxBuf}; +use esp_hal::dma_buffers; +use esp_hal::peripherals::AES; +use subtle::ConstantTimeEq; + +use ziggurat_ieee_802154::FrameBytes; +use ziggurat_ieee_802154::types::Key; +use ziggurat_zigbee::crypto::{self, CryptoBackend, DecryptionError, MIC_LENGTH}; + +/// Scratch/DMA buffer size. +const DMA_BUF_SIZE: usize = 256; + +/// The AES peripheral in DMA mode plus its two DMA buffers. `AesDma::process` consumes +/// the driver + buffers and hands them back from the transfer, so they live in +/// `Option`s and are taken/replaced around each pass. Behind a critical-section mutex: +/// the peripheral is shared by every task that does crypto. +struct DmaState { + aes: Option>, + rx: Option, + tx: Option, +} + +static HW: Mutex>> = + Mutex::new(RefCell::new(None)); + +struct EspCrypto; + +impl CryptoBackend for EspCrypto { + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]) { + HW.lock(|cell| { + let mut guard = cell.borrow_mut(); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + // Single ECB block on the Typical path (the inner non-DMA driver). + dma.aes.as_mut().unwrap().aes.encrypt(block, *key); + }); + } + + fn encrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, + ) -> FrameBytes { + let plen = buffer.len(); + + let mut cbc_in = [0u8; DMA_BUF_SIZE]; + let cbc_len = format_cbc_mac_input(&mut cbc_in, nonce, auth_data, buffer.as_slice()); + + let mut ctr_in = [0u8; DMA_BUF_SIZE]; + ctr_in[16..16 + plen].copy_from_slice(buffer.as_slice()); + let ctr_len = round_up_16(16 + plen); + + let mut cbc_out = [0u8; DMA_BUF_SIZE]; + let mut ctr_out = [0u8; DMA_BUF_SIZE]; + let cbc_state: DmaCipherState = Cbc::new([0u8; 16]).into(); + let ctr_state: DmaCipherState = Ctr::new(ctr_block(nonce, 0)).into(); + HW.lock(|cell| { + let mut guard = cell.borrow_mut(); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + run( + dma, + &cbc_state, + &key.0, + &cbc_in[..cbc_len], + &mut cbc_out[..cbc_len], + ); + run( + dma, + &ctr_state, + &key.0, + &ctr_in[..ctr_len], + &mut ctr_out[..ctr_len], + ); + }); + + let tag = &cbc_out[cbc_len - 16..cbc_len]; + let s0 = &ctr_out[0..16]; + + let mut out = FrameBytes::new(); + out.extend_from_slice(&ctr_out[16..16 + plen]) + .expect("ciphertext fits a frame"); + for i in 0..MIC_LENGTH { + out.push(tag[i] ^ s0[i]) + .expect("frame has room for the MIC"); + } + out + } + + fn decrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, + ) -> Result { + let clen = tagged_ciphertext + .len() + .checked_sub(MIC_LENGTH) + .ok_or(DecryptionError::CiphertextTooShort)?; + let (ciphertext, recv_mic) = tagged_ciphertext.as_slice().split_at(clen); + + let mut ctr_in = [0u8; DMA_BUF_SIZE]; + ctr_in[16..16 + clen].copy_from_slice(ciphertext); + let ctr_len = round_up_16(16 + clen); + + let mut ctr_out = [0u8; DMA_BUF_SIZE]; + let ctr_state: DmaCipherState = Ctr::new(ctr_block(nonce, 0)).into(); + // Recover the plaintext (and S0) first, then MAC the recovered plaintext. + let mut cbc_out = [0u8; DMA_BUF_SIZE]; + let cbc_state: DmaCipherState = Cbc::new([0u8; 16]).into(); + let mut cbc_in = [0u8; DMA_BUF_SIZE]; + let cbc_len = HW.lock(|cell| { + let mut guard = cell.borrow_mut(); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + run( + dma, + &ctr_state, + &key.0, + &ctr_in[..ctr_len], + &mut ctr_out[..ctr_len], + ); + + let cbc_len = + format_cbc_mac_input(&mut cbc_in, nonce, auth_data, &ctr_out[16..16 + clen]); + run( + dma, + &cbc_state, + &key.0, + &cbc_in[..cbc_len], + &mut cbc_out[..cbc_len], + ); + cbc_len + }); + + let tag = &cbc_out[cbc_len - 16..cbc_len]; + let s0 = &ctr_out[0..16]; + + let expected_mic: [u8; MIC_LENGTH] = core::array::from_fn(|i| tag[i] ^ s0[i]); + if !bool::from(expected_mic[..].ct_eq(recv_mic)) { + return Err(DecryptionError::InvalidMacTag); + } + + Ok(FrameBytes::from_slice(&ctr_out[16..16 + clen]).expect("plaintext fits a frame")) + } +} + +static BACKEND: EspCrypto = EspCrypto; + +/// One DMA-AES pass over `input` (length a multiple of 16), copying the result into +/// `out`. +fn run(dma: &mut DmaState, state: &DmaCipherState, key: &[u8; 16], input: &[u8], out: &mut [u8]) { + let blocks = input.len() / 16; + let mut tx = dma.tx.take().unwrap(); + let mut rx = dma.rx.take().unwrap(); + let aes = dma.aes.take().unwrap(); + + tx.fill(input); + rx.set_length(input.len()); + + let Ok(transfer) = aes.process(blocks, rx, tx, Operation::Encrypt, state, *key) else { + panic!("AES DMA transfer setup failed"); + }; + let (aes, rx, tx) = transfer.wait(); + out.copy_from_slice(&rx.as_slice()[..input.len()]); + + dma.aes = Some(aes); + dma.rx = Some(rx); + dma.tx = Some(tx); +} + +fn round_up_16(n: usize) -> usize { + n.div_ceil(16) * 16 +} + +/// CCM* counter block `A_i`: flags = L-1 = 1, then the nonce, then the 2-byte counter. +fn ctr_block(nonce: &[u8; 13], counter: u16) -> [u8; 16] { + let mut block = [0u8; 16]; + block[0] = 1; + block[1..14].copy_from_slice(nonce); + block[14..16].copy_from_slice(&counter.to_be_bytes()); + block +} + +/// Builds the CBC-MAC input `B0 || AAD-blocks || payload-blocks` into `out` (which must +/// be zeroed, so the padding is implicit) and returns its length (a multiple of 16). +/// AAD is assumed to be shorter than `0xFF00`, which always holds for Zigbee frames. +fn format_cbc_mac_input(out: &mut [u8], nonce: &[u8; 13], aad: &[u8], payload: &[u8]) -> usize { + // B0: flags, nonce, message length. + let adata = u8::from(!aad.is_empty()); + out[0] = (adata << 6) | (1 << 3) | 1; // Adata | (M-2)/2=1 | L-1=1 + out[1..14].copy_from_slice(nonce); + out[14..16].copy_from_slice(&(payload.len() as u16).to_be_bytes()); + + let mut pos = 16; + if !aad.is_empty() { + out[pos..pos + 2].copy_from_slice(&(aad.len() as u16).to_be_bytes()); + out[pos + 2..pos + 2 + aad.len()].copy_from_slice(aad); + pos = round_up_16(pos + 2 + aad.len()); + } + out[pos..pos + payload.len()].copy_from_slice(payload); + round_up_16(pos + payload.len()) +} + +/// Claim the AES peripheral + a DMA channel and install the hardware crypto backend. Call +/// once during startup, before the stack processes frames. +pub fn init(aes: AES<'static>, dma: impl AesDmaChannel<'static>) { + let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = dma_buffers!(DMA_BUF_SIZE); + let rx = DmaRxBuf::new( + DmaAlignedMut::new(rx_descriptors).unwrap(), + DmaAlignedMut::new(rx_buffer).unwrap(), + ) + .unwrap(); + let tx = DmaTxBuf::new( + DmaAlignedMut::new(tx_descriptors).unwrap(), + DmaAlignedMut::new(tx_buffer).unwrap(), + ) + .unwrap(); + let aes_dma = Aes::new(aes).with_dma(dma); + + HW.lock(|cell| { + *cell.borrow_mut() = Some(DmaState { + aes: Some(aes_dma), + rx: Some(rx), + tx: Some(tx), + }); + }); + + crypto::install(&BACKEND); +} diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs new file mode 100644 index 0000000..b3cce7d --- /dev/null +++ b/crates/ziggurat-esp/src/main.rs @@ -0,0 +1,189 @@ +//! ESP32-C6 firmware: runs the Ziggurat stack against the native 802.15.4 radio and +//! exposes the same line-delimited JSON API as the host server's `--api stdio` mode, over +//! the built-in USB-Serial-JTAG. One request per inbound line; one JSON object per +//! outbound line. + +#![no_std] +#![no_main] + +extern crate alloc; + +mod api; +mod hw_crypto; + +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use embassy_executor::Spawner; +use embassy_futures::select::{Either, select}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use embassy_time::{Duration, Timer}; +use embedded_io_async::Read; +use embedded_io_async::Write; +use esp_alloc as _; +use esp_backtrace as _; +use esp_hal::Async; +use esp_hal::interrupt::software::SoftwareInterruptControl; +use esp_hal::rng::Rng; +use esp_hal::timer::timg::TimerGroup; +use esp_hal::uart::{Config as UartConfig, UartTx}; +use esp_hal::usb::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagRx, UsbSerialJtagTx}; + +use ziggurat_driver::rng; +use ziggurat_driver::runtime::EmbassySpawner; +use ziggurat_driver::zigbee_stack::ZigbeeStack; +use ziggurat_phy_esp::EspPhy; + +esp_bootloader_esp_idf::esp_app_desc!(); + +/// Outbound JSON lines (responses, events, notifications) converge here and a single +/// writer task drains them to the serial port. +const OUTBOUND_DEPTH: usize = 256; +pub static OUTBOUND: Channel = + Channel::new(); + +/// Complete inbound request lines, produced by `serial_reader_task` and consumed by the +/// processor loop in `main`. Decoupling the read from the (slower) handling keeps the +/// USB RX FIFO drained promptly: a burst of commands fills this queue instead of +/// stalling the FIFO. +const INBOUND_DEPTH: usize = 32; +static INBOUND: Channel, INBOUND_DEPTH> = Channel::new(); + +/// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` +/// signals it so the task exits and frees the radio. +pub type CaptureStop = embassy_sync::signal::Signal; + +/// The firmware's mutable state, owned by (and only touched from) the processor loop. +pub struct App { + pub phy: Arc, + pub spawner: EmbassySpawner, + pub stack: Option>>, + /// `Some` while a packet capture is streaming; signalling it stops the capture. + pub capture_stop: Option>, +} + +/// Drain the radio's received frames; the stack reads them off the shared RX channel. +#[embassy_executor::task] +async fn rx_task(phy: Arc) { + phy.run_rx().await +} + +/// How often the reader re-checks the USB RX FIFO when no byte has arrived. Bounds the +/// recovery latency from a dropped esp-hal RX wakeup (see `serial_reader_task`). +const RX_WATCHDOG: Duration = Duration::from_millis(50); + +/// Drains the USB-Serial-JTAG RX continuously, splitting on newlines and queueing each +/// complete line for the processor. +#[embassy_executor::task] +async fn serial_reader_task(mut rx: UsbSerialJtagRx<'static, Async>) { + let mut buf = [0u8; 256]; + let mut line: Vec = Vec::with_capacity(2048); + loop { + let n = match select(rx.read(&mut buf), Timer::after(RX_WATCHDOG)).await { + Either::First(result) => result.unwrap_or(0), + Either::Second(()) => continue, + }; + for &byte in &buf[..n] { + match byte { + b'\n' => { + if !line.is_empty() { + INBOUND.send(line.clone()).await; + line.clear(); + } + } + b'\r' => {} + _ => line.push(byte), + } + } + } +} + +/// How long the writer waits for the USB host to accept a line before giving up on it. +const WRITE_TIMEOUT: Duration = Duration::from_millis(500); + +#[embassy_executor::task] +async fn serial_writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { + let mut resync = false; + loop { + let line = OUTBOUND.receive().await; + let write = async { + if resync { + let _ = tx.write_all(b"\n").await; + } + let _ = tx.write_all(line.as_bytes()).await; + let _ = tx.write_all(b"\n").await; + let _ = tx.flush().await; + }; + match select(write, Timer::after(WRITE_TIMEOUT)).await { + Either::First(()) => resync = false, + Either::Second(()) => { + resync = true; + } + } + } +} + +#[esp_rtos::main] +async fn main(spawner: Spawner) -> ! { + let peripherals = + esp_hal::init(esp_hal::Config::default().with_cpu_clock(esp_hal::clock::CpuClock::max())); + + let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); + let timg0 = TimerGroup::new(peripherals.TIMG0); + esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); + + // ~100-router network peaks at ~86 KB heap; ceiling is ~408 KB. + esp_alloc::heap_allocator!(size: 320 * 1024); + + // Configure UART0 for debug logging + let _debug_uart = UartTx::new( + peripherals.UART0, + UartConfig::default().with_baudrate(115200), + ) + .expect("UART0 config") + .with_tx(peripherals.GPIO16) + .into_async(); + + // Route Zigbee crypto through the AES accelerator: CCM* runs as two DMA passes + // (CBC-MAC + CTR) and AES-MMO rides the single-block path. Must happen before the + // stack processes any frames. + hw_crypto::init(peripherals.AES, peripherals.DMA_CH0); + + // Install the randomness source the stack pulls jitter, addresses, and keys from. The + // SoC RNG is true-random once the radio subsystem is up (it is, below). + rng::install(Box::new(|buf: &mut [u8]| { + let rng = Rng::new(); + for chunk in buf.chunks_mut(4) { + let bytes = rng.random().to_le_bytes(); + let len = chunk.len(); + chunk.copy_from_slice(&bytes[..len]); + } + })); + + let usb = UsbSerialJtag::new(peripherals.USB_DEVICE).into_async(); + let (serial_rx, serial_tx) = usb.split(); + + let phy = Arc::new(EspPhy::new(peripherals.IEEE802154)); + + spawner.spawn(rx_task(phy.clone()).unwrap()); + spawner.spawn(serial_reader_task(serial_rx).unwrap()); + spawner.spawn(serial_writer_task(serial_tx).unwrap()); + + let mut app = App { + phy, + spawner: EmbassySpawner::new(spawner.make_send()), + stack: None, + capture_stop: None, + }; + + api::emit(api::hello_message(false)).await; + + // The processor loop. `serial_reader_task` owns the RX side and keeps the FIFO + // drained. + loop { + let line = INBOUND.receive().await; + api::handle_line(&mut app, &line).await; + } +} diff --git a/crates/ziggurat-ieee-802154/Cargo.toml b/crates/ziggurat-ieee-802154/Cargo.toml index 006038e..5c45793 100644 --- a/crates/ziggurat-ieee-802154/Cargo.toml +++ b/crates/ziggurat-ieee-802154/Cargo.toml @@ -9,10 +9,10 @@ authors.workspace = true repository.workspace = true [dependencies] -abstract-bits = "0.2.0" -num_enum = "0.7.3" -hex = "0.4.3" -thiserror = "2.0.12" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } +num_enum = { version = "0.7.3", default-features = false } +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } +thiserror = { version = "2.0.12", default-features = false } serde = { version = "1.0.219", default-features = false, features = ["alloc"] } educe = { version = "0.6.0", default-features = false, features = ["Debug"] } heapless = "0.9.3" diff --git a/crates/ziggurat-ieee-802154/src/lib.rs b/crates/ziggurat-ieee-802154/src/lib.rs index 07c14bf..ed7efec 100644 --- a/crates/ziggurat-ieee-802154/src/lib.rs +++ b/crates/ziggurat-ieee-802154/src/lib.rs @@ -1,6 +1,13 @@ +#![no_std] + +extern crate alloc; + pub mod commands; pub mod types; +use alloc::vec; +use alloc::vec::Vec; + use crate::types::{Eui64, Nwk, PanId, format_hex}; use abstract_bits::{AbstractBits, BitReader, abstract_bits}; use num_enum::TryFromPrimitive; @@ -14,7 +21,7 @@ pub const MAX_PHY_PACKET_SIZE: usize = 127; /// is ignored for now. pub type FrameBytes = heapless::Vec; -#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone)] +#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone, PartialOrd, Ord)] pub enum Ieee802154Address { Nwk(Nwk), Eui64(Eui64), @@ -222,7 +229,7 @@ pub enum ParseError { /// Append a structure's serialized form to `bytes`. /// -/// Avoids the intermediate Vec that [`AbstractBits::to_abstract_bits`] allocates; the +/// Avoids the intermediate Vec that [`AbstractBits::to_abstract_bytes`] allocates; the /// scratch buffer fits anything that can go on the air (a full 802.15.4 frame is at /// most 127 bytes). pub fn extend_abstract_bits(bytes: &mut Vec, value: &T) { @@ -276,19 +283,19 @@ impl Ieee802154CommandPayload { Some(match id { Ieee802154CommandId::AssociationRequest => { - Self::AssociationRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::AssociationRequest(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::AssociationResponse => { - Self::AssociationResponse(AbstractBits::from_abstract_bits(body).ok()?) + Self::AssociationResponse(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::DisassociationNotification => { - Self::DisassociationNotification(AbstractBits::from_abstract_bits(body).ok()?) + Self::DisassociationNotification(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::DataRequest => { - Self::DataRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::DataRequest(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::BeaconRequest => { - Self::BeaconRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::BeaconRequest(AbstractBits::from_abstract_bytes(body).ok()?) } // Known command ids the stack does not implement are kept verbatim _ => return None, @@ -300,18 +307,18 @@ impl Ieee802154CommandPayload { let (id, body) = match self { Self::AssociationRequest(c) => ( Ieee802154CommandId::AssociationRequest, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::AssociationResponse(c) => ( Ieee802154CommandId::AssociationResponse, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::DisassociationNotification(c) => ( Ieee802154CommandId::DisassociationNotification, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), - Self::DataRequest(c) => (Ieee802154CommandId::DataRequest, c.to_abstract_bits()), - Self::BeaconRequest(c) => (Ieee802154CommandId::BeaconRequest, c.to_abstract_bits()), + Self::DataRequest(c) => (Ieee802154CommandId::DataRequest, c.to_abstract_bytes()), + Self::BeaconRequest(c) => (Ieee802154CommandId::BeaconRequest, c.to_abstract_bytes()), Self::Unknown(raw) => return raw.clone(), }; @@ -429,7 +436,7 @@ impl Ieee802154Frame { }); } let superframe_specification = - SuperframeSpecification::from_abstract_bits(&remaining[..4])?; + SuperframeSpecification::from_abstract_bytes(&remaining[..4])?; let gts_specification = remaining[2]; let pending_address_specification = remaining[3]; let beacon_payload = remaining[4..].to_vec(); @@ -590,7 +597,7 @@ mod test { #[test] fn test_frame_control() { let bytes = [0x61, 0x88, 0xFF]; - let frame_control = Ieee802154FrameControl::from_abstract_bits(&bytes).unwrap(); + let frame_control = Ieee802154FrameControl::from_abstract_bytes(&bytes).unwrap(); let remaining = &bytes[2..]; assert_eq!(frame_control.frame_type, Ieee802154FrameType::Data); @@ -608,7 +615,7 @@ mod test { assert_eq!(frame_control.src_addr_mode, Ieee802154AddressingMode::Short); assert_eq!(remaining, [0xFF]); - assert_eq!(frame_control.to_abstract_bits().unwrap(), bytes[..2]); + assert_eq!(frame_control.to_abstract_bytes().unwrap(), bytes[..2]); } #[test] diff --git a/crates/ziggurat-ieee-802154/src/types.rs b/crates/ziggurat-ieee-802154/src/types.rs index 6c4685b..870b301 100644 --- a/crates/ziggurat-ieee-802154/src/types.rs +++ b/crates/ziggurat-ieee-802154/src/types.rs @@ -1,5 +1,7 @@ +use core::fmt; + +use alloc::string::String; use hex; -use std::fmt; use crate::ParseError; @@ -8,7 +10,13 @@ pub enum FromHexError { #[error("invalid length, expected {expected} hex characters, got {got}")] InvalidLength { expected: usize, got: usize }, #[error("invalid hex")] - InvalidHex(#[from] hex::FromHexError), + InvalidHex(hex::FromHexError), +} + +impl From for FromHexError { + fn from(err: hex::FromHexError) -> Self { + Self::InvalidHex(err) + } } fn decode_hex(text: &str) -> Result<[u8; N], FromHexError> { @@ -47,7 +55,7 @@ deserialize_via_try_from_hex!(PanId); deserialize_via_try_from_hex!(Key); #[abstract_bits::abstract_bits] -#[derive(Eq, Hash, Copy, Clone, PartialEq)] +#[derive(Eq, Hash, Copy, Clone, PartialEq, PartialOrd, Ord)] pub struct Nwk(pub u16); impl Nwk { @@ -85,7 +93,7 @@ impl fmt::Debug for Nwk { } #[abstract_bits::abstract_bits] -#[derive(Eq, PartialEq, Hash, Copy, Clone)] +#[derive(Eq, PartialEq, Hash, Copy, Clone, PartialOrd, Ord)] pub struct Eui64(pub [u8; 8]); impl Eui64 { @@ -141,7 +149,7 @@ pub enum Address { } #[abstract_bits::abstract_bits] -#[derive(Eq, Hash, Copy, Clone, PartialEq)] +#[derive(Eq, Hash, Copy, Clone, PartialEq, PartialOrd, Ord)] pub struct PanId(pub u16); impl PanId { diff --git a/crates/ziggurat-phy-esp/Cargo.lock b/crates/ziggurat-phy-esp/Cargo.lock new file mode 100644 index 0000000..d4d917d --- /dev/null +++ b/crates/ziggurat-phy-esp/Cargo.lock @@ -0,0 +1,1611 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abstract-bits" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "abstract-bits-derive", + "arbitrary-int", + "bitvec", + "thiserror", +] + +[[package]] +name = "abstract-bits-derive" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[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.118", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byte" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc" + +[[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 = "ccm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + +[[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.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[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.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "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-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[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-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", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[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-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 = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-alloc" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config", + "esp-sync", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72" +dependencies = [ + "bitfield", + "bitflags", + "bytemuck", + "cfg-if", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-sync 0.8.0", + "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", + "esp32", + "esp32c2", + "esp32c3", + "esp32c6", + "esp32h2", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "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.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" + +[[package]] +name = "esp-phy" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.8.0", + "esp-config", + "esp-hal", + "esp-metadata-generated", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", +] + +[[package]] +name = "esp-radio" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" +dependencies = [ + "allocator-api2", + "byte", + "cfg-if", + "docsplay", + "document-features", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "esp-alloc", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-phy", + "esp-radio-rtos-driver", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", + "heapless 0.9.3", + "ieee802154", + "instability", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" +dependencies = [ + "cfg-if", + "esp-sync", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", + "esp32c6", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" + +[[package]] +name = "esp32" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" +dependencies = [ + "vcell", +] + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ieee802154" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5" +dependencies = [ + "byte", + "ccm", + "cipher", + "hash32 0.2.1", + "hash32-derive", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.118", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[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 = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[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 = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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-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.118", +] + +[[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 = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[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.118", +] + +[[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.118", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[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 = "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.118", +] + +[[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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.118", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[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.118", +] + +[[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.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[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.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" +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.118", +] + +[[package]] +name = "ziggurat-ieee-802154" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "educe", + "heapless 0.9.3", + "hex", + "num_enum", + "serde", + "thiserror", +] + +[[package]] +name = "ziggurat-phy" +version = "0.1.0" +dependencies = [ + "thiserror", + "ziggurat-ieee-802154", +] + +[[package]] +name = "ziggurat-phy-esp" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-sync 0.8.0", + "esp-hal", + "esp-radio", + "ziggurat-ieee-802154", + "ziggurat-phy", +] diff --git a/crates/ziggurat-phy-esp/Cargo.toml b/crates/ziggurat-phy-esp/Cargo.toml new file mode 100644 index 0000000..0a826fb --- /dev/null +++ b/crates/ziggurat-phy-esp/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ziggurat-phy-esp" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +description = "esp-radio (ESP32-C6/H2) backend implementing the ziggurat-phy RadioPhy trait" +license = "Apache-2.0" + +[dependencies] +ziggurat-phy = { path = "../ziggurat-phy" } +ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } + +embassy-sync = "0.8" +embassy-futures = "0.1" +embassy-time = "0.5" +esp-radio = { version = "1.0.0-beta.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-hal = { version = "1.1.0", features = ["esp32c6", "unstable"] } diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs new file mode 100644 index 0000000..6824d09 --- /dev/null +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -0,0 +1,443 @@ +//! [`RadioPhy`] implemented over the ESP32-C6/H2 native 802.15.4 radio via esp-radio. +//! +//! esp-radio's driver is blocking + callback-driven and takes `&mut self`; this wraps it +//! in an embassy async mutex (so the trait's `&self` works) and turns its `fn()` TX/RX +//! callbacks into `Signal`s an async future can await. +//! +//! Scaffold status: structure is real (locking, signals, channels, software TX retry). +//! Gaps marked TODO: exact raw-frame field extraction, source-match (frame-pending) table, +//! and energy detect (esp-radio does not expose ED in its public API). + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use core::sync::atomic::{AtomicU32, Ordering}; +use core::time::Duration; + +use embassy_futures::select::{Either, select}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::{Channel, Receiver as ChannelReceiver}; +use embassy_sync::mutex::{Mutex, MutexGuard}; +use embassy_sync::signal::Signal; +use embassy_time::Timer; +use esp_hal::interrupt::{self, Priority}; +use esp_hal::peripherals::{IEEE802154, Interrupt}; +use esp_hal::system::Cpu; +use esp_radio::ieee802154::{Config, Ieee802154}; +use ziggurat_ieee_802154::types::{Eui64, Nwk}; +use ziggurat_phy::{ + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, ResetEvent, RxFrame, TxFrame, + TxResult, +}; + +const RX_DEPTH: usize = 16; + +// 802.15.4 unslotted CSMA-CA backoff, matching the OpenThread RCP's RAIL config +// (`RAIL_CSMA_CONFIG_802_15_4_2003_2p4_GHz_OQPSK_CSMA`): a 20-symbol (320 us) unit backoff +// period and a backoff exponent that grows from 3 to 5 across retries. esp-radio does only +// a single hardware CCA with no backoff, so the stack performs the backoff itself. +const CSMA_UNIT_BACKOFF_US: u64 = 320; +const CSMA_MIN_BE: u32 = 3; +const CSMA_MAX_BE: u32 = 5; + +/// xorshift32 mixed with the monotonic clock, for CSMA backoff jitter. Not cryptographic; +/// the clock mix just keeps independent senders (and reboots) from sharing a sequence and +/// colliding repeatedly on a busy channel. +fn next_csma_random() -> u32 { + static STATE: AtomicU32 = AtomicU32::new(0x9E37_79B9); + let mut x = STATE.load(Ordering::Relaxed); + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + STATE.store(if x == 0 { 0x9E37_79B9 } else { x }, Ordering::Relaxed); + x ^ (embassy_time::Instant::now().as_ticks() as u32) +} + +// There is exactly one IEEE802154 peripheral, so a single set of statics backs it. The +// esp-radio completion callbacks are plain `fn()` (no captures), so they must reach the +// async side through statics. +static RX_CHANNEL: Channel = Channel::new(); +static RESET_CHANNEL: Channel = Channel::new(); +static RX_AVAILABLE: Signal = Signal::new(); +static TX_DONE: Signal = Signal::new(); +static TX_FAILED: Signal = Signal::new(); + +/// Direct IEEE802154 register access. esp-radio exposes neither an energy-detect API nor a +/// coex-disable, so we reach the memory-mapped registers ourselves (offsets/values per +/// ESP-IDF's `components/ieee802154`). esp-radio owns the peripheral, but the register block +/// is at a fixed address; we serialize with everything else through the radio `state` lock. +mod regs { + const BASE: usize = 0x600A_3000; + pub const CMD: usize = BASE + 0x00; + pub const CHANNEL: usize = BASE + 0x48; + pub const ED_DURATION: usize = BASE + 0x50; + pub const ED_CFG: usize = BASE + 0x54; + pub const EVENT_STATUS: usize = BASE + 0x64; + pub const PTI: usize = BASE + 0x70; + + pub const CMD_RX_START: u32 = 0x42; + pub const CMD_ED_START: u32 = 0x44; + pub const CMD_STOP: u32 = 0x45; + pub const EVENT_ED_DONE: u32 = 1 << 6; + pub const ALL_EVENTS: u32 = 0x1FFF; + /// `ed_cfg.ed_sample_mode`: 0 = report the peak (max) sample, 1 = average. + pub const ED_SAMPLE_MODE: u32 = 1 << 13; + /// `pti.pti` (bits 0..3) and `pti.hw_ack_pti` (bits 4..7), both set to 1 - ESP-IDF's + /// `ieee802154_ll_disable_coex`: the radio always wins arbitration, so a non-existent + /// coex partner can't gate (and starve) RX/TX/ED. + pub const COEX_DISABLE: u32 = 0x11; + + pub unsafe fn read(addr: usize) -> u32 { + unsafe { core::ptr::read_volatile(addr as *const u32) } + } + pub unsafe fn write(addr: usize, value: u32) { + unsafe { core::ptr::write_volatile(addr as *mut u32, value) } + } +} + +fn on_rx_available() { + RX_AVAILABLE.signal(()); +} +fn on_tx_done() { + TX_DONE.signal(()); +} +fn on_tx_failed() { + TX_FAILED.signal(()); +} + +struct RadioState { + radio: Ieee802154<'static>, + config: Config, +} + +pub struct EspPhy { + state: Mutex, + exclusive: Mutex, +} + +impl EspPhy { + pub fn new(peripheral: IEEE802154<'static>) -> Self { + let mut radio = Ieee802154::new(peripheral); + radio.set_rx_available_callback_fn(on_rx_available); + radio.set_tx_done_callback_fn(on_tx_done); + radio.set_tx_failed_callback_fn(on_tx_failed); + + // esp-radio enables coex PTI at init but never disables it, and there is no coex + // partner running here. + unsafe { + let pti = regs::read(regs::PTI); + regs::write(regs::PTI, (pti & !0xFF) | regs::COEX_DISABLE); + } + Self { + state: Mutex::new(RadioState { + radio, + config: Config::default(), + }), + exclusive: Mutex::new(()), + } + } + + /// Drains received frames into the RX channel. The binary spawns this as a task; it + /// wakes on the rx-available callback rather than busy-polling. + pub async fn run_rx(&self) -> ! { + loop { + RX_AVAILABLE.wait().await; + let mut state = self.state.lock().await; + while let Some(raw) = state.radio.raw_received() { + if let Some(frame) = raw_to_rx_frame(&raw.data, raw.channel) { + let _ = RX_CHANNEL.try_send(frame); + } + } + } + } + + async fn transmit_inner(&self, frame: &TxFrame) -> Result { + let retries = frame.max_frame_retries; + let mut attempt = 0; + let mut csma_attempt = 0; + let ack_requested = frame.psdu.first().is_some_and(|fcf| fcf & 0x20 != 0); + + loop { + let result = { + let mut state = self.state.lock().await; + if let Some(channel) = frame.channel { + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + } + TX_DONE.reset(); + TX_FAILED.reset(); + state + .radio + .transmit_raw(&frame.psdu, frame.csma_ca) + .map_err(|e| RadioError::Other(String::from(esp_err(e))))?; + + // Hold the radio lock across the completion wait. + match select(TX_DONE.wait(), TX_FAILED.wait()).await { + Either::First(()) => { + if !ack_requested || state.radio.get_ack_frame().is_some() { + TxResult::Acked + } else { + TxResult::NoAck + } + } + Either::Second(()) => TxResult::ChannelAccessFailure, + } + }; + + match result { + TxResult::NoAck if attempt < retries => attempt += 1, + TxResult::ChannelAccessFailure + if frame.csma_ca && csma_attempt < frame.max_csma_backoffs => + { + // esp-radio reports a busy channel after a single CCA. Back off a random + // number of unit periods (exponent growing per retry, per 802.15.4 + // CSMA-CA) before re-attempting, instead of failing the send outright. + let be = (CSMA_MIN_BE + u32::from(csma_attempt)).min(CSMA_MAX_BE); + let slots = next_csma_random() & ((1 << be) - 1); + Timer::after(embassy_time::Duration::from_micros( + u64::from(slots) * CSMA_UNIT_BACKOFF_US, + )) + .await; + csma_attempt += 1; + } + other => return Ok(other), + } + } + } +} + +fn rssi_to_lqi(rssi: i8) -> u8 { + if rssi < -80 { + 0 + } else if rssi > -30 { + 255 + } else { + ((i32::from(rssi) + 80) as u32 * 255 / 50) as u8 + } +} + +fn raw_to_rx_frame(data: &[u8], channel: u8) -> Option { + let len = (data[0] & 0x7f) as usize; + if len < 2 || 1 + len > data.len() { + return None; + } + let psdu = &data[1..1 + len]; + + // TODO: the LQI doesn't exceed 11 and esp-idf also computes it from RSSI. Why? + let rssi = psdu[len - 2] as i8; + let lqi = rssi_to_lqi(rssi); + + Some(RxFrame { + psdu: psdu[..len - 2].to_vec(), + channel, + rssi, + lqi, + timestamp_us: 0, // TODO: esp-radio does not surface a per-frame timestamp + }) +} + +fn esp_config(config: &RadioConfig) -> Config { + Config { + channel: config.channel, + txpower: config.tx_power, + promiscuous: config.promiscuous, + rx_when_idle: config.rx_on_when_idle, + auto_ack_rx: true, + auto_ack_tx: true, + pan_id: Some(config.pan_id.0), + short_addr: Some(config.short_address.as_u16()), + ext_addr: Some(u64::from_le_bytes(config.extended_address.to_bytes())), + ..Config::default() + } +} + +const fn esp_err(_e: esp_radio::ieee802154::Error) -> &'static str { + "esp-radio transmit error" +} + +/// Force the receiver onto `channel`. esp-radio's `set_config` only updates the deferred +/// PIB (applied later by `pib_update` in rx/tx init), and `start_receive` no-ops while +/// already receiving - so a channel change otherwise never reaches a running receiver, which +/// keeps hearing the old channel. Write the frequency register directly (same mapping as +/// esp-radio's `channel_to_freq`), then stop + restart RX so the radio re-reads it. The RX +/// buffer a prior `start_receive` set up persists across this. +fn retune_rx(channel: u8) { + let freq = u32::from((channel - 11) * 5 + 3); + unsafe { + let chan = regs::read(regs::CHANNEL); + regs::write(regs::CHANNEL, (chan & !0x7F) | freq); + regs::write(regs::CMD, regs::CMD_STOP); + regs::write(regs::CMD, regs::CMD_RX_START); + } +} + +pub struct EspRx(ChannelReceiver<'static, CriticalSectionRawMutex, RxFrame, RX_DEPTH>); + +impl Receiver for EspRx { + async fn recv(&mut self) -> Option { + Some(self.0.receive().await) + } +} + +/// Reset notifications: the native radio never spontaneously resets, so the only events +/// are the ones [`EspPhy::reset`] synthesizes when the driver asks for a reset. +pub struct EspReset(ChannelReceiver<'static, CriticalSectionRawMutex, ResetEvent, 1>); + +impl Receiver for EspReset { + async fn recv(&mut self) -> Option { + Some(self.0.receive().await) + } +} + +pub struct EspExclusive<'a> { + phy: &'a EspPhy, + _guard: MutexGuard<'a, CriticalSectionRawMutex, ()>, +} + +impl ExclusiveRadio for EspExclusive<'_> { + async fn set_channel(&self, channel: u8) -> Result<(), RadioError> { + let mut state = self.phy.state.lock().await; + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + // start_receive sets up the RX buffer the first time, but esp-radio no-ops it while + // already receiving, so it won't retune a running receiver. Force the retune below. + state.radio.start_receive(); + retune_rx(channel); + Ok(()) + } + + async fn transmit(&self, frame: TxFrame) -> Result { + self.phy.transmit_inner(&frame).await + } +} + +impl RadioPhy for EspPhy { + type Exclusive<'a> = EspExclusive<'a>; + type RxStream = EspRx; + type ResetStream = EspReset; + + async fn reset(&self) -> Result<(), RadioError> { + // No external RCP to reset; reconfigure re-applies all state. The driver waits for + // a reset notification afterward, so synthesize one. + let _ = RESET_CHANNEL.try_send(ResetEvent { + reason: String::from("esp radio ready"), + }); + Ok(()) + } + + async fn reconfigure(&self, config: &RadioConfig) -> Result<(), RadioError> { + let mut state = self.state.lock().await; + let channel = config.channel; + state.config = esp_config(config); + + let esp = state.config; + state.radio.set_config(esp); + state.radio.start_receive(); + + retune_rx(channel); + + Ok(()) + } + + async fn set_frame_pending_table( + &self, + short: &[Nwk], + extended: &[Eui64], + ) -> Result<(), RadioError> { + let shorts: Vec = short.iter().map(|n| n.as_u16()).collect(); + let exts: Vec = extended.iter().map(|e| u64::from_le_bytes(e.to_bytes())).collect(); + let mut state = self.state.lock().await; + state.radio.set_source_match_table(&shorts, &exts); + Ok(()) + } + + async fn transmit(&self, frame: TxFrame) -> Result { + // Wait behind any exclusive holder (a scan) so this transmit can't retune the radio + // mid-scan. + let _exclusive = self.exclusive.lock().await; + self.transmit_inner(&frame).await + } + + async fn energy_detect(&self, channel: u8, duration: Duration) -> Result { + use regs::*; + + let mut state = self.state.lock().await; + let home = state.config.channel; + + // Tune to the target channel (esp-radio maps channel -> RF frequency). + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + + // The hardware measures over `duration`, in 16 us symbol periods, latched into a + // 24-bit field. + let symbols = ((duration.as_micros() / 16) as u32).min(0x00FF_FFFF); + + // ED_DONE must stay enabled in the event mask for the hardware to latch the + // completion, but esp-radio's ISR clears every event and has no ED handler, so it + // would consume the completion (and could restart RX mid-measurement) before we + // read it. Mask the MAC interrupt at the controller for the measurement instead: + // the event still latches, we poll it, and the ISR cannot run. + interrupt::disable(Cpu::current(), Interrupt::ZB_MAC); + + let rss = unsafe { + write(CMD, CMD_STOP); + write(EVENT_STATUS, ALL_EVENTS); // write-1-to-clear + // Peak (max) sampling: report the strongest energy seen over the dwell, so a + // mostly-idle channel with brief bursts still registers them (averaging would + // wash them out to the noise floor). The driver wants peak channel energy. + write(ED_CFG, read(ED_CFG) & !ED_SAMPLE_MODE); + write(ED_DURATION, symbols); + write(CMD, CMD_ED_START); + + // Wait out the dwell, then poll for the latched completion. + Timer::after(embassy_time::Duration::from_micros(u64::from(symbols) * 16)).await; + let mut remaining = 50; + while read(EVENT_STATUS) & EVENT_ED_DONE == 0 && remaining > 0 { + Timer::after(embassy_time::Duration::from_millis(1)).await; + remaining -= 1; + } + + let rss = ((read(ED_CFG) >> 16) & 0xFF) as i8; + write(EVENT_STATUS, ALL_EVENTS); + rss + }; + + interrupt::enable(Interrupt::ZB_MAC, Priority::Priority1); + + // Restore the home channel and resume receiving. + state.config.channel = home; + let config = state.config; + state.radio.set_config(config); + state.radio.start_receive(); + + Ok(rss) + } + + async fn lock(&self) -> EspExclusive<'_> { + EspExclusive { + phy: self, + _guard: self.exclusive.lock().await, + } + } + + fn subscribe_rx(&self) -> EspRx { + EspRx(RX_CHANNEL.receiver()) + } + + fn subscribe_reset(&self) -> EspReset { + EspReset(RESET_CHANNEL.receiver()) + } +} + +// Compile-time proof that EspPhy satisfies the full RadioPhy contract, including the +// `Send + Sync + 'static` supertrait and the `+ Send` bound on every returned future. +const _: () = { + fn assert_radiophy() {} + let _ = assert_radiophy::; +}; diff --git a/crates/ziggurat-phy-spinel/src/lib.rs b/crates/ziggurat-phy-spinel/src/lib.rs index aeeec86..481cbd8 100644 --- a/crates/ziggurat-phy-spinel/src/lib.rs +++ b/crates/ziggurat-phy-spinel/src/lib.rs @@ -9,12 +9,11 @@ use tokio::sync::mpsc; use tokio::time::timeout; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{ - ExclusiveRadio, RadioConfig, RadioError, RadioPhy, ResetEvent, RxFrame, TxFrame, TxPriority, + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, ResetEvent, RxFrame, TxFrame, TxResult, }; use ziggurat_spinel::client::{ ExclusiveRadio as SpinelRadioGuard, SpinelClient, SpinelError, SpinelRxFrame, SpinelTxFrame, - TxPriority as SpinelTxPriority, }; use ziggurat_spinel::{ SpinelFramePropValueIs, SpinelMacPromiscuousMode, SpinelMacScanState, SpinelPropertyId, @@ -26,12 +25,23 @@ const ENERGY_SCAN_RESULT_TIMEOUT: Duration = Duration::from_secs(2); pub struct SpinelPhy { client: Arc, home_channel: Mutex, - rx_sink: Sink, - reset_sink: Sink, + rx_slot: Slot, + reset_slot: Slot, energy_rx: AsyncMutex>, } -type Sink = Arc>>>; +/// The sender half of the currently-subscribed stream. `subscribe_*` swaps a fresh +/// channel in here; the forwarder tasks read it each time they have an item to deliver. +type Slot = Arc>>>; + +/// A subscribed stream, returned by `subscribe_*` and pulled by the driver. +pub struct TokioRx(mpsc::Receiver); + +impl Receiver for TokioRx { + async fn recv(&mut self) -> Option { + self.0.recv().await + } +} impl SpinelPhy { pub fn new(client: Arc) -> Self { @@ -44,16 +54,16 @@ impl SpinelPhy { client.set_reset_notification_receiver(reset_tx); client.spawn_reader(); - let rx_sink: Sink = Arc::new(Mutex::new(None)); - let reset_sink: Sink = Arc::new(Mutex::new(None)); - spawn_rx_forwarder(raw_rx, Arc::clone(&rx_sink)); - spawn_reset_forwarder(reset_rx, Arc::clone(&reset_sink)); + let rx_slot: Slot = Arc::new(Mutex::new(None)); + let reset_slot: Slot = Arc::new(Mutex::new(None)); + spawn_rx_forwarder(raw_rx, Arc::clone(&rx_slot)); + spawn_reset_forwarder(reset_rx, Arc::clone(&reset_slot)); Self { client, home_channel: Mutex::new(11), - rx_sink, - reset_sink, + rx_slot, + reset_slot, energy_rx: AsyncMutex::new(energy_rx), } } @@ -95,7 +105,7 @@ impl ExclusiveRadio for SpinelExclusive<'_> { } } -fn spawn_rx_forwarder(mut raw: mpsc::Receiver, sink: Sink) { +fn spawn_rx_forwarder(mut raw: mpsc::Receiver, slot: Slot) { tokio::spawn(async move { while let Some(update) = raw.recv().await { let Ok(frame) = SpinelRxFrame::from_bytes(&update.value) else { @@ -111,23 +121,23 @@ fn spawn_rx_forwarder(mut raw: mpsc::Receiver, sink: Sin lqi: frame.lqi, timestamp_us: frame.timestamp_us, }; - let current = sink.lock().clone(); - if let Some(current) = current { - let _ = current.send(rx).await; + let tx = slot.lock().clone(); + if let Some(tx) = tx { + let _ = tx.try_send(rx); } } }); } -fn spawn_reset_forwarder(mut reset: mpsc::Receiver, sink: Sink) { +fn spawn_reset_forwarder(mut reset: mpsc::Receiver, slot: Slot) { tokio::spawn(async move { while let Some(status) = reset.recv().await { let event = ResetEvent { reason: format!("{status:?}"), }; - let current = sink.lock().clone(); - if let Some(current) = current { - let _ = current.send(event).await; + let tx = slot.lock().clone(); + if let Some(tx) = tx { + let _ = tx.try_send(event); } } }); @@ -259,6 +269,8 @@ fn tx_frame_to_spinel(frame: TxFrame, channel: u8) -> SpinelTxFrame { impl RadioPhy for SpinelPhy { type Exclusive<'a> = SpinelExclusive<'a>; + type RxStream = TokioRx; + type ResetStream = TokioRx; async fn reset(&self) -> Result<(), RadioError> { self.client @@ -287,12 +299,12 @@ impl RadioPhy for SpinelPhy { write_frame_pending(&self.client, short, extended).await } - async fn transmit(&self, frame: TxFrame, priority: TxPriority) -> Result { + async fn transmit(&self, frame: TxFrame) -> Result { let home = *self.home_channel.lock(); let spinel_frame = tx_frame_to_spinel(frame, home); let status = self .client - .transmit_frame(&spinel_frame, SpinelTxPriority(priority.0)) + .transmit_frame(&spinel_frame) .await .map_err(map_err)?; Ok(map_status(status)) @@ -355,11 +367,15 @@ impl RadioPhy for SpinelPhy { Ok(max_rssi) } - fn set_rx_sink(&self, sink: mpsc::Sender) { - *self.rx_sink.lock() = Some(sink); + fn subscribe_rx(&self) -> TokioRx { + let (tx, rx) = mpsc::channel(32); + *self.rx_slot.lock() = Some(tx); + TokioRx(rx) } - fn set_reset_sink(&self, sink: mpsc::Sender) { - *self.reset_sink.lock() = Some(sink); + fn subscribe_reset(&self) -> TokioRx { + let (tx, rx) = mpsc::channel(8); + *self.reset_slot.lock() = Some(tx); + TokioRx(rx) } } diff --git a/crates/ziggurat-phy/Cargo.toml b/crates/ziggurat-phy/Cargo.toml index a0b6c13..230624d 100644 --- a/crates/ziggurat-phy/Cargo.toml +++ b/crates/ziggurat-phy/Cargo.toml @@ -11,5 +11,4 @@ repository.workspace = true [dependencies] ziggurat-ieee-802154.workspace = true -thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["sync"] } +thiserror = { version = "2.0.12", default-features = false } diff --git a/crates/ziggurat-phy/src/lib.rs b/crates/ziggurat-phy/src/lib.rs index 198780e..6a6139f 100644 --- a/crates/ziggurat-phy/src/lib.rs +++ b/crates/ziggurat-phy/src/lib.rs @@ -1,22 +1,20 @@ //! Radio PHY abstraction. -use std::future::Future; -use std::time::Duration; +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use core::future::Future; +use core::time::Duration; -use tokio::sync::mpsc; use ziggurat_ieee_802154::types::{Eui64, Nwk, PanId}; -/// Transmit scheduling priority. Higher transmits first when the radio is contended. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct TxPriority(pub i8); - -impl TxPriority { - pub const BACKGROUND: Self = Self(-2); - pub const USER_LOW: Self = Self(-1); - pub const USER_NORMAL: Self = Self(0); - pub const USER_HIGH: Self = Self(1); - pub const USER_CRITICAL: Self = Self(2); - pub const STACK_CRITICAL: Self = Self(3); +/// A pull-based stream of events the backend delivers spontaneously (received frames, +/// reset notifications). `recv` resolves to `None` once the backend has shut down. +pub trait Receiver: Send { + fn recv(&mut self) -> impl Future> + Send; } /// A frame to transmit. `psdu` is the serialized 802.15.4 frame; the backend supplies @@ -90,6 +88,12 @@ pub trait RadioPhy: Send + Sync + 'static { where Self: 'a; + /// The backend's received-frame stream, handed out by [`subscribe_rx`]. + type RxStream: Receiver; + + /// The backend's reset-notification stream, handed out by [`subscribe_reset`]. + type ResetStream: Receiver; + /// Reset the radio and wait for it to come back. Clears all configuration. fn reset(&self) -> impl Future> + Send; @@ -106,11 +110,8 @@ pub trait RadioPhy: Send + Sync + 'static { ) -> impl Future> + Send; /// Transmit a frame, blocking while the radio is held exclusively (see [`lock`]). - fn transmit( - &self, - frame: TxFrame, - priority: TxPriority, - ) -> impl Future> + Send; + fn transmit(&self, frame: TxFrame) + -> impl Future> + Send; /// Energy-detect one channel for `duration`, returning peak RSSI in dBm. Exclusive; /// returns to the home channel when done. @@ -123,11 +124,12 @@ pub trait RadioPhy: Send + Sync + 'static { /// Take exclusive control of the radio until the returned guard is dropped. fn lock(&self) -> impl Future> + Send; - /// Where received frames are delivered. - fn set_rx_sink(&self, sink: mpsc::Sender); + /// Open a fresh received-frame stream, redirecting delivery to it. Called once per + /// driver instance; a later call supersedes the previous stream. + fn subscribe_rx(&self) -> Self::RxStream; - /// Where spontaneous reset notifications are delivered. - fn set_reset_sink(&self, sink: mpsc::Sender); + /// Open a fresh reset-notification stream, redirecting delivery to it. + fn subscribe_reset(&self) -> Self::ResetStream; } /// Exclusive radio access, held via [`RadioPhy::lock`]. diff --git a/crates/ziggurat-server/Cargo.toml b/crates/ziggurat-server/Cargo.toml index ff7448f..28c03e4 100644 --- a/crates/ziggurat-server/Cargo.toml +++ b/crates/ziggurat-server/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] ziggurat-spinel.workspace = true +ziggurat-phy.workspace = true ziggurat-phy-spinel.workspace = true ziggurat-driver.workspace = true ziggurat-zigbee.workspace = true @@ -21,7 +22,7 @@ hex = "0.4.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" -tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] } +tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util", "io-std"] } tokio-serial = "5.4" tokio-tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index bf87390..aae87a7 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use serde_json::json; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, UnixListener}; use tokio::sync::{broadcast, mpsc}; use tokio::task::JoinHandle; @@ -15,12 +15,15 @@ use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; +use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, - TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ApsAckResult, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, + RequestId, SendResult, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, + ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; use ziggurat_phy_spinel::SpinelPhy; use ziggurat_spinel::client::SpinelClient; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; @@ -39,6 +42,22 @@ const NOTIFICATION_HUB_DEPTH: usize = 1024; /// The radio transmit power (in dBm) used when `configure` does not specify one. const DEFAULT_TX_POWER: i8 = 8; +/// Radio programming for promiscuous capture: receive every frame on `channel`, no PAN/ +/// address filtering, no network required (dummy addresses). +const fn capture_config(channel: u8) -> RadioConfig { + RadioConfig { + channel, + tx_power: DEFAULT_TX_POWER, + short_address: Nwk(0xFFFF), + extended_address: Eui64([0; 8]), + pan_id: PanId(0xFFFF), + promiscuous: true, + rx_on_when_idle: true, + frame_pending_short: Vec::new(), + frame_pending_extended: Vec::new(), + } +} + /// Big-endian colon-separated hex, the format used by zigpy for EUI64 addresses fn eui64_to_string(eui64: Eui64) -> String { let mut bytes = eui64.to_bytes(); @@ -226,6 +245,20 @@ struct SetNwkUpdateIdRequest { nwk_update_id: u8, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +enum ResetType { + /// Return to idle, leaving any configured network running. + Soft, + /// Reset the radio (RCP). + Hard, +} + +#[derive(Deserialize, Debug)] +struct ResetRequest { + reset_type: ResetType, +} + fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json::Value { match notification_event { ZigbeeNotification::ReceivedApsCommand { @@ -307,6 +340,35 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json "key_id": key_id, }), ), + ZigbeeNotification::SendConfirm { request_id, result } => notification( + "send_confirm", + match result { + SendResult::Confirmed { next_hop } => json!({ + "id": request_id, + "status": "confirmed", + "next_hop": next_hop.map(|nwk| format!("{:04x}", nwk.0)), + }), + SendResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), + ZigbeeNotification::ApsAckConfirm { request_id, result } => notification( + "aps_ack_confirm", + match result { + ApsAckResult::Acked => json!({ + "id": request_id, + "status": "confirmed", + }), + ApsAckResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), } } @@ -414,6 +476,69 @@ impl ZigguratServer { Ok(new_phy) } + /// The greeting sent to every client on connect, advertising the protocol version + /// and whether the stack is already configured. + fn hello_message(&self) -> serde_json::Value { + let state = if self.current_stack().is_some() { + "running" + } else { + "awaiting_configuration" + }; + json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state}) + } + + /// Fan hub notifications out to one connection's outbound queue until it closes. + fn spawn_notification_forwarder( + self: &Arc, + outbound: mpsc::Sender, + addr: String, + ) -> JoinHandle<()> { + let mut notification_rx = self.notification_tx.subscribe(); + tokio::spawn(async move { + loop { + match notification_rx.recv().await { + Ok(event) => { + if outbound.send(notification_to_message(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!("Client {addr} lagged {count} notifications"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }) + } + + /// Parse one inbound JSON request (a WebSocket text frame or a serial line) and + /// dispatch it. Returns `false` once the outbound queue is gone and the connection + /// should be torn down. + async fn handle_request_text( + self: &Arc, + text: &str, + addr: &str, + outbound: &mpsc::Sender, + ) -> bool { + let request = match serde_json::from_str::(text) { + Ok(request) => request, + Err(e) => { + tracing::warn!("Invalid request from {addr}: {e}"); + return outbound + .send(error_response(0, "invalid_request", e)) + .await + .is_ok(); + } + }; + + tracing::debug!("Request from {addr}: {request:?}"); + if outbound.send(event(request.id, "accepted")).await.is_err() { + return false; + } + self.dispatch(request, outbound.clone()); + true + } + async fn handle_connection( self: &Arc, socket: S, @@ -442,54 +567,16 @@ impl ZigguratServer { let _ = sink.close().await; }); - let state = if self.current_stack().is_some() { - "running" - } else { - "awaiting_configuration" - }; - outbound_tx - .send(json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state})) - .await?; - - // Forward hub notifications to this connection - let mut notification_rx = self.notification_tx.subscribe(); - let notification_outbound = outbound_tx.clone(); - let forwarder_addr = addr.to_owned(); - let notification_forwarder = tokio::spawn(async move { - loop { - match notification_rx.recv().await { - Ok(event) => { - let message = notification_to_message(event); - - if notification_outbound.send(message).await.is_err() { - break; - } - } - Err(broadcast::error::RecvError::Lagged(count)) => { - tracing::warn!("Client {forwarder_addr} lagged {count} notifications"); - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); + outbound_tx.send(self.hello_message()).await?; + let notification_forwarder = + self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); while let Some(message) = stream.next().await { match message { Ok(Message::Text(text)) => { - let request = match serde_json::from_str::(&text) { - Ok(request) => request, - Err(e) => { - tracing::warn!("Invalid request from {addr}: {e}"); - let _ = outbound_tx - .send(error_response(0, "invalid_request", e)) - .await; - continue; - } - }; - - tracing::debug!("Request from {addr}: {request:?}"); - outbound_tx.send(event(request.id, "accepted")).await?; - self.dispatch(request, outbound_tx.clone()); + if !self.handle_request_text(&text, addr, &outbound_tx).await { + break; + } } Ok(Message::Close(_)) => break, Ok(_) => {} // Pings and pongs are handled by tungstenite itself @@ -507,6 +594,63 @@ impl ZigguratServer { Ok(()) } + /// Serve the line-delimited JSON API over any byte stream (stdio, or a serial port + /// on the eventual embedded target). One request per inbound line; one JSON object + /// per outbound line. The dispatch and notification machinery is shared verbatim + /// with the WebSocket transport. + async fn handle_line_connection( + self: &Arc, + reader: R, + mut writer: W, + addr: &str, + ) -> std::io::Result<()> + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + tracing::info!("Client {addr} connected"); + + let (outbound_tx, mut outbound_rx) = + mpsc::channel::(OUTBOUND_QUEUE_DEPTH); + + let writer_task = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + let mut line = message.to_string(); + line.push('\n'); + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + let _ = writer.flush().await; + } + }); + + let _ = outbound_tx.send(self.hello_message()).await; + let notification_forwarder = + self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); + + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + if !self.handle_request_text(&line, addr, &outbound_tx).await { + break; + } + } + + notification_forwarder.abort(); + drop(outbound_tx); + let _ = writer_task.await; + + Ok(()) + } + + async fn run_stdio(self: Arc) -> std::io::Result<()> { + tracing::info!("Serving line-delimited JSON API on stdin/stdout"); + self.handle_line_connection(tokio::io::stdin(), tokio::io::stdout(), "stdio") + .await + } + /// Dispatches a request, spawning everything that can block on network activity: /// a command waiting on a slow device must never delay other commands. fn dispatch(self: &Arc, request: Request, outbound: mpsc::Sender) { @@ -522,16 +666,23 @@ impl ZigguratServer { let message = match method.as_str() { "ping" => server.handle_ping(id).await, + "reset" => server.handle_reset(id, params).await, "configure" => server.handle_configure(id, params).await, "get_hw_address" => server.handle_get_hw_address(id).await, "get_network_info" => server.handle_get_network_info(id), - "send_aps" => server.handle_send_aps(id, params, &outbound).await, + "send_aps" => server.handle_send_aps(id, params), "energy_scan" => server.handle_energy_scan(id, params, &outbound).await, "network_scan" => server.handle_network_scan(id, params, &outbound).await, "permit_joins" => server.handle_permit_joins(id, params), "set_provisional_key" => server.handle_set_provisional_key(id, params), "set_nwk_update_id" => server.handle_set_nwk_update_id(id, params), "set_channel" => server.handle_set_channel(id, params).await, + "packet_capture" => server.handle_packet_capture(id, params, &outbound).await, + "packet_capture_change_channel" => { + server + .handle_packet_capture_change_channel(id, params) + .await + } _ => error_response(id, "unknown_method", method), }; @@ -549,6 +700,28 @@ impl ZigguratServer { response(id, json!({"status": "pong"})) } + /// Soft or hard reset. A soft reset is a no-op success on the host (no transient + /// radio state outlives a connection here), kept for wire parity with the firmware. A + /// hard reset resets the radio (RCP); the stack's recovery task reprograms it. + async fn handle_reset(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: ResetRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + if matches!(request.reset_type, ResetType::Hard) { + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + if let Err(e) = phy.reset().await { + return error_response(id, "reset_failed", e); + } + } + + response(id, json!({"status": "success"})) + } + /// (Re)initializes the Zigbee stack. The stack deliberately outlives client /// connections; reconfiguring replaces it wholesale. #[allow(clippy::significant_drop_tightening)] @@ -589,7 +762,7 @@ impl ZigguratServer { Err(e) => return error_response(id, "serial_port_error", e), }; - let (stack, mut stack_notification_rx) = ZigbeeStack::new( + let stack = ZigbeeStack::new( phy, NetworkConfig { role: request.role.into(), @@ -608,6 +781,7 @@ impl ZigguratServer { source_routing: request.source_routing, }, Tunables::new(), + TokioSpawner::default(), ); // Restore unique trust center link keys negotiated in earlier sessions @@ -639,12 +813,17 @@ impl ZigguratServer { stack_clone.run().await; }); - // Pump the stack's notifications into the server-level hub + // Drain the stack's notification outbox into the server-level hub. The task is + // aborted when the stack is replaced (see `handle_configure`), so it doesn't + // need to observe a closed channel to stop. let hub_tx = self.notification_tx.clone(); + let notification_stack = stack.clone(); let forwarder = tokio::spawn(async move { - while let Ok(event) = stack_notification_rx.recv().await { - // Send errors just mean no client is connected right now - let _ = hub_tx.send(event); + loop { + for event in notification_stack.next_notifications().await { + // Send errors just mean no client is connected right now + let _ = hub_tx.send(event); + } } }); @@ -690,6 +869,70 @@ impl ZigguratServer { } } + /// Put the radio in promiscuous mode and stream every received frame as a + /// `captured_packet` event until the client disconnects. No network is required (it + /// reprograms the radio directly), so a running stack is disrupted for the session. + async fn handle_packet_capture( + &self, + id: u64, + params: serde_json::Value, + outbound: &mpsc::Sender, + ) -> serde_json::Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + if let Err(e) = phy.reconfigure(&capture_config(request.channel)).await { + return error_response(id, "packet_capture_failed", e); + } + + let mut rx = phy.subscribe_rx(); + while let Some(frame) = rx.recv().await { + let event = event_data( + id, + "captured_packet", + json!({ + "channel": frame.channel, + "rssi": frame.rssi, + "lqi": frame.lqi, + "data": hex::encode(frame.psdu), + }), + ); + if outbound.send(event).await.is_err() { + break; // client disconnected + } + } + + response(id, json!({"status": "complete"})) + } + + async fn handle_packet_capture_change_channel( + &self, + id: u64, + params: serde_json::Value, + ) -> serde_json::Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + match phy.reconfigure(&capture_config(request.channel)).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } + } + /// Reads back the running network's settings, the counterpart of `configure`. /// While the stack runs, the server is the authoritative holder of the live state /// (e.g. frame counters), not the client that configured it. @@ -749,12 +992,7 @@ impl ZigguratServer { } } - async fn handle_send_aps( - &self, - id: u64, - params: serde_json::Value, - outbound: &mpsc::Sender, - ) -> serde_json::Value { + fn handle_send_aps(&self, id: u64, params: serde_json::Value) -> serde_json::Value { let request: SendApsRequest = match serde_json::from_value(params) { Ok(request) => request, Err(e) => return error_response(id, "invalid_request", e), @@ -808,41 +1046,30 @@ impl ZigguratServer { None }; - let ack_waiter = match stack - .send_aps_command( - request.delivery_mode, - destination, - request.profile_id, - request.cluster_id, - request.src_ep, - request.dst_ep, - if request.aps_ack { - ApsAck::Request - } else { - ApsAck::None - }, - request.radius, - request.aps_seq, - asdu, - aps_security, - TxPriority(request.priority), - ) - .await - { - Ok(ack_waiter) => ack_waiter, - Err(e) => return error_response(id, "transmit_failed", e), - }; - - // The frame is on the air (or extracted from the indirect queue); the - // terminal response then reports end-to-end delivery when an ack was requested - let _ = outbound.send(event(id, "transmitted")).await; - - match ack_waiter { - None => response(id, json!({"status": "sent"})), - Some(waiter) => match stack.wait_aps_ack(waiter).await { - Ok(()) => response(id, json!({"status": "delivered"})), - Err(e) => error_response(id, "aps_ack_timeout", e), + // The stack either accepts the frame for transmission or rejects it now. The + // delivery outcome arrives later as a `send_confirm` notification keyed by + // this request id (the send token). + match stack.send_aps( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None }, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + id as RequestId, + ) { + Ok(()) => response(id, json!({"status": "accepted"})), + Err(e) => error_response(id, "transmit_failed", e), } } @@ -857,37 +1084,32 @@ impl ZigguratServer { Err(e) => return error_response(id, "invalid_request", e), }; - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); + // An energy detect is a radio operation, not a network one: it drives the radio + // directly and needs no configured stack. + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), }; - let (result_tx, mut result_rx) = mpsc::channel::<(u8, i8)>(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. + // An energy detect is self-contained per channel, so the manager owns the loop + // and streams each result as the channel completes. let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); - let scan = tokio::spawn(async move { - stack - .energy_scan(&request.channels, duration, result_tx) - .await - }); - - while let Some((channel, rssi)) = result_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "energy_result", - json!({"channel": channel, "rssi": rssi}), - )) - .await; + for channel in request.channels { + match phy.energy_detect(channel, duration).await { + Ok(rssi) => { + let _ = outbound + .send(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": rssi}), + )) + .await; + } + Err(e) => return error_response(id, "energy_scan_failed", e), + } } - match scan.await { - Ok(Ok(())) => response(id, json!({"status": "complete"})), - Ok(Err(e)) => error_response(id, "energy_scan_failed", e), - Err(e) => error_response(id, "energy_scan_failed", e), - } + response(id, json!({"status": "complete"})) } async fn handle_network_scan( @@ -905,26 +1127,34 @@ impl ZigguratServer { return error_response(id, "not_configured", "no stack is running"); }; - let (found_tx, mut found_rx) = mpsc::channel::(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. + // Open the collection window before spawning, so the drain loop below cannot race + // ahead of the scan starting. The scan runs on its own task so it always reaches + // its channel restore even if this request's task is dropped. + stack.begin_network_scan(); let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let scan_stack = stack.clone(); let scan = tokio::spawn(async move { - stack - .network_scan(&request.channels, duration, found_tx) + scan_stack + .run_network_scan(&request.channels, duration) .await }); - while let Some(beacon) = found_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "network_found", - network_beacon_json(&beacon), - )) - .await; + // `next_scan_beacons` delivers beacons as they arrive and returns empty once the + // window has closed and the queue is drained, which ends the loop. + loop { + let batch = stack.next_scan_beacons().await; + if batch.is_empty() { + break; + } + for beacon in batch { + let _ = outbound + .send(event_data( + id, + "network_found", + network_beacon_json(&beacon), + )) + .await; + } } match scan.await { @@ -989,12 +1219,25 @@ pub struct SerialConfig { flow_control: FlowControlMode, } +/// How the Zigbee API is exposed to clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ApiMode { + /// JSON-RPC over WebSocket on `--listen` + Ws, + /// Line-delimited JSON over stdin/stdout (logs go to stderr) + Stdio, +} + #[derive(Debug, Parser)] #[command( version, about = "Host-side Zigbee stack speaking Spinel to an 802.15.4 RCP" )] struct Args { + /// How to expose the Zigbee API to clients + #[arg(long, value_enum, default_value_t = ApiMode::Ws)] + api: ApiMode, + /// Serial device of the 802.15.4 RCP #[arg(long)] device: String, @@ -1024,9 +1267,21 @@ fn main() -> Result<(), Box> { rt.block_on(async { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(args.log_level.to_string())); - tracing_subscriber::registry() - .with(fmt::layer().with_filter(filter)) - .init(); + + // In stdio mode stdout carries the JSON API, so logs must not touch it + if args.api == ApiMode::Stdio { + tracing_subscriber::registry() + .with( + fmt::layer() + .with_writer(std::io::stderr) + .with_filter(filter), + ) + .init(); + } else { + tracing_subscriber::registry() + .with(fmt::layer().with_filter(filter)) + .init(); + } let server = Arc::new(ZigguratServer::new(SerialConfig { device: args.device, @@ -1034,7 +1289,10 @@ fn main() -> Result<(), Box> { flow_control: args.flow_control, })); - server.run(&args.listen).await?; + match args.api { + ApiMode::Ws => server.run(&args.listen).await?, + ApiMode::Stdio => server.run_stdio().await?, + } Ok(()) }) diff --git a/crates/ziggurat-spinel/src/client.rs b/crates/ziggurat-spinel/src/client.rs index 8cceb47..aad7689 100644 --- a/crates/ziggurat-spinel/src/client.rs +++ b/crates/ziggurat-spinel/src/client.rs @@ -9,7 +9,6 @@ use tokio_serial::SerialStream; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::Eui64; -use crate::priority_lock::PriorityLock; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, Ordering}; @@ -235,25 +234,6 @@ struct SpinelWriter { hdlc_scratch: Vec, } -/// Radio transmit priority -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct TxPriority(pub i8); - -impl TxPriority { - pub const BACKGROUND: Self = Self(-2); - pub const USER_LOW: Self = Self(-1); - pub const USER_NORMAL: Self = Self(0); - pub const USER_HIGH: Self = Self(1); - pub const USER_CRITICAL: Self = Self(2); - pub const STACK_CRITICAL: Self = Self(3); -} - -impl Default for TxPriority { - fn default() -> Self { - Self::USER_NORMAL - } -} - #[derive(Debug)] pub struct SpinelClient { /// The reader half of the port, owned by the task spawned in `spawn_reader`. @@ -262,12 +242,8 @@ pub struct SpinelClient { /// concurrent commands cannot interleave partial frames inside the byte stream. writer: AsyncMutex, pub protocol: Arc>, - /// Orders queued transmits among themselves; priority decides which goes first. - transmit_lock: PriorityLock, /// Functional ownership of the radio (scan, reset recovery, channel retune), taken via - /// [`Self::lock_radio`]. A transmit locks it only for its send; an exclusive op holds it - /// throughout. Because transmits queue on `transmit_lock` first, an exclusive op waits - /// out only the in-flight frame, not the whole backlog. + /// [`Self::lock_radio`]. exclusive_lock: AsyncMutex<()>, consecutive_timeouts: AtomicU32, } @@ -285,7 +261,6 @@ impl SpinelClient { hdlc_scratch: Vec::with_capacity(2 * SPINEL_FRAME_MAX_SIZE + 2), }), protocol: Arc::new(Mutex::new(SpinelProtocol::new())), - transmit_lock: PriorityLock::new(), exclusive_lock: AsyncMutex::new(()), consecutive_timeouts: AtomicU32::new(0), } @@ -552,14 +527,8 @@ impl SpinelClient { pub async fn transmit_frame( &self, tx_frame: &SpinelTxFrame, - priority: TxPriority, ) -> Result { - // Wait our turn among transmits, then the radio-ownership gate. This order keeps - // the backlog on `transmit_lock`, so an exclusive op only outwaits the in-flight - // frame. - let _transmit_lock = self.transmit_lock.acquire(priority).await; let _exclusive_lock = self.exclusive_lock.lock().await; - self.transmit_frame_inner(tx_frame).await } diff --git a/crates/ziggurat-spinel/src/lib.rs b/crates/ziggurat-spinel/src/lib.rs index d84a46a..246598d 100644 --- a/crates/ziggurat-spinel/src/lib.rs +++ b/crates/ziggurat-spinel/src/lib.rs @@ -1,5 +1,4 @@ pub mod client; -pub mod priority_lock; use crc_all::CrcAlgo; use num_enum::TryFromPrimitive; diff --git a/crates/ziggurat-spinel/src/priority_lock.rs b/crates/ziggurat-spinel/src/priority_lock.rs deleted file mode 100644 index dad60b8..0000000 --- a/crates/ziggurat-spinel/src/priority_lock.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! A priority-ordered async mutex. - -use std::cmp::Ordering; -use std::collections::BinaryHeap; -use std::sync::{Arc, Mutex}; -use tokio::sync::oneshot; - -struct Waiter { - priority: P, - seq: u64, - grant: oneshot::Sender>, -} - -impl Ord for Waiter

{ - fn cmp(&self, other: &Self) -> Ordering { - // Max-heap: higher priority first, then lower sequence (FIFO within a priority). - self.priority - .cmp(&other.priority) - .then_with(|| other.seq.cmp(&self.seq)) - } -} -impl PartialOrd for Waiter

{ - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} -impl PartialEq for Waiter

{ - fn eq(&self, other: &Self) -> bool { - self.cmp(other) == Ordering::Equal - } -} -impl Eq for Waiter

{} - -struct State { - held: bool, - next_seq: u64, - waiters: BinaryHeap>, -} - -struct Inner { - state: Mutex>, -} - -impl Inner

{ - /// Hand the lock to the highest-priority live waiter, if the lock is free. Caller holds - /// the state lock; this never blocks and never re-enters it. - fn grant_next(self: &Arc, state: &mut State

) { - if state.held { - return; - } - while let Some(waiter) = state.waiters.pop() { - let guard = PriorityGuard { - inner: Arc::clone(self), - armed: true, - }; - match waiter.grant.send(guard) { - Ok(()) => { - state.held = true; - return; - } - Err(mut orphan) => { - // The acquirer was cancelled before being granted. Disarm the returned - // guard so its Drop does not re-enter the lock we currently hold, and - // try the next waiter. `held` was never set. - orphan.armed = false; - } - } - } - } - - fn release(self: &Arc) { - let mut state = self.state.lock().unwrap(); - state.held = false; - self.grant_next(&mut state); - drop(state); - } -} - -pub struct PriorityLock { - inner: Arc>, -} - -impl PriorityLock

{ - pub fn new() -> Self { - Self { - inner: Arc::new(Inner { - state: Mutex::new(State { - held: false, - next_seq: 0, - waiters: BinaryHeap::new(), - }), - }), - } - } - - pub async fn acquire(&self, priority: P) -> PriorityGuard

{ - let rx = { - let mut state = self.inner.state.lock().unwrap(); - state.next_seq += 1; - let seq = state.next_seq; - let (grant, rx) = oneshot::channel(); - state.waiters.push(Waiter { - priority, - seq, - grant, - }); - self.inner.grant_next(&mut state); - drop(state); - rx - }; - - // Err is unreachable on the live path: a waiter's sender is dropped without sending - // only after `grant_next` disarmed it, which happens precisely because this receiver - // was already gone (the future cancelled), so this await never observes it. - rx.await.expect("priority lock granted to a live waiter") - } -} - -impl Default for PriorityLock

{ - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Debug for PriorityLock

{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let state = self.inner.state.lock().unwrap(); - f.debug_struct("PriorityLock") - .field("held", &state.held) - .field("waiting", &state.waiters.len()) - .finish() - } -} - -/// Held lock. Releasing happens on drop, which hands the lock to the next waiter. -pub struct PriorityGuard { - inner: Arc>, - armed: bool, -} - -impl Drop for PriorityGuard

{ - fn drop(&mut self) { - if self.armed { - self.inner.release(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn drains_in_priority_then_fifo_order() { - let lock: Arc> = Arc::new(PriorityLock::new()); - let held = lock.acquire(0).await; // block the lock so the rest queue - - let order = Arc::new(Mutex::new(Vec::new())); - let mut handles = Vec::new(); - for p in [1u8, 5, 3, 5] { - let lock = Arc::clone(&lock); - let order = Arc::clone(&order); - handles.push(tokio::spawn(async move { - let _g = lock.acquire(p).await; - order.lock().unwrap().push(p); - })); - tokio::task::yield_now().await; // deterministic enqueue order ⇒ stable seqs - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - drop(held); - for h in handles { - h.await.unwrap(); - } - // priority 5s first (FIFO between them), then 3, then 1 - assert_eq!(*order.lock().unwrap(), vec![5, 5, 3, 1]); - } -} diff --git a/crates/ziggurat-zigbee/Cargo.toml b/crates/ziggurat-zigbee/Cargo.toml index 089bb36..ec70d76 100644 --- a/crates/ziggurat-zigbee/Cargo.toml +++ b/crates/ziggurat-zigbee/Cargo.toml @@ -11,17 +11,18 @@ repository.workspace = true [dependencies] ziggurat-ieee-802154.workspace = true -abstract-bits = "0.2.0" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } aes = "0.9.1" arbitrary-int = "2.1.1" ccm = { version = "0.6.0-rc.3", default-features = false } educe = { version = "0.6.0", default-features = false, features = ["Debug"] } -hex = "0.4.3" -tracing = "0.1" -num_enum = "0.7.3" +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } +tracing = { version = "0.1", default-features = false } +num_enum = { version = "0.7.3", default-features = false } +once_cell = { version = "1", default-features = false, features = ["race", "alloc"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } -subtle = "2" -thiserror = "2.0.12" +subtle = { version = "2", default-features = false } +thiserror = { version = "2.0.12", default-features = false } [dev-dependencies] hex-literal = "1.1.0" diff --git a/crates/ziggurat-zigbee/src/aps/frame.rs b/crates/ziggurat-zigbee/src/aps/frame.rs index 17448a6..0fc7c35 100644 --- a/crates/ziggurat-zigbee/src/aps/frame.rs +++ b/crates/ziggurat-zigbee/src/aps/frame.rs @@ -1,4 +1,6 @@ use abstract_bits::{AbstractBits, abstract_bits}; +use alloc::vec; +use alloc::vec::Vec; use educe::Educe; use num_enum::TryFromPrimitive; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, format_hex}; @@ -90,7 +92,7 @@ impl ApsAckFrame { return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); } - let frame_control = ApsAckFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsAckFrameControl::from_abstract_bytes(bytes)?; let remaining = &bytes[1..]; if frame_control.frame_type != ApsFrameType::Ack { @@ -179,7 +181,7 @@ impl ApsDataFrame { return Err(ParseError::UnexpectedEnd { ty: "ApsDataFrame" }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let mut remaining = &bytes[1..]; // Spec 2.2.5: reserved fields SHALL be zero on reception; a nonzero value marks @@ -219,7 +221,7 @@ impl ApsDataFrame { // the ASDU. We don't encounter fragmentation in the wild but if we ever do, we // should not pass along a corrupted ASDU. if frame_control.extended_header { - let extended_frame_control = ApsExtendedFrameControl::from_abstract_bits(remaining)?; + let extended_frame_control = ApsExtendedFrameControl::from_abstract_bytes(remaining)?; // The extended frame control is one octet; a fragmented block is followed by // a one-octet block number (the block count on the first block). @@ -360,16 +362,16 @@ impl ApsTransportKeyCommandFrame { let key_descriptor = match standard_key_type { ApsStandardKeyType::StandardNetworkKey => ApsTransportKeyDescriptor::NetworkKey( - ApsNetworkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsNetworkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ), ApsStandardKeyType::ApplicationLinkKey => { ApsTransportKeyDescriptor::ApplicationLinkKey( - ApsApplicationLinkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsApplicationLinkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ) } ApsStandardKeyType::TrustCenterLinkKey => { ApsTransportKeyDescriptor::TrustCenterLinkKey( - ApsTrustCenterLinkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsTrustCenterLinkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ) } }; @@ -386,13 +388,13 @@ impl ApsTransportKeyCommandFrame { match &self.key_descriptor { ApsTransportKeyDescriptor::TrustCenterLinkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } ApsTransportKeyDescriptor::NetworkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } ApsTransportKeyDescriptor::ApplicationLinkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } } @@ -581,7 +583,7 @@ impl ApsCommandFrame { }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let remaining = &bytes[1..]; let counter = u8::from_le_bytes([remaining[0]]); @@ -597,25 +599,25 @@ impl ApsCommandFrame { ApsTransportKeyCommandFrame::from_bytes(payload)?, ), ApsCommandId::UpdateDevice => ApsCommandFrameCommand::UpdateDevice( - ApsUpdateDeviceCommandFrame::from_abstract_bits(payload)?, + ApsUpdateDeviceCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::RemoveDevice => ApsCommandFrameCommand::RemoveDevice( - ApsRemoveDeviceCommandFrame::from_abstract_bits(payload)?, + ApsRemoveDeviceCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::RequestKey => { ApsCommandFrameCommand::RequestKey(ApsRequestKeyCommandFrame::from_bytes(payload)?) } ApsCommandId::SwitchKey => ApsCommandFrameCommand::SwitchKey( - ApsSwitchKeyCommandFrame::from_abstract_bits(payload)?, + ApsSwitchKeyCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::Tunnel => { ApsCommandFrameCommand::Tunnel(ApsTunnelCommandFrame::from_bytes(payload)?) } ApsCommandId::VerifyKey => ApsCommandFrameCommand::VerifyKey( - ApsVerifyKeyCommandFrame::from_abstract_bits(payload)?, + ApsVerifyKeyCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::ConfirmKey => ApsCommandFrameCommand::ConfirmKey( - ApsConfirmKeyCommandFrame::from_abstract_bits(payload)?, + ApsConfirmKeyCommandFrame::from_abstract_bytes(payload)?, ), _ => { return Err(ParseError::Unsupported("command ID for ApsCommandFrame")); @@ -636,13 +638,13 @@ impl ApsCommandFrame { bytes.extend(match &self.command { ApsCommandFrameCommand::TransportKey(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::UpdateDevice(cmd) => cmd.to_abstract_bits().unwrap(), - ApsCommandFrameCommand::RemoveDevice(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::UpdateDevice(cmd) => cmd.to_abstract_bytes().unwrap(), + ApsCommandFrameCommand::RemoveDevice(cmd) => cmd.to_abstract_bytes().unwrap(), ApsCommandFrameCommand::RequestKey(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::SwitchKey(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::SwitchKey(cmd) => cmd.to_abstract_bytes().unwrap(), ApsCommandFrameCommand::Tunnel(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::VerifyKey(cmd) => cmd.to_abstract_bits().unwrap(), - ApsCommandFrameCommand::ConfirmKey(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::VerifyKey(cmd) => cmd.to_abstract_bytes().unwrap(), + ApsCommandFrameCommand::ConfirmKey(cmd) => cmd.to_abstract_bytes().unwrap(), }); bytes @@ -701,7 +703,7 @@ impl ApsAuxHeader { return Err(ParseError::UnexpectedEnd { ty: "ApsAuxHeader" }); } - let security_control = NwkSecurityHeaderControlField::from_abstract_bits(bytes)?; + let security_control = NwkSecurityHeaderControlField::from_abstract_bytes(bytes)?; let mut remaining = &bytes[1..]; let frame_counter = @@ -839,7 +841,7 @@ impl EncryptedApsCommandFrame { }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let counter = bytes[1]; let (aux_header, remaining) = ApsAuxHeader::deserialize(&bytes[2..])?; diff --git a/crates/ziggurat-zigbee/src/aps/security.rs b/crates/ziggurat-zigbee/src/aps/security.rs index eaca73b..f5a6ccd 100644 --- a/crates/ziggurat-zigbee/src/aps/security.rs +++ b/crates/ziggurat-zigbee/src/aps/security.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; +use alloc::vec; use serde::Deserialize; use subtle::ConstantTimeEq; @@ -62,6 +63,17 @@ pub struct DeviceLinkKey { pub attributes: KeyAttributes, } +/// Which key decrypted an inbound APS frame. Lets a reply be secured with the same +/// key-pair entry (spec 4.7.3.8 step 2) without copying the key material out of here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppliedKey { + Network, + /// The device's key-pair entry (`device_link_key`). + Device, + /// The well-known global link key. + Global, +} + /// What we track per peer device. The two have independent lifetimes (a seed-derived /// device has a counter but no stored key, and reissuing a key /// (`issue_device_key`) must not reset the counter) so each field is independently @@ -91,7 +103,7 @@ pub struct ApsSecurity { global_link_key: Key, local_eui64: Eui64, /// Per-device link keys and replay counters, keyed by peer EUI64 - devices: HashMap, + devices: BTreeMap, /// When set, unique link keys are derived from this seed instead of generated /// randomly, mirroring the stack the network was taken over from tclk_seed: Option, @@ -101,11 +113,15 @@ pub struct ApsSecurity { } impl ApsSecurity { - pub fn new(global_link_key: Key, local_eui64: Eui64, tclk_seed: Option) -> Self { + pub const fn new( + global_link_key: Key, + local_eui64: Eui64, + tclk_seed: Option, + ) -> Self { Self { global_link_key, local_eui64, - devices: HashMap::new(), + devices: BTreeMap::new(), tclk_seed, outgoing_frame_counter: 0, } @@ -306,6 +322,25 @@ impl ApsSecurity { command.encrypt(&key, &aux_header) } + /// Encrypt a command reply with the same key-pair entry that decrypted the request + /// (spec 4.7.3.8 step 2). `None` when `applied` is the network key, which can't secure + /// a command reply. + pub fn encrypt_command_with_applied_key( + &mut self, + source: Eui64, + applied: AppliedKey, + key_id: NwkSecurityHeaderKeyId, + command: &ApsCommandFrame, + ) -> Option { + let link_key = match applied { + AppliedKey::Network => return None, + AppliedKey::Device => self.device_link_key(source), + AppliedKey::Global => self.global_link_key.clone(), + }; + + Some(self.encrypt_command_with_link_key(&link_key, key_id, command)) + } + /// The key allowed to APS-encrypt outgoing data frames and ACKs for a device. /// Spec 4.4.1.1 step 1a: only provisional or verified `apsDeviceKeyPairSet` /// entries may encrypt; a key issued to a device but not yet verified may not. @@ -366,13 +401,16 @@ impl ApsSecurity { /// well-known key until their key exchange completes, so retried frames may still /// use it even when a unique key is on record). Frames secured with a unique link /// key are checked against the incoming frame counter to reject replays. + /// + /// Also returns which key it was secured under, so a reply can reuse the same + /// key-pair entry (spec 4.7.3.8 step 2). fn decrypt_frame( &mut self, source: Eui64, aux_header: &ApsAuxHeader, network_key: &Key, decrypt: impl Fn(&Key) -> Option, - ) -> Option { + ) -> Option<(T, AppliedKey)> { // Spec 4.4.1.2 step 1: the maximum frame counter value is never valid if aux_header.frame_counter == u32::MAX { return None; @@ -381,7 +419,7 @@ impl ApsSecurity { let key_id = aux_header.security_control.key_id; if key_id == NwkSecurityHeaderKeyId::NetworkKey { - return decrypt(network_key); + return decrypt(network_key).map(|frame| (frame, AppliedKey::Network)); } let mut candidate_keys = vec![self.device_link_key(source)]; @@ -393,9 +431,14 @@ impl ApsSecurity { let key = Self::select_key(link_key, key_id).expect("NetworkKey is handled above"); decrypt(&key).map(|frame| (link_key, frame)) })?; + let applied = if *link_key == self.global_link_key { + AppliedKey::Global + } else { + AppliedKey::Device + }; // Spec 4.4.1.2 steps 4 and 9: replay protection applies to unique link keys - if *link_key != self.global_link_key { + if applied == AppliedKey::Device { if let Some(minimum) = self .devices .get(&source) @@ -415,7 +458,7 @@ impl ApsSecurity { .incoming_frame_counter = Some(aux_header.frame_counter + 1); } - Some(frame) + Some((frame, applied)) } pub fn decrypt_command( @@ -423,7 +466,7 @@ impl ApsSecurity { source: Eui64, frame: &EncryptedApsCommandFrame, network_key: &Key, - ) -> Option { + ) -> Option<(ApsCommandFrame, AppliedKey)> { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key).ok() }) @@ -438,6 +481,7 @@ impl ApsSecurity { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key, source).ok() }) + .map(|(frame, _key)| frame) } pub fn decrypt_ack( @@ -449,5 +493,6 @@ impl ApsSecurity { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key, source).ok() }) + .map(|(frame, _key)| frame) } } diff --git a/crates/ziggurat-zigbee/src/beacon.rs b/crates/ziggurat-zigbee/src/beacon.rs index ec8b6b4..02e4ad9 100644 --- a/crates/ziggurat-zigbee/src/beacon.rs +++ b/crates/ziggurat-zigbee/src/beacon.rs @@ -70,7 +70,7 @@ mod test { }; // The Zigbee beacon payload is exactly 15 bytes - let bytes = beacon.to_abstract_bits().unwrap(); + let bytes = beacon.to_abstract_bytes().unwrap(); assert_eq!(bytes, hex!("00 22 84 93cb3c0b 01449f3a ffffff 00").to_vec()); let mut reader = BitReader::from(bytes.as_slice()); diff --git a/crates/ziggurat-zigbee/src/constants.rs b/crates/ziggurat-zigbee/src/constants.rs index aeb1488..ffaa96c 100644 --- a/crates/ziggurat-zigbee/src/constants.rs +++ b/crates/ziggurat-zigbee/src/constants.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use core::time::Duration; use ziggurat_ieee_802154::types::Key; @@ -82,6 +82,12 @@ pub struct Tunables { pub unicast_retry_delay: Duration, pub broadcast_delivery_time: Duration, + /// How many route discoveries a frame parked awaiting a route will trigger before it + /// is discarded. `1` (the default) means a single discovery: if it fails, every frame + /// waiting on that destination inherits the failure. Higher values keep the parked + /// frames waiting while discovery is retried, the whole bucket riding along together. + pub pending_route_discovery_attempts: u8, + /// The default timeout for any end device child that does not negotiate a /// different value via the End Device Timeout Request command (spec 3.6.10.2). pub end_device_timeout_default: EndDeviceTimeout, @@ -148,6 +154,7 @@ impl Tunables { unicast_retries: 3, unicast_retry_delay: Duration::from_millis(50), broadcast_delivery_time: Duration::from_millis(9000), + pending_route_discovery_attempts: 1, end_device_timeout_default: EndDeviceTimeout::Minutes256, parent_annce_base_timer: Duration::from_secs(10), parent_annce_jitter_max: Duration::from_secs(10), diff --git a/crates/ziggurat-zigbee/src/crypto.rs b/crates/ziggurat-zigbee/src/crypto.rs index d888366..917829d 100644 --- a/crates/ziggurat-zigbee/src/crypto.rs +++ b/crates/ziggurat-zigbee/src/crypto.rs @@ -1,15 +1,124 @@ use aes::Aes128; -use aes::Block; -use aes::cipher::BlockCipherEncrypt; -use aes::cipher::KeyInit; +use aes::cipher::array::Array; +use aes::cipher::consts::{U1, U16}; +use aes::cipher::{ + Block, BlockCipherEncBackend, BlockCipherEncClosure, BlockCipherEncrypt, BlockSizeUser, InOut, + KeyInit, KeySizeUser, ParBlocksSizeUser, +}; +use alloc::boxed::Box; +use alloc::vec::Vec; use ccm::Ccm; use ccm::aead::AeadInOut; use ccm::consts::{U4, U13}; +use once_cell::race::OnceBox; use thiserror::Error; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Key}; +static SOFTWARE_BACKEND: SoftwareBackend = SoftwareBackend; + +/// The installed backend, or unset for the software default. +static BACKEND: OnceBox<&'static dyn CryptoBackend> = OnceBox::new(); + +/// Install the platform crypto backend. Call once during startup, before any frames are +/// processed. The host leaves this unset and runs everything in software. +pub fn install(backend: &'static dyn CryptoBackend) { + let _ = BACKEND.set(Box::new(backend)); +} + +fn backend() -> &'static dyn CryptoBackend { + BACKEND.get().copied().unwrap_or(&SOFTWARE_BACKEND) +} + +/// A complete platform crypto backend. The host and tests use [`SoftwareBackend`]; MCU +/// targets [`install`] one backed by their hardware. +/// +/// Implementors must provide the AES-128 block primitive. CCM* defaults to a software +/// implementation built on that block, so a backend whose accelerator offers only block +/// modes (like the ESP32-C6, whose AES accelerator has no CCM mode) gets hardware-backed +/// CCM* for free; an SoC with a dedicated CCM* engine overrides the two methods. +pub trait CryptoBackend: Sync { + /// AES-128 ECB, one block, encrypted in place. + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]); + + /// CCM*-protect a payload in place: `auth_data` is authenticated, the buffer is + /// encrypted, and the encrypted MIC ("MAC tag") is appended to it. + fn encrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, + ) -> FrameBytes { + software_encrypt_ccm(key, nonce, auth_data, buffer) + } + + /// Reverse of [`encrypt_ccm`](CryptoBackend::encrypt_ccm): verify the MIC and + /// decrypt in place, returning the buffer truncated to the plaintext. + fn decrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, + ) -> Result { + software_decrypt_ccm(key, nonce, auth_data, tagged_ciphertext) + } +} + +/// Pure-software backend (RustCrypto `aes`/`ccm`), used until an MCU installs its own. +pub struct SoftwareBackend; + +impl CryptoBackend for SoftwareBackend { + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]) { + let cipher = Aes128::new(&(*key).into()); + let mut buffer: Array = (*block).into(); + cipher.encrypt_block(&mut buffer); + *block = buffer.into(); + } +} + +/// A RustCrypto block cipher that delegates each AES-128 block to the installed +/// [`CryptoBackend`], so the software `ccm` implementation transparently uses hardware +/// AES when a backend is installed. +#[derive(Clone)] +struct BackendAes { + key: [u8; 16], +} + +impl KeySizeUser for BackendAes { + type KeySize = U16; +} + +impl KeyInit for BackendAes { + fn new(key: &aes::cipher::Key) -> Self { + Self { key: (*key).into() } + } +} + +impl BlockSizeUser for BackendAes { + type BlockSize = U16; +} + +impl ParBlocksSizeUser for BackendAes { + type ParBlocksSize = U1; +} + +impl BlockCipherEncBackend for BackendAes { + fn encrypt_block(&self, mut block: InOut<'_, '_, Block>) { + let mut buffer: [u8; 16] = (*block.get_in()).into(); + backend().aes128_encrypt_block(&self.key, &mut buffer); + *block.get_out() = buffer.into(); + } +} + +impl BlockCipherEncrypt for BackendAes { + fn encrypt_with_backend(&self, f: impl BlockCipherEncClosure) { + f.call(self); + } +} + /// AES-MMO (Matyas-Meyer-Oseas) cryptographic hash, Zigbee spec B.1.3/B.4. Only the /// short-message padding scheme is implemented (inputs below 2^16 bits). pub fn aes_mmo_hash(data: &[u8]) -> [u8; 16] { @@ -22,14 +131,15 @@ pub fn aes_mmo_hash(data: &[u8]) -> [u8; 16] { } padded.extend(((data.len() * 8) as u16).to_be_bytes()); + let backend = backend(); let mut digest = [0u8; 16]; for chunk in padded.chunks_exact(16) { - let cipher = Aes128::new(&digest.into()); - let block = Block::try_from(chunk).expect("16-byte chunk is always valid"); - - let mut encrypted = Block::default(); - cipher.encrypt_block_b2b(&block, &mut encrypted); + // MMO: encrypt the message block under the running digest as the key, then XOR the + // ciphertext with the plaintext block. + let block: [u8; 16] = chunk.try_into().expect("16-byte chunk is always valid"); + let mut encrypted = block; + backend.aes128_encrypt_block(&digest, &mut encrypted); for (digest_byte, (encrypted_byte, block_byte)) in digest.iter_mut().zip(encrypted.iter().zip(block.iter())) @@ -84,7 +194,7 @@ pub fn verify_key_hash(link_key: &Key) -> [u8; 16] { /// in practice, so keys are issued with a shift of 0. pub fn zstack_tclk(seed: &Key, eui64: Eui64, shift: usize) -> Key { let eui64 = eui64.to_bytes(); - Key(std::array::from_fn(|i| { + Key(core::array::from_fn(|i| { seed.0[(i + shift) % 16] ^ eui64[i % 8] })) } @@ -97,8 +207,9 @@ pub fn ezsp_tclk(seed: &Key, eui64: Eui64) -> Key { /// Zigbee CCM* at security level 5 (spec annex A): AES-128 CCM with a 4-byte MIC and /// a 13-byte nonce. CCM* only differs from standard CCM at the unencrypted security -/// levels, which are never used on the air. -type ZigbeeCcm = Ccm; +/// levels, which are never used on the air. The underlying AES blocks route through the +/// installed [`CryptoBackend`], so this picks up hardware AES automatically. +type ZigbeeCcm = Ccm; pub const MIC_LENGTH: usize = 4; @@ -113,6 +224,15 @@ pub enum DecryptionError { /// CCM*-protect a payload in place: `auth_data` is authenticated, the buffer is /// encrypted, and the encrypted MIC ("MAC tag") is appended to it. pub fn encrypt_ccm( + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, +) -> FrameBytes { + backend().encrypt_ccm(key, nonce, auth_data, buffer) +} + +fn software_encrypt_ccm( key: &Key, nonce: &[u8; 13], auth_data: &[u8], @@ -130,6 +250,15 @@ pub fn encrypt_ccm( /// Reverse of [`encrypt_ccm`]: verify the MIC and decrypt in place, returning the /// buffer truncated to the plaintext. pub fn decrypt_ccm( + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, +) -> Result { + backend().decrypt_ccm(key, nonce, auth_data, tagged_ciphertext) +} + +fn software_decrypt_ccm( key: &Key, nonce: &[u8; 13], auth_data: &[u8], @@ -203,6 +332,34 @@ mod test { ); } + /// CCM* round-trips through the backend-delegating block cipher and rejects tampering. + #[test] + fn test_ccm_round_trip() { + let key = Key::from_hex("0011223344556677889900aabbccddee"); + let nonce = hex!("000102030405060708090a0b0c"); + let aad = hex!("aabbccdd"); + let plaintext = hex!("decafbad01020304"); + + let ciphertext = encrypt_ccm( + &key, + &nonce, + &aad, + FrameBytes::from_slice(&plaintext).unwrap(), + ); + assert_eq!(ciphertext.len(), plaintext.len() + MIC_LENGTH); + assert_ne!(&ciphertext.as_slice()[..plaintext.len()], &plaintext[..]); + + let decrypted = decrypt_ccm(&key, &nonce, &aad, ciphertext.clone()).unwrap(); + assert_eq!(decrypted.as_slice(), &plaintext[..]); + + let mut tampered = ciphertext; + tampered.as_mut_slice()[0] ^= 0xff; + assert_eq!( + decrypt_ccm(&key, &nonce, &aad, tampered), + Err(DecryptionError::InvalidMacTag) + ); + } + /// Cross-validated against zigpy's `aes_mmo_hash` #[test] fn test_ezsp_tclk() { diff --git a/crates/ziggurat-zigbee/src/indirect.rs b/crates/ziggurat-zigbee/src/indirect.rs index 6f40cd1..58a4f64 100644 --- a/crates/ziggurat-zigbee/src/indirect.rs +++ b/crates/ziggurat-zigbee/src/indirect.rs @@ -1,21 +1,28 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::time::{Duration, Instant}; +use alloc::vec::Vec; +use core::time::Duration; -use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; +use alloc::collections::VecDeque; +use alloc::collections::{BTreeMap, BTreeSet}; + +use crate::Instant; -use crate::nwk::frame::EncryptedNwkFrame; +use ziggurat_ieee_802154::Ieee802154Address; +use ziggurat_ieee_802154::types::{Eui64, Nwk}; -/// A finished 802.15.4 frame awaiting indirect delivery (802.15.4 spec 6.7.3). +/// A frame awaiting indirect delivery (802.15.4 spec 6.7.3). /// /// The destination extracts it by polling with a MAC Data Request; the radio's /// automatic ACK of that poll has its frame pending bit set (via the source address /// match table), telling the device to keep listening. +/// +/// The payload type `F` is opaque to the queue: the driver chooses whether to store a +/// finished frame or one still to be encrypted at delivery time (so a frame can wait +/// here for seconds without holding a frame counter that would be stale once sent). #[derive(Debug)] -pub struct Transaction { +pub struct Transaction { /// The frame as queued; the frame pending bit is applied to a copy at delivery /// time, based on whether more transactions remain. - pub frame: Ieee802154Frame, + pub frame: F, pub expires_at: Instant, /// The driver's completion token, resolved on delivery, expiry, or drop. pub completion: C, @@ -23,10 +30,10 @@ pub struct Transaction { /// A transaction extracted by a poll, ready for transmission. #[derive(Debug)] -pub struct Delivery { +pub struct Delivery { /// The queue key the transaction was extracted from pub destination: Ieee802154Address, - pub transaction: Transaction, + pub transaction: Transaction, /// Further transactions remain queued: the delivered frame's pending bit is set /// so the device keeps polling (802.15.4 spec 6.7.3) pub more_pending: bool, @@ -36,19 +43,19 @@ pub struct Delivery { #[derive(Debug)] #[must_use = "the expired transactions must be failed and the delivery transmitted; \ dropping the outcome silently loses queued frames and leaks their completions"] -pub struct PollOutcome { +pub struct PollOutcome { /// Transactions that expired at the head of the queue, to be failed - pub expired: Vec<(Ieee802154Address, Transaction)>, + pub expired: Vec<(Ieee802154Address, Transaction)>, /// The oldest live transaction, if any - pub delivery: Option>, + pub delivery: Option>, } /// The source address match table contents most recently written to the RCP, used to /// tell whether the auto-ACK of a given poll advertised frame-pending=1. #[derive(Debug, Default)] pub struct SrcMatchTable { - pub short_addresses: HashSet, - pub extended_addresses: HashSet, + pub short_addresses: BTreeSet, + pub extended_addresses: BTreeSet, } impl SrcMatchTable { @@ -66,27 +73,21 @@ impl SrcMatchTable { /// was queued under; a poll is matched against both its extended and short source /// address. #[derive(Debug)] -pub struct IndirectQueue { +pub struct IndirectQueue { /// How long a transaction awaits a poll before expiring persistence_time: Duration, - queue: HashMap>>, + queue: BTreeMap>>, } -impl IndirectQueue { - pub fn new(persistence_time: Duration) -> Self { +impl IndirectQueue { + pub const fn new(persistence_time: Duration) -> Self { Self { persistence_time, - queue: HashMap::new(), + queue: BTreeMap::new(), } } - pub fn push( - &mut self, - destination: Ieee802154Address, - frame: Ieee802154Frame, - completion: C, - now: Instant, - ) { + pub fn push(&mut self, destination: Ieee802154Address, frame: F, completion: C, now: Instant) { self.queue .entry(destination) .or_default() @@ -100,7 +101,7 @@ impl IndirectQueue { /// 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, so a /// failed transmit goes back to the head of the queue for the next poll, keeping /// its original deadline. - pub fn requeue(&mut self, destination: Ieee802154Address, transaction: Transaction) { + pub fn requeue(&mut self, destination: Ieee802154Address, transaction: Transaction) { self.queue .entry(destination) .or_default() @@ -119,7 +120,7 @@ impl IndirectQueue { eui64: Option, nwk: Option, now: Instant, - ) -> PollOutcome { + ) -> PollOutcome { let keys = eui64 .map(Ieee802154Address::Eui64) .into_iter() @@ -179,7 +180,7 @@ impl IndirectQueue { &mut self, eui64: Option, nwk: Nwk, - ) -> Vec<(Ieee802154Address, Transaction)> { + ) -> Vec<(Ieee802154Address, Transaction)> { let keys = eui64 .map(Ieee802154Address::Eui64) .into_iter() @@ -205,7 +206,7 @@ impl IndirectQueue { /// uniform persistence time and requeued transmit failures keep their deadline. #[must_use = "expired transactions must be failed (their completions resolved); \ dropping them leaves their awaiters hanging"] - pub fn expire(&mut self, now: Instant) -> Vec<(Ieee802154Address, Transaction)> { + pub fn expire(&mut self, now: Instant) -> Vec<(Ieee802154Address, Transaction)> { let mut expired = Vec::new(); self.queue.retain(|&destination, transactions| { @@ -234,7 +235,7 @@ impl IndirectQueue { /// The source address match table the RCP should hold: every device with queued /// transactions, under both its address forms (the device may poll with either). - pub fn queued_addresses(&self, address_map: &HashMap) -> SrcMatchTable { + pub fn queued_addresses(&self, address_map: &BTreeMap) -> SrcMatchTable { let mut table = SrcMatchTable::default(); for key in self.queue.keys() { diff --git a/crates/ziggurat-zigbee/src/lib.rs b/crates/ziggurat-zigbee/src/lib.rs index 436e425..255bc2e 100644 --- a/crates/ziggurat-zigbee/src/lib.rs +++ b/crates/ziggurat-zigbee/src/lib.rs @@ -1,11 +1,18 @@ +#![no_std] + +extern crate alloc; + pub mod aps; pub mod beacon; pub mod constants; pub mod crypto; pub mod indirect; pub mod nwk; +pub mod time; pub mod zdp; +pub use time::Instant; + /// Failure to parse an NWK or APS frame (or one of its fields) off the wire. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ParseError { diff --git a/crates/ziggurat-zigbee/src/nwk/addresses.rs b/crates/ziggurat-zigbee/src/nwk/addresses.rs index 14f15be..1f93691 100644 --- a/crates/ziggurat-zigbee/src/nwk/addresses.rs +++ b/crates/ziggurat-zigbee/src/nwk/addresses.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; use ziggurat_ieee_802154::types::{Eui64, Nwk}; @@ -9,7 +9,7 @@ use crate::nwk::neighbors::Neighbors; #[derive(Debug)] pub struct AddressMap { own_address: Nwk, - map: HashMap, + map: BTreeMap, } impl AddressMap { @@ -18,7 +18,7 @@ impl AddressMap { pub fn new(own_address: Nwk, own_eui64: Eui64) -> Self { Self { own_address, - map: HashMap::from([(own_eui64, own_address)]), + map: BTreeMap::from([(own_eui64, own_address)]), } } @@ -125,7 +125,7 @@ impl AddressMap { /// The raw mapping, e.g. for completing indirect queue keys with the device's /// other address form. - pub const fn map(&self) -> &HashMap { + pub const fn map(&self) -> &BTreeMap { &self.map } } diff --git a/crates/ziggurat-zigbee/src/nwk/broadcasts.rs b/crates/ziggurat-zigbee/src/nwk/broadcasts.rs index 71f2481..c33a06a 100644 --- a/crates/ziggurat-zigbee/src/nwk/broadcasts.rs +++ b/crates/ziggurat-zigbee/src/nwk/broadcasts.rs @@ -1,5 +1,9 @@ -use std::collections::{HashMap, HashSet}; -use std::time::{Duration, Instant}; +use alloc::vec::Vec; +use core::time::Duration; + +use alloc::collections::{BTreeMap, BTreeSet}; + +use crate::Instant; use ziggurat_ieee_802154::types::Nwk; @@ -14,7 +18,7 @@ struct Transaction { expected_relayers: Vec, /// Neighbors heard relaying this broadcast: their passive acknowledgments /// (spec 3.6.6) - heard_from: HashSet, + heard_from: BTreeSet, } /// The NWK broadcast transaction table: deduplication of received broadcasts and @@ -26,15 +30,15 @@ pub struct Broadcasts { /// A broadcast with at least this many expected relayers is considered passively /// acknowledged once this many of them have been heard, instead of all of them quorum: usize, - table: HashMap<(Nwk, u8), Transaction>, + table: BTreeMap<(Nwk, u8), Transaction>, } impl Broadcasts { - pub fn new(delivery_time: Duration, quorum: usize) -> Self { + pub const fn new(delivery_time: Duration, quorum: usize) -> Self { Self { delivery_time, quorum, - table: HashMap::new(), + table: BTreeMap::new(), } } @@ -65,7 +69,7 @@ impl Broadcasts { expiration_time: now + self.delivery_time, expected_relayers: audience, // Whoever delivered the frame to us has already broadcast it - heard_from: HashSet::from([sender]), + heard_from: BTreeSet::from([sender]), }, ); @@ -86,7 +90,7 @@ impl Broadcasts { Transaction { expiration_time: now + self.delivery_time, expected_relayers: audience, - heard_from: HashSet::new(), + heard_from: BTreeSet::new(), }, ); } diff --git a/crates/ziggurat-zigbee/src/nwk/commands.rs b/crates/ziggurat-zigbee/src/nwk/commands.rs index 5cec046..1222ea6 100644 --- a/crates/ziggurat-zigbee/src/nwk/commands.rs +++ b/crates/ziggurat-zigbee/src/nwk/commands.rs @@ -1,4 +1,6 @@ #![allow(clippy::useless_conversion)] +use alloc::vec; +use alloc::vec::Vec; use abstract_bits::{AbstractBits, abstract_bits}; use num_enum::TryFromPrimitive; @@ -44,12 +46,12 @@ pub enum NwkRouteRequestManyToOne { pub struct NwkRouteRequestCommand { reserved: u3, pub many_to_one: NwkRouteRequestManyToOne, - #[abstract_bits(presence_of = destination_eui64)] - reserved: bool, + has_destination_eui64: bool, reserved: u2, pub route_request_identifier: u8, pub destination_address: Nwk, pub path_cost: u8, + #[abstract_bits(presence_from = has_destination_eui64)] pub destination_eui64: Option, } @@ -58,16 +60,16 @@ pub struct NwkRouteRequestCommand { #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkRouteReplyCommand { reserved: u4, - #[abstract_bits(presence_of = originator_eui64)] - reserved: bool, - #[abstract_bits(presence_of = responder_eui64)] - reserved: bool, + has_originator_eui64: bool, + has_responder_eui64: bool, reserved: u2, pub route_request_identifier: u8, pub originator_nwk: Nwk, pub responder_nwk: Nwk, pub path_cost: u8, + #[abstract_bits(presence_from = has_originator_eui64)] pub originator_eui64: Option, + #[abstract_bits(presence_from = has_responder_eui64)] pub responder_eui64: Option, } @@ -149,8 +151,8 @@ pub struct NwkNetworkStatusCommand { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkRouteRecordCommand { - #[abstract_bits(length_of = relays)] - reserved: u8, + relay_count: u8, + #[abstract_bits(length_from = relay_count)] pub relays: Vec, } @@ -232,11 +234,11 @@ pub struct NwkRejoinResponseCommand { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkLinkStatusCommand { - #[abstract_bits(length_of = link_statuses)] - reserved: u5, + link_statuses_len: u5, pub is_first_frame: bool, pub is_last_frame: bool, reserved: u1, + #[abstract_bits(length_from = link_statuses_len)] pub link_statuses: Vec, } @@ -305,13 +307,13 @@ pub enum NwkReportCommandIdentifier { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkNetworkReportCommand { - #[abstract_bits(length_of = pan_ids)] report_information_count: u5, pub report_command_identifier: NwkReportCommandIdentifier, pub epid: Eui64, /// A list of 16-bit PAN identifiers that are in conflict. This field's format is /// determined by the `report_command_identifier` but the only defined type is /// `PanIdentifierConflict`. + #[abstract_bits(length_from = report_information_count)] pub pan_ids: Vec, } @@ -392,8 +394,8 @@ pub struct NwkPowerListEntry { pub struct NwkLinkPowerDeltaCommand { pub command_type: NwkLinkPowerDeltaType, reserved: u6, - #[abstract_bits(length_of = power_list)] list_count: u8, + #[abstract_bits(length_from = list_count)] pub power_list: Vec, } @@ -524,30 +526,31 @@ impl NwkCommand { /// followed by the command body. pub fn to_bytes(&self) -> Vec { let (id, body) = match self { - Self::RouteRequest(c) => (NwkCommandId::RouteRequest, c.to_abstract_bits()), - Self::RouteReply(c) => (NwkCommandId::RouteReply, c.to_abstract_bits()), - Self::NetworkStatus(c) => (NwkCommandId::NetworkStatus, c.to_abstract_bits()), - Self::Leave(c) => (NwkCommandId::Leave, c.to_abstract_bits()), - Self::RouteRecord(c) => (NwkCommandId::RouteRecord, c.to_abstract_bits()), - Self::RejoinRequest(c) => (NwkCommandId::RejoinRequest, c.to_abstract_bits()), - Self::RejoinResponse(c) => (NwkCommandId::RejoinResponse, c.to_abstract_bits()), - Self::LinkStatus(c) => (NwkCommandId::LinkStatus, c.to_abstract_bits()), - Self::NetworkReport(c) => (NwkCommandId::NetworkReport, c.to_abstract_bits()), - Self::NetworkUpdate(c) => (NwkCommandId::NetworkUpdate, c.to_abstract_bits()), + Self::RouteRequest(c) => (NwkCommandId::RouteRequest, c.to_abstract_bytes()), + Self::RouteReply(c) => (NwkCommandId::RouteReply, c.to_abstract_bytes()), + Self::NetworkStatus(c) => (NwkCommandId::NetworkStatus, c.to_abstract_bytes()), + Self::Leave(c) => (NwkCommandId::Leave, c.to_abstract_bytes()), + Self::RouteRecord(c) => (NwkCommandId::RouteRecord, c.to_abstract_bytes()), + Self::RejoinRequest(c) => (NwkCommandId::RejoinRequest, c.to_abstract_bytes()), + Self::RejoinResponse(c) => (NwkCommandId::RejoinResponse, c.to_abstract_bytes()), + Self::LinkStatus(c) => (NwkCommandId::LinkStatus, c.to_abstract_bytes()), + Self::NetworkReport(c) => (NwkCommandId::NetworkReport, c.to_abstract_bytes()), + Self::NetworkUpdate(c) => (NwkCommandId::NetworkUpdate, c.to_abstract_bytes()), Self::EndDeviceTimeoutRequest(c) => { - (NwkCommandId::EndDeviceTimeoutRequest, c.to_abstract_bits()) - } - Self::EndDeviceTimeoutResponse(c) => { - (NwkCommandId::EndDeviceTimeoutResponse, c.to_abstract_bits()) + (NwkCommandId::EndDeviceTimeoutRequest, c.to_abstract_bytes()) } - Self::LinkPowerDelta(c) => (NwkCommandId::LinkPowerDelta, c.to_abstract_bits()), + Self::EndDeviceTimeoutResponse(c) => ( + NwkCommandId::EndDeviceTimeoutResponse, + c.to_abstract_bytes(), + ), + Self::LinkPowerDelta(c) => (NwkCommandId::LinkPowerDelta, c.to_abstract_bytes()), Self::NetworkCommissioningRequest(c) => ( NwkCommandId::NetworkCommissioningRequest, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::NetworkCommissioningResponse(c) => ( NwkCommandId::NetworkCommissioningResponse, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::Unparsed(raw) => return raw.to_vec(), }; @@ -559,7 +562,7 @@ impl NwkCommand { } fn parse_body(body: &[u8]) -> Option { - T::from_abstract_bits(body).ok() + T::from_abstract_bytes(body).ok() } #[cfg(test)] diff --git a/crates/ziggurat-zigbee/src/nwk/frame.rs b/crates/ziggurat-zigbee/src/nwk/frame.rs index de875f0..3844573 100644 --- a/crates/ziggurat-zigbee/src/nwk/frame.rs +++ b/crates/ziggurat-zigbee/src/nwk/frame.rs @@ -1,4 +1,5 @@ #![allow(clippy::useless_conversion)] +use alloc::vec::Vec; use abstract_bits::AbstractBits; use abstract_bits::abstract_bits; @@ -56,9 +57,9 @@ pub struct NwkFrameControl { #[abstract_bits] #[derive(Debug, Clone, PartialEq, Eq)] pub struct NwkSourceRoute { - #[abstract_bits(length_of = relays)] relay_count: u8, pub relay_index: u8, + #[abstract_bits(length_from = relay_count)] pub relays: Vec, } @@ -222,7 +223,7 @@ pub struct NwkSecurityHeaderControlField { } impl NwkSecurityHeaderControlField { - /// The field's single serialized byte, without `to_abstract_bits`'s allocation: + /// The field's single serialized byte, without `to_abstract_bytes`'s allocation: /// it goes into every CCM* nonce. pub fn to_bytes(&self) -> [u8; 1] { let mut buffer = [0u8; 1]; @@ -577,6 +578,7 @@ impl NwkFrame { #[cfg(test)] mod test { use super::*; + use alloc::vec; use hex_literal::hex; #[test] diff --git a/crates/ziggurat-zigbee/src/nwk/neighbors.rs b/crates/ziggurat-zigbee/src/nwk/neighbors.rs index f05fd6f..43cc699 100644 --- a/crates/ziggurat-zigbee/src/nwk/neighbors.rs +++ b/crates/ziggurat-zigbee/src/nwk/neighbors.rs @@ -1,8 +1,11 @@ -use std::cmp; -use std::collections::{HashMap, HashSet, VecDeque}; +use alloc::collections::VecDeque; +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::vec::Vec; +use core::cmp; +use crate::Instant; use crate::nwk::commands::{NwkLinkStatus, NwkLinkStatusCommand}; -use std::time::{Duration, Instant}; +use core::time::Duration; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use super::NwkDeviceType; @@ -162,18 +165,18 @@ pub struct Neighbors { network_address: Nwk, /// Neighbors silent for this long get their link costs reset max_age: Duration, - table: HashMap, + table: BTreeMap, /// LQI samples for senders that have no neighbor entry yet. - pending_lqas: HashMap>, + pending_lqas: BTreeMap>, } impl Neighbors { - pub fn new(network_address: Nwk, max_age: Duration) -> Self { + pub const fn new(network_address: Nwk, max_age: Duration) -> Self { Self { network_address, max_age, - table: HashMap::new(), - pending_lqas: HashMap::new(), + table: BTreeMap::new(), + pending_lqas: BTreeMap::new(), } } @@ -628,7 +631,7 @@ impl Neighbors { None } }) - .collect::>(); + .collect::>(); // Fold any LQI samples buffered for this address before its entry existed. let buffered_lqas = self.pending_lqas.remove(&source_nwk).unwrap_or_default(); @@ -709,7 +712,7 @@ impl Neighbors { // nwkcMinRouterBootstrapJitter < nwkcMaxRouterBootstrapJitter } - tracing::debug!("Updated neighbor table entry: {neighbor_entry:?}"); + tracing::trace!("Updated neighbor table entry: {neighbor_entry:?}"); let lost_link = previous_outgoing_cost > 0 && neighbor_entry.outgoing_cost == 0; diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 1b4c0dd..1320f2b 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -1,12 +1,14 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use crate::Instant; use crate::nwk::commands::NwkRouteRequestManyToOne; -use std::time::{Duration, Instant}; +use core::time::Duration; use ziggurat_ieee_802154::types::Nwk; use crate::nwk::frame::BROADCAST_ALL_ROUTERS_AND_COORDINATOR; -pub type RequestId = u8; +pub type RouteRequestId = u8; const UNKNOWN_NEXT_HOP: Nwk = Nwk(0xFFFF); @@ -102,7 +104,7 @@ pub struct DiscoveryEntry { /// distinct from the 16-bit Routing Sequence Number. The former is used to discern /// route requests originating in a particular router; the latter is used to /// identify stale routing information. - pub route_request_id: RequestId, + pub route_request_id: RouteRequestId, /// The 16-bit network address of the route request’s initiator. pub source_address: Nwk, /// The 16-bit network address of the device that has sent the most recent lowest @@ -162,19 +164,19 @@ pub struct Routing { mtorr_route_error_threshold: u8, mtorr_delivery_failure_threshold: u8, - route_table: HashMap, - discovery_table: HashMap<(Nwk, RequestId), DiscoveryEntry>, - route_record_table: HashMap>, + route_table: BTreeMap, + discovery_table: BTreeMap<(Nwk, RouteRequestId), DiscoveryEntry>, + route_record_table: BTreeMap>, /// Implied from the spec: "notice that this 8-bit identifier is distinct from the /// 16-bit Routing Sequence Number. The former is used to discern route requests /// originating in a particular router; the latter is used to identify stale routing /// information." - request_sequence_number: RequestId, + request_sequence_number: RouteRequestId, } impl Routing { - pub fn new( + pub const fn new( network_address: Nwk, route_discovery_time: Duration, mtorr_route_error_threshold: u8, @@ -187,9 +189,9 @@ impl Routing { mtorr_delivery_failures: 0, mtorr_route_error_threshold, mtorr_delivery_failure_threshold, - route_table: HashMap::new(), - discovery_table: HashMap::new(), - route_record_table: HashMap::new(), + route_table: BTreeMap::new(), + discovery_table: BTreeMap::new(), + route_record_table: BTreeMap::new(), request_sequence_number: 0, } } @@ -312,6 +314,13 @@ impl Routing { /// mostly reverse-route side effects of their discoveries). This deviates from /// the spec's table-first order (3.6.4.3). pub fn route_to(&self, destination: Nwk, max_source_route: u8) -> Option { + // TODO: a route record wins unconditionally here, with no outgoing-link-cost check. + // For a concentrator this reverses the device->coordinator path and assumes the link + // is symmetric: a device reachable directly inbound advertises Relay Count 0, so we + // transmit direct even when our outbound link to it is poor (asymmetric radio). The + // cost-aware AODV path only runs in the `_` fallback. Fold the neighbor's + // `outgoing_cost` in so a Relay-Count-0 record only yields `NextHop` when the + // outbound link is actually good; otherwise fall through to cost-based routing. match self.route_record_table.get(&destination) { // Spec 3.6.4.3.1: no intermediate relays means direct transmission Some(relays) if relays.is_empty() => Some(Route::NextHop(destination)), @@ -325,7 +334,7 @@ impl Routing { /// Prepare table state for a route discovery we originate: the routing entry enters /// `DiscoveryUnderway` and a discovery entry keyed by our own address is created. /// Returns the request identifier to put in the route request command. - pub fn begin_discovery(&mut self, destination: Nwk, now: Instant) -> RequestId { + pub fn begin_discovery(&mut self, destination: Nwk, now: Instant) -> RouteRequestId { // Expire stale discoveries before establishing the new one. A just-expired // discovery toward this same destination would otherwise tear down the // `DiscoveryUnderway` route entry created below. @@ -354,14 +363,14 @@ impl Routing { destination_address: destination, }); - tracing::debug!("Route discovery entry: [{key:?}] = {discovery_entry:?}"); + tracing::trace!("Route discovery entry: [{key:?}] = {discovery_entry:?}"); request_id } /// Register the discovery entry backing a many-to-one route advertisement, which /// is addressed to a broadcast address and never answered with a reply. - pub fn begin_many_to_one_advertisement(&mut self, now: Instant) -> RequestId { + pub fn begin_many_to_one_advertisement(&mut self, now: Instant) -> RouteRequestId { self.request_sequence_number = self.request_sequence_number.wrapping_add(1); let request_id = self.request_sequence_number; @@ -406,7 +415,7 @@ impl Routing { pub fn accept_route_request( &mut self, originator: Nwk, - request_id: RequestId, + request_id: RouteRequestId, destination: Nwk, sender: Nwk, updated_path_cost: u8, @@ -482,7 +491,7 @@ impl Routing { pub fn accept_route_reply( &mut self, originator: Nwk, - request_id: RequestId, + request_id: RouteRequestId, responder: Nwk, sender: Nwk, updated_path_cost: u8, diff --git a/crates/ziggurat-zigbee/src/nwk/security.rs b/crates/ziggurat-zigbee/src/nwk/security.rs index 2f22b83..1aaa220 100644 --- a/crates/ziggurat-zigbee/src/nwk/security.rs +++ b/crates/ziggurat-zigbee/src/nwk/security.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; use ziggurat_ieee_802154::types::{Eui64, Key}; @@ -11,7 +11,7 @@ enum NetworkKeyType { struct NwkSecurityDescriptor { key_seq_number: u8, outgoing_frame_counter: u32, - incoming_frame_counter_set: HashMap, + incoming_frame_counter_set: BTreeMap, key: Key, #[allow(dead_code)] network_key_type: NetworkKeyType, @@ -56,14 +56,14 @@ impl NwkSecurity { primary: NwkSecurityDescriptor { key_seq_number, outgoing_frame_counter, - incoming_frame_counter_set: HashMap::new(), + incoming_frame_counter_set: BTreeMap::new(), key, network_key_type: NetworkKeyType::Standard, }, alternate: NwkSecurityDescriptor { key_seq_number: 0, outgoing_frame_counter: 0, - incoming_frame_counter_set: HashMap::new(), + incoming_frame_counter_set: BTreeMap::new(), key: Key::from_hex("00000000000000000000000000000000"), network_key_type: NetworkKeyType::Standard, }, diff --git a/crates/ziggurat-zigbee/src/time.rs b/crates/ziggurat-zigbee/src/time.rs new file mode 100644 index 0000000..1049fa6 --- /dev/null +++ b/crates/ziggurat-zigbee/src/time.rs @@ -0,0 +1,40 @@ +use core::ops::Add; +use core::time::Duration; + +/// A monotonic instant, as microseconds since an arbitrary epoch chosen by the driver. +/// +/// The sans-io core never reads a clock; the driver passes `now` in and converts its own +/// platform clock (tokio, embassy, a sim) to and from this type at the boundary. Replacing +/// `std::time::Instant` is what lets this crate build for `no_std` targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Instant { + micros: u64, +} + +impl Instant { + pub const fn from_micros(micros: u64) -> Self { + Self { micros } + } + + pub const fn as_micros(&self) -> u64 { + self.micros + } + + /// Saturating elapsed time since `earlier`; zero if `earlier` is in the future. + pub const fn saturating_duration_since(&self, earlier: Self) -> Duration { + Duration::from_micros(self.micros.saturating_sub(earlier.micros)) + } +} + +impl Add for Instant { + type Output = Self; + + /// Saturating: a "never" sentinel built from a huge `Duration` clamps instead of + /// panicking on overflow. + fn add(self, rhs: Duration) -> Self { + let add = u64::try_from(rhs.as_micros()).unwrap_or(u64::MAX); + Self { + micros: self.micros.saturating_add(add), + } + } +} diff --git a/crates/ziggurat-zigbee/src/zdp.rs b/crates/ziggurat-zigbee/src/zdp.rs index eaf3a42..d56ab2b 100644 --- a/crates/ziggurat-zigbee/src/zdp.rs +++ b/crates/ziggurat-zigbee/src/zdp.rs @@ -1,4 +1,6 @@ #![allow(clippy::useless_conversion)] +use alloc::vec; +use alloc::vec::Vec; use crate::nwk::commands::NwkRejoinCapabilityInformation; use abstract_bits::{AbstractBits, BitReader, abstract_bits}; @@ -86,8 +88,8 @@ impl ZdpCommand for DeviceAnnce { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct ParentAnnce { - #[abstract_bits(length_of = children)] number_of_children: u8, + #[abstract_bits(length_from = number_of_children)] pub children: Vec, } @@ -101,8 +103,8 @@ impl ZdpCommand for ParentAnnce { #[derive(Debug, Clone, Eq, PartialEq)] pub struct ParentAnnceRsp { pub status: ZdpStatus, - #[abstract_bits(length_of = children)] number_of_children: u8, + #[abstract_bits(length_from = number_of_children)] pub children: Vec, } @@ -185,8 +187,8 @@ pub struct MgmtLqiRsp { pub status: ZdpStatus, pub neighbor_table_entries: u8, pub start_index: u8, - #[abstract_bits(length_of = neighbor_table_list)] neighbor_table_list_count: u8, + #[abstract_bits(length_from = neighbor_table_list_count)] pub neighbor_table_list: Vec, } @@ -235,8 +237,8 @@ pub struct MgmtRtgRsp { pub status: ZdpStatus, pub routing_table_entries: u8, pub start_index: u8, - #[abstract_bits(length_of = routing_table_list)] routing_table_list_count: u8, + #[abstract_bits(length_from = routing_table_list_count)] pub routing_table_list: Vec, }