From fd55dcf789310b24c4726907f21d2631370558bf Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:28:48 -0400 Subject: [PATCH 01/17] Remove tokio dependency from `ziggurat-phy` --- Cargo.lock | 1 - crates/ziggurat-driver/src/zigbee_stack.rs | 13 ++--- crates/ziggurat-phy-spinel/src/lib.rs | 63 ++++++++++++++-------- crates/ziggurat-phy/Cargo.toml | 3 +- crates/ziggurat-phy/src/lib.rs | 32 ++++++++--- 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7478a43..df9aefa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,7 +1719,6 @@ name = "ziggurat-phy" version = "0.1.0" dependencies = [ "thiserror", - "tokio", "ziggurat-ieee-802154", ] diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index eda7d91..8bfe37f 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -5,7 +5,7 @@ use arbitrary_int::prelude::*; use tokio::time::{sleep, timeout}; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{ - ExclusiveRadio, RadioConfig, RadioError, RadioPhy, ResetEvent, RxFrame, TxFrame, TxResult, + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, RxFrame, TxFrame, TxResult, }; use ziggurat_zigbee::aps::frame::{ApsAckFrame, ApsFrame, parse_aps_frame}; use ziggurat_zigbee::beacon::ZigbeeBeacon; @@ -583,8 +583,8 @@ pub struct ZigbeeStack { pub tunables: Tunables, pub radio: Arc

, pub notification_tx: broadcast::Sender, - pub raw_frame_rx: AsyncMutex>, - pub reset_rx: AsyncMutex>, + pub raw_frame_rx: AsyncMutex, + pub reset_rx: AsyncMutex, /// Installed for the duration of a network scan; the receive loop forwards decoded /// beacons here while it is set. network_scan_tx: Mutex>>, @@ -633,11 +633,8 @@ impl ZigbeeStack

{ ) -> (Arc, broadcast::Receiver) { let (notification_tx, notification_rx) = broadcast::channel::(32); - let (raw_frame_tx, raw_frame_rx) = mpsc::channel::(32); - radio.set_rx_sink(raw_frame_tx); - - let (reset_tx, reset_rx) = mpsc::channel::(8); - radio.set_reset_sink(reset_tx); + let raw_frame_rx = radio.subscribe_rx(); + let reset_rx = radio.subscribe_reset(); let arc_stack = Arc::new_cyclic(|weak_self| Self { self_weak: weak_self.clone(), diff --git a/crates/ziggurat-phy-spinel/src/lib.rs b/crates/ziggurat-phy-spinel/src/lib.rs index aeeec86..42f8667 100644 --- a/crates/ziggurat-phy-spinel/src/lib.rs +++ b/crates/ziggurat-phy-spinel/src/lib.rs @@ -9,8 +9,8 @@ use tokio::sync::mpsc; use tokio::time::timeout; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{ - ExclusiveRadio, RadioConfig, RadioError, RadioPhy, ResetEvent, RxFrame, TxFrame, TxPriority, - TxResult, + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, ResetEvent, RxFrame, TxFrame, + TxPriority, TxResult, }; use ziggurat_spinel::client::{ ExclusiveRadio as SpinelRadioGuard, SpinelClient, SpinelError, SpinelRxFrame, SpinelTxFrame, @@ -26,12 +26,23 @@ const ENERGY_SCAN_RESULT_TIMEOUT: Duration = Duration::from_secs(2); pub struct SpinelPhy { client: Arc, home_channel: Mutex, - rx_sink: Sink, - reset_sink: Sink, + rx_slot: Slot, + reset_slot: Slot, energy_rx: AsyncMutex>, } -type Sink = Arc>>>; +/// The sender half of the currently-subscribed stream. `subscribe_*` swaps a fresh +/// channel in here; the forwarder tasks read it each time they have an item to deliver. +type Slot = Arc>>>; + +/// A subscribed stream, returned by `subscribe_*` and pulled by the driver. +pub struct TokioRx(mpsc::Receiver); + +impl Receiver for TokioRx { + async fn recv(&mut self) -> Option { + self.0.recv().await + } +} impl SpinelPhy { pub fn new(client: Arc) -> Self { @@ -44,16 +55,16 @@ impl SpinelPhy { client.set_reset_notification_receiver(reset_tx); client.spawn_reader(); - let rx_sink: Sink = Arc::new(Mutex::new(None)); - let reset_sink: Sink = Arc::new(Mutex::new(None)); - spawn_rx_forwarder(raw_rx, Arc::clone(&rx_sink)); - spawn_reset_forwarder(reset_rx, Arc::clone(&reset_sink)); + let rx_slot: Slot = Arc::new(Mutex::new(None)); + let reset_slot: Slot = Arc::new(Mutex::new(None)); + spawn_rx_forwarder(raw_rx, Arc::clone(&rx_slot)); + spawn_reset_forwarder(reset_rx, Arc::clone(&reset_slot)); Self { client, home_channel: Mutex::new(11), - rx_sink, - reset_sink, + rx_slot, + reset_slot, energy_rx: AsyncMutex::new(energy_rx), } } @@ -95,7 +106,7 @@ impl ExclusiveRadio for SpinelExclusive<'_> { } } -fn spawn_rx_forwarder(mut raw: mpsc::Receiver, sink: Sink) { +fn spawn_rx_forwarder(mut raw: mpsc::Receiver, slot: Slot) { tokio::spawn(async move { while let Some(update) = raw.recv().await { let Ok(frame) = SpinelRxFrame::from_bytes(&update.value) else { @@ -111,23 +122,23 @@ fn spawn_rx_forwarder(mut raw: mpsc::Receiver, sink: Sin lqi: frame.lqi, timestamp_us: frame.timestamp_us, }; - let current = sink.lock().clone(); - if let Some(current) = current { - let _ = current.send(rx).await; + let tx = slot.lock().clone(); + if let Some(tx) = tx { + let _ = tx.try_send(rx); } } }); } -fn spawn_reset_forwarder(mut reset: mpsc::Receiver, sink: Sink) { +fn spawn_reset_forwarder(mut reset: mpsc::Receiver, slot: Slot) { tokio::spawn(async move { while let Some(status) = reset.recv().await { let event = ResetEvent { reason: format!("{status:?}"), }; - let current = sink.lock().clone(); - if let Some(current) = current { - let _ = current.send(event).await; + let tx = slot.lock().clone(); + if let Some(tx) = tx { + let _ = tx.try_send(event); } } }); @@ -259,6 +270,8 @@ fn tx_frame_to_spinel(frame: TxFrame, channel: u8) -> SpinelTxFrame { impl RadioPhy for SpinelPhy { type Exclusive<'a> = SpinelExclusive<'a>; + type RxStream = TokioRx; + type ResetStream = TokioRx; async fn reset(&self) -> Result<(), RadioError> { self.client @@ -355,11 +368,15 @@ impl RadioPhy for SpinelPhy { Ok(max_rssi) } - fn set_rx_sink(&self, sink: mpsc::Sender) { - *self.rx_sink.lock() = Some(sink); + fn subscribe_rx(&self) -> TokioRx { + let (tx, rx) = mpsc::channel(32); + *self.rx_slot.lock() = Some(tx); + TokioRx(rx) } - fn set_reset_sink(&self, sink: mpsc::Sender) { - *self.reset_sink.lock() = Some(sink); + fn subscribe_reset(&self) -> TokioRx { + let (tx, rx) = mpsc::channel(8); + *self.reset_slot.lock() = Some(tx); + TokioRx(rx) } } diff --git a/crates/ziggurat-phy/Cargo.toml b/crates/ziggurat-phy/Cargo.toml index a0b6c13..230624d 100644 --- a/crates/ziggurat-phy/Cargo.toml +++ b/crates/ziggurat-phy/Cargo.toml @@ -11,5 +11,4 @@ repository.workspace = true [dependencies] ziggurat-ieee-802154.workspace = true -thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["sync"] } +thiserror = { version = "2.0.12", default-features = false } diff --git a/crates/ziggurat-phy/src/lib.rs b/crates/ziggurat-phy/src/lib.rs index 198780e..12df54e 100644 --- a/crates/ziggurat-phy/src/lib.rs +++ b/crates/ziggurat-phy/src/lib.rs @@ -1,11 +1,22 @@ //! Radio PHY abstraction. -use std::future::Future; -use std::time::Duration; +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use core::future::Future; +use core::time::Duration; -use tokio::sync::mpsc; use ziggurat_ieee_802154::types::{Eui64, Nwk, PanId}; +/// A pull-based stream of events the backend delivers spontaneously (received frames, +/// reset notifications). `recv` resolves to `None` once the backend has shut down. +pub trait Receiver: Send { + fn recv(&mut self) -> impl Future> + Send; +} + /// Transmit scheduling priority. Higher transmits first when the radio is contended. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct TxPriority(pub i8); @@ -90,6 +101,12 @@ pub trait RadioPhy: Send + Sync + 'static { where Self: 'a; + /// The backend's received-frame stream, handed out by [`subscribe_rx`]. + type RxStream: Receiver; + + /// The backend's reset-notification stream, handed out by [`subscribe_reset`]. + type ResetStream: Receiver; + /// Reset the radio and wait for it to come back. Clears all configuration. fn reset(&self) -> impl Future> + Send; @@ -123,11 +140,12 @@ pub trait RadioPhy: Send + Sync + 'static { /// Take exclusive control of the radio until the returned guard is dropped. fn lock(&self) -> impl Future> + Send; - /// Where received frames are delivered. - fn set_rx_sink(&self, sink: mpsc::Sender); + /// Open a fresh received-frame stream, redirecting delivery to it. Called once per + /// driver instance; a later call supersedes the previous stream. + fn subscribe_rx(&self) -> Self::RxStream; - /// Where spontaneous reset notifications are delivered. - fn set_reset_sink(&self, sink: mpsc::Sender); + /// Open a fresh reset-notification stream, redirecting delivery to it. + fn subscribe_reset(&self) -> Self::ResetStream; } /// Exclusive radio access, held via [`RadioPhy::lock`]. From 918c44f5b27c16a747588ae53a6ef04aa285c995 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:36:32 -0400 Subject: [PATCH 02/17] stdio API for server --- crates/ziggurat-server/Cargo.toml | 2 +- crates/ziggurat-server/src/main.rs | 208 ++++++++++++++++++++++------- 2 files changed, 160 insertions(+), 50 deletions(-) diff --git a/crates/ziggurat-server/Cargo.toml b/crates/ziggurat-server/Cargo.toml index ff7448f..f8fa9e2 100644 --- a/crates/ziggurat-server/Cargo.toml +++ b/crates/ziggurat-server/Cargo.toml @@ -21,7 +21,7 @@ hex = "0.4.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" -tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] } +tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util", "io-std"] } tokio-serial = "5.4" tokio-tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index bf87390..7e7915b 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use serde_json::json; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, UnixListener}; use tokio::sync::{broadcast, mpsc}; use tokio::task::JoinHandle; @@ -414,6 +414,69 @@ impl ZigguratServer { Ok(new_phy) } + /// The greeting sent to every client on connect, advertising the protocol version + /// and whether the stack is already configured. + fn hello_message(&self) -> serde_json::Value { + let state = if self.current_stack().is_some() { + "running" + } else { + "awaiting_configuration" + }; + json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state}) + } + + /// Fan hub notifications out to one connection's outbound queue until it closes. + fn spawn_notification_forwarder( + self: &Arc, + outbound: mpsc::Sender, + addr: String, + ) -> JoinHandle<()> { + let mut notification_rx = self.notification_tx.subscribe(); + tokio::spawn(async move { + loop { + match notification_rx.recv().await { + Ok(event) => { + if outbound.send(notification_to_message(event)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!("Client {addr} lagged {count} notifications"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }) + } + + /// Parse one inbound JSON request (a WebSocket text frame or a serial line) and + /// dispatch it. Returns `false` once the outbound queue is gone and the connection + /// should be torn down. + async fn handle_request_text( + self: &Arc, + text: &str, + addr: &str, + outbound: &mpsc::Sender, + ) -> bool { + let request = match serde_json::from_str::(text) { + Ok(request) => request, + Err(e) => { + tracing::warn!("Invalid request from {addr}: {e}"); + return outbound + .send(error_response(0, "invalid_request", e)) + .await + .is_ok(); + } + }; + + tracing::debug!("Request from {addr}: {request:?}"); + if outbound.send(event(request.id, "accepted")).await.is_err() { + return false; + } + self.dispatch(request, outbound.clone()); + true + } + async fn handle_connection( self: &Arc, socket: S, @@ -442,54 +505,16 @@ impl ZigguratServer { let _ = sink.close().await; }); - let state = if self.current_stack().is_some() { - "running" - } else { - "awaiting_configuration" - }; - outbound_tx - .send(json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state})) - .await?; - - // Forward hub notifications to this connection - let mut notification_rx = self.notification_tx.subscribe(); - let notification_outbound = outbound_tx.clone(); - let forwarder_addr = addr.to_owned(); - let notification_forwarder = tokio::spawn(async move { - loop { - match notification_rx.recv().await { - Ok(event) => { - let message = notification_to_message(event); - - if notification_outbound.send(message).await.is_err() { - break; - } - } - Err(broadcast::error::RecvError::Lagged(count)) => { - tracing::warn!("Client {forwarder_addr} lagged {count} notifications"); - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); + outbound_tx.send(self.hello_message()).await?; + let notification_forwarder = + self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); while let Some(message) = stream.next().await { match message { Ok(Message::Text(text)) => { - let request = match serde_json::from_str::(&text) { - Ok(request) => request, - Err(e) => { - tracing::warn!("Invalid request from {addr}: {e}"); - let _ = outbound_tx - .send(error_response(0, "invalid_request", e)) - .await; - continue; - } - }; - - tracing::debug!("Request from {addr}: {request:?}"); - outbound_tx.send(event(request.id, "accepted")).await?; - self.dispatch(request, outbound_tx.clone()); + if !self.handle_request_text(&text, addr, &outbound_tx).await { + break; + } } Ok(Message::Close(_)) => break, Ok(_) => {} // Pings and pongs are handled by tungstenite itself @@ -507,6 +532,63 @@ impl ZigguratServer { Ok(()) } + /// Serve the line-delimited JSON API over any byte stream (stdio, or a serial port + /// on the eventual embedded target). One request per inbound line; one JSON object + /// per outbound line. The dispatch and notification machinery is shared verbatim + /// with the WebSocket transport. + async fn handle_line_connection( + self: &Arc, + reader: R, + mut writer: W, + addr: &str, + ) -> std::io::Result<()> + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + tracing::info!("Client {addr} connected"); + + let (outbound_tx, mut outbound_rx) = + mpsc::channel::(OUTBOUND_QUEUE_DEPTH); + + let writer_task = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + let mut line = message.to_string(); + line.push('\n'); + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + let _ = writer.flush().await; + } + }); + + let _ = outbound_tx.send(self.hello_message()).await; + let notification_forwarder = + self.spawn_notification_forwarder(outbound_tx.clone(), addr.to_owned()); + + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + if !self.handle_request_text(&line, addr, &outbound_tx).await { + break; + } + } + + notification_forwarder.abort(); + drop(outbound_tx); + let _ = writer_task.await; + + Ok(()) + } + + async fn run_stdio(self: Arc) -> std::io::Result<()> { + tracing::info!("Serving line-delimited JSON API on stdin/stdout"); + self.handle_line_connection(tokio::io::stdin(), tokio::io::stdout(), "stdio") + .await + } + /// Dispatches a request, spawning everything that can block on network activity: /// a command waiting on a slow device must never delay other commands. fn dispatch(self: &Arc, request: Request, outbound: mpsc::Sender) { @@ -989,12 +1071,25 @@ pub struct SerialConfig { flow_control: FlowControlMode, } +/// How the Zigbee API is exposed to clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ApiMode { + /// JSON-RPC over WebSocket on `--listen` + Ws, + /// Line-delimited JSON over stdin/stdout (logs go to stderr) + Stdio, +} + #[derive(Debug, Parser)] #[command( version, about = "Host-side Zigbee stack speaking Spinel to an 802.15.4 RCP" )] struct Args { + /// How to expose the Zigbee API to clients + #[arg(long, value_enum, default_value_t = ApiMode::Ws)] + api: ApiMode, + /// Serial device of the 802.15.4 RCP #[arg(long)] device: String, @@ -1024,9 +1119,21 @@ fn main() -> Result<(), Box> { rt.block_on(async { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(args.log_level.to_string())); - tracing_subscriber::registry() - .with(fmt::layer().with_filter(filter)) - .init(); + + // In stdio mode stdout carries the JSON API, so logs must not touch it + if args.api == ApiMode::Stdio { + tracing_subscriber::registry() + .with( + fmt::layer() + .with_writer(std::io::stderr) + .with_filter(filter), + ) + .init(); + } else { + tracing_subscriber::registry() + .with(fmt::layer().with_filter(filter)) + .init(); + } let server = Arc::new(ZigguratServer::new(SerialConfig { device: args.device, @@ -1034,7 +1141,10 @@ fn main() -> Result<(), Box> { flow_control: args.flow_control, })); - server.run(&args.listen).await?; + match args.api { + ApiMode::Ws => server.run(&args.listen).await?, + ApiMode::Stdio => server.run_stdio().await?, + } Ok(()) }) From 216f0a6581935c31dafe0699a9d14e9e20607950 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:24:22 -0400 Subject: [PATCH 03/17] Migrate to new abstract-bits `presence_from` syntax --- crates/ziggurat-zigbee/src/nwk/commands.rs | 24 +++++++++++----------- crates/ziggurat-zigbee/src/nwk/frame.rs | 2 +- crates/ziggurat-zigbee/src/zdp.rs | 8 ++++---- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/ziggurat-zigbee/src/nwk/commands.rs b/crates/ziggurat-zigbee/src/nwk/commands.rs index 5cec046..0f6ac54 100644 --- a/crates/ziggurat-zigbee/src/nwk/commands.rs +++ b/crates/ziggurat-zigbee/src/nwk/commands.rs @@ -44,12 +44,12 @@ pub enum NwkRouteRequestManyToOne { pub struct NwkRouteRequestCommand { reserved: u3, pub many_to_one: NwkRouteRequestManyToOne, - #[abstract_bits(presence_of = destination_eui64)] - reserved: bool, + has_destination_eui64: bool, reserved: u2, pub route_request_identifier: u8, pub destination_address: Nwk, pub path_cost: u8, + #[abstract_bits(presence_from = has_destination_eui64)] pub destination_eui64: Option, } @@ -58,16 +58,16 @@ pub struct NwkRouteRequestCommand { #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkRouteReplyCommand { reserved: u4, - #[abstract_bits(presence_of = originator_eui64)] - reserved: bool, - #[abstract_bits(presence_of = responder_eui64)] - reserved: bool, + has_originator_eui64: bool, + has_responder_eui64: bool, reserved: u2, pub route_request_identifier: u8, pub originator_nwk: Nwk, pub responder_nwk: Nwk, pub path_cost: u8, + #[abstract_bits(presence_from = has_originator_eui64)] pub originator_eui64: Option, + #[abstract_bits(presence_from = has_responder_eui64)] pub responder_eui64: Option, } @@ -149,8 +149,8 @@ pub struct NwkNetworkStatusCommand { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkRouteRecordCommand { - #[abstract_bits(length_of = relays)] - reserved: u8, + relay_count: u8, + #[abstract_bits(length_from = relay_count)] pub relays: Vec, } @@ -232,11 +232,11 @@ pub struct NwkRejoinResponseCommand { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkLinkStatusCommand { - #[abstract_bits(length_of = link_statuses)] - reserved: u5, + link_statuses_len: u5, pub is_first_frame: bool, pub is_last_frame: bool, reserved: u1, + #[abstract_bits(length_from = link_statuses_len)] pub link_statuses: Vec, } @@ -305,13 +305,13 @@ pub enum NwkReportCommandIdentifier { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct NwkNetworkReportCommand { - #[abstract_bits(length_of = pan_ids)] report_information_count: u5, pub report_command_identifier: NwkReportCommandIdentifier, pub epid: Eui64, /// A list of 16-bit PAN identifiers that are in conflict. This field's format is /// determined by the `report_command_identifier` but the only defined type is /// `PanIdentifierConflict`. + #[abstract_bits(length_from = report_information_count)] pub pan_ids: Vec, } @@ -392,8 +392,8 @@ pub struct NwkPowerListEntry { pub struct NwkLinkPowerDeltaCommand { pub command_type: NwkLinkPowerDeltaType, reserved: u6, - #[abstract_bits(length_of = power_list)] list_count: u8, + #[abstract_bits(length_from = list_count)] pub power_list: Vec, } diff --git a/crates/ziggurat-zigbee/src/nwk/frame.rs b/crates/ziggurat-zigbee/src/nwk/frame.rs index de875f0..7136e82 100644 --- a/crates/ziggurat-zigbee/src/nwk/frame.rs +++ b/crates/ziggurat-zigbee/src/nwk/frame.rs @@ -56,9 +56,9 @@ pub struct NwkFrameControl { #[abstract_bits] #[derive(Debug, Clone, PartialEq, Eq)] pub struct NwkSourceRoute { - #[abstract_bits(length_of = relays)] relay_count: u8, pub relay_index: u8, + #[abstract_bits(length_from = relay_count)] pub relays: Vec, } diff --git a/crates/ziggurat-zigbee/src/zdp.rs b/crates/ziggurat-zigbee/src/zdp.rs index eaf3a42..d223fca 100644 --- a/crates/ziggurat-zigbee/src/zdp.rs +++ b/crates/ziggurat-zigbee/src/zdp.rs @@ -86,8 +86,8 @@ impl ZdpCommand for DeviceAnnce { #[abstract_bits] #[derive(Debug, Clone, Eq, PartialEq)] pub struct ParentAnnce { - #[abstract_bits(length_of = children)] number_of_children: u8, + #[abstract_bits(length_from = number_of_children)] pub children: Vec, } @@ -101,8 +101,8 @@ impl ZdpCommand for ParentAnnce { #[derive(Debug, Clone, Eq, PartialEq)] pub struct ParentAnnceRsp { pub status: ZdpStatus, - #[abstract_bits(length_of = children)] number_of_children: u8, + #[abstract_bits(length_from = number_of_children)] pub children: Vec, } @@ -185,8 +185,8 @@ pub struct MgmtLqiRsp { pub status: ZdpStatus, pub neighbor_table_entries: u8, pub start_index: u8, - #[abstract_bits(length_of = neighbor_table_list)] neighbor_table_list_count: u8, + #[abstract_bits(length_from = neighbor_table_list_count)] pub neighbor_table_list: Vec, } @@ -235,8 +235,8 @@ pub struct MgmtRtgRsp { pub status: ZdpStatus, pub routing_table_entries: u8, pub start_index: u8, - #[abstract_bits(length_of = routing_table_list)] routing_table_list_count: u8, + #[abstract_bits(length_from = routing_table_list_count)] pub routing_table_list: Vec, } From 93d33bc88994af1832f6c8facbdf1573b29e4f98 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:52:48 -0400 Subject: [PATCH 04/17] Make ziggurat-ieee-802154 `no_std` --- crates/ziggurat-ieee-802154/Cargo.toml | 6 +++--- crates/ziggurat-ieee-802154/src/lib.rs | 7 +++++++ crates/ziggurat-ieee-802154/src/types.rs | 12 ++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/ziggurat-ieee-802154/Cargo.toml b/crates/ziggurat-ieee-802154/Cargo.toml index 006038e..a7b024c 100644 --- a/crates/ziggurat-ieee-802154/Cargo.toml +++ b/crates/ziggurat-ieee-802154/Cargo.toml @@ -10,9 +10,9 @@ repository.workspace = true [dependencies] abstract-bits = "0.2.0" -num_enum = "0.7.3" -hex = "0.4.3" -thiserror = "2.0.12" +num_enum = { version = "0.7.3", default-features = false } +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } +thiserror = { version = "2.0.12", default-features = false } serde = { version = "1.0.219", default-features = false, features = ["alloc"] } educe = { version = "0.6.0", default-features = false, features = ["Debug"] } heapless = "0.9.3" diff --git a/crates/ziggurat-ieee-802154/src/lib.rs b/crates/ziggurat-ieee-802154/src/lib.rs index 07c14bf..5dadf83 100644 --- a/crates/ziggurat-ieee-802154/src/lib.rs +++ b/crates/ziggurat-ieee-802154/src/lib.rs @@ -1,6 +1,13 @@ +#![no_std] + +extern crate alloc; + pub mod commands; pub mod types; +use alloc::vec; +use alloc::vec::Vec; + use crate::types::{Eui64, Nwk, PanId, format_hex}; use abstract_bits::{AbstractBits, BitReader, abstract_bits}; use num_enum::TryFromPrimitive; diff --git a/crates/ziggurat-ieee-802154/src/types.rs b/crates/ziggurat-ieee-802154/src/types.rs index 6c4685b..c1cd494 100644 --- a/crates/ziggurat-ieee-802154/src/types.rs +++ b/crates/ziggurat-ieee-802154/src/types.rs @@ -1,5 +1,7 @@ +use core::fmt; + +use alloc::string::String; use hex; -use std::fmt; use crate::ParseError; @@ -8,7 +10,13 @@ pub enum FromHexError { #[error("invalid length, expected {expected} hex characters, got {got}")] InvalidLength { expected: usize, got: usize }, #[error("invalid hex")] - InvalidHex(#[from] hex::FromHexError), + InvalidHex(hex::FromHexError), +} + +impl From for FromHexError { + fn from(err: hex::FromHexError) -> Self { + Self::InvalidHex(err) + } } fn decode_hex(text: &str) -> Result<[u8; N], FromHexError> { From 1ff0101c9881447bce950cbe5310a20ce978316d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:11:53 -0400 Subject: [PATCH 05/17] Propagate `no_std` into the main stack?? --- crates/ziggurat-driver/src/zigbee_stack.rs | 19 +++++++++ .../src/zigbee_stack/indirect.rs | 36 +++++++---------- .../src/zigbee_stack/joining.rs | 6 +-- .../src/zigbee_stack/neighbor.rs | 5 +-- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 11 ++--- .../ziggurat-driver/src/zigbee_stack/route.rs | 10 ++--- crates/ziggurat-ieee-802154/src/lib.rs | 2 +- crates/ziggurat-ieee-802154/src/types.rs | 6 +-- crates/ziggurat-zigbee/Cargo.toml | 10 ++--- crates/ziggurat-zigbee/src/aps/frame.rs | 2 + crates/ziggurat-zigbee/src/aps/security.rs | 13 ++++-- crates/ziggurat-zigbee/src/constants.rs | 2 +- crates/ziggurat-zigbee/src/crypto.rs | 3 +- crates/ziggurat-zigbee/src/indirect.rs | 21 ++++++---- crates/ziggurat-zigbee/src/lib.rs | 7 ++++ crates/ziggurat-zigbee/src/nwk/addresses.rs | 8 ++-- crates/ziggurat-zigbee/src/nwk/broadcasts.rs | 20 ++++++---- crates/ziggurat-zigbee/src/nwk/commands.rs | 2 + crates/ziggurat-zigbee/src/nwk/frame.rs | 2 + crates/ziggurat-zigbee/src/nwk/neighbors.rs | 21 +++++----- crates/ziggurat-zigbee/src/nwk/routing.rs | 20 +++++----- crates/ziggurat-zigbee/src/nwk/security.rs | 8 ++-- crates/ziggurat-zigbee/src/time.rs | 40 +++++++++++++++++++ crates/ziggurat-zigbee/src/zdp.rs | 2 + 24 files changed, 180 insertions(+), 96 deletions(-) create mode 100644 crates/ziggurat-zigbee/src/time.rs diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 8bfe37f..6476e4c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -7,6 +7,7 @@ use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{ ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, RxFrame, TxFrame, TxResult, }; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::aps::frame::{ApsAckFrame, ApsFrame, parse_aps_frame}; use ziggurat_zigbee::beacon::ZigbeeBeacon; @@ -626,6 +627,24 @@ impl ZigbeeStack

{ CoreGuard(self.state.core.try_lock_for(LOCK_ACQUIRE_TIMEOUT).unwrap()) } + /// The sans-io core's clock reads as microseconds since this stack started. These + /// convert between it and the tokio `Instant` our timers use, at the one boundary + /// where the driver hands time into (or receives deadlines back from) the core. + fn to_core_instant(&self, t: Instant) -> CoreInstant { + let micros = t + .saturating_duration_since(self.state.start_time) + .as_micros(); + CoreInstant::from_micros(micros as u64) + } + + fn core_now(&self) -> CoreInstant { + self.to_core_instant(Instant::now()) + } + + fn to_tokio_instant(&self, t: CoreInstant) -> Instant { + self.state.start_time + Duration::from_micros(t.as_micros()) + } + pub fn new( radio: Arc

, config: NetworkConfig, diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index c8ce6e7..0308317 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -35,12 +35,10 @@ impl ZigbeeStack

{ ) -> Result<(), ZigbeeStackError> { let (completion, result_rx) = oneshot::channel(); - self.core().mac.indirect_queue.push( - destination, - frame, - completion, - Instant::now().into_std(), - ); + self.core() + .mac + .indirect_queue + .push(destination, frame, completion, self.core_now()); self.src_match_sync.notify_one(); self.maintenance_wake.notify_one(); @@ -77,7 +75,7 @@ impl ZigbeeStack

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

{ source_eui64: Option, source_nwk: Option, ) -> bool { - let outcome = self.core().mac.indirect_queue.extract( - source_eui64, - source_nwk, - Instant::now().into_std(), - ); + let outcome = + self.core() + .mac + .indirect_queue + .extract(source_eui64, source_nwk, self.core_now()); for (destination, transaction) in outcome.expired { let _ = transaction @@ -170,7 +168,7 @@ impl ZigbeeStack

{ } // 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, // so a failed transmit goes back to the head of the queue for the next poll - Err(err) if Instant::now().into_std() < transaction.expires_at => { + Err(err) if self.core_now() < transaction.expires_at => { tracing::warn!("Indirect transmit to {destination:?} failed ({err}), requeueing"); self.core() .mac @@ -321,24 +319,20 @@ impl ZigbeeStack

{ .mac .indirect_queue .next_expiry() - .map(Instant::from_std); + .map(|t| self.to_tokio_instant(t)); let next_eviction = self .core() .nib .neighbors .next_child_timeout() - .map(Instant::from_std); + .map(|t| self.to_tokio_instant(t)); [next_expiry, next_eviction].into_iter().flatten().min() } fn expire_indirect_transactions(&self) { - let expired = self - .core() - .mac - .indirect_queue - .expire(Instant::now().into_std()); + let expired = self.core().mac.indirect_queue.expire(self.core_now()); if expired.is_empty() { return; @@ -359,7 +353,7 @@ impl ZigbeeStack

{ .core() .nib .neighbors - .evict_timed_out_children(Instant::now().into_std()); + .evict_timed_out_children(self.core_now()); for (eui64, nwk) in evicted { tracing::warn!("Child {eui64:?} ({nwk:?}) timed out without a keepalive, evicting"); diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 94dea16..f1804f7 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -110,7 +110,7 @@ impl ZigbeeStack

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

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

{ source, timeout, u16::from(request.end_device_configuration), - Instant::now().into_std(), + self.core_now(), ); // Requests from devices that are not our end device children are dropped diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index 52fd3e5..9e643bd 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -1,4 +1,3 @@ -use tokio::time::Instant; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxPriority}; @@ -44,7 +43,7 @@ impl ZigbeeStack

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

{ nwk_frame.nwk_header.source, lqi, &link_status_cmd, - Instant::now().into_std(), + self.core_now(), ); // Spec 3.6.4.4.2: when the outgoing cost collapses to zero the link is diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index a5c6aab..0739f04 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -55,7 +55,7 @@ impl ZigbeeStack

{ nwk_frame.nwk_header.sequence_number, sender_nwk, audience, - now.into_std(), + self.to_core_instant(now), ); drop(core); @@ -657,12 +657,9 @@ impl ZigbeeStack

{ let mut core = self.core(); let audience = core.nib.neighbors.expected_broadcast_relayers(); - core.nib.broadcasts.record_transmission( - key.0, - key.1, - audience, - Instant::now().into_std(), - ); + core.nib + .broadcasts + .record_transmission(key.0, key.1, audience, self.core_now()); } // Spec 3.6.6: retransmit only while the passive ack quorum has not been diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 4aaf7e6..e373015 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -167,7 +167,7 @@ impl ZigbeeStack

{ sender_nwk, updated_path_cost, route_request_cmd.many_to_one, - Instant::now().into_std(), + self.core_now(), ); if !accepted { @@ -312,7 +312,7 @@ impl ZigbeeStack

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

{ .core() .nib .routing - .discovery_deadline(destination, Instant::now().into_std()); + .discovery_deadline(destination, self.core_now()); // One should exist match deadline { - Some(deadline) => deadline - Instant::now().into_std(), + Some(deadline) => deadline.saturating_duration_since(self.core_now()), None => { tracing::warn!("No route discovery entry found for {destination:?}"); return Err(ZigbeeStackError::RouteDiscoveryNoEntry); @@ -596,7 +596,7 @@ impl ZigbeeStack

{ .core() .nib .routing - .begin_discovery(destination, Instant::now().into_std()); + .begin_discovery(destination, self.core_now()); // If we know the EUI64 corresponding to the NWK, use it let destination_eui64 = self.core().nib.address_map.eui64_for(destination); diff --git a/crates/ziggurat-ieee-802154/src/lib.rs b/crates/ziggurat-ieee-802154/src/lib.rs index 5dadf83..1d2e276 100644 --- a/crates/ziggurat-ieee-802154/src/lib.rs +++ b/crates/ziggurat-ieee-802154/src/lib.rs @@ -21,7 +21,7 @@ pub const MAX_PHY_PACKET_SIZE: usize = 127; /// is ignored for now. pub type FrameBytes = heapless::Vec; -#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone)] +#[derive(Debug, Eq, PartialEq, Hash, Copy, Clone, PartialOrd, Ord)] pub enum Ieee802154Address { Nwk(Nwk), Eui64(Eui64), diff --git a/crates/ziggurat-ieee-802154/src/types.rs b/crates/ziggurat-ieee-802154/src/types.rs index c1cd494..870b301 100644 --- a/crates/ziggurat-ieee-802154/src/types.rs +++ b/crates/ziggurat-ieee-802154/src/types.rs @@ -55,7 +55,7 @@ deserialize_via_try_from_hex!(PanId); deserialize_via_try_from_hex!(Key); #[abstract_bits::abstract_bits] -#[derive(Eq, Hash, Copy, Clone, PartialEq)] +#[derive(Eq, Hash, Copy, Clone, PartialEq, PartialOrd, Ord)] pub struct Nwk(pub u16); impl Nwk { @@ -93,7 +93,7 @@ impl fmt::Debug for Nwk { } #[abstract_bits::abstract_bits] -#[derive(Eq, PartialEq, Hash, Copy, Clone)] +#[derive(Eq, PartialEq, Hash, Copy, Clone, PartialOrd, Ord)] pub struct Eui64(pub [u8; 8]); impl Eui64 { @@ -149,7 +149,7 @@ pub enum Address { } #[abstract_bits::abstract_bits] -#[derive(Eq, Hash, Copy, Clone, PartialEq)] +#[derive(Eq, Hash, Copy, Clone, PartialEq, PartialOrd, Ord)] pub struct PanId(pub u16); impl PanId { diff --git a/crates/ziggurat-zigbee/Cargo.toml b/crates/ziggurat-zigbee/Cargo.toml index 089bb36..6e35f9e 100644 --- a/crates/ziggurat-zigbee/Cargo.toml +++ b/crates/ziggurat-zigbee/Cargo.toml @@ -16,12 +16,12 @@ aes = "0.9.1" arbitrary-int = "2.1.1" ccm = { version = "0.6.0-rc.3", default-features = false } educe = { version = "0.6.0", default-features = false, features = ["Debug"] } -hex = "0.4.3" -tracing = "0.1" -num_enum = "0.7.3" +hex = { version = "0.4.3", default-features = false, features = ["alloc"] } +tracing = { version = "0.1", default-features = false } +num_enum = { version = "0.7.3", default-features = false } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } -subtle = "2" -thiserror = "2.0.12" +subtle = { version = "2", default-features = false } +thiserror = { version = "2.0.12", default-features = false } [dev-dependencies] hex-literal = "1.1.0" diff --git a/crates/ziggurat-zigbee/src/aps/frame.rs b/crates/ziggurat-zigbee/src/aps/frame.rs index 17448a6..2b9381c 100644 --- a/crates/ziggurat-zigbee/src/aps/frame.rs +++ b/crates/ziggurat-zigbee/src/aps/frame.rs @@ -1,4 +1,6 @@ use abstract_bits::{AbstractBits, abstract_bits}; +use alloc::vec; +use alloc::vec::Vec; use educe::Educe; use num_enum::TryFromPrimitive; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, format_hex}; diff --git a/crates/ziggurat-zigbee/src/aps/security.rs b/crates/ziggurat-zigbee/src/aps/security.rs index eaca73b..3cd2613 100644 --- a/crates/ziggurat-zigbee/src/aps/security.rs +++ b/crates/ziggurat-zigbee/src/aps/security.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; +use alloc::vec; use serde::Deserialize; use subtle::ConstantTimeEq; @@ -91,7 +92,7 @@ pub struct ApsSecurity { global_link_key: Key, local_eui64: Eui64, /// Per-device link keys and replay counters, keyed by peer EUI64 - devices: HashMap, + devices: BTreeMap, /// When set, unique link keys are derived from this seed instead of generated /// randomly, mirroring the stack the network was taken over from tclk_seed: Option, @@ -101,11 +102,15 @@ pub struct ApsSecurity { } impl ApsSecurity { - pub fn new(global_link_key: Key, local_eui64: Eui64, tclk_seed: Option) -> Self { + pub const fn new( + global_link_key: Key, + local_eui64: Eui64, + tclk_seed: Option, + ) -> Self { Self { global_link_key, local_eui64, - devices: HashMap::new(), + devices: BTreeMap::new(), tclk_seed, outgoing_frame_counter: 0, } diff --git a/crates/ziggurat-zigbee/src/constants.rs b/crates/ziggurat-zigbee/src/constants.rs index aeb1488..6bf21cd 100644 --- a/crates/ziggurat-zigbee/src/constants.rs +++ b/crates/ziggurat-zigbee/src/constants.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use core::time::Duration; use ziggurat_ieee_802154::types::Key; diff --git a/crates/ziggurat-zigbee/src/crypto.rs b/crates/ziggurat-zigbee/src/crypto.rs index d888366..425324e 100644 --- a/crates/ziggurat-zigbee/src/crypto.rs +++ b/crates/ziggurat-zigbee/src/crypto.rs @@ -2,6 +2,7 @@ use aes::Aes128; use aes::Block; use aes::cipher::BlockCipherEncrypt; use aes::cipher::KeyInit; +use alloc::vec::Vec; use ccm::Ccm; use ccm::aead::AeadInOut; use ccm::consts::{U4, U13}; @@ -84,7 +85,7 @@ pub fn verify_key_hash(link_key: &Key) -> [u8; 16] { /// in practice, so keys are issued with a shift of 0. pub fn zstack_tclk(seed: &Key, eui64: Eui64, shift: usize) -> Key { let eui64 = eui64.to_bytes(); - Key(std::array::from_fn(|i| { + Key(core::array::from_fn(|i| { seed.0[(i + shift) % 16] ^ eui64[i % 8] })) } diff --git a/crates/ziggurat-zigbee/src/indirect.rs b/crates/ziggurat-zigbee/src/indirect.rs index 6f40cd1..9756da3 100644 --- a/crates/ziggurat-zigbee/src/indirect.rs +++ b/crates/ziggurat-zigbee/src/indirect.rs @@ -1,5 +1,10 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::time::{Duration, Instant}; +use alloc::vec::Vec; +use core::time::Duration; + +use alloc::collections::VecDeque; +use alloc::collections::{BTreeMap, BTreeSet}; + +use crate::Instant; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; @@ -47,8 +52,8 @@ pub struct PollOutcome { /// tell whether the auto-ACK of a given poll advertised frame-pending=1. #[derive(Debug, Default)] pub struct SrcMatchTable { - pub short_addresses: HashSet, - pub extended_addresses: HashSet, + pub short_addresses: BTreeSet, + pub extended_addresses: BTreeSet, } impl SrcMatchTable { @@ -69,14 +74,14 @@ impl SrcMatchTable { pub struct IndirectQueue { /// How long a transaction awaits a poll before expiring persistence_time: Duration, - queue: HashMap>>, + queue: BTreeMap>>, } impl IndirectQueue { - pub fn new(persistence_time: Duration) -> Self { + pub const fn new(persistence_time: Duration) -> Self { Self { persistence_time, - queue: HashMap::new(), + queue: BTreeMap::new(), } } @@ -234,7 +239,7 @@ impl IndirectQueue { /// The source address match table the RCP should hold: every device with queued /// transactions, under both its address forms (the device may poll with either). - pub fn queued_addresses(&self, address_map: &HashMap) -> SrcMatchTable { + pub fn queued_addresses(&self, address_map: &BTreeMap) -> SrcMatchTable { let mut table = SrcMatchTable::default(); for key in self.queue.keys() { diff --git a/crates/ziggurat-zigbee/src/lib.rs b/crates/ziggurat-zigbee/src/lib.rs index 436e425..255bc2e 100644 --- a/crates/ziggurat-zigbee/src/lib.rs +++ b/crates/ziggurat-zigbee/src/lib.rs @@ -1,11 +1,18 @@ +#![no_std] + +extern crate alloc; + pub mod aps; pub mod beacon; pub mod constants; pub mod crypto; pub mod indirect; pub mod nwk; +pub mod time; pub mod zdp; +pub use time::Instant; + /// Failure to parse an NWK or APS frame (or one of its fields) off the wire. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ParseError { diff --git a/crates/ziggurat-zigbee/src/nwk/addresses.rs b/crates/ziggurat-zigbee/src/nwk/addresses.rs index 14f15be..1f93691 100644 --- a/crates/ziggurat-zigbee/src/nwk/addresses.rs +++ b/crates/ziggurat-zigbee/src/nwk/addresses.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; use ziggurat_ieee_802154::types::{Eui64, Nwk}; @@ -9,7 +9,7 @@ use crate::nwk::neighbors::Neighbors; #[derive(Debug)] pub struct AddressMap { own_address: Nwk, - map: HashMap, + map: BTreeMap, } impl AddressMap { @@ -18,7 +18,7 @@ impl AddressMap { pub fn new(own_address: Nwk, own_eui64: Eui64) -> Self { Self { own_address, - map: HashMap::from([(own_eui64, own_address)]), + map: BTreeMap::from([(own_eui64, own_address)]), } } @@ -125,7 +125,7 @@ impl AddressMap { /// The raw mapping, e.g. for completing indirect queue keys with the device's /// other address form. - pub const fn map(&self) -> &HashMap { + pub const fn map(&self) -> &BTreeMap { &self.map } } diff --git a/crates/ziggurat-zigbee/src/nwk/broadcasts.rs b/crates/ziggurat-zigbee/src/nwk/broadcasts.rs index 71f2481..c33a06a 100644 --- a/crates/ziggurat-zigbee/src/nwk/broadcasts.rs +++ b/crates/ziggurat-zigbee/src/nwk/broadcasts.rs @@ -1,5 +1,9 @@ -use std::collections::{HashMap, HashSet}; -use std::time::{Duration, Instant}; +use alloc::vec::Vec; +use core::time::Duration; + +use alloc::collections::{BTreeMap, BTreeSet}; + +use crate::Instant; use ziggurat_ieee_802154::types::Nwk; @@ -14,7 +18,7 @@ struct Transaction { expected_relayers: Vec, /// Neighbors heard relaying this broadcast: their passive acknowledgments /// (spec 3.6.6) - heard_from: HashSet, + heard_from: BTreeSet, } /// The NWK broadcast transaction table: deduplication of received broadcasts and @@ -26,15 +30,15 @@ pub struct Broadcasts { /// A broadcast with at least this many expected relayers is considered passively /// acknowledged once this many of them have been heard, instead of all of them quorum: usize, - table: HashMap<(Nwk, u8), Transaction>, + table: BTreeMap<(Nwk, u8), Transaction>, } impl Broadcasts { - pub fn new(delivery_time: Duration, quorum: usize) -> Self { + pub const fn new(delivery_time: Duration, quorum: usize) -> Self { Self { delivery_time, quorum, - table: HashMap::new(), + table: BTreeMap::new(), } } @@ -65,7 +69,7 @@ impl Broadcasts { expiration_time: now + self.delivery_time, expected_relayers: audience, // Whoever delivered the frame to us has already broadcast it - heard_from: HashSet::from([sender]), + heard_from: BTreeSet::from([sender]), }, ); @@ -86,7 +90,7 @@ impl Broadcasts { Transaction { expiration_time: now + self.delivery_time, expected_relayers: audience, - heard_from: HashSet::new(), + heard_from: BTreeSet::new(), }, ); } diff --git a/crates/ziggurat-zigbee/src/nwk/commands.rs b/crates/ziggurat-zigbee/src/nwk/commands.rs index 0f6ac54..db9d010 100644 --- a/crates/ziggurat-zigbee/src/nwk/commands.rs +++ b/crates/ziggurat-zigbee/src/nwk/commands.rs @@ -1,4 +1,6 @@ #![allow(clippy::useless_conversion)] +use alloc::vec; +use alloc::vec::Vec; use abstract_bits::{AbstractBits, abstract_bits}; use num_enum::TryFromPrimitive; diff --git a/crates/ziggurat-zigbee/src/nwk/frame.rs b/crates/ziggurat-zigbee/src/nwk/frame.rs index 7136e82..e4913ce 100644 --- a/crates/ziggurat-zigbee/src/nwk/frame.rs +++ b/crates/ziggurat-zigbee/src/nwk/frame.rs @@ -1,4 +1,5 @@ #![allow(clippy::useless_conversion)] +use alloc::vec::Vec; use abstract_bits::AbstractBits; use abstract_bits::abstract_bits; @@ -577,6 +578,7 @@ impl NwkFrame { #[cfg(test)] mod test { use super::*; + use alloc::vec; use hex_literal::hex; #[test] diff --git a/crates/ziggurat-zigbee/src/nwk/neighbors.rs b/crates/ziggurat-zigbee/src/nwk/neighbors.rs index f05fd6f..1f53711 100644 --- a/crates/ziggurat-zigbee/src/nwk/neighbors.rs +++ b/crates/ziggurat-zigbee/src/nwk/neighbors.rs @@ -1,8 +1,11 @@ -use std::cmp; -use std::collections::{HashMap, HashSet, VecDeque}; +use alloc::collections::VecDeque; +use alloc::collections::{BTreeMap, BTreeSet}; +use alloc::vec::Vec; +use core::cmp; +use crate::Instant; use crate::nwk::commands::{NwkLinkStatus, NwkLinkStatusCommand}; -use std::time::{Duration, Instant}; +use core::time::Duration; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use super::NwkDeviceType; @@ -162,18 +165,18 @@ pub struct Neighbors { network_address: Nwk, /// Neighbors silent for this long get their link costs reset max_age: Duration, - table: HashMap, + table: BTreeMap, /// LQI samples for senders that have no neighbor entry yet. - pending_lqas: HashMap>, + pending_lqas: BTreeMap>, } impl Neighbors { - pub fn new(network_address: Nwk, max_age: Duration) -> Self { + pub const fn new(network_address: Nwk, max_age: Duration) -> Self { Self { network_address, max_age, - table: HashMap::new(), - pending_lqas: HashMap::new(), + table: BTreeMap::new(), + pending_lqas: BTreeMap::new(), } } @@ -628,7 +631,7 @@ impl Neighbors { None } }) - .collect::>(); + .collect::>(); // Fold any LQI samples buffered for this address before its entry existed. let buffered_lqas = self.pending_lqas.remove(&source_nwk).unwrap_or_default(); diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 1b4c0dd..e13731d 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -1,7 +1,9 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use crate::Instant; use crate::nwk::commands::NwkRouteRequestManyToOne; -use std::time::{Duration, Instant}; +use core::time::Duration; use ziggurat_ieee_802154::types::Nwk; use crate::nwk::frame::BROADCAST_ALL_ROUTERS_AND_COORDINATOR; @@ -162,9 +164,9 @@ pub struct Routing { mtorr_route_error_threshold: u8, mtorr_delivery_failure_threshold: u8, - route_table: HashMap, - discovery_table: HashMap<(Nwk, RequestId), DiscoveryEntry>, - route_record_table: HashMap>, + route_table: BTreeMap, + discovery_table: BTreeMap<(Nwk, RequestId), DiscoveryEntry>, + route_record_table: BTreeMap>, /// Implied from the spec: "notice that this 8-bit identifier is distinct from the /// 16-bit Routing Sequence Number. The former is used to discern route requests @@ -174,7 +176,7 @@ pub struct Routing { } impl Routing { - pub fn new( + pub const fn new( network_address: Nwk, route_discovery_time: Duration, mtorr_route_error_threshold: u8, @@ -187,9 +189,9 @@ impl Routing { mtorr_delivery_failures: 0, mtorr_route_error_threshold, mtorr_delivery_failure_threshold, - route_table: HashMap::new(), - discovery_table: HashMap::new(), - route_record_table: HashMap::new(), + route_table: BTreeMap::new(), + discovery_table: BTreeMap::new(), + route_record_table: BTreeMap::new(), request_sequence_number: 0, } } diff --git a/crates/ziggurat-zigbee/src/nwk/security.rs b/crates/ziggurat-zigbee/src/nwk/security.rs index 2f22b83..1aaa220 100644 --- a/crates/ziggurat-zigbee/src/nwk/security.rs +++ b/crates/ziggurat-zigbee/src/nwk/security.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use alloc::collections::BTreeMap; use ziggurat_ieee_802154::types::{Eui64, Key}; @@ -11,7 +11,7 @@ enum NetworkKeyType { struct NwkSecurityDescriptor { key_seq_number: u8, outgoing_frame_counter: u32, - incoming_frame_counter_set: HashMap, + incoming_frame_counter_set: BTreeMap, key: Key, #[allow(dead_code)] network_key_type: NetworkKeyType, @@ -56,14 +56,14 @@ impl NwkSecurity { primary: NwkSecurityDescriptor { key_seq_number, outgoing_frame_counter, - incoming_frame_counter_set: HashMap::new(), + incoming_frame_counter_set: BTreeMap::new(), key, network_key_type: NetworkKeyType::Standard, }, alternate: NwkSecurityDescriptor { key_seq_number: 0, outgoing_frame_counter: 0, - incoming_frame_counter_set: HashMap::new(), + incoming_frame_counter_set: BTreeMap::new(), key: Key::from_hex("00000000000000000000000000000000"), network_key_type: NetworkKeyType::Standard, }, diff --git a/crates/ziggurat-zigbee/src/time.rs b/crates/ziggurat-zigbee/src/time.rs new file mode 100644 index 0000000..1049fa6 --- /dev/null +++ b/crates/ziggurat-zigbee/src/time.rs @@ -0,0 +1,40 @@ +use core::ops::Add; +use core::time::Duration; + +/// A monotonic instant, as microseconds since an arbitrary epoch chosen by the driver. +/// +/// The sans-io core never reads a clock; the driver passes `now` in and converts its own +/// platform clock (tokio, embassy, a sim) to and from this type at the boundary. Replacing +/// `std::time::Instant` is what lets this crate build for `no_std` targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Instant { + micros: u64, +} + +impl Instant { + pub const fn from_micros(micros: u64) -> Self { + Self { micros } + } + + pub const fn as_micros(&self) -> u64 { + self.micros + } + + /// Saturating elapsed time since `earlier`; zero if `earlier` is in the future. + pub const fn saturating_duration_since(&self, earlier: Self) -> Duration { + Duration::from_micros(self.micros.saturating_sub(earlier.micros)) + } +} + +impl Add for Instant { + type Output = Self; + + /// Saturating: a "never" sentinel built from a huge `Duration` clamps instead of + /// panicking on overflow. + fn add(self, rhs: Duration) -> Self { + let add = u64::try_from(rhs.as_micros()).unwrap_or(u64::MAX); + Self { + micros: self.micros.saturating_add(add), + } + } +} diff --git a/crates/ziggurat-zigbee/src/zdp.rs b/crates/ziggurat-zigbee/src/zdp.rs index d223fca..d56ab2b 100644 --- a/crates/ziggurat-zigbee/src/zdp.rs +++ b/crates/ziggurat-zigbee/src/zdp.rs @@ -1,4 +1,6 @@ #![allow(clippy::useless_conversion)] +use alloc::vec; +use alloc::vec::Vec; use crate::nwk::commands::NwkRejoinCapabilityInformation; use abstract_bits::{AbstractBits, BitReader, abstract_bits}; From a5439e733c86c709c47671c76ef98ac7fd8c2d83 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:31:53 -0400 Subject: [PATCH 06/17] Test: minimal driver ESP32 --- Cargo.lock | 1140 +++++++++++++++++++- crates/ziggurat-phy-esp/Cargo.lock | 1613 ++++++++++++++++++++++++++++ crates/ziggurat-phy-esp/Cargo.toml | 27 + crates/ziggurat-phy-esp/src/lib.rs | 261 +++++ 4 files changed, 3016 insertions(+), 25 deletions(-) create mode 100644 crates/ziggurat-phy-esp/Cargo.lock create mode 100644 crates/ziggurat-phy-esp/Cargo.toml create mode 100644 crates/ziggurat-phy-esp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index df9aefa..ee657f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,16 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", ] [[package]] @@ -42,7 +51,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", ] @@ -56,6 +65,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + [[package]] name = "anstream" version = "1.0.0" @@ -124,6 +139,38 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -166,6 +213,18 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "byte" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -178,15 +237,27 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "ccm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317" +dependencies = [ + "aead 0.4.3", + "cipher 0.3.0", + "ctr 0.8.0", + "subtle", +] + [[package]] name = "ccm" version = "0.6.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" dependencies = [ - "aead", - "cipher", - "ctr", + "aead 0.6.0", + "cipher 0.5.2", + "ctr 0.10.1", "subtle", ] @@ -213,6 +284,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cipher" version = "0.5.2" @@ -255,7 +335,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -270,6 +350,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + [[package]] name = "core-foundation" version = "0.10.1" @@ -316,6 +402,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c46c1a17ebeef917714db3ae9a17bd2184f7e9977d8e020c6c8bcf59a28a6f1b" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crypto-common" version = "0.1.7" @@ -335,13 +427,90 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + [[package]] name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] @@ -350,6 +519,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -360,6 +540,35 @@ dependencies = [ "crypto-common 0.1.7", ] +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "educe" version = "0.6.0" @@ -369,7 +578,160 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-sink", + "futures-util", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", ] [[package]] @@ -389,7 +751,28 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -398,12 +781,302 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "esp-alloc" +version = "0.10.0" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config", + "esp-sync", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.0" +dependencies = [ + "bitfield", + "bitflags 2.13.0", + "bytemuck", + "cfg-if", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-sync 0.8.0", + "embedded-can", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "enumset", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-riscv-rt", + "esp-rom-sys", + "esp-sync", + "esp32", + "esp32c2", + "esp32c3", + "esp32c5", + "esp32c6", + "esp32c61", + "esp32h2", + "esp32p4", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_core 0.9.5", + "riscv", + "static_cell", + "strum", + "ufmt-write", + "xtensa-lx", + "xtensa-lx-rt", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" + +[[package]] +name = "esp-phy" +version = "0.2.0" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.8.0", + "esp-config", + "esp-hal", + "esp-metadata-generated", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", +] + +[[package]] +name = "esp-radio" +version = "1.0.0-beta.0" +dependencies = [ + "allocator-api2", + "byte", + "cfg-if", + "docsplay", + "document-features", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "esp-alloc", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-phy", + "esp-radio-rtos-driver", + "esp-rom-sys", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", + "heapless 0.9.3", + "ieee802154", + "instability", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +dependencies = [ + "cfg-if", + "esp-sync", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", + "esp32c6", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" + +[[package]] +name = "esp32" +version = "0.40.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c5" +version = "0.2.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c61" +version = "0.3.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32p4" +version = "0.2.0" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "fnv" +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 = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + [[package]] name = "funty" version = "2.0.0" @@ -466,7 +1139,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -498,6 +1171,12 @@ dependencies = [ "slab", ] +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + [[package]] name = "generic-array" version = "0.14.7" @@ -534,6 +1213,15 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -543,6 +1231,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -558,13 +1257,23 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" dependencies = [ - "hash32", + "hash32 0.3.1", "stable_deref_trait", ] @@ -617,6 +1326,25 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ieee802154" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5" +dependencies = [ + "byte", + "ccm 0.4.4", + "cipher 0.3.0", + "hash32 0.2.1", + "hash32-derive", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -629,6 +1357,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.2.2" @@ -638,6 +1375,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -678,6 +1428,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -742,6 +1504,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + [[package]] name = "nix" version = "0.26.4" @@ -774,6 +1551,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -793,7 +1590,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", ] [[package]] @@ -831,12 +1637,45 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -853,7 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -884,7 +1723,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -954,6 +1793,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -995,12 +1840,90 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "riscv-rt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" +dependencies = [ + "riscv", + "riscv-pac", + "riscv-rt-macros", + "riscv-target-parser", +] + +[[package]] +name = "riscv-rt-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1040,7 +1963,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1056,6 +1979,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialport" version = "4.9.0" @@ -1116,23 +2052,92 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "somni-expr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +dependencies = [ + "somni-parser", +] + +[[package]] +name = "somni-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" -version = "2.6.1" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] name = "syn" @@ -1151,6 +2156,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1168,7 +2182,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1203,7 +2217,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1281,7 +2295,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1345,6 +2359,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + [[package]] name = "unescaper" version = "0.1.8" @@ -1360,12 +2380,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1378,12 +2410,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1458,6 +2502,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1597,7 +2650,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1613,7 +2666,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1664,6 +2717,31 @@ dependencies = [ "tap", ] +[[package]] +name = "xtensa-lx" +version = "0.13.0" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.22.0" +dependencies = [ + "document-features", + "xtensa-lx", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -1681,7 +2759,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1706,7 +2784,7 @@ version = "0.1.0" dependencies = [ "abstract-bits", "educe", - "heapless", + "heapless 0.9.3", "hex", "hex-literal", "num_enum", @@ -1722,6 +2800,18 @@ dependencies = [ "ziggurat-ieee-802154", ] +[[package]] +name = "ziggurat-phy-esp" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-sync 0.8.0", + "esp-hal", + "esp-radio", + "ziggurat-ieee-802154", + "ziggurat-phy", +] + [[package]] name = "ziggurat-phy-spinel" version = "0.1.0" @@ -1775,7 +2865,7 @@ dependencies = [ "abstract-bits", "aes", "arbitrary-int 2.1.1", - "ccm", + "ccm 0.6.0-rc.3", "educe", "hex", "hex-literal", diff --git a/crates/ziggurat-phy-esp/Cargo.lock b/crates/ziggurat-phy-esp/Cargo.lock new file mode 100644 index 0000000..8a085b1 --- /dev/null +++ b/crates/ziggurat-phy-esp/Cargo.lock @@ -0,0 +1,1613 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abstract-bits" +version = "0.2.0" +dependencies = [ + "abstract-bits-derive", + "arbitrary-int", + "bitvec", + "thiserror", +] + +[[package]] +name = "abstract-bits-derive" +version = "0.2.0" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byte" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "ccm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-sink", + "futures-util", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-alloc" +version = "0.10.0" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config", + "esp-sync", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.0" +dependencies = [ + "bitfield", + "bitflags", + "bytemuck", + "cfg-if", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-sync 0.8.0", + "embedded-can", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "enumset", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-riscv-rt", + "esp-rom-sys", + "esp-sync", + "esp32", + "esp32c2", + "esp32c3", + "esp32c5", + "esp32c6", + "esp32c61", + "esp32h2", + "esp32p4", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_core 0.9.5", + "riscv", + "static_cell", + "strum", + "ufmt-write", + "xtensa-lx", + "xtensa-lx-rt", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" + +[[package]] +name = "esp-phy" +version = "0.2.0" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.8.0", + "esp-config", + "esp-hal", + "esp-metadata-generated", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", +] + +[[package]] +name = "esp-radio" +version = "1.0.0-beta.0" +dependencies = [ + "allocator-api2", + "byte", + "cfg-if", + "docsplay", + "document-features", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "esp-alloc", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-phy", + "esp-radio-rtos-driver", + "esp-rom-sys", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", + "heapless 0.9.3", + "ieee802154", + "instability", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +dependencies = [ + "cfg-if", + "esp-sync", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", + "esp32c6", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" + +[[package]] +name = "esp32" +version = "0.40.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c5" +version = "0.2.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c61" +version = "0.3.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32p4" +version = "0.2.0" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "critical-section", + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +dependencies = [ + "vcell", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ieee802154" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5" +dependencies = [ + "byte", + "ccm", + "cipher", + "hash32 0.2.1", + "hash32-derive", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "riscv-rt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" +dependencies = [ + "riscv", + "riscv-pac", + "riscv-rt-macros", + "riscv-target-parser", +] + +[[package]] +name = "riscv-rt-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "somni-expr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +dependencies = [ + "somni-parser", +] + +[[package]] +name = "somni-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xtensa-lx" +version = "0.13.0" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.22.0" +dependencies = [ + "document-features", + "xtensa-lx", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ziggurat-ieee-802154" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "educe", + "heapless 0.9.3", + "hex", + "num_enum", + "serde", + "thiserror", +] + +[[package]] +name = "ziggurat-phy" +version = "0.1.0" +dependencies = [ + "thiserror", + "ziggurat-ieee-802154", +] + +[[package]] +name = "ziggurat-phy-esp" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-sync 0.8.0", + "esp-hal", + "esp-radio", + "ziggurat-ieee-802154", + "ziggurat-phy", +] diff --git a/crates/ziggurat-phy-esp/Cargo.toml b/crates/ziggurat-phy-esp/Cargo.toml new file mode 100644 index 0000000..7891822 --- /dev/null +++ b/crates/ziggurat-phy-esp/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ziggurat-phy-esp" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +description = "esp-radio (ESP32-C6/H2) backend implementing the ziggurat-phy RadioPhy trait" +license = "Apache-2.0" + +[dependencies] +ziggurat-phy = { path = "../ziggurat-phy" } +ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } + +# Path deps to the local esp-hal checkout (~/Projects/esp-hal) while we track its API. +esp-hal = { path = "../../../esp-hal/esp-hal", features = ["esp32c6", "unstable"] } +esp-radio = { path = "../../../esp-hal/esp-radio", features = [ + "esp32c6", + "ieee802154", + "unstable", +] } + +embassy-sync = "0.8" +embassy-futures = "0.1" + +# Excluded from the workspace, so the root's patch doesn't reach us: repeat it, or the +# protocol crates pull the unpatched (std-only) abstract-bits and bitvec fails for no_std. +[patch.crates-io] +abstract-bits = { path = "../../../abstract-bits" } diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs new file mode 100644 index 0000000..88ce3e9 --- /dev/null +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -0,0 +1,261 @@ +//! [`RadioPhy`] implemented over the ESP32-C6/H2 native 802.15.4 radio via esp-radio. +//! +//! esp-radio's driver is blocking + callback-driven and takes `&mut self`; this wraps it +//! in an embassy async mutex (so the trait's `&self` works) and turns its `fn()` TX/RX +//! callbacks into `Signal`s an async future can await. +//! +//! Scaffold status: structure is real (locking, signals, channels, software TX retry). +//! Gaps marked TODO: exact raw-frame field extraction, source-match (frame-pending) table, +//! and energy detect (esp-radio does not expose ED in its public API). + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use core::time::Duration; + +use embassy_futures::select::{Either, select}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::{Channel, Receiver as ChannelReceiver}; +use embassy_sync::mutex::Mutex; +use embassy_sync::signal::Signal; +use esp_hal::peripherals::IEEE802154; +use esp_radio::ieee802154::{Config, Ieee802154}; +use ziggurat_ieee_802154::types::{Eui64, Nwk}; +use ziggurat_phy::{ + ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, ResetEvent, RxFrame, TxFrame, + TxPriority, TxResult, +}; + +const RX_DEPTH: usize = 16; + +// There is exactly one IEEE802154 peripheral, so a single set of statics backs it. The +// esp-radio completion callbacks are plain `fn()` (no captures), so they must reach the +// async side through statics. +static RX_CHANNEL: Channel = Channel::new(); +static RX_AVAILABLE: Signal = Signal::new(); +static TX_DONE: Signal = Signal::new(); +static TX_FAILED: Signal = Signal::new(); + +fn on_rx_available() { + RX_AVAILABLE.signal(()); +} +fn on_tx_done() { + TX_DONE.signal(()); +} +fn on_tx_failed() { + TX_FAILED.signal(()); +} + +struct RadioState { + radio: Ieee802154<'static>, + config: Config, +} + +pub struct EspPhy { + state: Mutex, +} + +impl EspPhy { + pub fn new(peripheral: IEEE802154<'static>) -> Self { + let mut radio = Ieee802154::new(peripheral); + radio.set_rx_available_callback_fn(on_rx_available); + radio.set_tx_done_callback_fn(on_tx_done); + radio.set_tx_failed_callback_fn(on_tx_failed); + Self { + state: Mutex::new(RadioState { + radio, + config: Config::default(), + }), + } + } + + /// Drains received frames into the RX channel. The binary spawns this as a task; it + /// wakes on the rx-available callback rather than busy-polling. + pub async fn run_rx(&self) -> ! { + loop { + RX_AVAILABLE.wait().await; + let mut state = self.state.lock().await; + while let Some(raw) = state.radio.raw_received() { + if let Some(frame) = raw_to_rx_frame(&raw.data, raw.channel) { + let _ = RX_CHANNEL.try_send(frame); + } + } + } + } + + async fn transmit_inner(&self, frame: &TxFrame) -> Result { + let retries = frame.max_frame_retries; + let mut attempt = 0; + loop { + let result = { + let mut state = self.state.lock().await; + if let Some(channel) = frame.channel { + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + } + TX_DONE.reset(); + TX_FAILED.reset(); + state + .radio + .transmit_raw(&frame.psdu, frame.csma_ca) + .map_err(|e| RadioError::Other(String::from(esp_err(e))))?; + + // Holds the radio lock across the completion wait, so RX is blocked for the + // TX duration. TODO: release and reacquire instead. + match select(TX_DONE.wait(), TX_FAILED.wait()).await { + Either::First(()) => { + if state.radio.get_ack_frame().is_some() { + TxResult::Acked + } else { + TxResult::NoAck + } + } + Either::Second(()) => TxResult::ChannelAccessFailure, + } + }; + + match result { + TxResult::NoAck if attempt < retries => attempt += 1, + other => return Ok(other), + } + } + } +} + +/// esp-radio RX buffer layout: `data[0]` is the PSDU length, `data[1..][..len]` the PSDU +/// (FCS included), and the final PSDU byte carries the RSSI. We strip the 2-byte FCS. +fn raw_to_rx_frame(data: &[u8], channel: u8) -> Option { + let len = data[0] as usize; + if len < 2 || 1 + len > data.len() { + return None; + } + let psdu = &data[1..1 + len]; + let rssi = psdu[len - 1] as i8; + Some(RxFrame { + psdu: psdu[..len - 2].to_vec(), + channel, + rssi, + lqi: esp_radio::ieee802154::rssi_to_lqi(rssi), + timestamp_us: 0, // TODO: esp-radio does not surface a per-frame timestamp + }) +} + +fn esp_config(config: &RadioConfig) -> Config { + Config { + channel: config.channel, + txpower: config.tx_power, + promiscuous: config.promiscuous, + rx_when_idle: config.rx_on_when_idle, + auto_ack_rx: true, + auto_ack_tx: true, + pan_id: Some(config.pan_id.0), + short_addr: Some(config.short_address.as_u16()), + ext_addr: Some(u64::from_le_bytes(config.extended_address.to_bytes())), + ..Config::default() + } +} + +const fn esp_err(_e: esp_radio::ieee802154::Error) -> &'static str { + "esp-radio transmit error" +} + +pub struct EspRx(ChannelReceiver<'static, CriticalSectionRawMutex, RxFrame, RX_DEPTH>); + +impl Receiver for EspRx { + async fn recv(&mut self) -> Option { + Some(self.0.receive().await) + } +} + +/// The native radio never spontaneously resets, so this stream never yields. +pub struct NeverReset; + +impl Receiver for NeverReset { + async fn recv(&mut self) -> Option { + core::future::pending().await + } +} + +pub struct EspExclusive<'a> { + phy: &'a EspPhy, +} + +impl ExclusiveRadio for EspExclusive<'_> { + async fn set_channel(&self, channel: u8) -> Result<(), RadioError> { + let mut state = self.phy.state.lock().await; + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + Ok(()) + } + + async fn transmit(&self, frame: TxFrame) -> Result { + self.phy.transmit_inner(&frame).await + } +} + +impl RadioPhy for EspPhy { + type Exclusive<'a> = EspExclusive<'a>; + type RxStream = EspRx; + type ResetStream = NeverReset; + + async fn reset(&self) -> Result<(), RadioError> { + // No external RCP to reset; reconfigure re-applies all state. + Ok(()) + } + + async fn reconfigure(&self, config: &RadioConfig) -> Result<(), RadioError> { + let mut state = self.state.lock().await; + state.config = esp_config(config); + let config = state.config; + state.radio.set_config(config); + state.radio.start_receive(); + Ok(()) + } + + async fn set_frame_pending_table( + &self, + _short: &[Nwk], + _extended: &[Eui64], + ) -> Result<(), RadioError> { + // TODO: esp-radio source-match via set_short_address(i, ..) + PendingMode. + Ok(()) + } + + async fn transmit( + &self, + frame: TxFrame, + _priority: TxPriority, + ) -> Result { + self.transmit_inner(&frame).await + } + + async fn energy_detect(&self, _channel: u8, _duration: Duration) -> Result { + // TODO: esp-radio does not expose ED scan; needs register access or an upstream PR. + Err(RadioError::Other(String::from( + "energy detect not supported by esp-radio", + ))) + } + + async fn lock(&self) -> EspExclusive<'_> { + EspExclusive { phy: self } + } + + fn subscribe_rx(&self) -> EspRx { + EspRx(RX_CHANNEL.receiver()) + } + + fn subscribe_reset(&self) -> NeverReset { + NeverReset + } +} + +// Compile-time proof that EspPhy satisfies the full RadioPhy contract, including the +// `Send + Sync + 'static` supertrait and the `+ Send` bound on every returned future. +const _: () = { + fn assert_radiophy() {} + let _ = assert_radiophy::; +}; From 3b3127bfddf1de74bafa6c40067ee75d26a25476 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:05:18 -0400 Subject: [PATCH 07/17] Test: async runtime abstraction --- crates/ziggurat-driver/Cargo.toml | 1 + crates/ziggurat-driver/src/lib.rs | 1 + crates/ziggurat-driver/src/runtime.rs | 97 +++++++++++++++++++ crates/ziggurat-driver/src/zigbee_stack.rs | 63 +++++++----- .../ziggurat-driver/src/zigbee_stack/aps.rs | 10 +- .../src/zigbee_stack/indirect.rs | 26 ++--- .../src/zigbee_stack/joining.rs | 15 +-- .../ziggurat-driver/src/zigbee_stack/mac.rs | 3 +- .../src/zigbee_stack/neighbor.rs | 5 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 30 +++--- .../ziggurat-driver/src/zigbee_stack/route.rs | 24 ++--- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 10 +- 12 files changed, 198 insertions(+), 87 deletions(-) create mode 100644 crates/ziggurat-driver/src/runtime.rs diff --git a/crates/ziggurat-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index ab48650..5fec210 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -15,6 +15,7 @@ ziggurat-zigbee.workspace = true abstract-bits = "0.2.0" arbitrary-int = "2.1.1" +futures = { version = "0.3", default-features = false } tracing = "0.1" parking_lot = "0.12.4" rand = "0.10.1" diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index 7ce51c4..8b5ce8e 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -1,3 +1,4 @@ +pub mod runtime; pub mod zigbee_stack; pub use ziggurat_ieee_802154; diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs new file mode 100644 index 0000000..7615192 --- /dev/null +++ b/crates/ziggurat-driver/src/runtime.rs @@ -0,0 +1,97 @@ +//! Async runtime abstraction layer. + +use core::future::Future; +use core::ops::Add; +use core::time::Duration; + +/// The instant type a [`Runtime`] measures time with. Bounded for exactly the +/// arithmetic the driver performs on deadlines. +pub trait RtInstant: Copy + Send + Sync + 'static + Add { + /// Saturating `self - earlier`, never panicking when `earlier` is in the future. + fn saturating_duration_since(self, earlier: Self) -> Duration; +} + +impl RtInstant for tokio::time::Instant { + fn saturating_duration_since(self, earlier: Self) -> Duration { + tokio::time::Instant::saturating_duration_since(&self, earlier) + } +} + +/// A deadline elapsed before the awaited future completed. Replaces +/// `tokio::time::error::Elapsed` so the stack's error type stays runtime-agnostic. +#[derive(Debug, thiserror::Error)] +#[error("deadline elapsed")] +pub struct Elapsed; + +/// The async runtime the driver runs on. Implemented by [`TokioRuntime`] for the +/// host server and (later) an embassy runtime for the MCU. +pub trait Runtime: Send + Sync + 'static { + type Instant: RtInstant; + + /// The current monotonic instant. + fn now() -> Self::Instant; + + /// Sleep for `duration`. + fn sleep(duration: Duration) -> impl Future + Send; + + /// Sleep until `deadline`. + fn sleep_until(deadline: Self::Instant) -> impl Future + Send; + + /// Run `future`, returning [`Elapsed`] if `duration` passes first. + fn timeout( + duration: Duration, + future: F, + ) -> impl Future> + Send + where + F: Future + Send, + F::Output: Send, + { + async move { + let future = core::pin::pin!(future); + let sleep = core::pin::pin!(Self::sleep(duration)); + match futures::future::select(future, sleep).await { + futures::future::Either::Left((output, _)) => Ok(output), + futures::future::Either::Right(((), _)) => Err(Elapsed), + } + } + } + + /// Run `future`, returning [`Elapsed`] if `deadline` passes first. + fn timeout_at( + deadline: Self::Instant, + future: F, + ) -> impl Future> + Send + where + F: Future + Send, + F::Output: Send, + { + async move { + let future = core::pin::pin!(future); + let sleep = core::pin::pin!(Self::sleep_until(deadline)); + match futures::future::select(future, sleep).await { + futures::future::Either::Left((output, _)) => Ok(output), + futures::future::Either::Right(((), _)) => Err(Elapsed), + } + } + } +} + +/// The tokio runtime: the host server's executor. +#[derive(Debug, Clone, Copy)] +pub struct TokioRuntime; + +impl Runtime for TokioRuntime { + type Instant = tokio::time::Instant; + + fn now() -> Self::Instant { + tokio::time::Instant::now() + } + + fn sleep(duration: Duration) -> impl Future + Send { + tokio::time::sleep(duration) + } + + fn sleep_until(deadline: Self::Instant) -> impl Future + Send { + tokio::time::sleep_until(deadline) + } +} diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 6476e4c..f8c3e1c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1,8 +1,8 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; +use crate::runtime::{Elapsed, RtInstant, Runtime}; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; -use tokio::time::{sleep, timeout}; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{ ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, RxFrame, TxFrame, TxResult, @@ -12,16 +12,15 @@ use ziggurat_zigbee::aps::frame::{ApsAckFrame, ApsFrame, parse_aps_frame}; use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; -use tokio::time::error::Elapsed; use parking_lot::{Mutex, MutexGuard}; use std::collections::HashMap; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::sync::{Arc, Weak}; +use std::time::Duration; use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc, oneshot}; use tokio::task::JoinSet; -use tokio::time::{Duration, Instant}; mod aps; mod indirect; @@ -204,7 +203,7 @@ pub struct NetworkConfig { /// cancelled when another device reported the same conflict first. #[derive(Debug, Clone, Copy)] pub struct AddressConflict { - pub handled_at: Instant, + pub handled_at: CoreInstant, pub heard_from_network: bool, } @@ -312,13 +311,13 @@ pub struct ZigbeeCore { /// Deadline until which the coordinator advertises `association_permit` in its /// beacon and accepts direct MAC associations. A deadline rather than a flag lets /// renewals extend the window. `None` or past means direct joins are denied. - pub permitting_joins_until: Option, + pub permitting_joins_until: Option, /// Deadline until which the trust center authorizes new devices joining through a /// router. Opened on every permit, independent of the beacon window, so a steered /// join completes while the coordinator's own beacon stays closed. Rejoins are /// never gated by this. - pub trust_center_joins_until: Option, + pub trust_center_joins_until: Option, } /// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It exists @@ -356,9 +355,7 @@ pub struct State { /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with /// the receipt time; an inbound data frame matching a live entry is a retransmission /// to be acknowledged but not delivered to the application a second time. - pub aps_duplicates: Mutex>, - - pub start_time: Instant, + pub aps_duplicates: Mutex>, // We intentionally violate the spec with these options // @@ -457,7 +454,6 @@ impl State { pending_route_notifications: Mutex::new(HashMap::new()), address_conflicts: Mutex::new(HashMap::new()), aps_duplicates: Mutex::new(HashMap::new()), - start_time: Instant::now(), hack_ignore_broadcast_startup_wait_period: true, hack_disable_tx: false, @@ -576,9 +572,13 @@ pub struct NetworkBeacon { } #[derive(Debug)] -pub struct ZigbeeStack { +pub struct ZigbeeStack { self_weak: Weak, + /// The runtime clock baseline. `now` is converted to the sans-io [`CoreInstant`] + /// (microseconds since this instant) at the one boundary that reads the clock. + start_time: R::Instant, + pub state: State, pub config: NetworkConfig, pub tunables: Tunables, @@ -598,7 +598,7 @@ pub struct ZigbeeStack { pub(crate) src_match_written: Mutex, /// When the last parent announcement was received; ours is deferred to avoid a /// network-wide broadcast storm (spec 2.4.3.1.12.2) - pub(crate) parent_annce_received: Mutex>, + pub(crate) parent_annce_received: Mutex>, /// Wakes the MTORR scheduler before its max interval when accumulated route /// errors or delivery failures cross their thresholds @@ -620,29 +620,39 @@ pub struct ZigbeeStack { background_tasks: Mutex>, } -impl ZigbeeStack

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

{ let arc_stack = Arc::new_cyclic(|weak_self| Self { self_weak: weak_self.clone(), + start_time: R::now(), state: State::new(&config, &tunables), config, tunables, @@ -916,7 +927,7 @@ impl ZigbeeStack

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

{ while let Err(err) = self.apply_radio_configuration().await { tracing::error!("Failed to reprogram the radio: {err}, retrying"); - sleep(RADIO_RECOVERY_RETRY_INTERVAL).await; + R::sleep(RADIO_RECOVERY_RETRY_INTERVAL).await; } tracing::info!("Radio reprogrammed, resuming normal operation"); @@ -1066,7 +1077,7 @@ impl ZigbeeStack

{ security_processed: true, }) .await?; - sleep(duration_per_channel).await; + R::sleep(duration_per_channel).await; } // Leave the radio on the home channel before releasing it. radio.set_channel(home_channel).await diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 839bbe9..dd24a9a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -1,3 +1,4 @@ +use crate::runtime::Runtime; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_zigbee::aps::frame::{ @@ -9,7 +10,6 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteD use std::cmp; use std::collections::hash_map::Entry; use tokio::sync::oneshot; -use tokio::time::Instant; use ziggurat_phy::{RadioPhy, TxPriority}; use super::{ @@ -17,7 +17,7 @@ use super::{ ZigbeeStackError, }; -impl ZigbeeStack

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

{ /// stops retransmitting, but must not reach the application twice. Expired entries /// are swept on each call. pub(super) fn is_duplicate_aps_frame(&self, source: Nwk, counter: u8) -> bool { - let now = Instant::now(); + let now = self.core_now(); let timeout = self.tunables.aps_duplicate_rejection_timeout; let mut table = self @@ -103,7 +103,7 @@ impl ZigbeeStack

{ .aps_duplicates .try_lock_for(LOCK_ACQUIRE_TIMEOUT) .unwrap(); - table.retain(|_, seen| now.duration_since(*seen) < timeout); + table.retain(|_, seen| now.saturating_duration_since(*seen) < timeout); match table.entry((source, counter)) { Entry::Occupied(mut slot) => { @@ -348,7 +348,7 @@ impl ZigbeeStack

{ /// Wait for the end-to-end APS ack of a previously transmitted frame. pub async fn wait_aps_ack(&self, waiter: ApsAckWaiter) -> Result<(), ZigbeeStackError> { - match tokio::time::timeout(waiter.timeout, waiter.receiver).await { + match R::timeout(waiter.timeout, waiter.receiver).await { Ok(Ok(())) => { tracing::debug!("APS ACK received"); Ok(()) diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 0308317..0a13058 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,9 +1,10 @@ +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxPriority}; use tokio::sync::oneshot; -use tokio::time::{Instant, timeout_at}; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; @@ -23,7 +24,7 @@ const fn set_frame_pending(frame: &mut Ieee802154Frame) { } } -impl ZigbeeStack

{ +impl ZigbeeStack { /// Queue a finished 802.15.4 frame for indirect delivery and wait for the /// destination to extract it with a MAC Data Request, or for the transaction to /// expire (802.15.4 spec 6.7.3). There is no retry loop here: the destination @@ -304,7 +305,9 @@ impl ZigbeeStack

{ match self.next_maintenance_deadline() { Some(deadline) => { - let _ = timeout_at(deadline, self.maintenance_wake.notified()).await; + let _ = self + .timeout_at_core(deadline, self.maintenance_wake.notified()) + .await; } None => self.maintenance_wake.notified().await, } @@ -313,20 +316,9 @@ impl ZigbeeStack

{ /// The earliest deadline the maintenance task has to act on: an indirect /// transaction expiry or a child keepalive timeout. - fn next_maintenance_deadline(&self) -> Option { - let next_expiry = self - .core() - .mac - .indirect_queue - .next_expiry() - .map(|t| self.to_tokio_instant(t)); - - let next_eviction = self - .core() - .nib - .neighbors - .next_child_timeout() - .map(|t| self.to_tokio_instant(t)); + fn next_maintenance_deadline(&self) -> Option { + let next_expiry = self.core().mac.indirect_queue.next_expiry(); + let next_eviction = self.core().nib.neighbors.next_child_timeout(); [next_expiry, next_eviction].into_iter().flatten().min() } diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index f1804f7..fb35afb 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1,3 +1,4 @@ +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::commands::{ AssociationRequestDeviceType, Ieee802154AssociationRequestCommand, Ieee802154AssociationResponseCommand, @@ -21,7 +22,7 @@ use ziggurat_zigbee::nwk::frame::{ BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, }; -use tokio::time::{Duration, Instant}; +use std::time::Duration; use ziggurat_zigbee::nwk::commands::{ Nwk802154AssociationStatus, NwkCommand, NwkEndDeviceTimeoutRequestCommand, NwkEndDeviceTimeoutResponseCommand, NwkEndDeviceTimeoutResponseStatus, NwkLeaveCommand, @@ -34,7 +35,7 @@ use super::{ NwkSecurityMode, RadioPhy, SendMode, ZigbeeNotification, ZigbeeStack, neighbors, }; -impl ZigbeeStack

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

{ .try_lock_for(LOCK_ACQUIRE_TIMEOUT) .unwrap(); - let now = Instant::now(); + let now = self.core_now(); let window = self.tunables.broadcast_delivery_time; // Detection re-triggers on every frame from the conflicted devices, so a @@ -250,7 +251,7 @@ impl ZigbeeStack

{ .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - tokio::time::sleep( + R::sleep( arc_self .tunables .max_broadcast_jitter @@ -1228,7 +1229,7 @@ impl ZigbeeStack

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

{ pub(super) fn permitting_joins(&self) -> bool { self.core() .permitting_joins_until - .is_some_and(|deadline| deadline > Instant::now()) + .is_some_and(|deadline| deadline > self.core_now()) } /// Whether the trust center authorizes new joins through a router right now. pub(super) fn trust_center_permitting_joins(&self) -> bool { self.core() .trust_center_joins_until - .is_some_and(|deadline| deadline > Instant::now()) + .is_some_and(|deadline| deadline > self.core_now()) } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index e449b2a..cef5132 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -1,3 +1,4 @@ +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154CommandFrame, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, @@ -14,7 +15,7 @@ use ziggurat_zigbee::nwk::frame::{ use super::{NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, ZigbeeStack, ZigbeeStackError}; -impl ZigbeeStack

{ +impl ZigbeeStack { pub fn process_802154_command_frame(&self, command_frame: &Ieee802154CommandFrame) { tracing::debug!( "Received 802.15.4 command frame: {:?}", diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index 9e643bd..e273290 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -1,3 +1,4 @@ +use crate::runtime::Runtime; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxPriority}; @@ -9,7 +10,7 @@ use super::{NwkSecurityMode, ZigbeeStack}; /// Maximum number of link status entries that can be carried in a single frame. const MAX_LINK_STATUSES: usize = 7; -impl ZigbeeStack

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

{ pub async fn periodic_link_status_broadcast_task(&self) { loop { - tokio::time::sleep(self.tunables.link_status_period).await; + R::sleep(self.tunables.link_status_period).await; self.send_link_status_broadcast(false).await; } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 0739f04..9a3f037 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1,8 +1,9 @@ +use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; -use tokio::time::{Instant, timeout_at}; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxPriority}; @@ -23,7 +24,7 @@ use super::{ ZigbeeStackError, }; -impl ZigbeeStack

{ +impl ZigbeeStack { pub fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); @@ -34,12 +35,12 @@ impl ZigbeeStack

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

{ nwk_frame.nwk_header.sequence_number, sender_nwk, audience, - self.to_core_instant(now), + now, ); drop(core); @@ -72,14 +73,15 @@ impl ZigbeeStack

{ /// window closes, waking on every recorded ack. Returns whether the broadcast /// is acknowledged. async fn await_broadcast_passive_acks(&self, key: (Nwk, u8)) -> bool { - let deadline = Instant::now() + self.tunables.passive_ack_timeout; + let deadline = self.core_now() + self.tunables.passive_ack_timeout; loop { if self.broadcast_passively_acked(key) { return true; } - if timeout_at(deadline, self.broadcast_acked.notified()) + if self + .timeout_at_core(deadline, self.broadcast_acked.notified()) .await .is_err() { @@ -158,7 +160,9 @@ impl ZigbeeStack

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

{ self.tunables.unicast_retries ); - tokio::time::sleep(self.tunables.unicast_retry_delay).await; + R::sleep(self.tunables.unicast_retry_delay).await; } } } @@ -674,7 +678,7 @@ impl ZigbeeStack

{ // Fresh jitter decorrelates the retransmission wave: every router // that missed its acks hits the same deadline together, preserving // the relative timing (and collisions) of the original wave - tokio::time::sleep( + R::sleep( self.tunables .max_broadcast_jitter .mul_f32(rand::random::()), @@ -962,7 +966,7 @@ impl ZigbeeStack

{ self.spawn_tracked(async move { // The relay is jittered to avoid synchronized rebroadcasts (spec 3.6.6) - tokio::time::sleep( + R::sleep( arc_self .tunables .max_broadcast_jitter @@ -981,7 +985,7 @@ impl ZigbeeStack

{ // Fresh jitter decorrelates the retransmission wave, which is // synchronized by the shared ack deadline - tokio::time::sleep( + R::sleep( arc_self .tunables .max_broadcast_jitter diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index e373015..968a977 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -1,6 +1,7 @@ +use crate::runtime::Runtime; use std::cmp; +use std::time::Duration; use tokio::sync::broadcast; -use tokio::time::{Duration, Instant, timeout, timeout_at}; use ziggurat_ieee_802154::types::Nwk; use ziggurat_phy::{RadioPhy, TxPriority}; @@ -17,7 +18,7 @@ use super::{ ZigbeeStackError, }; -impl ZigbeeStack

{ +impl ZigbeeStack { fn notify_routing_change(&self, nwk: &Nwk) { let tx = { let pending_route_notifications = self @@ -282,11 +283,11 @@ impl ZigbeeStack

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

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

{ self.core().nib.routing.reset_mtorr_triggers(); - let min_deadline = Instant::now() + self.tunables.mtorr_min_interval; - let max_deadline = Instant::now() + self.tunables.mtorr_max_interval; + let min_deadline = self.core_now() + self.tunables.mtorr_min_interval; + let max_deadline = self.core_now() + self.tunables.mtorr_max_interval; // Avertise every max interval, sooner when accumulated route errors or // delivery failures signal that routes toward us have gone bad, but never // within the min interval tokio::select! { - () = tokio::time::sleep_until(max_deadline) => {} + () = self.sleep_until_core(max_deadline) => {} () = self.mtorr_kick.notified() => { - tokio::time::sleep_until(min_deadline).await; + self.sleep_until_core(min_deadline).await; } } } @@ -570,7 +572,7 @@ impl ZigbeeStack

{ "Waiting for route discovery notification for NWK {destination:?} with timeout {discovery_timeout:?}" ); - match timeout(discovery_timeout, rx.recv()).await { + match R::timeout(discovery_timeout, rx.recv()).await { Ok(_) => { tracing::debug!("Route discovery completed for NWK {destination:#?}"); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 06bac84..7c00c52 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -1,9 +1,9 @@ +use crate::runtime::Runtime; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxPriority}; use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode}; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use tokio::time::Instant; use ziggurat_zigbee::zdp::{ DeviceAnnce, MgmtLqiReq, MgmtLqiRsp, MgmtRtgReq, MgmtRtgRsp, NeighborDescriptor, ParentAnnce, ParentAnnceRsp, RoutingDescriptor, ZDP_PROFILE_ID, ZdpAffinity, ZdpClusterId, ZdpCommand, @@ -25,7 +25,7 @@ const MGMT_LQI_DESCRIPTORS_PER_FRAME: usize = 2; /// Routing records per Mgmt_Rtg_rsp, keeping the ASDU within the NWK payload budget. const MGMT_RTG_DESCRIPTORS_PER_FRAME: usize = 10; -impl ZigbeeStack

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

{ *self .parent_annce_received .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = Some(Instant::now()); + .unwrap() = Some(self.core_now()); let (claimed, removed) = self .core() @@ -339,8 +339,8 @@ impl ZigbeeStack

{ .tunables .parent_annce_jitter_max .mul_f32(rand::random::()); - let slept_at = Instant::now(); - tokio::time::sleep(self.tunables.parent_annce_base_timer + jitter).await; + let slept_at = self.core_now(); + R::sleep(self.tunables.parent_annce_base_timer + jitter).await; // Spec 2.4.3.1.12.2: an announcement from another router restarts the // countdown From d9a74816151a73dfa1b76f4115b989415ecb4583 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:10:07 -0400 Subject: [PATCH 08/17] Test: explicit stack-owned TX tasks --- crates/ziggurat-driver/src/zigbee_stack.rs | 96 +++++++++- .../ziggurat-driver/src/zigbee_stack/aps.rs | 6 +- .../src/zigbee_stack/indirect.rs | 34 ++-- .../ziggurat-driver/src/zigbee_stack/mac.rs | 55 +++--- .../src/zigbee_stack/neighbor.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 152 +++++++++++++-- .../ziggurat-driver/src/zigbee_stack/route.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 6 +- crates/ziggurat-phy-spinel/src/lib.rs | 7 +- crates/ziggurat-phy/src/lib.rs | 20 +- crates/ziggurat-spinel/src/client.rs | 33 +--- crates/ziggurat-spinel/src/lib.rs | 1 - crates/ziggurat-spinel/src/priority_lock.rs | 178 ------------------ 13 files changed, 292 insertions(+), 304 deletions(-) delete mode 100644 crates/ziggurat-spinel/src/priority_lock.rs diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index f8c3e1c..c2a032a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -14,13 +14,16 @@ use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; use parking_lot::{Mutex, MutexGuard}; -use std::collections::HashMap; +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap}; use std::future::Future; use std::ops::{Deref, DerefMut}; +use std::sync::atomic::AtomicU64; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc, oneshot}; use tokio::task::JoinSet; +use ziggurat_zigbee::nwk::frame::NwkFrame; mod aps; mod indirect; @@ -31,7 +34,6 @@ mod nwk; mod route; mod zdp; -pub use ziggurat_phy::TxPriority; pub use ziggurat_zigbee::aps::security as aps_security; pub use ziggurat_zigbee::aps::security::{ApsSecurity, TclkSeed}; pub use ziggurat_zigbee::constants::{ @@ -84,6 +86,19 @@ pub enum ZigbeeStackError { Radio(#[from] RadioError), } +/// Transmit scheduling priority. Higher transmits first when the radio is contended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct TxPriority(pub i8); + +impl TxPriority { + pub const BACKGROUND: Self = Self(-2); + pub const USER_LOW: Self = Self(-1); + pub const USER_NORMAL: Self = Self(0); + pub const USER_HIGH: Self = Self(1); + pub const USER_CRITICAL: Self = Self(2); + pub const STACK_CRITICAL: Self = Self(3); +} + /// How an outgoing NWK frame is secured. Frames carrying the network key to a joining /// device are sent without NWK security; the APS payload is encrypted instead. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -243,6 +258,54 @@ pub struct ApsAckWaiter { pub(crate) ack_data: ApsAckData, } +/// A transmit queued for the single sender task ([`ZigbeeStack::sender_task`]). The NWK +/// frame is unencrypted: the sender assigns the frame counter at dequeue, so on-air order +/// always matches frame-counter order regardless of priority reordering in the queue. +#[derive(Debug)] +pub(crate) struct SendRequest { + seq: u64, + priority: TxPriority, + pub(crate) kind: SendKind, + pub(crate) completion: Option>>, +} + +#[derive(Debug)] +pub(crate) enum SendKind { + Unicast { + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + }, + Broadcast { + nwk_frame: NwkFrame, + security: NwkSecurityMode, + }, + /// An already-finished 802.15.4 frame (a beacon response, or an indirect poll + /// delivery): transmitted as-is, only the MAC sequence number assigned at dequeue. + Raw { frame: Ieee802154Frame }, +} + +impl PartialEq for SendRequest { + fn eq(&self, other: &Self) -> bool { + self.priority == other.priority && self.seq == other.seq + } +} +impl Eq for SendRequest {} +impl Ord for SendRequest { + fn cmp(&self, other: &Self) -> Ordering { + // Max-heap: higher priority first; within a priority, the earlier (lower) seq + // wins, so equal-priority frames drain in FIFO order. + self.priority + .cmp(&other.priority) + .then_with(|| other.seq.cmp(&self.seq)) + } +} +impl PartialOrd for SendRequest { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + /// The NWK Information Base (spec Table 3-66): the network layer's mutable attributes /// and decision tables. #[derive(Debug)] @@ -614,6 +677,14 @@ pub struct ZigbeeStack { /// could move the earliest expiry deadline closer pub(crate) maintenance_wake: Notify, + /// 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(crate) send_queue: Mutex>, + /// Wakes the sender task when a frame is enqueued. + pub(crate) send_wake: Notify, + /// Monotonic tiebreaker giving equal-priority sends FIFO order in `send_queue`. + pub(crate) send_seq: AtomicU64, + /// All tasks spawned by the stack, so that a replaced stack can be fully stopped: /// a leaked background task would keep the replaced stack processing frames and /// transmitting alongside its successor @@ -647,7 +718,11 @@ impl ZigbeeStack { } /// Run `future`, failing with [`Elapsed`] if a [`CoreInstant`] deadline passes first. - async fn timeout_at_core(&self, deadline: CoreInstant, future: F) -> Result + async fn timeout_at_core( + &self, + deadline: CoreInstant, + future: F, + ) -> Result where F: Future + Send, F::Output: Send, @@ -683,6 +758,9 @@ impl ZigbeeStack { link_status_received: Notify::new(), broadcast_acked: Notify::new(), maintenance_wake: Notify::new(), + send_queue: Mutex::new(BinaryHeap::new()), + send_wake: Notify::new(), + send_seq: AtomicU64::new(0), background_tasks: Mutex::new(JoinSet::new()), }); @@ -848,6 +926,18 @@ impl ZigbeeStack { self.reset_radio().await?; self.apply_radio_configuration().await?; + // The single sender task drains the transmit queue; it must run before anything + // enqueues a frame (the initial link status broadcast below would otherwise + // block on a completion nobody resolves). + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.sender_task().await; + }); + // To kick things off, send a link status broadcast. Silicon Labs routers will // "respond" to empty link status broadcasts proactively, independent of the // link status period diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index dd24a9a..78b60a9 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -10,11 +10,11 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteD use std::cmp; use std::collections::hash_map::Entry; use tokio::sync::oneshot; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use super::{ - ApsAck, ApsAckData, ApsAckWaiter, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, ZigbeeStack, - ZigbeeStackError, + ApsAck, ApsAckData, ApsAckWaiter, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, TxPriority, + ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 0a13058..02f0ec4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,7 +1,7 @@ use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use tokio::sync::oneshot; use ziggurat_zigbee::Instant as CoreInstant; @@ -11,19 +11,10 @@ use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, IndirectCompletion, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, - ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + DeviceLeaveReason, IndirectCompletion, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendKind, + TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; -const fn set_frame_pending(frame: &mut Ieee802154Frame) { - match frame { - Ieee802154Frame::Data(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Ack(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Beacon(f) => f.header.frame_control.frame_pending = true, - Ieee802154Frame::Command(f) => f.header.frame_control.frame_pending = true, - } -} - impl ZigbeeStack { /// Queue a finished 802.15.4 frame for indirect delivery and wait for the /// destination to extract it with a MAC Data Request, or for the transaction to @@ -153,14 +144,25 @@ impl ZigbeeStack { } = delivery; let mut frame = transaction.frame.clone(); + if more_pending { - set_frame_pending(&mut frame); + match frame { + Ieee802154Frame::Data(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Ack(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Beacon(ref mut f) => f.header.frame_control.frame_pending = true, + Ieee802154Frame::Command(ref mut f) => f.header.frame_control.frame_pending = true, + } } - // Indirect delivery answers a sleepy child's poll within macResponseWaitTime — a - // deadline-bound path, so it takes the radio ahead of the baseline backlog. + // Indirect delivery answers a sleepy child's poll within `macResponseWaitTime` + let raw_frame = Ieee802154Frame::from_bytes_without_fcs(&frame.to_bytes_without_fcs()) + .expect("a built indirect frame round-trips through bytes"); + match self - .send_802154_frame(frame, TxPriority::STACK_CRITICAL) + .send( + SendKind::Raw { frame: raw_frame }, + TxPriority::STACK_CRITICAL, + ) .await { Ok(()) => { diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index cef5132..65793de 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -6,14 +6,17 @@ use crate::ziggurat_ieee_802154::{ use abstract_bits::AbstractBits; use arbitrary_int::u24; use ziggurat_ieee_802154::types::{Nwk, PanId}; -use ziggurat_phy::{RadioPhy, TxFrame, TxPriority, TxResult}; +use ziggurat_phy::{RadioPhy, TxFrame, TxResult}; use ziggurat_zigbee::beacon::{RenamedU24, ZigbeeBeacon}; use ziggurat_zigbee::nwk::frame::{ BROADCAST_ALL_ROUTERS_AND_COORDINATOR, EncryptedNwkFrame, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, NwkSecurityLevel, }; -use super::{NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, ZigbeeStack, ZigbeeStackError}; +use super::{ + NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxPriority, ZigbeeStack, + ZigbeeStackError, +}; impl ZigbeeStack { pub fn process_802154_command_frame(&self, command_frame: &Ieee802154CommandFrame) { @@ -112,7 +115,21 @@ impl ZigbeeStack { fcs: 0x0000, }); - self.background_send_802154_frame(beacon_frame, TxPriority::USER_NORMAL); + let tx_priority = if permitting_joins { + // We should try to win any beacon races during joins + TxPriority::STACK_CRITICAL + } else { + // Otherwise, unexpected beacon requests should never compete with normal traffic + TxPriority::BACKGROUND + }; + + self.background_send( + SendKind::Raw { + frame: beacon_frame, + }, + tx_priority, + None, + ); } pub(super) fn beacon_request_psdu(&self) -> Vec { @@ -304,7 +321,6 @@ impl ZigbeeStack { pub(super) async fn send_802154_frame( &self, frame: Ieee802154Frame, - priority: TxPriority, ) -> Result<(), ZigbeeStackError> { // Increment the 802.15.4 sequence number let final_frame = if !frame.header().frame_control.sequence_number_suppression { @@ -313,6 +329,7 @@ impl ZigbeeStack { let mut core = self.core(); core.mac.ieee802154_sequence_number = core.mac.ieee802154_sequence_number.wrapping_add(1); + core.mac.ieee802154_sequence_number }; @@ -352,17 +369,14 @@ impl ZigbeeStack { let channel = self.core().mac.channel; let result = self .radio - .transmit( - TxFrame { - psdu: final_frame.to_bytes(), - channel: Some(channel), - csma_ca: true, - max_frame_retries: self.tunables.mac_max_frame_retries, - max_csma_backoffs: self.tunables.mac_max_csma_backoffs, - security_processed: true, - }, - priority, - ) + .transmit(TxFrame { + psdu: final_frame.to_bytes(), + channel: Some(channel), + csma_ca: true, + max_frame_retries: self.tunables.mac_max_frame_retries, + max_csma_backoffs: self.tunables.mac_max_csma_backoffs, + security_processed: true, + }) .await?; match result { @@ -374,15 +388,4 @@ impl ZigbeeStack { other => Err(ZigbeeStackError::TransmitFailed(other)), } } - - pub fn background_send_802154_frame(&self, frame: Ieee802154Frame, priority: TxPriority) { - self.spawn_tracked_self(|arc_self| async move { - arc_self - .send_802154_frame(frame, priority) - .await - .unwrap_or_else(|err| { - tracing::error!("Failed to send 802.15.4 frame: {err}"); - }); - }); - } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index e273290..b70eb15 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -1,11 +1,11 @@ use crate::runtime::Runtime; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLinkStatusCommand}; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use super::{NwkSecurityMode, ZigbeeStack}; +use super::{NwkSecurityMode, TxPriority, ZigbeeStack}; /// Maximum number of link status entries that can be carried in a single frame. const MAX_LINK_STATUSES: usize = 7; diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 9a3f037..43d11ad 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -3,10 +3,12 @@ use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; -use ziggurat_zigbee::Instant as CoreInstant; +use std::sync::atomic::Ordering as AtomicOrdering; +use tokio::sync::oneshot; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::{RadioPhy, TxResult}; +use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{ NwkCommand, NwkCommandId, NwkEndDeviceTimeoutResponseStatus, NwkNetworkStatus, NwkNetworkStatusCommand, @@ -20,8 +22,8 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::Route; use super::{ - AddrConflictSource, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, SendMode, ZigbeeStack, - ZigbeeStackError, + AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, + SendKind, SendMode, SendRequest, TxPriority, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -506,12 +508,14 @@ impl ZigbeeStack { self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame) } - /// Encrypt a fully-formed NWK frame and unicast it to the given next hop, with - /// retries. Unlike [`Self::send_unicast_nwk_frame`], the sequence number is not - /// touched: relayed frames keep the originator's sequence number (spec 3.6.4.3). + /// Queue a fully-formed NWK frame for unicast to the given next hop. The frame is + /// encrypted and transmitted (with retries) by the single sender task at dequeue, so + /// the frame counter is assigned in transmit order. Unlike [`Self::send_unicast_nwk_frame`], + /// the sequence number is not touched: relayed frames keep the originator's sequence + /// number (spec 3.6.4.3). pub(super) async fn transmit_unicast_nwk_frame( &self, - mut nwk_frame: NwkFrame, + nwk_frame: NwkFrame, next_hop_address: Nwk, security: NwkSecurityMode, priority: TxPriority, @@ -519,6 +523,8 @@ impl ZigbeeStack { // Sleepy children cannot hear direct transmissions: the finished frame waits // in the indirect queue until the child polls for it. No retry loop applies; // the child re-polling is the retry mechanism and expiry the failure signal. + // (Indirect delivery bypasses the sender queue: it is latency-critical, and a + // sleepy child only ever hears indirect frames, so its counters stay ordered.) if let Some(child_eui64) = self.sleepy_child_eui64(next_hop_address) { let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop_address, security); @@ -529,6 +535,106 @@ impl ZigbeeStack { .await; } + self.send( + SendKind::Unicast { + nwk_frame, + next_hop: next_hop_address, + security, + }, + priority, + ) + .await + } + + /// Enqueue a send into the queue and wake the sender task. + pub(super) fn background_send( + &self, + kind: SendKind, + priority: TxPriority, + completion: Option>>, + ) { + let seq = self.send_seq.fetch_add(1, AtomicOrdering::Relaxed); + self.send_queue + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .push(SendRequest { + seq, + priority, + kind, + completion, + }); + self.send_wake.notify_one(); + } + + /// Push a frame for the sender task and await its transmit result. + pub(super) async fn send( + &self, + kind: SendKind, + priority: TxPriority, + ) -> Result<(), ZigbeeStackError> { + let (completion_tx, completion_rx) = oneshot::channel(); + self.background_send(kind, priority, Some(completion_tx)); + completion_rx + .await + .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) + } + + /// The single transmit task: drains [`send_queue`](ZigbeeStack::send_queue) highest + /// priority first, encrypting each frame as it is sent so frame-counter order + /// always matches on-air order. Serializing all transmits here is what keeps the + /// counter monotonic; concurrent senders would race it and risk replay rejection. + pub(super) async fn sender_task(&self) { + loop { + loop { + let request = self + .send_queue + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .pop(); + + let Some(request) = request else { + break; + }; + + let result = match request.kind { + SendKind::Unicast { + nwk_frame, + next_hop, + security, + } => { + self.process_unicast_send(nwk_frame, next_hop, security) + .await + } + SendKind::Broadcast { + nwk_frame, + security, + } => self.process_broadcast_send(nwk_frame, security).await, + SendKind::Raw { frame } => self.send_802154_frame(frame).await, + }; + + match request.completion { + Some(completion) => { + let _ = completion.send(result); + } + None => { + if let Err(err) = result { + tracing::warn!("Background send failed: {err}"); + } + } + } + } + + self.send_wake.notified().await; + } + } + + /// Encrypt and unicast a dequeued frame to the next hop, with NWK retries. + async fn process_unicast_send( + &self, + mut nwk_frame: NwkFrame, + next_hop_address: Nwk, + security: NwkSecurityMode, + ) -> Result<(), ZigbeeStackError> { self.apply_nwk_aux_header(&mut nwk_frame, security); for attempt in 0..=self.tunables.unicast_retries { @@ -537,8 +643,6 @@ impl ZigbeeStack { self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame); // When forwarding packets to another node, update the counters for the neighbor - // TODO: maybe wrap the send state into some sort of struct to avoid - // needing to do this? { let mut core = self.core(); let relaying_ieee = core.nib.address_map.eui64_for(next_hop_address); @@ -555,7 +659,7 @@ impl ZigbeeStack { self.increment_tx_total(); - match self.send_802154_frame(ieee802154_frame, priority).await { + match self.send_802154_frame(ieee802154_frame).await { Ok(_) => { break; } @@ -706,14 +810,30 @@ impl ZigbeeStack { Ok(()) } - /// Encrypt a fully-formed NWK frame and broadcast a single copy of it. The sequence - /// number is not touched: relayed broadcasts and route request retries keep their - /// original sequence number. + /// Queue a fully-formed NWK frame for a single broadcast copy, encrypted and sent by + /// the sender task at dequeue. The sequence number is not touched: relayed broadcasts + /// and route request retries keep their original sequence number. pub(super) async fn transmit_broadcast_nwk_frame( &self, - mut nwk_frame: NwkFrame, + nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, + ) -> Result<(), ZigbeeStackError> { + self.send( + SendKind::Broadcast { + nwk_frame, + security, + }, + priority, + ) + .await + } + + /// Encrypt and broadcast a single dequeued copy of a frame. + async fn process_broadcast_send( + &self, + mut nwk_frame: NwkFrame, + security: NwkSecurityMode, ) -> Result<(), ZigbeeStackError> { self.apply_nwk_aux_header(&mut nwk_frame, security); @@ -753,7 +873,7 @@ impl ZigbeeStack { self.increment_tx_total(); - self.send_802154_frame(ieee802154_frame, priority).await + self.send_802154_frame(ieee802154_frame).await } /// Zigbee spec 3.6.4.3: relay a unicast frame addressed to another device. diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 968a977..72db678 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tokio::sync::broadcast; use ziggurat_ieee_802154::types::Nwk; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::nwk::commands::{ NwkCommand, NwkNetworkStatus, NwkNetworkStatusCommand, NwkRouteReplyCommand, @@ -14,7 +14,7 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFram use super::routing::{RouteReplyDisposition, Status}; use super::{ - AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, ZigbeeStack, + AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack, ZigbeeStackError, }; diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 7c00c52..b27aa94 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -1,6 +1,6 @@ use crate::runtime::Runtime; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_phy::{RadioPhy, TxPriority}; +use ziggurat_phy::RadioPhy; use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode}; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; @@ -11,8 +11,8 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkDeviceType, ZigbeeStack, ZigbeeStackError, - neighbors, routing, + ApsAck, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkDeviceType, TxPriority, ZigbeeStack, + ZigbeeStackError, neighbors, routing, }; /// EUI64s per Parent_annce frame, keeping the ASDU within the NWK payload budget. diff --git a/crates/ziggurat-phy-spinel/src/lib.rs b/crates/ziggurat-phy-spinel/src/lib.rs index 42f8667..481cbd8 100644 --- a/crates/ziggurat-phy-spinel/src/lib.rs +++ b/crates/ziggurat-phy-spinel/src/lib.rs @@ -10,11 +10,10 @@ use tokio::time::timeout; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{ ExclusiveRadio, RadioConfig, RadioError, RadioPhy, Receiver, ResetEvent, RxFrame, TxFrame, - TxPriority, TxResult, + TxResult, }; use ziggurat_spinel::client::{ ExclusiveRadio as SpinelRadioGuard, SpinelClient, SpinelError, SpinelRxFrame, SpinelTxFrame, - TxPriority as SpinelTxPriority, }; use ziggurat_spinel::{ SpinelFramePropValueIs, SpinelMacPromiscuousMode, SpinelMacScanState, SpinelPropertyId, @@ -300,12 +299,12 @@ impl RadioPhy for SpinelPhy { write_frame_pending(&self.client, short, extended).await } - async fn transmit(&self, frame: TxFrame, priority: TxPriority) -> Result { + async fn transmit(&self, frame: TxFrame) -> Result { let home = *self.home_channel.lock(); let spinel_frame = tx_frame_to_spinel(frame, home); let status = self .client - .transmit_frame(&spinel_frame, SpinelTxPriority(priority.0)) + .transmit_frame(&spinel_frame) .await .map_err(map_err)?; Ok(map_status(status)) diff --git a/crates/ziggurat-phy/src/lib.rs b/crates/ziggurat-phy/src/lib.rs index 12df54e..6a6139f 100644 --- a/crates/ziggurat-phy/src/lib.rs +++ b/crates/ziggurat-phy/src/lib.rs @@ -17,19 +17,6 @@ pub trait Receiver: Send { fn recv(&mut self) -> impl Future> + Send; } -/// Transmit scheduling priority. Higher transmits first when the radio is contended. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct TxPriority(pub i8); - -impl TxPriority { - pub const BACKGROUND: Self = Self(-2); - pub const USER_LOW: Self = Self(-1); - pub const USER_NORMAL: Self = Self(0); - pub const USER_HIGH: Self = Self(1); - pub const USER_CRITICAL: Self = Self(2); - pub const STACK_CRITICAL: Self = Self(3); -} - /// A frame to transmit. `psdu` is the serialized 802.15.4 frame; the backend supplies /// or recomputes the FCS. `channel` overrides the current channel for this frame only. #[derive(Debug, Clone)] @@ -123,11 +110,8 @@ pub trait RadioPhy: Send + Sync + 'static { ) -> impl Future> + Send; /// Transmit a frame, blocking while the radio is held exclusively (see [`lock`]). - fn transmit( - &self, - frame: TxFrame, - priority: TxPriority, - ) -> impl Future> + Send; + fn transmit(&self, frame: TxFrame) + -> impl Future> + Send; /// Energy-detect one channel for `duration`, returning peak RSSI in dBm. Exclusive; /// returns to the home channel when done. diff --git a/crates/ziggurat-spinel/src/client.rs b/crates/ziggurat-spinel/src/client.rs index 8cceb47..aad7689 100644 --- a/crates/ziggurat-spinel/src/client.rs +++ b/crates/ziggurat-spinel/src/client.rs @@ -9,7 +9,6 @@ use tokio_serial::SerialStream; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::Eui64; -use crate::priority_lock::PriorityLock; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, Ordering}; @@ -235,25 +234,6 @@ struct SpinelWriter { hdlc_scratch: Vec, } -/// Radio transmit priority -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct TxPriority(pub i8); - -impl TxPriority { - pub const BACKGROUND: Self = Self(-2); - pub const USER_LOW: Self = Self(-1); - pub const USER_NORMAL: Self = Self(0); - pub const USER_HIGH: Self = Self(1); - pub const USER_CRITICAL: Self = Self(2); - pub const STACK_CRITICAL: Self = Self(3); -} - -impl Default for TxPriority { - fn default() -> Self { - Self::USER_NORMAL - } -} - #[derive(Debug)] pub struct SpinelClient { /// The reader half of the port, owned by the task spawned in `spawn_reader`. @@ -262,12 +242,8 @@ pub struct SpinelClient { /// concurrent commands cannot interleave partial frames inside the byte stream. writer: AsyncMutex, pub protocol: Arc>, - /// Orders queued transmits among themselves; priority decides which goes first. - transmit_lock: PriorityLock, /// Functional ownership of the radio (scan, reset recovery, channel retune), taken via - /// [`Self::lock_radio`]. A transmit locks it only for its send; an exclusive op holds it - /// throughout. Because transmits queue on `transmit_lock` first, an exclusive op waits - /// out only the in-flight frame, not the whole backlog. + /// [`Self::lock_radio`]. exclusive_lock: AsyncMutex<()>, consecutive_timeouts: AtomicU32, } @@ -285,7 +261,6 @@ impl SpinelClient { hdlc_scratch: Vec::with_capacity(2 * SPINEL_FRAME_MAX_SIZE + 2), }), protocol: Arc::new(Mutex::new(SpinelProtocol::new())), - transmit_lock: PriorityLock::new(), exclusive_lock: AsyncMutex::new(()), consecutive_timeouts: AtomicU32::new(0), } @@ -552,14 +527,8 @@ impl SpinelClient { pub async fn transmit_frame( &self, tx_frame: &SpinelTxFrame, - priority: TxPriority, ) -> Result { - // Wait our turn among transmits, then the radio-ownership gate. This order keeps - // the backlog on `transmit_lock`, so an exclusive op only outwaits the in-flight - // frame. - let _transmit_lock = self.transmit_lock.acquire(priority).await; let _exclusive_lock = self.exclusive_lock.lock().await; - self.transmit_frame_inner(tx_frame).await } diff --git a/crates/ziggurat-spinel/src/lib.rs b/crates/ziggurat-spinel/src/lib.rs index d84a46a..246598d 100644 --- a/crates/ziggurat-spinel/src/lib.rs +++ b/crates/ziggurat-spinel/src/lib.rs @@ -1,5 +1,4 @@ pub mod client; -pub mod priority_lock; use crc_all::CrcAlgo; use num_enum::TryFromPrimitive; diff --git a/crates/ziggurat-spinel/src/priority_lock.rs b/crates/ziggurat-spinel/src/priority_lock.rs deleted file mode 100644 index dad60b8..0000000 --- a/crates/ziggurat-spinel/src/priority_lock.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! A priority-ordered async mutex. - -use std::cmp::Ordering; -use std::collections::BinaryHeap; -use std::sync::{Arc, Mutex}; -use tokio::sync::oneshot; - -struct Waiter { - priority: P, - seq: u64, - grant: oneshot::Sender>, -} - -impl Ord for Waiter

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

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

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

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

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

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

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

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

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

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

{ - fn drop(&mut self) { - if self.armed { - self.inner.release(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn drains_in_priority_then_fifo_order() { - let lock: Arc> = Arc::new(PriorityLock::new()); - let held = lock.acquire(0).await; // block the lock so the rest queue - - let order = Arc::new(Mutex::new(Vec::new())); - let mut handles = Vec::new(); - for p in [1u8, 5, 3, 5] { - let lock = Arc::clone(&lock); - let order = Arc::clone(&order); - handles.push(tokio::spawn(async move { - let _g = lock.acquire(p).await; - order.lock().unwrap().push(p); - })); - tokio::task::yield_now().await; // deterministic enqueue order ⇒ stable seqs - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - drop(held); - for h in handles { - h.await.unwrap(); - } - // priority 5s first (FIFO between them), then 3, then 1 - assert_eq!(*order.lock().unwrap(), vec![5, 5, 3, 1]); - } -} From a15ccc5854c8a310213c8fe618ea9bc20a208758 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:13:17 -0400 Subject: [PATCH 09/17] Test: begin migrating to event-based drivers for routing --- crates/ziggurat-driver/src/runtime.rs | 2 +- crates/ziggurat-driver/src/zigbee_stack.rs | 61 +- .../src/zigbee_stack/indirect.rs | 44 +- .../src/zigbee_stack/joining.rs | 27 +- .../ziggurat-driver/src/zigbee_stack/mac.rs | 2 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 575 +++++++++++++----- .../ziggurat-driver/src/zigbee_stack/route.rs | 143 +---- crates/ziggurat-zigbee/src/constants.rs | 7 + 8 files changed, 553 insertions(+), 308 deletions(-) diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 7615192..11d5923 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -13,7 +13,7 @@ pub trait RtInstant: Copy + Send + Sync + 'static + Add impl RtInstant for tokio::time::Instant { fn saturating_duration_since(self, earlier: Self) -> Duration { - tokio::time::Instant::saturating_duration_since(&self, earlier) + Self::saturating_duration_since(&self, earlier) } } diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index c2a032a..68e02a2 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -68,6 +68,8 @@ pub enum ZigbeeStackError { RouteDiscoveryNoEntry, #[error("route not active after discovery completed")] RouteInactiveAfterDiscovery, + #[error("no route to destination and route discovery is suppressed")] + RouteDiscoverySuppressed, #[error("next hop {next_hop:?} did not ACK")] NwkNoAck { next_hop: Ieee802154Address }, #[error("transmit rejected due to CCA failure")] @@ -245,9 +247,13 @@ impl ApsAckData { } } -/// Resolves an indirect transaction with its transmit result on extraction, or an -/// error on expiry or drop. -pub type IndirectCompletion = oneshot::Sender>; +/// The pending half of a transmit's outcome. +/// +/// Resolved `Ok` once the frame leaves the radio (or, for an indirect transaction, once +/// the child extracts it), or `Err` on transmit failure, expiry, or drop. Shared by the +/// sender queue, the indirect queue, and queued frames, since a completion can hand off +/// between them. +pub type TxCompletion = oneshot::Sender>; /// The end-to-end delivery confirmation of a transmitted APS frame, pending until the /// destination's APS ack arrives. Resolved via [`ZigbeeStack::wait_aps_ack`]. @@ -266,7 +272,7 @@ pub(crate) struct SendRequest { seq: u64, priority: TxPriority, pub(crate) kind: SendKind, - pub(crate) completion: Option>>, + pub(crate) completion: Option, } #[derive(Debug)] @@ -285,6 +291,30 @@ pub(crate) enum SendKind { Raw { frame: Ieee802154Frame }, } +/// A unicast frame queued because its destination has no known route. +/// +/// Held in [`State::pending_routes`] until route discovery resolves. The NWK sequence +/// number is already assigned; the frame counter is still assigned at dequeue. +#[derive(Debug)] +pub struct PendingFrame { + pub(crate) nwk_frame: NwkFrame, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + pub(crate) completion: Option, +} + +/// All frames waiting on one destination's route discovery. +/// +/// Discovery is started once per destination and the whole bucket is released or +/// discarded together, so ten frames to one device ride a single discovery. +#[derive(Debug)] +pub struct PendingRoute { + pub(crate) frames: Vec, + /// Discoveries left before the bucket is discarded. Seeded from + /// `Tunables::pending_route_discovery_attempts` and decremented on each timeout. + pub(crate) attempts_remaining: u8, +} + impl PartialEq for SendRequest { fn eq(&self, other: &Self) -> bool { self.priority == other.priority && self.seq == other.seq @@ -354,7 +384,7 @@ pub struct MacState { pub pan_id: PanId, /// Frames awaiting extraction by a polling device. Completions are resolved /// with the transmit result on extraction, or an error on expiry or drop. - pub indirect_queue: IndirectQueue, + pub indirect_queue: IndirectQueue, } /// The driver's unified mutable protocol state, behind a single lock. @@ -409,10 +439,8 @@ pub struct State { /// All mutable protocol state, behind one lock pub core: Mutex, - /// Async I/O bookkeeping, kept out of the core so transmit completions and client - /// notifications never contend with protocol work: pub pending_aps_acks: Mutex>>, - pub pending_route_notifications: Mutex>>, + pub pending_routes: Mutex>, pub address_conflicts: Mutex>, /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with @@ -514,7 +542,7 @@ impl State { trust_center_joins_until: None, }), pending_aps_acks: Mutex::new(HashMap::new()), - pending_route_notifications: Mutex::new(HashMap::new()), + pending_routes: Mutex::new(HashMap::new()), address_conflicts: Mutex::new(HashMap::new()), aps_duplicates: Mutex::new(HashMap::new()), @@ -682,6 +710,9 @@ pub struct ZigbeeStack { pub(crate) send_queue: Mutex>, /// Wakes the sender task when a frame is enqueued. pub(crate) send_wake: Notify, + /// Wakes the pending-route reactor when a frame is queued awaiting a route, or when a + /// route is established for a destination with queued frames. + pub(crate) pending_route_wake: Notify, /// Monotonic tiebreaker giving equal-priority sends FIFO order in `send_queue`. pub(crate) send_seq: AtomicU64, @@ -760,6 +791,7 @@ impl ZigbeeStack { maintenance_wake: Notify::new(), send_queue: Mutex::new(BinaryHeap::new()), send_wake: Notify::new(), + pending_route_wake: Notify::new(), send_seq: AtomicU64::new(0), background_tasks: Mutex::new(JoinSet::new()), }); @@ -938,6 +970,17 @@ impl ZigbeeStack { arc_self.sender_task().await; }); + // Drains frames queued awaiting route discovery, and discards them when discovery + // is exhausted. Must run before anything can queue one. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.pending_route_task().await; + }); + // To kick things off, send a link status broadcast. Silicon Labs routers will // "respond" to empty link status broadcasts proactively, independent of the // link status period diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 02f0ec4..2de8604 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -11,22 +11,23 @@ use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, IndirectCompletion, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendKind, - TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + DeviceLeaveReason, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendKind, TxCompletion, TxPriority, + ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { - /// Queue a finished 802.15.4 frame for indirect delivery and wait for the - /// destination to extract it with a MAC Data Request, or for the transaction to - /// expire (802.15.4 spec 6.7.3). There is no retry loop here: the destination - /// re-polling is the retry mechanism, expiry is the failure signal. - pub(super) async fn queue_indirect_frame( + /// Queue a finished 802.15.4 frame for a polling device, resolving `completion` with + /// the transmit result when the destination extracts it (802.15.4 spec 6.7.3), or + /// with an error on expiry or eviction. There is no retry loop: the destination + /// re-polling is the retry mechanism, expiry is the failure signal. Whoever wants the + /// outcome — an awaiting unicast originator, or nobody — owns the completion's + /// receiving half. + pub(super) fn enqueue_indirect_frame( &self, destination: Ieee802154Address, frame: Ieee802154Frame, - ) -> Result<(), ZigbeeStackError> { - let (completion, result_rx) = oneshot::channel(); - + completion: TxCompletion, + ) { self.core() .mac .indirect_queue @@ -34,10 +35,29 @@ impl ZigbeeStack { self.src_match_sync.notify_one(); self.maintenance_wake.notify_one(); + } + + /// Queue a frame for a polling device without waiting on its delivery; the returned + /// receiver resolves like [`Self::enqueue_indirect_frame`]'s completion. Fire-and-forget + /// callers drop it. + pub(super) fn push_indirect_frame( + &self, + destination: Ieee802154Address, + frame: Ieee802154Frame, + ) -> oneshot::Receiver> { + let (completion, result_rx) = oneshot::channel(); + self.enqueue_indirect_frame(destination, frame, completion); + result_rx + } + pub(super) async fn queue_indirect_frame( + &self, + destination: Ieee802154Address, + frame: Ieee802154Frame, + ) -> Result<(), ZigbeeStackError> { // Every transaction is eventually resolved by delivery, the expiry sweep, or // child eviction; a dropped sender means the stack is shutting down - result_rx + self.push_indirect_frame(destination, frame) .await .unwrap_or(Err(ZigbeeStackError::IndirectExpired { destination })) } @@ -136,7 +156,7 @@ impl ZigbeeStack { true } - async fn transmit_indirect_transaction(&self, delivery: Delivery) { + async fn transmit_indirect_transaction(&self, delivery: Delivery) { let Delivery { destination, transaction, diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index fb35afb..65c2c5b 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -19,7 +19,7 @@ use ziggurat_zigbee::aps::frame::{ ApsVerifyKeyCommandFrame, EncryptedApsCommandFrame, }; use ziggurat_zigbee::nwk::frame::{ - BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkSecurityHeaderKeyId, + BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderKeyId, }; use std::time::Duration; @@ -32,7 +32,7 @@ use ziggurat_zigbee::nwk::commands::{ use super::{ AddrConflictSource, DeviceLeaveReason, JoinKind, LOCK_ACQUIRE_TIMEOUT, NwkDeviceType, - NwkSecurityMode, RadioPhy, SendMode, ZigbeeNotification, ZigbeeStack, neighbors, + NwkSecurityMode, RadioPhy, SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, }; impl ZigbeeStack { @@ -282,11 +282,18 @@ impl ZigbeeStack { }), ); - arc_self.background_send_nwk_frame( - conflict_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route, - ); + // A broadcast keeps the passive-ack retransmit loop, so it is awaited here in + // the conflict task rather than fire-and-forget enqueued. + if let Err(err) = arc_self + .send_broadcast_nwk_frame( + conflict_frame, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + ) + .await + { + tracing::warn!("Failed to broadcast address conflict for {address:?}: {err}"); + } }); } @@ -552,7 +559,11 @@ impl ZigbeeStack { /// Send a serialized APS frame to an on-network device, with NWK security. Direct /// children do not participate in route discovery, so they are addressed directly. fn send_secured_aps_payload(&self, destination: Nwk, payload: Vec) { - let nwk_frame = self.nwk_data_frame(destination, payload); + // Routed delivery to a non-neighbor must be allowed to discover a route (NWK data + // frames default to suppressing discovery). + let nwk_frame = self + .nwk_data_frame(destination, payload) + .with_discover_route(NwkRouteDiscovery::Enable); self.background_send_nwk_frame( nwk_frame, diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 65793de..b59d970 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -123,7 +123,7 @@ impl ZigbeeStack { TxPriority::BACKGROUND }; - self.background_send( + self.enqueue_send( SendKind::Raw { frame: beacon_frame, }, diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 43d11ad..9b21bce 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1,4 +1,4 @@ -use crate::runtime::Runtime; +use crate::runtime::{Elapsed, Runtime}; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, @@ -20,12 +20,35 @@ use ziggurat_zigbee::nwk::frame::{ NwkSecurityLevel, NwkSourceRoute, }; -use super::routing::Route; +use super::routing::{Route, Status as RouteStatus}; use super::{ AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, - SendKind, SendMode, SendRequest, TxPriority, ZigbeeStack, ZigbeeStackError, + PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, TxPriority, + ZigbeeStack, ZigbeeStackError, }; +/// The outcome of resolving a unicast's MAC next hop without blocking (see +/// [`ZigbeeStack::resolve_next_hop`]). +enum NextHop { + /// Transmit to this next hop now. + Resolved(Nwk), + /// No route known; the frame must wait for route discovery. + NeedDiscovery, + /// No route known and the frame's `discover_route` flag forbids discovering one. + Discard, +} + +/// Where a queued destination's route discovery stands when the reactor inspects it (see +/// [`ZigbeeStack::discovery_state`]). +enum DiscoveryState { + /// A route is active; the queued frames can be sent. + Resolved, + /// Discovery is not progressing: its window elapsed, it failed, or no entry exists. + Lapsed, + /// Discovery is still in flight with time remaining. + InFlight, +} + impl ZigbeeStack { pub fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); @@ -292,34 +315,118 @@ impl ZigbeeStack { } } + /// Fire-and-forget originate of a unicast NWK frame at normal priority. Nothing is + /// awaited, so a failed transmit is handled by the sender, not reported back here. + /// Unicast only; broadcasts go through [`Self::send_broadcast_nwk_frame`]. pub fn background_send_nwk_frame( &self, nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, ) { - self.spawn_tracked_self(|arc_self| async move { - arc_self - .send_nwk_frame(nwk_frame, security, route_directly, TxPriority::USER_NORMAL) - .await - .unwrap_or_else(|err| { - tracing::error!("Failed to send NWK frame: {err}"); + debug_assert!( + nwk_frame.nwk_header.destination.as_u16() < BROADCAST_LOW_POWER_ROUTERS.as_u16(), + "background_send_nwk_frame is unicast only; got broadcast {:?}", + nwk_frame.nwk_header.destination + ); + self.originate_unicast(nwk_frame, security, mode, TxPriority::USER_NORMAL, None); + } + + /// 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). + fn originate_unicast( + &self, + mut nwk_frame: NwkFrame, + security: NwkSecurityMode, + mode: SendMode, + priority: TxPriority, + completion: Option, + ) { + let destination = nwk_frame.nwk_header.destination; + nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); + + match self.resolve_next_hop(&mut nwk_frame, mode) { + NextHop::Resolved(next_hop) => { + self.enqueue_unicast(nwk_frame, next_hop, security, priority, completion); + } + NextHop::NeedDiscovery => { + self.enqueue_awaiting_route(nwk_frame, security, priority, completion) + } + NextHop::Discard => { + tracing::debug!( + "Dropping frame to {destination:?}: no route and discovery suppressed" + ); + if let Some(completion) = completion { + let _ = completion.send(Err(ZigbeeStackError::RouteDiscoverySuppressed)); + } + } + } + } + + /// Resolve the MAC next hop for a unicast without ever blocking. A source-routed + /// result rewrites `nwk_frame`'s header in place (spec 3.6.4.3.1). When no route is + /// known the frame's `discover_route` flag decides between discovery and discard. + fn resolve_next_hop(&self, nwk_frame: &mut NwkFrame, mode: SendMode) -> NextHop { + let destination = nwk_frame.nwk_header.destination; + + if mode == SendMode::Direct { + return NextHop::Resolved(destination); + } + + // End device children never route-discover; their parent delivers directly. + if self.end_device_child_eui64(destination).is_some() { + return NextHop::Resolved(destination); + } + + // A stored source route (concentrator behavior) wins over the routing table. + match self.outbound_route(destination) { + Some(Route::NextHop(next_hop)) => return NextHop::Resolved(next_hop), + Some(Route::SourceRouted(relays)) => { + // Spec 3.6.4.3.1: the MAC destination is the relay closest to us, listed + // last; the relay index starts one below the relay count. + let next_hop = *relays.last().unwrap(); + nwk_frame.nwk_header.frame_control.source_route = true; + nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; + nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { + relay_index: relays.len() as u8 - 1, + relays, }); - }); + return NextHop::Resolved(next_hop); + } + None => {} + } + + // An active ad-hoc route, unless we are deliberately forcing rediscovery. + if !self.state.hack_force_route_discovery { + let core = self.core(); + if core.nib.routing.route_status(destination) == Some(RouteStatus::Active) + && let Some(next_hop) = core.nib.routing.next_hop(destination) + { + return NextHop::Resolved(next_hop); + } + } + + // No usable route. Spec 3.6.3.3: only initiate discovery if the frame allows it. + if nwk_frame.nwk_header.frame_control.discover_route == NwkRouteDiscovery::Suppress { + NextHop::Discard + } else { + NextHop::NeedDiscovery + } } pub async fn send_nwk_frame( &self, nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { self.send_broadcast_nwk_frame(nwk_frame, security, priority) .await } else { - self.send_unicast_nwk_frame(nwk_frame, security, route_directly, priority) + self.send_unicast_nwk_frame(nwk_frame, security, mode, priority) .await } } @@ -399,62 +506,22 @@ impl ZigbeeStack { .route_to(destination, self.tunables.max_source_route) } + /// Originate a unicast and await its delivery result. The completion resolves once + /// the frame leaves the radio (or, for a sleepy child, once it polls), or with an + /// error on transmit failure, route-discovery failure, or discovery being + /// suppressed. pub async fn send_unicast_nwk_frame( &self, - mut nwk_frame: NwkFrame, + nwk_frame: NwkFrame, security: NwkSecurityMode, - route_directly: SendMode, + mode: SendMode, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { - let destination = nwk_frame.nwk_header.destination; - - // Compute a next-hop address - let next_hop_address = if route_directly == SendMode::Direct { - destination - } else { - match self.outbound_route(destination) { - Some(Route::NextHop(next_hop)) => next_hop, - Some(Route::SourceRouted(relays)) => { - // Spec 3.6.4.3.1: the MAC destination is the relay closest to - // us, which is listed last; the relay index starts at one less - // than the relay count - let next_hop = *relays.last().unwrap(); - nwk_frame.nwk_header.frame_control.source_route = true; - nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; - nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { - relay_index: relays.len() as u8 - 1, - relays, - }); - next_hop - } - None => self.discover_route(destination).await?, - } - }; - - nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); - - let result = self - .transmit_unicast_nwk_frame(nwk_frame, next_hop_address, security, priority) - .await; - - // A dead next hop invalidates every route through it and any stored source - // route to the destination; the next transmission will rediscover - if result.is_err() { - self.invalidate_routes_via(next_hop_address); - - if self.core().nib.routing.remove_route_record(destination) { - tracing::info!("Removed source route to {destination:?} after delivery failure"); - } - - // Failed deliveries push the MTORR scheduler toward an early - // advertisement; expired indirect transactions to our own sleepy - // children are not routing failures - if self.sleepy_child_eui64(next_hop_address).is_none() { - self.note_delivery_failure(); - } - } - - result + let (completion_tx, completion_rx) = oneshot::channel(); + self.originate_unicast(nwk_frame, security, mode, priority, Some(completion_tx)); + completion_rx + .await + .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) } /// Wrap an encrypted NWK payload in a unicast 802.15.4 data frame. The sequence @@ -508,50 +575,12 @@ impl ZigbeeStack { self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame) } - /// Queue a fully-formed NWK frame for unicast to the given next hop. The frame is - /// encrypted and transmitted (with retries) by the single sender task at dequeue, so - /// the frame counter is assigned in transmit order. Unlike [`Self::send_unicast_nwk_frame`], - /// the sequence number is not touched: relayed frames keep the originator's sequence - /// number (spec 3.6.4.3). - pub(super) async fn transmit_unicast_nwk_frame( - &self, - nwk_frame: NwkFrame, - next_hop_address: Nwk, - security: NwkSecurityMode, - priority: TxPriority, - ) -> Result<(), ZigbeeStackError> { - // Sleepy children cannot hear direct transmissions: the finished frame waits - // in the indirect queue until the child polls for it. No retry loop applies; - // the child re-polling is the retry mechanism and expiry the failure signal. - // (Indirect delivery bypasses the sender queue: it is latency-critical, and a - // sleepy child only ever hears indirect frames, so its counters stay ordered.) - if let Some(child_eui64) = self.sleepy_child_eui64(next_hop_address) { - let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop_address, security); - - self.increment_tx_total(); - - return self - .queue_indirect_frame(Ieee802154Address::Eui64(child_eui64), frame) - .await; - } - - self.send( - SendKind::Unicast { - nwk_frame, - next_hop: next_hop_address, - security, - }, - priority, - ) - .await - } - - /// Enqueue a send into the queue and wake the sender task. - pub(super) fn background_send( + /// Enqueue a send into the priority queue and wake the sender task. + pub(super) fn enqueue_send( &self, kind: SendKind, priority: TxPriority, - completion: Option>>, + completion: Option, ) { let seq = self.send_seq.fetch_add(1, AtomicOrdering::Relaxed); self.send_queue @@ -566,6 +595,43 @@ impl ZigbeeStack { self.send_wake.notify_one(); } + /// Enqueue a unicast whose next hop is already resolved. A sleepy child goes to the + /// indirect queue. Everything else goes to the sender, which encrypts and retries + /// at dequeue so frame-counter order matches on-air order. The NWK sequence number + /// is left untouched: relayed frames keep the originator's (spec 3.6.4.3). A + /// `completion`, if supplied, is resolved by whichever queue takes the frame: the + /// sender on transmit, or the indirect queue on the child's poll or expiry. + pub(super) fn enqueue_unicast( + &self, + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + priority: TxPriority, + completion: Option, + ) { + if let Some(child_eui64) = self.sleepy_child_eui64(next_hop) { + let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop, security); + self.increment_tx_total(); + + let destination = Ieee802154Address::Eui64(child_eui64); + match completion { + Some(completion) => self.enqueue_indirect_frame(destination, frame, completion), + None => drop(self.push_indirect_frame(destination, frame)), + } + return; + } + + self.enqueue_send( + SendKind::Unicast { + nwk_frame, + next_hop, + security, + }, + priority, + completion, + ); + } + /// Push a frame for the sender task and await its transmit result. pub(super) async fn send( &self, @@ -573,12 +639,225 @@ impl ZigbeeStack { priority: TxPriority, ) -> Result<(), ZigbeeStackError> { let (completion_tx, completion_rx) = oneshot::channel(); - self.background_send(kind, priority, Some(completion_tx)); + self.enqueue_send(kind, priority, Some(completion_tx)); completion_rx .await .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) } + /// Enqueue a unicast awaiting a route and start discovery if necessary. + fn enqueue_awaiting_route( + &self, + nwk_frame: NwkFrame, + security: NwkSecurityMode, + priority: TxPriority, + completion: Option, + ) { + let destination = nwk_frame.nwk_header.destination; + + let start_discovery = { + let mut pending = self + .state + .pending_routes + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap(); + let is_new = !pending.contains_key(&destination); + pending + .entry(destination) + .or_insert_with(|| PendingRoute { + frames: Vec::new(), + attempts_remaining: self.tunables.pending_route_discovery_attempts, + }) + .frames + .push(PendingFrame { + nwk_frame, + security, + priority, + completion, + }); + is_new + }; + + if start_discovery { + tracing::debug!("Queuing frame and starting route discovery for {destination:?}"); + self.send_route_discovery(destination); + } + self.pending_route_wake.notify_one(); + } + + /// The pending-route reactor: a single long-lived task that owns every in-flight + /// route discovery. It sleeps until the nearest discovery deadline (or a wake + /// signal), then sends the frames whose route resolved and retries or discards + /// those whose discovery lapsed. + pub(super) async fn pending_route_task(&self) { + loop { + let next_deadline = self.earliest_discovery_deadline(); + + match next_deadline { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.pending_route_wake.notified()) + .await; + } + None => self.pending_route_wake.notified().await, + } + + self.drive_pending_routes(); + } + } + + /// The soonest live discovery deadline across all queued destinations, or `None` + /// when nothing is waiting on a deadline (the reactor then sleeps on its wake + /// signal). + fn earliest_discovery_deadline(&self) -> Option { + let destinations: Vec = self + .state + .pending_routes + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .keys() + .copied() + .collect(); + + let now = self.core_now(); + let core = self.core(); + destinations + .iter() + .filter_map(|destination| core.nib.routing.discovery_deadline(*destination, now)) + .min() + } + + /// One reactor pass: classify each queued destination and act on it. + fn drive_pending_routes(&self) { + let destinations: Vec = self + .state + .pending_routes + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .keys() + .copied() + .collect(); + + for destination in destinations { + match self.discovery_state(destination) { + DiscoveryState::Resolved => self.release_queued_frames(destination), + DiscoveryState::Lapsed => self.retry_or_fail_discovery(destination), + DiscoveryState::InFlight => {} + } + } + } + + /// Where `destination`'s route discovery currently stands, read from the routing + /// table. + fn discovery_state(&self, destination: Nwk) -> DiscoveryState { + let now = self.core_now(); + let core = self.core(); + match core.nib.routing.route_status(destination) { + Some(RouteStatus::Active) => DiscoveryState::Resolved, + Some(RouteStatus::DiscoveryUnderway) => { + // `discovery_deadline` only returns a live (future) deadline, so its + // absence means the discovery window has elapsed. + if core + .nib + .routing + .discovery_deadline(destination, now) + .is_some() + { + DiscoveryState::InFlight + } else { + DiscoveryState::Lapsed + } + } + // DiscoveryFailed / Inactive / no entry: nothing in flight. + _ => DiscoveryState::Lapsed, + } + } + + /// A route exists: re-resolve each queued frame and enqueue it. A frame whose route + /// vanished in the race is dropped with an error. + fn release_queued_frames(&self, destination: Nwk) { + let bucket = self + .state + .pending_routes + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .remove(&destination); + + let Some(bucket) = bucket else { + return; + }; + + tracing::debug!( + "Releasing {} queued frame(s) to {destination:?}", + bucket.frames.len() + ); + + for queued in bucket.frames { + let PendingFrame { + mut nwk_frame, + security, + priority, + completion, + } = queued; + + match self.resolve_next_hop(&mut nwk_frame, SendMode::Route) { + NextHop::Resolved(next_hop) => { + self.enqueue_unicast(nwk_frame, next_hop, security, priority, completion); + } + NextHop::NeedDiscovery | NextHop::Discard => { + if let Some(completion) = completion { + let _ = completion.send(Err(ZigbeeStackError::RouteInactiveAfterDiscovery)); + } + } + } + } + } + + /// A discovery window lapsed: retry the discovery if the destination has attempts + /// left, otherwise mark it failed and discard every frame waiting on it. + fn retry_or_fail_discovery(&self, destination: Nwk) { + let discarded = { + let mut pending = self + .state + .pending_routes + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap(); + + let Some(bucket) = pending.get_mut(&destination) else { + return; + }; + + bucket.attempts_remaining = bucket.attempts_remaining.saturating_sub(1); + + if bucket.attempts_remaining > 0 { + None + } else { + Some(pending.remove(&destination).unwrap().frames) + } + }; + + match discarded { + None => { + tracing::debug!("Route discovery to {destination:?} timed out, retrying"); + self.send_route_discovery(destination); + self.pending_route_wake.notify_one(); + } + Some(frames) => { + self.core().nib.routing.mark_discovery_failed(destination); + tracing::debug!( + "Route discovery to {destination:?} failed, dropping {} frame(s)", + frames.len() + ); + for PendingFrame { completion, .. } in frames { + if let Some(completion) = completion { + let _ = + completion.send(Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed))); + } + } + } + } + } + /// The single transmit task: drains [`send_queue`](ZigbeeStack::send_queue) highest /// priority first, encrypting each frame as it is sent so frame-counter order /// always matches on-air order. Serializing all transmits here is what keeps the @@ -680,6 +959,7 @@ impl ZigbeeStack { if attempt + 1 > self.tunables.unicast_retries { tracing::error!("Failed to send unicast frame after {attempt} attempts"); + self.handle_unicast_send_failure(&nwk_frame, next_hop_address); return Err(e); } tracing::debug!( @@ -696,11 +976,35 @@ impl ZigbeeStack { Ok(()) } + /// A unicast exhausted its retries at the sender. The next hop is dead: invalidate + /// routes through it. A frame we originated also drops any stored source route and + /// pushes the MTORR scheduler; a frame we were relaying reports the failure back + /// to its originator (spec 3.6.4.8.1). + fn handle_unicast_send_failure(&self, nwk_frame: &NwkFrame, next_hop: Nwk) { + if nwk_frame.nwk_header.source != self.state.network_address { + self.handle_relay_failure(nwk_frame, next_hop); + return; + } + + let destination = nwk_frame.nwk_header.destination; + self.invalidate_routes_via(next_hop); + + if self.core().nib.routing.remove_route_record(destination) { + tracing::info!("Removed source route to {destination:?} after delivery failure"); + } + + // Expired indirect transactions to our own sleepy children are not routing + // failures, so they do not push the MTORR scheduler. + if self.sleepy_child_eui64(next_hop).is_none() { + self.note_delivery_failure(); + } + } + /// Spec 3.6.6: a coordinator/router with rx-off end-device children must re-deliver /// every 0xFFFF broadcast to each of them as a MAC unicast through the indirect /// queue, since a sleeping radio never hears the broadcast itself. The NWK source is - /// skipped (it already has the frame). Each copy is queued on its own task: it is - /// only handed to the radio when the child polls, or dropped when it expires. + /// skipped (it already has the frame). Each copy is queued without waiting: it is only + /// handed to the radio when the child polls, or dropped when it expires. fn fan_out_broadcast_to_sleepy_children( &self, nwk_frame: &NwkFrame, @@ -720,25 +1024,12 @@ impl ZigbeeStack { .collect(); for (child_eui64, child_nwk) in sleepy_children { - let frame = nwk_frame.clone(); - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { - let finished = arc_self.finish_unicast_nwk_frame(frame, child_nwk, security); - arc_self.increment_tx_total(); + let finished = self.finish_unicast_nwk_frame(nwk_frame.clone(), child_nwk, security); + self.increment_tx_total(); - if let Err(err) = arc_self - .queue_indirect_frame(Ieee802154Address::Eui64(child_eui64), finished) - .await - { - tracing::debug!( - "Broadcast not delivered to sleepy child {child_eui64:?}: {err}" - ); - } - }); + // We don't await the result + let _result_rx = + self.push_indirect_frame(Ieee802154Address::Eui64(child_eui64), finished); } } @@ -994,23 +1285,16 @@ impl ZigbeeStack { nwk_frame.nwk_header.source ); - self.spawn_tracked_self(|arc_self| async move { - // The originator's sequence number is preserved when relaying - if let Err(err) = arc_self - .transmit_unicast_nwk_frame( - nwk_frame.clone(), - next_hop_address, - NwkSecurityMode::NetworkKey, - TxPriority::USER_NORMAL, - ) - .await - { - tracing::warn!( - "Failed to relay frame to {destination:?} via {next_hop_address:?}: {err}" - ); - arc_self.handle_relay_failure(&nwk_frame, next_hop_address); - } - }); + // The originator's sequence number is preserved when relaying. The transmit and + // any failure handling (route invalidation, the network status back to the + // originator) happen in the sender; nothing is awaited here. + self.enqueue_unicast( + nwk_frame, + next_hop_address, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + None, + ); } /// Zigbee spec 3.6.4.8.1: when relaying fails, the routes through the dead link are @@ -1030,6 +1314,8 @@ impl ZigbeeStack { NwkNetworkStatus::LinkFailure }; + // The originator may be several hops away with no route cached; allow this + // report to discover one. let network_status_frame = self .nwk_command_frame( source, @@ -1038,7 +1324,8 @@ impl ZigbeeStack { network_address: nwk_frame.nwk_header.destination, }), ) - .with_destination_ieee(destination_ieee); + .with_destination_ieee(destination_ieee) + .with_discover_route(NwkRouteDiscovery::Enable); self.background_send_nwk_frame( network_status_frame, diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 72db678..193ce87 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -1,7 +1,6 @@ use crate::runtime::Runtime; use std::cmp; use std::time::Duration; -use tokio::sync::broadcast; use ziggurat_ieee_802154::types::Nwk; use ziggurat_phy::RadioPhy; @@ -12,30 +11,10 @@ use ziggurat_zigbee::nwk::commands::{ }; use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFrame}; -use super::routing::{RouteReplyDisposition, Status}; -use super::{ - AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack, - ZigbeeStackError, -}; +use super::routing::RouteReplyDisposition; +use super::{AddrConflictSource, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack}; impl ZigbeeStack { - fn notify_routing_change(&self, nwk: &Nwk) { - let tx = { - let pending_route_notifications = self - .state - .pending_route_notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - - if !pending_route_notifications.contains_key(nwk) { - return; - } - - pending_route_notifications.get(nwk).unwrap().clone() - }; - let _ = tx.send(()); - } - #[allow(clippy::significant_drop_tightening)] pub(super) fn handle_route_reply( &self, @@ -74,7 +53,7 @@ impl ZigbeeStack { let (next_hop_nwk, path_cost) = match disposition { RouteReplyDisposition::Drop => return, RouteReplyDisposition::Established => { - self.notify_routing_change(&route_reply_cmd.responder_nwk); + self.pending_route_wake.notify_one(); return; } RouteReplyDisposition::Relay { @@ -83,7 +62,7 @@ impl ZigbeeStack { } => (next_hop, path_cost), }; - self.notify_routing_change(&route_reply_cmd.responder_nwk); + self.pending_route_wake.notify_one(); let next_hop_link = self.core().nib.neighbors.link(next_hop_nwk); @@ -175,7 +154,7 @@ impl ZigbeeStack { return; } - self.notify_routing_change(&nwk_frame.nwk_header.source); + self.pending_route_wake.notify_one(); // TODO: what do we do if the address and the EUI64 don't agree? This would be // an error, some device on the network is storing invalid information about @@ -484,114 +463,12 @@ impl ZigbeeStack { } } + /// Begin or restart ad-hoc route discovery toward a destination: the routing entry + /// enters `DiscoveryUnderway` and a route request is broadcast. The waiting is + /// owned by the pending-route reactor, not the caller; this only kicks off the + /// discovery. #[allow(clippy::significant_drop_tightening)] - pub async fn discover_route(&self, destination: Nwk) -> Result { - // End device children do not participate in route discovery (they could never - // answer a route request); their parent always delivers directly - if self.end_device_child_eui64(destination).is_some() { - return Ok(destination); - } - - if self.state.hack_force_route_discovery - || self.core().nib.routing.route_status(destination).is_none() - { - tracing::debug!("Starting route discovery for NWK {destination:?}"); - self.send_route_discovery(destination).await; - } - - // The entry just ensured above can be torn down concurrently (e.g. a - // link-failure network status removing the route), so a missing entry is - // treated like an inactive route and discovery starts over - let route_entry_status = self - .core() - .nib - .routing - .route_status(destination) - .unwrap_or(Status::Inactive); - - tracing::debug!("Routing table status for {destination:?}: {route_entry_status:?}"); - - match route_entry_status { - Status::Active => { - let next_hop = self.core().nib.routing.next_hop(destination); - - // The same concurrent teardown can strike between the two reads - if let Some(next_hop) = next_hop { - tracing::debug!( - "Using existing next hop for NWK {destination:?}: {next_hop:?}" - ); - return Ok(next_hop); - } - - self.send_route_discovery(destination).await; - } - Status::DiscoveryUnderway => { - // Do nothing - } - Status::DiscoveryFailed | Status::Inactive => { - self.send_route_discovery(destination).await; - } - } - - // Create a pending route notification - let mut rx = { - let mut pending_route_notifications = self - .state - .pending_route_notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); - let tx = pending_route_notifications - .entry(destination) - .or_insert_with(|| { - let (tx, _) = broadcast::channel(1); - tx - }); - - tx.subscribe() - }; - - // Pull the current route discovery entry for the device to determine the timeout - let discovery_timeout = { - let deadline = self - .core() - .nib - .routing - .discovery_deadline(destination, self.core_now()); - - // One should exist - match deadline { - Some(deadline) => deadline.saturating_duration_since(self.core_now()), - None => { - tracing::warn!("No route discovery entry found for {destination:?}"); - return Err(ZigbeeStackError::RouteDiscoveryNoEntry); - } - } - }; - - tracing::debug!( - "Waiting for route discovery notification for NWK {destination:?} with timeout {discovery_timeout:?}" - ); - - match R::timeout(discovery_timeout, rx.recv()).await { - Ok(_) => { - tracing::debug!("Route discovery completed for NWK {destination:#?}"); - } - Err(err) => { - tracing::debug!("Route discovery timed out"); - self.core().nib.routing.mark_discovery_failed(destination); - return Err(ZigbeeStackError::RouteDiscoveryTimeout(err)); - } - }; - - self.core() - .nib - .routing - .next_hop(destination) - .ok_or(ZigbeeStackError::RouteInactiveAfterDiscovery) - } - - #[allow(clippy::significant_drop_tightening)] - pub async fn send_route_discovery(&self, destination: Nwk) { + pub(super) fn send_route_discovery(&self, destination: Nwk) { tracing::debug!("Sending route discovery for NWK {destination:?}"); let route_request_identifier = self diff --git a/crates/ziggurat-zigbee/src/constants.rs b/crates/ziggurat-zigbee/src/constants.rs index 6bf21cd..ffaa96c 100644 --- a/crates/ziggurat-zigbee/src/constants.rs +++ b/crates/ziggurat-zigbee/src/constants.rs @@ -82,6 +82,12 @@ pub struct Tunables { pub unicast_retry_delay: Duration, pub broadcast_delivery_time: Duration, + /// How many route discoveries a frame parked awaiting a route will trigger before it + /// is discarded. `1` (the default) means a single discovery: if it fails, every frame + /// waiting on that destination inherits the failure. Higher values keep the parked + /// frames waiting while discovery is retried, the whole bucket riding along together. + pub pending_route_discovery_attempts: u8, + /// The default timeout for any end device child that does not negotiate a /// different value via the End Device Timeout Request command (spec 3.6.10.2). pub end_device_timeout_default: EndDeviceTimeout, @@ -148,6 +154,7 @@ impl Tunables { unicast_retries: 3, unicast_retry_delay: Duration::from_millis(50), broadcast_delivery_time: Duration::from_millis(9000), + pending_route_discovery_attempts: 1, end_device_timeout_default: EndDeviceTimeout::Minutes256, parent_annce_base_timer: Duration::from_secs(10), parent_annce_jitter_max: Duration::from_secs(10), From ca648446d432010b9e33088ef560ed2644b5f6a2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:49:58 -0400 Subject: [PATCH 10/17] Test: event-based broadcasts, with ACKs --- crates/ziggurat-driver/src/zigbee_stack.rs | 38 ++- .../src/zigbee_stack/joining.rs | 19 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 303 +++++++++++------- .../ziggurat-driver/src/zigbee_stack/route.rs | 6 + 4 files changed, 234 insertions(+), 132 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 68e02a2..b412531 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -315,6 +315,22 @@ pub struct PendingRoute { pub(crate) attempts_remaining: u8, } +/// A broadcast awaiting retransmission, held by the broadcast-retransmit reactor. +/// +/// Spec 3.6.6: a broadcast is rebroadcast until its passive-ack quorum is heard or its +/// attempts run out. This holds the frame to retransmit and the schedule; the passive-ack +/// contract itself lives in the sans-io [`Broadcasts`] table. +#[derive(Debug)] +pub struct PendingBroadcast { + pub(crate) nwk_frame: NwkFrame, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + /// Retransmissions left before the broadcast is given up on. + pub(crate) attempts_remaining: u8, + /// When the next retransmission is due, unless the quorum is heard first. + pub(crate) next_attempt: CoreInstant, +} + impl PartialEq for SendRequest { fn eq(&self, other: &Self) -> bool { self.priority == other.priority && self.seq == other.seq @@ -441,6 +457,8 @@ pub struct State { pub pending_aps_acks: Mutex>>, pub pending_routes: Mutex>, + /// Broadcasts awaiting retransmission, keyed by (source, sequence number). + pub pending_broadcasts: Mutex>, pub address_conflicts: Mutex>, /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with @@ -543,6 +561,7 @@ impl State { }), pending_aps_acks: Mutex::new(HashMap::new()), pending_routes: Mutex::new(HashMap::new()), + pending_broadcasts: Mutex::new(HashMap::new()), address_conflicts: Mutex::new(HashMap::new()), aps_duplicates: Mutex::new(HashMap::new()), @@ -698,9 +717,9 @@ pub struct ZigbeeStack { /// Signaled whenever a link status command is digested; the MTORR startup wait /// uses it to advertise as soon as a neighbor link is established pub(crate) link_status_received: Notify, - /// Signaled on every recorded broadcast passive ack, so retransmission loops can - /// re-evaluate completeness reactively instead of sleeping out the window - pub(crate) broadcast_acked: Notify, + /// Wakes the broadcast-retransmit reactor: signaled on every recorded passive ack + /// and whenever a broadcast is queued for retransmission. + pub(crate) broadcast_retransmit_wake: Notify, /// Wakes the maintenance task when a new indirect transaction or child entry /// could move the earliest expiry deadline closer pub(crate) maintenance_wake: Notify, @@ -787,7 +806,7 @@ impl ZigbeeStack { parent_annce_received: Mutex::new(None), mtorr_kick: Notify::new(), link_status_received: Notify::new(), - broadcast_acked: Notify::new(), + broadcast_retransmit_wake: Notify::new(), maintenance_wake: Notify::new(), send_queue: Mutex::new(BinaryHeap::new()), send_wake: Notify::new(), @@ -981,6 +1000,17 @@ impl ZigbeeStack { arc_self.pending_route_task().await; }); + // Retransmits broadcasts until their passive-ack quorum is heard or attempts run + // out. Must run before anything can queue a broadcast. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.broadcast_retransmit_task().await; + }); + // To kick things off, send a link status broadcast. Silicon Labs routers will // "respond" to empty link status broadcasts proactively, independent of the // link status period diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 65c2c5b..48a6cf8 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -282,18 +282,13 @@ impl ZigbeeStack { }), ); - // A broadcast keeps the passive-ack retransmit loop, so it is awaited here in - // the conflict task rather than fire-and-forget enqueued. - if let Err(err) = arc_self - .send_broadcast_nwk_frame( - conflict_frame, - NwkSecurityMode::NetworkKey, - TxPriority::USER_NORMAL, - ) - .await - { - tracing::warn!("Failed to broadcast address conflict for {address:?}: {err}"); - } + // The retransmit reactor owns the rebroadcasts; this task only applies the + // jittered delay and the cancel-if-already-reported check above. + arc_self.send_broadcast_nwk_frame( + conflict_frame, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + ); }); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 9b21bce..ee7b98b 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -4,6 +4,7 @@ use crate::ziggurat_ieee_802154::{ Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; use std::sync::atomic::Ordering as AtomicOrdering; +use std::time::Duration; use tokio::sync::oneshot; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; @@ -23,8 +24,8 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, Status as RouteStatus}; use super::{ AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, - PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, TxPriority, - ZigbeeStack, ZigbeeStackError, + PendingBroadcast, PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, + TxPriority, ZigbeeStack, ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -86,36 +87,159 @@ impl ZigbeeStack { drop(core); if duplicate { - // A duplicate is its sender's passive ack: retransmission loops - // re-evaluate completeness - self.broadcast_acked.notify_waiters(); + // A duplicate is its sender's passive ack: wake the retransmit reactor so it + // re-evaluates completeness and can drop a now-acknowledged broadcast early + self.broadcast_retransmit_wake.notify_one(); } duplicate } - /// Wait until the broadcast is passively acknowledged or the ack collection - /// window closes, waking on every recorded ack. Returns whether the broadcast - /// is acknowledged. - async fn await_broadcast_passive_acks(&self, key: (Nwk, u8)) -> bool { - let deadline = self.core_now() + self.tunables.passive_ack_timeout; - + /// The broadcast-retransmit reactor: a single long-lived task that owns every + /// in-flight broadcast's retransmission. + pub(super) async fn broadcast_retransmit_task(&self) { loop { + match self.earliest_broadcast_retransmit() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.broadcast_retransmit_wake.notified()) + .await; + } + None => self.broadcast_retransmit_wake.notified().await, + } + + self.drive_broadcast_retransmits(); + } + } + + /// The soonest retransmit deadline across all pending broadcasts, or `None` when none + /// are pending (the reactor then sleeps on its wake signal). + fn earliest_broadcast_retransmit(&self) -> Option { + self.state + .pending_broadcasts + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .values() + .map(|pending| pending.next_attempt) + .min() + } + + /// One reactor pass: for each pending broadcast, drop it if its quorum is now heard, + /// otherwise retransmit a copy if it is due (and not out of attempts). + #[allow(clippy::significant_drop_tightening)] + fn drive_broadcast_retransmits(&self) { + let keys: Vec<(Nwk, u8)> = self + .state + .pending_broadcasts + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .keys() + .copied() + .collect(); + + let now = self.core_now(); + + for key in keys { if self.broadcast_passively_acked(key) { - return true; + tracing::debug!("Broadcast {key:?} passively acknowledged"); + self.state + .pending_broadcasts + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .remove(&key); + continue; } - if self - .timeout_at_core(deadline, self.broadcast_acked.notified()) - .await - .is_err() - { - // The window closed; an ack recorded at the boundary still counts - return self.broadcast_passively_acked(key); + // Fresh jitter, computed before taking the lock so nothing non-trivial runs + // under it. + let next_attempt = now + self.tunables.passive_ack_timeout + self.broadcast_jitter(); + + // Decide under the lock; if a copy is due, extract it to transmit after release. + let retransmit = { + let mut pending = self + .state + .pending_broadcasts + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap(); + let Some(broadcast) = pending.get_mut(&key) else { + continue; + }; + + if broadcast.next_attempt > now { + None + } else if broadcast.attempts_remaining == 0 { + tracing::debug!("Broadcast {key:?} out of retransmit attempts"); + pending.remove(&key); + + None + } else { + broadcast.attempts_remaining -= 1; + broadcast.next_attempt = next_attempt; + + Some(( + broadcast.nwk_frame.clone(), + broadcast.security, + broadcast.priority, + )) + } + }; + + if let Some((nwk_frame, security, priority)) = retransmit { + tracing::debug!("Retransmitting broadcast {key:?}"); + self.enqueue_send( + SendKind::Broadcast { + nwk_frame, + security, + }, + priority, + None, + ); } } } + /// Insert a broadcast into the pending-retransmit map and wake the reactor. + fn schedule_broadcast( + &self, + key: (Nwk, u8), + nwk_frame: NwkFrame, + security: NwkSecurityMode, + priority: TxPriority, + first_delay: Duration, + attempts: u8, + ) { + if attempts == 0 { + return; + } + + self.state + .pending_broadcasts + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .insert( + key, + PendingBroadcast { + nwk_frame, + security, + priority, + attempts_remaining: attempts, + next_attempt: self.core_now() + first_delay, + }, + ); + self.broadcast_retransmit_wake.notify_one(); + } + + /// A random retransmission jitter in `[0, max_broadcast_jitter)` (spec 3.6.6). + /// + // TODO: `no_std` randomness source. This and the other `rand::random` sites + // (RREQ relay jitter in route.rs, the address-conflict and parent-annce jitters, + // plus address/key allocation) call the std global thread RNG directly. + fn broadcast_jitter(&self) -> Duration { + self.tunables + .max_broadcast_jitter + .mul_f32(rand::random::()) + } + /// Whether the broadcast's passive ack quorum has been heard from the audience /// members that are still live neighbors. fn broadcast_passively_acked(&self, key: (Nwk, u8)) -> bool { @@ -423,8 +547,10 @@ impl ZigbeeStack { priority: TxPriority, ) -> Result<(), ZigbeeStackError> { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { - self.send_broadcast_nwk_frame(nwk_frame, security, priority) - .await + // Broadcasts are fire-and-forget: the retransmit reactor owns delivery, and + // there is no end-to-end result to await. + self.send_broadcast_nwk_frame(nwk_frame, security, priority); + Ok(()) } else { self.send_unicast_nwk_frame(nwk_frame, security, mode, priority) .await @@ -1033,12 +1159,16 @@ impl ZigbeeStack { } } - pub async fn send_broadcast_nwk_frame( + /// Originate a broadcast: assign its sequence number, fan it out to sleepy children, + /// form the passive-ack contract, transmit the first copy now, and hand any + /// retransmissions to the broadcast-retransmit reactor (spec 3.6.6). Fire-and-forget: + /// a broadcast has no end-to-end result to await. + pub fn send_broadcast_nwk_frame( &self, mut nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, - ) -> Result<(), ZigbeeStackError> { + ) { nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); // Sleepy children never hear the over-the-air broadcast; queue a unicast copy @@ -1061,44 +1191,24 @@ impl ZigbeeStack { .record_transmission(key.0, key.1, audience, self.core_now()); } - // Spec 3.6.6: retransmit only while the passive ack quorum has not been - // heard within the ack collection window - for attempt in 0..=self.tunables.max_broadcast_retries { - if attempt > 0 { - if self.await_broadcast_passive_acks(key).await { - tracing::debug!("Broadcast {key:?} passively acknowledged"); - return Ok(()); - } - - // Fresh jitter decorrelates the retransmission wave: every router - // that missed its acks hits the same deadline together, preserving - // the relative timing (and collisions) of the original wave - R::sleep( - self.tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Acks may have trickled in during the jitter sleep - if self.broadcast_passively_acked(key) { - tracing::debug!("Broadcast {key:?} passively acknowledged"); - return Ok(()); - } - - tracing::debug!( - "Broadcast {key:?} is missing passive acks, retransmitting \ - (attempt {attempt} of {})", - self.tunables.max_broadcast_retries, - ); - } - - let _ = self - .transmit_broadcast_nwk_frame(nwk_frame.clone(), security, priority) - .await; - } - - Ok(()) + // Transmit the first copy immediately; the reactor makes any retransmissions, + // each after an ack-collection window plus fresh jitter. + self.enqueue_send( + SendKind::Broadcast { + nwk_frame: nwk_frame.clone(), + security, + }, + priority, + None, + ); + self.schedule_broadcast( + key, + nwk_frame, + security, + priority, + self.tunables.passive_ack_timeout + self.broadcast_jitter(), + self.tunables.max_broadcast_retries, + ); } /// Queue a fully-formed NWK frame for a single broadcast copy, encrypted and sent by @@ -1334,8 +1444,10 @@ impl ZigbeeStack { ); } - /// Zigbee spec 3.6.6: re-broadcast a newly seen broadcast frame after a random - /// jitter, preserving the originator's source address and sequence number. + /// Zigbee spec 3.6.6: re-broadcast a newly seen broadcast frame, preserving the + /// originator's source address and sequence number. The first relay is jittered to + /// decorrelate from the originator's wave; the broadcast-retransmit reactor then + /// retransmits until the passive-ack quorum is heard or attempts run out. fn maybe_relay_broadcast(&self, nwk_frame: &NwkFrame) { // Broadcast NWK commands are not generically relayed: link status and leave // frames have a radius of 1, and route requests accumulate path cost in their @@ -1366,58 +1478,17 @@ impl ZigbeeStack { relayed_frame.nwk_header.sequence_number, ); - let arc_self = self - .self_weak - .upgrade() - .expect("Unable to upgrade self reference"); - - self.spawn_tracked(async move { - // The relay is jittered to avoid synchronized rebroadcasts (spec 3.6.6) - R::sleep( - arc_self - .tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Retransmissions follow the same passive acknowledgment rule as our own - // broadcasts; the neighbor we heard the frame from is already counted - for attempt in 0..=arc_self.tunables.max_broadcast_retries { - if attempt > 0 { - if arc_self.await_broadcast_passive_acks(key).await { - tracing::debug!("Relayed broadcast {key:?} passively acknowledged"); - return; - } - - // Fresh jitter decorrelates the retransmission wave, which is - // synchronized by the shared ack deadline - R::sleep( - arc_self - .tunables - .max_broadcast_jitter - .mul_f32(rand::random::()), - ) - .await; - - // Acks may have trickled in during the jitter sleep - if arc_self.broadcast_passively_acked(key) { - tracing::debug!("Relayed broadcast {key:?} passively acknowledged"); - return; - } - } - - if let Err(err) = arc_self - .transmit_broadcast_nwk_frame( - relayed_frame.clone(), - NwkSecurityMode::NetworkKey, - TxPriority::USER_NORMAL, - ) - .await - { - tracing::warn!("Failed to relay broadcast: {err}"); - } - } - }); + // Unlike an originated broadcast, the first relay is also scheduled (after jitter) + // rather than sent inline, so the attempt count includes it. The passive-ack + // contract was recorded when we received the frame, so the reactor's quorum check + // already covers relayed broadcasts. + self.schedule_broadcast( + key, + relayed_frame, + NwkSecurityMode::NetworkKey, + TxPriority::USER_NORMAL, + self.broadcast_jitter(), + self.tunables.max_broadcast_retries + 1, + ); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 193ce87..1c56d7f 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -250,6 +250,12 @@ impl ZigbeeStack { /// Broadcast a route request `attempts` times, separated by the RREQ retry /// interval. The frame's sequence number must already be assigned: route request /// retries and relays are not new frames. + /// + // TODO: this is the last per-broadcast spawn. Route requests are a distinct + // retransmit regime from data broadcasts: no passive-ack, a fixed count at a fixed + // interval when originated (spec 3.6.4.5.1.4) and jittered per-retransmission when + // relayed. They were left out of the broadcast-retransmit reactor. Fold them in + // (as a non-passive-ack schedule variant) to remove this spawn. fn background_broadcast_route_request( &self, nwk_frame: NwkFrame, From f976dcf9f9312106a7ee5a4f72df6a8a00653fd1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:10:37 -0400 Subject: [PATCH 11/17] `Signal` primitive to wrap Mutex + Notify and replace `oneshot::Sender` --- crates/ziggurat-driver/src/lib.rs | 1 + crates/ziggurat-driver/src/signal.rs | 115 ++++++++++++++++++ crates/ziggurat-driver/src/zigbee_stack.rs | 9 +- .../ziggurat-driver/src/zigbee_stack/aps.rs | 8 +- .../src/zigbee_stack/indirect.rs | 26 ++-- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 17 +-- 6 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 crates/ziggurat-driver/src/signal.rs diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index 8b5ce8e..d23924d 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -1,4 +1,5 @@ pub mod runtime; +pub mod signal; pub mod zigbee_stack; pub use ziggurat_ieee_802154; diff --git a/crates/ziggurat-driver/src/signal.rs b/crates/ziggurat-driver/src/signal.rs new file mode 100644 index 0000000..0d467ae --- /dev/null +++ b/crates/ziggurat-driver/src/signal.rs @@ -0,0 +1,115 @@ +//! `Signal` primitive: effectively a `Mutex` plus a `Notify`. + +use core::fmt; +use parking_lot::Mutex; +use std::sync::Arc; +use tokio::sync::Notify; + +/// 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 b412531..e897bf7 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1,6 +1,7 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; use crate::runtime::{Elapsed, RtInstant, Runtime}; +use crate::signal::{Signal, SignalWaiter}; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; @@ -21,7 +22,7 @@ use std::ops::{Deref, DerefMut}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc, oneshot}; +use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc}; use tokio::task::JoinSet; use ziggurat_zigbee::nwk::frame::NwkFrame; @@ -253,13 +254,13 @@ impl ApsAckData { /// the child extracts it), or `Err` on transmit failure, expiry, or drop. Shared by the /// sender queue, the indirect queue, and queued frames, since a completion can hand off /// between them. -pub type TxCompletion = oneshot::Sender>; +pub type TxCompletion = Signal>; /// The end-to-end delivery confirmation of a transmitted APS frame, pending until the /// destination's APS ack arrives. Resolved via [`ZigbeeStack::wait_aps_ack`]. #[derive(Debug)] pub struct ApsAckWaiter { - pub(crate) receiver: oneshot::Receiver<()>, + pub(crate) receiver: SignalWaiter<()>, pub(crate) timeout: Duration, pub(crate) ack_data: ApsAckData, } @@ -455,7 +456,7 @@ pub struct State { /// All mutable protocol state, behind one lock pub core: Mutex, - pub pending_aps_acks: Mutex>>, + pub pending_aps_acks: Mutex>>, pub pending_routes: Mutex>, /// Broadcasts awaiting retransmission, keyed by (source, sequence number). pub pending_broadcasts: Mutex>, diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 78b60a9..b422871 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -7,9 +7,9 @@ use ziggurat_zigbee::aps::frame::{ }; use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery}; +use crate::signal; use std::cmp; use std::collections::hash_map::Entry; -use tokio::sync::oneshot; use ziggurat_phy::RadioPhy; use super::{ @@ -86,7 +86,7 @@ impl ZigbeeStack { .unwrap() .remove(&ack_data); if let Some(tx) = tx { - let _ = tx.send(()); + tx.signal(()); } } @@ -304,7 +304,7 @@ impl ZigbeeStack { counter: aps_seq, }; - let (ack_tx, ack_rx) = oneshot::channel(); + let (ack_tx, ack_rx) = signal::channel(); tracing::debug!("APS ACK requested, waiting for {ack_data:?}"); { @@ -348,7 +348,7 @@ impl ZigbeeStack { /// Wait for the end-to-end APS ack of a previously transmitted frame. pub async fn wait_aps_ack(&self, waiter: ApsAckWaiter) -> Result<(), ZigbeeStackError> { - match R::timeout(waiter.timeout, waiter.receiver).await { + match R::timeout(waiter.timeout, waiter.receiver.wait()).await { Ok(Ok(())) => { tracing::debug!("APS ACK received"); Ok(()) diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 2de8604..72b5631 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,9 +1,9 @@ use crate::runtime::Runtime; +use crate::signal::{self, SignalWaiter}; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::RadioPhy; -use tokio::sync::oneshot; use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; @@ -44,8 +44,8 @@ impl ZigbeeStack { &self, destination: Ieee802154Address, frame: Ieee802154Frame, - ) -> oneshot::Receiver> { - let (completion, result_rx) = oneshot::channel(); + ) -> SignalWaiter> { + let (completion, result_rx) = signal::channel(); self.enqueue_indirect_frame(destination, frame, completion); result_rx } @@ -57,7 +57,9 @@ impl ZigbeeStack { ) -> Result<(), ZigbeeStackError> { // Every transaction is eventually resolved by delivery, the expiry sweep, or // child eviction; a dropped sender means the stack is shutting down - self.push_indirect_frame(destination, frame) + let waiter = self.push_indirect_frame(destination, frame); + waiter + .wait() .await .unwrap_or(Err(ZigbeeStackError::IndirectExpired { destination })) } @@ -130,9 +132,9 @@ impl ZigbeeStack { .extract(source_eui64, source_nwk, self.core_now()); for (destination, transaction) in outcome.expired { - let _ = transaction + transaction .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + .signal(Err(ZigbeeStackError::IndirectExpired { destination })); } let Some(delivery) = outcome.delivery else { @@ -186,7 +188,7 @@ impl ZigbeeStack { .await { Ok(()) => { - let _ = transaction.completion.send(Ok(())); + transaction.completion.signal(Ok(())); self.remove_indirect_queue_if_empty(destination); } // 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, @@ -199,7 +201,7 @@ impl ZigbeeStack { .requeue(destination, transaction); } Err(err) => { - let _ = transaction.completion.send(Err(err)); + transaction.completion.signal(Err(err)); self.remove_indirect_queue_if_empty(destination); } } @@ -220,9 +222,9 @@ impl ZigbeeStack { } for (destination, transaction) in dropped { - let _ = transaction + transaction .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + .signal(Err(ZigbeeStackError::IndirectExpired { destination })); } self.src_match_sync.notify_one(); @@ -354,9 +356,9 @@ impl ZigbeeStack { for (destination, transaction) in expired { tracing::warn!("Indirect transaction to {destination:?} expired without a poll"); - let _ = transaction + transaction .completion - .send(Err(ZigbeeStackError::IndirectExpired { destination })); + .signal(Err(ZigbeeStackError::IndirectExpired { destination })); } self.src_match_sync.notify_one(); diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index ee7b98b..1a5ddae 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1,11 +1,11 @@ use crate::runtime::{Elapsed, Runtime}; +use crate::signal; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; use std::sync::atomic::Ordering as AtomicOrdering; use std::time::Duration; -use tokio::sync::oneshot; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxResult}; @@ -482,7 +482,7 @@ impl ZigbeeStack { "Dropping frame to {destination:?}: no route and discovery suppressed" ); if let Some(completion) = completion { - let _ = completion.send(Err(ZigbeeStackError::RouteDiscoverySuppressed)); + completion.signal(Err(ZigbeeStackError::RouteDiscoverySuppressed)); } } } @@ -643,9 +643,10 @@ impl ZigbeeStack { mode: SendMode, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { - let (completion_tx, completion_rx) = oneshot::channel(); + let (completion_tx, completion_rx) = signal::channel(); self.originate_unicast(nwk_frame, security, mode, priority, Some(completion_tx)); completion_rx + .wait() .await .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) } @@ -764,9 +765,10 @@ impl ZigbeeStack { kind: SendKind, priority: TxPriority, ) -> Result<(), ZigbeeStackError> { - let (completion_tx, completion_rx) = oneshot::channel(); + let (completion_tx, completion_rx) = signal::channel(); self.enqueue_send(kind, priority, Some(completion_tx)); completion_rx + .wait() .await .unwrap_or(Err(ZigbeeStackError::TransmitFailed(TxResult::Aborted))) } @@ -932,7 +934,7 @@ impl ZigbeeStack { } NextHop::NeedDiscovery | NextHop::Discard => { if let Some(completion) = completion { - let _ = completion.send(Err(ZigbeeStackError::RouteInactiveAfterDiscovery)); + completion.signal(Err(ZigbeeStackError::RouteInactiveAfterDiscovery)); } } } @@ -976,8 +978,7 @@ impl ZigbeeStack { ); for PendingFrame { completion, .. } in frames { if let Some(completion) = completion { - let _ = - completion.send(Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed))); + completion.signal(Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed))); } } } @@ -1019,7 +1020,7 @@ impl ZigbeeStack { match request.completion { Some(completion) => { - let _ = completion.send(result); + completion.signal(result); } None => { if let Err(err) = result { From dab7f35f8bb61082c1f49fc864a48fab72049d6e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:26 -0400 Subject: [PATCH 12/17] Abstract away `broadcast` --- crates/ziggurat-driver/src/zigbee_stack.rs | 57 ++++++++++++------- .../src/zigbee_stack/indirect.rs | 2 +- .../src/zigbee_stack/joining.rs | 48 +++++++--------- crates/ziggurat-server/src/main.rs | 15 +++-- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index e897bf7..d6db93c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -16,13 +16,13 @@ use thiserror::Error; use parking_lot::{Mutex, MutexGuard}; use std::cmp::Ordering; -use std::collections::{BinaryHeap, HashMap}; +use std::collections::{BinaryHeap, HashMap, VecDeque}; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast, mpsc}; +use tokio::sync::{Mutex as AsyncMutex, Notify, mpsc}; use tokio::task::JoinSet; use ziggurat_zigbee::nwk::frame::NwkFrame; @@ -694,7 +694,8 @@ pub struct ZigbeeStack { pub config: NetworkConfig, pub tunables: Tunables, pub radio: Arc

, - pub notification_tx: broadcast::Sender, + notifications: Mutex>, + notification_wake: Notify, pub raw_frame_rx: AsyncMutex, pub reset_rx: AsyncMutex, /// Installed for the duration of a network scan; the receive loop forwards decoded @@ -781,24 +782,19 @@ impl ZigbeeStack { R::timeout(deadline.saturating_duration_since(self.core_now()), future).await } - pub fn new( - radio: Arc

, - config: NetworkConfig, - tunables: Tunables, - ) -> (Arc, broadcast::Receiver) { - let (notification_tx, notification_rx) = broadcast::channel::(32); - + pub fn new(radio: Arc

, config: NetworkConfig, tunables: Tunables) -> Arc { let raw_frame_rx = radio.subscribe_rx(); let reset_rx = radio.subscribe_reset(); - let arc_stack = Arc::new_cyclic(|weak_self| Self { + Arc::new_cyclic(|weak_self| Self { self_weak: weak_self.clone(), start_time: R::now(), state: State::new(&config, &tunables), config, tunables, radio, - notification_tx, + notifications: Mutex::new(VecDeque::new()), + notification_wake: Notify::new(), raw_frame_rx: AsyncMutex::new(raw_frame_rx), reset_rx: AsyncMutex::new(reset_rx), network_scan_tx: Mutex::new(None), @@ -814,9 +810,32 @@ impl ZigbeeStack { pending_route_wake: Notify::new(), send_seq: AtomicU64::new(0), background_tasks: Mutex::new(JoinSet::new()), - }); + }) + } - (arc_stack, notification_rx) + /// Queue a network event and wake the notification drainer. + pub(crate) fn push_notification(&self, notification: ZigbeeNotification) { + self.notifications + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .push_back(notification); + self.notification_wake.notify_one(); + } + + /// Wait for and take all queued network events. + pub async fn next_notifications(&self) -> Vec { + loop { + let batch: Vec = self + .notifications + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .drain(..) + .collect(); + if !batch.is_empty() { + return batch; + } + self.notification_wake.notified().await; + } } // This function intentionally holds locks across await points to maintain @@ -934,7 +953,7 @@ impl ZigbeeStack { rssi: packet.rssi, data: aps_frame.asdu.to_vec(), }; - let _ = self.notification_tx.send(notification); + self.push_notification(notification); } ziggurat_ieee_802154::Ieee802154Frame::Ack(_ack_frame) => {} ziggurat_ieee_802154::Ieee802154Frame::Beacon(beacon_frame) => { @@ -1351,11 +1370,9 @@ impl ZigbeeStack { let advance = self.core().nib.nwk_security.next_outgoing_frame_counter(); if advance.should_persist { - let _ = self - .notification_tx - .send(ZigbeeNotification::FrameCounterUpdate { - frame_counter: advance.value, - }); + self.push_notification(ZigbeeNotification::FrameCounterUpdate { + frame_counter: advance.value, + }); } advance.value diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 72b5631..266abe2 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -379,7 +379,7 @@ impl ZigbeeStack { self.drop_indirect_transactions(Some(eui64), nwk); self.core().nib.routing.remove_route(nwk); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk, ieee: Some(eui64), reason: DeviceLeaveReason::KeepaliveTimeout, diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 48a6cf8..2dbbee7 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -376,9 +376,7 @@ impl ZigbeeStack { if let Some(key) = provisional_key { tracing::info!("Device {ieee:?} is joining with its provisional link key"); - let _ = self - .notification_tx - .send(ZigbeeNotification::LinkKeyUpdate { ieee, key }); + self.push_notification(ZigbeeNotification::LinkKeyUpdate { ieee, key }); } } @@ -450,7 +448,7 @@ impl ZigbeeStack { self.background_send_nwk_frame(nwk_frame, NwkSecurityMode::Unsecured, SendMode::Direct); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: destination, ieee: destination_eui64, parent: self.state.network_address, @@ -488,17 +486,15 @@ impl ZigbeeStack { nwk_frame.nwk_header.source, extended_source ); - let _ = self - .notification_tx - .send(ZigbeeNotification::ApsDecryptionFailure { - source: nwk_frame.nwk_header.source, - source_ieee: extended_source, - frame_counter: encrypted_command_frame.aux_header.frame_counter, - key_id: format!( - "{:?}", - encrypted_command_frame.aux_header.security_control.key_id - ), - }); + self.push_notification(ZigbeeNotification::ApsDecryptionFailure { + source: nwk_frame.nwk_header.source, + source_ieee: extended_source, + frame_counter: encrypted_command_frame.aux_header.frame_counter, + key_id: format!( + "{:?}", + encrypted_command_frame.aux_header.security_control.key_id + ), + }); } } } @@ -725,12 +721,10 @@ impl ZigbeeStack { // Persist only now that the device has proven possession (spec 4.7.3.3): // the pending key has been promoted to the device's active key. let key = self.core().aib.aps_security.device_link_key(source_ieee); - let _ = self - .notification_tx - .send(ZigbeeNotification::LinkKeyUpdate { - ieee: source_ieee, - key, - }); + self.push_notification(ZigbeeNotification::LinkKeyUpdate { + ieee: source_ieee, + key, + }); APS_STATUS_SUCCESS } @@ -852,7 +846,7 @@ impl ZigbeeStack { self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); self.send_tunneled_network_key(router_nwk, update.device_address, JoinKind::New); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -873,7 +867,7 @@ impl ZigbeeStack { self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); self.send_tunneled_network_key(router_nwk, update.device_address, JoinKind::Rejoin); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -882,7 +876,7 @@ impl ZigbeeStack { ApsUpdateDeviceStatus::StandardDeviceSecuredRejoin => { self.update_nwk_eui64_mapping(update.device_short_address, update.device_address); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, @@ -896,7 +890,7 @@ impl ZigbeeStack { .source_ieee .or_else(|| self.core().nib.address_map.eui64_for(router_nwk)); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk: update.device_short_address, ieee: Some(update.device_address), reason: DeviceLeaveReason::RouterReported { @@ -1076,7 +1070,7 @@ impl ZigbeeStack { // `send_network_key` also emits the join notification self.send_network_key(assigned_nwk, source_ieee, JoinKind::Rejoin); } else { - let _ = self.notification_tx.send(ZigbeeNotification::DeviceJoined { + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: assigned_nwk, ieee: source_ieee, parent: self.state.network_address, @@ -1146,7 +1140,7 @@ impl ZigbeeStack { self.drop_indirect_transactions(source_ieee, source); self.core().nib.routing.remove_route(source); - let _ = self.notification_tx.send(ZigbeeNotification::DeviceLeft { + self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, ieee: source_ieee, reason: DeviceLeaveReason::Announced { diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 7e7915b..98c44e5 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -671,7 +671,7 @@ impl ZigguratServer { Err(e) => return error_response(id, "serial_port_error", e), }; - let (stack, mut stack_notification_rx) = ZigbeeStack::new( + let stack = ZigbeeStack::new( phy, NetworkConfig { role: request.role.into(), @@ -721,12 +721,17 @@ impl ZigguratServer { stack_clone.run().await; }); - // Pump the stack's notifications into the server-level hub + // Drain the stack's notification outbox into the server-level hub. The task is + // aborted when the stack is replaced (see `handle_configure`), so it doesn't + // need to observe a closed channel to stop. let hub_tx = self.notification_tx.clone(); + let notification_stack = stack.clone(); let forwarder = tokio::spawn(async move { - while let Ok(event) = stack_notification_rx.recv().await { - // Send errors just mean no client is connected right now - let _ = hub_tx.send(event); + loop { + for event in notification_stack.next_notifications().await { + // Send errors just mean no client is connected right now + let _ = hub_tx.send(event); + } } }); From 7de3546f9dae5a12e6ce763192abbb324f4955df Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:43:43 -0400 Subject: [PATCH 13/17] Test: migrate network scanning --- crates/ziggurat-driver/src/zigbee_stack.rs | 100 ++++++++++++++------- crates/ziggurat-server/src/main.rs | 78 ++++++++-------- 2 files changed, 106 insertions(+), 72 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index d6db93c..25991a9 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -19,10 +19,10 @@ use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap, VecDeque}; use std::future::Future; use std::ops::{Deref, DerefMut}; -use std::sync::atomic::AtomicU64; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::{Mutex as AsyncMutex, Notify, mpsc}; +use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::task::JoinSet; use ziggurat_zigbee::nwk::frame::NwkFrame; @@ -698,9 +698,11 @@ pub struct ZigbeeStack { notification_wake: Notify, pub raw_frame_rx: AsyncMutex, pub reset_rx: AsyncMutex, - /// Installed for the duration of a network scan; the receive loop forwards decoded - /// beacons here while it is set. - network_scan_tx: Mutex>>, + /// Whether a network scan is collecting. The receive loop only queues beacons while + /// this is set, so stray beacons outside a scan are dropped. + scan_active: AtomicBool, + scan_beacons: Mutex>, + scan_beacon_wake: Notify, /// Wakes the task that rewrites the RCP source address match table whenever the /// set of devices with queued indirect transactions changes @@ -797,7 +799,9 @@ impl ZigbeeStack { notification_wake: Notify::new(), raw_frame_rx: AsyncMutex::new(raw_frame_rx), reset_rx: AsyncMutex::new(reset_rx), - network_scan_tx: Mutex::new(None), + scan_active: AtomicBool::new(false), + scan_beacons: Mutex::new(VecDeque::new()), + scan_beacon_wake: Notify::new(), src_match_sync: Notify::new(), src_match_written: Mutex::new(SrcMatchTable::default()), parent_annce_received: Mutex::new(None), @@ -1188,8 +1192,9 @@ impl ZigbeeStack { } } - /// Decode a received beacon and, if a network scan is in flight, forward it to the - /// scan's collector. Beacons received outside a scan are dropped. + /// Decode a received beacon and, if a network scan is in flight, collect it into + /// the scan's outbox for the collector to drain. Beacons received outside a scan + /// are dropped. fn handle_beacon( &self, beacon: &ziggurat_ieee_802154::Ieee802154BeaconFrame, @@ -1197,9 +1202,10 @@ impl ZigbeeStack { lqi: u8, rssi: i8, ) { - let Some(tx) = self.network_scan_tx.lock().clone() else { + // Skip the decode entirely when no scan is collecting. + if !self.scan_active.load(AtomicOrdering::Relaxed) { return; - }; + } let payload = match ZigbeeBeacon::from_abstract_bits(&beacon.beacon_payload) { Ok(payload) => payload, @@ -1217,7 +1223,7 @@ impl ZigbeeStack { return; }; - let _ = tx.try_send(NetworkBeacon { + let network_beacon = NetworkBeacon { channel, source, pan_id, @@ -1231,18 +1237,31 @@ impl ZigbeeStack { update_id: payload.update_id, lqi, rssi, - }); + }; + + self.scan_beacons + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .push_back(network_beacon); + self.scan_beacon_wake.notify_one(); } - /// Active scan: broadcast a beacon request on each channel and collect the beacons. - pub async fn network_scan( + /// Open the beacon-collection window for an active scan. + pub fn begin_network_scan(&self) { + self.scan_beacons + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .clear(); + self.scan_active.store(true, AtomicOrdering::Relaxed); + } + + /// Active scan: broadcast a beacon request on each channel and dwell to collect + /// beacons. + pub async fn run_network_scan( &self, channels: &[u8], duration_per_channel: Duration, - found: mpsc::Sender, ) -> Result<(), ZigbeeStackError> { - *self.network_scan_tx.lock() = Some(found); - let beacon_request = self.beacon_request_psdu(); let home_channel = self.core().mac.channel; @@ -1267,28 +1286,43 @@ impl ZigbeeStack { } .await; - *self.network_scan_tx.lock() = None; + // Close the window and wake the drainer so it delivers the last beacons and stops. + self.scan_active.store(false, AtomicOrdering::Relaxed); + self.scan_beacon_wake.notify_one(); result.map_err(Into::into) } - /// Performs an energy detect scan, sending the maximum RSSI seen on each channel to - /// `results` as that channel completes. - pub async fn energy_scan( - &self, - channels: &[u8], - duration_per_channel: Duration, - results: mpsc::Sender<(u8, i8)>, - ) -> Result<(), ZigbeeStackError> { - for &channel in channels { - let max_rssi = self - .radio - .energy_detect(channel, duration_per_channel) - .await?; - let _ = results.send((channel, max_rssi)).await; + /// Wait for and take beacons collected so far by the active scan. Drains any + /// remaining beacons even after the window closes, then returns empty once both + /// the window is closed. + pub async fn next_scan_beacons(&self) -> Vec { + loop { + let batch: Vec = self + .scan_beacons + .try_lock_for(LOCK_ACQUIRE_TIMEOUT) + .unwrap() + .drain(..) + .collect(); + if !batch.is_empty() { + return batch; + } + if !self.scan_active.load(AtomicOrdering::Relaxed) { + return Vec::new(); + } + self.scan_beacon_wake.notified().await; } + } - Ok(()) + /// One channel of an energy-detect scan: the maximum RSSI seen on `channel`. The + /// manager loops over channels and streams the results; no radio state is held + /// between calls. + pub async fn energy_detect( + &self, + channel: u8, + duration: Duration, + ) -> Result { + Ok(self.radio.energy_detect(channel, duration).await?) } /// Retune the radio to a new channel, the coordinator's half of a network-wide diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 98c44e5..93b0287 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -948,33 +948,25 @@ impl ZigguratServer { return error_response(id, "not_configured", "no stack is running"); }; - let (result_tx, mut result_rx) = mpsc::channel::<(u8, i8)>(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. + // An energy detect is self-contained per channel, so the manager owns the loop + // and streams each result as the channel completes. let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); - let scan = tokio::spawn(async move { - stack - .energy_scan(&request.channels, duration, result_tx) - .await - }); - - while let Some((channel, rssi)) = result_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "energy_result", - json!({"channel": channel, "rssi": rssi}), - )) - .await; + for channel in request.channels { + match stack.energy_detect(channel, duration).await { + Ok(rssi) => { + let _ = outbound + .send(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": rssi}), + )) + .await; + } + Err(e) => return error_response(id, "energy_scan_failed", e), + } } - match scan.await { - Ok(Ok(())) => response(id, json!({"status": "complete"})), - Ok(Err(e)) => error_response(id, "energy_scan_failed", e), - Err(e) => error_response(id, "energy_scan_failed", e), - } + response(id, json!({"status": "complete"})) } async fn handle_network_scan( @@ -992,26 +984,34 @@ impl ZigguratServer { return error_response(id, "not_configured", "no stack is running"); }; - let (found_tx, mut found_rx) = mpsc::channel::(32); - - // The scan runs on its own task so it always reaches its channel restore, even if - // this request's task is dropped. Its only sender lives until the scan ends, so - // the drain loop below terminates exactly when the scan is done. + // Open the collection window before spawning, so the drain loop below cannot race + // ahead of the scan starting. The scan runs on its own task so it always reaches + // its channel restore even if this request's task is dropped. + stack.begin_network_scan(); let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let scan_stack = stack.clone(); let scan = tokio::spawn(async move { - stack - .network_scan(&request.channels, duration, found_tx) + scan_stack + .run_network_scan(&request.channels, duration) .await }); - while let Some(beacon) = found_rx.recv().await { - let _ = outbound - .send(event_data( - id, - "network_found", - network_beacon_json(&beacon), - )) - .await; + // `next_scan_beacons` delivers beacons as they arrive and returns empty once the + // window has closed and the queue is drained, which ends the loop. + loop { + let batch = stack.next_scan_beacons().await; + if batch.is_empty() { + break; + } + for beacon in batch { + let _ = outbound + .send(event_data( + id, + "network_found", + network_beacon_json(&beacon), + )) + .await; + } } match scan.await { From 23fabcbaca5816fe2b0c27a58e568346d47dbf49 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:03:16 -0400 Subject: [PATCH 14/17] Test: abstract parking_lot Mutex and drop deadlock canary --- crates/ziggurat-driver/src/lib.rs | 1 + crates/ziggurat-driver/src/signal.rs | 3 +- crates/ziggurat-driver/src/sync.rs | 5 + crates/ziggurat-driver/src/zigbee_stack.rs | 64 +++-------- .../ziggurat-driver/src/zigbee_stack/aps.rs | 32 ++---- .../src/zigbee_stack/indirect.rs | 17 +-- .../src/zigbee_stack/joining.rs | 13 +-- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 106 +++++------------- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 11 +- 9 files changed, 70 insertions(+), 182 deletions(-) create mode 100644 crates/ziggurat-driver/src/sync.rs diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index d23924d..e606cda 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -1,5 +1,6 @@ pub mod runtime; pub mod signal; +pub mod sync; pub mod zigbee_stack; pub use ziggurat_ieee_802154; diff --git a/crates/ziggurat-driver/src/signal.rs b/crates/ziggurat-driver/src/signal.rs index 0d467ae..f0b7ee0 100644 --- a/crates/ziggurat-driver/src/signal.rs +++ b/crates/ziggurat-driver/src/signal.rs @@ -1,9 +1,8 @@ //! `Signal` primitive: effectively a `Mutex` plus a `Notify`. +use crate::sync::{Mutex, Notify}; use core::fmt; -use parking_lot::Mutex; use std::sync::Arc; -use tokio::sync::Notify; /// The producer was dropped without ever signalling a value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/ziggurat-driver/src/sync.rs b/crates/ziggurat-driver/src/sync.rs new file mode 100644 index 0000000..393cc0e --- /dev/null +++ b/crates/ziggurat-driver/src/sync.rs @@ -0,0 +1,5 @@ +//! The synchronization primitives the stack rests on: a blocking [`Mutex`] and an async +//! [`Notify`]. + +pub use parking_lot::{Mutex, MutexGuard}; +pub use tokio::sync::Notify; diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 25991a9..5eb7cad 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -14,7 +14,8 @@ use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; -use parking_lot::{Mutex, MutexGuard}; +use crate::sync::Notify; +use crate::sync::{Mutex, MutexGuard}; use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap, VecDeque}; use std::future::Future; @@ -22,7 +23,7 @@ use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::{Mutex as AsyncMutex, Notify}; +use tokio::sync::Mutex as AsyncMutex; use tokio::task::JoinSet; use ziggurat_zigbee::nwk::frame::NwkFrame; @@ -49,9 +50,6 @@ pub use ziggurat_zigbee::nwk::routing::Routing; pub use ziggurat_zigbee::nwk::security::NwkSecurity; pub use ziggurat_zigbee::nwk::{neighbors, routing}; -/// Hard deadline for acquiring a lock. Anything exceeding this is an error. -const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(10); - /// How long the RCP gets to announce itself after a `CMD_RESET` before we resend. const RESET_NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(2); const RESET_ATTEMPTS: u32 = 5; @@ -430,11 +428,9 @@ pub struct ZigbeeCore { pub trust_center_joins_until: Option, } -/// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It exists -/// to encode the single-lock discipline in one place: -/// -/// - It is `!Send` so holding it across an `.await` is a compile-time error. -/// - It is acquired with a [`LOCK_ACQUIRE_TIMEOUT`] so we fail at runtime if this lapses. +/// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It encodes +/// the single-lock discipline: it is `!Send`, so holding it across an `.await` is a +/// compile-time error. pub struct CoreGuard<'a>(MutexGuard<'a, ZigbeeCore>); impl Deref for CoreGuard<'_> { @@ -749,7 +745,7 @@ impl ZigbeeStack { /// Briefly lock the protocol core. See [`CoreGuard`] for the locking discipline the /// returned guard encodes. fn core(&self) -> CoreGuard<'_> { - CoreGuard(self.state.core.try_lock_for(LOCK_ACQUIRE_TIMEOUT).unwrap()) + CoreGuard(self.state.core.lock()) } /// The sans-io core's clock reads as microseconds since this stack started. This @@ -819,22 +815,14 @@ impl ZigbeeStack { /// Queue a network event and wake the notification drainer. pub(crate) fn push_notification(&self, notification: ZigbeeNotification) { - self.notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .push_back(notification); + self.notifications.lock().push_back(notification); self.notification_wake.notify_one(); } /// Wait for and take all queued network events. pub async fn next_notifications(&self) -> Vec { loop { - let batch: Vec = self - .notifications - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .drain(..) - .collect(); + let batch: Vec = self.notifications.lock().drain(..).collect(); if !batch.is_empty() { return batch; } @@ -1158,10 +1146,7 @@ impl ZigbeeStack { self.radio.reconfigure(&config).await?; - *self - .src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = table; + *self.src_match_written.lock() = table; Ok(()) } @@ -1239,19 +1224,13 @@ impl ZigbeeStack { rssi, }; - self.scan_beacons - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .push_back(network_beacon); + self.scan_beacons.lock().push_back(network_beacon); self.scan_beacon_wake.notify_one(); } /// Open the beacon-collection window for an active scan. pub fn begin_network_scan(&self) { - self.scan_beacons - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .clear(); + self.scan_beacons.lock().clear(); self.scan_active.store(true, AtomicOrdering::Relaxed); } @@ -1298,12 +1277,7 @@ impl ZigbeeStack { /// the window is closed. pub async fn next_scan_beacons(&self) -> Vec { loop { - let batch: Vec = self - .scan_beacons - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .drain(..) - .collect(); + let batch: Vec = self.scan_beacons.lock().drain(..).collect(); if !batch.is_empty() { return batch; } @@ -1345,10 +1319,7 @@ impl ZigbeeStack { where F: Future + Send + 'static, { - let mut tasks = self - .background_tasks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut tasks = self.background_tasks.lock(); // A completed task's entire cell is retained until it is reaped from the // set: drain here so the set tracks live tasks instead of growing by one @@ -1382,12 +1353,7 @@ impl ZigbeeStack { /// replaced stack provably stops processing frames and transmitting before its /// successor takes over the shared Spinel client. pub async fn shutdown(&self) { - let mut tasks = std::mem::take( - &mut *self - .background_tasks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(), - ); + let mut tasks = std::mem::take(&mut *self.background_tasks.lock()); tasks.abort_all(); while tasks.join_next().await.is_some() {} diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index b422871..926605b 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -13,8 +13,8 @@ use std::collections::hash_map::Entry; use ziggurat_phy::RadioPhy; use super::{ - ApsAck, ApsAckData, ApsAckWaiter, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendMode, TxPriority, - ZigbeeStack, ZigbeeStackError, + ApsAck, ApsAckData, ApsAckWaiter, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack, + ZigbeeStackError, }; impl ZigbeeStack { @@ -79,12 +79,7 @@ impl ZigbeeStack { let ack_data = ApsAckData::from_aps_ack(nwk_frame.nwk_header.source, ack); tracing::debug!("Received APS ack: {ack_data:?}"); - let tx = self - .state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&ack_data); + let tx = self.state.pending_aps_acks.lock().remove(&ack_data); if let Some(tx) = tx { tx.signal(()); } @@ -98,11 +93,7 @@ impl ZigbeeStack { let now = self.core_now(); let timeout = self.tunables.aps_duplicate_rejection_timeout; - let mut table = self - .state - .aps_duplicates - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut table = self.state.aps_duplicates.lock(); table.retain(|_, seen| now.saturating_duration_since(*seen) < timeout); match table.entry((source, counter)) { @@ -310,8 +301,7 @@ impl ZigbeeStack { { self.state .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .insert(ack_data.clone(), ack_tx); } @@ -324,11 +314,7 @@ impl ZigbeeStack { ) .await { - self.state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&ack_data); + self.state.pending_aps_acks.lock().remove(&ack_data); return Err(err); } @@ -355,11 +341,7 @@ impl ZigbeeStack { } Ok(Err(_)) | Err(_) => { tracing::warn!("APS ACK timed out for {:?}", waiter.ack_data); - self.state - .pending_aps_acks - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&waiter.ack_data); + self.state.pending_aps_acks.lock().remove(&waiter.ack_data); Err(ZigbeeStackError::ApsAckTimeout) } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 266abe2..5a4c067 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -11,8 +11,8 @@ use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, LOCK_ACQUIRE_TIMEOUT, NwkSecurityMode, SendKind, TxCompletion, TxPriority, - ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + DeviceLeaveReason, NwkSecurityMode, SendKind, TxCompletion, TxPriority, ZigbeeNotification, + ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -97,12 +97,8 @@ impl ZigbeeStack { // source address match table. If that write is still in flight, the device is // asleep again by now: everything stays queued for the next poll instead of // being transmitted into the void. - let fp_advertised = poll_source.is_some_and(|address| { - self.src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .contains(address) - }); + let fp_advertised = + poll_source.is_some_and(|address| self.src_match_written.lock().contains(address)); let delivered = fp_advertised && self.deliver_indirect_transaction(source_eui64, source_nwk); @@ -310,10 +306,7 @@ impl ZigbeeStack { .set_frame_pending_table(&short, &extended) .await?; - *self - .src_match_written - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = table; + *self.src_match_written.lock() = table; Ok(()) } diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 2dbbee7..a25bdae 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -31,8 +31,8 @@ use ziggurat_zigbee::nwk::commands::{ }; use super::{ - AddrConflictSource, DeviceLeaveReason, JoinKind, LOCK_ACQUIRE_TIMEOUT, NwkDeviceType, - NwkSecurityMode, RadioPhy, SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, + AddrConflictSource, DeviceLeaveReason, JoinKind, NwkDeviceType, NwkSecurityMode, RadioPhy, + SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, }; impl ZigbeeStack { @@ -189,11 +189,7 @@ impl ZigbeeStack { /// device children are moved to a fresh address; routers resolve on their own. pub(super) fn handle_address_conflict(&self, address: Nwk, source: AddrConflictSource) { { - let mut conflicts = self - .state - .address_conflicts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut conflicts = self.state.address_conflicts.lock(); let now = self.core_now(); let window = self.tunables.broadcast_delivery_time; @@ -262,8 +258,7 @@ impl ZigbeeStack { let heard_from_network = arc_self .state .address_conflicts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .get(&address) .is_some_and(|conflict| conflict.heard_from_network); diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 1a5ddae..997a2f8 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -23,9 +23,9 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, - PendingBroadcast, PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, - TxPriority, ZigbeeStack, ZigbeeStackError, + AddrConflictSource, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, + PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, TxPriority, + ZigbeeStack, ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -117,8 +117,7 @@ impl ZigbeeStack { fn earliest_broadcast_retransmit(&self) -> Option { self.state .pending_broadcasts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .values() .map(|pending| pending.next_attempt) .min() @@ -131,8 +130,7 @@ impl ZigbeeStack { let keys: Vec<(Nwk, u8)> = self .state .pending_broadcasts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .keys() .copied() .collect(); @@ -142,11 +140,7 @@ impl ZigbeeStack { for key in keys { if self.broadcast_passively_acked(key) { tracing::debug!("Broadcast {key:?} passively acknowledged"); - self.state - .pending_broadcasts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&key); + self.state.pending_broadcasts.lock().remove(&key); continue; } @@ -156,11 +150,7 @@ impl ZigbeeStack { // Decide under the lock; if a copy is due, extract it to transmit after release. let retransmit = { - let mut pending = self - .state - .pending_broadcasts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut pending = self.state.pending_broadcasts.lock(); let Some(broadcast) = pending.get_mut(&key) else { continue; }; @@ -212,20 +202,16 @@ impl ZigbeeStack { return; } - self.state - .pending_broadcasts - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .insert( - key, - PendingBroadcast { - nwk_frame, - security, - priority, - attempts_remaining: attempts, - next_attempt: self.core_now() + first_delay, - }, - ); + self.state.pending_broadcasts.lock().insert( + key, + PendingBroadcast { + nwk_frame, + security, + priority, + attempts_remaining: attempts, + next_attempt: self.core_now() + first_delay, + }, + ); self.broadcast_retransmit_wake.notify_one(); } @@ -710,15 +696,12 @@ impl ZigbeeStack { completion: Option, ) { let seq = self.send_seq.fetch_add(1, AtomicOrdering::Relaxed); - self.send_queue - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .push(SendRequest { - seq, - priority, - kind, - completion, - }); + self.send_queue.lock().push(SendRequest { + seq, + priority, + kind, + completion, + }); self.send_wake.notify_one(); } @@ -784,11 +767,7 @@ impl ZigbeeStack { let destination = nwk_frame.nwk_header.destination; let start_discovery = { - let mut pending = self - .state - .pending_routes - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut pending = self.state.pending_routes.lock(); let is_new = !pending.contains_key(&destination); pending .entry(destination) @@ -838,14 +817,7 @@ impl ZigbeeStack { /// when nothing is waiting on a deadline (the reactor then sleeps on its wake /// signal). fn earliest_discovery_deadline(&self) -> Option { - let destinations: Vec = self - .state - .pending_routes - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .keys() - .copied() - .collect(); + let destinations: Vec = self.state.pending_routes.lock().keys().copied().collect(); let now = self.core_now(); let core = self.core(); @@ -857,14 +829,7 @@ impl ZigbeeStack { /// One reactor pass: classify each queued destination and act on it. fn drive_pending_routes(&self) { - let destinations: Vec = self - .state - .pending_routes - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .keys() - .copied() - .collect(); + let destinations: Vec = self.state.pending_routes.lock().keys().copied().collect(); for destination in destinations { match self.discovery_state(destination) { @@ -904,12 +869,7 @@ impl ZigbeeStack { /// A route exists: re-resolve each queued frame and enqueue it. A frame whose route /// vanished in the race is dropped with an error. fn release_queued_frames(&self, destination: Nwk) { - let bucket = self - .state - .pending_routes - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .remove(&destination); + let bucket = self.state.pending_routes.lock().remove(&destination); let Some(bucket) = bucket else { return; @@ -945,11 +905,7 @@ impl ZigbeeStack { /// left, otherwise mark it failed and discard every frame waiting on it. fn retry_or_fail_discovery(&self, destination: Nwk) { let discarded = { - let mut pending = self - .state - .pending_routes - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap(); + let mut pending = self.state.pending_routes.lock(); let Some(bucket) = pending.get_mut(&destination) else { return; @@ -992,11 +948,7 @@ impl ZigbeeStack { pub(super) async fn sender_task(&self) { loop { loop { - let request = self - .send_queue - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() - .pop(); + let request = self.send_queue.lock().pop(); let Some(request) = request else { break; diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index b27aa94..6b2d915 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -11,8 +11,7 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, LOCK_ACQUIRE_TIMEOUT, MAX_DEPTH, NwkDeviceType, TxPriority, ZigbeeStack, - ZigbeeStackError, neighbors, routing, + ApsAck, MAX_DEPTH, NwkDeviceType, TxPriority, ZigbeeStack, ZigbeeStackError, neighbors, routing, }; /// EUI64s per Parent_annce frame, keeping the ASDU within the NWK payload budget. @@ -238,10 +237,7 @@ impl ZigbeeStack { // Spec 2.4.3.1.12.2: another router's announcement restarts our own pending // announcement countdown to avoid a network-wide broadcast storm - *self - .parent_annce_received - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() = Some(self.core_now()); + *self.parent_annce_received.lock() = Some(self.core_now()); let (claimed, removed) = self .core() @@ -346,8 +342,7 @@ impl ZigbeeStack { // countdown if self .parent_annce_received - .try_lock_for(LOCK_ACQUIRE_TIMEOUT) - .unwrap() + .lock() .is_some_and(|received_at| received_at > slept_at) { continue; From 9bd5d5618f21e10a0bfc5ab665226a64df2c9a5c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:16:00 -0400 Subject: [PATCH 15/17] Abstract the task spawner --- crates/ziggurat-driver/src/runtime.rs | 58 ++++++++++++++++++++++ crates/ziggurat-driver/src/zigbee_stack.rs | 42 ++++++---------- crates/ziggurat-server/src/main.rs | 2 + 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 11d5923..2f7dc92 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -2,8 +2,32 @@ use core::future::Future; use core::ops::Add; +use core::pin::Pin; use core::time::Duration; +/// A detached background task, boxed so one spawn path serves every runtime. +/// +/// Tokio drops it into a tracked `JoinSet`; embassy (later) hands it to a static +/// task-pool runner. Our tasks capture only `Arc` and never hold a +/// `CoreGuard` across an `.await`, so they are genuinely `Send` — no +/// single-threaded-executor `unsafe` is needed. +pub type SpawnedTask = Pin + Send + 'static>>; + +/// Spawns the stack's background tasks. +/// +/// A value, not a static method, because embassy spawning needs its `Spawner` token +/// (which tokio's global spawn doesn't) — so the stack is handed one at construction. +/// Reached via[`Runtime::Spawner`]. +pub trait Spawn: Send + Sync + 'static { + /// Spawn a detached background task. + fn spawn(&self, task: SpawnedTask); + + /// Stop every task spawned through this spawner and wait for them to finish, so a + /// replaced host stack provably stops before its successor runs. A no-op on + /// executors that cannot cancel tasks (embassy). + fn shutdown(&self) -> impl Future + Send; +} + /// The instant type a [`Runtime`] measures time with. Bounded for exactly the /// arithmetic the driver performs on deadlines. pub trait RtInstant: Copy + Send + Sync + 'static + Add { @@ -28,6 +52,9 @@ pub struct Elapsed; pub trait Runtime: Send + Sync + 'static { type Instant: RtInstant; + /// Spawns the stack's background tasks; see [`Spawn`]. + type Spawner: Spawn; + /// The current monotonic instant. fn now() -> Self::Instant; @@ -82,6 +109,7 @@ pub struct TokioRuntime; impl Runtime for TokioRuntime { type Instant = tokio::time::Instant; + type Spawner = TokioSpawner; fn now() -> Self::Instant { tokio::time::Instant::now() @@ -95,3 +123,33 @@ impl Runtime for TokioRuntime { tokio::time::sleep_until(deadline) } } + +/// The tokio spawner: tasks go into a `JoinSet` so a replaced stack can abort them. +#[derive(Default)] +pub struct TokioSpawner { + tasks: parking_lot::Mutex>, +} + +impl Spawn for TokioSpawner { + fn spawn(&self, task: SpawnedTask) { + let mut tasks = self.tasks.lock(); + + // A completed task's cell lingers until reaped; drain here so the set tracks live + // tasks instead of growing by one dead entry per spawn. + while let Some(result) = tasks.try_join_next() { + if let Err(e) = result + && e.is_panic() + { + tracing::error!("Background task panicked: {e}"); + } + } + + tasks.spawn(task); + } + + async fn shutdown(&self) { + let mut tasks = core::mem::take(&mut *self.tasks.lock()); + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + } +} diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 5eb7cad..f1d3841 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1,6 +1,6 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; -use crate::runtime::{Elapsed, RtInstant, Runtime}; +use crate::runtime::{Elapsed, RtInstant, Runtime, Spawn}; use crate::signal::{Signal, SignalWaiter}; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; @@ -24,7 +24,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; use std::sync::{Arc, Weak}; use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; -use tokio::task::JoinSet; use ziggurat_zigbee::nwk::frame::NwkFrame; mod aps; @@ -735,10 +734,10 @@ pub struct ZigbeeStack { /// Monotonic tiebreaker giving equal-priority sends FIFO order in `send_queue`. pub(crate) send_seq: AtomicU64, - /// All tasks spawned by the stack, so that a replaced stack can be fully stopped: - /// a leaked background task would keep the replaced stack processing frames and - /// transmitting alongside its successor - background_tasks: Mutex>, + /// Spawns and owns the stack's background tasks, so that a replaced stack can be fully + /// stopped: a leaked background task would keep the replaced stack processing frames + /// and transmitting alongside its successor. + spawner: R::Spawner, } impl ZigbeeStack { @@ -780,7 +779,12 @@ impl ZigbeeStack { R::timeout(deadline.saturating_duration_since(self.core_now()), future).await } - pub fn new(radio: Arc

, config: NetworkConfig, tunables: Tunables) -> Arc { + pub fn new( + radio: Arc

, + config: NetworkConfig, + tunables: Tunables, + spawner: R::Spawner, + ) -> Arc { let raw_frame_rx = radio.subscribe_rx(); let reset_rx = radio.subscribe_reset(); @@ -809,7 +813,7 @@ impl ZigbeeStack { send_wake: Notify::new(), pending_route_wake: Notify::new(), send_seq: AtomicU64::new(0), - background_tasks: Mutex::new(JoinSet::new()), + spawner, }) } @@ -1314,25 +1318,12 @@ impl ZigbeeStack { self.core().nib.update_id = update_id; } - /// Spawns a task tied to the stack's lifetime: it is aborted on `shutdown`. + /// Spawns a task tied to the stack's lifetime: it is stopped on `shutdown`. pub fn spawn_tracked(&self, future: F) where F: Future + Send + 'static, { - let mut tasks = self.background_tasks.lock(); - - // A completed task's entire cell is retained until it is reaped from the - // set: drain here so the set tracks live tasks instead of growing by one - // dead entry per spawn - while let Some(result) = tasks.try_join_next() { - if let Err(e) = result - && e.is_panic() - { - tracing::error!("Background task panicked: {e}"); - } - } - - tasks.spawn(future); + self.spawner.spawn(Box::pin(future)); } /// Spawns a tracked task that needs an owned handle to the stack. @@ -1353,10 +1344,7 @@ impl ZigbeeStack { /// replaced stack provably stops processing frames and transmitting before its /// successor takes over the shared Spinel client. pub async fn shutdown(&self) { - let mut tasks = std::mem::take(&mut *self.background_tasks.lock()); - - tasks.abort_all(); - while tasks.join_next().await.is_some() {} + self.spawner.shutdown().await; } pub fn next_aps_counter(&self) -> u8 { diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 93b0287..96fe7d7 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -15,6 +15,7 @@ use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; +use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ ApsAck, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, @@ -690,6 +691,7 @@ impl ZigguratServer { source_routing: request.source_routing, }, Tunables::new(), + TokioSpawner::default(), ); // Restore unique trust center link keys negotiated in earlier sessions From 71f6c667333c96fd7f265446aee0d665fc881a27 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:24:37 -0400 Subject: [PATCH 16/17] Abstract the mutex as well --- crates/ziggurat-driver/src/sync.rs | 5 +++++ crates/ziggurat-driver/src/zigbee_stack.rs | 4 +--- crates/ziggurat-driver/src/zigbee_stack/route.rs | 14 ++++++++------ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/ziggurat-driver/src/sync.rs b/crates/ziggurat-driver/src/sync.rs index 393cc0e..c82203d 100644 --- a/crates/ziggurat-driver/src/sync.rs +++ b/crates/ziggurat-driver/src/sync.rs @@ -3,3 +3,8 @@ pub use parking_lot::{Mutex, MutexGuard}; pub use tokio::sync::Notify; + +/// An async mutex, for the few places a guard is held across an `.await` (the radio +/// stream and reset receivers). Distinct from the blocking [`Mutex`], which must never +/// span a yield; reach for this only when the guard genuinely outlives an await point. +pub use tokio::sync::Mutex as AsyncMutex; diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index f1d3841..c348e2e 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -14,8 +14,7 @@ use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; -use crate::sync::Notify; -use crate::sync::{Mutex, MutexGuard}; +use crate::sync::{AsyncMutex, Mutex, MutexGuard, Notify}; use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap, VecDeque}; use std::future::Future; @@ -23,7 +22,6 @@ use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; use std::sync::{Arc, Weak}; use std::time::Duration; -use tokio::sync::Mutex as AsyncMutex; use ziggurat_zigbee::nwk::frame::NwkFrame; mod aps; diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 1c56d7f..c370581 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -358,14 +358,16 @@ impl ZigbeeStack { let min_deadline = self.core_now() + self.tunables.mtorr_min_interval; let max_deadline = self.core_now() + self.tunables.mtorr_max_interval; - // Avertise every max interval, sooner when accumulated route errors or + // Advertise every max interval, sooner when accumulated route errors or // delivery failures signal that routes toward us have gone bad, but never // within the min interval - tokio::select! { - () = self.sleep_until_core(max_deadline) => {} - () = self.mtorr_kick.notified() => { - self.sleep_until_core(min_deadline).await; - } + let max_sleep = core::pin::pin!(self.sleep_until_core(max_deadline)); + let kicked = core::pin::pin!(self.mtorr_kick.notified()); + if let futures::future::Either::Right(((), _)) = + futures::future::select(max_sleep, kicked).await + { + // Kicked early: still honor the minimum spacing before re-advertising. + self.sleep_until_core(min_deadline).await; } } } From b6f85aca0bbd222f1b61c34c96ca6b307190909a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:37:29 -0400 Subject: [PATCH 17/17] Test: embassy on host?? --- crates/ziggurat-driver/Cargo.toml | 17 +++++ crates/ziggurat-driver/src/runtime.rs | 89 +++++++++++++++++++++++++++ crates/ziggurat-driver/src/sync.rs | 66 +++++++++++++++++--- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/crates/ziggurat-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index 5fec210..b1690b9 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -21,3 +21,20 @@ parking_lot = "0.12.4" rand = "0.10.1" thiserror = "2.0.12" tokio = { version = "1.43.0", features = ["rt", "macros", "time", "sync", "io-util"] } + +# The embassy runtime adapter, host-runnable via arch-std so it can stand in for tokio. +embassy-executor = { version = "0.7", features = [ + "arch-std", + "executor-thread", +], optional = true } +embassy-time = { version = "0.4", features = ["std"], optional = true } +embassy-sync = { version = "0.6", optional = true } +spin = { version = "0.9", default-features = false, features = [ + "spin_mutex", +], optional = true } + +[features] +default = [] +# Select the embassy runtime adapter (and its no_std-friendly sync primitives) instead of +# the default tokio one. Mutually overrides tokio at the `sync`/`runtime` seam. +embassy = ["dep:embassy-executor", "dep:embassy-time", "dep:embassy-sync", "dep:spin"] diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 2f7dc92..181495d 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -153,3 +153,92 @@ impl Spawn for TokioSpawner { while tasks.join_next().await.is_some() {} } } + +/// The embassy runtime adapter. Host-runnable through `arch-std` so it can stand in for +/// tokio, and the same impl drives the MCU once an esp PHY backs it. +#[cfg(feature = "embassy")] +pub use embassy_impl::{EmbassyRuntime, EmbassySpawner}; + +#[cfg(feature = "embassy")] +mod embassy_impl { + use super::{RtInstant, Runtime, Spawn, SpawnedTask}; + use core::future::Future; + use core::ops::Add; + use core::time::Duration; + + const fn to_embassy(duration: Duration) -> embassy_time::Duration { + embassy_time::Duration::from_micros(duration.as_micros() as u64) + } + + const fn from_embassy(duration: embassy_time::Duration) -> Duration { + Duration::from_micros(duration.as_micros()) + } + + /// Wraps `embassy_time::Instant` so the trait's `core::time::Duration` arithmetic + /// works against embassy's own `Duration` type. + #[derive(Copy, Clone)] + pub struct EmbassyInstant(embassy_time::Instant); + + impl Add for EmbassyInstant { + type Output = Self; + + fn add(self, rhs: Duration) -> Self { + Self(self.0 + to_embassy(rhs)) + } + } + + impl RtInstant for EmbassyInstant { + fn saturating_duration_since(self, earlier: Self) -> Duration { + from_embassy(self.0.saturating_duration_since(earlier.0)) + } + } + + pub struct EmbassyRuntime; + + impl Runtime for EmbassyRuntime { + type Instant = EmbassyInstant; + type Spawner = EmbassySpawner; + + fn now() -> Self::Instant { + EmbassyInstant(embassy_time::Instant::now()) + } + + fn sleep(duration: Duration) -> impl Future + Send { + embassy_time::Timer::after(to_embassy(duration)) + } + + fn sleep_until(deadline: Self::Instant) -> impl Future + Send { + embassy_time::Timer::at(deadline.0) + } + } + + /// Each detached task runs in one slot of this fixed pool — embassy has no dynamic + /// spawn, so the size bounds the stack's concurrent background tasks (long-lived + /// reactors plus the transient ZDP/indirect/route-request ones). + #[embassy_executor::task(pool_size = 24)] + async fn task_runner(task: SpawnedTask) { + task.await; + } + + /// Spawns into the embassy executor. Holds a [`SendSpawner`](embassy_executor::SendSpawner) + /// so it is `Send + Sync`; obtained from the executor at startup. + pub struct EmbassySpawner(embassy_executor::SendSpawner); + + impl EmbassySpawner { + pub const fn new(spawner: embassy_executor::SendSpawner) -> Self { + Self(spawner) + } + } + + impl Spawn for EmbassySpawner { + fn spawn(&self, task: SpawnedTask) { + if self.0.spawn(task_runner(task)).is_err() { + tracing::error!("embassy task pool exhausted; background task dropped"); + } + } + + // Embassy cannot cancel spawned tasks; the MCU stack is never replaced, so there + // is nothing to stop. + async fn shutdown(&self) {} + } +} diff --git a/crates/ziggurat-driver/src/sync.rs b/crates/ziggurat-driver/src/sync.rs index c82203d..6224941 100644 --- a/crates/ziggurat-driver/src/sync.rs +++ b/crates/ziggurat-driver/src/sync.rs @@ -1,10 +1,60 @@ -//! The synchronization primitives the stack rests on: a blocking [`Mutex`] and an async -//! [`Notify`]. +//! The synchronization primitives the stack rests on: a blocking [`Mutex`], an async +//! [`AsyncMutex`], and an [`Notify`]. +//! +//! Everything in the driver imports these from here rather than naming `parking_lot`, +//! `tokio`, `spin`, or `embassy-sync` directly, so this module is the single seam where +//! the implementation is chosen by the `embassy` feature. The blocking [`Mutex`] must +//! never be held across an `.await` (the protocol core's +//! [`CoreGuard`](crate::zigbee_stack::CoreGuard) enforces this by being `!Send`); use +//! [`AsyncMutex`] for the few guards that genuinely outlive an await point. -pub use parking_lot::{Mutex, MutexGuard}; -pub use tokio::sync::Notify; +#[cfg(not(feature = "embassy"))] +mod imp { + pub use parking_lot::{Mutex, MutexGuard}; + pub use tokio::sync::Mutex as AsyncMutex; + pub use tokio::sync::Notify; +} -/// An async mutex, for the few places a guard is held across an `.await` (the radio -/// stream and reset receivers). Distinct from the blocking [`Mutex`], which must never -/// span a yield; reach for this only when the guard genuinely outlives an await point. -pub use tokio::sync::Mutex as AsyncMutex; +#[cfg(feature = "embassy")] +mod imp { + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; + + // A spinlock is a guard-returning, `Sync`, no_std mutex; on the cooperative + // single-core MCU executor it never actually spins, and the stack never holds a guard + // across an `.await`, so the lock window is always brief. + pub use spin::{Mutex, MutexGuard}; + + /// The async mutex, for guards held across an `.await` (the radio stream + reset + /// receivers). Pinned to the critical-section raw mutex. + pub type AsyncMutex = embassy_sync::mutex::Mutex; + + /// A parameterless wake matching `tokio::sync::Notify`'s surface. + /// + /// Built over embassy's single-slot [`Signal`](embassy_sync::signal::Signal): + /// `notify_one` stores one permit and coalesces repeats; `notified` consumes it — the + /// same single-waiter contract every wake in the stack relies on. + #[derive(Default)] + pub struct Notify(embassy_sync::signal::Signal); + + impl Notify { + pub const fn new() -> Self { + Self(embassy_sync::signal::Signal::new()) + } + + pub fn notify_one(&self) { + self.0.signal(()); + } + + pub async fn notified(&self) { + self.0.wait().await; + } + } + + impl core::fmt::Debug for Notify { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("Notify") + } + } +} + +pub use imp::*;