diff --git a/Cargo.lock b/Cargo.lock index d840583..b810d9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ dependencies = [ "abstract-bits-derive", "arbitrary-int 1.3.0", "bitvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -21,16 +21,16 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "aead" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] @@ -42,7 +42,7 @@ checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -115,9 +115,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary-int" @@ -145,15 +145,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -161,15 +161,6 @@ dependencies = [ "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" @@ -202,15 +193,15 @@ dependencies = [ [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -236,19 +227,19 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -268,16 +259,16 @@ 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", + "block-buffer", + "crypto-common", "inout", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -285,9 +276,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -297,14 +288,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -315,9 +306,13 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cobs" -version = "0.2.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +checksum = "dd93fd2c1b27acd030440c9dbd9d14c1122aad622374fe05a670b67a4bc034be" +dependencies = [ + "heapless", + "thiserror 2.0.19", +] [[package]] name = "colorchoice" @@ -325,6 +320,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cordyceps" version = "0.3.4" @@ -357,15 +358,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -387,16 +379,6 @@ 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" @@ -436,7 +418,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -447,7 +429,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -469,12 +451,13 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "const-oid", + "crypto-common", ] [[package]] @@ -488,14 +471,14 @@ dependencies = [ [[package]] name = "educe" -version = "0.6.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "92c3e1715a2bf74bc8f68cd7bae12ff144f02669c602106ad1fa16f2ba62e646" dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -520,7 +503,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -621,22 +604,22 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -657,12 +640,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "funty" version = "2.0.0" @@ -671,13 +648,12 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", - "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -686,9 +662,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -696,63 +672,37 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -772,40 +722,16 @@ dependencies = [ "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 = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", + "r-efi", + "rand_core", ] [[package]] @@ -817,15 +743,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -878,9 +795,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -909,12 +826,6 @@ dependencies = [ "cc", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -928,9 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -981,17 +890,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "litrs" @@ -1010,9 +913,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -1047,15 +950,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -1065,15 +968,15 @@ dependencies = [ [[package]] name = "mio-serial" -version = "5.0.6" +version = "5.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +checksum = "6d4ba3f20276f21b7cad3f1b54c97489cf096a3894fd627cc6951cb3abdd4c60" dependencies = [ "log", "mio", - "nix 0.29.0", + "nix 0.31.3", "serialport", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -1104,11 +1007,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -1151,7 +1054,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1206,25 +1109,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1253,33 +1137,27 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1294,42 +1172,13 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "getrandom", + "rand_core", ] [[package]] @@ -1344,14 +1193,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1366,9 +1215,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scoped-tls" @@ -1382,52 +1231,24 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[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", + "syn 3.0.3", ] [[package]] @@ -1436,7 +1257,7 @@ version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "core-foundation", "core-foundation-sys", @@ -1450,12 +1271,12 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -1482,15 +1303,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1498,9 +1319,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" [[package]] name = "stable_deref_trait" @@ -1516,9 +1337,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.4.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -1533,9 +1354,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1559,11 +1391,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -1574,34 +1406,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1614,23 +1446,24 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "tokio-serial" -version = "5.4.5" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +checksum = "dd00f5f8b1e01c3e5afccd9e42ed80c2ad2df6d007877f29f8592c62e69cd116" dependencies = [ "cfg-if", - "futures", + "futures-core", + "futures-sink", "log", "mio-serial", "serialport", @@ -1639,9 +1472,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -1660,9 +1493,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -1698,7 +1531,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1743,18 +1576,18 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.4", + "rand", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1765,11 +1598,11 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unescaper" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -1778,12 +1611,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.2" @@ -1796,12 +1623,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[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" @@ -1814,24 +1635,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1864,7 +1667,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1877,62 +1680,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -1954,7 +1701,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1965,7 +1712,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2076,107 +1823,13 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wyz" version = "0.5.1" @@ -2186,26 +1839,6 @@ dependencies = [ "tap", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ziggurat-driver" version = "0.1.0" @@ -2217,9 +1850,9 @@ dependencies = [ "embassy-time", "futures", "parking_lot", - "rand 0.10.1", + "rand", "spin", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "ziggurat-ieee-802154", @@ -2237,14 +1870,14 @@ dependencies = [ "hex", "hex-literal", "num_enum", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "ziggurat-phy" version = "0.1.0" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", "ziggurat-ieee-802154", ] @@ -2266,6 +1899,7 @@ dependencies = [ "abstract-bits", "arbitrary-int 2.1.1", "num_enum", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", @@ -2299,9 +1933,10 @@ dependencies = [ "crc_all", "hex-literal", "num_enum", - "rand 0.10.1", - "thiserror 2.0.18", + "rand", + "thiserror 2.0.19", "tokio", + "tokio-serial", "tracing", "ziggurat-ieee-802154", ] @@ -2331,13 +1966,7 @@ dependencies = [ "num_enum", "once_cell", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.19", "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-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index 64973f4..75cf1a4 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -15,14 +15,14 @@ ziggurat-zigbee.workspace = true abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } arbitrary-int = "2.1.1" -futures = { version = "0.3", default-features = false } +futures = { version = "0.3", default-features = false, features = ["alloc"] } tracing = { version = "0.1", default-features = false } -thiserror = { version = "2.0.12", default-features = false } +thiserror = { version = "2.0.19", 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 = [ +parking_lot = { version = "0.12.5", optional = true } +rand = { version = "0.10.2", optional = true } +tokio = { version = "1.53.0", features = [ "rt", "macros", "time", @@ -36,7 +36,7 @@ tokio = { version = "1.43.0", features = [ 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 = { version = "0.12", default-features = false, features = [ "spin_mutex", ], optional = true } diff --git a/crates/ziggurat-driver/src/broadcast_budget.rs b/crates/ziggurat-driver/src/broadcast_budget.rs new file mode 100644 index 0000000..cf1e74d --- /dev/null +++ b/crates/ziggurat-driver/src/broadcast_budget.rs @@ -0,0 +1,120 @@ +//! Broadcast admission budget. + +use core::time::Duration; + +use ziggurat_zigbee::Instant as CoreInstant; + +use crate::frame_token::TrafficClass; + +/// The outcome of asking the budget to admit one broadcast. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BroadcastAdmission { + /// Send now. + Admit, + /// Refused: a token for this class frees no sooner than `retry_in`. Only + /// [`TrafficClass::Forwarding`] and [`TrafficClass::Host`] are ever refused; + /// [`TrafficClass::Critical`] always admits. + Defer { retry_in: Duration }, +} + +/// A token bucket with per-class reserves. +#[derive(Debug)] +pub struct BroadcastBudget { + /// Available tokens. Critical broadcasts bypass the bucket and draw nothing; host + /// and forwarding traffic only draws above its floor, so the count never dips below + /// zero. + tokens: u32, + /// When the last whole token was accrued. Advanced in whole-token steps so the + /// sub-token remainder of elapsed time is never discarded. + last_refill: CoreInstant, +} + +impl BroadcastBudget { + /// A bucket that starts full at the stack's clock baseline (time zero). + pub fn new(initial_tokens: u8) -> Self { + Self { + tokens: u32::from(initial_tokens), + last_refill: CoreInstant::from_micros(0), + } + } + + /// The token floor a class may not dip into. + fn floor(class: TrafficClass, critical_reserve: u8, forwarding_reserve: u8) -> u32 { + match class { + TrafficClass::Critical => 0, + TrafficClass::Forwarding => u32::from(critical_reserve), + TrafficClass::Host => u32::from(critical_reserve) + u32::from(forwarding_reserve), + } + } + + /// Regenerate one token per `refill_interval`, capped at `capacity`. + fn refill(&mut self, now: CoreInstant, capacity: u32, refill_interval: Duration) { + let interval_us = refill_interval.as_micros(); + + // A zero interval means "no rate limit": keep the bucket full. + if interval_us == 0 { + self.tokens = capacity; + self.last_refill = now; + return; + } + + // Already full (or clamped down after a tunable lowered the capacity): nothing + // to add, but keep the clock current so a later deficit is measured from now. + if self.tokens >= capacity { + self.tokens = capacity; + self.last_refill = now; + return; + } + + let elapsed_us = now.saturating_duration_since(self.last_refill).as_micros(); + let accrued = elapsed_us / interval_us; + if accrued == 0 { + return; + } + + // `tokens < capacity` here, so headroom fits comfortably in the token range. + let headroom = u128::from(capacity - self.tokens); + let added = accrued.min(headroom) as u32; + self.tokens += added; + + if self.tokens >= capacity { + self.tokens = capacity; + self.last_refill = now; + } else { + self.last_refill = self.last_refill + refill_interval.saturating_mul(added); + } + } + + /// Ask to admit one broadcast of `class`, refilling first. + #[allow(clippy::too_many_arguments)] + pub fn take( + &mut self, + class: TrafficClass, + now: CoreInstant, + capacity: u8, + refill_interval: Duration, + critical_reserve: u8, + forwarding_reserve: u8, + ) -> BroadcastAdmission { + let capacity = u32::from(capacity); + self.refill(now, capacity, refill_interval); + + if class == TrafficClass::Critical { + // Bypass: necessary broadcasts are always admitted AND draw no token, so a + // burst of them can never lock out host or forwarding traffic while it + // refills. + BroadcastAdmission::Admit + } else { + let floor = Self::floor(class, critical_reserve, forwarding_reserve); + if self.tokens > floor { + self.tokens -= 1; + BroadcastAdmission::Admit + } else { + let deficit = floor + 1 - self.tokens; + BroadcastAdmission::Defer { + retry_in: refill_interval.saturating_mul(deficit), + } + } + } + } +} diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index 1d6dd81..0339f29 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -6,10 +6,10 @@ extern crate alloc; #[cfg(feature = "embassy-host")] extern crate std; +pub mod broadcast_budget; pub mod frame_token; pub mod rng; pub mod runtime; -pub mod signal; pub mod sync; pub mod zigbee_stack; diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index c073f35..d4966cf 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -45,7 +45,7 @@ impl RtInstant for tokio::time::Instant { /// 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)] +#[derive(Debug, Clone, thiserror::Error)] #[error("deadline elapsed")] pub struct Elapsed; diff --git a/crates/ziggurat-driver/src/signal.rs b/crates/ziggurat-driver/src/signal.rs deleted file mode 100644 index 1b6f286..0000000 --- a/crates/ziggurat-driver/src/signal.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! `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/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 5f1ef99..01ae59a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -2,7 +2,6 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame, ParseError use crate::frame_token::{self, FrameToken, TrafficClass}; use crate::runtime::{Elapsed, RtInstant, Runtime, Spawn}; -use crate::signal::Signal; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; @@ -18,7 +17,6 @@ use thiserror::Error; use crate::sync::{AsyncMutex, Mutex, MutexGuard, Notify}; use alloc::boxed::Box; use alloc::collections::{BinaryHeap, VecDeque}; -use alloc::string::String; use alloc::sync::{Arc, Weak}; use alloc::vec::Vec; use core::cmp::Ordering; @@ -36,8 +34,12 @@ mod mac; mod neighbor; mod nwk; mod route; +mod send_handle; +mod tasklets; mod zdp; +pub use send_handle::{SendHandle, SendProgress, SendSlot, TrackStage}; + pub use ziggurat_zigbee::aps::security as aps_security; pub use ziggurat_zigbee::aps::security::{ApsSecurity, TclkSeed}; pub use ziggurat_zigbee::constants::{ @@ -68,16 +70,38 @@ const FRAME_COUNTER_NOTIFY_INTERVAL: u32 = 100; /// 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 { +/// A synchronous admission failure, returned by the `send_*` entry points. +/// +/// An `Err` means nothing was enqueued and no later confirmation follows; `retry_in` +/// lives here structurally, so a rate-limited caller learns the backoff from the +/// rejection itself. Maps to the wire `Error`/`Status` channel. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum EnqueueError { + #[error("rejected due to rate limiting, retry in {retry_in:?}")] + RateLimited { retry_in: Duration }, + #[error("frame memory budget exhausted")] + BudgetExhausted, + #[error("payload does not fit in a single frame")] + PayloadTooLong, + #[error("aps security material unavailable or unusable")] + SecurityUnavailable, + #[error("no route to destination and route discovery is suppressed")] + RouteDiscoverySuppressed, + #[error("network not started")] + NotStarted, +} + +/// A send's terminal verdict, resolved into its slot once the mesh has spoken. `Clone` +/// because a slot hands copies to its awaiter(s) and the wire tracker. Maps to the wire +/// `SendStatus` channel only. +#[derive(Error, Debug, Clone)] +pub enum DeliveryError { #[error("route discovery timed out")] RouteDiscoveryTimeout(#[from] Elapsed), #[error("no route discovery entry found for the destination")] 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")] @@ -86,14 +110,16 @@ pub enum ZigbeeStackError { TransmitFailed(TxResult), #[error("aps ack timeout")] ApsAckTimeout, - #[error("payload does not fit in a single frame")] - PayloadTooLong, - #[error("frame memory budget exhausted")] - FrameBudgetExhausted, - #[error("aps security material unavailable or unusable")] - ApsSecurityFailed, + #[error("broadcast passive-ack quorum not reached")] + BroadcastQuorumNotReached, #[error("indirect transaction expired before {destination:?} polled")] IndirectExpired { destination: Ieee802154Address }, + /// A frame reached a mid-pipeline enqueue (an indirect delivery, a retry re-enqueue) + /// with the budget exhausted, so it could never be handed to the radio. + #[error("frame memory budget exhausted")] + BudgetExhausted, + #[error("send cancelled")] + Cancelled, #[error("radio error: {0}")] Radio(#[from] RadioError), } @@ -136,6 +162,16 @@ pub struct TxPolicy { pub class: TrafficClass, } +impl TxPolicy { + /// Stack machinery answering protocol events (key transports, rejoin responses, + /// APS acks, network status reports): critical for both transmit scheduling and + /// the frame budget. + pub const STACK_CRITICAL: Self = Self { + priority: TxPriority::StackCritical, + class: TrafficClass::Critical, + }; +} + /// 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)] @@ -315,25 +351,18 @@ impl ApsAckData { } } -/// 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). + /// Nobody is waiting; a failure is only logged (internal fire-and-forget 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, + /// Resolve one stage of a send's [`SendSlot`]. The stage rides the outcome because + /// `resolve_outcome` is a single funnel called from stage-distinct sites (the sender + /// task resolves handoff, the broadcast/aps-ack reactor resolves delivery); the slot + /// itself is shapeless. See [`SendSlot::resolve`] for the write rules. + Track { + slot: Arc, + stage: TrackStage, }, /// An extracted indirect transaction in flight to the radio. Success resolves the /// transaction's own completion; a failed transmit puts it back at the head of its @@ -344,18 +373,34 @@ pub enum TxOutcome { /// [`TxOutcome`]. transaction: Box>, }, - /// A successful association response was extracted by the joiner, confirming its - /// short address: deliver the network key (spec 4.6.3.2). Expiry is only logged; - /// the joiner retries the association. - DeliverNetworkKey { nwk: Nwk, eui64: Eui64 }, +} + +/// A unicast handed to [`ZigbeeStack::send_unicast`]. +#[derive(Debug)] +pub struct Unicast { + pub frame: NwkFrame, + pub security: NwkSecurityMode, + pub mode: SendMode, + pub policy: TxPolicy, + pub outcome: TxOutcome, +} + +/// A broadcast handed to [`ZigbeeStack::send_broadcast`] or +/// [`ZigbeeStack::send_oneshot_broadcast`]. +#[derive(Debug)] +pub struct Broadcast { + pub frame: NwkFrame, + pub security: NwkSecurityMode, + pub policy: TxPolicy, + pub slot: 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`. +/// ack. The ack arrival (or its timeout) resolves the send's `delivered` stage through +/// the held slot. #[derive(Debug)] pub struct PendingApsAck { - pub(crate) request_id: RequestId, + pub(crate) slot: Arc, pub(crate) deadline: CoreInstant, } @@ -441,9 +486,11 @@ pub struct PendingBroadcast { 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, + /// The tracked send's slot, if any. The reactor resolves its `Delivery` stage: `Ok` + /// when the passive-ack quorum is heard, `Err(BroadcastQuorumNotReached)` when attempts run + /// out without one. `None` for an internal fire-and-forget broadcast (a relayed + /// broadcast, a route request). Each on-air copy resolves the `HandOff` stage. + pub(crate) slot: Option>, /// Held for the broadcast's whole retransmit schedule, only to be dropped with it. pub(crate) _token: FrameToken, } @@ -827,14 +874,6 @@ pub enum ZigbeeNotification { device_type: Option, rx_on_when_idle: bool, }, - /// A routing table entry's active route changed. - RouteChanged { - destination: Nwk, - next_hop: Nwk, - path_cost: u8, - }, - /// A routing table entry was removed; the client drops it from its persisted cache - RouteRemoved { destination: Nwk }, /// A source route (relay list) to a destination was learned or cleared RouteRecord { destination: Nwk, relays: Vec }, /// The outgoing APS security frame counter has advanced; the client persists it to @@ -858,31 +897,6 @@ pub enum ZigbeeNotification { frame_counter: u32, key_id: NwkSecurityHeaderKeyId, }, - 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)] @@ -960,6 +974,14 @@ pub struct ZigbeeStack /// could move the earliest expiry deadline closer pub(crate) maintenance_wake: Notify, + /// Dynamically-spawned tasklets (multi-step flows like the join), all multiplexed + /// on one pool task + pub(crate) tasklets: tasklets::Tasklets, + + /// Admission budget for outgoing broadcasts: a token bucket with per-class reserves + /// so a host broadcast flood cannot starve stack-critical broadcasts. + pub(crate) broadcast_budget: 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>, @@ -1038,6 +1060,8 @@ impl ZigbeeStack { let raw_frame_rx = radio.subscribe_rx(); let reset_rx = radio.subscribe_reset(); + let initial_broadcast_tokens = tunables.broadcast_budget_tokens(); + Arc::new_cyclic(|weak_self| Self { self_weak: weak_self.clone(), start_time: R::now(), @@ -1064,6 +1088,10 @@ impl ZigbeeStack { aps_ack_wake: Notify::new(), beacon_spam_wake: Notify::new(), maintenance_wake: Notify::new(), + tasklets: tasklets::Tasklets::default(), + broadcast_budget: Mutex::new(crate::broadcast_budget::BroadcastBudget::new( + initial_broadcast_tokens, + )), send_queue: Mutex::new(BinaryHeap::new()), send_wake: Notify::new(), pending_route_wake: Notify::new(), @@ -1343,74 +1371,44 @@ impl ZigbeeStack { } } - pub async fn start_network(&self) -> Result<(), ZigbeeStackError> { + pub async fn start_network(&self) -> Result<(), RadioError> { 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 { + self.spawn_tracked(|arc_self| 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 { + self.spawn_tracked(|arc_self| 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 { + self.spawn_tracked(|arc_self| 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 { + self.spawn_tracked(|arc_self| 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 { + self.spawn_tracked(|arc_self| 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 { + self.spawn_tracked(|arc_self| async move { arc_self.beacon_spam_task().await; }); @@ -1418,78 +1416,54 @@ impl ZigbeeStack { // "respond" to empty link status broadcasts proactively, independent of the // link status period tracing::info!("Sending initial link status broadcast"); - self.send_link_status_broadcast(true).await; - - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); + let _ = self.send_link_status_broadcast(true).handed_off().await; // Start the background link status broadcaster task - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.periodic_link_status_broadcast_task().await; }); // Advertise many-to-one routes to ourselves so that devices can route inbound // traffic without per-device route discoveries if self.state.is_concentrator { - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.periodic_many_to_one_route_request_task().await; }); } // Reprogram the radio whenever it resets out from under us - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.radio_recovery_task().await; }); // Mirror the indirect queue state into the RCP source address match table - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.src_match_sync_task().await; }); // Expire undelivered indirect transactions and age out silent children - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.indirect_maintenance_task().await; }); - // Announce our end device children to the other routers after boot + // Drive the in-flight tasklets (multi-step flows like the join) on one task + self.spawn_tracked(|arc_self| async move { + arc_self.tasklet_task().await; + }); + + // Announce our end device children to the other routers after boot. A finite + // post-boot flow, so it rides the tasklet runner instead of holding a pool slot. let arc_self = self .self_weak .upgrade() .expect("Unable to upgrade self reference"); - self.spawn_tracked(async move { - arc_self.parent_annce_task().await; + self.tasklets.push(async move { + arc_self.run_parent_annce().await; }); // Broadcast jittered address-conflict reports (spec 3.6.1.10.5) - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { + self.spawn_tracked(|arc_self| async move { arc_self.address_conflict_task().await; }); @@ -1497,7 +1471,7 @@ impl ZigbeeStack { } /// Reset the RCP and wait for it to announce itself, retrying if it stays silent. - async fn reset_radio(&self) -> Result<(), ZigbeeStackError> { + async fn reset_radio(&self) -> Result<(), RadioError> { let mut reset_rx = self.reset_rx.try_lock().expect("Reset receiver is locked"); for attempt in 1..=RESET_ATTEMPTS { @@ -1508,19 +1482,19 @@ impl ZigbeeStack { tracing::info!("Radio reset complete: {:?}", event.reason); return Ok(()); } - Ok(None) => return Err(RadioError::TransportClosed.into()), + Ok(None) => return Err(RadioError::TransportClosed), Err(_) => { tracing::warn!("No reset notification, attempt {attempt}/{RESET_ATTEMPTS}"); } } } - Err(RadioError::Timeout.into()) + Err(RadioError::Timeout) } /// Program the radio with our network parameters. A radio reset wipes all of this, /// so it must be re-applied after every reset. - async fn apply_radio_configuration(&self) -> Result<(), ZigbeeStackError> { + async fn apply_radio_configuration(&self) -> Result<(), RadioError> { let (config, table) = { let core = self.core(); let table = core @@ -1643,7 +1617,7 @@ impl ZigbeeStack { &self, channels: &[u8], duration_per_channel: Duration, - ) -> Result<(), ZigbeeStackError> { + ) -> Result<(), RadioError> { let beacon_request = self.beacon_request_psdu(); let home_channel = self.core().mac.channel; @@ -1684,7 +1658,7 @@ impl ZigbeeStack { self.scan_active.store(false, AtomicOrdering::Relaxed); self.scan_beacon_wake.notify_one(); - result.map_err(Into::into) + result } /// Wait for and take beacons collected so far by the active scan. Drains any @@ -1706,18 +1680,14 @@ impl ZigbeeStack { /// 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?) + pub async fn energy_detect(&self, channel: u8, duration: Duration) -> Result { + self.radio.energy_detect(channel, duration).await } /// Retune the radio to a new channel, the coordinator's half of a network-wide /// channel migration. Mesh state is untouched; subsequent resets and energy scans /// return to the new channel. - pub async fn set_channel(&self, channel: u8) -> Result<(), ZigbeeStackError> { + pub async fn set_channel(&self, channel: u8) -> Result<(), RadioError> { self.radio.lock().await.set_channel(channel).await?; self.core().mac.channel = channel; Ok(()) @@ -1730,9 +1700,10 @@ impl ZigbeeStack { } /// Spawns a task tied to the stack's lifetime: it is stopped on `shutdown`. - pub fn spawn_tracked(&self, future: F) + pub fn spawn_tracked(&self, future_func: F) where - F: Future + Send + 'static, + F: FnOnce(Arc) -> Fut + Send + 'static, + Fut: Future + Send + 'static, { let id = self.next_task_id.fetch_add(1, AtomicOrdering::Relaxed); let cancel = Arc::new(Notify::new()); @@ -1742,11 +1713,17 @@ impl ZigbeeStack { // still deregister without keeping the stack alive. let weak = self.self_weak.clone(); + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + 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`). { + let future = future_func(arc_self); let future = core::pin::pin!(future); let cancelled = core::pin::pin!(cancel.notified()); let _ = futures::future::select(future, cancelled).await; diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 29feb10..816603d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -5,11 +5,9 @@ use ziggurat_zigbee::aps::frame::{ ApsAckFrame, ApsAckFrameControl, ApsDataFrame, ApsDeliveryMode, ApsFrameControl, ApsFrameType, EncryptedApsAckFrame, EncryptedApsDataFrame, }; -use ziggurat_zigbee::nwk::frame::{ - BROADCAST_LOW_POWER_ROUTERS, BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery, -}; +use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery}; -use alloc::string::ToString; +use alloc::sync::Arc; use alloc::vec::Vec; use core::cmp; use core::time::Duration; @@ -18,8 +16,9 @@ use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::flat_map::Entry; use super::{ - ApsAck, ApsAckData, ApsAckResult, NwkSecurityMode, PendingApsAck, RequestId, RouteDirective, - SendMode, TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + ApsAck, ApsAckData, Broadcast, DeliveryError, EnqueueError, NwkSecurityMode, PendingApsAck, + RouteDirective, SendHandle, SendMode, SendSlot, TrackStage, TxOutcome, TxPolicy, TxPriority, + Unicast, ZigbeeStack, }; use crate::frame_token::TrafficClass; @@ -87,11 +86,8 @@ impl ZigbeeStack { 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, - }); + if let Some(PendingApsAck { slot, .. }) = pending { + slot.resolve(TrackStage::Delivery, Ok(())); } } @@ -171,25 +167,42 @@ impl ZigbeeStack { // Send our ACK back to the sender let aps_ack_frame = self .nwk_data_frame(nwk_frame.nwk_header.source, payload) + .expect("ACK frame is always valid") .with_discover_route(NwkRouteDiscovery::Enable); - self.background_send_nwk_frame( - aps_ack_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route(RouteDirective::StackDecides), - ); + let send = Unicast { + frame: aps_ack_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Route(RouteDirective::StackDecides), + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }; + if let Err(err) = self.send_unicast(send) { + tracing::warn!("Failed to send APS ack: {err}"); + } } - /// 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`]. + /// How long to wait for a device's APS ack: longer for a sleepy destination, which + /// only sees (and acks) the frame after polling. + fn aps_ack_timeout(&self, destination: Nwk, sleepy_destination: bool) -> Duration { + if sleepy_destination || self.sleepy_child_eui64(destination).is_some() { + self.tunables.aps_ack_timeout_indirect() + } else { + self.tunables.aps_ack_timeout() + } + } + + /// Send a unicast APS data frame, returning a [`SendHandle`] over its two stages. + /// `Err` rejects at admission (malformed, rate limited, frame budget) and no handle is + /// returned. On `Ok`, `handed_off` resolves on next-hop acceptance and `delivered` on + /// the end-to-end APS ack (for an ack send) or on that same acceptance (rule 4, for a + /// no-ack send). /// - /// `aps_security` requests APS encryption of the ASDU with the link key shared - /// with that device (unicast only: link keys are pairwise). + /// `aps_security` requests APS encryption of the ASDU with the link key shared with + /// that device (link keys are pairwise, so this is unicast-only). #[allow(clippy::too_many_arguments)] - pub(super) fn prepare_aps_send( + pub fn send_aps_unicast( &self, - delivery_mode: ApsDeliveryMode, destination: Nwk, profile_id: u16, cluster_id: u16, @@ -200,64 +213,31 @@ impl ZigbeeStack { aps_seq: u8, data: Vec, aps_security: Option, - ) -> Result<(NwkFrame, Option), ZigbeeStackError> { - let asdu = FrameBytes::from_slice(&data).map_err(|_| ZigbeeStackError::PayloadTooLong)?; - - let aps_frame = match delivery_mode { - ApsDeliveryMode::Unicast => ApsDataFrame { - frame_control: ApsFrameControl { - frame_type: ApsFrameType::Data, - delivery_mode: ApsDeliveryMode::Unicast, - reserved1: 0b0, - security: aps_security.is_some(), - ack_request: aps_ack == ApsAck::Request, - extended_header: false, - }, - group_id: None, - destination_endpoint: Some(dst_ep), - cluster_id, - profile_id, - source_endpoint: src_ep, - counter: aps_seq, - asdu, - }, - ApsDeliveryMode::Broadcast => ApsDataFrame { - frame_control: ApsFrameControl { - frame_type: ApsFrameType::Data, - delivery_mode: ApsDeliveryMode::Broadcast, - reserved1: 0b0, - security: false, - ack_request: false, - extended_header: false, - }, - group_id: None, - destination_endpoint: Some(dst_ep), - cluster_id, - profile_id, - source_endpoint: src_ep, - counter: aps_seq, - asdu, - }, - ApsDeliveryMode::Multicast => ApsDataFrame { - frame_control: ApsFrameControl { - frame_type: ApsFrameType::Data, - delivery_mode: ApsDeliveryMode::Multicast, - reserved1: 0b0, - security: false, - ack_request: false, - extended_header: false, - }, - group_id: Some(destination.as_u16()), - destination_endpoint: None, - cluster_id, - profile_id, - source_endpoint: src_ep, - counter: aps_seq, - asdu, + sleepy_destination: bool, + priority: TxPriority, + route: RouteDirective, + ) -> Result { + let asdu = FrameBytes::from_slice(&data).map_err(|_| EnqueueError::PayloadTooLong)?; + + let aps_frame = ApsDataFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Data, + delivery_mode: ApsDeliveryMode::Unicast, + reserved1: 0b0, + security: aps_security.is_some(), + ack_request: aps_ack == ApsAck::Request, + extended_header: false, }, + group_id: None, + destination_endpoint: Some(dst_ep), + cluster_id, + profile_id, + source_endpoint: src_ep, + counter: aps_seq, + asdu, }; - tracing::trace!("Prepared APS frame: {aps_frame:?}"); + tracing::trace!("Prepared unicast APS frame: {aps_frame:?}"); let aps_payload = if let Some(destination_eui64) = aps_security { let encrypted = self @@ -270,150 +250,191 @@ impl ZigbeeStack { self.maybe_notify_aps_frame_counter(); encrypted.to_bytes() } - None => return Err(ZigbeeStackError::ApsSecurityFailed), + None => return Err(EnqueueError::SecurityUnavailable), } } else { aps_frame.to_bytes() }; - // Zigbee 3.0 groupcast: the group lives only in the APS header; the NWK frame - // is broadcast to all rx-on-when-idle devices (spec 2.2.4.1.1.1) - let nwk_destination = if delivery_mode == ApsDeliveryMode::Multicast { - BROADCAST_RX_ON_WHEN_IDLE - } else { - destination - }; - let nwk_frame = self - .nwk_data_frame(nwk_destination, aps_payload) + .nwk_data_frame(destination, aps_payload)? .with_discover_route(NwkRouteDiscovery::Enable) .with_radius(cmp::max(radius, 1)); + // The end-to-end ack correlates on the swapped endpoints (our destination is the + // acker's source, and vice-versa). let ack_data = (aps_ack == ApsAck::Request).then_some(ApsAckData { src: destination, - destination_endpoint: Some(src_ep), // These are swapped + destination_endpoint: Some(src_ep), cluster_id: Some(cluster_id), profile_id: Some(profile_id), - source_endpoint: Some(dst_ep), // These are swapped + source_endpoint: Some(dst_ep), counter: aps_seq, }); - Ok((nwk_frame, ack_data)) - } + let (handle, slot) = SendHandle::new(); - /// How long to wait for a device's APS ack: longer for a sleepy destination, which - /// only sees (and acks) the frame after polling. - fn aps_ack_timeout(&self, destination: Nwk, sleepy_destination: bool) -> Duration { - if sleepy_destination || self.sleepy_child_eui64(destination).is_some() { - self.tunables.aps_ack_timeout_indirect() + // With an APS ack, the sender resolves only `handed_off` (next-hop acceptance); + // the ack arrival/timeout resolves `delivered` through the slot held here. Without + // one, next-hop acceptance is the whole verdict, so the sender resolves + // `Delivery` directly (rule 4 back-fills `handed_off`). + let stage = if ack_data.is_some() { + TrackStage::HandOff } else { - self.tunables.aps_ack_timeout() + TrackStage::Delivery + }; + + // An APS-ack send registers its pending ack (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, sleepy_destination); + self.state.pending_aps_acks.lock().insert( + ack_data.clone(), + PendingApsAck { + slot: slot.clone(), + deadline, + }, + ); + self.aps_ack_wake.notify_one(); + } + + let accepted = self.send_unicast(Unicast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Route(route), + policy: TxPolicy { + priority, + class: TrafficClass::Host, + }, + outcome: TxOutcome::Track { slot, stage }, + }); + + // A rejected frame gets no confirmation: unregister its pending ack and drop the + // handle by returning the admission error. + if let Err(err) = accepted { + if let Some(ack_data) = ack_data { + self.state.pending_aps_acks.lock().remove(&ack_data); + } + return Err(err); } + + Ok(handle) } - /// 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. + /// Send a broadcast APS data frame to a broadcast sink, returning a [`SendHandle`]. + /// `Err` rejects at admission; on `Ok`, `handed_off` resolves when the first copy + /// reaches the air and `delivered` on the passive-ack quorum result. A broadcast is + /// never APS-secured nor end-to-end acked: the quorum is its confirmation. #[allow(clippy::too_many_arguments)] - pub fn send_aps( + pub fn send_aps_broadcast( &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, - sleepy_destination: bool, priority: TxPriority, - route: RouteDirective, - request_id: RequestId, - ) -> Result<(), ZigbeeStackError> { - let (nwk_frame, ack_data) = self.prepare_aps_send( - delivery_mode, - destination, - profile_id, + ) -> Result { + let asdu = FrameBytes::from_slice(&data).map_err(|_| EnqueueError::PayloadTooLong)?; + + let aps_frame = ApsDataFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Data, + delivery_mode: ApsDeliveryMode::Broadcast, + reserved1: 0b0, + security: false, + ack_request: false, + extended_header: false, + }, + group_id: None, + destination_endpoint: Some(dst_ep), 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, sleepy_destination); - self.state.pending_aps_acks.lock().insert( - ack_data.clone(), - PendingApsAck { - request_id, - deadline, - }, - ); - self.aps_ack_wake.notify_one(); - } + profile_id, + source_endpoint: src_ep, + counter: aps_seq, + asdu, + }; - // The class is fixed here, not host-chosen: a host send can never draw from - // the forwarding or critical budget tiers, whatever its priority. - self.enqueue_aps_frame( - nwk_frame, - TxPolicy { + tracing::trace!("Prepared broadcast APS frame: {aps_frame:?}"); + + let aps_payload = aps_frame.to_bytes(); + let nwk_frame = self + .nwk_data_frame(destination, aps_payload)? + .with_discover_route(NwkRouteDiscovery::Enable) + .with_radius(cmp::max(radius, 1)); + + let (handle, slot) = SendHandle::new(); + self.send_broadcast(Broadcast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + policy: TxPolicy { priority, class: TrafficClass::Host, }, - TxOutcome::Confirm { - request_id, - aps_ack: ack_data, - }, - SendMode::Route(route), - ); - Ok(()) + slot: Some(slot), + })?; + Ok(handle) } - /// 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( + /// Send a groupcast (APS multicast) data frame, returning a [`SendHandle`]. The group + /// lives only in the APS header; the NWK frame is broadcast to all rx-on-when-idle + /// devices (spec 2.2.4.1.1.1), so it rides the broadcast machinery and, like a + /// broadcast, its confirmation is the passive-ack quorum result and it is never + /// APS-secured or acked. + #[allow(clippy::too_many_arguments)] + pub fn send_aps_groupcast( &self, - nwk_frame: NwkFrame, - policy: TxPolicy, - outcome: TxOutcome, - mode: SendMode, - ) { - 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, - // Indirect-queue continuations never ride an APS frame - TxOutcome::IndirectDelivery { .. } | TxOutcome::DeliverNetworkKey { .. } => { - unreachable!() - } - }; - self.send_broadcast_nwk_frame( - nwk_frame, - NwkSecurityMode::NetworkKey, - policy, - request_id, - ); - } else { - self.originate_unicast( - nwk_frame, - NwkSecurityMode::NetworkKey, - mode, - policy, - outcome, - ); - } + group_id: u16, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + radius: u8, + aps_seq: u8, + data: Vec, + priority: TxPriority, + ) -> Result { + let asdu = FrameBytes::from_slice(&data).map_err(|_| EnqueueError::PayloadTooLong)?; + + let aps_frame = ApsDataFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Data, + delivery_mode: ApsDeliveryMode::Multicast, + reserved1: 0b0, + security: false, + ack_request: false, + extended_header: false, + }, + group_id: Some(group_id), + destination_endpoint: None, + cluster_id, + profile_id, + source_endpoint: src_ep, + counter: aps_seq, + asdu, + }; + + tracing::trace!("Prepared group broadcast APS frame: {aps_frame:?}"); + + let aps_payload = aps_frame.to_bytes(); + let nwk_frame = self + .nwk_data_frame(BROADCAST_RX_ON_WHEN_IDLE, aps_payload)? + .with_discover_route(NwkRouteDiscovery::Enable) + .with_radius(cmp::max(radius, 1)); + + let (handle, slot) = SendHandle::new(); + self.send_broadcast(Broadcast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + policy: TxPolicy { + priority, + class: TrafficClass::Host, + }, + slot: Some(slot), + })?; + Ok(handle) } /// The APS-ack timeout reactor: sleeps to the earliest pending send's deadline, then @@ -442,31 +463,37 @@ impl ZigbeeStack { .min() } + /// Expire pending APS acks: an entry past its deadline resolves `delivered` with a + /// timeout, and a cancelled entry with `Cancelled`. A stale entry whose send already + /// failed at handoff still expires here; its slot is already resolved, so the late + /// write is ignored (write-once, rule 1). fn expire_aps_acks(&self) { let now = self.core_now(); - let expired: Vec = { + let due: Vec<(Arc, bool)> = { let mut pending = self.state.pending_aps_acks.lock(); - let due: Vec<(ApsAckData, RequestId)> = pending + let due: Vec<(ApsAckData, Arc, bool)> = pending .iter() - .filter(|(_, p)| p.deadline <= now) - .map(|(key, p)| (key.clone(), p.request_id)) + .filter(|(_, p)| p.deadline <= now || p.slot.is_cancelled()) + .map(|(key, p)| (key.clone(), p.slot.clone(), p.slot.is_cancelled())) .collect(); - for (key, _) in &due { + for (key, _, _) in &due { pending.remove(key); } drop(pending); - due.into_iter().map(|(_, request_id)| request_id).collect() + due.into_iter() + .map(|(_, slot, cancelled)| (slot, cancelled)) + .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(), - }, - }); + for (slot, cancelled) in due { + let result = if cancelled { + Err(DeliveryError::Cancelled) + } else { + Err(DeliveryError::ApsAckTimeout) + }; + + slot.resolve(TrackStage::Delivery, result); } } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 3f14123..d053dbe 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -4,7 +4,7 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Iee use alloc::boxed::Box; use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::RadioPhy; +use ziggurat_phy::{RadioError, RadioPhy}; use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; @@ -12,8 +12,8 @@ use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, TxOutcome, - TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + DeliveryError, DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, + TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, }; impl ZigbeeStack { @@ -99,8 +99,7 @@ impl ZigbeeStack { for (destination, transaction) in outcome.expired { self.resolve_outcome( transaction.completion, - None, - Err(ZigbeeStackError::IndirectExpired { destination }), + Err(DeliveryError::IndirectExpired { destination }), ); } @@ -177,8 +176,7 @@ impl ZigbeeStack { for (destination, transaction) in dropped { self.resolve_outcome( transaction.completion, - None, - Err(ZigbeeStackError::IndirectExpired { destination }), + Err(DeliveryError::IndirectExpired { destination }), ); } @@ -250,7 +248,7 @@ impl ZigbeeStack { /// Replace the RCP source address match table with the addresses of every device /// that has queued indirect transactions. - pub(super) async fn write_src_match_table(&self) -> Result<(), ZigbeeStackError> { + pub(super) async fn write_src_match_table(&self) -> Result<(), RadioError> { let table = { let core = self.core(); @@ -316,8 +314,7 @@ impl ZigbeeStack { tracing::warn!("Indirect transaction to {destination:?} expired without a poll"); self.resolve_outcome( transaction.completion, - None, - Err(ZigbeeStackError::IndirectExpired { destination }), + Err(DeliveryError::IndirectExpired { destination }), ); } @@ -337,9 +334,7 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept so that the // device can rejoin later (mirrors `handle_leave`) self.drop_indirect_transactions(Some(eui64), nwk); - if self.core().nib.routing.remove_route(nwk) { - self.push_notification(ZigbeeNotification::RouteRemoved { destination: nwk }); - } + self.core().nib.routing.remove_route(nwk); self.push_notification(ZigbeeNotification::DeviceLeft { nwk, diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 4a90f43..0446456 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -35,9 +35,10 @@ use ziggurat_zigbee::nwk::commands::{ }; use super::{ - AddrConflictSource, DeviceLeaveReason, IndirectFrame, IndirectPayload, JoinKind, NwkDeviceType, - NwkSecurityMode, RadioPhy, RouteDirective, SendMode, TxOutcome, TxPolicy, TxPriority, - ZigbeeNotification, ZigbeeStack, neighbors, + AddrConflictSource, Broadcast, DeviceLeaveReason, EnqueueError, IndirectFrame, IndirectPayload, + JoinKind, NwkDeviceType, NwkSecurityMode, RadioPhy, RouteDirective, SendHandle, SendMode, + TrackStage, TxOutcome, TxPolicy, TxPriority, Unicast, ZigbeeNotification, ZigbeeStack, + neighbors, }; impl ZigbeeStack { @@ -124,43 +125,109 @@ impl ZigbeeStack { // A new child deadline may precede everything the maintenance task knows self.maintenance_wake.notify_one(); - self.queue_association_response( - source_eui64, + // The rest of the join awaits its way through the remaining steps; every + // in-flight join multiplexes onto the one tasklet runner. + let stack = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.tasklets.push(async move { + stack.run_join(source_eui64, short_address).await; + }); + } + + /// The join follow-up flow (spec 4.6.3.2), one tasklet per joiner: the joiner polls + /// the association response out of the indirect queue, confirming its short + /// address; the network key follows; the join is announced once the key transport + /// lands. + async fn run_join(&self, eui64: Eui64, short_address: Nwk) { + let Some(response) = self.queue_association_response( + eui64, short_address, Ieee802154AssociationStatus::AssociationSuccessful, - ); + ) else { + return; + }; + + // A retried association drops the stale response, failing this await: this + // tasklet ends and the retry's fresh tasklet takes over. + if let Err(err) = response.delivered().await { + tracing::warn!("Association response to {eui64:?} was not extracted: {err}"); + return; + } + + self.deliver_key_and_announce(short_address, eui64, JoinKind::New) + .await; + } + + /// Deliver the network key and, once the transport lands, announce the join. The + /// shared tail of the association and unsecured-rejoin flows. + async fn deliver_key_and_announce(&self, nwk: Nwk, eui64: Eui64, join_kind: JoinKind) { + let Some(key_transport) = self.send_network_key(nwk, eui64, join_kind) else { + return; + }; + + if let Err(err) = key_transport.delivered().await { + tracing::warn!( + "Network key transport to {eui64:?} was not delivered ({err}); \ + the device will retry" + ); + return; + } + + self.announce_join(nwk, eui64); + } + + /// Emit the `DeviceJoined` notification for a device whose key material is in place. + fn announce_join(&self, nwk: Nwk, eui64: Eui64) { + let (device_type, rx_on_when_idle) = self.device_join_capability(eui64); + + self.push_notification(ZigbeeNotification::DeviceJoined { + nwk, + ieee: eui64, + parent: self.state.network_address, + device_type, + rx_on_when_idle, + }); } /// 802.15.4 spec 6.4.1: association responses are sent indirectly. The joiner - /// extracts the queued response by polling with a MAC Data Request; once the - /// response is extracted and acknowledged, the network key follows (delivered by - /// the resolution of the queued [`TxOutcome::DeliverNetworkKey`]). + /// extracts the queued response by polling with a MAC Data Request. For a + /// successful association the returned handle's `delivered` is that extraction; + /// a denial (or an exhausted frame budget) returns `None`. fn queue_association_response( &self, eui64: Eui64, short_address: Nwk, status: Ieee802154AssociationStatus, - ) { + ) -> Option { // Joiners that miss the response retry the association request, so anything // still queued from the previous attempt is stale self.drop_indirect_transactions(Some(eui64), short_address); let Some(token) = frame_token::take(TrafficClass::Critical) else { tracing::warn!("Frame budget exhausted; dropping association response to {eui64:?}"); - return; + return None; }; let response_frame = self.build_802154_association_response(eui64, short_address, status); - // A denial has no follow-up; a successful join hands the joiner its network - // key once the response is extracted (spec 4.6.3.2). - let outcome = if matches!(status, Ieee802154AssociationStatus::AssociationSuccessful) { - TxOutcome::DeliverNetworkKey { - nwk: short_address, - eui64, + // A denial has no follow-up; a successful join's tasklet awaits the + // extraction. A raw MAC frame has no later verdict: extraction is delivery. + let (handle, outcome) = match status { + Ieee802154AssociationStatus::AssociationSuccessful => { + let (handle, slot) = SendHandle::new(); + + ( + Some(handle), + TxOutcome::Track { + slot, + stage: TrackStage::Delivery, + }, + ) } - } else { - TxOutcome::Discard + _ => (None, TxOutcome::Discard), }; self.enqueue_indirect_frame( @@ -171,6 +238,8 @@ impl ZigbeeStack { }, outcome, ); + + handle } /// Pick an unused random network address for a joining device, reusing the previous @@ -248,16 +317,9 @@ impl ZigbeeStack { // Routers resolve their own conflicts after hearing the notification; our // mapping for the address is ambiguous until the keeper re-announces - let removed = { - let mut core = self.core(); - core.nib.address_map.forget_address(address); - core.nib.routing.remove_route(address) - }; - if removed { - self.push_notification(ZigbeeNotification::RouteRemoved { - destination: address, - }); - } + let mut core = self.core(); + core.nib.address_map.forget_address(address); + core.nib.routing.remove_route(address); } /// The address-conflict report reactor: a single long-lived task owning the @@ -328,15 +390,17 @@ impl ZigbeeStack { // The retransmit reactor owns the rebroadcasts; the jitter was applied by // the report deadline, and the cancel-if-already-reported check above. - self.send_broadcast_nwk_frame( - conflict_frame, - NwkSecurityMode::NetworkKey, - TxPolicy { + if let Err(err) = self.send_broadcast(Broadcast { + frame: conflict_frame, + security: NwkSecurityMode::NetworkKey, + policy: TxPolicy { priority: TxPriority::UserNormal, class: TrafficClass::Critical, }, - None, - ); + slot: None, + }) { + tracing::warn!("Failed to broadcast address conflict report: {err}"); + } } } @@ -485,30 +549,40 @@ impl ZigbeeStack { /// Zigbee spec 4.6.3.2: deliver the network key to a joining device. The NWK frame /// is unsecured; the APS command is encrypted with the key-transport key derived - /// from the joiner's link key. - pub(super) fn send_network_key( + /// from the joiner's link key. `None` when the send is rejected at enqueue. + fn send_network_key( &self, destination: Nwk, destination_eui64: Eui64, join_kind: JoinKind, - ) { + ) -> Option { let encrypted_command = self.build_encrypted_network_key_transport(destination_eui64, join_kind); let nwk_frame = self .nwk_data_frame(destination, encrypted_command.to_bytes()) + .expect("Transport-Key command should always fit") .unsecured(); - self.background_send_nwk_frame(nwk_frame, NwkSecurityMode::Unsecured, SendMode::Direct); + let (handle, slot) = SendHandle::new(); + + // A direct one-hop unicast without an APS ack: next-hop acceptance is the + // delivery verdict. + if let Err(err) = self.send_unicast(Unicast { + frame: nwk_frame, + security: NwkSecurityMode::Unsecured, + mode: SendMode::Direct, + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Track { + slot, + stage: TrackStage::Delivery, + }, + }) { + tracing::warn!("Failed to send network key transport to {destination_eui64:?}: {err}"); + return None; + } - let (device_type, rx_on_when_idle) = self.device_join_capability(destination_eui64); - self.push_notification(ZigbeeNotification::DeviceJoined { - nwk: destination, - ieee: destination_eui64, - parent: self.state.network_address, - device_type, - rx_on_when_idle, - }); + Some(handle) } pub fn handle_encrypted_aps_command_frame( @@ -615,22 +689,28 @@ 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) { + fn send_secured_aps_payload( + &self, + destination: Nwk, + payload: Vec, + ) -> Result<(), EnqueueError> { // 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) + .nwk_data_frame(destination, payload)? .with_discover_route(NwkRouteDiscovery::Enable); - self.background_send_nwk_frame( - nwk_frame, - NwkSecurityMode::NetworkKey, - if self.is_neighbor(destination) { + self.send_unicast(Unicast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + mode: if self.is_neighbor(destination) { SendMode::Direct } else { SendMode::Route(RouteDirective::StackDecides) }, - ); + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }) } /// Zigbee spec 4.7.3.8: a device requests a unique trust center link key to replace @@ -737,7 +817,9 @@ impl ZigbeeStack { return; }; - self.send_secured_aps_payload(nwk_frame.nwk_header.source, encrypted_command.to_bytes()); + self.send_secured_aps_payload(nwk_frame.nwk_header.source, encrypted_command.to_bytes()) + .map_err(|err| tracing::warn!("Failed to send transport key to {source_ieee:?}: {err}")) + .ok(); } /// Zigbee spec 4.4.8.1: a device proves possession of its new link key by sending a @@ -854,7 +936,11 @@ impl ZigbeeStack { confirm_key_command.to_bytes() }; - self.send_secured_aps_payload(destination, payload); + self.send_secured_aps_payload(destination, payload) + .map_err(|err| { + tracing::warn!("Failed to send confirm key to {destination_eui64:?}: {err}") + }) + .ok(); } /// Zigbee spec 4.6.3.2.2: a router notifies us that a device joined (or rejoined) @@ -1012,7 +1098,7 @@ impl ZigbeeStack { }), }; - self.send_secured_aps_payload(router_nwk, tunnel_command.to_bytes()); + self.send_secured_aps_payload(router_nwk, tunnel_command.to_bytes()).map_err(|err| tracing::warn!("Failed to send tunneled network key to {device_eui64:?} via {router_nwk:?}: {err}")).ok(); } /// Process the rare NWK frames that arrive without encryption. The only one we @@ -1155,18 +1241,19 @@ impl ZigbeeStack { ); return; } - // `send_network_key` also emits the join notification - self.send_network_key(assigned_nwk, source_ieee, JoinKind::Rejoin); - } else { - let (device_type, rx_on_when_idle) = self.device_join_capability(source_ieee); - - self.push_notification(ZigbeeNotification::DeviceJoined { - nwk: assigned_nwk, - ieee: source_ieee, - parent: self.state.network_address, - device_type, - rx_on_when_idle, + // The join is announced only once the key transport lands + let stack = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.tasklets.push(async move { + stack + .deliver_key_and_announce(assigned_nwk, source_ieee, JoinKind::Rejoin) + .await; }); + } else { + self.announce_join(assigned_nwk, source_ieee); } } @@ -1199,7 +1286,15 @@ impl ZigbeeStack { }; // The rejoining device is within radio range - self.background_send_nwk_frame(response_frame, security, SendMode::Direct); + self.send_unicast(Unicast { + frame: response_frame, + security, + mode: SendMode::Direct, + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }) + .map_err(|err| tracing::warn!("Failed to send rejoin response: {err}")) + .ok(); } /// Zigbee spec 3.6.1.10.3: a device announces that it is leaving the network, or @@ -1230,11 +1325,7 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept around so that the // device can rejoin later self.drop_indirect_transactions(source_ieee, source); - if self.core().nib.routing.remove_route(source) { - self.push_notification(ZigbeeNotification::RouteRemoved { - destination: source, - }); - } + self.core().nib.routing.remove_route(source); self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, @@ -1313,11 +1404,16 @@ impl ZigbeeStack { // The child is a direct neighbor; responses to sleepy children go through the // indirect queue via the NWK unicast fork - self.background_send_nwk_frame( - response_frame, - NwkSecurityMode::NetworkKey, - SendMode::Direct, - ); + let send = Unicast { + frame: response_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Direct, + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }; + if let Err(err) = self.send_unicast(send) { + tracing::warn!("Failed to send end device timeout response: {err}"); + } } /// Open (or close, with `duration == 0`) the join window. The trust center diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 14d076f..47947c5 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -20,8 +20,8 @@ use ziggurat_zigbee::nwk::frame::{ use crate::frame_token::TrafficClass; use super::{ - NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxOutcome, TxPolicy, TxPriority, - ZigbeeStack, ZigbeeStackError, + DeliveryError, NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxOutcome, TxPolicy, + TxPriority, ZigbeeStack, }; /// Spacing between sprayed beacons while a [`hack_beacon_spam_duration`] window is open. @@ -371,7 +371,7 @@ impl ZigbeeStack { pub(super) async fn send_802154_frame( &self, frame: Ieee802154Frame, - ) -> Result<(), ZigbeeStackError> { + ) -> Result<(), DeliveryError> { // Increment the 802.15.4 sequence number let final_frame = if !frame.header().frame_control.sequence_number_suppression { // Hold the lock for the shortest time possible @@ -436,11 +436,9 @@ impl ZigbeeStack { 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)), + .map_or(Ok(()), |next_hop| Err(DeliveryError::NwkNoAck { next_hop })), + TxResult::ChannelAccessFailure => Err(DeliveryError::CcaFailure), + other => Err(DeliveryError::TransmitFailed(other)), } } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index 5892afe..c14dacc 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -8,7 +8,9 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFram use crate::frame_token::TrafficClass; -use super::{NwkSecurityMode, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack}; +use super::{ + Broadcast, NwkSecurityMode, SendHandle, TrackStage, TxPolicy, TxPriority, ZigbeeStack, +}; /// Maximum number of link status entries that can be carried in a single frame. const MAX_LINK_STATUSES: usize = 7; @@ -33,9 +35,7 @@ impl ZigbeeStack { tracing::info!("Child {eui64:?} ({nwk:?}) is now parented by {new_parent:?}"); self.drop_indirect_transactions(Some(eui64), nwk); - if self.core().nib.routing.remove_route(nwk) { - self.push_notification(ZigbeeNotification::RouteRemoved { destination: nwk }); - } + self.core().nib.routing.remove_route(nwk); } /// Drop our child entry for a device known to have attached to another parent. @@ -93,12 +93,16 @@ impl ZigbeeStack { self.link_status_received.notify_one(); } - pub async fn send_link_status_broadcast(&self, empty: bool) { + /// Broadcast the link status notification. + pub fn send_link_status_broadcast(&self, empty: bool) -> SendHandle { tracing::debug!("Sending periodic link status broadcast"); + let (handle, slot) = SendHandle::new(); + if self.state.network_address == Nwk(0xFFFF) { tracing::debug!("Skipping, stack has not been initialized yet"); - return; + slot.resolve(TrackStage::Delivery, Ok(())); + return handle; } // Decrement the `recent_activity` field of every active routing table entry @@ -140,43 +144,50 @@ impl ZigbeeStack { link_statuses: link_statuses[start..end].to_vec(), }), ) - .with_radius(1) - // Sent via `transmit_*`, which does not assign sequence numbers - .with_sequence_number(self.next_nwk_sequence_number()); + .with_radius(1); + + // The last frame carries the tracking slot; earlier chunks are + // fire-and-forget. They drain the single sender in enqueue order, so the + // last frame's handoff implies the whole batch was transmitted. + let is_last = end == total; // Spec 3.6.4.4.1: link statuses are one-hop broadcasts sent without // retries. Nobody relays a radius-1 frame, so the passive ack machinery // of the regular broadcast path could never complete for them anyway. - if let Err(err) = self - .transmit_broadcast_nwk_frame( - link_status_frame, - NwkSecurityMode::NetworkKey, - // Housekeeping the mesh depends on: last to transmit, but never - // memory-starved by a host flood - TxPolicy { - priority: TxPriority::Background, - class: TrafficClass::Critical, - }, - ) - .await - { - tracing::warn!("Failed to broadcast link status: {err}"); - } - - if end == total { + self.send_oneshot_broadcast(Broadcast { + frame: link_status_frame, + security: NwkSecurityMode::NetworkKey, + // Housekeeping the mesh depends on: last to transmit, but never + // memory-starved by a host flood + policy: TxPolicy { + priority: TxPriority::Background, + class: TrafficClass::Critical, + }, + slot: is_last.then(|| slot.clone()), + }); + + if is_last { break; } // Repeat the boundary entry as the first of the next frame start = end - 1; } + + handle } pub async fn periodic_link_status_broadcast_task(&self) { + let mut next = self.core_now() + self.tunables.link_status_period(); + loop { - R::sleep(self.tunables.link_status_period()).await; + self.sleep_until_core(next).await; + + // The link status broadcast has a radius of 1 so delivery does not wait for + // a relay quorum. + let _ = self.send_link_status_broadcast(false).delivered().await; - self.send_link_status_broadcast(false).await; + next = next + self.tunables.link_status_period(); } } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 3926aa9..89bfc13 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1,19 +1,18 @@ +use crate::broadcast_budget::BroadcastAdmission; use crate::frame_token::{self, FrameToken, TrafficClass}; use crate::runtime::{Elapsed, Runtime}; -use crate::signal; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, Ieee802154FrameVersion, }; use alloc::boxed::Box; -use alloc::collections::BinaryHeap; -use alloc::string::ToString; +use alloc::sync::Arc; 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, TxResult}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{ NwkCommand, NwkCommandId, NwkEndDeviceTimeoutResponseStatus, NwkNetworkStatus, @@ -26,12 +25,13 @@ use ziggurat_zigbee::nwk::frame::{ NwkSecurityLevel, NwkSourceRoute, }; -use super::routing::{Route, RouteUpdate, Status as RouteStatus}; +use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, BroadcastSchedule, HostRoute, IndirectFrame, IndirectPayload, JoinKind, - MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, - PendingUnicastRetry, RequestId, RouteDirective, SendKind, SendMode, SendRequest, SendResult, - TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + AddrConflictSource, Broadcast, BroadcastSchedule, DeliveryError, EnqueueError, HostRoute, + IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, + PendingFrame, PendingRoute, PendingUnicastRetry, RouteDirective, SendKind, SendMode, + SendRequest, SendSlot, TrackStage, TxOutcome, TxPolicy, TxPriority, Unicast, + ZigbeeNotification, ZigbeeStack, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -151,11 +151,11 @@ impl ZigbeeStack { { 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 }, - }); + if let Some(PendingBroadcast { + slot: Some(slot), .. + }) = removed + { + slot.resolve(TrackStage::Delivery, Ok(())); } continue; } @@ -172,8 +172,9 @@ impl ZigbeeStack { #[allow(clippy::large_enum_variant)] enum Next { Idle, - Retransmit(NwkFrame, NwkSecurityMode, TxPolicy), - Exhausted(Option), + Retransmit(NwkFrame, NwkSecurityMode, TxPolicy, Option>), + Cancelled(Option>), + Exhausted(Option>), } // Decide under the lock; if a copy is due, extract it to transmit after release. @@ -183,12 +184,16 @@ impl ZigbeeStack { continue; }; - if broadcast.next_attempt > now { + if broadcast + .slot + .as_ref() + .is_some_and(|slot| slot.is_cancelled()) + { + Next::Cancelled(pending.remove(&key).unwrap().slot) + } else 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) + Next::Exhausted(pending.remove(&key).unwrap().slot) } else { broadcast.attempts_remaining -= 1; broadcast.next_attempt = next_attempt; @@ -196,38 +201,60 @@ impl ZigbeeStack { broadcast.nwk_frame.clone(), broadcast.security, broadcast.policy, + broadcast.slot.clone(), ) } }; match action { Next::Idle => {} - Next::Retransmit(nwk_frame, security, policy) => { + Next::Retransmit(nwk_frame, security, policy, slot) => { tracing::debug!("Retransmitting broadcast {key:?}"); + // Each on-air copy resolves the send's handoff; write-once means only + // the first successful one takes. self.enqueue_send( SendKind::Broadcast { nwk_frame, security, }, policy, - TxOutcome::Discard, + Self::broadcast_copy_outcome(slot), ); } - Next::Exhausted(request_id) => { + Next::Cancelled(slot) => { + tracing::debug!("Broadcast {key:?} cancelled"); + if let Some(slot) = slot { + slot.resolve(TrackStage::Delivery, Err(DeliveryError::Cancelled)); + } + } + Next::Exhausted(slot) => { 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(), - }, - }); + // A fixed-interval broadcast (a route request) completes by running + // out of attempts; a data broadcast fails its quorum. + if let Some(slot) = slot { + let result = match schedule { + BroadcastSchedule::PassiveAck => { + Err(DeliveryError::BroadcastQuorumNotReached) + } + BroadcastSchedule::FixedInterval { .. } => Ok(()), + }; + slot.resolve(TrackStage::Delivery, result); } } } } } + /// The outcome for one on-air broadcast copy: a tracked broadcast resolves its + /// `HandOff` stage (write-once, so only the first successful copy takes), an internal + /// one is fire-and-forget. + fn broadcast_copy_outcome(slot: Option>) -> TxOutcome { + slot.map_or(TxOutcome::Discard, |slot| TxOutcome::Track { + slot, + stage: TrackStage::HandOff, + }) + } + /// Insert a broadcast into the pending-retransmit map and wake the reactor. #[allow(clippy::too_many_arguments)] fn schedule_broadcast( @@ -239,34 +266,17 @@ impl ZigbeeStack { schedule: BroadcastSchedule, first_delay: Duration, attempts: u8, - request_id: Option, + slot: Option>, + // Caller-taken, held by the retained copy for the whole retransmit schedule + token: FrameToken, ) { - // 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() { + // A tracked broadcast is scheduled even at zero retransmits, so the reactor can + // resolve its quorum (or fail it); an untracked one with nothing to retransmit is + // a no-op. + if attempts == 0 && slot.is_none() { return; } - // The retained copy holds a budget token for its whole retransmit schedule; - // each transmitted copy takes its own at enqueue. - let Some(token) = frame_token::take(policy.class) else { - tracing::warn!( - "Frame budget exhausted ({}/{} tokens); dropping {:?} broadcast retransmissions", - frame_token::used(), - frame_token::total(), - policy.class, - ); - if let Some(request_id) = request_id { - self.push_notification(ZigbeeNotification::SendConfirm { - request_id, - result: SendResult::Failed { - reason: ZigbeeStackError::FrameBudgetExhausted.to_string(), - }, - }); - } - return; - }; - self.state.pending_broadcasts.lock().insert( key, PendingBroadcast { @@ -276,7 +286,7 @@ impl ZigbeeStack { schedule, attempts_remaining: attempts, next_attempt: self.core_now() + first_delay, - request_id, + slot, _token: token, }, ); @@ -289,6 +299,11 @@ impl ZigbeeStack { attempts: u8, initial_delay: Duration, ) { + let Some(token) = frame_token::take(TrafficClass::Critical) else { + tracing::warn!("Frame budget exhausted; dropping route request retransmissions"); + return; + }; + let key = ( nwk_frame.nwk_header.source, nwk_frame.nwk_header.sequence_number, @@ -308,6 +323,7 @@ impl ZigbeeStack { initial_delay, attempts, None, + token, ); } @@ -460,23 +476,6 @@ impl ZigbeeStack { } } - /// Notify the client of an established or re-pointed route so it can update its - /// warm-start next-hop cache in real time. - pub(crate) fn notify_route_update(&self, update: Option) { - if let Some(RouteUpdate { - destination, - next_hop, - path_cost, - }) = update - { - self.push_notification(ZigbeeNotification::RouteChanged { - destination, - next_hop, - path_cost, - }); - } - } - /// A NWK command frame originated by us, with stack-wide defaults: secured, route /// discovery suppressed, radius `2 * max_depth`, sequence number assigned on send, /// our EUI64 as the extended source. Deviations chain `with_*` overrides. @@ -515,8 +514,12 @@ impl ZigbeeStack { /// A NWK data frame originated by us; same defaults as [`Self::nwk_command_frame`] /// except data frames carry no extended source. - pub(super) fn nwk_data_frame(&self, destination: Nwk, payload: Vec) -> NwkFrame { - NwkFrame { + pub(super) fn nwk_data_frame( + &self, + destination: Nwk, + payload: Vec, + ) -> Result { + Ok(NwkFrame { nwk_header: NwkHeader { frame_control: NwkFrameControl { frame_type: NwkFrameType::Data, @@ -541,51 +544,30 @@ impl ZigbeeStack { }, aux_header: None, // Applied at encryption time payload: NwkPayload::Opaque( - FrameBytes::from_slice(&payload).expect("NWK payload is frame-bounded"), + FrameBytes::from_slice(&payload).map_err(|_| EnqueueError::PayloadTooLong)?, ), - } + }) } - /// 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, - mode: SendMode, - ) { + /// Send 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). + /// `Err` rejects at enqueue without consuming `outcome`; on `Ok` the outcome + /// resolves with the delivery result. + pub(super) fn send_unicast(&self, send: Unicast) -> Result<(), EnqueueError> { + let Unicast { + frame: mut nwk_frame, + security, + mode, + policy, + outcome, + } = send; debug_assert!( nwk_frame.nwk_header.destination.as_u16() < BROADCAST_LOW_POWER_ROUTERS.as_u16(), - "background_send_nwk_frame is unicast only; got broadcast {:?}", + "send_unicast is unicast only; got broadcast {:?}", nwk_frame.nwk_header.destination ); - // Everything sent through here is stack machinery answering protocol events - // (key transports, rejoin responses, APS acks, network status reports): - // critical for both transmit scheduling and the frame budget. - self.originate_unicast( - nwk_frame, - security, - mode, - TxPolicy { - priority: TxPriority::StackCritical, - class: TrafficClass::Critical, - }, - 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, - policy: TxPolicy, - outcome: TxOutcome, - ) { // The token is taken here, at the frame's entry into the stack's ownership, so // that route-discovery parking is covered by the budget too. It travels with // the frame through every queue until its terminal outcome. @@ -596,11 +578,9 @@ impl ZigbeeStack { frame_token::total(), policy.class, ); - self.resolve_outcome(outcome, None, Err(ZigbeeStackError::FrameBudgetExhausted)); - return; + return Err(EnqueueError::BudgetExhausted); }; - 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) { @@ -613,19 +593,18 @@ impl ZigbeeStack { outcome, token, ); + Ok(()) } NextHop::NeedDiscovery => { - self.enqueue_awaiting_route(nwk_frame, security, policy.priority, outcome, token) + self.enqueue_awaiting_route(nwk_frame, security, policy.priority, outcome, token); + Ok(()) } NextHop::Discard => { + let destination = nwk_frame.nwk_header.destination; tracing::debug!( "Dropping frame to {destination:?}: no route and discovery suppressed" ); - self.resolve_outcome( - outcome, - None, - Err(ZigbeeStackError::RouteDiscoverySuppressed), - ); + Err(EnqueueError::RouteDiscoverySuppressed) } } } @@ -705,24 +684,6 @@ impl ZigbeeStack { NextHop::Resolved(next_hop) } - pub async fn send_nwk_frame( - &self, - nwk_frame: NwkFrame, - security: NwkSecurityMode, - mode: SendMode, - policy: TxPolicy, - ) -> Result<(), ZigbeeStackError> { - if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { - // 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, policy, None); - Ok(()) - } else { - self.send_unicast_nwk_frame(nwk_frame, security, mode, policy) - .await - } - } - pub(super) fn next_nwk_sequence_number(&self) -> u8 { let mut core = self.core(); core.nib.sequence_number = core.nib.sequence_number.wrapping_add(1); @@ -798,31 +759,6 @@ 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, - nwk_frame: NwkFrame, - security: NwkSecurityMode, - mode: SendMode, - policy: TxPolicy, - ) -> Result<(), ZigbeeStackError> { - let (completion_tx, completion_rx) = signal::channel(); - self.originate_unicast( - nwk_frame, - security, - mode, - policy, - 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 /// number is assigned at transmit time. fn build_unicast_802154_data_frame( @@ -885,7 +821,7 @@ impl ZigbeeStack { frame_token::total(), policy.class, ); - self.resolve_outcome(outcome, None, Err(ZigbeeStackError::FrameBudgetExhausted)); + self.resolve_outcome(outcome, Err(DeliveryError::BudgetExhausted)); return; }; @@ -960,20 +896,6 @@ impl ZigbeeStack { ); } - /// Push a frame for the sender task and await its transmit result. - pub(super) async fn send( - &self, - kind: SendKind, - policy: TxPolicy, - ) -> Result<(), ZigbeeStackError> { - let (completion_tx, completion_rx) = signal::channel(); - self.enqueue_send(kind, policy, TxOutcome::Signal(completion_tx)); - completion_rx - .wait() - .await - .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) - } - /// Enqueue a unicast awaiting a route and start discovery if necessary. fn enqueue_awaiting_route( &self, @@ -1117,11 +1039,7 @@ impl ZigbeeStack { self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome, token); } NextHop::NeedDiscovery | NextHop::Discard => { - self.resolve_outcome( - outcome, - None, - Err(ZigbeeStackError::RouteInactiveAfterDiscovery), - ); + self.resolve_outcome(outcome, Err(DeliveryError::RouteInactiveAfterDiscovery)); } } } @@ -1161,8 +1079,7 @@ impl ZigbeeStack { for PendingFrame { outcome, .. } in frames { self.resolve_outcome( outcome, - None, - Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed)), + Err(DeliveryError::RouteDiscoveryTimeout(Elapsed)), ); } } @@ -1192,6 +1109,13 @@ impl ZigbeeStack { .. } = request; + // A send cancelled while queued never reaches the air; its token frees + // as `token` drops here. + if Self::outcome_cancelled(&outcome) { + self.resolve_outcome(outcome, Err(DeliveryError::Cancelled)); + continue; + } + match *kind { SendKind::Unicast { nwk_frame, @@ -1217,11 +1141,11 @@ impl ZigbeeStack { security, } => { let result = self.process_broadcast_send(nwk_frame, security).await; - self.resolve_outcome(outcome, None, result); + self.resolve_outcome(outcome, result); } SendKind::Raw { frame } => { let result = self.send_802154_frame(frame).await; - self.resolve_outcome(outcome, None, result); + self.resolve_outcome(outcome, result); } } } @@ -1232,42 +1156,14 @@ impl ZigbeeStack { /// 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>, - ) { + pub(super) fn resolve_outcome(&self, outcome: TxOutcome, result: Result<(), DeliveryError>) { 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); - } - self.push_notification(ZigbeeNotification::SendConfirm { - request_id, - result: SendResult::Failed { - reason: err.to_string(), - }, - }); - } - }, + TxOutcome::Track { slot, stage } => slot.resolve(stage, result), TxOutcome::IndirectDelivery { destination, transaction, @@ -1286,18 +1182,10 @@ impl ZigbeeStack { } _ => { let transaction = *transaction; - self.resolve_outcome(transaction.completion, None, result); + self.resolve_outcome(transaction.completion, result); self.remove_indirect_queue_if_empty(destination); } }, - TxOutcome::DeliverNetworkKey { nwk, eui64 } => match result { - // Zigbee spec 4.6.3.2: the network key is delivered once the device - // has confirmed receipt of its short address - Ok(()) => self.send_network_key(nwk, eui64, JoinKind::New), - Err(err) => { - tracing::warn!("Association response to {eui64:?} was not extracted: {err}"); - } - }, } } @@ -1339,14 +1227,14 @@ impl ZigbeeStack { self.increment_tx_total(); let Err(e) = self.send_802154_frame(ieee802154_frame).await else { - self.resolve_outcome(outcome, Some(next_hop_address), Ok(())); + self.resolve_outcome(outcome, Ok(())); return; }; // 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 { + if let DeliveryError::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); @@ -1358,7 +1246,7 @@ impl ZigbeeStack { 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)); + self.resolve_outcome(outcome, Err(e)); return; } @@ -1451,7 +1339,9 @@ impl ZigbeeStack { let mut due = Vec::new(); let mut i = 0; while i < pending.len() { - if pending[i].next_attempt <= now { + // A retry that is due, or cancelled, is pulled: due ones re-enqueue, + // cancelled ones resolve below (freeing their token early). + if pending[i].next_attempt <= now || Self::outcome_cancelled(&pending[i].outcome) { // Order does not matter (the priority queue reorders anyway), so an // O(1) swap-remove is fine. due.push(pending.swap_remove(i)); @@ -1465,6 +1355,10 @@ impl ZigbeeStack { }; for retry in due { + if Self::outcome_cancelled(&retry.outcome) { + self.resolve_outcome(retry.outcome, Err(DeliveryError::Cancelled)); + continue; + } self.enqueue_send_with_token( SendKind::Unicast { nwk_frame: retry.nwk_frame, @@ -1479,106 +1373,22 @@ impl ZigbeeStack { } } - /// Best-effort cancellation of an application send by the `request_id` it was - /// issued under. Tears the send out of whichever pre-delivery queue still holds - /// it. - pub fn cancel_send(&self, request_id: RequestId) -> bool { - self.cancel_queued_send(request_id) - || self.cancel_pending_route(request_id) - || self.cancel_pending_unicast_retry(request_id) - || self.cancel_pending_broadcast(request_id) - } - - /// Whether a transmit outcome is the confirmation of `request_id`. - const fn confirms_request(outcome: &TxOutcome, request_id: RequestId) -> bool { - matches!(outcome, TxOutcome::Confirm { request_id: rid, .. } if *rid == request_id) + /// Whether this outcome belongs to a cancelled send. Only a tracked send carries a + /// slot, and only a tracked send can be cancelled; every reactor checks this before + /// acting on the entry it holds, dropping it and resolving `Err(Cancelled)` if set. + fn outcome_cancelled(outcome: &TxOutcome) -> bool { + matches!(outcome, TxOutcome::Track { slot, .. } if slot.is_cancelled()) } - /// Remove a send still waiting in the sender priority queue (not yet transmitted). - fn cancel_queued_send(&self, request_id: RequestId) -> bool { - let mut queue = self.send_queue.lock(); - if !queue - .iter() - .any(|send| Self::confirms_request(&send.outcome, request_id)) - { - return false; - } - - // The heap has no keyed removal, so drain, drop the one match (freeing its - // token), and rebuild from the rest. Cancellation is rare, so the rebuild is - // fine. - let mut removed = false; - let kept: Vec = queue - .drain() - .filter(|send| { - let drop_it = !removed && Self::confirms_request(&send.outcome, request_id); - removed |= drop_it; - !drop_it - }) - .collect(); - *queue = BinaryHeap::from(kept); - true - } - - /// Remove a frame parked awaiting route discovery. When it was the last frame - /// waiting on that destination, drop the empty bucket so the reactor stops - /// inspecting it; the shared route request (critical-class, a fixed short burst) - /// and the routing table's `DiscoveryUnderway` entry are left to run out / expire - /// on their own. - fn cancel_pending_route(&self, request_id: RequestId) -> bool { - let mut pending = self.state.pending_routes.lock(); - - let mut emptied = None; - let mut found = false; - - for (destination, bucket) in pending.iter_mut() { - let before = bucket.frames.len(); - bucket - .frames - .retain(|frame| !Self::confirms_request(&frame.outcome, request_id)); - - if bucket.frames.len() != before { - found = true; - - if bucket.frames.is_empty() { - emptied = Some(*destination); - } - - break; - } - } - - if let Some(destination) = emptied { - pending.remove(&destination); - } - found - } - - /// Remove a unicast parked in the retry backoff before its next attempt is due. - fn cancel_pending_unicast_retry(&self, request_id: RequestId) -> bool { - let mut pending = self.state.pending_unicast_retries.lock(); - - pending - .iter() - .position(|retry| Self::confirms_request(&retry.outcome, request_id)) - .is_some_and(|index| { - pending.swap_remove(index); - true - }) - } - - /// Remove a data/group broadcast still being retransmitted, stopping its remaining - /// transmissions. Route-request broadcasts carry no `request_id` and are never matched. - fn cancel_pending_broadcast(&self, request_id: RequestId) -> bool { - let mut pending = self.state.pending_broadcasts.lock(); - - pending - .iter() - .find_map(|(key, broadcast)| (broadcast.request_id == Some(request_id)).then_some(*key)) - .is_some_and(|key| { - pending.remove(&key); - true - }) + /// Nudge every reactor that parks a cancellable send, so a just-set cancel flag is + /// acted on this pass rather than at the reactor's next scheduled wake. Called after a + /// [`SendHandle::cancel`](super::SendHandle::cancel). + pub fn nudge_cancellation(&self) { + self.send_wake.notify_one(); + self.unicast_retry_wake.notify_one(); + self.broadcast_retransmit_wake.notify_one(); + self.pending_route_wake.notify_one(); + self.aps_ack_wake.notify_one(); } /// A unicast exhausted its retries at the sender. The next hop is dead: invalidate @@ -1610,7 +1420,7 @@ impl ZigbeeStack { /// queue, since a sleeping radio never hears the broadcast itself. The NWK source is /// 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( + fn maybe_fan_out_broadcast_to_sleepy_children( &self, nwk_frame: &NwkFrame, security: NwkSecurityMode, @@ -1656,24 +1466,45 @@ impl ZigbeeStack { } } - /// 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, - policy: TxPolicy, - // An application send awaiting confirmation on passive-ack quorum; internal - // broadcasts pass `None`. - request_id: Option, - ) { + /// Send a broadcast. + pub(super) fn send_broadcast(&self, send: Broadcast) -> Result<(), EnqueueError> { + let Broadcast { + frame: mut nwk_frame, + security, + policy, + slot, + } = send; + // Stack-critical broadcasts always pass; host- and forwarding-class broadcasts + // yield to the reserves when the bucket is drained. + let admission = self.broadcast_budget.lock().take( + policy.class, + self.core_now(), + self.tunables.broadcast_budget_tokens(), + self.tunables.broadcast_token_refill(), + self.tunables.broadcast_critical_reserve(), + self.tunables.broadcast_forwarding_reserve(), + ); + + if let BroadcastAdmission::Defer { retry_in } = admission { + return Err(EnqueueError::RateLimited { retry_in }); + } + + // The retained copy's token, held for the whole retransmit schedule. + let Some(token) = frame_token::take(policy.class) else { + tracing::warn!( + "Frame budget exhausted ({}/{} tokens); rejecting {:?} broadcast", + frame_token::used(), + frame_token::total(), + policy.class, + ); + return Err(EnqueueError::BudgetExhausted); + }; + nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); // Sleepy children never hear the over-the-air broadcast; queue a unicast copy // for each (spec 3.6.6). - self.fan_out_broadcast_to_sleepy_children(&nwk_frame, security, policy.class); + self.maybe_fan_out_broadcast_to_sleepy_children(&nwk_frame, security, policy.class); let key = ( nwk_frame.nwk_header.source, @@ -1695,16 +1526,18 @@ impl ZigbeeStack { ); } - // Transmit the first copy immediately; the reactor makes any retransmissions, - // each after an ack-collection window plus fresh jitter. + // Transmit the first copy immediately: a lost copy is not terminal, the reactor + // retransmits. The copy resolves the send's handoff (the first on-air copy wins); + // the reactor holds the slot and resolves the end-to-end quorum result. self.enqueue_send( SendKind::Broadcast { nwk_frame: nwk_frame.clone(), security, }, policy, - TxOutcome::Discard, + Self::broadcast_copy_outcome(slot.clone()), ); + self.schedule_broadcast( key, nwk_frame, @@ -1713,27 +1546,40 @@ impl ZigbeeStack { BroadcastSchedule::PassiveAck, self.tunables.passive_ack_timeout() + self.broadcast_jitter(), self.tunables.max_broadcast_retries(), - request_id, + slot, + token, ); + Ok(()) } - /// 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, - nwk_frame: NwkFrame, - security: NwkSecurityMode, - policy: TxPolicy, - ) -> Result<(), ZigbeeStackError> { - self.send( + /// Send a single-copy broadcast: assign its sequence number and queue one copy + /// for the sender task. + pub(super) fn send_oneshot_broadcast(&self, send: Broadcast) { + let Broadcast { + frame: mut nwk_frame, + security, + policy, + slot, + } = send; + nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); + + #[allow(clippy::option_if_let_else)] + let outcome = match slot { + Some(slot) => TxOutcome::Track { + slot, + stage: TrackStage::Delivery, + }, + None => TxOutcome::Discard, + }; + + self.enqueue_send( SendKind::Broadcast { nwk_frame, security, }, policy, - ) - .await + outcome, + ); } /// Encrypt and broadcast a single dequeued copy of a frame. @@ -1741,7 +1587,7 @@ impl ZigbeeStack { &self, mut nwk_frame: NwkFrame, security: NwkSecurityMode, - ) -> Result<(), ZigbeeStackError> { + ) -> Result<(), DeliveryError> { self.apply_nwk_aux_header(&mut nwk_frame, security); let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); @@ -1952,11 +1798,16 @@ impl ZigbeeStack { .with_destination_ieee(destination_ieee) .with_discover_route(NwkRouteDiscovery::Enable); - self.background_send_nwk_frame( - network_status_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route(RouteDirective::StackDecides), - ); + let send = Unicast { + frame: network_status_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Route(RouteDirective::StackDecides), + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }; + if let Err(err) = self.send_unicast(send) { + tracing::warn!("Failed to send network status report: {err}"); + } } /// Zigbee spec 3.6.6: re-broadcast a newly seen broadcast frame, preserving the @@ -1978,7 +1829,7 @@ impl ZigbeeStack { // Spec 3.6.6: deliver another device's 0xFFFF broadcast to our own sleepy // children as MAC unicasts (a no-op for non-0xFFFF destinations). - self.fan_out_broadcast_to_sleepy_children( + self.maybe_fan_out_broadcast_to_sleepy_children( nwk_frame, NwkSecurityMode::NetworkKey, TrafficClass::Forwarding, @@ -1997,6 +1848,11 @@ impl ZigbeeStack { relayed_frame.nwk_header.sequence_number, ); + let Some(token) = frame_token::take(TrafficClass::Forwarding) else { + tracing::warn!("Frame budget exhausted; not relaying broadcast {key:?}"); + return; + }; + // 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 @@ -2013,6 +1869,7 @@ impl ZigbeeStack { self.broadcast_jitter(), self.tunables.max_broadcast_retries() + 1, None, + token, ); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 4b8f85c..c1c9be0 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -15,8 +15,8 @@ use super::routing::RouteReplyDisposition; use crate::frame_token::TrafficClass; use super::{ - AddrConflictSource, NwkSecurityMode, SendMode, TxPolicy, TxPriority, ZigbeeNotification, - ZigbeeStack, + AddrConflictSource, Broadcast, NwkSecurityMode, SendHandle, SendMode, TxOutcome, TxPolicy, + TxPriority, Unicast, ZigbeeStack, }; impl ZigbeeStack { @@ -56,8 +56,6 @@ impl ZigbeeStack { updated_path_cost, ); - self.notify_route_update(outcome.update); - let (next_hop_nwk, path_cost) = match outcome.disposition { RouteReplyDisposition::Drop => return, RouteReplyDisposition::Established => { @@ -101,11 +99,15 @@ impl ZigbeeStack { .with_destination_ieee(Some(next_hop_link.eui64)); // The next hop toward the originator is a direct radio neighbor - self.background_send_nwk_frame( - relayed_route_reply_frame, - NwkSecurityMode::NetworkKey, - SendMode::Direct, - ); + self.send_unicast(Unicast { + frame: relayed_route_reply_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Direct, + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }) + .map_err(|err| tracing::warn!("Failed to relay route reply: {err}")) + .ok(); } #[allow(clippy::significant_drop_tightening)] @@ -159,8 +161,6 @@ impl ZigbeeStack { self.tunables.route_discovery_time(), ); - self.notify_route_update(outcome.update); - if !outcome.accepted { return; } @@ -202,11 +202,16 @@ impl ZigbeeStack { .with_destination_ieee(Some(sender_ieee)); // The next hop toward the originator is a direct radio neighbor - self.background_send_nwk_frame( - route_reply_frame, - NwkSecurityMode::NetworkKey, - SendMode::Direct, - ); + self.send_unicast(Unicast { + frame: route_reply_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Direct, + policy: TxPolicy::STACK_CRITICAL, + outcome: TxOutcome::Discard, + }) + .map_err(|err| tracing::warn!("Failed to send route reply: {err}")) + .ok(); + return; } @@ -267,7 +272,7 @@ impl ZigbeeStack { /// router records a path toward the concentrator. Devices can then reach us without /// per-device route discoveries, and respond with route record commands that we /// store for future source routing. - pub async fn send_many_to_one_route_request(&self) { + pub fn send_many_to_one_route_request(&self) -> SendHandle { let route_request_identifier = self.core().nib.routing.begin_many_to_one_advertisement( self.state.network_address, self.core_now(), @@ -287,24 +292,22 @@ impl ZigbeeStack { destination_eui64: None, }), ) - .with_radius(self.tunables.concentrator_radius()) - // Sent via `transmit_*`, which does not assign sequence numbers - .with_sequence_number(self.next_nwk_sequence_number()); + .with_radius(self.tunables.concentrator_radius()); + + let (handle, slot) = SendHandle::new(); // Many-to-one route requests are not retried (spec 3.6.4.5.1) - if let Err(err) = self - .transmit_broadcast_nwk_frame( - many_to_one_request_frame, - NwkSecurityMode::NetworkKey, - TxPolicy { - priority: TxPriority::Background, - class: TrafficClass::Critical, - }, - ) - .await - { - tracing::warn!("Failed to broadcast many-to-one route request: {err}"); - } + self.send_oneshot_broadcast(Broadcast { + frame: many_to_one_request_frame, + security: NwkSecurityMode::NetworkKey, + policy: TxPolicy { + priority: TxPriority::Background, + class: TrafficClass::Critical, + }, + slot: Some(slot), + }); + + handle } pub async fn periodic_many_to_one_route_request_task(&self) { @@ -328,18 +331,19 @@ impl ZigbeeStack { } loop { - self.send_many_to_one_route_request().await; - - self.core().nib.routing.reset_mtorr_triggers(); - let min_deadline = self.core_now() + self.tunables.mtorr_min_interval(); let max_deadline = self.core_now() + self.tunables.mtorr_max_interval(); + let _ = self.send_many_to_one_route_request().delivered().await; + + self.core().nib.routing.reset_mtorr_triggers(); + // 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 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 { @@ -408,8 +412,7 @@ impl ZigbeeStack { | NwkNetworkStatus::SourceRouteFailure => { let mut core = self.core(); - let removed_route = core - .nib + core.nib .routing .remove_route(network_status_cmd.network_address); @@ -424,15 +427,6 @@ impl ZigbeeStack { drop(core); - if removed_route { - tracing::info!( - "Removed failed route to {:?}", - network_status_cmd.network_address - ); - self.push_notification(ZigbeeNotification::RouteRemoved { - destination: network_status_cmd.network_address, - }); - } if removed_record { tracing::info!( "Removed failed source route to {:?}", diff --git a/crates/ziggurat-driver/src/zigbee_stack/send_handle.rs b/crates/ziggurat-driver/src/zigbee_stack/send_handle.rs new file mode 100644 index 0000000..6f0dd95 --- /dev/null +++ b/crates/ziggurat-driver/src/zigbee_stack/send_handle.rs @@ -0,0 +1,195 @@ +//! An awaitable, staged view over a single send. +//! +//! An outgoing request goes through two send stages: hand off (the frame was actually +//! sent on air, either to a next hop or as an initial broadcast) and delivery +//! (dependent on the request: APS ACK, broadcast relay quorum, etc.). A send point can +//! decide what stage to block on (if any). + +use alloc::sync::Arc; +use core::sync::atomic::{AtomicBool, Ordering}; + +use crate::sync::{Mutex, Notify}; + +use super::DeliveryError; + +/// Which of a send's two verdicts a resolution supplies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackStage { + /// The mesh accepted the frame (next-hop MAC ack, or the first broadcast copy on + /// air). + HandOff, + /// The send's final verdict. Delivery subsumes acceptance, so it also back-fills a + /// still-unset `handed_off` with the same result. + Delivery, +} + +/// The two write-once verdicts of a send, plus the tracker's aggregate wake. +#[derive(Debug, Default)] +struct Progress { + handed_off: Option>, + delivered: Option>, + /// Also notified on any stage resolution, so a poller (the wire `SendTracker`) can + /// sweep without occupying either per-stage waiter. Set via + /// [`SendHandle::set_completion_wake`]. + completion_wake: Option>, +} + +/// The shared slot a send's outcome is written into. +/// +/// Two write-once verdicts, a per-stage wake each (so the stages can be awaited +/// independently), and a cancellation flag the reactors honour. The producer and every +/// awaiter/poller share one via `Arc`. +#[derive(Debug)] +pub struct SendSlot { + progress: Mutex, + handed_off_wake: Notify, + delivered_wake: Notify, + cancelled: AtomicBool, +} + +/// A non-blocking snapshot of a slot's two verdicts, taken by the wire tracker's sweep. +#[derive(Debug, Clone)] +pub struct SendProgress { + pub handed_off: Option>, + pub delivered: Option>, +} + +impl SendSlot { + fn new() -> Self { + Self { + progress: Mutex::new(Progress::default()), + handed_off_wake: Notify::new(), + delivered_wake: Notify::new(), + cancelled: AtomicBool::new(false), + } + } + + /// Whether the send has been cancelled. Checked by each reactor before it acts on the + /// entry holding this slot. + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + + /// Resolve one of the send's stages, applying the write rules: + /// + /// 1. **First write wins** — both fields are write-once; a later write is ignored. + /// 2. **`Ok` writes its own stage** — a `HandOff` `Ok` leaves `delivered` unset. + /// 3. **`Err` at either stage writes both** — a failed handoff is a failed delivery. + /// 4. **A `Delivery` write back-fills `handed_off`** if unset, with the same result, + /// so `handed_off().await` can never outlive the send. + /// + /// Rules 1, 3 and 4 collapse to: any resolution establishes `handed_off` if still + /// unset; `delivered` is written by a `Delivery` resolution or by any failure. + pub(crate) fn resolve(&self, stage: TrackStage, result: Result<(), DeliveryError>) { + let (woke_handed_off, woke_delivered, completion) = { + let mut progress = self.progress.lock(); + + let woke_handed_off = progress.handed_off.is_none(); + if woke_handed_off { + progress.handed_off = Some(result.clone()); + } + + let woke_delivered = progress.delivered.is_none() + && (matches!(stage, TrackStage::Delivery) || result.is_err()); + if woke_delivered { + progress.delivered = Some(result); + } + + ( + woke_handed_off, + woke_delivered, + progress.completion_wake.clone(), + ) + }; + + if woke_handed_off { + self.handed_off_wake.notify_one(); + } + if woke_delivered { + self.delivered_wake.notify_one(); + } + if (woke_handed_off || woke_delivered) + && let Some(completion) = completion + { + completion.notify_one(); + } + } +} + +/// An awaitable, detachable view over one send. +/// +/// Cloning it is deliberately not offered: each stage has a single-waiter wake (see the +/// module docs), so a send is awaited by one task. Dropping the handle detaches — the +/// producer keeps its own `Arc` and resolves harmlessly into the slot; nothing is +/// cancelled (fire-and-forget is "drop the handle"). +#[derive(Debug)] +pub struct SendHandle { + slot: Arc, +} + +impl SendHandle { + /// Create a fresh slot and a handle over it. The caller passes the returned + /// `Arc` into the send's [`TxOutcome::Track`](super::TxOutcome::Track) so + /// the producer and this handle share it. + pub(crate) fn new() -> (Self, Arc) { + let slot = Arc::new(SendSlot::new()); + (Self { slot: slot.clone() }, slot) + } + + /// Await the mesh accepting the (first) frame: the next-hop MAC ack of a unicast, or + /// the first broadcast copy reaching the air. Resolves early with the terminal error + /// if the send fails before it is ever accepted. + pub async fn handed_off(&self) -> Result<(), DeliveryError> { + loop { + // Arm the wake before checking, so a resolution between the check and the + // await is not lost. Bind the snapshot to a local so the guard drops before + // the await. + let wait = self.slot.handed_off_wake.notified(); + let resolved = self.slot.progress.lock().handed_off.clone(); + if let Some(result) = resolved { + return result; + } + wait.await; + } + } + + /// Await the send's final verdict: the end-to-end APS ack for an ack unicast, the + /// next-hop acceptance for a no-ack unicast, or the passive-ack quorum for a + /// broadcast/groupcast. + pub async fn delivered(&self) -> Result<(), DeliveryError> { + loop { + let wait = self.slot.delivered_wake.notified(); + let resolved = self.slot.progress.lock().delivered.clone(); + if let Some(result) = resolved { + return result; + } + wait.await; + } + } + + /// A non-blocking snapshot of both stages, what the wire tracker sweeps with. Never + /// occupies either per-stage waiter. + pub fn status(&self) -> SendProgress { + let progress = self.slot.progress.lock(); + SendProgress { + handed_off: progress.handed_off.clone(), + delivered: progress.delivered.clone(), + } + } + + /// Request cancellation. Sets the flag every in-flight reactor checks before acting; + /// the reactor that next touches this send drops its entry and resolves both stages + /// `Err(Cancelled)`. Cleanup is lazy (the next reactor pass) unless the caller also + /// nudges the reactor wakes. + pub fn cancel(&self) { + self.slot.cancelled.store(true, Ordering::Relaxed); + } + + /// Register the tracker's aggregate wake, notified on any stage resolution in + /// addition to the per-stage wakes. Set once, by the tracker, after the send returns; + /// the slot may already be resolved, so the tracker self-notifies once after + /// registration and re-checks. + pub fn set_completion_wake(&self, wake: Arc) { + self.slot.progress.lock().completion_wake = Some(wake); + } +} diff --git a/crates/ziggurat-driver/src/zigbee_stack/tasklets.rs b/crates/ziggurat-driver/src/zigbee_stack/tasklets.rs new file mode 100644 index 0000000..696043f --- /dev/null +++ b/crates/ziggurat-driver/src/zigbee_stack/tasklets.rs @@ -0,0 +1,63 @@ +//! Small tasks multiplexed on one executor slot. + +use alloc::boxed::Box; +use alloc::vec::Vec; +use core::future::Future; +use core::pin::Pin; + +use futures::stream::{FuturesUnordered, StreamExt}; + +use crate::runtime::Runtime; +use crate::sync::{Mutex, Notify}; +use ziggurat_phy::RadioPhy; + +use super::ZigbeeStack; + +type Tasklet = Pin + Send>>; + +/// The inbox of not-yet-started tasklets. +#[derive(Default)] +pub struct Tasklets { + injected: Mutex>, + wake: Notify, +} + +impl Tasklets { + /// Hand a tasklet to the runner. The future must own its stack reference (capture + /// an `Arc`); it starts on the runner's next pass. + pub fn push(&self, tasklet: impl Future + Send + 'static) { + self.injected.lock().push(Box::pin(tasklet)); + self.wake.notify_one(); + } +} + +impl core::fmt::Debug for Tasklets { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Tasklets") + .field("injected", &self.injected.lock().len()) + .finish() + } +} + +impl ZigbeeStack { + /// Drive every in-flight tasklet, multiplexed on this one task. + pub(super) async fn tasklet_task(&self) { + let mut running: FuturesUnordered = FuturesUnordered::new(); + + loop { + running.extend(self.tasklets.injected.lock().drain(..)); + + // `next()` on an empty set is `None` immediately, not pending: park on the + // inbox wake instead of spinning. + if running.is_empty() { + self.tasklets.wake.notified().await; + continue; + } + + // Wake on a tasklet finishing or a new injection. Dropping the losing + // `next()` future drops only the poll adapter, never the tasklets. + let injected = core::pin::pin!(self.tasklets.wake.notified()); + let _ = futures::future::select(running.next(), injected).await; + } + } +} diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 088a371..c3e4955 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -1,9 +1,13 @@ use crate::runtime::Runtime; use alloc::vec::Vec; +use core::time::Duration; +use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::RadioPhy; -use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode}; -use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; +use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode, ApsFrameControl, ApsFrameType}; +use ziggurat_zigbee::nwk::frame::{ + BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame, NwkRouteDiscovery, +}; use ziggurat_zigbee::zdp::{ DeviceAnnce, MgmtLqiReq, MgmtLqiRsp, MgmtRtgReq, MgmtRtgRsp, NeighborDescriptor, ParentAnnce, @@ -12,8 +16,9 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, MAX_DEPTH, NwkDeviceType, RouteDirective, SendMode, TxOutcome, TxPolicy, TxPriority, - ZigbeeStack, ZigbeeStackError, neighbors, routing, + Broadcast, EnqueueError, MAX_DEPTH, NwkDeviceType, NwkSecurityMode, RouteDirective, SendHandle, + SendMode, TrackStage, TxOutcome, TxPolicy, TxPriority, Unicast, ZigbeeStack, neighbors, + routing, }; use crate::frame_token::TrafficClass; @@ -261,39 +266,73 @@ impl ZigbeeStack { } } - /// Build and enqueue a ZDP command fire-and-forget. + /// Build and enqueue a ZDP command. fn send_zdp_command( &self, destination: Nwk, delivery_mode: ApsDeliveryMode, tsn: u8, command: &T, - ) -> Result<(), ZigbeeStackError> { - let (nwk_frame, _ack) = self.prepare_aps_send( - delivery_mode, - destination, - ZDP_PROFILE_ID, - T::CLUSTER_ID as u16, - 0, - 0, - ApsAck::None, - 2 * MAX_DEPTH, - self.next_aps_counter(), - command.serialize(tsn).unwrap(), - None, - )?; + ) -> Result { + let asdu = FrameBytes::from_slice(&command.serialize(tsn).unwrap()) + .map_err(|_| EnqueueError::PayloadTooLong)?; + + let aps_frame = ApsDataFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Data, + delivery_mode, + reserved1: 0b0, + security: false, + ack_request: false, + extended_header: false, + }, + group_id: None, + destination_endpoint: Some(0), + cluster_id: T::CLUSTER_ID as u16, + profile_id: ZDP_PROFILE_ID, + source_endpoint: 0, + counter: self.next_aps_counter(), + asdu, + }; + + tracing::trace!("Prepared unicast ZDP APS frame: {aps_frame:?}"); + let nwk_frame = self + .nwk_data_frame(destination, aps_frame.to_bytes())? + .with_discover_route(NwkRouteDiscovery::Enable) + .with_radius(2 * MAX_DEPTH); // ZDP responses are answerable to the remote requester's retries: best-effort - self.enqueue_aps_frame( - nwk_frame, - TxPolicy { - priority: TxPriority::UserNormal, - class: TrafficClass::Host, - }, - TxOutcome::Discard, - SendMode::Route(RouteDirective::StackDecides), - ); - Ok(()) + let policy = TxPolicy { + priority: TxPriority::UserNormal, + class: TrafficClass::Host, + }; + + let (handle, slot) = SendHandle::new(); + + match delivery_mode { + ApsDeliveryMode::Broadcast => { + self.send_broadcast(Broadcast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + policy, + slot: Some(slot), + })?; + } + ApsDeliveryMode::Unicast | ApsDeliveryMode::Multicast => { + self.send_unicast(Unicast { + frame: nwk_frame, + security: NwkSecurityMode::NetworkKey, + mode: SendMode::Route(RouteDirective::StackDecides), + policy, + outcome: TxOutcome::Track { + slot, + stage: TrackStage::Delivery, + }, + })? + } + } + + Ok(handle) } /// Spec 2.4.4.2.22.2: a router answered our parent announcement, claiming @@ -322,17 +361,21 @@ impl ZigbeeStack { /// Spec 2.4.3.1.12.1: after a reboot, announce our end device children so other /// routers drop stale entries for them. Until neighbor table restoration exists - /// the table is empty at startup and nothing is sent. - pub(super) async fn parent_annce_task(&self) { + /// the table is empty at startup and nothing is sent. A finite post-boot flow, + /// run as a tasklet. + pub(super) async fn run_parent_annce(&self) { let mut remaining: Option> = None; + let mut send_time = Duration::ZERO; loop { let jitter = self .tunables .parent_annce_jitter_max() .mul_f32(crate::rng::random_f32()); + let slept_at = self.core_now(); - R::sleep(self.tunables.parent_annce_base_timer() + jitter).await; + R::sleep((self.tunables.parent_annce_base_timer() + jitter).saturating_sub(send_time)) + .await; // Spec 2.4.3.1.12.2: an announcement from another router restarts the // countdown @@ -374,16 +417,24 @@ impl ZigbeeStack { let announcement = ParentAnnce { children: chunk }; let tsn = self.next_aps_counter(); + let started = self.core_now(); - if let Err(err) = self.send_zdp_command( + match self.send_zdp_command( BROADCAST_ALL_ROUTERS_AND_COORDINATOR, ApsDeliveryMode::Broadcast, tsn, &announcement, ) { - tracing::warn!("Failed to broadcast a parent announcement: {err}"); + Ok(send) => { + let _ = send.handed_off().await; + } + Err(err) => { + tracing::warn!("Failed to broadcast a parent announcement: {err}"); + } } + send_time = self.core_now().saturating_duration_since(started); + if remaining.as_ref().is_some_and(Vec::is_empty) { return; } diff --git a/crates/ziggurat-ieee-802154/Cargo.toml b/crates/ziggurat-ieee-802154/Cargo.toml index 264acbc..0f755e2 100644 --- a/crates/ziggurat-ieee-802154/Cargo.toml +++ b/crates/ziggurat-ieee-802154/Cargo.toml @@ -10,10 +10,10 @@ repository.workspace = true [dependencies] abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } -num_enum = { version = "0.7.3", default-features = false } +num_enum = { version = "0.7.6", default-features = false } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } -thiserror = { version = "2.0.12", default-features = false } -educe = { version = "0.6.0", default-features = false, features = ["Debug"] } +thiserror = { version = "2.0.19", default-features = false } +educe = { version = "0.7.4", default-features = false, features = ["Debug"] } heapless = "0.9.3" [dev-dependencies] diff --git a/crates/ziggurat-ncp-api/Cargo.lock b/crates/ziggurat-ncp-api/Cargo.lock index 4136f70..92ba2af 100644 --- a/crates/ziggurat-ncp-api/Cargo.lock +++ b/crates/ziggurat-ncp-api/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -202,7 +202,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -213,7 +213,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -227,14 +227,14 @@ dependencies = [ [[package]] name = "educe" -version = "0.6.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "92c3e1715a2bf74bc8f68cd7bae12ff144f02669c602106ad1fa16f2ba62e646" dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -259,7 +259,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -370,7 +370,7 @@ checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -449,6 +449,7 @@ dependencies = [ "futures-sink", "futures-task", "pin-project-lite", + "slab", ] [[package]] @@ -609,7 +610,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -643,7 +644,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -714,6 +715,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -722,9 +729,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spin" -version = "0.9.8" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" [[package]] name = "stable_deref_trait" @@ -755,6 +762,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" @@ -763,22 +781,22 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] @@ -809,7 +827,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -946,6 +964,7 @@ dependencies = [ "embassy-futures", "embassy-sync", "num_enum", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", @@ -968,6 +987,7 @@ dependencies = [ "abstract-bits", "arbitrary-int 2.1.1", "num_enum", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", diff --git a/crates/ziggurat-ncp-api/Cargo.toml b/crates/ziggurat-ncp-api/Cargo.toml index 37d6b22..d266d93 100644 --- a/crates/ziggurat-ncp-api/Cargo.toml +++ b/crates/ziggurat-ncp-api/Cargo.toml @@ -15,6 +15,7 @@ abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", versio num_enum = { version = "0.7.3", default-features = false } embassy-sync = "0.8" embassy-futures = "0.1" +tracing = { version = "0.1", default-features = false } # Standalone workspace: consumed only by the MCU firmware crates (ziggurat-ot, # ziggurat-esp), which pin ziggurat-driver to the embassy runtime; excluded from the host diff --git a/crates/ziggurat-ncp-api/src/lib.rs b/crates/ziggurat-ncp-api/src/lib.rs index 9748e7f..ce502b0 100644 --- a/crates/ziggurat-ncp-api/src/lib.rs +++ b/crates/ziggurat-ncp-api/src/lib.rs @@ -14,14 +14,18 @@ pub mod protocol; use alloc::sync::Arc; use alloc::vec::Vec; +use core::cell::RefCell; +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; use ziggurat_driver::runtime::EmbassySpawner; -use ziggurat_driver::zigbee_stack::ZigbeeStack; +use ziggurat_driver::sync::Notify; +use ziggurat_driver::zigbee_stack::{SendHandle, ZigbeeStack}; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy}; +use ziggurat_protocol::{RequestId, SendTracker, ConfirmKind}; pub(crate) const DEFAULT_TX_POWER: i8 = 8; @@ -32,6 +36,43 @@ pub static OUTBOUND: Channel, OUTBOUND_DEPTH> = /// Cancels an in-progress packet capture (see the `reset` command). pub type CaptureStop = embassy_sync::signal::Signal; +/// One firmware's send tracker, shared between the request dispatch path and the +/// wake-and-sweep reactor (two independent tasks, so it lives behind a shared cell rather +/// than in one task's stack). The blocking lock is held only briefly, never across an +/// await. There is one client, so one tracker per [`App`]. +pub type SendTrackerCell = Arc>>; + +/// Build an empty send tracker cell for an [`App`]. Its contents are replaced with a +/// fresh tracker (and reactor) each time a stack starts. +pub fn new_send_tracker() -> SendTrackerCell { + Arc::new(BlockingMutex::new(RefCell::new(SendTracker::new(Arc::new( + Notify::new(), + ))))) +} + +/// Begin tracking a send and nudge the sweep reactor. Called by the `send_aps` handler. +pub(crate) fn track_send( + sends: &SendTrackerCell, + id: RequestId, + handle: SendHandle, + confirm_kind: ConfirmKind, +) { + let wake = sends.lock(|sends| { + let mut tracker = sends.borrow_mut(); + tracker.insert(id, handle, confirm_kind); + tracker.wake() + }); + wake.notify_one(); +} + +/// Run `f` against the live send tracker. Used by the `cancel_request` handler. +pub(crate) fn with_send_tracker( + sends: &SendTrackerCell, + f: impl FnOnce(&mut SendTracker) -> R, +) -> R { + sends.lock(|sends| f(&mut sends.borrow_mut())) +} + /// Board specifics the protocol surface needs but the transport-agnostic core can't know. pub trait Platform: Send + Sync { /// The factory-programmed EUI-64, used as the coordinator IEEE address. @@ -57,6 +98,9 @@ pub struct App { /// `start_network` are separate phases). pub started: bool, pub capture_stop: Option>, + /// Turns tracked sends into their wire confirm frames. Build with + /// [`new_send_tracker`]. + pub sends: SendTrackerCell, } /// Queue one encoded frame, dropping the oldest queued frame when full. For @@ -111,23 +155,40 @@ pub async fn handle_frame(app: &mut App

, frame: &[u8]) { protocol::handle_frame(app, frame).await; } -/// Start the receive loop and the notification pump for a freshly-started stack. -pub(crate) fn spawn_stack_pumps(stack: &Arc>) { - let run_stack = stack.clone(); - stack.spawn_tracked(async move { - run_stack.run().await; +/// Start the receive loop, the notification pump, and the send-confirm sweep reactor for +/// a freshly-started stack. +pub(crate) fn spawn_stack_pumps(stack: &Arc>, sends: SendTrackerCell) { + stack.spawn_tracked(|arc_self| async move { + arc_self.run().await; }); - let notify_stack = stack.clone(); - stack.spawn_tracked(async move { + stack.spawn_tracked(|arc_self| async move { loop { - for notification in notify_stack.next_notifications().await { + for notification in arc_self.next_notifications().await { if let Some(frame) = protocol::notification_frame(¬ification) { push_outbound(frame); } } } }); + + // Reset the tracker with a fresh wake for this stack, dropping any stale entries from + // a previous one; the old stack's reactor was cancelled with it. The dispatch path + // holds the same cell, so it sees the reset tracker. + let wake = Arc::new(Notify::new()); + sends.lock(|cell| *cell.borrow_mut() = SendTracker::new(wake.clone())); + stack.spawn_tracked(|_arc_self| async move { + loop { + wake.notified().await; + let frames: Vec> = with_send_tracker(&sends, SendTracker::sweep) + .into_iter() + .filter_map(|notification| notification.frame()) + .collect(); + for frame in frames { + push_outbound(frame); + } + } + }); } /// Radio programming for promiscuous capture: receive every frame on `channel`, no diff --git a/crates/ziggurat-ncp-api/src/protocol.rs b/crates/ziggurat-ncp-api/src/protocol.rs index 804afd3..f970097 100644 --- a/crates/ziggurat-ncp-api/src/protocol.rs +++ b/crates/ziggurat-ncp-api/src/protocol.rs @@ -1,7 +1,6 @@ //! Embedded dispatch for the binary control protocol: it routes parsed requests to //! the live `ZigbeeStack` and streams the replies onto [`crate::OUTBOUND`]. -use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; use core::time::Duration; @@ -10,9 +9,9 @@ use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::{Tunables, ZigbeeStack}; use ziggurat_phy::{RadioPhy, Receiver}; use ziggurat_protocol::{ - self as proto, CapturedPacketPayload, ChannelPayload, CommandId, ConfigurePayload, - EnergyResultPayload, Error, Event, FirmwareInfoPayload, HwAddressPayload, NwkUpdateIdPayload, - PermitJoinsPayload, ProvisionalKeyPayload, Request, RequestHeader, RequestId, ResetPayload, + self as proto, CapturedPacketPayload, ChannelPayload, ConfigurePayload, EnergyResultPayload, + Error, Event, FirmwareInfoPayload, FrameType, Header, HwAddressPayload, NwkUpdateIdPayload, + PermitJoinsPayload, ProvisionalKeyPayload, Request, RequestCommand, RequestId, ResetPayload, Response, ScanCountPayload, ScanRequestPayload, Status, }; @@ -34,15 +33,21 @@ async fn send_event(request_id: RequestId, event: Event) { /// Dispatch one inbound frame; every path emits exactly one response or error, /// preceded by any streamed events. pub async fn handle_frame(app: &mut App

, bytes: &[u8]) { - let Some((header, consumed)) = RequestHeader::parse(bytes) else { - send_outbound(Error::parse("truncated header").frame(0, 0)).await; + let Some((header, consumed)) = Header::parse(bytes) else { + tracing::warn!("Inbound frame is shorter than a header"); return; }; let payload = &bytes[consumed..]; let request_id = header.request_id; - let request = CommandId::try_from(header.command) - .map_err(|_| Error::new(Status::UnknownCommand, "")) + if header.frame_type != FrameType::Request { + tracing::warn!("Inbound frame is not a request"); + send_outbound(Error::from(Status::InvalidRequest).frame(header.command, request_id)).await; + return; + } + + let request = RequestCommand::try_from(header.command) + .map_err(|_| Error::from(Status::UnknownCommand)) .and_then(|command| Request::parse(command, payload)); let reply = match request { @@ -63,7 +68,6 @@ async fn dispatch( request: Request, ) -> Result { match request { - Request::Ping => Ok(Response::Empty), Request::Reset(payload) => handle_reset(app, payload), Request::GetFirmwareInfo => { let version = concat!("ziggurat/", env!("CARGO_PKG_VERSION")); @@ -89,6 +93,14 @@ async fn dispatch( proto::apply_address_cache(&**loadable(app)?, payload); Ok(Response::Empty) } + Request::LoadRouteTable(payload) => { + proto::apply_route_table(&**loadable(app)?, payload); + Ok(Response::Empty) + } + Request::LoadSourceRoutes(payload) => { + proto::apply_source_routes(&**loadable(app)?, payload); + Ok(Response::Empty) + } Request::StartNetwork => handle_start_network(app).await, Request::GetNetworkInfo => Ok(Response::NetworkInfo(proto::network_info_payload( &**configured(app)?, @@ -130,8 +142,19 @@ async fn dispatch( }) .await } - Request::SendAps(payload) => { - proto::send_aps(&**running(app)?, payload, request_id)?; + Request::SendUnicast(payload) => { + let (handle, confirm_kind) = proto::send_unicast(&**running(app)?, payload)?; + crate::track_send(&app.sends, request_id, handle, confirm_kind); + Ok(Response::Empty) + } + Request::SendBroadcast(payload) => { + let (handle, confirm_kind) = proto::send_broadcast(&**running(app)?, payload)?; + crate::track_send(&app.sends, request_id, handle, confirm_kind); + Ok(Response::Empty) + } + Request::SendGroupcast(payload) => { + let (handle, confirm_kind) = proto::send_groupcast(&**running(app)?, payload)?; + crate::track_send(&app.sends, request_id, handle, confirm_kind); Ok(Response::Empty) } Request::PermitJoins(payload) => handle_permit_joins(app, payload), @@ -148,10 +171,13 @@ async fn dispatch( proto::set_tunable(&**configured(app)?, &payload)?; Ok(Response::Empty) } - Request::CancelRequest(payload) => Ok(Response::CancelResult(proto::cancel_request( - &**running(app)?, - &payload, - ))), + Request::CancelRequest(payload) => { + let stack = running(app)?.clone(); + let result = crate::with_send_tracker(&app.sends, |tracker| { + proto::cancel_request(&*stack, tracker, &payload) + }); + Ok(Response::CancelResult(result)) + } } } @@ -159,15 +185,15 @@ async fn dispatch( /// The stack, in any state after `configure`. fn configured(app: &App

) -> Result<&Arc>, Error> { - app.stack.as_ref().ok_or_else(Error::not_configured) + app.stack.as_ref().ok_or(Error::Status(Status::NotConfigured)) } /// The stack, if it is in the load window (configured but not started). fn loadable(app: &App

) -> Result<&Arc>, Error> { match app.stack.as_ref() { Some(stack) if !app.started => Ok(stack), - Some(_) => Err(Error::new(Status::InvalidState, "network already started")), - None => Err(Error::not_configured()), + Some(_) => Err(Status::AlreadyStarted.into()), + None => Err(Status::NotConfigured.into()), } } @@ -175,7 +201,8 @@ fn loadable(app: &App

) -> Result<&Arc>, Error> { fn running(app: &App

) -> Result<&Arc>, Error> { match app.stack.as_ref() { Some(stack) if app.started => Ok(stack), - _ => Err(Error::not_configured()), + Some(_) => Err(Status::NotStarted.into()), + None => Err(Status::NotConfigured.into()), } } @@ -207,7 +234,8 @@ async fn handle_shutdown(app: &mut App

) -> Result(app: &mut App

) -> Result( match stack.set_channel(request.channel).await { Ok(()) => Ok(Response::Empty), - Err(e) => Err(Error::new(Status::RadioError, &e.to_string())), + Err(e) => { + tracing::warn!("set_channel failed: {e}"); + Err(Status::RadioError.into()) + } } } @@ -327,7 +359,10 @@ async fn handle_energy_scan( ) .await; } - Err(e) => return Err(Error::new(Status::ScanFailed, &e.to_string())), + Err(e) => { + tracing::warn!("Energy scan failed: {e}"); + return Err(Status::ScanFailed.into()); + } } } @@ -357,7 +392,10 @@ async fn handle_network_scan( match result { Ok(()) => Ok(Response::Empty), - Err(e) => Err(Error::new(Status::ScanFailed, &e.to_string())), + Err(e) => { + tracing::warn!("Network scan failed: {e}"); + Err(Status::ScanFailed.into()) + } } } @@ -367,7 +405,8 @@ async fn handle_packet_capture( request: ChannelPayload, ) -> Result { if let Err(e) = app.phy.reconfigure(&capture_config(request.channel)).await { - return Err(Error::new(Status::RadioError, &e.to_string())); + tracing::warn!("Capture reconfigure failed: {e}"); + return Err(Status::RadioError.into()); } // Already capturing: the reconfigure above retuned it; don't spawn a second task. @@ -407,6 +446,9 @@ async fn handle_packet_capture_channel( ) -> Result { match app.phy.reconfigure(&capture_config(request.channel)).await { Ok(()) => Ok(Response::Empty), - Err(e) => Err(Error::new(Status::RadioError, &e.to_string())), + Err(e) => { + tracing::warn!("Capture reconfigure failed: {e}"); + Err(Status::RadioError.into()) + } } } diff --git a/crates/ziggurat-ot/Cargo.lock b/crates/ziggurat-ot/Cargo.lock index 565643a..c0c66ab 100644 --- a/crates/ziggurat-ot/Cargo.lock +++ b/crates/ziggurat-ot/Cargo.lock @@ -375,9 +375,9 @@ dependencies = [ [[package]] name = "educe" -version = "0.6.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "92c3e1715a2bf74bc8f68cd7bae12ff144f02669c602106ad1fa16f2ba62e646" dependencies = [ "enum-ordinalize", "proc-macro2", @@ -641,6 +641,7 @@ dependencies = [ "futures-sink", "futures-task", "pin-project-lite", + "slab", ] [[package]] @@ -1113,6 +1114,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -1121,9 +1128,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spin" -version = "0.9.8" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" [[package]] name = "stable_deref_trait" @@ -1178,6 +1185,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" @@ -1199,22 +1217,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] @@ -1460,6 +1478,7 @@ dependencies = [ "embassy-futures", "embassy-sync", "num_enum", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", @@ -1513,6 +1532,7 @@ dependencies = [ "abstract-bits", "arbitrary-int 2.1.1", "num_enum", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", diff --git a/crates/ziggurat-ot/src/lib.rs b/crates/ziggurat-ot/src/lib.rs index bdc471b..0fb3d9f 100644 --- a/crates/ziggurat-ot/src/lib.rs +++ b/crates/ziggurat-ot/src/lib.rs @@ -161,6 +161,7 @@ async fn ziggurat_main(spawner: embassy_executor::SendSpawner) { stack: None, started: false, capture_stop: None, + sends: api::new_send_tracker(), }; api::emit_hello(false).await; diff --git a/crates/ziggurat-phy-spinel/Cargo.toml b/crates/ziggurat-phy-spinel/Cargo.toml index 5a10a30..52e9fe7 100644 --- a/crates/ziggurat-phy-spinel/Cargo.toml +++ b/crates/ziggurat-phy-spinel/Cargo.toml @@ -13,5 +13,5 @@ ziggurat-ieee-802154.workspace = true ziggurat-phy.workspace = true ziggurat-spinel.workspace = true -parking_lot = "0.12.4" -tokio = { version = "1.43.0", features = ["rt", "time", "sync"] } +parking_lot = "0.12.5" +tokio = { version = "1.53.0", features = ["rt", "time", "sync"] } diff --git a/crates/ziggurat-phy/Cargo.toml b/crates/ziggurat-phy/Cargo.toml index 230624d..6316943 100644 --- a/crates/ziggurat-phy/Cargo.toml +++ b/crates/ziggurat-phy/Cargo.toml @@ -11,4 +11,4 @@ repository.workspace = true [dependencies] ziggurat-ieee-802154.workspace = true -thiserror = { version = "2.0.12", default-features = false } +thiserror = { version = "2.0.19", default-features = false } diff --git a/crates/ziggurat-phy/src/lib.rs b/crates/ziggurat-phy/src/lib.rs index c95a84e..32fb68b 100644 --- a/crates/ziggurat-phy/src/lib.rs +++ b/crates/ziggurat-phy/src/lib.rs @@ -70,7 +70,7 @@ pub struct ResetEvent { pub reason: String, } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error)] pub enum RadioError { #[error("radio command timed out")] Timeout, diff --git a/crates/ziggurat-protocol/Cargo.toml b/crates/ziggurat-protocol/Cargo.toml index b2628fc..e801abb 100644 --- a/crates/ziggurat-protocol/Cargo.toml +++ b/crates/ziggurat-protocol/Cargo.toml @@ -14,4 +14,5 @@ ziggurat-zigbee = { path = "../ziggurat-zigbee" } ziggurat-driver = { path = "../ziggurat-driver", default-features = false } abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } arbitrary-int = "2.1.1" -num_enum = { version = "0.7.3", default-features = false } +num_enum = { version = "0.7.6", default-features = false } +tracing = { version = "0.1", default-features = false } diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index b220460..7b55a03 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -4,15 +4,14 @@ //! `configure`, `send_aps`, beacons, and notifications. Shared verbatim by the //! embedded firmware and the host server so the two cannot drift. -use alloc::string::ToString; use alloc::vec::Vec; use core::time::Duration; use ziggurat_driver::runtime::Runtime; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ApsAckResult, DeviceLeaveReason, HostRoute, NetworkBeacon, NetworkConfig, - NwkDeviceType, RequestId as StackRequestId, RouteDirective, SendResult, TclkSeed, TxPriority, + ApsAck, DeliveryError, DeviceLeaveReason, EnqueueError, HostRoute, NetworkBeacon, + NetworkConfig, NwkDeviceType, RouteDirective, SendHandle, TclkSeed, TxPriority, ZigbeeNotification, ZigbeeStack, }; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk}; @@ -21,6 +20,8 @@ use ziggurat_zigbee::nwk::frame::NwkSecurityHeaderKeyId; use ziggurat_zigbee::nwk::neighbors::{ChildDescriptor, Relationship}; use ziggurat_zigbee::nwk::routing; +use crate::send_tracker::{ConfirmKind, SendTracker}; + use crate::wire::*; /// Child entries restored from a backup re-negotiate their real timeout at the @@ -261,28 +262,30 @@ impl From<&NetworkBeacon> for BeaconPayload { } } -/// Hand `send_aps` to the stack, translating the wire flags. The delivery outcome -/// arrives later as a `SendConfirm` / `ApsAckConfirm` notification keyed by -/// `request_id`. -pub fn send_aps( +/// Hand a unicast send to the stack, returning a [`SendHandle`] and the +/// [`ConfirmKind`] that says which confirm frames it owes; the caller registers both +/// in its [`SendTracker`]. +pub fn send_unicast( stack: &ZigbeeStack, - payload: SendApsPayload, - request_id: RequestId, -) -> Result<(), Error> { + payload: SendUnicastPayload, +) -> Result<(SendHandle, ConfirmKind), Error> { let aps_security = (payload.flags.aps_encryption && payload.flags.has_eui64) .then_some(payload.destination_eui64); - let aps_ack = if payload.flags.aps_ack { ApsAck::Request } else { ApsAck::None }; - let route = route_directive(payload.route, payload.next_hop, payload.relays)?; + let confirm_kind = if payload.flags.aps_ack { + ConfirmKind::UnicastApsAck + } else { + ConfirmKind::UnicastNoAck + }; + stack - .send_aps( - payload.flags.delivery_mode, + .send_aps_unicast( payload.destination, payload.profile_id, payload.cluster_id, @@ -296,9 +299,64 @@ pub fn send_aps( payload.flags.sleepy_destination, TxPriority::from_host(payload.priority as i8), route, - StackRequestId::from(request_id), ) - .map_err(|e| Error::new(Status::TransmitFailed, &e.to_string())) + .map(|handle| (handle, confirm_kind)) + .map_err(|e| enqueue_error(&e)) +} + +/// Hand a broadcast send to the stack; its confirm frame is the passive-ack quorum +/// verdict. +pub fn send_broadcast( + stack: &ZigbeeStack, + payload: SendBroadcastPayload, +) -> Result<(SendHandle, ConfirmKind), Error> { + stack + .send_aps_broadcast( + payload.destination, + payload.profile_id, + payload.cluster_id, + payload.src_ep, + payload.dst_ep, + payload.radius, + payload.aps_seq, + payload.asdu, + TxPriority::from_host(payload.priority as i8), + ) + .map(|handle| (handle, ConfirmKind::Broadcast)) + .map_err(|e| enqueue_error(&e)) +} + +/// Hand a groupcast send to the stack; it rides the broadcast machinery, so its confirm +/// frame is likewise the passive-ack quorum verdict. +pub fn send_groupcast( + stack: &ZigbeeStack, + payload: SendGroupcastPayload, +) -> Result<(SendHandle, ConfirmKind), Error> { + stack + .send_aps_groupcast( + payload.group_id, + payload.profile_id, + payload.cluster_id, + payload.src_ep, + payload.radius, + payload.aps_seq, + payload.asdu, + TxPriority::from_host(payload.priority as i8), + ) + .map(|handle| (handle, ConfirmKind::Broadcast)) + .map_err(|e| enqueue_error(&e)) +} + +/// Map a synchronous admission failure onto its wire `Error` frame. +fn enqueue_error(e: &EnqueueError) -> Error { + match e { + EnqueueError::RateLimited { retry_in } => Error::rate_limited(*retry_in), + EnqueueError::BudgetExhausted => Status::BudgetExhausted.into(), + EnqueueError::NotStarted => Status::NotStarted.into(), + EnqueueError::PayloadTooLong => Status::PayloadTooLong.into(), + EnqueueError::SecurityUnavailable => Status::SecurityUnavailable.into(), + EnqueueError::RouteDiscoverySuppressed => Status::NoRoute.into(), + } } /// Build the driver's [`RouteDirective`] from the wire route control. @@ -309,10 +367,8 @@ fn route_directive( ) -> Result { let host_source_route = |relays: SourceRouteRelays| -> Result { if relays.relays.is_empty() { - Err(Error::new( - Status::InvalidRequest, - "a source route must contain at least one relay", - )) + tracing::warn!("Rejecting a host source route with no relays"); + Err(Status::InvalidRequest.into()) } else { Ok(HostRoute::SourceRoute(relays.relays)) } @@ -329,13 +385,20 @@ fn route_directive( }) } -/// Cancel an in-flight send by the `request_id` it was issued under. Best-effort: the -/// reply reports whether a still-cancellable (pre-delivery) send was found and removed. +/// Cancel an in-flight send by the `request_id` it was issued under. +/// +/// Best-effort: the reply reports whether the tracker held it and it was still +/// unresolved. Setting the slot's flag is lazy, so the driver's reactors are nudged to +/// act on it this pass. pub fn cancel_request( stack: &ZigbeeStack, + tracker: &mut SendTracker, payload: &CancelRequestPayload, ) -> CancelResultPayload { - let cancelled = stack.cancel_send(StackRequestId::from(payload.request_id)); + let cancelled = tracker.cancel(payload.request_id); + if cancelled { + stack.nudge_cancellation(); + } CancelResultPayload { cancelled } } @@ -345,12 +408,37 @@ pub fn set_tunable( stack: &ZigbeeStack, payload: &SetTunablePayload, ) -> Result<(), Error> { - let name = core::str::from_utf8(&payload.name) - .map_err(|_| Error::new(Status::InvalidRequest, "tunable name is not UTF-8"))?; + let name = core::str::from_utf8(&payload.name).map_err(|_| { + tracing::warn!("Tunable name is not UTF-8"); + Error::Status(Status::InvalidRequest) + })?; - stack - .set_tunable(name, payload.value) - .map_err(|e| Error::new(Status::InvalidRequest, &alloc::format!("{name}: {e}"))) + stack.set_tunable(name, payload.value).map_err(|e| { + tracing::warn!("Rejecting set_tunable {name}: {e}"); + Error::Status(Status::InvalidRequest) + }) +} + +/// Mirror a send's terminal result onto its wire status. Exhaustive on purpose: a new +/// `DeliveryError` variant must pick its `SendStatus` here to compile. +pub(crate) const fn send_status(result: &Result<(), DeliveryError>) -> SendStatus { + match result { + Ok(()) => SendStatus::Success, + Err(err) => match err { + DeliveryError::RouteDiscoveryTimeout(_) => SendStatus::RouteDiscoveryTimeout, + DeliveryError::RouteDiscoveryNoEntry => SendStatus::RouteDiscoveryNoEntry, + DeliveryError::RouteInactiveAfterDiscovery => SendStatus::RouteInactiveAfterDiscovery, + DeliveryError::NwkNoAck { .. } => SendStatus::NwkNoAck, + DeliveryError::CcaFailure => SendStatus::CcaFailure, + DeliveryError::TransmitFailed(_) => SendStatus::TransmitFailed, + DeliveryError::ApsAckTimeout => SendStatus::ApsAckTimeout, + DeliveryError::BroadcastQuorumNotReached => SendStatus::BroadcastQuorumNotReached, + DeliveryError::IndirectExpired { .. } => SendStatus::IndirectExpired, + DeliveryError::BudgetExhausted => SendStatus::FrameBudgetExhausted, + DeliveryError::Cancelled => SendStatus::Cancelled, + DeliveryError::Radio(_) => SendStatus::RadioError, + }, + } } /// Encode one unsolicited notification. `send_confirm`/`aps_ack_confirm` carry @@ -381,31 +469,6 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { rssi: *rssi as u8, data: data.clone(), }), - ZigbeeNotification::SendConfirm { request_id, result } => { - let (confirmed, next_hop, reason) = match result { - SendResult::Confirmed { next_hop } => { - (true, next_hop.unwrap_or(Nwk(0xFFFF)), Vec::new()) - } - SendResult::Failed { reason } => { - (false, Nwk(0xFFFF), reason.to_string().into_bytes()) - } - }; - Notification::SendConfirm( - *request_id as u16, - SendConfirmPayload { - confirmed, - next_hop, - reason, - }, - ) - } - ZigbeeNotification::ApsAckConfirm { request_id, result } => { - let (acked, reason) = match result { - ApsAckResult::Acked => (true, Vec::new()), - ApsAckResult::Failed { reason } => (false, reason.to_string().into_bytes()), - }; - Notification::ApsAckConfirm(*request_id as u16, ApsAckConfirmPayload { acked, reason }) - } ZigbeeNotification::DeviceJoined { nwk, ieee, @@ -468,20 +531,6 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { ieee: *ieee, key: key.clone(), }), - ZigbeeNotification::RouteChanged { - destination, - next_hop, - path_cost, - } => Notification::RouteChanged(RouteChangedPayload { - destination: *destination, - next_hop: *next_hop, - path_cost: *path_cost, - }), - ZigbeeNotification::RouteRemoved { destination } => { - Notification::RouteRemoved(RouteRemovedPayload { - destination: *destination, - }) - } ZigbeeNotification::RouteRecord { destination, relays, diff --git a/crates/ziggurat-protocol/src/lib.rs b/crates/ziggurat-protocol/src/lib.rs index 6ebd1d3..eba2f96 100644 --- a/crates/ziggurat-protocol/src/lib.rs +++ b/crates/ziggurat-protocol/src/lib.rs @@ -11,7 +11,9 @@ extern crate alloc; pub mod bridge; +pub mod send_tracker; pub mod wire; pub use bridge::*; +pub use send_tracker::*; pub use wire::*; diff --git a/crates/ziggurat-protocol/src/send_tracker.rs b/crates/ziggurat-protocol/src/send_tracker.rs new file mode 100644 index 0000000..976418a --- /dev/null +++ b/crates/ziggurat-protocol/src/send_tracker.rs @@ -0,0 +1,173 @@ +//! Turns tracked sends into their wire confirm frames: an `id -> SendHandle` map plus a +//! sweep. +//! +//! Once `request_id` leaves the driver, this map must exist at the protocol layer +//! regardless — cancellation needs it. It is sans-io and shared verbatim by the host +//! server and the NCP firmware, so the two transports emit the same confirm frames for +//! a send and cannot drift. +//! +//! The tracker only ever calls [`SendHandle::status`] (it never awaits) so both of a +//! handle's per-stage waiters stay free for a local caller. The async shell around it +//! is one wake-and-sweep reactor per transport: it waits on the shared wake, sweeps +//! the whole table, and emits the frames the sweep returns. + +use alloc::sync::Arc; +use alloc::vec::Vec; + +use ziggurat_driver::sync::Notify; +use ziggurat_driver::zigbee_stack::SendHandle; +use ziggurat_zigbee::flat_map::FlatMap; + +use crate::bridge::send_status; +use crate::wire::{ + ApsAckConfirmPayload, BroadcastConfirmPayload, Notification, RequestId, SendConfirmPayload, +}; + +/// Which confirm frames a tracked send owes, fixed when the send is started. +/// +/// Frame names stay truthful: `SendConfirm` = the mesh accepted a unicast; `ApsAckConfirm` +/// = the end-to-end APS ack verdict; `BroadcastConfirm` = the passive-ack quorum verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmKind { + /// `SendConfirm` at `delivered` (which equals `handed_off` for a no-ack unicast). + UnicastNoAck, + /// `SendConfirm` at `handed_off`, then `ApsAckConfirm` at `delivered`. + UnicastApsAck, + /// `BroadcastConfirm` at `delivered` (the passive-ack quorum verdict); groupcast + /// too. The broadcast `handed_off` checkpoint stays local-only, never a wire + /// frame. + Broadcast, +} + +struct TrackedSend { + handle: SendHandle, + confirm_kind: ConfirmKind, + emitted_send_confirm: bool, +} + +/// The per-transport map of in-flight tracked sends. Entries resolve as their slots do +/// and are removed by [`sweep`](Self::sweep) once every frame they owe has been emitted. +pub struct SendTracker { + entries: FlatMap, + wake: Arc, +} + +impl SendTracker { + /// Create a tracker whose entries share `wake`: every handle inserted is given it as + /// its completion wake, so any stage resolution nudges the shell to sweep. + pub const fn new(wake: Arc) -> Self { + Self { + entries: FlatMap::new(), + wake, + } + } + + /// The shared wake handed to every tracked send. A shell notifies it after an insert + /// to close the registration race, and its reactor awaits it to drive the sweep. + pub fn wake(&self) -> Arc { + self.wake.clone() + } + + /// Begin tracking a send under its wire `request_id`. Registers the shared wake on + /// the handle; the slot may already be resolved, so the shell must self-notify once + /// after inserting (the sweep re-checks everything, closing the race). + pub fn insert(&mut self, id: RequestId, handle: SendHandle, confirm_kind: ConfirmKind) { + handle.set_completion_wake(self.wake.clone()); + self.entries.insert( + id, + TrackedSend { + handle, + confirm_kind, + emitted_send_confirm: false, + }, + ); + } + + /// Cancel the send tracked under `id`. Returns whether an entry was present and still + /// unresolved (its terminal frame not yet owed). The caller must also nudge the + /// driver's reactors so the cancellation is acted on promptly. + pub fn cancel(&mut self, id: RequestId) -> bool { + self.entries.get(&id).is_some_and(|entry| { + let unresolved = entry.handle.status().delivered.is_none(); + entry.handle.cancel(); + unresolved + }) + } + + /// One reactor pass: emit every confirm frame now owed and drop entries that have + /// emitted their last. Per-entry `emitted_send_confirm` flags keep the two-frame kind + /// exactly-once and ordered even though a coalesced wake sweeps the whole table. + pub fn sweep(&mut self) -> Vec { + let mut out = Vec::new(); + let mut done: Vec = Vec::new(); + + for (id, entry) in self.entries.iter_mut() { + let progress = entry.handle.status(); + + match entry.confirm_kind { + ConfirmKind::UnicastNoAck => { + if let Some(delivered) = &progress.delivered { + out.push(Notification::SendConfirm( + *id, + SendConfirmPayload { + status: send_status(delivered), + }, + )); + done.push(*id); + } + } + ConfirmKind::Broadcast => { + if let Some(delivered) = &progress.delivered { + out.push(Notification::BroadcastConfirm( + *id, + BroadcastConfirmPayload { + status: send_status(delivered), + }, + )); + done.push(*id); + } + } + ConfirmKind::UnicastApsAck => { + if !entry.emitted_send_confirm { + if let Some(handed_off) = &progress.handed_off { + out.push(Notification::SendConfirm( + *id, + SendConfirmPayload { + status: send_status(handed_off), + }, + )); + entry.emitted_send_confirm = true; + + // A failed handoff is terminal: no end-to-end ack will follow, + // so the `ApsAckConfirm` is never owed. + if handed_off.is_err() { + done.push(*id); + continue; + } + } + } + + // Reached only once the handoff succeeded (its failure was retired + // above), so `delivered` here is the end-to-end ack verdict. + if entry.emitted_send_confirm { + if let Some(delivered) = &progress.delivered { + out.push(Notification::ApsAckConfirm( + *id, + ApsAckConfirmPayload { + status: send_status(delivered), + }, + )); + done.push(*id); + } + } + } + } + } + + for id in done { + self.entries.remove(&id); + } + + out + } +} diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index 0f8e9a8..f83feb2 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -1,109 +1,174 @@ -//! The binary command set. +//! The binary wire format, shared by both the WebSocket server and MCUs over serial. //! -//! Request/response keyed by request id, streamed events, unsolicited notifications, -//! fixed-layout payloads, and index-free scan/load state transfer. Pure codec: no -//! runtime, no stack, no transport. - -// The `#[abstract_bits(length_from = …)]` expansion iterates `(0..len).into_iter()`. -#![allow(clippy::useless_conversion)] +//! The transport provides the framing: one frame per WebSocket binary message, or +//! COBS-encoded frames delimited by `0x00` over a raw byte stream (serial, stdio). +//! Within a frame, fields pack LSB-first in declaration order and multi-byte integers +//! are little-endian. +//! +//! Every frame in both directions leads with the same 3-byte [`Header`], packing the +//! command, frame type, and request id into 24 bits (bit 0 leftmost within a byte): +//! +//! ```text +//! byte 0 byte 1 byte 2 +//! +----------------+------+------------+------------------+ +//! | command | type | request id | request id | +//! | u8 | u2 | u14 (low) | u14 (high) | +//! +----------------+------+------------+------------------+ +//! ``` +//! +//! Bytes 1-2 read as one little-endian u16 equal to `request_id << 2 | frame_type`. +//! +//! The [`FrameType`] distinguishes between requests, responses, and notifications. +//! +//! - `Request` (host -> device): asks the device to do something. Every request is +//! answered by exactly one `Response` echoing its command and request id. +//! - `Event` (device -> host): a streamed item belonging to a still-pending request +//! (table scan rows, beacons, captured packets), carrying that request's command and +//! id. All of a request's events precede its response. +//! - `Response` (device -> host): the final reply to a request. The body begins with a +//! [`Status`] byte and contains either the command response or an error-specific +//! payload. +//! - `Notification` (device -> host): unsolicited notifications. +//! +//! Request ids are host-chosen: 14 bits wide, with 0 left for notifications. -use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::time::Duration; use abstract_bits::{abstract_bits, AbstractBits, BitReader}; use num_enum::TryFromPrimitive; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; -use ziggurat_zigbee::aps::frame::ApsDeliveryMode; -pub const PROTOCOL_VERSION: u8 = 1; +pub const PROTOCOL_VERSION: u8 = 2; + +/// Host-chosen request id. 14 bits on the wire: values must stay below `0x4000`. pub type RequestId = u16; +/// Host -> device opcodes. `Response` and `Event` frames echo the opcode of the +/// request they belong to, so three frame types share this namespace. #[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] #[repr(u8)] -pub enum CommandId { - // Notifications (device -> host, unsolicited). - Hello = 0x00, - // Requests (host -> device). - Ping = 0x01, - Reset = 0x02, - GetFirmwareInfo = 0x03, - GetHwAddress = 0x04, - Shutdown = 0x05, +pub enum RequestCommand { + // Device management. + Reset = 0x00, + GetFirmwareInfo = 0x01, + GetHwAddress = 0x02, + Shutdown = 0x03, + // Phased bring-up: configure, load state into the stopped stack, start. Configure = 0x10, LoadKeyTable = 0x11, LoadChildren = 0x12, LoadAddressCache = 0x13, - StartNetwork = 0x14, - LoadRouteTable = 0x15, - LoadSourceRoutes = 0x16, - GetNetworkInfo = 0x18, - ScanKeyTable = 0x19, - ScanChildren = 0x1A, - ScanAddressCache = 0x1B, - ScanRouteTable = 0x1C, - SendAps = 0x20, - PermitJoins = 0x21, - SetChannel = 0x22, - SetNwkUpdateId = 0x23, - SetProvisionalKey = 0x24, - EnergyScan = 0x25, - NetworkScan = 0x26, - PacketCapture = 0x27, - PacketCaptureChannel = 0x28, - SetTunable = 0x29, - CancelRequest = 0x2A, - // More notifications. - ReceivedAps = 0x30, - SendConfirm = 0x31, - ApsAckConfirm = 0x32, - DeviceJoined = 0x33, - DeviceLeft = 0x34, - FrameCounter = 0x35, - LinkKey = 0x36, - ApsDecryptFailure = 0x37, - LastReset = 0x38, - RouteChanged = 0x39, - RouteRecord = 0x3A, - ApsFrameCounter = 0x3B, - RouteRemoved = 0x3C, -} - -impl From for u8 { - fn from(id: CommandId) -> Self { - id as Self + LoadRouteTable = 0x14, + LoadSourceRoutes = 0x15, + StartNetwork = 0x16, + // Introspection: one-shot info and streamed table scans. + GetNetworkInfo = 0x20, + ScanKeyTable = 0x21, + ScanChildren = 0x22, + ScanAddressCache = 0x23, + ScanRouteTable = 0x24, + // The send path and runtime control. + SendUnicast = 0x30, + SendBroadcast = 0x31, + SendGroupcast = 0x32, + CancelRequest = 0x33, + PermitJoins = 0x34, + SetChannel = 0x35, + SetNwkUpdateId = 0x36, + SetProvisionalKey = 0x37, + SetTunable = 0x38, + // Radio scans and packet capture. + EnergyScan = 0x40, + NetworkScan = 0x41, + PacketCapture = 0x42, + PacketCaptureChannel = 0x43, +} + +impl From for u8 { + fn from(command: RequestCommand) -> Self { + command as Self } } -/// How the host must route a device -> host frame. Inbound frames are always -/// requests, so they carry no frame type. `Error` folds into `Response`: a -/// response carries a [`Status`], so `Status::Ok` + payload is success and any -/// other status + message is failure — one terminal path for the client. -#[abstract_bits(bits = 8)] +/// Device -> host opcodes for unsolicited [`FrameType::Notification`] frames. A +/// separate namespace from [`RequestCommand`]; the frame type disambiguates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] +#[repr(u8)] +pub enum NotificationCommand { + // Connection lifecycle. + Hello = 0x00, + LastReset = 0x01, + // Traffic: received frames and send verdicts. + ReceivedAps = 0x10, + SendConfirm = 0x11, + ApsAckConfirm = 0x12, + BroadcastConfirm = 0x13, + // Network membership. + DeviceJoined = 0x20, + DeviceLeft = 0x21, + // Security and routing state the host must persist or act on. + FrameCounter = 0x30, + ApsFrameCounter = 0x31, + LinkKey = 0x32, + ApsDecryptFailure = 0x33, + RouteRecord = 0x34, +} + +impl From for u8 { + fn from(command: NotificationCommand) -> Self { + command as Self + } +} + +/// What a frame is, and which namespace its command byte indexes (see the module +/// docs). Two bits of the [`Header`]. +#[abstract_bits(bits = 2)] #[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] #[repr(u8)] pub enum FrameType { + Request = 0, Response = 1, Event = 2, Notification = 3, } -/// Response status. `Ok` carries the response payload; any other value carries a -/// diagnostic message string instead (see [`Error`]). +/// Response status. `Ok` is followed by the command's response payload; any other +/// value by that status's tail (empty unless documented on the variant). The set is +/// append-only — a new failure condition gets a new code, never a repurposed one — +/// and clients must treat an unknown value as a generic failure. #[abstract_bits(bits = 8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] #[repr(u8)] pub enum Status { - Ok = 0, - Parse = 1, - UnknownCommand = 2, - InvalidState = 3, - NotConfigured = 4, - RadioError = 5, - NetworkStartFailed = 6, - TransmitFailed = 7, - ScanFailed = 8, - InvalidRequest = 9, + Ok = 0x00, + /// The command is known but its payload did not decode. + MalformedPayload = 0x01, + UnknownCommand = 0x02, + /// The request decoded but is semantically invalid: a non-request frame type, + /// an empty source route, a bad tunable name or value. + InvalidRequest = 0x03, + // Lifecycle (the phased bring-up: unconfigured -> load window -> started). + NotConfigured = 0x10, + NotStarted = 0x11, + AlreadyStarted = 0x12, + // Send admission (synchronous rejects; delivery failures ride [`SendStatus`]). + /// Tail: `retry_in_ms: u32` ([`RateLimitedPayload`]). + RateLimited = 0x20, + BudgetExhausted = 0x21, + PayloadTooLong = 0x22, + SecurityUnavailable = 0x23, + /// No route to the destination exists, and the request's route control forbade + /// discovering one. + NoRoute = 0x24, + // Execution. + RadioError = 0x30, + NetworkStartFailed = 0x31, + ScanFailed = 0x32, + // Device-side failures. + /// The device built a reply exceeding [`MAX_FRAME`]. + ResponseTooLarge = 0x40, } /// Role a `configure` sets the coordinator up as. @@ -159,17 +224,19 @@ pub enum LeaveReason { KeepaliveTimeout = 2, } -/// The 3-byte header of every host -> device frame (always a request). +/// The 3-byte header leading every frame in both directions (the module docs show +/// the bit layout). `request_id` is `u14` on the wire, `u16` in Rust. #[abstract_bits] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RequestHeader { +pub struct Header { pub command: u8, - pub request_id: RequestId, + pub frame_type: FrameType, + pub request_id: u14, } -impl RequestHeader { - /// Parse the request header off the front of a frame, returning it and the - /// number of bytes it consumed (the payload starts there). +impl Header { + /// Parse the header off the front of a frame, returning it and the number of + /// bytes it consumed (the payload starts there). pub fn parse(bytes: &[u8]) -> Option<(Self, usize)> { let mut reader = BitReader::from(bytes); let header = Self::read_abstract_bits(&mut reader).ok()?; @@ -177,15 +244,6 @@ impl RequestHeader { } } -/// The 4-byte header of every device -> host frame. -#[abstract_bits] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReplyHeader { - pub frame_type: FrameType, - pub command: u8, - pub request_id: RequestId, -} - // -- payload structs ------------------------------------------------------------- #[abstract_bits] @@ -349,15 +407,14 @@ pub struct RouteEntry { #[abstract_bits] #[derive(Debug, Clone)] -pub struct SendApsFlags { +pub struct SendUnicastFlags { pub has_eui64: bool, pub aps_ack: bool, pub aps_encryption: bool, - pub delivery_mode: ApsDeliveryMode, /// The destination is a sleepy device. It only sees frames by polling its parent, /// so the APS ack wait must cover a poll cycle. pub sleepy_destination: bool, - pub reserved: u2, + pub reserved: u4, } /// How the host wants a unicast routed. @@ -386,10 +443,12 @@ pub struct SourceRouteRelays { pub relays: Vec, } +/// A unicast APS send. The route control and its optional `next_hop`/`relays` are +/// unicast-only; broadcast and groupcast have their own commands. #[abstract_bits] #[derive(Debug, Clone)] -pub struct SendApsPayload { - pub flags: SendApsFlags, +pub struct SendUnicastPayload { + pub flags: SendUnicastFlags, pub destination: Nwk, pub destination_eui64: Eui64, pub profile_id: u16, @@ -413,6 +472,55 @@ pub struct SendApsPayload { pub asdu: Vec, } +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SendBroadcastFlags { + pub reserved: u8, +} + +/// A broadcast APS send to a broadcast sink (`destination`). Never APS-secured or acked, +/// so it carries no flags, EUI64, or route control. +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SendBroadcastPayload { + pub flags: SendBroadcastFlags, + pub destination: Nwk, + pub profile_id: u16, + pub cluster_id: u16, + pub src_ep: u8, + pub dst_ep: u8, + pub aps_seq: u8, + pub radius: u8, + pub priority: u8, // i8 two's complement + pub asdu_len: u16, + #[abstract_bits(length_from = asdu_len)] + pub asdu: Vec, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SendGroupcastFlags { + pub reserved: u8, +} + +/// A groupcast (APS multicast) send. The group lives in the APS header and the NWK frame +/// is broadcast to rx-on-when-idle devices, so there is no destination endpoint. +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SendGroupcastPayload { + pub flags: SendGroupcastFlags, + pub group_id: u16, + pub profile_id: u16, + pub cluster_id: u16, + pub src_ep: u8, + pub aps_seq: u8, + pub radius: u8, + pub priority: u8, // i8 two's complement + pub asdu_len: u16, + #[abstract_bits(length_from = asdu_len)] + pub asdu: Vec, +} + /// Cancels an in-flight send by the `request_id` it was issued under. Best-effort: /// the send is torn down only if it is still in a pre-delivery state (queued, awaiting /// route discovery, or between retries). The reply reports whether anything was caught. @@ -515,15 +623,15 @@ pub struct CapturedPacketPayload { pub psdu: Vec, } -/// The body of a failed response: a `Status` other than `Ok` followed by a -/// diagnostic, human-readable message. +/// The body of a `Status::RateLimited` response: the delay (milliseconds) after which +/// the host may retry the rejected send. Leads with `status` like every failed reply, +/// so the client dispatches on that byte and parses this body when it reads +/// `RateLimited`. #[abstract_bits] #[derive(Debug, Clone)] -pub struct ErrorPayload { +pub struct RateLimitedPayload { pub status: Status, - pub message_len: u16, - #[abstract_bits(length_from = message_len)] - pub message: Vec, + pub retry_in_ms: u32, } #[abstract_bits] @@ -563,25 +671,45 @@ pub struct ReceivedApsPayload { pub data: Vec, } +/// A send's terminal verdict, carried in its `SendConfirm` / `ApsAckConfirm` / +/// `BroadcastConfirm` notification. Mirrors the driver's `DeliveryError`; admission +/// failures ride the synchronous `Error` frame's [`Status`] instead. +#[abstract_bits(bits = 8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] +#[repr(u8)] +pub enum SendStatus { + Success = 0, + RouteDiscoveryTimeout = 1, + RouteDiscoveryNoEntry = 2, + RouteInactiveAfterDiscovery = 3, + NwkNoAck = 4, + CcaFailure = 5, + TransmitFailed = 6, + ApsAckTimeout = 7, + BroadcastQuorumNotReached = 8, + IndirectExpired = 9, + FrameBudgetExhausted = 10, + Cancelled = 11, + RadioError = 12, +} + #[abstract_bits] #[derive(Debug, Clone)] pub struct SendConfirmPayload { - pub confirmed: bool, - pub reserved: u7, - pub next_hop: Nwk, // 0xFFFF when unknown - pub reason_len: u16, - #[abstract_bits(length_from = reason_len)] - pub reason: Vec, + pub status: SendStatus, } #[abstract_bits] #[derive(Debug, Clone)] pub struct ApsAckConfirmPayload { - pub acked: bool, - pub reserved: u7, - pub reason_len: u16, - #[abstract_bits(length_from = reason_len)] - pub reason: Vec, + pub status: SendStatus, +} + +/// The passive-ack quorum verdict of a broadcast or groupcast send. +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct BroadcastConfirmPayload { + pub status: SendStatus, } #[abstract_bits] @@ -620,20 +748,6 @@ pub struct LinkKeyPayload { pub key: Key, } -#[abstract_bits] -#[derive(Debug, Clone)] -pub struct RouteChangedPayload { - pub destination: Nwk, - pub next_hop: Nwk, - pub path_cost: u8, -} - -#[abstract_bits] -#[derive(Debug, Clone)] -pub struct RouteRemovedPayload { - pub destination: Nwk, -} - #[abstract_bits] #[derive(Debug, Clone)] pub struct RouteRecordPayload { @@ -662,7 +776,6 @@ pub struct ApsDecryptFailPayload { /// A parsed host -> device request. pub enum Request { - Ping, Reset(ResetPayload), GetFirmwareInfo, GetHwAddress, @@ -679,7 +792,9 @@ pub enum Request { ScanChildren, ScanAddressCache, ScanRouteTable, - SendAps(SendApsPayload), + SendUnicast(SendUnicastPayload), + SendBroadcast(SendBroadcastPayload), + SendGroupcast(SendGroupcastPayload), PermitJoins(PermitJoinsPayload), SetChannel(ChannelPayload), SetNwkUpdateId(NwkUpdateIdPayload), @@ -693,44 +808,37 @@ pub enum Request { } impl Request { - pub fn parse(command: CommandId, payload: &[u8]) -> Result { + pub fn parse(command: RequestCommand, payload: &[u8]) -> Result { Ok(match command { - CommandId::Ping => Self::Ping, - CommandId::Reset => Self::Reset(require(payload, "reset")?), - CommandId::GetFirmwareInfo => Self::GetFirmwareInfo, - CommandId::GetHwAddress => Self::GetHwAddress, - CommandId::Shutdown => Self::Shutdown, - CommandId::Configure => Self::Configure(require(payload, "configure")?), - CommandId::LoadKeyTable => Self::LoadKeyTable(require(payload, "key entries")?), - CommandId::LoadChildren => Self::LoadChildren(require(payload, "child entries")?), - CommandId::LoadAddressCache => { - Self::LoadAddressCache(require(payload, "addr entries")?) - } - CommandId::LoadRouteTable => Self::LoadRouteTable(require(payload, "route entries")?), - CommandId::LoadSourceRoutes => { - Self::LoadSourceRoutes(require(payload, "source route entries")?) - } - CommandId::StartNetwork => Self::StartNetwork, - CommandId::GetNetworkInfo => Self::GetNetworkInfo, - CommandId::ScanKeyTable => Self::ScanKeyTable, - CommandId::ScanChildren => Self::ScanChildren, - CommandId::ScanAddressCache => Self::ScanAddressCache, - CommandId::ScanRouteTable => Self::ScanRouteTable, - CommandId::SendAps => Self::SendAps(require(payload, "send_aps")?), - CommandId::PermitJoins => Self::PermitJoins(require(payload, "permit_joins")?), - CommandId::SetChannel => Self::SetChannel(require(payload, "channel")?), - CommandId::SetNwkUpdateId => Self::SetNwkUpdateId(require(payload, "update id")?), - CommandId::SetProvisionalKey => Self::SetProvisionalKey(require(payload, "key")?), - CommandId::EnergyScan => Self::EnergyScan(require(payload, "energy_scan")?), - CommandId::NetworkScan => Self::NetworkScan(require(payload, "network_scan")?), - CommandId::PacketCapture => Self::PacketCapture(require(payload, "channel")?), - CommandId::PacketCaptureChannel => { - Self::PacketCaptureChannel(require(payload, "channel")?) - } - CommandId::SetTunable => Self::SetTunable(require(payload, "set_tunable")?), - CommandId::CancelRequest => Self::CancelRequest(require(payload, "cancel_request")?), - // Everything else (the device -> host notification opcodes) is not a request. - _ => return Err(Error::new(Status::UnknownCommand, "not a request")), + RequestCommand::Reset => Self::Reset(require(payload)?), + RequestCommand::GetFirmwareInfo => Self::GetFirmwareInfo, + RequestCommand::GetHwAddress => Self::GetHwAddress, + RequestCommand::Shutdown => Self::Shutdown, + RequestCommand::Configure => Self::Configure(require(payload)?), + RequestCommand::LoadKeyTable => Self::LoadKeyTable(require(payload)?), + RequestCommand::LoadChildren => Self::LoadChildren(require(payload)?), + RequestCommand::LoadAddressCache => Self::LoadAddressCache(require(payload)?), + RequestCommand::LoadRouteTable => Self::LoadRouteTable(require(payload)?), + RequestCommand::LoadSourceRoutes => Self::LoadSourceRoutes(require(payload)?), + RequestCommand::StartNetwork => Self::StartNetwork, + RequestCommand::GetNetworkInfo => Self::GetNetworkInfo, + RequestCommand::ScanKeyTable => Self::ScanKeyTable, + RequestCommand::ScanChildren => Self::ScanChildren, + RequestCommand::ScanAddressCache => Self::ScanAddressCache, + RequestCommand::ScanRouteTable => Self::ScanRouteTable, + RequestCommand::SendUnicast => Self::SendUnicast(require(payload)?), + RequestCommand::SendBroadcast => Self::SendBroadcast(require(payload)?), + RequestCommand::SendGroupcast => Self::SendGroupcast(require(payload)?), + RequestCommand::PermitJoins => Self::PermitJoins(require(payload)?), + RequestCommand::SetChannel => Self::SetChannel(require(payload)?), + RequestCommand::SetNwkUpdateId => Self::SetNwkUpdateId(require(payload)?), + RequestCommand::SetProvisionalKey => Self::SetProvisionalKey(require(payload)?), + RequestCommand::EnergyScan => Self::EnergyScan(require(payload)?), + RequestCommand::NetworkScan => Self::NetworkScan(require(payload)?), + RequestCommand::PacketCapture => Self::PacketCapture(require(payload)?), + RequestCommand::PacketCaptureChannel => Self::PacketCaptureChannel(require(payload)?), + RequestCommand::SetTunable => Self::SetTunable(require(payload)?), + RequestCommand::CancelRequest => Self::CancelRequest(require(payload)?), }) } } @@ -758,46 +866,50 @@ impl Response { Self::CancelResult(payload) => append(&mut bytes, payload), }; if !fits { - return Error::new(Status::InvalidRequest, "reply too large") - .frame(command, request_id); + return Error::Status(Status::ResponseTooLarge).frame(command, request_id); } bytes } } -/// A failed reply: any non-`Ok` [`Status`] plus a diagnostic message. The client -/// branches on the status; the text is for humans. -pub struct Error { - pub status: Status, - pub message: String, +/// A failed reply. The client always branches on the `Status` byte that leads the +/// body; each variant serializes that status's tail (nothing, for most of them). +pub enum Error { + Status(Status), + /// `Status::RateLimited` with a machine-readable retry delay, so the host can pace + /// itself instead of busy-retrying a rejected broadcast. + RateLimited { + retry_in: Duration, + }, } -impl Error { - pub fn new(status: Status, message: &str) -> Self { - Self { - status, - message: message.to_string(), - } - } - - pub fn parse(what: &str) -> Self { - Self::new(Status::Parse, what) +impl From for Error { + fn from(status: Status) -> Self { + Self::Status(status) } +} - pub fn not_configured() -> Self { - Self::new(Status::NotConfigured, "") +impl Error { + pub const fn rate_limited(retry_in: Duration) -> Self { + Self::RateLimited { retry_in } } pub fn frame(&self, command: u8, request_id: RequestId) -> Vec { - let message = &self.message.as_bytes()[..self.message.len().min(255)]; let mut bytes = envelope(FrameType::Response, command, request_id); - append( - &mut bytes, - &ErrorPayload { - status: self.status, - message: message.to_vec(), - }, - ); + match self { + Self::Status(status) => { + append(&mut bytes, status); + } + Self::RateLimited { retry_in } => { + append( + &mut bytes, + &RateLimitedPayload { + status: Status::RateLimited, + retry_in_ms: retry_in.as_millis() as u32, + }, + ); + } + } bytes } } @@ -817,13 +929,13 @@ pub enum Event { impl Event { pub fn frame(&self, request_id: RequestId) -> Option> { let command = match self { - Self::KeyEntry(_) => CommandId::ScanKeyTable, - Self::Child(_) => CommandId::ScanChildren, - Self::Address(_) => CommandId::ScanAddressCache, - Self::Route(_) => CommandId::ScanRouteTable, - Self::EnergyResult(_) => CommandId::EnergyScan, - Self::Beacon(_) => CommandId::NetworkScan, - Self::CapturedPacket(_) => CommandId::PacketCapture, + Self::KeyEntry(_) => RequestCommand::ScanKeyTable, + Self::Child(_) => RequestCommand::ScanChildren, + Self::Address(_) => RequestCommand::ScanAddressCache, + Self::Route(_) => RequestCommand::ScanRouteTable, + Self::EnergyResult(_) => RequestCommand::EnergyScan, + Self::Beacon(_) => RequestCommand::NetworkScan, + Self::CapturedPacket(_) => RequestCommand::PacketCapture, }; let mut bytes = envelope(FrameType::Event, command.into(), request_id); let fits = match self { @@ -847,13 +959,12 @@ pub enum Notification { ReceivedAps(ReceivedApsPayload), SendConfirm(RequestId, SendConfirmPayload), ApsAckConfirm(RequestId, ApsAckConfirmPayload), + BroadcastConfirm(RequestId, BroadcastConfirmPayload), DeviceJoined(DeviceJoinedPayload), DeviceLeft(DeviceLeftPayload), FrameCounter(FrameCounterPayload), LinkKey(LinkKeyPayload), ApsDecryptFailure(ApsDecryptFailPayload), - RouteChanged(RouteChangedPayload), - RouteRemoved(RouteRemovedPayload), RouteRecord(RouteRecordPayload), ApsFrameCounter(ApsFrameCounterPayload), } @@ -861,20 +972,21 @@ pub enum Notification { impl Notification { pub fn frame(&self) -> Option> { let (command, request_id) = match self { - Self::Hello(_) => (CommandId::Hello, 0), - Self::LastReset(_) => (CommandId::LastReset, 0), - Self::ReceivedAps(_) => (CommandId::ReceivedAps, 0), - Self::SendConfirm(request_id, _) => (CommandId::SendConfirm, *request_id), - Self::ApsAckConfirm(request_id, _) => (CommandId::ApsAckConfirm, *request_id), - Self::DeviceJoined(_) => (CommandId::DeviceJoined, 0), - Self::DeviceLeft(_) => (CommandId::DeviceLeft, 0), - Self::FrameCounter(_) => (CommandId::FrameCounter, 0), - Self::LinkKey(_) => (CommandId::LinkKey, 0), - Self::ApsDecryptFailure(_) => (CommandId::ApsDecryptFailure, 0), - Self::RouteChanged(_) => (CommandId::RouteChanged, 0), - Self::RouteRemoved(_) => (CommandId::RouteRemoved, 0), - Self::RouteRecord(_) => (CommandId::RouteRecord, 0), - Self::ApsFrameCounter(_) => (CommandId::ApsFrameCounter, 0), + Self::Hello(_) => (NotificationCommand::Hello, 0), + Self::LastReset(_) => (NotificationCommand::LastReset, 0), + Self::ReceivedAps(_) => (NotificationCommand::ReceivedAps, 0), + Self::SendConfirm(request_id, _) => (NotificationCommand::SendConfirm, *request_id), + Self::ApsAckConfirm(request_id, _) => (NotificationCommand::ApsAckConfirm, *request_id), + Self::BroadcastConfirm(request_id, _) => { + (NotificationCommand::BroadcastConfirm, *request_id) + } + Self::DeviceJoined(_) => (NotificationCommand::DeviceJoined, 0), + Self::DeviceLeft(_) => (NotificationCommand::DeviceLeft, 0), + Self::FrameCounter(_) => (NotificationCommand::FrameCounter, 0), + Self::LinkKey(_) => (NotificationCommand::LinkKey, 0), + Self::ApsDecryptFailure(_) => (NotificationCommand::ApsDecryptFailure, 0), + Self::RouteRecord(_) => (NotificationCommand::RouteRecord, 0), + Self::ApsFrameCounter(_) => (NotificationCommand::ApsFrameCounter, 0), }; let mut bytes = envelope(FrameType::Notification, command.into(), request_id); let fits = match self { @@ -883,13 +995,12 @@ impl Notification { Self::ReceivedAps(payload) => append(&mut bytes, payload), Self::SendConfirm(_, payload) => append(&mut bytes, payload), Self::ApsAckConfirm(_, payload) => append(&mut bytes, payload), + Self::BroadcastConfirm(_, payload) => append(&mut bytes, payload), Self::DeviceJoined(payload) => append(&mut bytes, payload), Self::DeviceLeft(payload) => append(&mut bytes, payload), Self::FrameCounter(payload) => append(&mut bytes, payload), Self::LinkKey(payload) => append(&mut bytes, payload), Self::ApsDecryptFailure(payload) => append(&mut bytes, payload), - Self::RouteChanged(payload) => append(&mut bytes, payload), - Self::RouteRemoved(payload) => append(&mut bytes, payload), Self::RouteRecord(payload) => append(&mut bytes, payload), Self::ApsFrameCounter(payload) => append(&mut bytes, payload), }; @@ -920,9 +1031,9 @@ pub fn envelope(frame_type: FrameType, command: u8, request_id: RequestId) -> Ve let mut bytes = Vec::with_capacity(32); append( &mut bytes, - &ReplyHeader { - frame_type, + &Header { command, + frame_type, request_id, }, ); @@ -935,6 +1046,6 @@ pub fn parse(payload: &[u8]) -> Option { T::read_abstract_bits(&mut reader).ok() } -pub fn require(payload: &[u8], what: &str) -> Result { - parse(payload).ok_or_else(|| Error::parse(what)) +pub fn require(payload: &[u8]) -> Result { + parse(payload).ok_or(Error::Status(Status::MalformedPayload)) } diff --git a/crates/ziggurat-server/Cargo.toml b/crates/ziggurat-server/Cargo.toml index e8e6707..8f33598 100644 --- a/crates/ziggurat-server/Cargo.toml +++ b/crates/ziggurat-server/Cargo.toml @@ -16,14 +16,14 @@ ziggurat-driver.workspace = true ziggurat-zigbee.workspace = true ziggurat-protocol.workspace = true -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.6", features = ["derive"] } tracing = "0.1" futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] } -cobs = "0.2" +cobs = "0.5" tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } -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"] } +tokio = { version = "1.53.0", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util", "io-std"] } +tokio-serial = "5.5" +tokio-tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } [[bin]] name = "ziggurat" diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 28cc0cf..f14901e 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -17,7 +17,7 @@ use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; use ziggurat_driver::runtime::TokioSpawner; -use ziggurat_driver::zigbee_stack::{Tunables, ZigbeeNotification, ZigbeeStack}; +use ziggurat_driver::zigbee_stack::{SendHandle, Tunables, ZigbeeNotification, ZigbeeStack}; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; use ziggurat_phy_spinel::SpinelPhy; @@ -52,9 +52,21 @@ const fn capture_config(channel: u8) -> RadioConfig { } } -/// Map a serial-port open failure to a protocol error. -fn radio_error(e: impl ToString) -> proto::Error { - proto::Error::new(proto::Status::RadioError, &e.to_string()) +/// Log a radio-layer failure and map it to its wire error. The diagnostic stays in +/// the log (correlated by the request span); the wire carries only the status. +fn radio_error(e: impl std::fmt::Display) -> proto::Error { + tracing::warn!("Radio error: {e}"); + proto::Status::RadioError.into() +} + +/// One connection's send-confirmation machinery: a per-connection [`proto::SendTracker`] +/// and its shared wake. Confirms are per-connection (no cross-client `request_id` +/// collisions on the shared hub) and lossless (they bypass the drop-oldest notification +/// queue). Cheap to clone into each spawned request handler. +#[derive(Clone)] +struct Sends { + tracker: Arc>, + wake: Arc, } pub struct ZigguratServer { @@ -194,6 +206,55 @@ impl ZigguratServer { }) } + /// Set up one connection's [`Sends`] and spawn its wake-and-sweep reactor: the task + /// waits on the tracker's shared wake, sweeps for the confirm frames now owed, and + /// writes them to this connection's outbound queue. + fn spawn_send_sweeper(&self, outbound: mpsc::Sender>) -> (Sends, JoinHandle<()>) { + let wake = Arc::new(ziggurat_driver::sync::Notify::new()); + let tracker = Arc::new(Mutex::new(proto::SendTracker::new(wake.clone()))); + + let sweep_tracker = tracker.clone(); + let sweep_wake = wake.clone(); + let task = tokio::spawn(async move { + loop { + sweep_wake.notified().await; + let frames: Vec> = { + let mut tracker = sweep_tracker.lock().unwrap(); + tracker + .sweep() + .into_iter() + .filter_map(|n| n.frame()) + .collect() + }; + for frame in frames { + if outbound.send(frame).await.is_err() { + return; + } + } + } + }); + + (Sends { tracker, wake }, task) + } + + /// Register a freshly-issued send in this connection's tracker, then self-notify so + /// the sweep runs even if the slot resolved before registration (it re-checks + /// everything, closing the race). + fn register_send( + &self, + request_id: proto::RequestId, + sends: &Sends, + tracked: (SendHandle, proto::ConfirmKind), + ) { + let (handle, confirm_kind) = tracked; + sends + .tracker + .lock() + .unwrap() + .insert(request_id, handle, confirm_kind); + sends.wake.notify_one(); + } + async fn handle_connection( self: &Arc, socket: S, @@ -225,11 +286,12 @@ impl ZigguratServer { outbound_tx.send(self.hello_frame()).await?; let notification_forwarder = self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); + let (sends, send_sweeper) = self.spawn_send_sweeper(outbound_tx.clone()); while let Some(message) = stream.next().await { match message { Ok(Message::Binary(data)) => { - if !self.handle_frame(&data, addr, &outbound_tx).await { + if !self.handle_frame(&data, addr, &outbound_tx, &sends).await { break; } } @@ -243,6 +305,7 @@ impl ZigguratServer { } notification_forwarder.abort(); + send_sweeper.abort(); drop(outbound_tx); let _ = writer.await; @@ -281,6 +344,7 @@ impl ZigguratServer { let _ = outbound_tx.send(self.hello_frame()).await; let notification_forwarder = self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); + let (sends, send_sweeper) = self.spawn_send_sweeper(outbound_tx.clone()); let mut reader = reader; let mut buffer = [0u8; 1024]; @@ -302,20 +366,19 @@ impl ZigguratServer { accumulator.clear(); match decoded { Ok(frame) => { - if !self.handle_frame(&frame, addr, &outbound_tx).await { + if !self.handle_frame(&frame, addr, &outbound_tx, &sends).await { break 'read; } } - Err(()) => { - let _ = outbound_tx - .send(proto::Error::parse("cobs").frame(0, 0)) - .await; - } + // Unframeable input is attributable to no request, so nothing + // useful can be sent back; it only merits a log line. + Err(e) => tracing::warn!("COBS decode failed: {e:?}"), } } } notification_forwarder.abort(); + send_sweeper.abort(); drop(outbound_tx); let _ = writer_task.await; @@ -335,24 +398,34 @@ impl ZigguratServer { bytes: &[u8], addr: &str, outbound: &mpsc::Sender>, + sends: &Sends, ) -> bool { - let Some((header, consumed)) = proto::RequestHeader::parse(bytes) else { + let Some((header, consumed)) = proto::Header::parse(bytes) else { + tracing::warn!("Frame from {addr} is shorter than a header"); + return true; + }; + let payload = &bytes[consumed..]; + + if header.frame_type != proto::FrameType::Request { + tracing::warn!("Inbound frame from {addr} is not a request"); return outbound - .send(proto::Error::parse("truncated header").frame(0, 0)) + .send( + proto::Error::from(proto::Status::InvalidRequest) + .frame(header.command, header.request_id), + ) .await .is_ok(); - }; - let payload = &bytes[consumed..]; + } - let request = proto::CommandId::try_from(header.command) - .map_err(|_| proto::Error::new(proto::Status::UnknownCommand, "")) + let request = proto::RequestCommand::try_from(header.command) + .map_err(|_| proto::Error::from(proto::Status::UnknownCommand)) .and_then(|command| proto::Request::parse(command, payload)); tracing::debug!("Request from {addr}: command={:#04x}", header.command); match request { Ok(request) => { - self.dispatch(header, request, outbound.clone()); + self.dispatch(header, request, outbound.clone(), sends.clone()); true } Err(e) => outbound @@ -367,9 +440,10 @@ impl ZigguratServer { /// emits exactly one response or error, preceded by any streamed events. fn dispatch( self: &Arc, - header: proto::RequestHeader, + header: proto::Header, request: proto::Request, outbound: mpsc::Sender>, + sends: Sends, ) { let server = self.clone(); @@ -380,7 +454,7 @@ impl ZigguratServer { tokio::spawn( async move { let request_id = header.request_id; - let reply = server.handle(request_id, request, &outbound).await; + let reply = server.handle(request_id, request, &outbound, &sends).await; let frame = match reply { Ok(response) => response.frame(header.command, request_id), Err(e) => e.frame(header.command, request_id), @@ -396,18 +470,17 @@ impl ZigguratServer { /// The stack, in any state after `configure`. fn configured(&self) -> Result>, proto::Error> { self.current_stack() - .ok_or_else(proto::Error::not_configured) + .ok_or(proto::Error::Status(proto::Status::NotConfigured)) } /// The stack, if it is in the load window (configured but not started). fn loadable(&self) -> Result>, proto::Error> { match self.current_stack() { - Some(_) if self.started.load(Ordering::SeqCst) => Err(proto::Error::new( - proto::Status::InvalidState, - "network already started", - )), + Some(_) if self.started.load(Ordering::SeqCst) => { + Err(proto::Status::AlreadyStarted.into()) + } Some(stack) => Ok(stack), - None => Err(proto::Error::not_configured()), + None => Err(proto::Status::NotConfigured.into()), } } @@ -415,7 +488,8 @@ impl ZigguratServer { fn running(&self) -> Result>, proto::Error> { match self.current_stack() { Some(stack) if self.started.load(Ordering::SeqCst) => Ok(stack), - _ => Err(proto::Error::not_configured()), + Some(_) => Err(proto::Status::NotStarted.into()), + None => Err(proto::Status::NotConfigured.into()), } } @@ -426,10 +500,10 @@ impl ZigguratServer { request_id: proto::RequestId, request: proto::Request, outbound: &mpsc::Sender>, + sends: &Sends, ) -> Result { use proto::Request as R; match request { - R::Ping => Ok(proto::Response::Empty), R::Reset(payload) => self.handle_reset(payload).await, R::GetFirmwareInfo => Ok(proto::Response::FirmwareInfo(proto::FirmwareInfoPayload { protocol_version: proto::PROTOCOL_VERSION, @@ -493,8 +567,19 @@ impl ZigguratServer { .collect(); self.stream_scan(request_id, outbound, events).await } - R::SendAps(payload) => { - proto::send_aps(&*self.running()?, payload, request_id)?; + R::SendUnicast(payload) => { + let tracked = proto::send_unicast(&*self.running()?, payload)?; + self.register_send(request_id, sends, tracked); + Ok(proto::Response::Empty) + } + R::SendBroadcast(payload) => { + let tracked = proto::send_broadcast(&*self.running()?, payload)?; + self.register_send(request_id, sends, tracked); + Ok(proto::Response::Empty) + } + R::SendGroupcast(payload) => { + let tracked = proto::send_groupcast(&*self.running()?, payload)?; + self.register_send(request_id, sends, tracked); Ok(proto::Response::Empty) } R::PermitJoins(payload) => { @@ -532,10 +617,15 @@ impl ZigguratServer { proto::set_tunable(&*self.configured()?, &payload)?; Ok(proto::Response::Empty) } - R::CancelRequest(payload) => Ok(proto::Response::CancelResult(proto::cancel_request( - &*self.running()?, - &payload, - ))), + R::CancelRequest(payload) => { + let stack = self.running()?; + let mut tracker = sends.tracker.lock().unwrap(); + Ok(proto::Response::CancelResult(proto::cancel_request( + &*stack, + &mut tracker, + &payload, + ))) + } } } @@ -549,9 +639,7 @@ impl ZigguratServer { ) -> Result { if payload.hard { let phy = self.phy().await.map_err(radio_error)?; - phy.reset() - .await - .map_err(|e| proto::Error::new(proto::Status::RadioError, &e.to_string()))?; + phy.reset().await.map_err(radio_error)?; } Ok(proto::Response::Empty) @@ -559,10 +647,7 @@ impl ZigguratServer { async fn handle_get_hw_address(&self) -> Result { let phy = self.phy().await.map_err(radio_error)?; - let ieee = phy - .hw_address() - .await - .map_err(|e| proto::Error::new(proto::Status::RadioError, &e.to_string()))?; + let ieee = phy.hw_address().await.map_err(radio_error)?; Ok(proto::Response::HwAddress(proto::HwAddressPayload { ieee })) } @@ -573,7 +658,7 @@ impl ZigguratServer { let phy = self.phy().await.map_err(radio_error)?; phy.set_frame_pending_table(&[], &[]) .await - .map_err(|e| proto::Error::new(proto::Status::RadioError, &e.to_string()))?; + .map_err(radio_error)?; Ok(proto::Response::Empty) } @@ -621,15 +706,12 @@ impl ZigguratServer { let stack = self.loadable()?; if let Err(e) = stack.start_network().await { - return Err(proto::Error::new( - proto::Status::NetworkStartFailed, - &e.to_string(), - )); + tracing::warn!("Network start failed: {e}"); + return Err(proto::Status::NetworkStartFailed.into()); } - let run_stack = stack.clone(); - stack.spawn_tracked(async move { - run_stack.run().await; + stack.spawn_tracked(|arc_self| async move { + arc_self.run().await; }); // Drain the stack's notification outbox into the server-level hub. The task is @@ -712,7 +794,8 @@ impl ZigguratServer { } } Err(e) => { - return Err(proto::Error::new(proto::Status::ScanFailed, &e.to_string())); + tracing::warn!("Energy scan failed: {e}"); + return Err(proto::Status::ScanFailed.into()); } } } @@ -755,8 +838,14 @@ impl ZigguratServer { match scan.await { Ok(Ok(())) => Ok(proto::Response::Empty), - Ok(Err(e)) => Err(proto::Error::new(proto::Status::ScanFailed, &e.to_string())), - Err(e) => Err(proto::Error::new(proto::Status::ScanFailed, &e.to_string())), + Ok(Err(e)) => { + tracing::warn!("Network scan failed: {e}"); + Err(proto::Status::ScanFailed.into()) + } + Err(e) => { + tracing::warn!("Network scan task failed: {e}"); + Err(proto::Status::ScanFailed.into()) + } } } @@ -774,7 +863,7 @@ impl ZigguratServer { phy.reconfigure(&capture_config(payload.channel)) .await - .map_err(|e| proto::Error::new(proto::Status::RadioError, &e.to_string()))?; + .map_err(radio_error)?; // The stream outlives this request, ending when the connection's outbound // queue closes (the client disconnected). @@ -808,7 +897,7 @@ impl ZigguratServer { phy.reconfigure(&capture_config(payload.channel)) .await - .map_err(|e| proto::Error::new(proto::Status::RadioError, &e.to_string()))?; + .map_err(radio_error)?; Ok(proto::Response::Empty) } diff --git a/crates/ziggurat-spinel/Cargo.toml b/crates/ziggurat-spinel/Cargo.toml index 564ab84..97ec1e3 100644 --- a/crates/ziggurat-spinel/Cargo.toml +++ b/crates/ziggurat-spinel/Cargo.toml @@ -13,10 +13,11 @@ ziggurat-ieee-802154.workspace = true crc_all = "0.2.2" tracing = "0.1" -num_enum = "0.7.3" -thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["rt", "time", "sync", "io-util"] } +num_enum = "0.7.6" +thiserror = "2.0.19" +tokio = { version = "1.53.0", features = ["rt", "time", "sync", "io-util"] } +tokio-serial = "5.5" [dev-dependencies] hex-literal = "1.1.0" -rand = "0.10.1" +rand = "0.10.2" diff --git a/crates/ziggurat-zigbee/Cargo.toml b/crates/ziggurat-zigbee/Cargo.toml index da810ba..925fe68 100644 --- a/crates/ziggurat-zigbee/Cargo.toml +++ b/crates/ziggurat-zigbee/Cargo.toml @@ -15,13 +15,13 @@ abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", versio 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"] } +educe = { version = "0.7.4", default-features = false, features = ["Debug"] } 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 } +num_enum = { version = "0.7.6", default-features = false } once_cell = { version = "1", default-features = false, features = ["race", "alloc"] } subtle = { version = "2", default-features = false } -thiserror = { version = "2.0.12", default-features = false } +thiserror = { version = "2.0.19", default-features = false } [dev-dependencies] hex-literal = "1.1.0" diff --git a/crates/ziggurat-zigbee/src/constants.rs b/crates/ziggurat-zigbee/src/constants.rs index ef500d3..0ed5ec5 100644 --- a/crates/ziggurat-zigbee/src/constants.rs +++ b/crates/ziggurat-zigbee/src/constants.rs @@ -271,9 +271,9 @@ tunables! { max_children: u8 = 32, /// Trust center policy: allow an unsecured (trust center) rejoin from a device that - /// has not established a unique link key. Off by default — such a rejoin re-delivers - /// the network key encrypted with the well-known key, exposing it to anyone who - /// knows that key (spec 4.7.3.6). + /// has not established a unique link key. Off by default — such a rejoin + /// re-delivers the network key encrypted with the well-known key, exposing it to + /// anyone who knows that key (spec 4.7.3.6). allow_unsecured_rejoins: bool = false, /// Trust center policy: accept an Update-Device command that is not APS-encrypted @@ -326,10 +326,11 @@ tunables! { unicast_retry_delay: Duration = Duration::from_millis(50), broadcast_delivery_time: Duration = Duration::from_millis(9000), - /// 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. + /// 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. pending_route_discovery_attempts: u8 = 1, /// The default timeout for any end device child that does not negotiate a @@ -355,8 +356,8 @@ tunables! { /// back. aps_ack_timeout_indirect: Duration = Duration::from_millis(10000), - /// `macMaxCSMABackoffs`: how many times the radio backs off on a busy channel before - /// declaring a transmit failed. + /// `macMaxCSMABackoffs`: how many times the radio backs off on a busy channel + /// before declaring a transmit failed. mac_max_csma_backoffs: u8 = 2, /// `macMaxFrameRetries`: how many times the radio retransmits a unicast that goes @@ -373,4 +374,26 @@ tunables! { /// Frame tokens reserved for transit traffic, so a host flood cannot stop the /// device from routing. forwarding_reserve_frames: usize = 16, + + /// Broadcast admission budget: a token bucket shared across traffic classes, + /// mirroring the frame budget above but bounding broadcast *rate*. + /// + /// This is the burst capacity: a discrete host action (a button press, a scene) + /// draws from it and goes out immediately, however low the sustained rate is, so + /// responsiveness comes from the burst, not from the rate below. + broadcast_budget_tokens: u8 = 15, + + /// Time to regenerate one broadcast token; the reciprocal is the sustained + /// admission rate (~0.55/s here). Held just under a stock SiLabs router's ~0.6/s + /// relay budget so the surrounding mesh always keeps headroom to carry our + /// broadcasts. + broadcast_token_refill: Duration = Duration::from_millis(1800), + + /// Broadcast tokens only stack-critical broadcasts (route discovery, key updates, + /// leaves, ZDO management) may draw; a host flood can never consume them. + broadcast_critical_reserve: u8 = 3, + + /// Broadcast tokens reserved for relayed (transit) broadcasts above the host floor, + /// so a host flood cannot stop us relaying the mesh's own broadcasts. + broadcast_forwarding_reserve: u8 = 2, } diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 38f6de3..fb1206b 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -86,8 +86,7 @@ impl TableEntry { /// Update this entry to route through `next_hop` at advertised `cost`, honoring the /// spec 3.6.4.5.3 suitability rule: an ACTIVE entry is only replaced by a strictly /// cheaper route, so a worse advertisement never clobbers a good route or forms a - /// loop. Returns whether the active route actually changed (a fresh establishment or - /// a different hop), for change tracking. + /// loop. fn consider_route(&mut self, next_hop: Nwk, cost: u8) -> bool { if self.status == Status::Active && cost >= self.path_cost { return false;