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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] `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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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::*; From 2ec3d037444852b36e3f94b699078a0ad9783aee Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:03:25 -0400 Subject: [PATCH 18/61] Clean up deps --- Cargo.lock | 1047 ++--------------- Cargo.toml | 5 +- crates/ziggurat-driver/Cargo.toml | 2 +- crates/ziggurat-driver/src/zigbee_stack.rs | 2 +- .../ziggurat-driver/src/zigbee_stack/mac.rs | 2 +- crates/ziggurat-ieee-802154/Cargo.toml | 2 +- crates/ziggurat-ieee-802154/src/lib.rs | 28 +- crates/ziggurat-phy-esp/Cargo.lock | 102 +- crates/ziggurat-phy-esp/Cargo.toml | 15 +- crates/ziggurat-zigbee/Cargo.toml | 2 +- crates/ziggurat-zigbee/src/aps/frame.rs | 44 +- crates/ziggurat-zigbee/src/beacon.rs | 2 +- crates/ziggurat-zigbee/src/nwk/commands.rs | 37 +- crates/ziggurat-zigbee/src/nwk/frame.rs | 2 +- 14 files changed, 207 insertions(+), 1085 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee657f6..90df3cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,8 +5,7 @@ version = 4 [[package]] name = "abstract-bits" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c265591a83d97ca12d32d679e8e0df1b11ff21b333a1679a52ff1bec2e16add" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "abstract-bits-derive", "arbitrary-int 1.3.0", @@ -17,22 +16,12 @@ dependencies = [ [[package]] name = "abstract-bits-derive" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ad589d11a94666dca636f13148a47005575d58034ed0f9d63d24b661c9d622" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "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", + "syn", ] [[package]] @@ -51,7 +40,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", "cpufeatures 0.3.0", ] @@ -65,12 +54,6 @@ 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" @@ -139,38 +122,6 @@ 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" @@ -213,18 +164,6 @@ 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" @@ -237,27 +176,15 @@ 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 0.6.0", - "cipher 0.5.2", - "ctr 0.10.1", + "aead", + "cipher", + "ctr", "subtle", ] @@ -284,15 +211,6 @@ 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" @@ -335,7 +253,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -350,12 +268,6 @@ 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" @@ -427,90 +339,48 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "ctr" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" -dependencies = [ - "cipher 0.3.0", -] - [[package]] name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] name = "darling" -version = "0.21.3" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 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", + "darling_core", + "darling_macro", ] [[package]] name = "darling_core" -version = "0.21.3" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 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", + "syn", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -519,17 +389,6 @@ 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" @@ -540,26 +399,6 @@ 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" @@ -578,39 +417,30 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "embassy-embedded-hal" -version = "0.6.0" +name = "embassy-executor" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +checksum = "90327bcc66333a507f89ecc4e2d911b265c45f5c9bc241f98eee076752d35ac6" 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", + "critical-section", + "document-features", + "embassy-executor-macros", ] [[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" +name = "embassy-executor-macros" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +checksum = "3577b1e9446f61381179a330fc5324b01d511624c55f25e3c66c9e3c626dbecf" dependencies = [ - "num-traits", + "darling", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -621,47 +451,46 @@ checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" dependencies = [ "cfg-if", "critical-section", - "embedded-io-async 0.6.1", + "embedded-io-async", "futures-sink", "futures-util", "heapless 0.8.0", ] [[package]] -name = "embassy-sync" -version = "0.7.2" +name = "embassy-time" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" dependencies = [ "cfg-if", "critical-section", - "embedded-io-async 0.6.1", - "futures-core", - "futures-sink", - "heapless 0.8.0", + "document-features", + "embassy-time-driver", + "embassy-time-queue-utils", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-util", ] [[package]] -name = "embassy-sync" -version = "0.8.0" +name = "embassy-time-driver" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" dependencies = [ - "cfg-if", - "critical-section", - "embedded-io-async 0.7.0", - "futures-core", - "futures-sink", - "heapless 0.9.3", + "document-features", ] [[package]] -name = "embedded-can" -version = "0.4.1" +name = "embassy-time-queue-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +checksum = "dc55c748d16908a65b166d09ce976575fb8852cf60ccd06174092b41064d8f83" dependencies = [ - "nb 1.1.0", + "embassy-executor", + "heapless 0.8.0", ] [[package]] @@ -695,43 +524,13 @@ 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", + "embedded-io", ] [[package]] @@ -740,321 +539,25 @@ 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.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]] -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 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", + "enum-ordinalize-derive", ] [[package]] -name = "esp32s2" -version = "0.31.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ - "vcell", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "esp32s3" -version = "0.35.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" -dependencies = [ - "vcell", -] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "fnv" @@ -1068,15 +571,6 @@ 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" @@ -1139,7 +633,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1171,12 +665,6 @@ 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" @@ -1213,15 +701,6 @@ 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" @@ -1231,17 +710,6 @@ 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" @@ -1263,7 +731,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32 0.3.1", + "hash32", "stable_deref_trait", ] @@ -1273,7 +741,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" dependencies = [ - "hash32 0.3.1", + "hash32", "stable_deref_trait", ] @@ -1332,19 +800,6 @@ 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" @@ -1357,15 +812,6 @@ 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" @@ -1375,19 +821,6 @@ 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" @@ -1428,12 +861,6 @@ 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" @@ -1551,26 +978,6 @@ 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" @@ -1590,16 +997,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "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", + "syn", ] [[package]] @@ -1637,45 +1035,12 @@ 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" @@ -1692,7 +1057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -1723,7 +1088,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1793,12 +1158,6 @@ 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" @@ -1840,90 +1199,12 @@ 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" @@ -1963,7 +1244,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1979,19 +1260,6 @@ 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" @@ -2053,19 +1321,10 @@ dependencies = [ ] [[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" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "stable_deref_trait" @@ -2073,72 +1332,18 @@ 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.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.117" @@ -2156,15 +1361,6 @@ 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" @@ -2182,7 +1378,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2217,7 +1413,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2295,7 +1491,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2359,12 +1555,6 @@ 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" @@ -2380,24 +1570,12 @@ 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" @@ -2410,12 +1588,6 @@ 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" @@ -2502,15 +1674,6 @@ 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" @@ -2650,7 +1813,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2666,7 +1829,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2717,31 +1880,6 @@ 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" @@ -2759,7 +1897,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2768,8 +1906,13 @@ version = "0.1.0" dependencies = [ "abstract-bits", "arbitrary-int 2.1.1", + "embassy-executor", + "embassy-sync", + "embassy-time", + "futures", "parking_lot", "rand 0.10.1", + "spin", "thiserror", "tokio", "tracing", @@ -2800,18 +1943,6 @@ 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" @@ -2865,7 +1996,7 @@ dependencies = [ "abstract-bits", "aes", "arbitrary-int 2.1.1", - "ccm 0.6.0-rc.3", + "ccm", "educe", "hex", "hex-literal", diff --git a/Cargo.toml b/Cargo.toml index 09dcb33..47c5620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,9 @@ [workspace] resolver = "2" members = ["crates/*"] -exclude = ["fuzz"] +# ziggurat-phy-esp only builds for an ESP32-C6 (riscv32imac) with esp-hal; excluded so +# host `cargo build` over the workspace doesn't try (and fail) to compile it. +exclude = ["fuzz", "crates/ziggurat-phy-esp"] [workspace.package] version = "0.1.0" @@ -11,6 +13,7 @@ license = "Apache-2.0" authors = [] repository = "https://github.com/zigpy/ziggurat" + [workspace.dependencies] ziggurat-ieee-802154 = { path = "crates/ziggurat-ieee-802154", version = "0.1.0" } ziggurat-phy = { path = "crates/ziggurat-phy", version = "0.1.0" } diff --git a/crates/ziggurat-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index b1690b9..12b5f6a 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -13,7 +13,7 @@ ziggurat-ieee-802154.workspace = true ziggurat-phy.workspace = true ziggurat-zigbee.workspace = true -abstract-bits = "0.2.0" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } arbitrary-int = "2.1.1" futures = { version = "0.3", default-features = false } tracing = "0.1" diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index c348e2e..4322491 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1194,7 +1194,7 @@ impl ZigbeeStack { return; } - let payload = match ZigbeeBeacon::from_abstract_bits(&beacon.beacon_payload) { + let payload = match ZigbeeBeacon::from_abstract_bytes(&beacon.beacon_payload) { Ok(payload) => payload, Err(e) => { tracing::debug!("Ignoring non-Zigbee beacon: {e:?}"); diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index b59d970..50faaf4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -108,7 +108,7 @@ impl ZigbeeStack { tx_offset: RenamedU24(u24::new(0xFFFFFF)), update_id, } - .to_abstract_bits() + .to_abstract_bytes() .unwrap(), gts_specification: 0x00, pending_address_specification: 0x00, diff --git a/crates/ziggurat-ieee-802154/Cargo.toml b/crates/ziggurat-ieee-802154/Cargo.toml index a7b024c..5c45793 100644 --- a/crates/ziggurat-ieee-802154/Cargo.toml +++ b/crates/ziggurat-ieee-802154/Cargo.toml @@ -9,7 +9,7 @@ authors.workspace = true repository.workspace = true [dependencies] -abstract-bits = "0.2.0" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } num_enum = { version = "0.7.3", default-features = false } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } thiserror = { version = "2.0.12", default-features = false } diff --git a/crates/ziggurat-ieee-802154/src/lib.rs b/crates/ziggurat-ieee-802154/src/lib.rs index 1d2e276..ed7efec 100644 --- a/crates/ziggurat-ieee-802154/src/lib.rs +++ b/crates/ziggurat-ieee-802154/src/lib.rs @@ -229,7 +229,7 @@ pub enum ParseError { /// Append a structure's serialized form to `bytes`. /// -/// Avoids the intermediate Vec that [`AbstractBits::to_abstract_bits`] allocates; the +/// Avoids the intermediate Vec that [`AbstractBits::to_abstract_bytes`] allocates; the /// scratch buffer fits anything that can go on the air (a full 802.15.4 frame is at /// most 127 bytes). pub fn extend_abstract_bits(bytes: &mut Vec, value: &T) { @@ -283,19 +283,19 @@ impl Ieee802154CommandPayload { Some(match id { Ieee802154CommandId::AssociationRequest => { - Self::AssociationRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::AssociationRequest(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::AssociationResponse => { - Self::AssociationResponse(AbstractBits::from_abstract_bits(body).ok()?) + Self::AssociationResponse(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::DisassociationNotification => { - Self::DisassociationNotification(AbstractBits::from_abstract_bits(body).ok()?) + Self::DisassociationNotification(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::DataRequest => { - Self::DataRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::DataRequest(AbstractBits::from_abstract_bytes(body).ok()?) } Ieee802154CommandId::BeaconRequest => { - Self::BeaconRequest(AbstractBits::from_abstract_bits(body).ok()?) + Self::BeaconRequest(AbstractBits::from_abstract_bytes(body).ok()?) } // Known command ids the stack does not implement are kept verbatim _ => return None, @@ -307,18 +307,18 @@ impl Ieee802154CommandPayload { let (id, body) = match self { Self::AssociationRequest(c) => ( Ieee802154CommandId::AssociationRequest, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::AssociationResponse(c) => ( Ieee802154CommandId::AssociationResponse, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::DisassociationNotification(c) => ( Ieee802154CommandId::DisassociationNotification, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), - Self::DataRequest(c) => (Ieee802154CommandId::DataRequest, c.to_abstract_bits()), - Self::BeaconRequest(c) => (Ieee802154CommandId::BeaconRequest, c.to_abstract_bits()), + Self::DataRequest(c) => (Ieee802154CommandId::DataRequest, c.to_abstract_bytes()), + Self::BeaconRequest(c) => (Ieee802154CommandId::BeaconRequest, c.to_abstract_bytes()), Self::Unknown(raw) => return raw.clone(), }; @@ -436,7 +436,7 @@ impl Ieee802154Frame { }); } let superframe_specification = - SuperframeSpecification::from_abstract_bits(&remaining[..4])?; + SuperframeSpecification::from_abstract_bytes(&remaining[..4])?; let gts_specification = remaining[2]; let pending_address_specification = remaining[3]; let beacon_payload = remaining[4..].to_vec(); @@ -597,7 +597,7 @@ mod test { #[test] fn test_frame_control() { let bytes = [0x61, 0x88, 0xFF]; - let frame_control = Ieee802154FrameControl::from_abstract_bits(&bytes).unwrap(); + let frame_control = Ieee802154FrameControl::from_abstract_bytes(&bytes).unwrap(); let remaining = &bytes[2..]; assert_eq!(frame_control.frame_type, Ieee802154FrameType::Data); @@ -615,7 +615,7 @@ mod test { assert_eq!(frame_control.src_addr_mode, Ieee802154AddressingMode::Short); assert_eq!(remaining, [0xFF]); - assert_eq!(frame_control.to_abstract_bits().unwrap(), bytes[..2]); + assert_eq!(frame_control.to_abstract_bytes().unwrap(), bytes[..2]); } #[test] diff --git a/crates/ziggurat-phy-esp/Cargo.lock b/crates/ziggurat-phy-esp/Cargo.lock index 8a085b1..d4d917d 100644 --- a/crates/ziggurat-phy-esp/Cargo.lock +++ b/crates/ziggurat-phy-esp/Cargo.lock @@ -5,6 +5,7 @@ version = 4 [[package]] name = "abstract-bits" version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "abstract-bits-derive", "arbitrary-int", @@ -15,6 +16,7 @@ dependencies = [ [[package]] name = "abstract-bits-derive" version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -161,9 +163,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -511,6 +513,8 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "esp-alloc" version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" dependencies = [ "allocator-api2", "cfg-if", @@ -525,6 +529,8 @@ dependencies = [ [[package]] name = "esp-config" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" dependencies = [ "document-features", "esp-metadata-generated", @@ -535,7 +541,9 @@ dependencies = [ [[package]] name = "esp-hal" -version = "1.1.0" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72" dependencies = [ "bitfield", "bitflags", @@ -565,11 +573,8 @@ dependencies = [ "esp32", "esp32c2", "esp32c3", - "esp32c5", "esp32c6", - "esp32c61", "esp32h2", - "esp32p4", "esp32s2", "esp32s3", "fugit", @@ -581,7 +586,6 @@ dependencies = [ "rand_core 0.6.4", "rand_core 0.9.5", "riscv", - "static_cell", "strum", "ufmt-write", "xtensa-lx", @@ -591,6 +595,8 @@ dependencies = [ [[package]] name = "esp-hal-procmacros" version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" dependencies = [ "document-features", "object", @@ -604,10 +610,14 @@ dependencies = [ [[package]] name = "esp-metadata-generated" version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" [[package]] name = "esp-phy" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" dependencies = [ "cfg-if", "document-features", @@ -622,7 +632,9 @@ dependencies = [ [[package]] name = "esp-radio" -version = "1.0.0-beta.0" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" dependencies = [ "allocator-api2", "byte", @@ -641,7 +653,6 @@ dependencies = [ "esp-metadata-generated", "esp-phy", "esp-radio-rtos-driver", - "esp-rom-sys", "esp-sync", "esp-wifi-sys-esp32c6", "esp32c6", @@ -657,6 +668,8 @@ dependencies = [ [[package]] name = "esp-radio-rtos-driver" version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" dependencies = [ "cfg-if", "esp-sync", @@ -666,6 +679,8 @@ dependencies = [ [[package]] name = "esp-riscv-rt" version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" dependencies = [ "document-features", "riscv", @@ -675,6 +690,8 @@ dependencies = [ [[package]] name = "esp-rom-sys" version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" dependencies = [ "cfg-if", "document-features", @@ -685,6 +702,8 @@ dependencies = [ [[package]] name = "esp-sync" version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" dependencies = [ "cfg-if", "document-features", @@ -705,7 +724,8 @@ checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" [[package]] name = "esp32" version = "0.40.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" dependencies = [ "vcell", ] @@ -713,7 +733,8 @@ dependencies = [ [[package]] name = "esp32c2" version = "0.29.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" dependencies = [ "vcell", ] @@ -721,15 +742,8 @@ dependencies = [ [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" dependencies = [ "vcell", ] @@ -737,15 +751,8 @@ dependencies = [ [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" dependencies = [ "vcell", ] @@ -753,24 +760,17 @@ dependencies = [ [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" dependencies = [ - "critical-section", "vcell", ] [[package]] name = "esp32s2" version = "0.31.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" dependencies = [ "vcell", ] @@ -778,7 +778,8 @@ dependencies = [ [[package]] name = "esp32s3" version = "0.35.2" -source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" dependencies = [ "vcell", ] @@ -841,9 +842,9 @@ checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1322,15 +1323,6 @@ 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" @@ -1557,6 +1549,8 @@ dependencies = [ [[package]] name = "xtensa-lx" version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" dependencies = [ "critical-section", ] @@ -1564,6 +1558,8 @@ dependencies = [ [[package]] name = "xtensa-lx-rt" version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" dependencies = [ "document-features", "xtensa-lx", @@ -1573,6 +1569,8 @@ dependencies = [ [[package]] name = "xtensa-lx-rt-proc-macros" version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ziggurat-phy-esp/Cargo.toml b/crates/ziggurat-phy-esp/Cargo.toml index 7891822..79f11cf 100644 --- a/crates/ziggurat-phy-esp/Cargo.toml +++ b/crates/ziggurat-phy-esp/Cargo.toml @@ -10,18 +10,7 @@ license = "Apache-2.0" 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" } +esp-radio = { version = "0.18.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-hal = { version = "1.1.1", features = ["esp32c6", "unstable"] } diff --git a/crates/ziggurat-zigbee/Cargo.toml b/crates/ziggurat-zigbee/Cargo.toml index 6e35f9e..0cd028d 100644 --- a/crates/ziggurat-zigbee/Cargo.toml +++ b/crates/ziggurat-zigbee/Cargo.toml @@ -11,7 +11,7 @@ repository.workspace = true [dependencies] ziggurat-ieee-802154.workspace = true -abstract-bits = "0.2.0" +abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } aes = "0.9.1" arbitrary-int = "2.1.1" ccm = { version = "0.6.0-rc.3", default-features = false } diff --git a/crates/ziggurat-zigbee/src/aps/frame.rs b/crates/ziggurat-zigbee/src/aps/frame.rs index 2b9381c..0fc7c35 100644 --- a/crates/ziggurat-zigbee/src/aps/frame.rs +++ b/crates/ziggurat-zigbee/src/aps/frame.rs @@ -92,7 +92,7 @@ impl ApsAckFrame { return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); } - let frame_control = ApsAckFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsAckFrameControl::from_abstract_bytes(bytes)?; let remaining = &bytes[1..]; if frame_control.frame_type != ApsFrameType::Ack { @@ -181,7 +181,7 @@ impl ApsDataFrame { return Err(ParseError::UnexpectedEnd { ty: "ApsDataFrame" }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let mut remaining = &bytes[1..]; // Spec 2.2.5: reserved fields SHALL be zero on reception; a nonzero value marks @@ -221,7 +221,7 @@ impl ApsDataFrame { // the ASDU. We don't encounter fragmentation in the wild but if we ever do, we // should not pass along a corrupted ASDU. if frame_control.extended_header { - let extended_frame_control = ApsExtendedFrameControl::from_abstract_bits(remaining)?; + let extended_frame_control = ApsExtendedFrameControl::from_abstract_bytes(remaining)?; // The extended frame control is one octet; a fragmented block is followed by // a one-octet block number (the block count on the first block). @@ -362,16 +362,16 @@ impl ApsTransportKeyCommandFrame { let key_descriptor = match standard_key_type { ApsStandardKeyType::StandardNetworkKey => ApsTransportKeyDescriptor::NetworkKey( - ApsNetworkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsNetworkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ), ApsStandardKeyType::ApplicationLinkKey => { ApsTransportKeyDescriptor::ApplicationLinkKey( - ApsApplicationLinkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsApplicationLinkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ) } ApsStandardKeyType::TrustCenterLinkKey => { ApsTransportKeyDescriptor::TrustCenterLinkKey( - ApsTrustCenterLinkKeyDescriptor::from_abstract_bits(&bytes[1..])?, + ApsTrustCenterLinkKeyDescriptor::from_abstract_bytes(&bytes[1..])?, ) } }; @@ -388,13 +388,13 @@ impl ApsTransportKeyCommandFrame { match &self.key_descriptor { ApsTransportKeyDescriptor::TrustCenterLinkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } ApsTransportKeyDescriptor::NetworkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } ApsTransportKeyDescriptor::ApplicationLinkKey(desc) => { - bytes.extend(desc.to_abstract_bits().unwrap()); + bytes.extend(desc.to_abstract_bytes().unwrap()); } } @@ -583,7 +583,7 @@ impl ApsCommandFrame { }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let remaining = &bytes[1..]; let counter = u8::from_le_bytes([remaining[0]]); @@ -599,25 +599,25 @@ impl ApsCommandFrame { ApsTransportKeyCommandFrame::from_bytes(payload)?, ), ApsCommandId::UpdateDevice => ApsCommandFrameCommand::UpdateDevice( - ApsUpdateDeviceCommandFrame::from_abstract_bits(payload)?, + ApsUpdateDeviceCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::RemoveDevice => ApsCommandFrameCommand::RemoveDevice( - ApsRemoveDeviceCommandFrame::from_abstract_bits(payload)?, + ApsRemoveDeviceCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::RequestKey => { ApsCommandFrameCommand::RequestKey(ApsRequestKeyCommandFrame::from_bytes(payload)?) } ApsCommandId::SwitchKey => ApsCommandFrameCommand::SwitchKey( - ApsSwitchKeyCommandFrame::from_abstract_bits(payload)?, + ApsSwitchKeyCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::Tunnel => { ApsCommandFrameCommand::Tunnel(ApsTunnelCommandFrame::from_bytes(payload)?) } ApsCommandId::VerifyKey => ApsCommandFrameCommand::VerifyKey( - ApsVerifyKeyCommandFrame::from_abstract_bits(payload)?, + ApsVerifyKeyCommandFrame::from_abstract_bytes(payload)?, ), ApsCommandId::ConfirmKey => ApsCommandFrameCommand::ConfirmKey( - ApsConfirmKeyCommandFrame::from_abstract_bits(payload)?, + ApsConfirmKeyCommandFrame::from_abstract_bytes(payload)?, ), _ => { return Err(ParseError::Unsupported("command ID for ApsCommandFrame")); @@ -638,13 +638,13 @@ impl ApsCommandFrame { bytes.extend(match &self.command { ApsCommandFrameCommand::TransportKey(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::UpdateDevice(cmd) => cmd.to_abstract_bits().unwrap(), - ApsCommandFrameCommand::RemoveDevice(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::UpdateDevice(cmd) => cmd.to_abstract_bytes().unwrap(), + ApsCommandFrameCommand::RemoveDevice(cmd) => cmd.to_abstract_bytes().unwrap(), ApsCommandFrameCommand::RequestKey(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::SwitchKey(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::SwitchKey(cmd) => cmd.to_abstract_bytes().unwrap(), ApsCommandFrameCommand::Tunnel(cmd) => cmd.to_bytes(), - ApsCommandFrameCommand::VerifyKey(cmd) => cmd.to_abstract_bits().unwrap(), - ApsCommandFrameCommand::ConfirmKey(cmd) => cmd.to_abstract_bits().unwrap(), + ApsCommandFrameCommand::VerifyKey(cmd) => cmd.to_abstract_bytes().unwrap(), + ApsCommandFrameCommand::ConfirmKey(cmd) => cmd.to_abstract_bytes().unwrap(), }); bytes @@ -703,7 +703,7 @@ impl ApsAuxHeader { return Err(ParseError::UnexpectedEnd { ty: "ApsAuxHeader" }); } - let security_control = NwkSecurityHeaderControlField::from_abstract_bits(bytes)?; + let security_control = NwkSecurityHeaderControlField::from_abstract_bytes(bytes)?; let mut remaining = &bytes[1..]; let frame_counter = @@ -841,7 +841,7 @@ impl EncryptedApsCommandFrame { }); } - let frame_control = ApsFrameControl::from_abstract_bits(bytes)?; + let frame_control = ApsFrameControl::from_abstract_bytes(bytes)?; let counter = bytes[1]; let (aux_header, remaining) = ApsAuxHeader::deserialize(&bytes[2..])?; diff --git a/crates/ziggurat-zigbee/src/beacon.rs b/crates/ziggurat-zigbee/src/beacon.rs index ec8b6b4..02e4ad9 100644 --- a/crates/ziggurat-zigbee/src/beacon.rs +++ b/crates/ziggurat-zigbee/src/beacon.rs @@ -70,7 +70,7 @@ mod test { }; // The Zigbee beacon payload is exactly 15 bytes - let bytes = beacon.to_abstract_bits().unwrap(); + let bytes = beacon.to_abstract_bytes().unwrap(); assert_eq!(bytes, hex!("00 22 84 93cb3c0b 01449f3a ffffff 00").to_vec()); let mut reader = BitReader::from(bytes.as_slice()); diff --git a/crates/ziggurat-zigbee/src/nwk/commands.rs b/crates/ziggurat-zigbee/src/nwk/commands.rs index db9d010..1222ea6 100644 --- a/crates/ziggurat-zigbee/src/nwk/commands.rs +++ b/crates/ziggurat-zigbee/src/nwk/commands.rs @@ -526,30 +526,31 @@ impl NwkCommand { /// followed by the command body. pub fn to_bytes(&self) -> Vec { let (id, body) = match self { - Self::RouteRequest(c) => (NwkCommandId::RouteRequest, c.to_abstract_bits()), - Self::RouteReply(c) => (NwkCommandId::RouteReply, c.to_abstract_bits()), - Self::NetworkStatus(c) => (NwkCommandId::NetworkStatus, c.to_abstract_bits()), - Self::Leave(c) => (NwkCommandId::Leave, c.to_abstract_bits()), - Self::RouteRecord(c) => (NwkCommandId::RouteRecord, c.to_abstract_bits()), - Self::RejoinRequest(c) => (NwkCommandId::RejoinRequest, c.to_abstract_bits()), - Self::RejoinResponse(c) => (NwkCommandId::RejoinResponse, c.to_abstract_bits()), - Self::LinkStatus(c) => (NwkCommandId::LinkStatus, c.to_abstract_bits()), - Self::NetworkReport(c) => (NwkCommandId::NetworkReport, c.to_abstract_bits()), - Self::NetworkUpdate(c) => (NwkCommandId::NetworkUpdate, c.to_abstract_bits()), + Self::RouteRequest(c) => (NwkCommandId::RouteRequest, c.to_abstract_bytes()), + Self::RouteReply(c) => (NwkCommandId::RouteReply, c.to_abstract_bytes()), + Self::NetworkStatus(c) => (NwkCommandId::NetworkStatus, c.to_abstract_bytes()), + Self::Leave(c) => (NwkCommandId::Leave, c.to_abstract_bytes()), + Self::RouteRecord(c) => (NwkCommandId::RouteRecord, c.to_abstract_bytes()), + Self::RejoinRequest(c) => (NwkCommandId::RejoinRequest, c.to_abstract_bytes()), + Self::RejoinResponse(c) => (NwkCommandId::RejoinResponse, c.to_abstract_bytes()), + Self::LinkStatus(c) => (NwkCommandId::LinkStatus, c.to_abstract_bytes()), + Self::NetworkReport(c) => (NwkCommandId::NetworkReport, c.to_abstract_bytes()), + Self::NetworkUpdate(c) => (NwkCommandId::NetworkUpdate, c.to_abstract_bytes()), Self::EndDeviceTimeoutRequest(c) => { - (NwkCommandId::EndDeviceTimeoutRequest, c.to_abstract_bits()) + (NwkCommandId::EndDeviceTimeoutRequest, c.to_abstract_bytes()) } - Self::EndDeviceTimeoutResponse(c) => { - (NwkCommandId::EndDeviceTimeoutResponse, c.to_abstract_bits()) - } - Self::LinkPowerDelta(c) => (NwkCommandId::LinkPowerDelta, c.to_abstract_bits()), + Self::EndDeviceTimeoutResponse(c) => ( + NwkCommandId::EndDeviceTimeoutResponse, + c.to_abstract_bytes(), + ), + Self::LinkPowerDelta(c) => (NwkCommandId::LinkPowerDelta, c.to_abstract_bytes()), Self::NetworkCommissioningRequest(c) => ( NwkCommandId::NetworkCommissioningRequest, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::NetworkCommissioningResponse(c) => ( NwkCommandId::NetworkCommissioningResponse, - c.to_abstract_bits(), + c.to_abstract_bytes(), ), Self::Unparsed(raw) => return raw.to_vec(), }; @@ -561,7 +562,7 @@ impl NwkCommand { } fn parse_body(body: &[u8]) -> Option { - T::from_abstract_bits(body).ok() + T::from_abstract_bytes(body).ok() } #[cfg(test)] diff --git a/crates/ziggurat-zigbee/src/nwk/frame.rs b/crates/ziggurat-zigbee/src/nwk/frame.rs index e4913ce..3844573 100644 --- a/crates/ziggurat-zigbee/src/nwk/frame.rs +++ b/crates/ziggurat-zigbee/src/nwk/frame.rs @@ -223,7 +223,7 @@ pub struct NwkSecurityHeaderControlField { } impl NwkSecurityHeaderControlField { - /// The field's single serialized byte, without `to_abstract_bits`'s allocation: + /// The field's single serialized byte, without `to_abstract_bytes`'s allocation: /// it goes into every CCM* nonce. pub fn to_bytes(&self) -> [u8; 1] { let mut buffer = [0u8; 1]; From eb510b58e2db0b250c4d622d6d5dda14533b2f2b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:05:03 -0400 Subject: [PATCH 19/61] Bridge embassy with tokio for testing on host --- crates/ziggurat-driver/src/runtime.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 181495d..08d4289 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -157,7 +157,7 @@ impl Spawn for TokioSpawner { /// 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}; +pub use embassy_impl::{EmbassyRuntime, EmbassySpawner, start_embassy_executor}; #[cfg(feature = "embassy")] mod embassy_impl { @@ -241,4 +241,27 @@ mod embassy_impl { // is nothing to stop. async fn shutdown(&self) {} } + + /// Run an embassy `arch-std` executor on a dedicated thread, returning a spawner + /// for it. + pub fn start_embassy_executor(tokio_handle: tokio::runtime::Handle) -> EmbassySpawner { + use std::sync::mpsc; + + let (tx, rx) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("embassy-executor".into()) + .spawn(move || { + // Held for the executor's (and thread's) entire life, so every poll on this + // thread sees the tokio runtime. + let _enter = tokio_handle.enter(); + let executor: &'static mut embassy_executor::Executor = + Box::leak(Box::new(embassy_executor::Executor::new())); + executor.run(move |spawner| { + let _ = tx.send(spawner.make_send()); + }); + }) + .expect("spawn embassy-executor thread"); + + EmbassySpawner::new(rx.recv().expect("embassy executor failed to start")) + } } From 6e40154a76cfe562551de1c76d009cb6c031fdeb Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:38:10 -0400 Subject: [PATCH 20/61] Test: finish MCU target compilation --- Cargo.lock | 133 ++++++++++++++---- crates/ziggurat-driver/Cargo.toml | 45 ++++-- crates/ziggurat-driver/src/lib.rs | 9 ++ crates/ziggurat-driver/src/rng.rs | 53 +++++++ crates/ziggurat-driver/src/runtime.rs | 34 ++++- crates/ziggurat-driver/src/signal.rs | 2 +- crates/ziggurat-driver/src/sync.rs | 13 +- crates/ziggurat-driver/src/zigbee_stack.rs | 47 ++++--- .../ziggurat-driver/src/zigbee_stack/aps.rs | 5 +- .../src/zigbee_stack/indirect.rs | 1 + .../src/zigbee_stack/joining.rs | 12 +- .../ziggurat-driver/src/zigbee_stack/mac.rs | 1 + .../src/zigbee_stack/neighbor.rs | 3 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 7 +- .../ziggurat-driver/src/zigbee_stack/route.rs | 6 +- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 3 +- 16 files changed, 277 insertions(+), 97 deletions(-) create mode 100644 crates/ziggurat-driver/src/rng.rs diff --git a/Cargo.lock b/Cargo.lock index 90df3cd..d06ebd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "ccm" version = "0.6.0-rc.3" @@ -268,6 +278,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -422,20 +442,22 @@ dependencies = [ [[package]] name = "embassy-executor" -version = "0.7.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90327bcc66333a507f89ecc4e2d911b265c45f5c9bc241f98eee076752d35ac6" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" dependencies = [ + "cordyceps", "critical-section", "document-features", "embassy-executor-macros", + "embassy-executor-timer-queue", ] [[package]] name = "embassy-executor-macros" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3577b1e9446f61381179a330fc5324b01d511624c55f25e3c66c9e3c626dbecf" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" dependencies = [ "darling", "proc-macro2", @@ -443,25 +465,31 @@ dependencies = [ "syn", ] +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + [[package]] name = "embassy-sync" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" dependencies = [ "cfg-if", "critical-section", "embedded-io-async", + "futures-core", "futures-sink", - "futures-util", - "heapless 0.8.0", + "heapless", ] [[package]] name = "embassy-time" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" dependencies = [ "cfg-if", "critical-section", @@ -471,7 +499,7 @@ dependencies = [ "embedded-hal 0.2.7", "embedded-hal 1.0.0", "embedded-hal-async", - "futures-util", + "futures-core", ] [[package]] @@ -485,12 +513,12 @@ dependencies = [ [[package]] name = "embassy-time-queue-utils" -version = "0.1.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc55c748d16908a65b166d09ce976575fb8852cf60ccd06174092b41064d8f83" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" dependencies = [ - "embassy-executor", - "heapless 0.8.0", + "embassy-executor-timer-queue", + "heapless", ] [[package]] @@ -520,15 +548,15 @@ dependencies = [ [[package]] name = "embedded-io" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" [[package]] name = "embedded-io-async" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" dependencies = [ "embedded-io", ] @@ -559,6 +587,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -665,6 +699,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -725,16 +774,6 @@ 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", - "stable_deref_trait", -] - [[package]] name = "heapless" version = "0.9.3" @@ -882,6 +921,19 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "mach2" version = "0.4.3" @@ -1205,6 +1257,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1298,6 +1356,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.12" @@ -1686,6 +1750,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1927,7 +2000,7 @@ version = "0.1.0" dependencies = [ "abstract-bits", "educe", - "heapless 0.9.3", + "heapless", "hex", "hex-literal", "num_enum", diff --git a/crates/ziggurat-driver/Cargo.toml b/crates/ziggurat-driver/Cargo.toml index 12b5f6a..64973f4 100644 --- a/crates/ziggurat-driver/Cargo.toml +++ b/crates/ziggurat-driver/Cargo.toml @@ -16,25 +16,42 @@ ziggurat-zigbee.workspace = true abstract-bits = { git = "https://github.com/yara-blue/abstract-bits.git", version = "0.2.0" } arbitrary-int = "2.1.1" futures = { version = "0.3", default-features = false } -tracing = "0.1" -parking_lot = "0.12.4" -rand = "0.10.1" -thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["rt", "macros", "time", "sync", "io-util"] } +tracing = { version = "0.1", default-features = false } +thiserror = { version = "2.0.12", default-features = false } -# 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", +# Host (tokio) runtime backend. +parking_lot = { version = "0.12.4", optional = true } +rand = { version = "0.10.1", optional = true } +tokio = { version = "1.43.0", features = [ + "rt", + "macros", + "time", + "sync", + "io-util", ], optional = true } -embassy-time = { version = "0.4", features = ["std"], optional = true } -embassy-sync = { version = "0.6", optional = true } + +# Embassy runtime adapter. The MCU binary provides the executor + arch (via esp-rtos), so +# the base dependency pulls no arch feature; the `embassy-host` feature adds the std +# executor so the same adapter can run on the host bridged to tokio. +embassy-executor = { version = "0.10", optional = true } +embassy-time = { version = "0.5", optional = true } +embassy-sync = { version = "0.8", optional = true } spin = { version = "0.9", default-features = false, features = [ "spin_mutex", ], optional = true } [features] -default = [] -# 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. +default = ["tokio"] +# The host runtime: tokio executor, parking_lot locks, OS randomness. +tokio = ["dep:tokio", "dep:parking_lot", "dep:rand"] +# The embassy runtime adapter (and its no_std-friendly sync primitives), for the MCU. +# Mutually exclusive with `tokio` at the `sync`/`runtime`/`rng` seam. embassy = ["dep:embassy-executor", "dep:embassy-time", "dep:embassy-sync", "dep:spin"] +# Run the embassy adapter on the host, bridged to a tokio reactor (for tests). +embassy-host = [ + "embassy", + "dep:tokio", + "embassy-executor/platform-std", + "embassy-executor/executor-thread", + "embassy-time/std", +] diff --git a/crates/ziggurat-driver/src/lib.rs b/crates/ziggurat-driver/src/lib.rs index e606cda..7b4942f 100644 --- a/crates/ziggurat-driver/src/lib.rs +++ b/crates/ziggurat-driver/src/lib.rs @@ -1,3 +1,12 @@ +#![no_std] + +extern crate alloc; + +// The host bridge spawns an OS thread for the embassy executor; that path alone needs std. +#[cfg(feature = "embassy-host")] +extern crate std; + +pub mod rng; pub mod runtime; pub mod signal; pub mod sync; diff --git a/crates/ziggurat-driver/src/rng.rs b/crates/ziggurat-driver/src/rng.rs new file mode 100644 index 0000000..11f2d81 --- /dev/null +++ b/crates/ziggurat-driver/src/rng.rs @@ -0,0 +1,53 @@ +//! Randomness crate. + +/// A uniform `f32` in `[0, 1)`, for jitter scaling. +pub fn random_f32() -> f32 { + let mut bytes = [0u8; 4]; + fill_bytes(&mut bytes); + // 24-bit mantissa worth of entropy mapped into [0, 1) + (u32::from_le_bytes(bytes) >> 8) as f32 / (1u32 << 24) as f32 +} + +/// A uniform `u16`, for stochastic address allocation. +pub fn random_u16() -> u16 { + let mut bytes = [0u8; 2]; + fill_bytes(&mut bytes); + u16::from_le_bytes(bytes) +} + +/// `N` random bytes, for key material. +pub fn random_array() -> [u8; N] { + let mut bytes = [0u8; N]; + fill_bytes(&mut bytes); + bytes +} + +#[cfg(feature = "tokio")] +fn fill_bytes(buf: &mut [u8]) { + use rand::RngExt; + rand::rng().fill(buf); +} + +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +pub use embassy_rng::{fill_bytes, install}; + +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +mod embassy_rng { + use crate::sync::Mutex; + use alloc::boxed::Box; + + type FillFn = Box; + + static FILL: Mutex> = Mutex::new(None); + + /// Install the byte source. The MCU binary backs this with the SoC hardware RNG. + pub fn install(fill: FillFn) { + *FILL.lock() = Some(fill); + } + + pub fn fill_bytes(buf: &mut [u8]) { + let mut guard = FILL.lock(); + let fill = guard.as_mut().expect("rng::install was never called"); + fill(buf); + } +} diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 08d4289..2a8d9e4 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -1,5 +1,6 @@ //! Async runtime abstraction layer. +use alloc::boxed::Box; use core::future::Future; use core::ops::Add; use core::pin::Pin; @@ -35,6 +36,7 @@ pub trait RtInstant: Copy + Send + Sync + 'static + Add fn saturating_duration_since(self, earlier: Self) -> Duration; } +#[cfg(feature = "tokio")] impl RtInstant for tokio::time::Instant { fn saturating_duration_since(self, earlier: Self) -> Duration { Self::saturating_duration_since(&self, earlier) @@ -104,9 +106,11 @@ pub trait Runtime: Send + Sync + 'static { } /// The tokio runtime: the host server's executor. +#[cfg(feature = "tokio")] #[derive(Debug, Clone, Copy)] pub struct TokioRuntime; +#[cfg(feature = "tokio")] impl Runtime for TokioRuntime { type Instant = tokio::time::Instant; type Spawner = TokioSpawner; @@ -125,11 +129,13 @@ impl Runtime for TokioRuntime { } /// The tokio spawner: tasks go into a `JoinSet` so a replaced stack can abort them. +#[cfg(feature = "tokio")] #[derive(Default)] pub struct TokioSpawner { tasks: parking_lot::Mutex>, } +#[cfg(feature = "tokio")] impl Spawn for TokioSpawner { fn spawn(&self, task: SpawnedTask) { let mut tasks = self.tasks.lock(); @@ -154,14 +160,26 @@ impl Spawn for TokioSpawner { } } -/// 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. +/// The embassy runtime adapter. Drives the MCU directly; host-runnable through +/// `embassy-host` (`arch-std`) so it can stand in for tokio in tests. #[cfg(feature = "embassy")] -pub use embassy_impl::{EmbassyRuntime, EmbassySpawner, start_embassy_executor}; +pub use embassy_impl::{EmbassyRuntime, EmbassySpawner}; + +#[cfg(feature = "embassy-host")] +pub use embassy_impl::start_embassy_executor; + +/// The runtime the stack defaults to when no `R` type parameter is given. Resolves to +/// whichever backend feature is enabled. +#[cfg(feature = "tokio")] +pub type DefaultRuntime = TokioRuntime; +#[cfg(all(feature = "embassy", not(feature = "tokio")))] +pub type DefaultRuntime = EmbassyRuntime; #[cfg(feature = "embassy")] mod embassy_impl { use super::{RtInstant, Runtime, Spawn, SpawnedTask}; + #[cfg(feature = "embassy-host")] + use alloc::boxed::Box; use core::future::Future; use core::ops::Add; use core::time::Duration; @@ -232,8 +250,13 @@ mod embassy_impl { 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"); + // In embassy-executor 0.10 the pool slot is claimed when the token is built, + // so exhaustion surfaces here rather than at `spawn`. + match task_runner(task) { + Ok(token) => self.0.spawn(token), + Err(_) => { + tracing::error!("embassy task pool exhausted; background task dropped"); + } } } @@ -244,6 +267,7 @@ mod embassy_impl { /// Run an embassy `arch-std` executor on a dedicated thread, returning a spawner /// for it. + #[cfg(feature = "embassy-host")] pub fn start_embassy_executor(tokio_handle: tokio::runtime::Handle) -> EmbassySpawner { use std::sync::mpsc; diff --git a/crates/ziggurat-driver/src/signal.rs b/crates/ziggurat-driver/src/signal.rs index f0b7ee0..1b6f286 100644 --- a/crates/ziggurat-driver/src/signal.rs +++ b/crates/ziggurat-driver/src/signal.rs @@ -1,8 +1,8 @@ //! `Signal` primitive: effectively a `Mutex` plus a `Notify`. use crate::sync::{Mutex, Notify}; +use alloc::sync::Arc; use core::fmt; -use std::sync::Arc; /// 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 index 6224941..471f455 100644 --- a/crates/ziggurat-driver/src/sync.rs +++ b/crates/ziggurat-driver/src/sync.rs @@ -1,21 +1,14 @@ //! 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. - -#[cfg(not(feature = "embassy"))] + +#[cfg(feature = "tokio")] mod imp { pub use parking_lot::{Mutex, MutexGuard}; pub use tokio::sync::Mutex as AsyncMutex; pub use tokio::sync::Notify; } -#[cfg(feature = "embassy")] +#[cfg(all(feature = "embassy", not(feature = "tokio")))] mod imp { use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 4322491..a1f3e4a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -15,13 +15,16 @@ use ziggurat_zigbee::beacon::ZigbeeBeacon; use thiserror::Error; use crate::sync::{AsyncMutex, Mutex, MutexGuard, Notify}; -use std::cmp::Ordering; -use std::collections::{BinaryHeap, HashMap, VecDeque}; -use std::future::Future; -use std::ops::{Deref, DerefMut}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; -use std::sync::{Arc, Weak}; -use std::time::Duration; +use alloc::boxed::Box; +use alloc::collections::{BTreeMap, BinaryHeap, VecDeque}; +use alloc::string::String; +use alloc::sync::{Arc, Weak}; +use alloc::vec::Vec; +use core::cmp::Ordering; +use core::future::Future; +use core::ops::{Deref, DerefMut}; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering as AtomicOrdering}; +use core::time::Duration; use ziggurat_zigbee::nwk::frame::NwkFrame; mod aps; @@ -220,7 +223,7 @@ pub struct AddressConflict { pub heard_from_network: bool, } -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct ApsAckData { pub src: Nwk, pub destination_endpoint: Option, @@ -265,7 +268,7 @@ pub struct ApsAckWaiter { /// always matches frame-counter order regardless of priority reordering in the queue. #[derive(Debug)] pub(crate) struct SendRequest { - seq: u64, + seq: u32, priority: TxPriority, pub(crate) kind: SendKind, pub(crate) completion: Option, @@ -449,16 +452,16 @@ pub struct State { /// All mutable protocol state, behind one lock pub core: Mutex, - pub pending_aps_acks: Mutex>>, - pub pending_routes: Mutex>, + 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>, + pub pending_broadcasts: Mutex>, + pub address_conflicts: Mutex>, /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with /// the receipt time; an inbound data frame matching a live entry is a retransmission /// to be acknowledged but not delivered to the application a second time. - pub aps_duplicates: Mutex>, + pub aps_duplicates: Mutex>, // We intentionally violate the spec with these options // @@ -553,11 +556,11 @@ impl State { permitting_joins_until: None, trust_center_joins_until: None, }), - 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()), + pending_aps_acks: Mutex::new(BTreeMap::new()), + pending_routes: Mutex::new(BTreeMap::new()), + pending_broadcasts: Mutex::new(BTreeMap::new()), + address_conflicts: Mutex::new(BTreeMap::new()), + aps_duplicates: Mutex::new(BTreeMap::new()), hack_ignore_broadcast_startup_wait_period: true, hack_disable_tx: false, @@ -676,7 +679,7 @@ 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`] @@ -730,7 +733,7 @@ pub struct ZigbeeStack { /// 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, + pub(crate) send_seq: AtomicU32, /// Spawns and owns the stack's background tasks, so that a replaced stack can be fully /// stopped: a leaked background task would keep the replaced stack processing frames @@ -810,7 +813,7 @@ impl ZigbeeStack { send_queue: Mutex::new(BinaryHeap::new()), send_wake: Notify::new(), pending_route_wake: Notify::new(), - send_seq: AtomicU64::new(0), + send_seq: AtomicU32::new(0), spawner, }) } diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 926605b..21ace9d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -8,8 +8,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 alloc::collections::btree_map::Entry; +use alloc::vec::Vec; +use core::cmp; use ziggurat_phy::RadioPhy; use super::{ diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 5a4c067..2afc6df 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,6 +1,7 @@ use crate::runtime::Runtime; use crate::signal::{self, SignalWaiter}; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::RadioPhy; diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index a25bdae..e5d605d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -22,7 +22,9 @@ use ziggurat_zigbee::nwk::frame::{ BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderKeyId, }; -use std::time::Duration; +use alloc::format; +use alloc::vec::Vec; +use core::time::Duration; use ziggurat_zigbee::nwk::commands::{ Nwk802154AssociationStatus, NwkCommand, NwkEndDeviceTimeoutRequestCommand, NwkEndDeviceTimeoutResponseCommand, NwkEndDeviceTimeoutResponseStatus, NwkLeaveCommand, @@ -171,7 +173,7 @@ impl ZigbeeStack { core.nib.address_map.allocate( eui64, &core.nib.neighbors, - std::iter::repeat_with(|| Nwk(rand::random::())), + core::iter::repeat_with(|| Nwk(crate::rng::random_u16())), ) } @@ -180,7 +182,7 @@ impl ZigbeeStack { core.nib.address_map.generate_unused( &core.nib.neighbors, - std::iter::repeat_with(|| Nwk(rand::random::())), + core::iter::repeat_with(|| Nwk(crate::rng::random_u16())), ) } @@ -251,7 +253,7 @@ impl ZigbeeStack { arc_self .tunables .max_broadcast_jitter - .mul_f32(rand::random::()), + .mul_f32(crate::rng::random_f32()), ) .await; @@ -620,7 +622,7 @@ impl ZigbeeStack { let new_key = core .aib .aps_security - .issue_device_key(source_ieee, Key(rand::random())); + .issue_device_key(source_ieee, Key(crate::rng::random_array())); drop(core); // The key is persisted only once the device proves possession via Verify-Key diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 50faaf4..99b7167 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -4,6 +4,7 @@ use crate::ziggurat_ieee_802154::{ Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; use abstract_bits::AbstractBits; +use alloc::vec::Vec; use arbitrary_int::u24; use ziggurat_ieee_802154::types::{Nwk, PanId}; use ziggurat_phy::{RadioPhy, TxFrame, TxResult}; diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index b70eb15..16e3335 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -1,4 +1,5 @@ use crate::runtime::Runtime; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::RadioPhy; @@ -119,7 +120,7 @@ impl ZigbeeStack { // repeat one boundary entry (the last of frame N is the first of frame N+1) // so a receiver can stitch the advertised address range together (spec // 3.6.4.4.2). An empty list still emits a single first+last frame. - let end = std::cmp::min(start + MAX_LINK_STATUSES, total); + let end = core::cmp::min(start + MAX_LINK_STATUSES, total); let link_status_frame = self .nwk_command_frame( diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 997a2f8..17bfa0a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -4,8 +4,9 @@ use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; -use std::sync::atomic::Ordering as AtomicOrdering; -use std::time::Duration; +use alloc::vec::Vec; +use core::sync::atomic::Ordering as AtomicOrdering; +use core::time::Duration; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{RadioPhy, TxResult}; @@ -223,7 +224,7 @@ impl ZigbeeStack { fn broadcast_jitter(&self) -> Duration { self.tunables .max_broadcast_jitter - .mul_f32(rand::random::()) + .mul_f32(crate::rng::random_f32()) } /// Whether the broadcast's passive ack quorum has been heard from the audience diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index c370581..70afe12 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -1,6 +1,6 @@ use crate::runtime::Runtime; -use std::cmp; -use std::time::Duration; +use core::cmp; +use core::time::Duration; use ziggurat_ieee_802154::types::Nwk; use ziggurat_phy::RadioPhy; @@ -237,7 +237,7 @@ impl ZigbeeStack { // Spec 3.6.4.5.1.4: relayed route requests are jittered and retried let jitter = (self.tunables.min_rreq_jitter + (self.tunables.max_rreq_jitter - self.tunables.min_rreq_jitter) - .mul_f32(rand::random::())) + .mul_f32(crate::rng::random_f32())) * 2; self.background_broadcast_route_request( diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 6b2d915..f50ab5a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -1,4 +1,5 @@ use crate::runtime::Runtime; +use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::RadioPhy; use ziggurat_zigbee::aps::frame::{ApsDataFrame, ApsDeliveryMode}; @@ -334,7 +335,7 @@ impl ZigbeeStack { let jitter = self .tunables .parent_annce_jitter_max - .mul_f32(rand::random::()); + .mul_f32(crate::rng::random_f32()); let slept_at = self.core_now(); R::sleep(self.tunables.parent_annce_base_timer + jitter).await; From f476807f09df4937e087551b90c77b36c8f29909 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:51:28 -0400 Subject: [PATCH 21/61] Test: hacky ESP32 JSON API --- Cargo.toml | 6 +- crates/ziggurat-driver/src/runtime.rs | 1 + crates/ziggurat-esp/.cargo/config.toml | 15 + crates/ziggurat-esp/Cargo.lock | 2308 +++++++++++++++++++++++ crates/ziggurat-esp/Cargo.toml | 51 + crates/ziggurat-esp/rust-toolchain.toml | 4 + crates/ziggurat-esp/src/api.rs | 521 +++++ crates/ziggurat-esp/src/main.rs | 125 ++ crates/ziggurat-phy-esp/src/lib.rs | 8 +- 9 files changed, 3030 insertions(+), 9 deletions(-) create mode 100644 crates/ziggurat-esp/.cargo/config.toml create mode 100644 crates/ziggurat-esp/Cargo.lock create mode 100644 crates/ziggurat-esp/Cargo.toml create mode 100644 crates/ziggurat-esp/rust-toolchain.toml create mode 100644 crates/ziggurat-esp/src/api.rs create mode 100644 crates/ziggurat-esp/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 47c5620..18bfc9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "2" members = ["crates/*"] -# ziggurat-phy-esp only builds for an ESP32-C6 (riscv32imac) with esp-hal; excluded so -# host `cargo build` over the workspace doesn't try (and fail) to compile it. -exclude = ["fuzz", "crates/ziggurat-phy-esp"] +# ziggurat-phy-esp and ziggurat-esp only build for an ESP32-C6 (riscv32imac) with esp-hal; +# excluded so host `cargo build` over the workspace doesn't try (and fail) to compile them. +exclude = ["fuzz", "crates/ziggurat-phy-esp", "crates/ziggurat-esp"] [workspace.package] version = "0.1.0" diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 2a8d9e4..6db28f8 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -240,6 +240,7 @@ mod embassy_impl { /// Spawns into the embassy executor. Holds a [`SendSpawner`](embassy_executor::SendSpawner) /// so it is `Send + Sync`; obtained from the executor at startup. + #[derive(Clone, Copy)] pub struct EmbassySpawner(embassy_executor::SendSpawner); impl EmbassySpawner { diff --git a/crates/ziggurat-esp/.cargo/config.toml b/crates/ziggurat-esp/.cargo/config.toml new file mode 100644 index 0000000..ad57a48 --- /dev/null +++ b/crates/ziggurat-esp/.cargo/config.toml @@ -0,0 +1,15 @@ +[target.riscv32imac-unknown-none-elf] +runner = "espflash flash --monitor --chip esp32c6" + +[build] +rustflags = [ + "-C", "link-arg=-Tlinkall.x", + "-C", "force-frame-pointers", +] +target = "riscv32imac-unknown-none-elf" + +[env] +ESP_LOG = "info" + +[unstable] +build-std = ["core", "alloc"] diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock new file mode 100644 index 0000000..2fa61ec --- /dev/null +++ b/crates/ziggurat-esp/Cargo.lock @@ -0,0 +1,2308 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "abstract-bits" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "abstract-bits-derive", + "arbitrary-int 1.3.0", + "bitvec", + "thiserror", +] + +[[package]] +name = "abstract-bits-derive" +version = "0.2.0" +source = "git+https://github.com/yara-blue/abstract-bits.git#f82569eb658505e2b96c9baa498b376169adfe51" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "byte" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21c7ab3e4ae80853c7f8dcdcd904dfa25c02cc373534b8d165194325a088a7cc" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9cf981c7e62b6fb02225592ee7ebf221e0b0b5317984a57a1e9d21af20e317" +dependencies = [ + "aead 0.4.3", + "cipher 0.3.0", + "ctr 0.8.0", + "subtle", +] + +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead 0.6.1", + "cipher 0.5.2", + "ctr 0.10.1", + "subtle", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" +dependencies = [ + "cordyceps", + "critical-section", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-sink", + "futures-util", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.3", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.9.3", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-alloc" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config", + "esp-sync", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-backtrace" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f" +dependencies = [ + "cfg-if", + "document-features", + "esp-config", + "esp-metadata-generated", + "esp-println", + "heapless 0.9.3", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-bootloader-esp-idf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c" +dependencies = [ + "cfg-if", + "document-features", + "embedded-storage", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-rom-sys", + "jiff", + "strum", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72" +dependencies = [ + "bitfield", + "bitflags 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", + "esp32c6", + "esp32h2", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_core 0.9.5", + "riscv", + "strum", + "ufmt-write", + "xtensa-lx", + "xtensa-lx-rt", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" + +[[package]] +name = "esp-phy" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.8.0", + "esp-config", + "esp-hal", + "esp-metadata-generated", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", +] + +[[package]] +name = "esp-println" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6" +dependencies = [ + "document-features", + "esp-metadata-generated", + "esp-sync", + "log", + "portable-atomic", +] + +[[package]] +name = "esp-radio" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" +dependencies = [ + "allocator-api2", + "byte", + "cfg-if", + "docsplay", + "document-features", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "esp-alloc", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-phy", + "esp-radio-rtos-driver", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", + "heapless 0.9.3", + "ieee802154", + "instability", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" +dependencies = [ + "cfg-if", + "esp-sync", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", + "esp32c6", +] + +[[package]] +name = "esp-rtos" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712" +dependencies = [ + "cfg-if", + "document-features", + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time-driver", + "embassy-time-queue-utils", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-rom-sys", + "esp-sync", + "portable-atomic", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" + +[[package]] +name = "esp32" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" +dependencies = [ + "vcell", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d2aba832b60be25c1b169146b27c64115470981b128ed84c8db18c1b03c6ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ieee802154" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb6de62f20180795db19ae2ab338852a66f8576581554fa8a730e437b450a5" +dependencies = [ + "byte", + "ccm 0.4.4", + "cipher 0.3.0", + "hash32 0.2.1", + "hash32-derive", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "riscv-rt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" +dependencies = [ + "riscv", + "riscv-pac", + "riscv-rt-macros", + "riscv-target-parser", +] + +[[package]] +name = "riscv-rt-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "somni-expr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +dependencies = [ + "somni-parser", +] + +[[package]] +name = "somni-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xtensa-lx" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" +dependencies = [ + "document-features", + "xtensa-lx", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ziggurat-driver" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "arbitrary-int 2.1.1", + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time", + "futures", + "spin", + "thiserror", + "tracing", + "ziggurat-ieee-802154", + "ziggurat-phy", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-esp" +version = "0.1.0" +dependencies = [ + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-io-async 0.7.0", + "esp-alloc", + "esp-backtrace", + "esp-bootloader-esp-idf", + "esp-hal", + "esp-println", + "esp-radio", + "esp-rtos", + "hex", + "serde", + "serde_json", + "ziggurat-driver", + "ziggurat-ieee-802154", + "ziggurat-phy", + "ziggurat-phy-esp", + "ziggurat-zigbee", +] + +[[package]] +name = "ziggurat-ieee-802154" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "educe", + "heapless 0.9.3", + "hex", + "num_enum", + "serde", + "thiserror", +] + +[[package]] +name = "ziggurat-phy" +version = "0.1.0" +dependencies = [ + "thiserror", + "ziggurat-ieee-802154", +] + +[[package]] +name = "ziggurat-phy-esp" +version = "0.1.0" +dependencies = [ + "embassy-futures", + "embassy-sync 0.8.0", + "esp-hal", + "esp-radio", + "ziggurat-ieee-802154", + "ziggurat-phy", +] + +[[package]] +name = "ziggurat-zigbee" +version = "0.1.0" +dependencies = [ + "abstract-bits", + "aes", + "arbitrary-int 2.1.1", + "ccm 0.6.0-rc.3", + "educe", + "hex", + "num_enum", + "serde", + "subtle", + "thiserror", + "tracing", + "ziggurat-ieee-802154", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml new file mode 100644 index 0000000..0e51334 --- /dev/null +++ b/crates/ziggurat-esp/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "ziggurat-esp" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +description = "ESP32-C6 firmware running the Ziggurat stack with a line-delimited JSON API" +license = "Apache-2.0" + +[[bin]] +name = "ziggurat-esp" +path = "src/main.rs" + +[dependencies] +ziggurat-driver = { path = "../ziggurat-driver", default-features = false, features = [ + "embassy", +] } +ziggurat-phy = { path = "../ziggurat-phy" } +ziggurat-phy-esp = { path = "../ziggurat-phy-esp" } +ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } +ziggurat-zigbee = { path = "../ziggurat-zigbee" } + +esp-hal = { version = "1.1.1", features = ["esp32c6", "unstable"] } +esp-radio = { version = "0.18.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-rtos = { version = "0.3.0", features = ["esp32c6", "embassy"] } +esp-alloc = "0.10.0" +esp-backtrace = { version = "0.19.0", features = [ + "esp32c6", + "panic-handler", + "println", +] } +esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6"] } +esp-println = { version = "0.17.0", features = ["esp32c6", "log-04"] } + +embassy-executor = "0.10" +embassy-time = "0.5" +embassy-sync = "0.8" +embedded-io-async = "0.7" + +serde = { version = "1", default-features = false, features = ["alloc", "derive"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +hex = { version = "0.4", default-features = false, features = ["alloc"] } + +[profile.release] +opt-level = "s" +debug = true +lto = "fat" +codegen-units = 1 + +# A standalone workspace: this crate only builds for the ESP32-C6 target and is excluded +# from the host workspace. +[workspace] diff --git a/crates/ziggurat-esp/rust-toolchain.toml b/crates/ziggurat-esp/rust-toolchain.toml new file mode 100644 index 0000000..dda19b6 --- /dev/null +++ b/crates/ziggurat-esp/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +components = ["rust-src"] +targets = ["riscv32imac-unknown-none-elf"] diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs new file mode 100644 index 0000000..37b4d55 --- /dev/null +++ b/crates/ziggurat-esp/src/api.rs @@ -0,0 +1,521 @@ +//! The line-delimited JSON-RPC surface, mirroring the host server's wire protocol. One +//! request per line; each is answered with an `accepted` event then a `response`. +//! Unsolicited `notification` lines carry network events. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; +use ziggurat_driver::zigbee_stack::{ + ApsAck, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, + ZigbeeNotification, ZigbeeStack, +}; +use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_zigbee::aps::frame::ApsDeliveryMode; + +use crate::{App, OUTBOUND}; + +const PROTOCOL_VERSION: u32 = 1; +const DEFAULT_TX_POWER: i8 = 8; + +/// Queue one JSON object for the serial writer task. +pub async fn emit(value: Value) { + if let Ok(line) = serde_json::to_string(&value) { + OUTBOUND.send(line).await; + } +} + +pub fn hello_message(configured: bool) -> Value { + let state = if configured { + "running" + } else { + "awaiting_configuration" + }; + json!({"type": "hello", "version": PROTOCOL_VERSION, "state": state}) +} + +fn event(id: u64, name: &str) -> Value { + json!({"type": "event", "id": id, "event": name}) +} + +fn response(id: u64, result: Value) -> Value { + json!({"type": "response", "id": id, "result": result}) +} + +fn error_response(id: u64, code: &str, message: impl ToString) -> Value { + json!({ + "type": "response", "id": id, + "error": {"code": code, "message": message.to_string()}, + }) +} + +fn notification(name: &str, data: Value) -> Value { + json!({"type": "notification", "event": name, "data": data}) +} + +/// Big-endian colon-separated hex, matching the host server / zigpy format. +fn eui64_to_string(eui64: Eui64) -> String { + let mut bytes = eui64.to_bytes(); + bytes.reverse(); + join_hex(&bytes) +} + +fn key_to_string(key: &Key) -> String { + join_hex(&key.to_bytes()) +} + +fn join_hex(bytes: &[u8]) -> String { + let mut out = String::new(); + for (i, byte) in bytes.iter().enumerate() { + if i != 0 { + out.push(':'); + } + out.push_str(&format!("{byte:02x}")); + } + out +} + +#[derive(Deserialize)] +struct Request { + id: u64, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Deserialize, Default, Clone, Copy)] +#[serde(rename_all = "lowercase")] +enum NodeRole { + #[default] + Coordinator, + Router, +} + +impl From for NwkDeviceType { + fn from(role: NodeRole) -> Self { + match role { + NodeRole::Coordinator => Self::Coordinator, + NodeRole::Router => Self::Router, + } + } +} + +#[derive(Deserialize)] +struct ConfigureRequest { + #[serde(default)] + role: NodeRole, + channel: u8, + nwk_update_id: u8, + pan_id: PanId, + extended_pan_id: Eui64, + nwk_address: Nwk, + ieee_address: Eui64, + network_key: Key, + network_key_seq: u8, + network_key_tx_counter: u32, + tc_link_key: Option, + tclk_seed: Option, + tclk_flavor: Option, + #[serde(default)] + key_table: Vec, + #[serde(default)] + source_routing: bool, + tx_power: Option, +} + +#[derive(Deserialize)] +struct KeyTableEntry { + partner_ieee: Eui64, + key: Key, +} + +#[derive(Deserialize)] +struct SendApsRequest { + delivery_mode: ApsDeliveryMode, + destination_eui64: Option, + destination: Option, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: bool, + aps_seq: u8, + radius: u8, + /// Hex-encoded ASDU + data: String, + #[serde(default)] + aps_encryption: bool, + #[serde(default)] + priority: i8, +} + +#[derive(Deserialize)] +struct PermitJoinsRequest { + #[serde(default)] + duration: u64, + #[serde(default = "default_accept_direct_joins")] + accept_direct_joins: bool, +} + +const fn default_accept_direct_joins() -> bool { + true +} + +#[derive(Deserialize)] +struct SetChannelRequest { + channel: u8, +} + +#[derive(Deserialize)] +struct SetNwkUpdateIdRequest { + nwk_update_id: u8, +} + +#[derive(Deserialize)] +struct SetProvisionalKeyRequest { + ieee: Eui64, + key: Key, +} + +/// Parse and dispatch one inbound line, emitting the `accepted` event and the response. +pub async fn handle_line(app: &mut App, line: &[u8]) { + let request: Request = match serde_json::from_slice(line) { + Ok(request) => request, + Err(e) => { + emit(error_response(0, "invalid_request", e)).await; + return; + } + }; + + emit(event(request.id, "accepted")).await; + + let Request { id, method, params } = request; + let message = match method.as_str() { + "ping" => response(id, json!({"status": "pong"})), + "configure" => handle_configure(app, id, params).await, + "get_hw_address" => handle_get_hw_address(id), + "get_network_info" => handle_get_network_info(app, id), + "send_aps" => handle_send_aps(app, id, params).await, + "permit_joins" => handle_permit_joins(app, id, params), + "set_channel" => handle_set_channel(app, id, params).await, + "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), + "set_provisional_key" => handle_set_provisional_key(app, id, params), + other => error_response(id, "unknown_method", other), + }; + + emit(message).await; +} + +async fn handle_configure(app: &mut App, id: u64, params: Value) -> Value { + let request: ConfigureRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let tclk_seed = match (request.tclk_seed, request.tclk_flavor) { + (Some(seed), Some(flavor)) => Some(TclkSeed { seed, flavor }), + (None, None) => None, + _ => { + return error_response( + id, + "invalid_request", + "tclk_seed and tclk_flavor must be provided together", + ); + } + }; + + let stack = ZigbeeStack::new( + app.phy.clone(), + NetworkConfig { + role: request.role.into(), + channel: request.channel, + update_id: request.nwk_update_id, + pan_id: request.pan_id, + extended_pan_id: request.extended_pan_id, + network_address: request.nwk_address, + ieee_address: request.ieee_address, + network_key: request.network_key, + network_key_seq_number: request.network_key_seq, + network_key_tx_counter: request.network_key_tx_counter, + tc_link_key: request.tc_link_key.unwrap_or(WELL_KNOWN_LINK_KEY), + tclk_seed, + tx_power: request.tx_power.unwrap_or(DEFAULT_TX_POWER), + source_routing: request.source_routing, + }, + Tunables::new(), + app.spawner, + ); + + if !request.key_table.is_empty() { + let mut core = stack.state.core.lock(); + for entry in request.key_table { + core.aib + .aps_security + .restore_device_key(entry.partner_ieee, entry.key); + } + } + + if let Err(e) = stack.start_network().await { + return error_response(id, "network_start_failed", e); + } + + let run_stack = stack.clone(); + stack.spawn_tracked(async move { + run_stack.run().await; + }); + + // Drain network events to the serial writer. + let notify_stack = stack.clone(); + stack.spawn_tracked(async move { + loop { + for notification_event in notify_stack.next_notifications().await { + emit(notification_to_json(notification_event)).await; + } + } + }); + + app.stack = Some(stack); + response(id, json!({"status": "success"})) +} + +/// The factory IEEE address, derived from the SoC's eFuse base MAC (EUI-48 → EUI-64). +fn handle_get_hw_address(id: u64) -> Value { + let mac = esp_hal::efuse::base_mac_address(); + let mac = mac.as_bytes(); + // Big-endian EUI-64: first 3 MAC bytes, FF FE, last 3 MAC bytes. + let big_endian = [ + mac[0], mac[1], mac[2], 0xff, 0xfe, mac[3], mac[4], mac[5], + ]; + let mut le = big_endian; + le.reverse(); + response( + id, + json!({"ieee_address": eui64_to_string(Eui64(le))}), + ) +} + +fn handle_get_network_info(app: &App, id: u64) -> Value { + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let state = &stack.state; + let core = state.core.lock(); + let nwk_security = &core.nib.nwk_security; + + response( + id, + json!({ + "channel": core.mac.channel, + "nwk_update_id": core.nib.update_id, + "pan_id": format!("{:04x}", core.mac.pan_id.0), + "extended_pan_id": eui64_to_string(state.extended_pan_id), + "nwk_address": format!("{:04x}", state.network_address.as_u16()), + "ieee_address": eui64_to_string(state.ieee_address), + "network_key": key_to_string(&nwk_security.network_key()), + "network_key_seq": nwk_security.key_seq_number(), + "network_key_tx_counter": nwk_security.outgoing_frame_counter(), + "tx_power": stack.config.tx_power, + }), + ) +} + +async fn handle_send_aps(app: &App, id: u64, params: Value) -> Value { + let request: SendApsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let destination = match (request.destination_eui64, request.destination) { + (_, Some(nwk)) => nwk, + (Some(eui64), None) => match stack.state.core.lock().nib.address_map.nwk_for(eui64) { + Some(nwk) => nwk, + None => return error_response(id, "unknown_destination_eui64", format!("{eui64:?}")), + }, + (None, None) => return error_response(id, "missing_destination", "no destination given"), + }; + + let asdu = match hex::decode(&request.data) { + Ok(asdu) => asdu, + Err(e) => return error_response(id, "invalid_data", e), + }; + + let aps_security = if request.aps_encryption { + match (request.destination_eui64, request.delivery_mode) { + (Some(eui64), ApsDeliveryMode::Unicast) => Some(eui64), + _ => { + return error_response( + id, + "invalid_request", + "aps_encryption requires a unicast destination_eui64", + ); + } + } + } else { + None + }; + + let ack_waiter = match stack + .send_aps_command( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None + }, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + ) + .await + { + Ok(ack_waiter) => ack_waiter, + Err(e) => return error_response(id, "transmit_failed", e), + }; + + emit(event(id, "transmitted")).await; + + match ack_waiter { + None => response(id, json!({"status": "sent"})), + Some(waiter) => match stack.wait_aps_ack(waiter).await { + Ok(()) => response(id, json!({"status": "delivered"})), + Err(e) => error_response(id, "aps_ack_timeout", e), + }, + } +} + +fn handle_permit_joins(app: &App, id: u64, params: Value) -> Value { + let request: PermitJoinsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.permit_joins(request.duration, request.accept_direct_joins); + response(id, json!({"status": "success"})) +} + +async fn handle_set_channel(app: &App, id: u64, params: Value) -> Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + match stack.set_channel(request.channel).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } +} + +fn handle_set_nwk_update_id(app: &App, id: u64, params: Value) -> Value { + let request: SetNwkUpdateIdRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_nwk_update_id(request.nwk_update_id); + response(id, json!({"status": "success"})) +} + +fn handle_set_provisional_key(app: &App, id: u64, params: Value) -> Value { + let request: SetProvisionalKeyRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.set_provisional_key(request.ieee, request.key); + response(id, json!({"status": "success"})) +} + +fn notification_to_json(notification_event: ZigbeeNotification) -> Value { + match notification_event { + ZigbeeNotification::ReceivedApsCommand { + source, + destination, + group, + profile_id, + cluster_id, + src_ep, + dst_ep, + lqi, + rssi, + data, + } => notification( + "received_aps_command", + json!({ + "source": hex::encode(source.to_bytes()), + "destination": hex::encode(destination.to_bytes()), + "group": group, + "profile_id": profile_id, + "cluster_id": cluster_id, "src_ep": src_ep, "dst_ep": dst_ep, + "lqi": lqi, "rssi": rssi, "data": hex::encode(data), + }), + ), + ZigbeeNotification::FrameCounterUpdate { frame_counter } => { + notification("frame_counter_update", json!({"frame_counter": frame_counter})) + } + ZigbeeNotification::LinkKeyUpdate { ieee, key } => notification( + "link_key_update", + json!({"ieee": eui64_to_string(ieee), "key": key_to_string(&key)}), + ), + ZigbeeNotification::DeviceJoined { nwk, ieee, parent } => notification( + "device_joined", + json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": eui64_to_string(ieee), + "parent": hex::encode(parent.to_bytes()), + }), + ), + ZigbeeNotification::DeviceLeft { nwk, ieee, .. } => notification( + "device_left", + json!({ + "nwk": hex::encode(nwk.to_bytes()), + "ieee": ieee.map(eui64_to_string), + }), + ), + ZigbeeNotification::ApsDecryptionFailure { + source, + source_ieee, + frame_counter, + key_id, + } => notification( + "aps_decryption_failure", + json!({ + "source": hex::encode(source.to_bytes()), + "source_ieee": eui64_to_string(source_ieee), + "frame_counter": frame_counter, + "key_id": key_id, + }), + ), + } +} diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs new file mode 100644 index 0000000..d731039 --- /dev/null +++ b/crates/ziggurat-esp/src/main.rs @@ -0,0 +1,125 @@ +//! ESP32-C6 firmware: runs the Ziggurat stack against the native 802.15.4 radio and +//! exposes the same line-delimited JSON API as the host server's `--api stdio` mode, over +//! the built-in USB-Serial-JTAG. One request per inbound line; one JSON object per +//! outbound line. + +#![no_std] +#![no_main] + +extern crate alloc; + +mod api; + +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use embassy_executor::Spawner; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use embedded_io_async::Read; +use embedded_io_async::Write; +use esp_alloc as _; +use esp_backtrace as _; +use esp_hal::Async; +use esp_hal::interrupt::software::SoftwareInterruptControl; +use esp_hal::rng::Rng; +use esp_hal::timer::timg::TimerGroup; +use esp_hal::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagTx}; + +use ziggurat_driver::rng; +use ziggurat_driver::runtime::EmbassySpawner; +use ziggurat_driver::zigbee_stack::ZigbeeStack; +use ziggurat_phy_esp::EspPhy; + +esp_bootloader_esp_idf::esp_app_desc!(); + +/// Outbound JSON lines (responses, events, notifications) converge here and a single +/// writer task drains them to the serial port. +const OUTBOUND_DEPTH: usize = 16; +pub static OUTBOUND: Channel = + Channel::new(); + +/// The firmware's mutable state, owned by (and only touched from) the reader loop. +pub struct App { + pub phy: Arc, + pub spawner: EmbassySpawner, + pub stack: Option>>, +} + +/// Drain the radio's received frames; the stack reads them off the shared RX channel. +#[embassy_executor::task] +async fn rx_task(phy: Arc) { + phy.run_rx().await +} + +/// The single serial writer: every outbound line goes through it, so concurrent +/// producers (request handlers and the notification drainer) never interleave on the bus. +#[embassy_executor::task] +async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { + loop { + let line = OUTBOUND.receive().await; + let _ = tx.write_all(line.as_bytes()).await; + let _ = tx.write_all(b"\n").await; + let _ = tx.flush().await; + } +} + +#[esp_rtos::main] +async fn main(spawner: Spawner) -> ! { + let peripherals = esp_hal::init(esp_hal::Config::default()); + + let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); + let timg0 = TimerGroup::new(peripherals.TIMG0); + esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); + + esp_alloc::heap_allocator!(size: 96 * 1024); + + // Install the randomness source the stack pulls jitter, addresses, and keys from. The + // SoC RNG is true-random once the radio subsystem is up (it is, below). + rng::install(Box::new(|buf: &mut [u8]| { + let rng = Rng::new(); + for chunk in buf.chunks_mut(4) { + let bytes = rng.random().to_le_bytes(); + let len = chunk.len(); + chunk.copy_from_slice(&bytes[..len]); + } + })); + + let usb = UsbSerialJtag::new(peripherals.USB_DEVICE).into_async(); + let (mut serial_rx, serial_tx) = usb.split(); + + let phy = Arc::new(EspPhy::new(peripherals.IEEE802154)); + + spawner.spawn(rx_task(phy.clone()).unwrap()); + spawner.spawn(writer_task(serial_tx).unwrap()); + + let mut app = App { + phy, + spawner: EmbassySpawner::new(spawner.make_send()), + stack: None, + }; + + api::emit(api::hello_message(false)).await; + + // The reader loop: accumulate bytes into a line, dispatch on newline. `buf` is only + // the per-read chunk; `line` grows without bound, so a full-network-state `configure` + // line spanning many reads is reassembled whole. + let mut buf = [0u8; 256]; + let mut line: Vec = Vec::with_capacity(2048); + loop { + let n = serial_rx.read(&mut buf).await.unwrap_or(0); + for &byte in &buf[..n] { + match byte { + b'\n' => { + if !line.is_empty() { + api::handle_line(&mut app, &line).await; + line.clear(); + } + } + b'\r' => {} + _ => line.push(byte), + } + } + } +} diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 88ce3e9..de63234 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -25,7 +25,7 @@ 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, + TxResult, }; const RX_DEPTH: usize = 16; @@ -225,11 +225,7 @@ impl RadioPhy for EspPhy { Ok(()) } - async fn transmit( - &self, - frame: TxFrame, - _priority: TxPriority, - ) -> Result { + async fn transmit(&self, frame: TxFrame) -> Result { self.transmit_inner(&frame).await } From 56e42e64c5d1b24926912f91b13818591af5a8a4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:20:08 -0400 Subject: [PATCH 22/61] Fix ESP32 radio reset --- crates/ziggurat-phy-esp/src/lib.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index de63234..92bdd28 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -34,6 +34,7 @@ const RX_DEPTH: usize = 16; // esp-radio completion callbacks are plain `fn()` (no captures), so they must reach the // async side through statics. static RX_CHANNEL: Channel = Channel::new(); +static RESET_CHANNEL: Channel = Channel::new(); static RX_AVAILABLE: Signal = Signal::new(); static TX_DONE: Signal = Signal::new(); static TX_FAILED: Signal = Signal::new(); @@ -170,12 +171,13 @@ impl Receiver for EspRx { } } -/// The native radio never spontaneously resets, so this stream never yields. -pub struct NeverReset; +/// Reset notifications: the native radio never spontaneously resets, so the only events +/// are the ones [`EspPhy::reset`] synthesizes when the driver asks for a reset. +pub struct EspReset(ChannelReceiver<'static, CriticalSectionRawMutex, ResetEvent, 1>); -impl Receiver for NeverReset { +impl Receiver for EspReset { async fn recv(&mut self) -> Option { - core::future::pending().await + Some(self.0.receive().await) } } @@ -200,10 +202,14 @@ impl ExclusiveRadio for EspExclusive<'_> { impl RadioPhy for EspPhy { type Exclusive<'a> = EspExclusive<'a>; type RxStream = EspRx; - type ResetStream = NeverReset; + type ResetStream = EspReset; async fn reset(&self) -> Result<(), RadioError> { - // No external RCP to reset; reconfigure re-applies all state. + // No external RCP to reset; reconfigure re-applies all state. The driver waits for + // a reset notification afterward, so synthesize one. + let _ = RESET_CHANNEL.try_send(ResetEvent { + reason: String::from("esp radio ready"), + }); Ok(()) } @@ -244,8 +250,8 @@ impl RadioPhy for EspPhy { EspRx(RX_CHANNEL.receiver()) } - fn subscribe_reset(&self) -> NeverReset { - NeverReset + fn subscribe_reset(&self) -> EspReset { + EspReset(RESET_CHANNEL.receiver()) } } From c5650b30cdceeda4a3931cf31112632f669fc495 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:58:03 -0400 Subject: [PATCH 23/61] ESP32 network scan API --- crates/ziggurat-esp/src/api.rs | 111 ++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 37b4d55..9f0c236 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -5,14 +5,15 @@ use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::time::Duration; use serde::Deserialize; use serde_json::{Value, json}; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, - ZigbeeNotification, ZigbeeStack, + ApsAck, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, TxPriority, + WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; @@ -42,6 +43,10 @@ fn event(id: u64, name: &str) -> Value { json!({"type": "event", "id": id, "event": name}) } +fn event_data(id: u64, name: &str, data: Value) -> Value { + json!({"type": "event", "id": id, "event": name, "data": data}) +} + fn response(id: u64, result: Value) -> Value { json!({"type": "response", "id": id, "result": result}) } @@ -165,6 +170,19 @@ const fn default_accept_direct_joins() -> bool { true } +#[derive(Deserialize)] +struct EnergyScanRequest { + channels: Vec, + #[allow(dead_code)] + duration_per_channel_ms: u16, +} + +#[derive(Deserialize)] +struct NetworkScanRequest { + channels: Vec, + duration_per_channel_ms: u16, +} + #[derive(Deserialize)] struct SetChannelRequest { channel: u8, @@ -200,6 +218,8 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), "send_aps" => handle_send_aps(app, id, params).await, + "energy_scan" => handle_energy_scan(id, params).await, + "network_scan" => handle_network_scan(app, id, params).await, "permit_joins" => handle_permit_joins(app, id, params), "set_channel" => handle_set_channel(app, id, params).await, "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), @@ -306,6 +326,8 @@ fn handle_get_network_info(app: &App, id: u64) -> Value { let state = &stack.state; let core = state.core.lock(); let nwk_security = &core.nib.nwk_security; + let aps_security = &core.aib.aps_security; + let tclk_seed = &stack.config.tclk_seed; response( id, @@ -319,6 +341,19 @@ fn handle_get_network_info(app: &App, id: u64) -> Value { "network_key": key_to_string(&nwk_security.network_key()), "network_key_seq": nwk_security.key_seq_number(), "network_key_tx_counter": nwk_security.outgoing_frame_counter(), + "tc_link_key": key_to_string(&stack.config.tc_link_key), + "tclk_seed": tclk_seed.as_ref().map(|tclk| hex::encode(tclk.seed.to_bytes())), + "tclk_flavor": tclk_seed.as_ref().map(|tclk| match tclk.flavor { + TclkFlavor::ZStack => "zstack", + TclkFlavor::Ezsp => "ezsp", + }), + "key_table": aps_security + .device_keys() + .map(|(partner_ieee, entry)| json!({ + "partner_ieee": eui64_to_string(partner_ieee), + "key": key_to_string(&entry.key), + })) + .collect::>(), "tx_power": stack.config.tx_power, }), ) @@ -399,6 +434,78 @@ async fn handle_send_aps(app: &App, id: u64, params: Value) -> Value { } } +/// Placeholder energy scan: esp-radio exposes no energy-detect API, so this streams a +/// flat floor RSSI per channel. Channel selection is therefore not energy-informed, but +/// `form` and other callers that expect a per-channel stream still work. +async fn handle_energy_scan(id: u64, params: Value) -> Value { + let request: EnergyScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + for channel in request.channels { + emit(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": -90}), + )) + .await; + } + + response(id, json!({"status": "complete"})) +} + +fn network_beacon_json(beacon: &NetworkBeacon) -> Value { + json!({ + "channel": beacon.channel, + "source": beacon.source.map(|nwk| format!("{:04x}", nwk.0)), + "pan_id": format!("{:04x}", beacon.pan_id.0), + "extended_pan_id": eui64_to_string(beacon.extended_pan_id), + "permit_joining": beacon.permit_joining, + "stack_profile": beacon.stack_profile, + "protocol_version": beacon.protocol_version, + "router_capacity": beacon.router_capacity, + "end_device_capacity": beacon.end_device_capacity, + "device_depth": beacon.device_depth, + "update_id": beacon.update_id, + "lqi": beacon.lqi, + "rssi": beacon.rssi, + }) +} + +/// Active scan: beacon-request each channel and stream the beacons heard. Runs inline — +/// the receive loop collects beacons concurrently during the per-channel dwell, so they +/// are all queued by the time the scan returns and we drain them. +async fn handle_network_scan(app: &App, id: u64, params: Value) -> Value { + let request: NetworkScanRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + stack.begin_network_scan(); + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); + let result = stack.run_network_scan(&request.channels, duration).await; + + loop { + let batch = stack.next_scan_beacons().await; + if batch.is_empty() { + break; + } + for beacon in batch { + emit(event_data(id, "network_found", network_beacon_json(&beacon))).await; + } + } + + match result { + Ok(()) => response(id, json!({"status": "complete"})), + Err(e) => error_response(id, "network_scan_failed", e), + } +} + fn handle_permit_joins(app: &App, id: u64, params: Value) -> Value { let request: PermitJoinsRequest = match serde_json::from_value(params) { Ok(request) => request, From 748495ade5c828cec74a0070f3aadb457d14801a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:50:33 -0400 Subject: [PATCH 24/61] Task cancellation --- crates/ziggurat-driver/src/zigbee_stack.rs | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index a1f3e4a..d1934b7 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -739,6 +739,14 @@ pub struct ZigbeeStack /// stopped: a leaked background task would keep the replaced stack processing frames /// and transmitting alongside its successor. spawner: R::Spawner, + + /// Per-task cancel signals, keyed by task id. + cancels: Mutex>>, + /// Hands each spawned task a unique id for the `cancels` map. + next_task_id: AtomicU32, + /// Woken whenever a task removes itself from `cancels`, so `shutdown` can await the + /// set draining to empty. + tasks_drained: Notify, } impl ZigbeeStack { @@ -815,6 +823,9 @@ impl ZigbeeStack { pending_route_wake: Notify::new(), send_seq: AtomicU32::new(0), spawner, + cancels: Mutex::new(BTreeMap::new()), + next_task_id: AtomicU32::new(0), + tasks_drained: Notify::new(), }) } @@ -1324,7 +1335,29 @@ impl ZigbeeStack { where F: Future + Send + 'static, { - self.spawner.spawn(Box::pin(future)); + let id = self.next_task_id.fetch_add(1, AtomicOrdering::Relaxed); + let cancel = Arc::new(Notify::new()); + self.cancels.lock().insert(id, cancel.clone()); + + // Hold a weak handle so a task that outlives `shutdown` (or finishes naturally) can + // still deregister without keeping the stack alive. + let weak = self.self_weak.clone(); + + self.spawner.spawn(Box::pin(async move { + // Run the task until it finishes or `shutdown` cancels it. Dropping the task + // future at an await point is safe: the stack never holds the blocking core + // lock across an await (enforced by `CoreGuard` being `!Send`). + { + let future = core::pin::pin!(future); + let cancelled = core::pin::pin!(cancel.notified()); + let _ = futures::future::select(future, cancelled).await; + } + + if let Some(stack) = weak.upgrade() { + stack.cancels.lock().remove(&id); + stack.tasks_drained.notify_one(); + } + })); } /// Spawns a tracked task that needs an owned handle to the stack. @@ -1345,6 +1378,18 @@ impl ZigbeeStack { /// replaced stack provably stops processing frames and transmitting before its /// successor takes over the shared Spinel client. pub async fn shutdown(&self) { + // Cooperatively cancel every tracked task, then wait for each to deregister. This + // is what stops the tasks on embassy, where the spawner cannot abort them. + for cancel in self.cancels.lock().values() { + cancel.notify_one(); + } + + while !self.cancels.lock().is_empty() { + self.tasks_drained.notified().await; + } + + // On executors that can abort (tokio), this also reaps the finished JoinSet + // entries; on embassy it is a no-op. self.spawner.shutdown().await; } From 3f67d03b4b8e5256349e9b5b1540b4765f3c3371 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:52:08 -0400 Subject: [PATCH 25/61] Energy scan --- crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/src/api.rs | 32 ++++++---- crates/ziggurat-phy-esp/src/lib.rs | 94 ++++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 2fa61ec..51ff100 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2277,6 +2277,7 @@ version = "0.1.0" dependencies = [ "embassy-futures", "embassy-sync 0.8.0", + "embassy-time", "esp-hal", "esp-radio", "ziggurat-ieee-802154", diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 9f0c236..c7f345c 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -173,7 +173,6 @@ const fn default_accept_direct_joins() -> bool { #[derive(Deserialize)] struct EnergyScanRequest { channels: Vec, - #[allow(dead_code)] duration_per_channel_ms: u16, } @@ -218,7 +217,7 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), "send_aps" => handle_send_aps(app, id, params).await, - "energy_scan" => handle_energy_scan(id, params).await, + "energy_scan" => handle_energy_scan(app, id, params).await, "network_scan" => handle_network_scan(app, id, params).await, "permit_joins" => handle_permit_joins(app, id, params), "set_channel" => handle_set_channel(app, id, params).await, @@ -434,22 +433,31 @@ async fn handle_send_aps(app: &App, id: u64, params: Value) -> Value { } } -/// Placeholder energy scan: esp-radio exposes no energy-detect API, so this streams a -/// flat floor RSSI per channel. Channel selection is therefore not energy-informed, but -/// `form` and other callers that expect a per-channel stream still work. -async fn handle_energy_scan(id: u64, params: Value) -> Value { +/// Energy scan: per-channel hardware energy detection, streamed as `energy_result` +/// events. The radio's ED is driven directly through its registers (see ziggurat-phy-esp). +async fn handle_energy_scan(app: &App, id: u64, params: Value) -> Value { let request: EnergyScanRequest = match serde_json::from_value(params) { Ok(request) => request, Err(e) => return error_response(id, "invalid_request", e), }; + let Some(stack) = app.stack.as_ref() else { + return error_response(id, "not_configured", "no stack is running"); + }; + + let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); for channel in request.channels { - emit(event_data( - id, - "energy_result", - json!({"channel": channel, "rssi": -90}), - )) - .await; + match stack.energy_detect(channel, duration).await { + Ok(rssi) => { + emit(event_data( + id, + "energy_result", + json!({"channel": channel, "rssi": rssi}), + )) + .await; + } + Err(e) => return error_response(id, "energy_scan_failed", e), + } } response(id, json!({"status": "complete"})) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 92bdd28..a79d932 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -39,6 +39,37 @@ static RX_AVAILABLE: Signal = Signal::new(); static TX_DONE: Signal = Signal::new(); static TX_FAILED: Signal = Signal::new(); +/// Direct IEEE802154 register access. esp-radio exposes neither an energy-detect API nor a +/// coex-disable, so we reach the memory-mapped registers ourselves (offsets/values per +/// ESP-IDF's `components/ieee802154`). esp-radio owns the peripheral, but the register block +/// is at a fixed address; we serialize with everything else through the radio `state` lock. +mod regs { + const BASE: usize = 0x600A_3000; + pub const CMD: usize = BASE + 0x00; + pub const ED_DURATION: usize = BASE + 0x50; + pub const ED_CFG: usize = BASE + 0x54; + pub const EVENT_STATUS: usize = BASE + 0x64; + pub const PTI: usize = BASE + 0x70; + + pub const CMD_ED_START: u32 = 0x44; + pub const CMD_STOP: u32 = 0x45; + pub const EVENT_ED_DONE: u32 = 1 << 6; + pub const ALL_EVENTS: u32 = 0x1FFF; + /// `ed_cfg.ed_sample_mode`: 0 = report the peak (max) sample, 1 = average. + pub const ED_SAMPLE_MODE: u32 = 1 << 13; + /// `pti.pti` (bits 0..3) and `pti.hw_ack_pti` (bits 4..7), both set to 1 — ESP-IDF's + /// `ieee802154_ll_disable_coex`: the radio always wins arbitration, so a non-existent + /// coex partner can't gate (and starve) RX/TX/ED. + pub const COEX_DISABLE: u32 = 0x11; + + pub unsafe fn read(addr: usize) -> u32 { + unsafe { core::ptr::read_volatile(addr as *const u32) } + } + pub unsafe fn write(addr: usize, value: u32) { + unsafe { core::ptr::write_volatile(addr as *mut u32, value) } + } +} + fn on_rx_available() { RX_AVAILABLE.signal(()); } @@ -191,6 +222,10 @@ impl ExclusiveRadio for EspExclusive<'_> { state.config.channel = channel; let config = state.config; state.radio.set_config(config); + // A channel change only reaches the running receiver when RX is (re)started, so + // re-arm it; otherwise the radio keeps receiving on the previous channel (e.g. a + // network scan would only ever hear the home channel). + state.radio.start_receive(); Ok(()) } @@ -235,11 +270,60 @@ impl RadioPhy for EspPhy { 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 energy_detect(&self, channel: u8, duration: Duration) -> Result { + use regs::*; + + let mut state = self.state.lock().await; + let home = state.config.channel; + + // Tune to the target channel (esp-radio maps channel -> RF frequency). + state.config.channel = channel; + let config = state.config; + state.radio.set_config(config); + + // The hardware measures over `duration`, in 16 us symbol periods, latched into a + // 24-bit field. + let symbols = ((duration.as_micros() / 16) as u32).min(0x00FF_FFFF); + + // ED_DONE must stay enabled in the event mask for the hardware to latch the + // completion, but esp-radio's ISR clears every event and has no ED handler, so it + // would consume the completion (and could restart RX mid-measurement) before we + // read it. Mask the MAC interrupt at the controller for the measurement instead: + // the event still latches, we poll it, and the ISR cannot run. + interrupt::disable(Cpu::current(), Interrupt::ZB_MAC); + + let rss = unsafe { + write(CMD, CMD_STOP); + write(EVENT_STATUS, ALL_EVENTS); // write-1-to-clear + // Peak (max) sampling: report the strongest energy seen over the dwell, so a + // mostly-idle channel with brief bursts still registers them (averaging would + // wash them out to the noise floor). The driver wants peak channel energy. + write(ED_CFG, read(ED_CFG) & !ED_SAMPLE_MODE); + write(ED_DURATION, symbols); + write(CMD, CMD_ED_START); + + // Wait out the dwell, then poll for the latched completion. + Timer::after(embassy_time::Duration::from_micros(u64::from(symbols) * 16)).await; + let mut remaining = 50; + while read(EVENT_STATUS) & EVENT_ED_DONE == 0 && remaining > 0 { + Timer::after(embassy_time::Duration::from_millis(1)).await; + remaining -= 1; + } + + let rss = ((read(ED_CFG) >> 16) & 0xFF) as i8; + write(EVENT_STATUS, ALL_EVENTS); + rss + }; + + interrupt::enable(Interrupt::ZB_MAC, Priority::Priority1); + + // Restore the home channel and resume receiving. + state.config.channel = home; + let config = state.config; + state.radio.set_config(config); + state.radio.start_receive(); + + Ok(rss) } async fn lock(&self) -> EspExclusive<'_> { From 74c12ea22971e3ff78baffa1f9d4a0463ef63671 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:52:51 -0400 Subject: [PATCH 26/61] Fix RX --- crates/ziggurat-esp/src/api.rs | 4 ++++ crates/ziggurat-esp/src/main.rs | 18 ++++++++++++++++++ crates/ziggurat-phy-esp/Cargo.toml | 1 + crates/ziggurat-phy-esp/src/lib.rs | 19 ++++++++++++++----- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index c7f345c..ccd0cea 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -235,6 +235,10 @@ async fn handle_configure(app: &mut App, id: u64, params: Value) -> Value { Err(e) => return error_response(id, "invalid_request", e), }; + if let Some(old_stack) = app.stack.take() { + old_stack.shutdown().await; + } + let tclk_seed = match (request.tclk_seed, request.tclk_flavor) { (Some(seed), Some(flavor)) => Some(TclkSeed { seed, flavor }), (None, None) => None, diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index d731039..8993c2f 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -22,6 +22,8 @@ use embedded_io_async::Write; use esp_alloc as _; use esp_backtrace as _; use esp_hal::Async; +use esp_hal::delay::Delay; +use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::rng::Rng; use esp_hal::timer::timg::TimerGroup; @@ -69,6 +71,22 @@ async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { async fn main(spawner: Spawner) -> ! { let peripherals = esp_hal::init(esp_hal::Config::default()); + // XIAO ESP32-C6 antenna RF switch: GPIO3 low powers the switch, then (after it + // settles) GPIO14 low selects the onboard ceramic antenna. Without this the board + // uses the U.FL external port. Leaked so the pins stay driven for the process + // lifetime. + core::mem::forget(Output::new( + peripherals.GPIO3, + Level::Low, + OutputConfig::default(), + )); + Delay::new().delay_millis(100); + core::mem::forget(Output::new( + peripherals.GPIO14, + Level::Low, + OutputConfig::default(), + )); + let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); let timg0 = TimerGroup::new(peripherals.TIMG0); esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); diff --git a/crates/ziggurat-phy-esp/Cargo.toml b/crates/ziggurat-phy-esp/Cargo.toml index 79f11cf..6721b5f 100644 --- a/crates/ziggurat-phy-esp/Cargo.toml +++ b/crates/ziggurat-phy-esp/Cargo.toml @@ -12,5 +12,6 @@ ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } embassy-sync = "0.8" embassy-futures = "0.1" +embassy-time = "0.5" esp-radio = { version = "0.18.0", features = ["esp32c6", "ieee802154", "unstable"] } esp-hal = { version = "1.1.1", features = ["esp32c6", "unstable"] } diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index a79d932..2423a50 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -20,7 +20,10 @@ 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 embassy_time::Timer; +use esp_hal::interrupt::{self, Priority}; +use esp_hal::peripherals::{IEEE802154, Interrupt}; +use esp_hal::system::Cpu; use esp_radio::ieee802154::{Config, Ieee802154}; use ziggurat_ieee_802154::types::{Eui64, Nwk}; use ziggurat_phy::{ @@ -95,6 +98,13 @@ impl EspPhy { radio.set_rx_available_callback_fn(on_rx_available); radio.set_tx_done_callback_fn(on_tx_done); radio.set_tx_failed_callback_fn(on_tx_failed); + + // esp-radio enables coex PTI at init but never disables it, and there is no coex + // partner running here. + unsafe { + let pti = regs::read(regs::PTI); + regs::write(regs::PTI, (pti & !0xFF) | regs::COEX_DISABLE); + } Self { state: Mutex::new(RadioState { radio, @@ -157,20 +167,19 @@ impl EspPhy { } } -/// 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; + let rssi = psdu[len - 2] as i8; + let lqi = psdu[len - 1]; Some(RxFrame { psdu: psdu[..len - 2].to_vec(), channel, rssi, - lqi: esp_radio::ieee802154::rssi_to_lqi(rssi), + lqi, timestamp_us: 0, // TODO: esp-radio does not surface a per-frame timestamp }) } From 2694f8b692ce7befb29a186d0c54bbb624de1bb0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:38:37 -0400 Subject: [PATCH 27/61] Implement ESP32 radio exclusivity --- crates/ziggurat-phy-esp/src/lib.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 2423a50..e27c04d 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -18,7 +18,7 @@ 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::mutex::{Mutex, MutexGuard}; use embassy_sync::signal::Signal; use embassy_time::Timer; use esp_hal::interrupt::{self, Priority}; @@ -90,6 +90,7 @@ struct RadioState { pub struct EspPhy { state: Mutex, + exclusive: Mutex, } impl EspPhy { @@ -110,6 +111,7 @@ impl EspPhy { radio, config: Config::default(), }), + exclusive: Mutex::new(()), } } @@ -223,6 +225,7 @@ impl Receiver for EspReset { pub struct EspExclusive<'a> { phy: &'a EspPhy, + _guard: MutexGuard<'a, CriticalSectionRawMutex, ()>, } impl ExclusiveRadio for EspExclusive<'_> { @@ -276,6 +279,9 @@ impl RadioPhy for EspPhy { } async fn transmit(&self, frame: TxFrame) -> Result { + // Wait behind any exclusive holder (a scan) so this transmit can't retune the radio + // mid-scan. + let _exclusive = self.exclusive.lock().await; self.transmit_inner(&frame).await } @@ -336,7 +342,10 @@ impl RadioPhy for EspPhy { } async fn lock(&self) -> EspExclusive<'_> { - EspExclusive { phy: self } + EspExclusive { + phy: self, + _guard: self.exclusive.lock().await, + } } fn subscribe_rx(&self) -> EspRx { From d30752d3caca4ceb355d235181810f04bce9f2e7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:46:46 -0400 Subject: [PATCH 28/61] Packet capture API for ESP32 --- Cargo.lock | 1 + crates/ziggurat-esp/src/api.rs | 76 ++++++++++++++++++++++++++ crates/ziggurat-server/Cargo.toml | 1 + crates/ziggurat-server/src/main.rs | 87 ++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d06ebd7..4260c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2042,6 +2042,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ziggurat-driver", + "ziggurat-phy", "ziggurat-phy-spinel", "ziggurat-spinel", "ziggurat-zigbee", diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index ccd0cea..0923e66 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -2,6 +2,7 @@ //! request per line; each is answered with an `accepted` event then a `response`. //! Unsolicited `notification` lines carry network events. +use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; @@ -10,12 +11,14 @@ use core::time::Duration; use serde::Deserialize; use serde_json::{Value, json}; +use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ ApsAck, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; use crate::{App, OUTBOUND}; @@ -221,6 +224,15 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "network_scan" => handle_network_scan(app, id, params).await, "permit_joins" => handle_permit_joins(app, id, params), "set_channel" => handle_set_channel(app, id, params).await, + "packet_capture" => { + // Streaming with no terminal response: the spawned capture task emits + // `captured_packet` events until the client disconnects. + handle_packet_capture(app, id, params).await; + return; + } + "packet_capture_change_channel" => { + handle_packet_capture_change_channel(app, id, params).await + } "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), "set_provisional_key" => handle_set_provisional_key(app, id, params), other => error_response(id, "unknown_method", other), @@ -548,6 +560,70 @@ async fn handle_set_channel(app: &App, id: u64, params: Value) -> Value { } } +/// Radio programming for promiscuous capture: receive every frame on `channel`, no PAN/ +/// address filtering, no network required. Dummy addresses since nothing is addressed to us. +const fn capture_config(channel: u8) -> RadioConfig { + RadioConfig { + channel, + tx_power: DEFAULT_TX_POWER, + short_address: Nwk(0xFFFF), + extended_address: Eui64([0; 8]), + pan_id: PanId(0xFFFF), + promiscuous: true, + rx_on_when_idle: true, + frame_pending_short: Vec::new(), + frame_pending_extended: Vec::new(), + } +} + +/// Put the radio in promiscuous mode and stream every received frame as a `captured_packet` +/// event. Runs without a configured stack (only `connect()` is needed). The capture task +/// streams until the client disconnects; there is no terminal response. +async fn handle_packet_capture(app: &App, id: u64, params: Value) { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => { + emit(error_response(id, "invalid_request", e)).await; + return; + } + }; + + if let Err(e) = app.phy.reconfigure(&capture_config(request.channel)).await { + emit(error_response(id, "packet_capture_failed", e)).await; + return; + } + + let phy = app.phy.clone(); + app.spawner.spawn(Box::pin(async move { + let mut rx = phy.subscribe_rx(); + while let Some(frame) = rx.recv().await { + emit(event_data( + id, + "captured_packet", + json!({ + "channel": frame.channel, + "rssi": frame.rssi, + "lqi": frame.lqi, + "data": hex::encode(&frame.psdu), + }), + )) + .await; + } + })); +} + +async fn handle_packet_capture_change_channel(app: &App, id: u64, params: Value) -> Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + match app.phy.reconfigure(&capture_config(request.channel)).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } +} + fn handle_set_nwk_update_id(app: &App, id: u64, params: Value) -> Value { let request: SetNwkUpdateIdRequest = match serde_json::from_value(params) { Ok(request) => request, diff --git a/crates/ziggurat-server/Cargo.toml b/crates/ziggurat-server/Cargo.toml index f8fa9e2..28c03e4 100644 --- a/crates/ziggurat-server/Cargo.toml +++ b/crates/ziggurat-server/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] ziggurat-spinel.workspace = true +ziggurat-phy.workspace = true ziggurat-phy-spinel.workspace = true ziggurat-driver.workspace = true ziggurat-zigbee.workspace = true diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 96fe7d7..837fd0b 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -22,6 +22,7 @@ use ziggurat_driver::zigbee_stack::{ TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; use ziggurat_phy_spinel::SpinelPhy; use ziggurat_spinel::client::SpinelClient; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; @@ -40,6 +41,22 @@ const NOTIFICATION_HUB_DEPTH: usize = 1024; /// The radio transmit power (in dBm) used when `configure` does not specify one. const DEFAULT_TX_POWER: i8 = 8; +/// Radio programming for promiscuous capture: receive every frame on `channel`, no PAN/ +/// address filtering, no network required (dummy addresses). +const fn capture_config(channel: u8) -> RadioConfig { + RadioConfig { + channel, + tx_power: DEFAULT_TX_POWER, + short_address: Nwk(0xFFFF), + extended_address: Eui64([0; 8]), + pan_id: PanId(0xFFFF), + promiscuous: true, + rx_on_when_idle: true, + frame_pending_short: Vec::new(), + frame_pending_extended: Vec::new(), + } +} + /// Big-endian colon-separated hex, the format used by zigpy for EUI64 addresses fn eui64_to_string(eui64: Eui64) -> String { let mut bytes = eui64.to_bytes(); @@ -615,6 +632,12 @@ impl ZigguratServer { "set_provisional_key" => server.handle_set_provisional_key(id, params), "set_nwk_update_id" => server.handle_set_nwk_update_id(id, params), "set_channel" => server.handle_set_channel(id, params).await, + "packet_capture" => server.handle_packet_capture(id, params, &outbound).await, + "packet_capture_change_channel" => { + server + .handle_packet_capture_change_channel(id, params) + .await + } _ => error_response(id, "unknown_method", method), }; @@ -779,6 +802,70 @@ impl ZigguratServer { } } + /// Put the radio in promiscuous mode and stream every received frame as a + /// `captured_packet` event until the client disconnects. No network is required (it + /// reprograms the radio directly), so a running stack is disrupted for the session. + async fn handle_packet_capture( + &self, + id: u64, + params: serde_json::Value, + outbound: &mpsc::Sender, + ) -> serde_json::Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + if let Err(e) = phy.reconfigure(&capture_config(request.channel)).await { + return error_response(id, "packet_capture_failed", e); + } + + let mut rx = phy.subscribe_rx(); + while let Some(frame) = rx.recv().await { + let event = event_data( + id, + "captured_packet", + json!({ + "channel": frame.channel, + "rssi": frame.rssi, + "lqi": frame.lqi, + "data": hex::encode(frame.psdu), + }), + ); + if outbound.send(event).await.is_err() { + break; // client disconnected + } + } + + response(id, json!({"status": "complete"})) + } + + async fn handle_packet_capture_change_channel( + &self, + id: u64, + params: serde_json::Value, + ) -> serde_json::Value { + let request: SetChannelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + + match phy.reconfigure(&capture_config(request.channel)).await { + Ok(()) => response(id, json!({"status": "success"})), + Err(e) => error_response(id, "set_channel_failed", e), + } + } + /// Reads back the running network's settings, the counterpart of `configure`. /// While the stack runs, the server is the authoritative holder of the live state /// (e.g. frame counters), not the client that configured it. From fda843df4210eb92a7fbc3360070ee2cf2bd0b89 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:42:55 -0400 Subject: [PATCH 29/61] Reset request --- crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/Cargo.toml | 1 + crates/ziggurat-esp/src/api.rs | 95 +++++++++++++++++++++++------- crates/ziggurat-esp/src/main.rs | 7 +++ crates/ziggurat-phy-esp/src/lib.rs | 35 +++++++++-- crates/ziggurat-server/src/main.rs | 46 ++++++++++++++- 6 files changed, 156 insertions(+), 29 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 51ff100..44d84cf 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2230,6 +2230,7 @@ name = "ziggurat-esp" version = "0.1.0" dependencies = [ "embassy-executor", + "embassy-futures", "embassy-sync 0.8.0", "embassy-time", "embedded-io-async 0.7.0", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index 0e51334..5fe8329 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -34,6 +34,7 @@ esp-println = { version = "0.17.0", features = ["esp32c6", "log-04"] } embassy-executor = "0.10" embassy-time = "0.5" embassy-sync = "0.8" +embassy-futures = "0.1" embedded-io-async = "0.7" serde = { version = "1", default-features = false, features = ["alloc", "derive"] } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 0923e66..5d660d6 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -5,12 +5,16 @@ use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; +use alloc::sync::Arc; use alloc::vec::Vec; use core::time::Duration; +use embassy_futures::select::{Either, select}; use serde::Deserialize; use serde_json::{Value, json}; +use crate::CaptureStop; + use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ @@ -201,6 +205,21 @@ struct SetProvisionalKeyRequest { key: Key, } +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum ResetType { + /// Stop transient radio activity (packet capture) and return to idle. The configured + /// network, if any, keeps running. Sent by the client on connect as a session reset. + Soft, + /// Reboot the MCU. The serial link drops and the client reconnects. + Hard, +} + +#[derive(Deserialize)] +struct ResetRequest { + reset_type: ResetType, +} + /// Parse and dispatch one inbound line, emitting the `accepted` event and the response. pub async fn handle_line(app: &mut App, line: &[u8]) { let request: Request = match serde_json::from_slice(line) { @@ -216,6 +235,7 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { let Request { id, method, params } = request; let message = match method.as_str() { "ping" => response(id, json!({"status": "pong"})), + "reset" => handle_reset(app, id, params), "configure" => handle_configure(app, id, params).await, "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), @@ -241,6 +261,26 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { emit(message).await; } +/// Soft or hard reset. Both stop any in-progress packet capture (freeing the radio); a hard +/// reset additionally reboots the MCU. The configured network is left running on a soft +/// reset — it must survive client reconnects. +fn handle_reset(app: &mut App, id: u64, params: Value) -> Value { + let request: ResetRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + if let Some(stop) = app.capture_stop.take() { + stop.signal(()); + } + + if matches!(request.reset_type, ResetType::Hard) { + esp_hal::system::software_reset(); // diverges; the link drops and the client reconnects + } + + response(id, json!({"status": "success"})) +} + async fn handle_configure(app: &mut App, id: u64, params: Value) -> Value { let request: ConfigureRequest = match serde_json::from_value(params) { Ok(request) => request, @@ -457,13 +497,10 @@ async fn handle_energy_scan(app: &App, id: u64, params: Value) -> Value { Err(e) => return error_response(id, "invalid_request", e), }; - let Some(stack) = app.stack.as_ref() else { - return error_response(id, "not_configured", "no stack is running"); - }; - + // Energy detect is a radio operation, not a network one. let duration = Duration::from_millis(u64::from(request.duration_per_channel_ms)); for channel in request.channels { - match stack.energy_detect(channel, duration).await { + match app.phy.energy_detect(channel, duration).await { Ok(rssi) => { emit(event_data( id, @@ -577,9 +614,10 @@ const fn capture_config(channel: u8) -> RadioConfig { } /// Put the radio in promiscuous mode and stream every received frame as a `captured_packet` -/// event. Runs without a configured stack (only `connect()` is needed). The capture task -/// streams until the client disconnects; there is no terminal response. -async fn handle_packet_capture(app: &App, id: u64, params: Value) { +/// event. No configured stack needed (only `connect()`). Singleton: a second call just +/// retunes. Stopped by `reset` (the client sends a soft reset on connect, clearing any stale +/// capture). There is no terminal response. +async fn handle_packet_capture(app: &mut App, id: u64, params: Value) { let request: SetChannelRequest = match serde_json::from_value(params) { Ok(request) => request, Err(e) => { @@ -593,21 +631,38 @@ async fn handle_packet_capture(app: &App, id: u64, params: Value) { return; } + // Already capturing: the reconfigure above retuned it; don't spawn a second task. + if app.capture_stop.is_some() { + return; + } + + let stop = Arc::new(CaptureStop::new()); + app.capture_stop = Some(stop.clone()); + let phy = app.phy.clone(); app.spawner.spawn(Box::pin(async move { let mut rx = phy.subscribe_rx(); - while let Some(frame) = rx.recv().await { - emit(event_data( - id, - "captured_packet", - json!({ - "channel": frame.channel, - "rssi": frame.rssi, - "lqi": frame.lqi, - "data": hex::encode(&frame.psdu), - }), - )) - .await; + loop { + match select(rx.recv(), stop.wait()).await { + Either::First(Some(frame)) => { + if let Ok(line) = serde_json::to_string(&event_data( + id, + "captured_packet", + json!({ + "channel": frame.channel, + "rssi": frame.rssi, + "lqi": frame.lqi, + "data": hex::encode(&frame.psdu), + }), + )) { + // Drop on a full queue: a sniffer must not block (or back up stale + // frames for a reconnecting client). + let _ = OUTBOUND.try_send(line); + } + } + // RX stream closed or `reset` signalled the stop. + _ => break, + } } })); } diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 8993c2f..9773bca 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -42,11 +42,17 @@ const OUTBOUND_DEPTH: usize = 16; pub static OUTBOUND: Channel = Channel::new(); +/// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` +/// signals it so the task exits and frees the radio. +pub type CaptureStop = embassy_sync::signal::Signal; + /// The firmware's mutable state, owned by (and only touched from) the reader loop. pub struct App { pub phy: Arc, pub spawner: EmbassySpawner, pub stack: Option>>, + /// `Some` while a packet capture is streaming; signalling it stops the capture. + pub capture_stop: Option>, } /// Drain the radio's received frames; the stack reads them off the shared RX channel. @@ -116,6 +122,7 @@ async fn main(spawner: Spawner) -> ! { phy, spawner: EmbassySpawner::new(spawner.make_send()), stack: None, + capture_stop: None, }; api::emit(api::hello_message(false)).await; diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index e27c04d..5d24818 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -49,18 +49,20 @@ static TX_FAILED: Signal = Signal::new(); mod regs { const BASE: usize = 0x600A_3000; pub const CMD: usize = BASE + 0x00; + pub const CHANNEL: usize = BASE + 0x48; pub const ED_DURATION: usize = BASE + 0x50; pub const ED_CFG: usize = BASE + 0x54; pub const EVENT_STATUS: usize = BASE + 0x64; pub const PTI: usize = BASE + 0x70; + pub const CMD_RX_START: u32 = 0x42; pub const CMD_ED_START: u32 = 0x44; pub const CMD_STOP: u32 = 0x45; pub const EVENT_ED_DONE: u32 = 1 << 6; pub const ALL_EVENTS: u32 = 0x1FFF; /// `ed_cfg.ed_sample_mode`: 0 = report the peak (max) sample, 1 = average. pub const ED_SAMPLE_MODE: u32 = 1 << 13; - /// `pti.pti` (bits 0..3) and `pti.hw_ack_pti` (bits 4..7), both set to 1 — ESP-IDF's + /// `pti.pti` (bits 0..3) and `pti.hw_ack_pti` (bits 4..7), both set to 1 - ESP-IDF's /// `ieee802154_ll_disable_coex`: the radio always wins arbitration, so a non-existent /// coex partner can't gate (and starve) RX/TX/ED. pub const COEX_DISABLE: u32 = 0x11; @@ -205,6 +207,22 @@ const fn esp_err(_e: esp_radio::ieee802154::Error) -> &'static str { "esp-radio transmit error" } +/// Force the receiver onto `channel`. esp-radio's `set_config` only updates the deferred +/// PIB (applied later by `pib_update` in rx/tx init), and `start_receive` no-ops while +/// already receiving - so a channel change otherwise never reaches a running receiver, which +/// keeps hearing the old channel. Write the frequency register directly (same mapping as +/// esp-radio's `channel_to_freq`), then stop + restart RX so the radio re-reads it. The RX +/// buffer a prior `start_receive` set up persists across this. +fn retune_rx(channel: u8) { + let freq = u32::from((channel - 11) * 5 + 3); + unsafe { + let chan = regs::read(regs::CHANNEL); + regs::write(regs::CHANNEL, (chan & !0x7F) | freq); + regs::write(regs::CMD, regs::CMD_STOP); + regs::write(regs::CMD, regs::CMD_RX_START); + } +} + pub struct EspRx(ChannelReceiver<'static, CriticalSectionRawMutex, RxFrame, RX_DEPTH>); impl Receiver for EspRx { @@ -234,10 +252,10 @@ impl ExclusiveRadio for EspExclusive<'_> { state.config.channel = channel; let config = state.config; state.radio.set_config(config); - // A channel change only reaches the running receiver when RX is (re)started, so - // re-arm it; otherwise the radio keeps receiving on the previous channel (e.g. a - // network scan would only ever hear the home channel). + // start_receive sets up the RX buffer the first time, but esp-radio no-ops it while + // already receiving, so it won't retune a running receiver. Force the retune below. state.radio.start_receive(); + retune_rx(channel); Ok(()) } @@ -262,10 +280,15 @@ impl RadioPhy for EspPhy { async fn reconfigure(&self, config: &RadioConfig) -> Result<(), RadioError> { let mut state = self.state.lock().await; + let channel = config.channel; state.config = esp_config(config); - let config = state.config; - state.radio.set_config(config); + + let esp = state.config; + state.radio.set_config(esp); state.radio.start_receive(); + + retune_rx(channel); + Ok(()) } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 837fd0b..985d42a 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -244,6 +244,20 @@ struct SetNwkUpdateIdRequest { nwk_update_id: u8, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +enum ResetType { + /// Return to idle, leaving any configured network running. + Soft, + /// Reset the radio (RCP). + Hard, +} + +#[derive(Deserialize, Debug)] +struct ResetRequest { + reset_type: ResetType, +} + fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json::Value { match notification_event { ZigbeeNotification::ReceivedApsCommand { @@ -622,6 +636,7 @@ impl ZigguratServer { let message = match method.as_str() { "ping" => server.handle_ping(id).await, + "reset" => server.handle_reset(id, params).await, "configure" => server.handle_configure(id, params).await, "get_hw_address" => server.handle_get_hw_address(id).await, "get_network_info" => server.handle_get_network_info(id), @@ -655,6 +670,28 @@ impl ZigguratServer { response(id, json!({"status": "pong"})) } + /// Soft or hard reset. A soft reset is a no-op success on the host (no transient + /// radio state outlives a connection here), kept for wire parity with the firmware. A + /// hard reset resets the radio (RCP); the stack's recovery task reprograms it. + async fn handle_reset(&self, id: u64, params: serde_json::Value) -> serde_json::Value { + let request: ResetRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + if matches!(request.reset_type, ResetType::Hard) { + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), + }; + if let Err(e) = phy.reset().await { + return error_response(id, "reset_failed", e); + } + } + + response(id, json!({"status": "success"})) + } + /// (Re)initializes the Zigbee stack. The stack deliberately outlives client /// connections; reconfiguring replaces it wholesale. #[allow(clippy::significant_drop_tightening)] @@ -1033,15 +1070,18 @@ impl ZigguratServer { Err(e) => return error_response(id, "invalid_request", e), }; - let Some(stack) = self.current_stack() else { - return error_response(id, "not_configured", "no stack is running"); + // An energy detect is a radio operation, not a network one: it drives the radio + // directly and needs no configured stack. + let phy = match self.phy() { + Ok(p) => p, + Err(e) => return error_response(id, "serial_port_error", e), }; // 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)); for channel in request.channels { - match stack.energy_detect(channel, duration).await { + match phy.energy_detect(channel, duration).await { Ok(rssi) => { let _ = outbound .send(event_data( From 32e2144764b65a32361361f1b5306f070763bd8a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:58:27 -0400 Subject: [PATCH 30/61] Mostly hardware accelerated AES for the ESP32-C6 --- Cargo.lock | 1 + crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/src/hw_crypto.rs | 46 +++++++ crates/ziggurat-esp/src/main.rs | 5 + crates/ziggurat-zigbee/Cargo.toml | 1 + crates/ziggurat-zigbee/src/crypto.rs | 176 +++++++++++++++++++++++++-- 6 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 crates/ziggurat-esp/src/hw_crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 4260c30..c30c5a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2075,6 +2075,7 @@ dependencies = [ "hex", "hex-literal", "num_enum", + "once_cell", "serde", "subtle", "thiserror", diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 44d84cf..1ccf23a 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2296,6 +2296,7 @@ dependencies = [ "educe", "hex", "num_enum", + "once_cell", "serde", "subtle", "thiserror", diff --git a/crates/ziggurat-esp/src/hw_crypto.rs b/crates/ziggurat-esp/src/hw_crypto.rs new file mode 100644 index 0000000..f714b4f --- /dev/null +++ b/crates/ziggurat-esp/src/hw_crypto.rs @@ -0,0 +1,46 @@ +//! Hardware-accelerated crypto backend: routes the stack's AES-128 block primitive to +//! the ESP32-C6 AES peripheral via `esp_hal::aes`. CCM* and AES-MMO both ride this +//! block, so all Zigbee crypto runs on the accelerator instead of software AES on the +//! RISC-V core. + +use core::cell::RefCell; + +use embassy_sync::blocking_mutex::Mutex; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use esp_hal::aes::Aes; +use esp_hal::peripherals::AES; +use esp_println::println; + +use ziggurat_zigbee::crypto::{self, CryptoBackend}; + +/// The AES peripheral is a singleton shared by every task that does crypto, so it lives +/// behind a critical-section mutex. The peripheral re-loads the key on each block. +static HW_AES: Mutex>>> = + Mutex::new(RefCell::new(None)); + +/// The C6 AES accelerator has no CCM mode (only block modes), and `esp_hal` exposes +/// just single-block ECB, so this backend implements only the block primitive and +/// inherits the software CCM* default. +struct EspCrypto; + +impl CryptoBackend for EspCrypto { + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]) { + HW_AES.lock(|cell| { + let mut guard = cell.borrow_mut(); + let aes = guard.as_mut().expect("hw_crypto::init was never called"); + aes.encrypt(block, *key); + }); + } +} + +static BACKEND: EspCrypto = EspCrypto; + +/// Claim the AES peripheral and install the hardware crypto backend. Call once during +/// startup, before the stack processes any frames. +pub fn init(aes: AES<'static>) { + let hw = Aes::new(aes); + + HW_AES.lock(|cell| *cell.borrow_mut() = Some(hw)); + crypto::install(&BACKEND); + println!("hw_crypto: AES hardware acceleration enabled"); +} diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 9773bca..ee9b9c7 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -9,6 +9,7 @@ extern crate alloc; mod api; +mod hw_crypto; use alloc::boxed::Box; use alloc::sync::Arc; @@ -99,6 +100,10 @@ async fn main(spawner: Spawner) -> ! { esp_alloc::heap_allocator!(size: 96 * 1024); + // Route all AES (CCM* and AES-MMO) through the hardware accelerator instead of + // software AES on the RISC-V core. Must happen before the stack processes any frames. + hw_crypto::init(peripherals.AES); + // Install the randomness source the stack pulls jitter, addresses, and keys from. The // SoC RNG is true-random once the radio subsystem is up (it is, below). rng::install(Box::new(|buf: &mut [u8]| { diff --git a/crates/ziggurat-zigbee/Cargo.toml b/crates/ziggurat-zigbee/Cargo.toml index 0cd028d..ec70d76 100644 --- a/crates/ziggurat-zigbee/Cargo.toml +++ b/crates/ziggurat-zigbee/Cargo.toml @@ -19,6 +19,7 @@ educe = { version = "0.6.0", default-features = false, features = ["Debug"] } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } tracing = { version = "0.1", default-features = false } num_enum = { version = "0.7.3", default-features = false } +once_cell = { version = "1", default-features = false, features = ["race", "alloc"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } subtle = { version = "2", default-features = false } thiserror = { version = "2.0.12", default-features = false } diff --git a/crates/ziggurat-zigbee/src/crypto.rs b/crates/ziggurat-zigbee/src/crypto.rs index 425324e..917829d 100644 --- a/crates/ziggurat-zigbee/src/crypto.rs +++ b/crates/ziggurat-zigbee/src/crypto.rs @@ -1,16 +1,124 @@ use aes::Aes128; -use aes::Block; -use aes::cipher::BlockCipherEncrypt; -use aes::cipher::KeyInit; +use aes::cipher::array::Array; +use aes::cipher::consts::{U1, U16}; +use aes::cipher::{ + Block, BlockCipherEncBackend, BlockCipherEncClosure, BlockCipherEncrypt, BlockSizeUser, InOut, + KeyInit, KeySizeUser, ParBlocksSizeUser, +}; +use alloc::boxed::Box; use alloc::vec::Vec; use ccm::Ccm; use ccm::aead::AeadInOut; use ccm::consts::{U4, U13}; +use once_cell::race::OnceBox; use thiserror::Error; use ziggurat_ieee_802154::FrameBytes; use ziggurat_ieee_802154::types::{Eui64, Key}; +static SOFTWARE_BACKEND: SoftwareBackend = SoftwareBackend; + +/// The installed backend, or unset for the software default. +static BACKEND: OnceBox<&'static dyn CryptoBackend> = OnceBox::new(); + +/// Install the platform crypto backend. Call once during startup, before any frames are +/// processed. The host leaves this unset and runs everything in software. +pub fn install(backend: &'static dyn CryptoBackend) { + let _ = BACKEND.set(Box::new(backend)); +} + +fn backend() -> &'static dyn CryptoBackend { + BACKEND.get().copied().unwrap_or(&SOFTWARE_BACKEND) +} + +/// A complete platform crypto backend. The host and tests use [`SoftwareBackend`]; MCU +/// targets [`install`] one backed by their hardware. +/// +/// Implementors must provide the AES-128 block primitive. CCM* defaults to a software +/// implementation built on that block, so a backend whose accelerator offers only block +/// modes (like the ESP32-C6, whose AES accelerator has no CCM mode) gets hardware-backed +/// CCM* for free; an SoC with a dedicated CCM* engine overrides the two methods. +pub trait CryptoBackend: Sync { + /// AES-128 ECB, one block, encrypted in place. + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]); + + /// CCM*-protect a payload in place: `auth_data` is authenticated, the buffer is + /// encrypted, and the encrypted MIC ("MAC tag") is appended to it. + fn encrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, + ) -> FrameBytes { + software_encrypt_ccm(key, nonce, auth_data, buffer) + } + + /// Reverse of [`encrypt_ccm`](CryptoBackend::encrypt_ccm): verify the MIC and + /// decrypt in place, returning the buffer truncated to the plaintext. + fn decrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, + ) -> Result { + software_decrypt_ccm(key, nonce, auth_data, tagged_ciphertext) + } +} + +/// Pure-software backend (RustCrypto `aes`/`ccm`), used until an MCU installs its own. +pub struct SoftwareBackend; + +impl CryptoBackend for SoftwareBackend { + fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]) { + let cipher = Aes128::new(&(*key).into()); + let mut buffer: Array = (*block).into(); + cipher.encrypt_block(&mut buffer); + *block = buffer.into(); + } +} + +/// A RustCrypto block cipher that delegates each AES-128 block to the installed +/// [`CryptoBackend`], so the software `ccm` implementation transparently uses hardware +/// AES when a backend is installed. +#[derive(Clone)] +struct BackendAes { + key: [u8; 16], +} + +impl KeySizeUser for BackendAes { + type KeySize = U16; +} + +impl KeyInit for BackendAes { + fn new(key: &aes::cipher::Key) -> Self { + Self { key: (*key).into() } + } +} + +impl BlockSizeUser for BackendAes { + type BlockSize = U16; +} + +impl ParBlocksSizeUser for BackendAes { + type ParBlocksSize = U1; +} + +impl BlockCipherEncBackend for BackendAes { + fn encrypt_block(&self, mut block: InOut<'_, '_, Block>) { + let mut buffer: [u8; 16] = (*block.get_in()).into(); + backend().aes128_encrypt_block(&self.key, &mut buffer); + *block.get_out() = buffer.into(); + } +} + +impl BlockCipherEncrypt for BackendAes { + fn encrypt_with_backend(&self, f: impl BlockCipherEncClosure) { + f.call(self); + } +} + /// AES-MMO (Matyas-Meyer-Oseas) cryptographic hash, Zigbee spec B.1.3/B.4. Only the /// short-message padding scheme is implemented (inputs below 2^16 bits). pub fn aes_mmo_hash(data: &[u8]) -> [u8; 16] { @@ -23,14 +131,15 @@ pub fn aes_mmo_hash(data: &[u8]) -> [u8; 16] { } padded.extend(((data.len() * 8) as u16).to_be_bytes()); + let backend = backend(); let mut digest = [0u8; 16]; for chunk in padded.chunks_exact(16) { - let cipher = Aes128::new(&digest.into()); - let block = Block::try_from(chunk).expect("16-byte chunk is always valid"); - - let mut encrypted = Block::default(); - cipher.encrypt_block_b2b(&block, &mut encrypted); + // MMO: encrypt the message block under the running digest as the key, then XOR the + // ciphertext with the plaintext block. + let block: [u8; 16] = chunk.try_into().expect("16-byte chunk is always valid"); + let mut encrypted = block; + backend.aes128_encrypt_block(&digest, &mut encrypted); for (digest_byte, (encrypted_byte, block_byte)) in digest.iter_mut().zip(encrypted.iter().zip(block.iter())) @@ -98,8 +207,9 @@ pub fn ezsp_tclk(seed: &Key, eui64: Eui64) -> Key { /// Zigbee CCM* at security level 5 (spec annex A): AES-128 CCM with a 4-byte MIC and /// a 13-byte nonce. CCM* only differs from standard CCM at the unencrypted security -/// levels, which are never used on the air. -type ZigbeeCcm = Ccm; +/// levels, which are never used on the air. The underlying AES blocks route through the +/// installed [`CryptoBackend`], so this picks up hardware AES automatically. +type ZigbeeCcm = Ccm; pub const MIC_LENGTH: usize = 4; @@ -114,6 +224,15 @@ pub enum DecryptionError { /// CCM*-protect a payload in place: `auth_data` is authenticated, the buffer is /// encrypted, and the encrypted MIC ("MAC tag") is appended to it. pub fn encrypt_ccm( + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, +) -> FrameBytes { + backend().encrypt_ccm(key, nonce, auth_data, buffer) +} + +fn software_encrypt_ccm( key: &Key, nonce: &[u8; 13], auth_data: &[u8], @@ -131,6 +250,15 @@ pub fn encrypt_ccm( /// Reverse of [`encrypt_ccm`]: verify the MIC and decrypt in place, returning the /// buffer truncated to the plaintext. pub fn decrypt_ccm( + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, +) -> Result { + backend().decrypt_ccm(key, nonce, auth_data, tagged_ciphertext) +} + +fn software_decrypt_ccm( key: &Key, nonce: &[u8; 13], auth_data: &[u8], @@ -204,6 +332,34 @@ mod test { ); } + /// CCM* round-trips through the backend-delegating block cipher and rejects tampering. + #[test] + fn test_ccm_round_trip() { + let key = Key::from_hex("0011223344556677889900aabbccddee"); + let nonce = hex!("000102030405060708090a0b0c"); + let aad = hex!("aabbccdd"); + let plaintext = hex!("decafbad01020304"); + + let ciphertext = encrypt_ccm( + &key, + &nonce, + &aad, + FrameBytes::from_slice(&plaintext).unwrap(), + ); + assert_eq!(ciphertext.len(), plaintext.len() + MIC_LENGTH); + assert_ne!(&ciphertext.as_slice()[..plaintext.len()], &plaintext[..]); + + let decrypted = decrypt_ccm(&key, &nonce, &aad, ciphertext.clone()).unwrap(); + assert_eq!(decrypted.as_slice(), &plaintext[..]); + + let mut tampered = ciphertext; + tampered.as_mut_slice()[0] ^= 0xff; + assert_eq!( + decrypt_ccm(&key, &nonce, &aad, tampered), + Err(DecryptionError::InvalidMacTag) + ); + } + /// Cross-validated against zigpy's `aes_mmo_hash` #[test] fn test_ezsp_tclk() { From 4b470145377025485a29452a33f6b89e0ada1629 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:12:03 -0400 Subject: [PATCH 31/61] Much faster hardware accelerated AES Clean up comment --- crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/Cargo.toml | 1 + crates/ziggurat-esp/src/hw_crypto.rs | 203 ++++++++++++++++++++++++--- crates/ziggurat-esp/src/main.rs | 7 +- 4 files changed, 187 insertions(+), 25 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 1ccf23a..5d9e988 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2244,6 +2244,7 @@ dependencies = [ "hex", "serde", "serde_json", + "subtle", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index 5fe8329..2ec2aba 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -40,6 +40,7 @@ embedded-io-async = "0.7" serde = { version = "1", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } hex = { version = "0.4", default-features = false, features = ["alloc"] } +subtle = { version = "2", default-features = false } [profile.release] opt-level = "s" diff --git a/crates/ziggurat-esp/src/hw_crypto.rs b/crates/ziggurat-esp/src/hw_crypto.rs index f714b4f..3c2b586 100644 --- a/crates/ziggurat-esp/src/hw_crypto.rs +++ b/crates/ziggurat-esp/src/hw_crypto.rs @@ -1,46 +1,205 @@ -//! Hardware-accelerated crypto backend: routes the stack's AES-128 block primitive to -//! the ESP32-C6 AES peripheral via `esp_hal::aes`. CCM* and AES-MMO both ride this -//! block, so all Zigbee crypto runs on the accelerator instead of software AES on the -//! RISC-V core. +//! Hardware crypto backend for the ESP32-C6. use core::cell::RefCell; use embassy_sync::blocking_mutex::Mutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use esp_hal::aes::Aes; +use esp_hal::aes::cipher_modes::{Cbc, Ctr}; +use esp_hal::aes::dma::{AesDma, DmaCipherState}; +use esp_hal::aes::{Aes, Operation}; +use esp_hal::dma::{DmaChannelFor, DmaRxBuf, DmaTxBuf}; +use esp_hal::dma_buffers; use esp_hal::peripherals::AES; -use esp_println::println; +use subtle::ConstantTimeEq; -use ziggurat_zigbee::crypto::{self, CryptoBackend}; +use ziggurat_ieee_802154::FrameBytes; +use ziggurat_ieee_802154::types::Key; +use ziggurat_zigbee::crypto::{self, CryptoBackend, DecryptionError, MIC_LENGTH}; -/// The AES peripheral is a singleton shared by every task that does crypto, so it lives -/// behind a critical-section mutex. The peripheral re-loads the key on each block. -static HW_AES: Mutex>>> = +/// Scratch/DMA buffer size. +const DMA_BUF_SIZE: usize = 256; + +/// The AES peripheral in DMA mode plus its two DMA buffers. `AesDma::process` consumes +/// the driver + buffers and hands them back from the transfer, so they live in +/// `Option`s and are taken/replaced around each pass. Behind a critical-section mutex: +/// the peripheral is shared by every task that does crypto. +struct DmaState { + aes: Option>, + rx: Option, + tx: Option, +} + +static HW: Mutex>> = Mutex::new(RefCell::new(None)); -/// The C6 AES accelerator has no CCM mode (only block modes), and `esp_hal` exposes -/// just single-block ECB, so this backend implements only the block primitive and -/// inherits the software CCM* default. struct EspCrypto; impl CryptoBackend for EspCrypto { fn aes128_encrypt_block(&self, key: &[u8; 16], block: &mut [u8; 16]) { - HW_AES.lock(|cell| { + HW.lock(|cell| { let mut guard = cell.borrow_mut(); - let aes = guard.as_mut().expect("hw_crypto::init was never called"); - aes.encrypt(block, *key); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + // Single ECB block on the Typical path (the inner non-DMA driver). + dma.aes.as_mut().unwrap().aes.encrypt(block, *key); }); } + + fn encrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + buffer: FrameBytes, + ) -> FrameBytes { + let plen = buffer.len(); + + let mut cbc_in = [0u8; DMA_BUF_SIZE]; + let cbc_len = format_cbc_mac_input(&mut cbc_in, nonce, auth_data, buffer.as_slice()); + + let mut ctr_in = [0u8; DMA_BUF_SIZE]; + ctr_in[16..16 + plen].copy_from_slice(buffer.as_slice()); + let ctr_len = round_up_16(16 + plen); + + let mut cbc_out = [0u8; DMA_BUF_SIZE]; + let mut ctr_out = [0u8; DMA_BUF_SIZE]; + let cbc_state: DmaCipherState = Cbc::new([0u8; 16]).into(); + let ctr_state: DmaCipherState = Ctr::new(ctr_block(nonce, 0)).into(); + HW.lock(|cell| { + let mut guard = cell.borrow_mut(); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + run(dma, &cbc_state, &key.0, &cbc_in[..cbc_len], &mut cbc_out[..cbc_len]); + run(dma, &ctr_state, &key.0, &ctr_in[..ctr_len], &mut ctr_out[..ctr_len]); + }); + + let tag = &cbc_out[cbc_len - 16..cbc_len]; + let s0 = &ctr_out[0..16]; + + let mut out = FrameBytes::new(); + out.extend_from_slice(&ctr_out[16..16 + plen]) + .expect("ciphertext fits a frame"); + for i in 0..MIC_LENGTH { + out.push(tag[i] ^ s0[i]).expect("frame has room for the MIC"); + } + out + } + + fn decrypt_ccm( + &self, + key: &Key, + nonce: &[u8; 13], + auth_data: &[u8], + tagged_ciphertext: FrameBytes, + ) -> Result { + let clen = tagged_ciphertext + .len() + .checked_sub(MIC_LENGTH) + .ok_or(DecryptionError::CiphertextTooShort)?; + let (ciphertext, recv_mic) = tagged_ciphertext.as_slice().split_at(clen); + + let mut ctr_in = [0u8; DMA_BUF_SIZE]; + ctr_in[16..16 + clen].copy_from_slice(ciphertext); + let ctr_len = round_up_16(16 + clen); + + let mut ctr_out = [0u8; DMA_BUF_SIZE]; + let ctr_state: DmaCipherState = Ctr::new(ctr_block(nonce, 0)).into(); + // Recover the plaintext (and S0) first, then MAC the recovered plaintext. + let mut cbc_out = [0u8; DMA_BUF_SIZE]; + let cbc_state: DmaCipherState = Cbc::new([0u8; 16]).into(); + let mut cbc_in = [0u8; DMA_BUF_SIZE]; + let cbc_len = HW.lock(|cell| { + let mut guard = cell.borrow_mut(); + let dma = guard.as_mut().expect("hw_crypto::init was never called"); + run(dma, &ctr_state, &key.0, &ctr_in[..ctr_len], &mut ctr_out[..ctr_len]); + + let cbc_len = format_cbc_mac_input(&mut cbc_in, nonce, auth_data, &ctr_out[16..16 + clen]); + run(dma, &cbc_state, &key.0, &cbc_in[..cbc_len], &mut cbc_out[..cbc_len]); + cbc_len + }); + + let tag = &cbc_out[cbc_len - 16..cbc_len]; + let s0 = &ctr_out[0..16]; + + let expected_mic: [u8; MIC_LENGTH] = core::array::from_fn(|i| tag[i] ^ s0[i]); + if !bool::from(expected_mic[..].ct_eq(recv_mic)) { + return Err(DecryptionError::InvalidMacTag); + } + + Ok(FrameBytes::from_slice(&ctr_out[16..16 + clen]).expect("plaintext fits a frame")) + } } static BACKEND: EspCrypto = EspCrypto; -/// Claim the AES peripheral and install the hardware crypto backend. Call once during -/// startup, before the stack processes any frames. -pub fn init(aes: AES<'static>) { - let hw = Aes::new(aes); +/// One DMA-AES pass over `input` (length a multiple of 16), copying the result into +/// `out`. +fn run(dma: &mut DmaState, state: &DmaCipherState, key: &[u8; 16], input: &[u8], out: &mut [u8]) { + let blocks = input.len() / 16; + let mut tx = dma.tx.take().unwrap(); + let mut rx = dma.rx.take().unwrap(); + let aes = dma.aes.take().unwrap(); + + tx.fill(input); + rx.set_length(input.len()); + + let Ok(transfer) = aes.process(blocks, rx, tx, Operation::Encrypt, state, *key) else { + panic!("AES DMA transfer setup failed"); + }; + let (aes, rx, tx) = transfer.wait(); + out.copy_from_slice(&rx.as_slice()[..input.len()]); + + dma.aes = Some(aes); + dma.rx = Some(rx); + dma.tx = Some(tx); +} + +fn round_up_16(n: usize) -> usize { + n.div_ceil(16) * 16 +} + +/// CCM* counter block `A_i`: flags = L-1 = 1, then the nonce, then the 2-byte counter. +fn ctr_block(nonce: &[u8; 13], counter: u16) -> [u8; 16] { + let mut block = [0u8; 16]; + block[0] = 1; + block[1..14].copy_from_slice(nonce); + block[14..16].copy_from_slice(&counter.to_be_bytes()); + block +} + +/// Builds the CBC-MAC input `B0 || AAD-blocks || payload-blocks` into `out` (which must +/// be zeroed, so the padding is implicit) and returns its length (a multiple of 16). +/// AAD is assumed to be shorter than `0xFF00`, which always holds for Zigbee frames. +fn format_cbc_mac_input(out: &mut [u8], nonce: &[u8; 13], aad: &[u8], payload: &[u8]) -> usize { + // B0: flags, nonce, message length. + let adata = u8::from(!aad.is_empty()); + out[0] = (adata << 6) | (1 << 3) | 1; // Adata | (M-2)/2=1 | L-1=1 + out[1..14].copy_from_slice(nonce); + out[14..16].copy_from_slice(&(payload.len() as u16).to_be_bytes()); + + let mut pos = 16; + if !aad.is_empty() { + out[pos..pos + 2].copy_from_slice(&(aad.len() as u16).to_be_bytes()); + out[pos + 2..pos + 2 + aad.len()].copy_from_slice(aad); + pos = round_up_16(pos + 2 + aad.len()); + } + out[pos..pos + payload.len()].copy_from_slice(payload); + round_up_16(pos + payload.len()) +} + +/// Claim the AES peripheral + a DMA channel and install the hardware crypto backend. Call +/// once during startup, before the stack processes frames. +pub fn init(aes: AES<'static>, dma: impl DmaChannelFor>) { + let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = dma_buffers!(DMA_BUF_SIZE); + let rx = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap(); + let tx = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap(); + let aes_dma = Aes::new(aes).with_dma(dma); + + HW.lock(|cell| { + *cell.borrow_mut() = Some(DmaState { + aes: Some(aes_dma), + rx: Some(rx), + tx: Some(tx), + }); + }); - HW_AES.lock(|cell| *cell.borrow_mut() = Some(hw)); crypto::install(&BACKEND); - println!("hw_crypto: AES hardware acceleration enabled"); } diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index ee9b9c7..c1737d9 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -100,9 +100,10 @@ async fn main(spawner: Spawner) -> ! { esp_alloc::heap_allocator!(size: 96 * 1024); - // Route all AES (CCM* and AES-MMO) through the hardware accelerator instead of - // software AES on the RISC-V core. Must happen before the stack processes any frames. - hw_crypto::init(peripherals.AES); + // Route Zigbee crypto through the AES accelerator: CCM* runs as two DMA passes + // (CBC-MAC + CTR) and AES-MMO rides the single-block path. Must happen before the + // stack processes any frames. + hw_crypto::init(peripherals.AES, peripherals.DMA_CH0); // Install the randomness source the stack pulls jitter, addresses, and keys from. The // SoC RNG is true-random once the radio subsystem is up (it is, below). From 1dec8519d778043d759ec947be8c2ac39dc9c039 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:12:35 -0400 Subject: [PATCH 32/61] Boost the CPU from 80MHz to 160MHz --- crates/ziggurat-esp/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index c1737d9..72e9eb8 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -76,7 +76,8 @@ async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { - let peripherals = esp_hal::init(esp_hal::Config::default()); + let peripherals = + esp_hal::init(esp_hal::Config::default().with_cpu_clock(esp_hal::clock::CpuClock::max())); // XIAO ESP32-C6 antenna RF switch: GPIO3 low powers the switch, then (after it // settles) GPIO14 low selects the onboard ceramic antenna. Without this the board From 5ca4fc23824b2007c530562c7cf67d7fd1acafc5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:27:39 -0400 Subject: [PATCH 33/61] Gracefully handle no-ACK frames that error due to no ACK (why?) --- crates/ziggurat-driver/src/zigbee_stack/mac.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 99b7167..0c1d2d8 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -382,9 +382,14 @@ impl ZigbeeStack { match result { TxResult::Acked => Ok(()), - TxResult::NoAck => Err(ZigbeeStackError::NwkNoAck { - next_hop: final_frame.header().dest_address.unwrap(), - }), + // A frame with no destination (e.g. a beacon) never requested an ACK, so + // "no ACK" is the expected outcome, not a failure. + TxResult::NoAck => final_frame + .header() + .dest_address + .map_or(Ok(()), |next_hop| { + Err(ZigbeeStackError::NwkNoAck { next_hop }) + }), TxResult::ChannelAccessFailure => Err(ZigbeeStackError::CcaFailure), other => Err(ZigbeeStackError::TransmitFailed(other)), } From d44b78a70c63b0be3cbe98e5fd450f904f28685b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:28:34 -0400 Subject: [PATCH 34/61] Hack: beacon spray during permit window --- crates/ziggurat-driver/src/zigbee_stack.rs | 24 ++++++++++++ .../ziggurat-driver/src/zigbee_stack/mac.rs | 39 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index d1934b7..b5fad3d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -426,6 +426,10 @@ pub struct ZigbeeCore { /// join completes while the coordinator's own beacon stays closed. Rejoins are /// never gated by this. pub trust_center_joins_until: Option, + + /// While set and in the future, the beacon-spam reactor sends a beacon every 5 ms + /// (the `hack_beacon_spam_duration` hack). Each beacon request extends it. + pub beacon_spam_until: Option, } /// Guard over the protocol [`ZigbeeCore`], obtained from [`ZigbeeStack::core`]. It encodes @@ -477,6 +481,10 @@ pub struct State { /// Instead of caching route information, always perform route discovery. This is /// much slower but ensures that routing logic is always followed. pub hack_force_route_discovery: bool, + /// While permitting joins, spray a beacon every 5 ms for this long after each beacon + /// request, to out-compete neighbouring networks that permanently permit joins. Zero + /// disables it (a single beacon per request, the spec behaviour). + pub hack_beacon_spam_duration: Duration, pub role: NwkDeviceType, pub capability_information: NwkCapabilityInformation, @@ -555,6 +563,7 @@ impl State { }, permitting_joins_until: None, trust_center_joins_until: None, + beacon_spam_until: None, }), pending_aps_acks: Mutex::new(BTreeMap::new()), pending_routes: Mutex::new(BTreeMap::new()), @@ -565,6 +574,7 @@ impl State { hack_ignore_broadcast_startup_wait_period: true, hack_disable_tx: false, hack_force_route_discovery: false, + hack_beacon_spam_duration: Duration::from_millis(100), role: config.role, capability_information: NwkCapabilityInformation { @@ -720,6 +730,8 @@ pub struct ZigbeeStack /// 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 beacon-spam reactor when a beacon request opens its spray window. + pub(crate) beacon_spam_wake: Notify, /// Wakes the maintenance task when a new indirect transaction or child entry /// could move the earliest expiry deadline closer pub(crate) maintenance_wake: Notify, @@ -817,6 +829,7 @@ impl ZigbeeStack { mtorr_kick: Notify::new(), link_status_received: Notify::new(), broadcast_retransmit_wake: Notify::new(), + beacon_spam_wake: Notify::new(), maintenance_wake: Notify::new(), send_queue: Mutex::new(BinaryHeap::new()), send_wake: Notify::new(), @@ -1039,6 +1052,17 @@ impl ZigbeeStack { arc_self.broadcast_retransmit_task().await; }); + // Sprays beacons while a beacon-spam window is open (the hack_beacon_spam_duration + // hack). Idle unless beacon requests open the window. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.beacon_spam_task().await; + }); + // To kick things off, send a link status broadcast. Silicon Labs routers will // "respond" to empty link status broadcasts proactively, independent of the // link status period diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 0c1d2d8..85acd70 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -1,3 +1,5 @@ +use core::time::Duration; + use crate::runtime::Runtime; use crate::ziggurat_ieee_802154::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154CommandFrame, Ieee802154DataFrame, @@ -19,6 +21,11 @@ use super::{ ZigbeeStackError, }; +/// Spacing between sprayed beacons while a [`hack_beacon_spam_duration`] window is open. +/// +/// [`hack_beacon_spam_duration`]: super::State::hack_beacon_spam_duration +const BEACON_SPAM_INTERVAL: Duration = Duration::from_millis(5); + impl ZigbeeStack { pub fn process_802154_command_frame(&self, command_frame: &Ieee802154CommandFrame) { tracing::debug!( @@ -28,7 +35,16 @@ impl ZigbeeStack { match &command_frame.command_payload { ziggurat_ieee_802154::Ieee802154CommandPayload::BeaconRequest(_) => { - self.send_802154_beacon(); + let spam = self.state.hack_beacon_spam_duration; + if spam.is_zero() || !self.permitting_joins() { + self.send_802154_beacon(); + } else { + // Open/extend the spray window and let the beacon-spam reactor drive + // the cadence, so a storm of requests can't exceed one beacon per + // BEACON_SPAM_INTERVAL. + self.core().beacon_spam_until = Some(self.core_now() + spam); + self.beacon_spam_wake.notify_one(); + } } ziggurat_ieee_802154::Ieee802154CommandPayload::AssociationRequest( ieee802154_association_request_command, @@ -133,6 +149,27 @@ impl ZigbeeStack { ); } + /// The beacon-spam reactor (the [`hack_beacon_spam_duration`] hack): while a spray + /// window is open and joins are still permitting, send a beacon every + /// [`BEACON_SPAM_INTERVAL`], then idle on its wake. A beacon request opens or + /// extends the window; the fixed sleep (rather than racing the wake) is what caps + /// the rate at one beacon per interval no matter how many requests arrive. + /// + /// [`hack_beacon_spam_duration`]: super::State::hack_beacon_spam_duration + pub(super) async fn beacon_spam_task(&self) { + loop { + let until = self.core().beacon_spam_until; + if until.is_some_and(|deadline| deadline > self.core_now()) { + if self.permitting_joins() { + self.send_802154_beacon(); + } + R::sleep(BEACON_SPAM_INTERVAL).await; + } else { + self.beacon_spam_wake.notified().await; + } + } + } + pub(super) fn beacon_request_psdu(&self) -> Vec { let sequence_number = { let mut core = self.core(); From 9abbf2a505b3b44b40f250403ec335b626959a6e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:53:31 -0400 Subject: [PATCH 35/61] Migrate to development branch of `esp-hal` --- crates/ziggurat-esp/Cargo.lock | 217 ++++++++++++++++++--------- crates/ziggurat-esp/Cargo.toml | 13 +- crates/ziggurat-esp/src/hw_crypto.rs | 19 ++- crates/ziggurat-esp/src/main.rs | 2 +- crates/ziggurat-phy-esp/Cargo.toml | 4 +- 5 files changed, 172 insertions(+), 83 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 5d9e988..b19d203 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -770,6 +770,19 @@ 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", + "document-features", + "enumset", + "esp-config 0.7.0", + "esp-sync 0.2.1", + "linked_list_allocator", + "rlsf", +] + [[package]] name = "esp-alloc" version = "0.10.0" @@ -780,8 +793,8 @@ dependencies = [ "cfg-if", "document-features", "enumset", - "esp-config", - "esp-sync", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "linked_list_allocator", "rlsf", ] @@ -794,12 +807,12 @@ checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f" dependencies = [ "cfg-if", "document-features", - "esp-config", - "esp-metadata-generated", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-println", "heapless 0.9.3", "riscv", - "xtensa-lx", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -811,14 +824,25 @@ dependencies = [ "cfg-if", "document-features", "embedded-storage", - "esp-config", - "esp-hal-procmacros", - "esp-metadata-generated", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-hal-procmacros 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-rom-sys", "jiff", "strum", ] +[[package]] +name = "esp-config" +version = "0.7.0" +dependencies = [ + "document-features", + "esp-metadata-generated 0.4.0", + "serde", + "serde_yaml", + "somni-expr", +] + [[package]] name = "esp-config" version = "0.7.0" @@ -826,7 +850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" dependencies = [ "document-features", - "esp-metadata-generated", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_yaml", "somni-expr", @@ -834,14 +858,11 @@ dependencies = [ [[package]] name = "esp-hal" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72" +version = "1.1.0" dependencies = [ "bitfield", "bitflags 2.13.0", "bytemuck", - "cfg-if", "critical-section", "delegate", "digest", @@ -857,17 +878,20 @@ dependencies = [ "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", "enumset", - "esp-config", - "esp-hal-procmacros", - "esp-metadata-generated", + "esp-config 0.7.0", + "esp-hal-procmacros 0.22.0", + "esp-metadata-generated 0.4.0", "esp-riscv-rt", "esp-rom-sys", - "esp-sync", + "esp-sync 0.2.1", "esp32", "esp32c2", "esp32c3", + "esp32c5", "esp32c6", + "esp32c61", "esp32h2", + "esp32p4", "esp32s2", "esp32s3", "fugit", @@ -879,12 +903,26 @@ dependencies = [ "rand_core 0.6.4", "rand_core 0.9.5", "riscv", + "static_cell", "strum", "ufmt-write", - "xtensa-lx", + "xtensa-lx 0.13.0", "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-hal-procmacros" version = "0.22.0" @@ -892,7 +930,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" dependencies = [ "document-features", - "object", "proc-macro-crate", "proc-macro2", "quote", @@ -900,6 +937,10 @@ dependencies = [ "termcolor", ] +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" + [[package]] name = "esp-metadata-generated" version = "0.4.0" @@ -909,16 +950,13 @@ checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" [[package]] name = "esp-phy" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" dependencies = [ - "cfg-if", "document-features", "embassy-sync 0.8.0", - "esp-config", + "esp-config 0.7.0", "esp-hal", - "esp-metadata-generated", - "esp-sync", + "esp-metadata-generated 0.4.0", + "esp-sync 0.2.1", "esp-wifi-sys-esp32c6", "esp32c6", ] @@ -930,21 +968,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6" dependencies = [ "document-features", - "esp-metadata-generated", - "esp-sync", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "log", "portable-atomic", ] [[package]] name = "esp-radio" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" +version = "1.0.0-beta.0" dependencies = [ "allocator-api2", "byte", - "cfg-if", "docsplay", "document-features", "embassy-sync 0.8.0", @@ -952,14 +987,15 @@ dependencies = [ "embedded-io 0.7.1", "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", - "esp-alloc", - "esp-config", + "esp-alloc 0.10.0", + "esp-config 0.7.0", "esp-hal", - "esp-hal-procmacros", - "esp-metadata-generated", + "esp-hal-procmacros 0.22.0", + "esp-metadata-generated 0.4.0", "esp-phy", "esp-radio-rtos-driver", - "esp-sync", + "esp-rom-sys", + "esp-sync 0.2.1", "esp-wifi-sys-esp32c6", "esp32c6", "heapless 0.9.3", @@ -974,19 +1010,14 @@ dependencies = [ [[package]] name = "esp-radio-rtos-driver" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" dependencies = [ - "cfg-if", - "esp-sync", + "esp-sync 0.2.1", "portable-atomic", ] [[package]] name = "esp-riscv-rt" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" dependencies = [ "document-features", "riscv", @@ -996,12 +1027,9 @@ dependencies = [ [[package]] name = "esp-rom-sys" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" dependencies = [ - "cfg-if", "document-features", - "esp-metadata-generated", + "esp-metadata-generated 0.4.0", "esp32c6", ] @@ -1017,15 +1045,28 @@ dependencies = [ "embassy-sync 0.8.0", "embassy-time-driver", "embassy-time-queue-utils", - "esp-config", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-hal", - "esp-hal-procmacros", - "esp-metadata-generated", + "esp-hal-procmacros 0.22.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-rom-sys", - "esp-sync", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "portable-atomic", "riscv", - "xtensa-lx", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +dependencies = [ + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated 0.4.0", + "riscv", + "xtensa-lx 0.13.0", ] [[package]] @@ -1039,9 +1080,9 @@ dependencies = [ "embassy-sync 0.6.2", "embassy-sync 0.7.2", "embassy-sync 0.8.0", - "esp-metadata-generated", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "riscv", - "xtensa-lx", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1053,8 +1094,7 @@ checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" [[package]] name = "esp32" version = "0.40.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" dependencies = [ "vcell", ] @@ -1062,8 +1102,7 @@ dependencies = [ [[package]] name = "esp32c2" version = "0.29.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" dependencies = [ "vcell", ] @@ -1071,8 +1110,15 @@ dependencies = [ [[package]] name = "esp32c3" version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" +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", ] @@ -1080,8 +1126,15 @@ dependencies = [ [[package]] name = "esp32c6" version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" +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", ] @@ -1089,17 +1142,24 @@ dependencies = [ [[package]] name = "esp32h2" version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" +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 = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" dependencies = [ "vcell", ] @@ -1107,8 +1167,7 @@ dependencies = [ [[package]] name = "esp32s3" version = "0.35.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" +source = "git+https://github.com/esp-rs/esp-pacs?rev=4c1aeb8#4c1aeb8b80645a32d52593fcd962779748a33552" dependencies = [ "vcell", ] @@ -1868,6 +1927,15 @@ 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" @@ -2176,6 +2244,13 @@ dependencies = [ "tap", ] +[[package]] +name = "xtensa-lx" +version = "0.13.0" +dependencies = [ + "critical-section", +] + [[package]] name = "xtensa-lx" version = "0.13.0" @@ -2188,19 +2263,15 @@ dependencies = [ [[package]] name = "xtensa-lx-rt" version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" dependencies = [ "document-features", - "xtensa-lx", + "xtensa-lx 0.13.0", "xtensa-lx-rt-proc-macros", ] [[package]] name = "xtensa-lx-rt-proc-macros" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" dependencies = [ "proc-macro2", "quote", @@ -2234,7 +2305,7 @@ dependencies = [ "embassy-sync 0.8.0", "embassy-time", "embedded-io-async 0.7.0", - "esp-alloc", + "esp-alloc 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-backtrace", "esp-bootloader-esp-idf", "esp-hal", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index 2ec2aba..a28b3d0 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -19,8 +19,8 @@ ziggurat-phy-esp = { path = "../ziggurat-phy-esp" } ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } ziggurat-zigbee = { path = "../ziggurat-zigbee" } -esp-hal = { version = "1.1.1", features = ["esp32c6", "unstable"] } -esp-radio = { version = "0.18.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-hal = { version = "1.1.0", features = ["esp32c6", "unstable"] } +esp-radio = { version = "1.0.0-beta.0", features = ["esp32c6", "ieee802154", "unstable"] } esp-rtos = { version = "0.3.0", features = ["esp32c6", "embassy"] } esp-alloc = "0.10.0" esp-backtrace = { version = "0.19.0", features = [ @@ -51,3 +51,12 @@ codegen-units = 1 # A standalone workspace: this crate only builds for the ESP32-C6 target and is excluded # from the host workspace. [workspace] + +# Build against a local checkout of the esp-hal fork so we can patch esp-radio's +# IEEE 802.15.4 driver to set the auto-ACK frame-pending bit (RX_DONE ISR). esp-radio's +# intra-repo deps are path-based, so esp-hal must be patched alongside it to avoid two +# esp-hal copies. To be repointed at the fork's pushed commit later. +[patch.crates-io] +esp-hal = { path = "../../../esp-hal/esp-hal" } +esp-radio = { path = "../../../esp-hal/esp-radio" } +esp-rom-sys = { path = "../../../esp-hal/esp-rom-sys" } diff --git a/crates/ziggurat-esp/src/hw_crypto.rs b/crates/ziggurat-esp/src/hw_crypto.rs index 3c2b586..6806cae 100644 --- a/crates/ziggurat-esp/src/hw_crypto.rs +++ b/crates/ziggurat-esp/src/hw_crypto.rs @@ -5,9 +5,10 @@ use core::cell::RefCell; use embassy_sync::blocking_mutex::Mutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use esp_hal::aes::cipher_modes::{Cbc, Ctr}; -use esp_hal::aes::dma::{AesDma, DmaCipherState}; +use esp_hal::aes::dma::{AesDma, AesDmaChannel, DmaCipherState}; use esp_hal::aes::{Aes, Operation}; -use esp_hal::dma::{DmaChannelFor, DmaRxBuf, DmaTxBuf}; +use esp_hal::dma::aligned::DmaAlignedMut; +use esp_hal::dma::{DmaRxBuf, DmaTxBuf}; use esp_hal::dma_buffers; use esp_hal::peripherals::AES; use subtle::ConstantTimeEq; @@ -187,10 +188,18 @@ fn format_cbc_mac_input(out: &mut [u8], nonce: &[u8; 13], aad: &[u8], payload: & /// Claim the AES peripheral + a DMA channel and install the hardware crypto backend. Call /// once during startup, before the stack processes frames. -pub fn init(aes: AES<'static>, dma: impl DmaChannelFor>) { +pub fn init(aes: AES<'static>, dma: impl AesDmaChannel<'static>) { let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = dma_buffers!(DMA_BUF_SIZE); - let rx = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap(); - let tx = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap(); + let rx = DmaRxBuf::new( + DmaAlignedMut::new(rx_descriptors).unwrap(), + DmaAlignedMut::new(rx_buffer).unwrap(), + ) + .unwrap(); + let tx = DmaTxBuf::new( + DmaAlignedMut::new(tx_descriptors).unwrap(), + DmaAlignedMut::new(tx_buffer).unwrap(), + ) + .unwrap(); let aes_dma = Aes::new(aes).with_dma(dma); HW.lock(|cell| { diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 72e9eb8..3946063 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -28,7 +28,7 @@ use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::rng::Rng; use esp_hal::timer::timg::TimerGroup; -use esp_hal::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagTx}; +use esp_hal::usb::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagTx}; use ziggurat_driver::rng; use ziggurat_driver::runtime::EmbassySpawner; diff --git a/crates/ziggurat-phy-esp/Cargo.toml b/crates/ziggurat-phy-esp/Cargo.toml index 6721b5f..0a826fb 100644 --- a/crates/ziggurat-phy-esp/Cargo.toml +++ b/crates/ziggurat-phy-esp/Cargo.toml @@ -13,5 +13,5 @@ ziggurat-ieee-802154 = { path = "../ziggurat-ieee-802154" } embassy-sync = "0.8" embassy-futures = "0.1" embassy-time = "0.5" -esp-radio = { version = "0.18.0", features = ["esp32c6", "ieee802154", "unstable"] } -esp-hal = { version = "1.1.1", features = ["esp32c6", "unstable"] } +esp-radio = { version = "1.0.0-beta.0", features = ["esp32c6", "ieee802154", "unstable"] } +esp-hal = { version = "1.1.0", features = ["esp32c6", "unstable"] } From 5d0058137bba6ae213ca1a76e71e2e92e7558e61 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:32:02 -0400 Subject: [PATCH 36/61] Do not stall the USB UART FIFO --- crates/ziggurat-esp/src/main.rs | 71 +++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 3946063..ed74bf3 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -16,8 +16,10 @@ use alloc::sync::Arc; use alloc::vec::Vec; use embassy_executor::Spawner; +use embassy_futures::select::{Either, select}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; +use embassy_time::{Duration, Timer}; use embedded_io_async::Read; use embedded_io_async::Write; use esp_alloc as _; @@ -28,7 +30,7 @@ use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::rng::Rng; use esp_hal::timer::timg::TimerGroup; -use esp_hal::usb::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagTx}; +use esp_hal::usb::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagRx, UsbSerialJtagTx}; use ziggurat_driver::rng; use ziggurat_driver::runtime::EmbassySpawner; @@ -43,11 +45,18 @@ const OUTBOUND_DEPTH: usize = 16; pub static OUTBOUND: Channel = Channel::new(); +/// Complete inbound request lines, produced by `reader_task` and consumed by the +/// processor loop in `main`. Decoupling the read from the (slower) handling keeps the +/// USB RX FIFO drained promptly: a burst of commands fills this queue instead of +/// stalling the FIFO. +const INBOUND_DEPTH: usize = 32; +static INBOUND: Channel, INBOUND_DEPTH> = Channel::new(); + /// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` /// signals it so the task exits and frees the radio. pub type CaptureStop = embassy_sync::signal::Signal; -/// The firmware's mutable state, owned by (and only touched from) the reader loop. +/// The firmware's mutable state, owned by (and only touched from) the processor loop. pub struct App { pub phy: Arc, pub spawner: EmbassySpawner, @@ -62,6 +71,36 @@ async fn rx_task(phy: Arc) { phy.run_rx().await } +/// How often the reader re-checks the USB RX FIFO when no byte has arrived. Bounds the +/// recovery latency from a dropped esp-hal RX wakeup (see `reader_task`). +const RX_WATCHDOG: Duration = Duration::from_millis(50); + +/// Drains the USB-Serial-JTAG RX continuously, splitting on newlines and queueing each +/// complete line for the processor. +#[embassy_executor::task] +async fn reader_task(mut rx: UsbSerialJtagRx<'static, Async>) { + let mut buf = [0u8; 256]; + let mut line: Vec = Vec::with_capacity(2048); + loop { + let n = match select(rx.read(&mut buf), Timer::after(RX_WATCHDOG)).await { + Either::First(result) => result.unwrap_or(0), + Either::Second(()) => continue, + }; + for &byte in &buf[..n] { + match byte { + b'\n' => { + if !line.is_empty() { + INBOUND.send(line.clone()).await; + line.clear(); + } + } + b'\r' => {} + _ => line.push(byte), + } + } + } +} + /// The single serial writer: every outbound line goes through it, so concurrent /// producers (request handlers and the notification drainer) never interleave on the bus. #[embassy_executor::task] @@ -118,11 +157,12 @@ async fn main(spawner: Spawner) -> ! { })); let usb = UsbSerialJtag::new(peripherals.USB_DEVICE).into_async(); - let (mut serial_rx, serial_tx) = usb.split(); + let (serial_rx, serial_tx) = usb.split(); let phy = Arc::new(EspPhy::new(peripherals.IEEE802154)); spawner.spawn(rx_task(phy.clone()).unwrap()); + spawner.spawn(reader_task(serial_rx).unwrap()); spawner.spawn(writer_task(serial_tx).unwrap()); let mut app = App { @@ -134,24 +174,13 @@ async fn main(spawner: Spawner) -> ! { api::emit(api::hello_message(false)).await; - // The reader loop: accumulate bytes into a line, dispatch on newline. `buf` is only - // the per-read chunk; `line` grows without bound, so a full-network-state `configure` - // line spanning many reads is reassembled whole. - let mut buf = [0u8; 256]; - let mut line: Vec = Vec::with_capacity(2048); + // The processor loop. `reader_task` owns the RX side and keeps the FIFO drained, so + // a slow handler here only backs up `INBOUND` (bounded) instead of stalling + // intake. `handle_line` spawns each `send_aps` on its own task so a slow transmit + // (route discovery, APS-ack wait) doesn't serialize every later command behind + // it. loop { - let n = serial_rx.read(&mut buf).await.unwrap_or(0); - for &byte in &buf[..n] { - match byte { - b'\n' => { - if !line.is_empty() { - api::handle_line(&mut app, &line).await; - line.clear(); - } - } - b'\r' => {} - _ => line.push(byte), - } - } + let line = INBOUND.receive().await; + api::handle_line(&mut app, &line).await; } } From ff08cd097cecb9d0218728f37c45db4fb38d6e01 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:44:49 -0400 Subject: [PATCH 37/61] Implement source match table support in the ESP32 HAL --- crates/ziggurat-esp/Cargo.lock | 96 +++++++++++++++++------------- crates/ziggurat-esp/Cargo.toml | 14 ++--- crates/ziggurat-phy-esp/src/lib.rs | 15 +++-- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index b19d203..af287d7 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -773,12 +773,15 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "esp-alloc" version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" dependencies = [ "allocator-api2", + "cfg-if", "document-features", "enumset", - "esp-config 0.7.0", - "esp-sync 0.2.1", + "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "linked_list_allocator", "rlsf", ] @@ -786,15 +789,13 @@ dependencies = [ [[package]] name = "esp-alloc" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "allocator-api2", - "cfg-if", "document-features", "enumset", - "esp-config 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", - "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "linked_list_allocator", "rlsf", ] @@ -835,9 +836,11 @@ dependencies = [ [[package]] name = "esp-config" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" dependencies = [ "document-features", - "esp-metadata-generated 0.4.0", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_yaml", "somni-expr", @@ -846,11 +849,10 @@ dependencies = [ [[package]] name = "esp-config" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", - "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "serde", "serde_yaml", "somni-expr", @@ -859,6 +861,7 @@ dependencies = [ [[package]] name = "esp-hal" version = "1.1.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "bitfield", "bitflags 2.13.0", @@ -878,12 +881,12 @@ dependencies = [ "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", "enumset", - "esp-config 0.7.0", - "esp-hal-procmacros 0.22.0", - "esp-metadata-generated 0.4.0", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-riscv-rt", "esp-rom-sys", - "esp-sync 0.2.1", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp32", "esp32c2", "esp32c3", @@ -906,16 +909,17 @@ dependencies = [ "static_cell", "strum", "ufmt-write", - "xtensa-lx 0.13.0", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "xtensa-lx-rt", ] [[package]] name = "esp-hal-procmacros" version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" dependencies = [ "document-features", - "object", "proc-macro-crate", "proc-macro2", "quote", @@ -926,10 +930,10 @@ dependencies = [ [[package]] name = "esp-hal-procmacros" version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", + "object", "proc-macro-crate", "proc-macro2", "quote", @@ -940,23 +944,25 @@ dependencies = [ [[package]] name = "esp-metadata-generated" version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" [[package]] name = "esp-metadata-generated" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" [[package]] name = "esp-phy" version = "0.2.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", "embassy-sync 0.8.0", - "esp-config 0.7.0", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-hal", - "esp-metadata-generated 0.4.0", - "esp-sync 0.2.1", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-wifi-sys-esp32c6", "esp32c6", ] @@ -977,6 +983,7 @@ dependencies = [ [[package]] name = "esp-radio" version = "1.0.0-beta.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "allocator-api2", "byte", @@ -987,15 +994,15 @@ dependencies = [ "embedded-io 0.7.1", "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", - "esp-alloc 0.10.0", - "esp-config 0.7.0", + "esp-alloc 0.10.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-hal", - "esp-hal-procmacros 0.22.0", - "esp-metadata-generated 0.4.0", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-phy", "esp-radio-rtos-driver", "esp-rom-sys", - "esp-sync 0.2.1", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp-wifi-sys-esp32c6", "esp32c6", "heapless 0.9.3", @@ -1010,14 +1017,16 @@ dependencies = [ [[package]] name = "esp-radio-rtos-driver" version = "0.3.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ - "esp-sync 0.2.1", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "portable-atomic", ] [[package]] name = "esp-riscv-rt" version = "0.14.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", "riscv", @@ -1027,9 +1036,10 @@ dependencies = [ [[package]] name = "esp-rom-sys" version = "0.1.4" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", - "esp-metadata-generated 0.4.0", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "esp32c6", ] @@ -1059,30 +1069,31 @@ dependencies = [ [[package]] name = "esp-sync" version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" dependencies = [ + "cfg-if", "document-features", "embassy-sync 0.6.2", "embassy-sync 0.7.2", "embassy-sync 0.8.0", - "esp-metadata-generated 0.4.0", + "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "riscv", - "xtensa-lx 0.13.0", + "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "esp-sync" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ - "cfg-if", "document-features", "embassy-sync 0.6.2", "embassy-sync 0.7.2", "embassy-sync 0.8.0", - "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "riscv", - "xtensa-lx 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", ] [[package]] @@ -2247,6 +2258,8 @@ dependencies = [ [[package]] name = "xtensa-lx" version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" dependencies = [ "critical-section", ] @@ -2254,8 +2267,7 @@ dependencies = [ [[package]] name = "xtensa-lx" version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "critical-section", ] @@ -2263,15 +2275,17 @@ dependencies = [ [[package]] name = "xtensa-lx-rt" version = "0.22.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "document-features", - "xtensa-lx 0.13.0", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", "xtensa-lx-rt-proc-macros", ] [[package]] name = "xtensa-lx-rt-proc-macros" version = "0.5.0" +source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index a28b3d0..ac4a00d 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -52,11 +52,11 @@ codegen-units = 1 # from the host workspace. [workspace] -# Build against a local checkout of the esp-hal fork so we can patch esp-radio's -# IEEE 802.15.4 driver to set the auto-ACK frame-pending bit (RX_DONE ISR). esp-radio's -# intra-repo deps are path-based, so esp-hal must be patched alongside it to avoid two -# esp-hal copies. To be repointed at the fork's pushed commit later. +# Build against the esp-hal fork so we can patch esp-radio's IEEE 802.15.4 driver to set +# the auto-ACK frame-pending bit (RX_DONE ISR). esp-radio's intra-repo deps are path-based, +# so esp-hal must be patched alongside it to avoid two esp-hal copies; cargo resolves each +# package by name within the git workspace. [patch.crates-io] -esp-hal = { path = "../../../esp-hal/esp-hal" } -esp-radio = { path = "../../../esp-hal/esp-radio" } -esp-rom-sys = { path = "../../../esp-hal/esp-rom-sys" } +esp-hal = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } +esp-radio = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } +esp-rom-sys = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 5d24818..7abee6a 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -13,6 +13,7 @@ extern crate alloc; use alloc::string::String; +use alloc::vec::Vec; use core::time::Duration; use embassy_futures::select::{Either, select}; @@ -149,8 +150,7 @@ impl EspPhy { .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. + // Hold the radio lock across the completion wait. match select(TX_DONE.wait(), TX_FAILED.wait()).await { Either::First(()) => { if state.radio.get_ack_frame().is_some() { @@ -286,7 +286,7 @@ impl RadioPhy for EspPhy { let esp = state.config; state.radio.set_config(esp); state.radio.start_receive(); - + retune_rx(channel); Ok(()) @@ -294,10 +294,13 @@ impl RadioPhy for EspPhy { async fn set_frame_pending_table( &self, - _short: &[Nwk], - _extended: &[Eui64], + short: &[Nwk], + extended: &[Eui64], ) -> Result<(), RadioError> { - // TODO: esp-radio source-match via set_short_address(i, ..) + PendingMode. + let shorts: Vec = short.iter().map(|n| n.as_u16()).collect(); + let exts: Vec = extended.iter().map(|e| u64::from_le_bytes(e.to_bytes())).collect(); + let mut state = self.state.lock().await; + state.radio.set_source_match_table(&shorts, &exts); Ok(()) } From b6b82fc1593373c90ac2237369ff1422baa70ef8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:45:23 -0400 Subject: [PATCH 38/61] Ensure APS sends run in concurrent tasks --- crates/ziggurat-esp/src/api.rs | 55 ++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 5d660d6..155f84e 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -23,6 +23,7 @@ use ziggurat_driver::zigbee_stack::{ }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; +use ziggurat_phy_esp::EspPhy; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; use crate::{App, OUTBOUND}; @@ -239,7 +240,12 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "configure" => handle_configure(app, id, params).await, "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), - "send_aps" => handle_send_aps(app, id, params).await, + "send_aps" => { + // A send blocks on route discovery (seconds) and the APS-ack wait, so it + // runs on its own task. + dispatch_send_aps(app, id, params).await; + return; + } "energy_scan" => handle_energy_scan(app, id, params).await, "network_scan" => handle_network_scan(app, id, params).await, "permit_joins" => handle_permit_joins(app, id, params), @@ -414,39 +420,57 @@ fn handle_get_network_info(app: &App, id: u64) -> Value { ) } -async fn handle_send_aps(app: &App, id: u64, params: Value) -> Value { - let request: SendApsRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), +/// Validate that a stack is running, then spawn the send on its own task. Sends block +/// on route discovery and the APS-ack wait, so handling one inline in the command loop +/// would serialize the whole API behind each transmit. The task holds its own stack +/// handle(`Arc`), so it outlives this borrow. +async fn dispatch_send_aps(app: &App, id: u64, params: Value) { + let Some(stack) = app.stack.as_ref() else { + emit(error_response(id, "not_configured", "no stack is running")).await; + return; }; - let Some(stack) = app.stack.as_ref() else { - return error_response(id, "not_configured", "no stack is running"); + app.spawner + .spawn(Box::pin(run_send_aps(stack.clone(), id, params))); +} + +/// Drives one send to completion (transmit, then APS-ack wait), emitting the lifecycle +/// events and terminal response itself. +async fn run_send_aps(stack: Arc>, id: u64, params: Value) { + let request: SendApsRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return emit(error_response(id, "invalid_request", e)).await, }; let destination = match (request.destination_eui64, request.destination) { (_, Some(nwk)) => nwk, (Some(eui64), None) => match stack.state.core.lock().nib.address_map.nwk_for(eui64) { Some(nwk) => nwk, - None => return error_response(id, "unknown_destination_eui64", format!("{eui64:?}")), + None => { + return emit(error_response(id, "unknown_destination_eui64", format!("{eui64:?}"))) + .await; + } }, - (None, None) => return error_response(id, "missing_destination", "no destination given"), + (None, None) => { + return emit(error_response(id, "missing_destination", "no destination given")).await; + } }; let asdu = match hex::decode(&request.data) { Ok(asdu) => asdu, - Err(e) => return error_response(id, "invalid_data", e), + Err(e) => return emit(error_response(id, "invalid_data", e)).await, }; let aps_security = if request.aps_encryption { match (request.destination_eui64, request.delivery_mode) { (Some(eui64), ApsDeliveryMode::Unicast) => Some(eui64), _ => { - return error_response( + return emit(error_response( id, "invalid_request", "aps_encryption requires a unicast destination_eui64", - ); + )) + .await; } } } else { @@ -475,18 +499,19 @@ async fn handle_send_aps(app: &App, id: u64, params: Value) -> Value { .await { Ok(ack_waiter) => ack_waiter, - Err(e) => return error_response(id, "transmit_failed", e), + Err(e) => return emit(error_response(id, "transmit_failed", e)).await, }; emit(event(id, "transmitted")).await; - match ack_waiter { + let message = match ack_waiter { None => response(id, json!({"status": "sent"})), Some(waiter) => match stack.wait_aps_ack(waiter).await { Ok(()) => response(id, json!({"status": "delivered"})), Err(e) => error_response(id, "aps_ack_timeout", e), }, - } + }; + emit(message).await; } /// Energy scan: per-channel hardware energy detection, streamed as `energy_result` From 03350eceefeda69e6ee06c33c3a9b678bd13757d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:45:49 -0400 Subject: [PATCH 39/61] Mark some more TODOs --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 6 ++++++ crates/ziggurat-zigbee/src/nwk/routing.rs | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 17bfa0a..324f3d6 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -721,6 +721,12 @@ impl ZigbeeStack { completion: Option, ) { if let Some(child_eui64) = self.sleepy_child_eui64(next_hop) { + // TODO: this assigns the NWK frame counter NOW (finish_unicast_nwk_frame -> + // encrypt_nwk_frame), but the frame then waits in the indirect queue until the + // child polls. Meanwhile the sender_task hands out higher counters, so the + // indirect frame can hit the air with a counter below ones already sent and get + // replay-rejected. Encrypt at indirect-transmit time (on the poll), like the + // sender_task does at dequeue, so counter order always matches on-air order. let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop, security); self.increment_tx_total(); diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index e13731d..83c59f5 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -314,6 +314,13 @@ impl Routing { /// mostly reverse-route side effects of their discoveries). This deviates from /// the spec's table-first order (3.6.4.3). pub fn route_to(&self, destination: Nwk, max_source_route: u8) -> Option { + // TODO: a route record wins unconditionally here, with no outgoing-link-cost check. + // For a concentrator this reverses the device->coordinator path and assumes the link + // is symmetric: a device reachable directly inbound advertises Relay Count 0, so we + // transmit direct even when our outbound link to it is poor (asymmetric radio). The + // cost-aware AODV path only runs in the `_` fallback. Fold the neighbor's + // `outgoing_cost` in so a Relay-Count-0 record only yields `NextHop` when the + // outbound link is actually good; otherwise fall through to cost-based routing. match self.route_record_table.get(&destination) { // Spec 3.6.4.3.1: no intermediate relays means direct transmission Some(relays) if relays.is_empty() => Some(Route::NextHop(destination)), From 92a2d79a7e70a4db039d7392c5d24d521f0d116b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:14:09 -0400 Subject: [PATCH 40/61] Tunnel logging frames over the WebSocket API? --- crates/ziggurat-driver/src/zigbee_stack.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/aps.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/mac.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 2 +- .../ziggurat-driver/src/zigbee_stack/route.rs | 2 +- crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/Cargo.toml | 1 + crates/ziggurat-esp/src/api.rs | 36 +++++++ crates/ziggurat-esp/src/log_sink.rs | 96 +++++++++++++++++++ crates/ziggurat-esp/src/main.rs | 4 + crates/ziggurat-zigbee/src/nwk/neighbors.rs | 2 +- crates/ziggurat-zigbee/src/nwk/routing.rs | 2 +- 12 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 crates/ziggurat-esp/src/log_sink.rs diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index b5fad3d..b247d3b 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -937,7 +937,7 @@ impl ZigbeeStack { } }; - tracing::debug!("Received APS data frame: {aps_frame:?}"); + tracing::trace!("Received APS data frame: {aps_frame:?}"); // Spec 2.2.8.4.2: a retransmission is still acknowledged so the // sender stops, but must not be delivered to the application twice. @@ -1003,7 +1003,7 @@ impl ZigbeeStack { match Ieee802154Frame::from_bytes_without_fcs(&packet.psdu) { Ok(frame) => { - tracing::debug!("Received 802.15.4 frame: {frame:?}"); + tracing::trace!("Received 802.15.4 frame: {frame:?}"); return (packet, frame); } Err(e) => { diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 21ace9d..2891437 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -78,7 +78,7 @@ impl ZigbeeStack { /// Resolve an inbound APS ACK against the pending transmissions waiting for it. pub(super) fn handle_aps_ack(&self, nwk_frame: &NwkFrame, ack: &ApsAckFrame) { let ack_data = ApsAckData::from_aps_ack(nwk_frame.nwk_header.source, ack); - tracing::debug!("Received APS ack: {ack_data:?}"); + tracing::trace!("Received APS ack: {ack_data:?}"); let tx = self.state.pending_aps_acks.lock().remove(&ack_data); if let Some(tx) = tx { @@ -246,7 +246,7 @@ impl ZigbeeStack { }, }; - tracing::debug!("Prepared APS frame: {aps_frame:?}"); + tracing::trace!("Prepared APS frame: {aps_frame:?}"); let aps_payload = if let Some(destination_eui64) = aps_security { let encrypted = self diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 85acd70..1eb8e99 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -69,7 +69,7 @@ impl ZigbeeStack { pub fn send_802154_beacon(&self) { let permitting_joins = self.permitting_joins(); - tracing::debug!("Sending 802.15.4 beacon frame (permitting joins: {permitting_joins})"); + tracing::trace!("Sending 802.15.4 beacon frame (permitting joins: {permitting_joins})"); let end_device_capacity = { self.core().nib.neighbors.child_count() } < usize::from(self.tunables.max_children); @@ -393,7 +393,7 @@ impl ZigbeeStack { frame }; - tracing::debug!("Sending 802.15.4 frame: {final_frame:?}"); + tracing::trace!("Sending 802.15.4 frame: {final_frame:?}"); tracing::trace!( "Sending 802.15.4 frame bytes: {:02X?}", final_frame.to_bytes() diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 324f3d6..d2807e6 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -317,7 +317,7 @@ impl ZigbeeStack { NwkCommand::LinkStatus(cmd) => self.handle_link_status(nwk_frame, cmd.clone(), lqi), NwkCommand::RouteReply(cmd) => self.handle_route_reply(nwk_frame, cmd.clone()), NwkCommand::RouteRecord(cmd) => { - tracing::debug!("Route record command frame received: {cmd:?}"); + tracing::trace!("Route record command frame received: {cmd:?}"); self.core() .nib .routing diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 70afe12..4a701dd 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -21,7 +21,7 @@ impl ZigbeeStack { nwk_frame: &NwkFrame, route_reply_cmd: NwkRouteReplyCommand, ) { - tracing::debug!("Route reply command frame: {route_reply_cmd:?}"); + tracing::trace!("Route reply command frame: {route_reply_cmd:?}"); // Both `responder_eui64` and `originator_eui64` SHALL be set according to the // R23 spec but real devices do not do this diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index af287d7..5fd67af 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2330,6 +2330,7 @@ dependencies = [ "serde", "serde_json", "subtle", + "tracing", "ziggurat-driver", "ziggurat-ieee-802154", "ziggurat-phy", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index ac4a00d..db0bf4b 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -37,6 +37,7 @@ embassy-sync = "0.8" embassy-futures = "0.1" embedded-io-async = "0.7" +tracing = { version = "0.1", default-features = false } serde = { version = "1", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } hex = { version = "0.4", default-features = false, features = ["alloc"] } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 155f84e..4000e8b 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -38,6 +38,20 @@ pub async fn emit(value: Value) { } } +/// Forward one log record as a `log` notification, without blocking. Called +/// synchronously from the tracing subscriber (often while the stack holds a lock), so +/// it must not `await`; a full outbound queue drops the record rather than stalling +/// the stack. +pub fn emit_log(level: &str, target: &str, message: &str) { + let line = notification( + "log", + json!({ "level": level, "target": target, "message": message }), + ); + if let Ok(text) = serde_json::to_string(&line) { + let _ = OUTBOUND.try_send(text); + } +} + pub fn hello_message(configured: bool) -> Value { let state = if configured { "running" @@ -206,6 +220,27 @@ struct SetProvisionalKeyRequest { key: Key, } +#[derive(Deserialize)] +struct SetLogLevelRequest { + /// `off` / `error` / `warn` / `info` / `debug` / `trace`. + level: String, +} + +/// Adjust the firmware log verbosity at runtime (records are forwarded as `log` +/// notifications). Live-settable so a session can flip to `debug` without a +/// reconfigure (which would reset the stack). +fn handle_set_log_level(id: u64, params: Value) -> Value { + let request: SetLogLevelRequest = match serde_json::from_value(params) { + Ok(request) => request, + Err(e) => return error_response(id, "invalid_request", e), + }; + + match crate::log_sink::set_log_level(&request.level) { + Some(level) => response(id, json!({"status": "success", "level": level})), + None => error_response(id, "invalid_request", "unknown log level"), + } +} + #[derive(Deserialize)] #[serde(rename_all = "lowercase")] enum ResetType { @@ -261,6 +296,7 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { } "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), "set_provisional_key" => handle_set_provisional_key(app, id, params), + "set_log_level" => handle_set_log_level(id, params), other => error_response(id, "unknown_method", other), }; diff --git a/crates/ziggurat-esp/src/log_sink.rs b/crates/ziggurat-esp/src/log_sink.rs new file mode 100644 index 0000000..d0b7f4a --- /dev/null +++ b/crates/ziggurat-esp/src/log_sink.rs @@ -0,0 +1,96 @@ +//! A `tracing` subscriber that forwards log records to the host as `log` notifications on +//! the JSON API, rather than to a serial console — the USB-Serial-JTAG already carries the +//! protocol, so println-style logging there would corrupt it. + +use alloc::string::String; +use core::fmt::{self, Write}; +use core::sync::atomic::{AtomicU8, Ordering}; + +use tracing::field::{Field, Visit}; +use tracing::subscriber::Interest; +use tracing::{Event, Level, Metadata, Subscriber, span}; + +use crate::api; + +/// Verbosity threshold as a rank; records at or below it are forwarded. +static MAX_LEVEL: AtomicU8 = AtomicU8::new(3); + +const fn rank(level: &Level) -> u8 { + match *level { + Level::ERROR => 1, + Level::WARN => 2, + Level::INFO => 3, + Level::DEBUG => 4, + Level::TRACE => 5, + } +} + +/// Set the verbosity threshold from a level name (`off`/`error`/`warn`/`info`/`debug`/ +/// `trace`); unknown names are ignored. Returns the level applied, if recognized. +pub fn set_log_level(level: &str) -> Option<&'static str> { + let rank = match level { + "off" => 0, + "error" => 1, + "warn" => 2, + "info" => 3, + "debug" => 4, + "trace" => 5, + _ => return None, + }; + MAX_LEVEL.store(rank, Ordering::Relaxed); + Some(match rank { + 0 => "off", + 1 => "error", + 2 => "warn", + 3 => "info", + 4 => "debug", + _ => "trace", + }) +} + +pub fn install() { + let _ = tracing::subscriber::set_global_default(LogSink); +} + +struct LogSink; + +impl Subscriber for LogSink { + fn register_callsite(&self, _metadata: &Metadata<'_>) -> Interest { + Interest::sometimes() + } + + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + rank(metadata.level()) <= MAX_LEVEL.load(Ordering::Relaxed) + } + + fn event(&self, event: &Event<'_>) { + let metadata = event.metadata(); + + let mut message = String::new(); + event.record(&mut MessageVisitor(&mut message)); + + api::emit_log(metadata.level().as_str(), metadata.target(), &message); + } + + // Events-only: spans are not used by the stack, so span bookkeeping is a no-op. + fn new_span(&self, _: &span::Attributes<'_>) -> span::Id { + span::Id::from_u64(1) + } + fn record(&self, _: &span::Id, _: &span::Record<'_>) {} + fn record_follows_from(&self, _: &span::Id, _: &span::Id) {} + fn enter(&self, _: &span::Id) {} + fn exit(&self, _: &span::Id) {} +} + +/// Collects an event's `message` field (and appends any structured fields) into a string. +struct MessageVisitor<'a>(&'a mut String); + +impl Visit for MessageVisitor<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.0, "{value:?}"); + } else { + let _ = write!(self.0, " {}={value:?}", field.name()); + } + } +} diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index ed74bf3..7cd14d7 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -10,6 +10,7 @@ extern crate alloc; mod api; mod hw_crypto; +mod log_sink; use alloc::boxed::Box; use alloc::sync::Arc; @@ -140,6 +141,9 @@ async fn main(spawner: Spawner) -> ! { esp_alloc::heap_allocator!(size: 96 * 1024); + // Forward the stack's `tracing` records to the host as `log` notifications. + log_sink::install(); + // Route Zigbee crypto through the AES accelerator: CCM* runs as two DMA passes // (CBC-MAC + CTR) and AES-MMO rides the single-block path. Must happen before the // stack processes any frames. diff --git a/crates/ziggurat-zigbee/src/nwk/neighbors.rs b/crates/ziggurat-zigbee/src/nwk/neighbors.rs index 1f53711..43cc699 100644 --- a/crates/ziggurat-zigbee/src/nwk/neighbors.rs +++ b/crates/ziggurat-zigbee/src/nwk/neighbors.rs @@ -712,7 +712,7 @@ impl Neighbors { // nwkcMinRouterBootstrapJitter < nwkcMaxRouterBootstrapJitter } - tracing::debug!("Updated neighbor table entry: {neighbor_entry:?}"); + tracing::trace!("Updated neighbor table entry: {neighbor_entry:?}"); let lost_link = previous_outgoing_cost > 0 && neighbor_entry.outgoing_cost == 0; diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 83c59f5..862eaf0 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -363,7 +363,7 @@ impl Routing { destination_address: destination, }); - tracing::debug!("Route discovery entry: [{key:?}] = {discovery_entry:?}"); + tracing::trace!("Route discovery entry: [{key:?}] = {discovery_entry:?}"); request_id } From 54ebb504bf352946dc6e9f7b395b5aeec100d6cc Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:18:56 -0400 Subject: [PATCH 41/61] Return `TxResult::Acked` for frames that do not need an ACK --- crates/ziggurat-phy-esp/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 7abee6a..0ba8c01 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -135,6 +135,8 @@ impl EspPhy { async fn transmit_inner(&self, frame: &TxFrame) -> Result { let retries = frame.max_frame_retries; let mut attempt = 0; + let ack_requested = frame.psdu.first().is_some_and(|fcf| fcf & 0x20 != 0); + loop { let result = { let mut state = self.state.lock().await; @@ -153,7 +155,7 @@ impl EspPhy { // Hold the radio lock across the completion wait. match select(TX_DONE.wait(), TX_FAILED.wait()).await { Either::First(()) => { - if state.radio.get_ack_frame().is_some() { + if !ack_requested || state.radio.get_ack_frame().is_some() { TxResult::Acked } else { TxResult::NoAck From 3887137f76e2d64edaa0295b1903377d367dec86 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:41:24 -0400 Subject: [PATCH 42/61] Test: CSMA backoff for ESP32 --- crates/ziggurat-phy-esp/src/lib.rs | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index 0ba8c01..a55ab6f 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -14,6 +14,7 @@ extern crate alloc; use alloc::string::String; use alloc::vec::Vec; +use core::sync::atomic::{AtomicU32, Ordering}; use core::time::Duration; use embassy_futures::select::{Either, select}; @@ -34,6 +35,27 @@ use ziggurat_phy::{ const RX_DEPTH: usize = 16; +// 802.15.4 unslotted CSMA-CA backoff, matching the OpenThread RCP's RAIL config +// (`RAIL_CSMA_CONFIG_802_15_4_2003_2p4_GHz_OQPSK_CSMA`): a 20-symbol (320 us) unit backoff +// period and a backoff exponent that grows from 3 to 5 across retries. esp-radio does only +// a single hardware CCA with no backoff, so the stack performs the backoff itself. +const CSMA_UNIT_BACKOFF_US: u64 = 320; +const CSMA_MIN_BE: u32 = 3; +const CSMA_MAX_BE: u32 = 5; + +/// xorshift32 mixed with the monotonic clock, for CSMA backoff jitter. Not cryptographic; +/// the clock mix just keeps independent senders (and reboots) from sharing a sequence and +/// colliding repeatedly on a busy channel. +fn next_csma_random() -> u32 { + static STATE: AtomicU32 = AtomicU32::new(0x9E37_79B9); + let mut x = STATE.load(Ordering::Relaxed); + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + STATE.store(if x == 0 { 0x9E37_79B9 } else { x }, Ordering::Relaxed); + x ^ (embassy_time::Instant::now().as_ticks() as u32) +} + // There is exactly one IEEE802154 peripheral, so a single set of statics backs it. The // esp-radio completion callbacks are plain `fn()` (no captures), so they must reach the // async side through statics. @@ -135,6 +157,7 @@ impl EspPhy { async fn transmit_inner(&self, frame: &TxFrame) -> Result { let retries = frame.max_frame_retries; let mut attempt = 0; + let mut csma_attempt = 0; let ack_requested = frame.psdu.first().is_some_and(|fcf| fcf & 0x20 != 0); loop { @@ -167,6 +190,20 @@ impl EspPhy { match result { TxResult::NoAck if attempt < retries => attempt += 1, + TxResult::ChannelAccessFailure + if frame.csma_ca && csma_attempt < frame.max_csma_backoffs => + { + // esp-radio reports a busy channel after a single CCA. Back off a random + // number of unit periods (exponent growing per retry, per 802.15.4 + // CSMA-CA) before re-attempting, instead of failing the send outright. + let be = (CSMA_MIN_BE + u32::from(csma_attempt)).min(CSMA_MAX_BE); + let slots = next_csma_random() & ((1 << be) - 1); + Timer::after(embassy_time::Duration::from_micros( + u64::from(slots) * CSMA_UNIT_BACKOFF_US, + )) + .await; + csma_attempt += 1; + } other => return Ok(other), } } From 5497f481f45ffe1c9b895fa63f10f7412c53b04f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:43:25 -0400 Subject: [PATCH 43/61] Test: cap executor stack size and drop unconsumed notifications --- crates/ziggurat-driver/src/runtime.rs | 2 +- crates/ziggurat-driver/src/zigbee_stack.rs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ziggurat-driver/src/runtime.rs b/crates/ziggurat-driver/src/runtime.rs index 6db28f8..da0fa32 100644 --- a/crates/ziggurat-driver/src/runtime.rs +++ b/crates/ziggurat-driver/src/runtime.rs @@ -233,7 +233,7 @@ mod embassy_impl { /// 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)] + #[embassy_executor::task(pool_size = 32)] async fn task_runner(task: SpawnedTask) { task.await; } diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index b247d3b..ee0dd14 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -59,6 +59,13 @@ const RADIO_RECOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(1); /// counter, so that its persisted copy never lags far behind. const FRAME_COUNTER_NOTIFY_INTERVAL: u32 = 100; +/// Upper bound on the unsent notification queue. The drainer stalls whenever the client +/// stops reading the serial/WS link (e.g. a disconnected client), but the stack keeps +/// producing events from live mesh traffic; without a ceiling the queue grows until it +/// exhausts the heap (fatal on the MCU). When full we drop the oldest event: a client +/// that wasn't reading has already missed it and re-syncs on reconnect. +const NOTIFICATION_QUEUE_CAP: usize = 64; + #[derive(Error, Debug)] pub enum ZigbeeStackError { #[error("route discovery timed out")] @@ -842,9 +849,17 @@ impl ZigbeeStack { }) } - /// Queue a network event and wake the notification drainer. + /// Queue a network event and wake the notification drainer. Bounded: when the queue is + /// full (the client isn't draining the link) the oldest event is dropped rather than + /// growing the queue without bound, which would exhaust the heap on the MCU. pub(crate) fn push_notification(&self, notification: ZigbeeNotification) { - self.notifications.lock().push_back(notification); + let mut notifications = self.notifications.lock(); + while notifications.len() >= NOTIFICATION_QUEUE_CAP { + notifications.pop_front(); + } + notifications.push_back(notification); + drop(notifications); + self.notification_wake.notify_one(); } From b2018c02f7648a775d88cade0beb1f3b956f454e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:34:54 -0400 Subject: [PATCH 44/61] Properly decouple frame sending from retrying --- crates/ziggurat-driver/src/zigbee_stack.rs | 104 ++++++- .../src/zigbee_stack/indirect.rs | 83 ++--- .../src/zigbee_stack/joining.rs | 13 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 285 +++++++++++++----- crates/ziggurat-zigbee/src/indirect.rs | 46 ++- 5 files changed, 369 insertions(+), 162 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index ee0dd14..57db999 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -25,7 +25,7 @@ use core::future::Future; use core::ops::{Deref, DerefMut}; use core::sync::atomic::{AtomicBool, AtomicU32, Ordering as AtomicOrdering}; use core::time::Duration; -use ziggurat_zigbee::nwk::frame::NwkFrame; +use ziggurat_zigbee::nwk::frame::{EncryptedNwkFrame, NwkFrame}; mod aps; mod indirect; @@ -122,7 +122,7 @@ pub enum SendMode { /// lookup (and route discovery suppressed). Used for frames to a one-hop neighbor, /// e.g. delivering the network key to a joining device. Direct, - /// Resolve the next hop through the routing layer — the route table or an applicable + /// Resolve the next hop through the routing layer: the route table or an applicable /// source route, discovering a route first if none is known. Route, } @@ -287,6 +287,11 @@ pub(crate) enum SendKind { nwk_frame: NwkFrame, next_hop: Nwk, security: NwkSecurityMode, + /// NWK-layer retries left after the current attempt. Seeded from + /// `Tunables::unicast_retries`; a failed attempt with retries remaining is + /// re-enqueued (decremented) by the unicast-retry reactor rather than slept on + /// in the sender, so the single sender task never blocks on a retry delay. + attempts_remaining: u8, }, Broadcast { nwk_frame: NwkFrame, @@ -337,6 +342,29 @@ pub struct PendingBroadcast { pub(crate) next_attempt: CoreInstant, } +/// A unicast awaiting re-transmission after a failed attempt, held by the unicast-retry +/// reactor ([`ZigbeeStack::unicast_retry_task`]). +/// +/// Pulling the NWK retry delay out of the sender task is what keeps the single sender +/// from ever sleeping mid-queue: a failed unicast is parked here and re-enqueued once +/// its delay elapses, so the radio keeps draining other frames in the meantime. The +/// frame is held as plaintext (no aux header / counter yet) so the next attempt earns +/// a fresh frame counter at dequeue, keeping on-air order equal to counter order. The +/// completion rides along and is resolved when the frame finally succeeds or exhausts +/// its attempts. +#[derive(Debug)] +pub struct PendingUnicastRetry { + pub(crate) nwk_frame: NwkFrame, + pub(crate) next_hop: Nwk, + pub(crate) security: NwkSecurityMode, + pub(crate) priority: TxPriority, + /// Attempts left after the one that just failed. + pub(crate) attempts_remaining: u8, + /// When the re-enqueue is due. + pub(crate) next_attempt: CoreInstant, + pub(crate) completion: Option, +} + impl PartialEq for SendRequest { fn eq(&self, other: &Self) -> bool { self.priority == other.priority && self.seq == other.seq @@ -397,6 +425,45 @@ pub struct Aib { pub aps_security: ApsSecurity, } +/// A frame queued for indirect delivery to a sleepy device, together with the address it +/// is queued under (802.15.4 spec 6.7.3). +/// +/// `poll_address` is the address the device polls with, which a MAC Data Request is +/// matched against and so the indirect queue's key: the extended address before a device +/// has joined (it has no short address yet), the short address once it has. It is neither +/// the frame's NWK destination (`nwk_header.destination`) nor a [`Deferred`] payload's +/// `next_hop` (the short address used to build the MAC header) — the same device can wear +/// all three. +/// +/// [`Deferred`]: IndirectPayload::Deferred +#[derive(Debug)] +pub struct IndirectFrame { + pub poll_address: Ieee802154Address, + pub payload: IndirectPayload, +} + +/// The payload of an [`IndirectFrame`]. +/// +/// A NWK frame to a sleepy child is queued as [`Deferred`](Self::Deferred): it is +/// finished (encrypted, with a fresh NWK frame counter assigned) only when the child +/// polls, not when it is queued. A transaction can wait in the indirect queue for +/// seconds; assigning the counter at enqueue would let the sender hand out higher +/// counters in the meantime, so the frame would reach the air with a counter below +/// ones already sent and the destination's replay window would reject it. Finishing at +/// delivery keeps the counter in on-air order. +/// +/// Frames with no NWK layer to encrypt (a MAC association response) are queued +/// [`Final`](Self::Final) and transmitted as-is. +#[derive(Debug)] +pub enum IndirectPayload { + Deferred { + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + }, + Final(Ieee802154Frame), +} + /// Host-side mirror of the MAC PIB attributes we drive on the RCP. The MAC sub-layer /// physically lives on the radio coprocessor; these are our authoritative copies. #[derive(Debug)] @@ -406,17 +473,10 @@ pub struct MacState { pub pan_id: PanId, /// Frames awaiting extraction by a polling device. Completions are resolved /// with the transmit result on extraction, or an error on expiry or drop. - pub indirect_queue: IndirectQueue, + pub indirect_queue: IndirectQueue, } /// The driver's unified mutable protocol state, behind a single lock. -/// -/// An operation spanning several layers takes one guard instead of juggling a lock per -/// field (and can never deadlock against itself on lock ordering). This is also the -/// shape the eventual no_std core will own directly — there with no lock, here behind -/// one `Mutex` for the threaded driver. Spec attributes are grouped by their -/// information base ([`Nib`],[`Aib`], [`MacState`]); a field directly on the core is, -/// by that absence, one of our own constructs with no spec information-base home. #[derive(Debug)] pub struct ZigbeeCore { pub nib: Nib, @@ -467,6 +527,10 @@ pub struct State { pub pending_routes: Mutex>, /// Broadcasts awaiting retransmission, keyed by (source, sequence number). pub pending_broadcasts: Mutex>, + /// Unicasts awaiting re-transmission after a failed attempt. Unordered: each entry + /// is an independent in-flight frame (no dedup key like broadcasts have), drained + /// by the unicast-retry reactor when due. + pub pending_unicast_retries: Mutex>, pub address_conflicts: Mutex>, /// Spec 2.2.8.4.2: APS duplicate rejection. Keyed by (originator, APS counter) with @@ -575,6 +639,7 @@ impl State { pending_aps_acks: Mutex::new(BTreeMap::new()), pending_routes: Mutex::new(BTreeMap::new()), pending_broadcasts: Mutex::new(BTreeMap::new()), + pending_unicast_retries: Mutex::new(Vec::new()), address_conflicts: Mutex::new(BTreeMap::new()), aps_duplicates: Mutex::new(BTreeMap::new()), @@ -737,6 +802,9 @@ pub struct ZigbeeStack /// Wakes the broadcast-retransmit reactor: signaled on every recorded passive ack /// and whenever a broadcast is queued for retransmission. pub(crate) broadcast_retransmit_wake: Notify, + /// Wakes the unicast-retry reactor whenever a failed unicast is parked for a later + /// re-enqueue. + pub(crate) unicast_retry_wake: Notify, /// Wakes the beacon-spam reactor when a beacon request opens its spray window. pub(crate) beacon_spam_wake: Notify, /// Wakes the maintenance task when a new indirect transaction or child entry @@ -836,6 +904,7 @@ impl ZigbeeStack { mtorr_kick: Notify::new(), link_status_received: Notify::new(), broadcast_retransmit_wake: Notify::new(), + unicast_retry_wake: Notify::new(), beacon_spam_wake: Notify::new(), maintenance_wake: Notify::new(), send_queue: Mutex::new(BinaryHeap::new()), @@ -1045,8 +1114,8 @@ 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. + // 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() @@ -1067,6 +1136,17 @@ impl ZigbeeStack { arc_self.broadcast_retransmit_task().await; }); + // Re-enqueues failed unicasts after their retry delay, so the sender task never + // sleeps mid-queue. Must run before anything can queue a unicast. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.unicast_retry_task().await; + }); + // Sprays beacons while a beacon-spam window is open (the hack_beacon_spam_duration // hack). Idle unless beacon requests open the window. let arc_self = self diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 2afc6df..15a2624 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -1,5 +1,5 @@ use crate::runtime::Runtime; -use crate::signal::{self, SignalWaiter}; +use crate::signal; use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154CommandFrame, Ieee802154Frame}; use alloc::vec::Vec; use ziggurat_ieee_802154::types::{Eui64, Nwk}; @@ -7,58 +7,47 @@ use ziggurat_phy::RadioPhy; use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; -use ziggurat_zigbee::nwk::frame::EncryptedNwkFrame; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, NwkSecurityMode, SendKind, TxCompletion, TxPriority, ZigbeeNotification, - ZigbeeStack, ZigbeeStackError, + DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, TxCompletion, + TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { - /// 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. + /// Queue a frame for a polling device (802.15.4 spec 6.7.3), under its own + /// `poll_address`. A `completion`, if supplied, is resolved with the transmit result + /// when the device extracts the frame, or with an error on expiry or eviction; a + /// fire-and-forget caller passes `None`. pub(super) fn enqueue_indirect_frame( &self, - destination: Ieee802154Address, - frame: Ieee802154Frame, - completion: TxCompletion, + frame: IndirectFrame, + completion: Option, ) { + // The queue stores a completion unconditionally; a fire-and-forget caller gets + // a throwaway whose waiter is dropped, so resolving it later is a no-op. + let completion = completion.unwrap_or_else(|| signal::channel().0); + self.core() .mac .indirect_queue - .push(destination, frame, completion, self.core_now()); + .push(frame.poll_address, frame, completion, self.core_now()); 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, - ) -> SignalWaiter> { - let (completion, result_rx) = signal::channel(); - self.enqueue_indirect_frame(destination, frame, completion); - result_rx - } - + /// Queue a frame and await its delivery: resolves once the device extracts it, or + /// with an error on expiry or eviction. A dropped sender (stack shutdown) reads as + /// expiry. pub(super) async fn queue_indirect_frame( &self, - destination: Ieee802154Address, - frame: Ieee802154Frame, + frame: IndirectFrame, ) -> Result<(), ZigbeeStackError> { - // Every transaction is eventually resolved by delivery, the expiry sweep, or - // child eviction; a dropped sender means the stack is shutting down - let waiter = self.push_indirect_frame(destination, frame); + let destination = frame.poll_address; + let (completion, waiter) = signal::channel(); + self.enqueue_indirect_frame(frame, Some(completion)); waiter .wait() .await @@ -155,14 +144,26 @@ impl ZigbeeStack { true } - async fn transmit_indirect_transaction(&self, delivery: Delivery) { + async fn transmit_indirect_transaction(&self, delivery: Delivery) { let Delivery { destination, transaction, more_pending, } = delivery; - let mut frame = transaction.frame.clone(); + // Finish a deferred NWK frame now, at delivery time: this is where its frame + // counter is assigned (via `encrypt_nwk_frame`), just before the frame hits the + // air, rather than when it was queued. The transaction is left intact (we clone + // out of it) so a failed transmit can requeue the plaintext and earn a fresh + // counter on the next poll. + let mut frame = match &transaction.frame.payload { + IndirectPayload::Deferred { + nwk_frame, + next_hop, + security, + } => self.finish_unicast_nwk_frame(nwk_frame.clone(), *next_hop, *security), + IndirectPayload::Final(frame) => frame.clone(), + }; if more_pending { match frame { @@ -259,10 +260,16 @@ impl ZigbeeStack { .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - let frame = - arc_self.finish_unicast_nwk_frame(nwk_frame, nwk, NwkSecurityMode::NetworkKey); - - if let Err(err) = arc_self.queue_indirect_frame(destination, frame).await { + let frame = IndirectFrame { + poll_address: destination, + payload: IndirectPayload::Deferred { + nwk_frame, + next_hop: nwk, + security: NwkSecurityMode::NetworkKey, + }, + }; + + if let Err(err) = arc_self.queue_indirect_frame(frame).await { tracing::debug!("Queued leave to {nwk:?} was not extracted: {err}"); } }); diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index e5d605d..3c8e3ae 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -33,8 +33,8 @@ use ziggurat_zigbee::nwk::commands::{ }; use super::{ - AddrConflictSource, DeviceLeaveReason, JoinKind, NwkDeviceType, NwkSecurityMode, RadioPhy, - SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, + AddrConflictSource, DeviceLeaveReason, IndirectFrame, IndirectPayload, JoinKind, NwkDeviceType, + NwkSecurityMode, RadioPhy, SendMode, TxPriority, ZigbeeNotification, ZigbeeStack, neighbors, }; impl ZigbeeStack { @@ -147,10 +147,11 @@ impl ZigbeeStack { .expect("Unable to upgrade self reference"); self.spawn_tracked(async move { - match arc_self - .queue_indirect_frame(Ieee802154Address::Eui64(eui64), response_frame) - .await - { + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(eui64), + payload: IndirectPayload::Final(response_frame), + }; + match arc_self.queue_indirect_frame(frame).await { Ok(()) => { // Zigbee spec 4.6.3.2: the network key is delivered once the // device has confirmed receipt of its short address diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index d2807e6..e020877 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -24,9 +24,9 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, - PendingFrame, PendingRoute, SendKind, SendMode, SendRequest, TxCompletion, TxPriority, - ZigbeeStack, ZigbeeStackError, + AddrConflictSource, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, + PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, SendKind, + SendMode, SendRequest, TxCompletion, TxPriority, ZigbeeStack, ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -721,20 +721,20 @@ impl ZigbeeStack { completion: Option, ) { if let Some(child_eui64) = self.sleepy_child_eui64(next_hop) { - // TODO: this assigns the NWK frame counter NOW (finish_unicast_nwk_frame -> - // encrypt_nwk_frame), but the frame then waits in the indirect queue until the - // child polls. Meanwhile the sender_task hands out higher counters, so the - // indirect frame can hit the air with a counter below ones already sent and get - // replay-rejected. Encrypt at indirect-transmit time (on the poll), like the - // sender_task does at dequeue, so counter order always matches on-air order. - let frame = self.finish_unicast_nwk_frame(nwk_frame, next_hop, security); + // The frame is left as plaintext and finished (encrypted, counter assigned) + // only when the child polls. See `IndirectFrame`. The NWK sequence number + // is already assigned. + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(child_eui64), + payload: IndirectPayload::Deferred { + nwk_frame, + next_hop, + security, + }, + }; self.increment_tx_total(); - 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)), - } + self.enqueue_indirect_frame(frame, completion); return; } @@ -743,6 +743,7 @@ impl ZigbeeStack { nwk_frame, next_hop, security, + attempts_remaining: self.tunables.unicast_retries, }, priority, completion, @@ -961,30 +962,35 @@ impl ZigbeeStack { break; }; - let result = match request.kind { + match request.kind { SendKind::Unicast { nwk_frame, next_hop, security, + attempts_remaining, } => { - self.process_unicast_send(nwk_frame, next_hop, security) - .await + // Owns the completion: resolves it on success or terminal + // failure, or hands it to the retry reactor. + self.attempt_unicast_send( + nwk_frame, + next_hop, + security, + request.priority, + attempts_remaining, + request.completion, + ) + .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) => { - completion.signal(result); + } => { + let result = self.process_broadcast_send(nwk_frame, security).await; + Self::resolve_completion(request.completion, result); } - None => { - if let Err(err) = result { - tracing::warn!("Background send failed: {err}"); - } + SendKind::Raw { frame } => { + let result = self.send_802154_frame(frame).await; + Self::resolve_completion(request.completion, result); } } } @@ -993,73 +999,183 @@ impl ZigbeeStack { } } - /// Encrypt and unicast a dequeued frame to the next hop, with NWK retries. - async fn process_unicast_send( + /// Resolve a send's completion (if any) with its outcome, logging a dropped + /// background failure that nothing is waiting on. + fn resolve_completion(completion: Option, result: Result<(), ZigbeeStackError>) { + match completion { + Some(completion) => completion.signal(result), + None => { + if let Err(err) = result { + tracing::warn!("Background send failed: {err}"); + } + } + } + } + + /// One transmit attempt for a dequeued unicast: assign the frame counter, encrypt, + /// and send once. On success (or terminal failure) the completion resolves here; + /// on a failed attempt with retries left, the plaintext frame is parked with the + /// unicast-retry reactor instead of being slept on, so the sender stays free. + async fn attempt_unicast_send( &self, mut nwk_frame: NwkFrame, next_hop_address: Nwk, security: NwkSecurityMode, - ) -> Result<(), ZigbeeStackError> { + priority: TxPriority, + attempts_remaining: u8, + completion: Option, + ) { self.apply_nwk_aux_header(&mut nwk_frame, security); + let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); + let ieee802154_frame = + self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame); - for attempt in 0..=self.tunables.unicast_retries { - let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); - let ieee802154_frame = - self.build_unicast_802154_data_frame(next_hop_address, encrypted_nwk_frame); + // When forwarding packets to another node, update the counters for the neighbor + { + let mut core = self.core(); + let relaying_ieee = core.nib.address_map.eui64_for(next_hop_address); - // When forwarding packets to another node, update the counters for the neighbor - { - let mut core = self.core(); - let relaying_ieee = core.nib.address_map.eui64_for(next_hop_address); + if let Some(relaying_ieee) = relaying_ieee { + core.nib.neighbors.record_outbound_activity(relaying_ieee); + } - if let Some(relaying_ieee) = relaying_ieee { - core.nib.neighbors.record_outbound_activity(relaying_ieee); - } + // And the routing table counters + core.nib + .routing + .record_usage(nwk_frame.nwk_header.destination); + } - // And the routing table counters - core.nib - .routing - .record_usage(nwk_frame.nwk_header.destination); + self.increment_tx_total(); + + let Err(e) = self.send_802154_frame(ieee802154_frame).await else { + Self::resolve_completion(completion, Ok(())); + return; + }; + + // Spec Table 3-75: an unacknowledged unicast is a transmit failure recorded + // against the next hop. Counted per MCPS-DATA.request, like `nwkTxTotal` above, + // so the two stay on the same denominator. + if let ZigbeeStackError::NwkNoAck { .. } = e { + let mut core = self.core(); + if let Some(next_hop_eui64) = core.nib.address_map.eui64_for(next_hop_address) { + core.nib.neighbors.record_transmit_failure(next_hop_eui64); } + } - self.increment_tx_total(); + tracing::warn!("Failed to send unicast frame: {e}"); - match self.send_802154_frame(ieee802154_frame).await { - Ok(_) => { - break; - } - Err(e) => { - // Spec Table 3-75: an unacknowledged unicast is a transmit failure - // recorded against the next hop. Counted per MCPS-DATA.request, like - // `nwkTxTotal` above, so the two stay on the same denominator. - if let ZigbeeStackError::NwkNoAck { .. } = e { - let mut core = self.core(); - if let Some(next_hop_eui64) = - core.nib.address_map.eui64_for(next_hop_address) - { - core.nib.neighbors.record_transmit_failure(next_hop_eui64); - } - } + if attempts_remaining == 0 { + tracing::error!("Failed to send unicast frame after all attempts"); + self.handle_unicast_send_failure(&nwk_frame, next_hop_address); + Self::resolve_completion(completion, Err(e)); + return; + } - tracing::warn!("Failed to send unicast frame: {e}"); + // Park the frame for re-transmission after the retry delay. The plaintext frame + // is re-enqueued (not the ciphertext), so the next attempt earns a fresh counter + // at dequeue and on-air order stays equal to counter order. + tracing::debug!("Scheduling unicast retry, {attempts_remaining} attempt(s) remaining"); + self.schedule_unicast_retry( + nwk_frame, + next_hop_address, + security, + priority, + attempts_remaining - 1, + completion, + ); + } - 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!( - "Retrying unicast frame send, attempt {} of {}", - attempt, - self.tunables.unicast_retries - ); + /// Park a failed unicast for re-enqueue after [`unicast_retry_delay`] and wake the + /// retry reactor. + fn schedule_unicast_retry( + &self, + nwk_frame: NwkFrame, + next_hop: Nwk, + security: NwkSecurityMode, + priority: TxPriority, + attempts_remaining: u8, + completion: Option, + ) { + let next_attempt = self.core_now() + self.tunables.unicast_retry_delay; + self.state + .pending_unicast_retries + .lock() + .push(PendingUnicastRetry { + nwk_frame, + next_hop, + security, + priority, + attempts_remaining, + next_attempt, + completion, + }); + self.unicast_retry_wake.notify_one(); + } - R::sleep(self.tunables.unicast_retry_delay).await; + /// The unicast-retry reactor: a single long-lived task that re-enqueues failed + /// unicasts once their retry delay elapses, mirroring the broadcast-retransmit + /// reactor. + pub(super) async fn unicast_retry_task(&self) { + loop { + match self.earliest_unicast_retry() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.unicast_retry_wake.notified()) + .await; } + None => self.unicast_retry_wake.notified().await, } + + self.drive_unicast_retries(); } + } - Ok(()) + /// The soonest re-enqueue deadline across all parked retries, or `None` when none are + /// parked (the reactor then sleeps on its wake signal). + fn earliest_unicast_retry(&self) -> Option { + self.state + .pending_unicast_retries + .lock() + .iter() + .map(|retry| retry.next_attempt) + .min() + } + + /// One reactor pass: re-enqueue every parked retry whose delay has elapsed. The + /// re-enqueued frame competes by its priority and earns a fresh counter at dequeue. + fn drive_unicast_retries(&self) { + let now = self.core_now(); + + let due: Vec = { + let mut pending = self.state.pending_unicast_retries.lock(); + let mut due = Vec::new(); + let mut i = 0; + while i < pending.len() { + if pending[i].next_attempt <= now { + // Order does not matter (the priority queue reorders anyway), so an + // O(1) swap-remove is fine. + due.push(pending.swap_remove(i)); + } else { + i += 1; + } + } + drop(pending); + + due + }; + + for retry in due { + self.enqueue_send( + SendKind::Unicast { + nwk_frame: retry.nwk_frame, + next_hop: retry.next_hop, + security: retry.security, + attempts_remaining: retry.attempts_remaining, + }, + retry.priority, + retry.completion, + ); + } } /// A unicast exhausted its retries at the sender. The next hop is dead: invalidate @@ -1110,12 +1226,19 @@ impl ZigbeeStack { .collect(); for (child_eui64, child_nwk) in sleepy_children { - let finished = self.finish_unicast_nwk_frame(nwk_frame.clone(), child_nwk, security); + // Finished only when the child polls (see `IndirectFrame`). + let frame = IndirectFrame { + poll_address: Ieee802154Address::Eui64(child_eui64), + payload: IndirectPayload::Deferred { + nwk_frame: nwk_frame.clone(), + next_hop: child_nwk, + security, + }, + }; self.increment_tx_total(); - // We don't await the result - let _result_rx = - self.push_indirect_frame(Ieee802154Address::Eui64(child_eui64), finished); + // Fire-and-forget: a broadcast copy has no end-to-end result to await. + self.enqueue_indirect_frame(frame, None); } } diff --git a/crates/ziggurat-zigbee/src/indirect.rs b/crates/ziggurat-zigbee/src/indirect.rs index 9756da3..58a4f64 100644 --- a/crates/ziggurat-zigbee/src/indirect.rs +++ b/crates/ziggurat-zigbee/src/indirect.rs @@ -6,21 +6,23 @@ use alloc::collections::{BTreeMap, BTreeSet}; use crate::Instant; +use ziggurat_ieee_802154::Ieee802154Address; use ziggurat_ieee_802154::types::{Eui64, Nwk}; -use ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; -use crate::nwk::frame::EncryptedNwkFrame; - -/// A finished 802.15.4 frame awaiting indirect delivery (802.15.4 spec 6.7.3). +/// A frame awaiting indirect delivery (802.15.4 spec 6.7.3). /// /// The destination extracts it by polling with a MAC Data Request; the radio's /// automatic ACK of that poll has its frame pending bit set (via the source address /// match table), telling the device to keep listening. +/// +/// The payload type `F` is opaque to the queue: the driver chooses whether to store a +/// finished frame or one still to be encrypted at delivery time (so a frame can wait +/// here for seconds without holding a frame counter that would be stale once sent). #[derive(Debug)] -pub struct Transaction { +pub struct Transaction { /// The frame as queued; the frame pending bit is applied to a copy at delivery /// time, based on whether more transactions remain. - pub frame: Ieee802154Frame, + pub frame: F, pub expires_at: Instant, /// The driver's completion token, resolved on delivery, expiry, or drop. pub completion: C, @@ -28,10 +30,10 @@ pub struct Transaction { /// A transaction extracted by a poll, ready for transmission. #[derive(Debug)] -pub struct Delivery { +pub struct Delivery { /// The queue key the transaction was extracted from pub destination: Ieee802154Address, - pub transaction: Transaction, + pub transaction: Transaction, /// Further transactions remain queued: the delivered frame's pending bit is set /// so the device keeps polling (802.15.4 spec 6.7.3) pub more_pending: bool, @@ -41,11 +43,11 @@ pub struct Delivery { #[derive(Debug)] #[must_use = "the expired transactions must be failed and the delivery transmitted; \ dropping the outcome silently loses queued frames and leaks their completions"] -pub struct PollOutcome { +pub struct PollOutcome { /// Transactions that expired at the head of the queue, to be failed - pub expired: Vec<(Ieee802154Address, Transaction)>, + pub expired: Vec<(Ieee802154Address, Transaction)>, /// The oldest live transaction, if any - pub delivery: Option>, + pub delivery: Option>, } /// The source address match table contents most recently written to the RCP, used to @@ -71,13 +73,13 @@ impl SrcMatchTable { /// was queued under; a poll is matched against both its extended and short source /// address. #[derive(Debug)] -pub struct IndirectQueue { +pub struct IndirectQueue { /// How long a transaction awaits a poll before expiring persistence_time: Duration, - queue: BTreeMap>>, + queue: BTreeMap>>, } -impl IndirectQueue { +impl IndirectQueue { pub const fn new(persistence_time: Duration) -> Self { Self { persistence_time, @@ -85,13 +87,7 @@ impl IndirectQueue { } } - pub fn push( - &mut self, - destination: Ieee802154Address, - frame: Ieee802154Frame, - completion: C, - now: Instant, - ) { + pub fn push(&mut self, destination: Ieee802154Address, frame: F, completion: C, now: Instant) { self.queue .entry(destination) .or_default() @@ -105,7 +101,7 @@ impl IndirectQueue { /// 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, so a /// failed transmit goes back to the head of the queue for the next poll, keeping /// its original deadline. - pub fn requeue(&mut self, destination: Ieee802154Address, transaction: Transaction) { + pub fn requeue(&mut self, destination: Ieee802154Address, transaction: Transaction) { self.queue .entry(destination) .or_default() @@ -124,7 +120,7 @@ impl IndirectQueue { eui64: Option, nwk: Option, now: Instant, - ) -> PollOutcome { + ) -> PollOutcome { let keys = eui64 .map(Ieee802154Address::Eui64) .into_iter() @@ -184,7 +180,7 @@ impl IndirectQueue { &mut self, eui64: Option, nwk: Nwk, - ) -> Vec<(Ieee802154Address, Transaction)> { + ) -> Vec<(Ieee802154Address, Transaction)> { let keys = eui64 .map(Ieee802154Address::Eui64) .into_iter() @@ -210,7 +206,7 @@ impl IndirectQueue { /// uniform persistence time and requeued transmit failures keep their deadline. #[must_use = "expired transactions must be failed (their completions resolved); \ dropping them leaves their awaiters hanging"] - pub fn expire(&mut self, now: Instant) -> Vec<(Ieee802154Address, Transaction)> { + pub fn expire(&mut self, now: Instant) -> Vec<(Ieee802154Address, Transaction)> { let mut expired = Vec::new(); self.queue.retain(|&destination, transactions| { From 19ee6006f67a454b81f98e13ce7f7f7a2e4f81c5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:35:54 -0400 Subject: [PATCH 45/61] Add retry jitter --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index e020877..7b74d6d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -1096,7 +1096,12 @@ impl ZigbeeStack { attempts_remaining: u8, completion: Option, ) { - let next_attempt = self.core_now() + self.tunables.unicast_retry_delay; + let delay = self.tunables.unicast_retry_delay; + + // The frame has a random jitter of up to one retry delay period + let jitter = delay.mul_f32(crate::rng::random_f32()); + let next_attempt = self.core_now() + delay + jitter; + self.state .pending_unicast_retries .lock() From 7c17c058cb853d5fc7dfb20a2d7133919e5271f0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:44:53 -0400 Subject: [PATCH 46/61] Switch from Seeed XIAO ESP32-C6 to the ESP32-C6-DevKitM-1 --- crates/ziggurat-esp/src/main.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 7cd14d7..c2865d9 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -26,8 +26,6 @@ use embedded_io_async::Write; use esp_alloc as _; use esp_backtrace as _; use esp_hal::Async; -use esp_hal::delay::Delay; -use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::rng::Rng; use esp_hal::timer::timg::TimerGroup; @@ -119,22 +117,6 @@ async fn main(spawner: Spawner) -> ! { let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(esp_hal::clock::CpuClock::max())); - // XIAO ESP32-C6 antenna RF switch: GPIO3 low powers the switch, then (after it - // settles) GPIO14 low selects the onboard ceramic antenna. Without this the board - // uses the U.FL external port. Leaked so the pins stay driven for the process - // lifetime. - core::mem::forget(Output::new( - peripherals.GPIO3, - Level::Low, - OutputConfig::default(), - )); - Delay::new().delay_millis(100); - core::mem::forget(Output::new( - peripherals.GPIO14, - Level::Low, - OutputConfig::default(), - )); - let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); let timg0 = TimerGroup::new(peripherals.TIMG0); esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); From 75e8e712dcf0ea2ab2871b8769cda8bccb214150 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:02:21 -0400 Subject: [PATCH 47/61] Log to UART --- crates/ziggurat-esp/src/api.rs | 36 -------------------- crates/ziggurat-esp/src/log_sink.rs | 52 +++++------------------------ crates/ziggurat-esp/src/main.rs | 27 ++++++++++++++- 3 files changed, 34 insertions(+), 81 deletions(-) diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 4000e8b..155f84e 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -38,20 +38,6 @@ pub async fn emit(value: Value) { } } -/// Forward one log record as a `log` notification, without blocking. Called -/// synchronously from the tracing subscriber (often while the stack holds a lock), so -/// it must not `await`; a full outbound queue drops the record rather than stalling -/// the stack. -pub fn emit_log(level: &str, target: &str, message: &str) { - let line = notification( - "log", - json!({ "level": level, "target": target, "message": message }), - ); - if let Ok(text) = serde_json::to_string(&line) { - let _ = OUTBOUND.try_send(text); - } -} - pub fn hello_message(configured: bool) -> Value { let state = if configured { "running" @@ -220,27 +206,6 @@ struct SetProvisionalKeyRequest { key: Key, } -#[derive(Deserialize)] -struct SetLogLevelRequest { - /// `off` / `error` / `warn` / `info` / `debug` / `trace`. - level: String, -} - -/// Adjust the firmware log verbosity at runtime (records are forwarded as `log` -/// notifications). Live-settable so a session can flip to `debug` without a -/// reconfigure (which would reset the stack). -fn handle_set_log_level(id: u64, params: Value) -> Value { - let request: SetLogLevelRequest = match serde_json::from_value(params) { - Ok(request) => request, - Err(e) => return error_response(id, "invalid_request", e), - }; - - match crate::log_sink::set_log_level(&request.level) { - Some(level) => response(id, json!({"status": "success", "level": level})), - None => error_response(id, "invalid_request", "unknown log level"), - } -} - #[derive(Deserialize)] #[serde(rename_all = "lowercase")] enum ResetType { @@ -296,7 +261,6 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { } "set_nwk_update_id" => handle_set_nwk_update_id(app, id, params), "set_provisional_key" => handle_set_provisional_key(app, id, params), - "set_log_level" => handle_set_log_level(id, params), other => error_response(id, "unknown_method", other), }; diff --git a/crates/ziggurat-esp/src/log_sink.rs b/crates/ziggurat-esp/src/log_sink.rs index d0b7f4a..de5424f 100644 --- a/crates/ziggurat-esp/src/log_sink.rs +++ b/crates/ziggurat-esp/src/log_sink.rs @@ -1,52 +1,15 @@ -//! A `tracing` subscriber that forwards log records to the host as `log` notifications on -//! the JSON API, rather than to a serial console — the USB-Serial-JTAG already carries the -//! protocol, so println-style logging there would corrupt it. +//! A `tracing` subscriber that writes log records to the UART. use alloc::string::String; use core::fmt::{self, Write}; -use core::sync::atomic::{AtomicU8, Ordering}; use tracing::field::{Field, Visit}; use tracing::subscriber::Interest; use tracing::{Event, Level, Metadata, Subscriber, span}; -use crate::api; +use crate::LOG_OUTBOUND; -/// Verbosity threshold as a rank; records at or below it are forwarded. -static MAX_LEVEL: AtomicU8 = AtomicU8::new(3); - -const fn rank(level: &Level) -> u8 { - match *level { - Level::ERROR => 1, - Level::WARN => 2, - Level::INFO => 3, - Level::DEBUG => 4, - Level::TRACE => 5, - } -} - -/// Set the verbosity threshold from a level name (`off`/`error`/`warn`/`info`/`debug`/ -/// `trace`); unknown names are ignored. Returns the level applied, if recognized. -pub fn set_log_level(level: &str) -> Option<&'static str> { - let rank = match level { - "off" => 0, - "error" => 1, - "warn" => 2, - "info" => 3, - "debug" => 4, - "trace" => 5, - _ => return None, - }; - MAX_LEVEL.store(rank, Ordering::Relaxed); - Some(match rank { - 0 => "off", - 1 => "error", - 2 => "warn", - 3 => "info", - 4 => "debug", - _ => "trace", - }) -} +const MAX_LEVEL: Level = Level::DEBUG; pub fn install() { let _ = tracing::subscriber::set_global_default(LogSink); @@ -60,16 +23,17 @@ impl Subscriber for LogSink { } fn enabled(&self, metadata: &Metadata<'_>) -> bool { - rank(metadata.level()) <= MAX_LEVEL.load(Ordering::Relaxed) + *metadata.level() <= MAX_LEVEL } fn event(&self, event: &Event<'_>) { let metadata = event.metadata(); - let mut message = String::new(); - event.record(&mut MessageVisitor(&mut message)); + let mut line = String::new(); + let _ = write!(line, "{} {}: ", metadata.level().as_str(), metadata.target()); + event.record(&mut MessageVisitor(&mut line)); - api::emit_log(metadata.level().as_str(), metadata.target(), &message); + let _ = LOG_OUTBOUND.try_send(line); } // Events-only: spans are not used by the stack, so span bookkeeping is a no-op. diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index c2865d9..caa5fc0 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -29,6 +29,7 @@ use esp_hal::Async; use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::rng::Rng; use esp_hal::timer::timg::TimerGroup; +use esp_hal::uart::{Config as UartConfig, UartTx}; use esp_hal::usb::usb_serial_jtag::{UsbSerialJtag, UsbSerialJtagRx, UsbSerialJtagTx}; use ziggurat_driver::rng; @@ -51,6 +52,11 @@ pub static OUTBOUND: Channel, INBOUND_DEPTH> = Channel::new(); +/// Plaintext log lines bound for the UART console. +const LOG_OUTBOUND_DEPTH: usize = 32; +pub static LOG_OUTBOUND: Channel = + Channel::new(); + /// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` /// signals it so the task exits and frees the radio. pub type CaptureStop = embassy_sync::signal::Signal; @@ -112,6 +118,18 @@ async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { } } +/// Drains the log channel to the UART console. The actual (blocking) UART write happens +/// here, off the stack's critical path, exactly like `writer_task` does for the JSON bus. +#[embassy_executor::task] +async fn uart_log_task(mut tx: UartTx<'static, Async>) { + loop { + let line = LOG_OUTBOUND.receive().await; + let _ = tx.write_all(line.as_bytes()).await; + let _ = tx.write_all(b"\r\n").await; + // No flush needed: the UART peripheral shifts the FIFO out autonomously. + } +} + #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { let peripherals = @@ -123,7 +141,14 @@ async fn main(spawner: Spawner) -> ! { esp_alloc::heap_allocator!(size: 96 * 1024); - // Forward the stack's `tracing` records to the host as `log` notifications. + // Bring up the UART console + let log_uart = UartTx::new(peripherals.UART0, UartConfig::default().with_baudrate(460_800)) + .expect("UART0 config") + .with_tx(peripherals.GPIO16) + .into_async(); + spawner.spawn(uart_log_task(log_uart).unwrap()); + + // Route the stack's `tracing` records to the UART console. log_sink::install(); // Route Zigbee crypto through the AES accelerator: CCM* runs as two DMA passes From 6edebd4805b40f9bfc7d547f66a0f254749d8403 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:41:07 -0400 Subject: [PATCH 48/61] Ensure we encrypt command replies with the same key as the request --- crates/ziggurat-driver/src/zigbee_stack.rs | 2 +- .../src/zigbee_stack/joining.rs | 55 ++++++++++++++----- crates/ziggurat-zigbee/src/aps/security.rs | 50 +++++++++++++++-- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 57db999..e899188 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1008,7 +1008,7 @@ impl ZigbeeStack { continue; } Ok(ApsFrame::Command(cmd)) => { - self.handle_aps_command_frame(&nwk_frame, &cmd, None); + self.handle_aps_command_frame(&nwk_frame, &cmd, None, None); continue; } Ok(ApsFrame::EncryptedCommand(encrypted_cmd)) => { diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 3c8e3ae..2ff6147 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -18,6 +18,7 @@ use ziggurat_zigbee::aps::frame::{ ApsTunnelCommandFrame, ApsUpdateDeviceCommandFrame, ApsUpdateDeviceStatus, ApsVerifyKeyCommandFrame, EncryptedApsCommandFrame, }; +use ziggurat_zigbee::aps::security::AppliedKey; use ziggurat_zigbee::nwk::frame::{ BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderKeyId, }; @@ -474,9 +475,14 @@ impl ZigbeeStack { drop(core); match decrypted { - Some(command_frame) => { + Some((command_frame, applied_key)) => { tracing::debug!("Decrypted APS command frame: {command_frame:?}"); - self.handle_aps_command_frame(nwk_frame, &command_frame, Some(extended_source)); + self.handle_aps_command_frame( + nwk_frame, + &command_frame, + Some(extended_source), + Some(applied_key), + ); } None => { tracing::warn!( @@ -502,6 +508,8 @@ impl ZigbeeStack { nwk_frame: &NwkFrame, command_frame: &ApsCommandFrame, aps_source_ieee: Option, + // Which key decrypted this command, when it was APS-secured. + applied_key: Option, ) { let source = nwk_frame.nwk_header.source; @@ -521,7 +529,13 @@ impl ZigbeeStack { } ApsCommandFrameCommand::RequestKey(cmd) => { if self.state.role == NwkDeviceType::Coordinator { - self.handle_request_key(nwk_frame, command_frame, cmd, aps_source_ieee); + self.handle_request_key( + nwk_frame, + command_frame, + cmd, + aps_source_ieee, + applied_key, + ); } else { tracing::debug!("Ignoring request-key from {source:?}: not the trust center"); } @@ -566,14 +580,14 @@ impl ZigbeeStack { } /// Zigbee spec 4.7.3.8: a device requests a unique trust center link key to replace - /// the well-known key it joined with. The new key is delivered encrypted with the - /// key-load key derived from the device's current link key. + /// the well-known key it joined with. fn handle_request_key( &self, nwk_frame: &NwkFrame, command_frame: &ApsCommandFrame, request: &ApsRequestKeyCommandFrame, aps_source_ieee: Option, + applied_key: Option, ) { if request.key_type != ApsRequestKeyType::TrustCenterLinkKey { tracing::warn!( @@ -615,16 +629,19 @@ impl ZigbeeStack { return; } + // The reply reuses whichever key decrypted the request (spec 4.7.3.8 step 2): the + // key the device actually holds. A secured request always has one. + let Some(applied_key) = applied_key else { + return; + }; + tracing::info!("Sending a new trust center link key to {source_ieee:?}"); - // The new key is delivered encrypted with the key it replaces - let mut core = self.core(); - let current_key = core.aib.aps_security.device_link_key(source_ieee); - let new_key = core + let new_key = self + .core() .aib .aps_security .issue_device_key(source_ieee, Key(crate::rng::random_array())); - drop(core); // The key is persisted only once the device proves possession via Verify-Key // (see `handle_verify_key`); a device that never completes the exchange must not @@ -652,11 +669,19 @@ impl ZigbeeStack { }), }; - let encrypted_command = self.core().aib.aps_security.encrypt_command_with_link_key( - ¤t_key, - NwkSecurityHeaderKeyId::KeyLoadKey, - &transport_key_command, - ); + let Some(encrypted_command) = self + .core() + .aib + .aps_security + .encrypt_command_with_applied_key( + source_ieee, + applied_key, + NwkSecurityHeaderKeyId::KeyLoadKey, + &transport_key_command, + ) + else { + return; + }; self.send_secured_aps_payload(nwk_frame.nwk_header.source, encrypted_command.to_bytes()); } diff --git a/crates/ziggurat-zigbee/src/aps/security.rs b/crates/ziggurat-zigbee/src/aps/security.rs index 3cd2613..f5a6ccd 100644 --- a/crates/ziggurat-zigbee/src/aps/security.rs +++ b/crates/ziggurat-zigbee/src/aps/security.rs @@ -63,6 +63,17 @@ pub struct DeviceLinkKey { pub attributes: KeyAttributes, } +/// Which key decrypted an inbound APS frame. Lets a reply be secured with the same +/// key-pair entry (spec 4.7.3.8 step 2) without copying the key material out of here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppliedKey { + Network, + /// The device's key-pair entry (`device_link_key`). + Device, + /// The well-known global link key. + Global, +} + /// What we track per peer device. The two have independent lifetimes (a seed-derived /// device has a counter but no stored key, and reissuing a key /// (`issue_device_key`) must not reset the counter) so each field is independently @@ -311,6 +322,25 @@ impl ApsSecurity { command.encrypt(&key, &aux_header) } + /// Encrypt a command reply with the same key-pair entry that decrypted the request + /// (spec 4.7.3.8 step 2). `None` when `applied` is the network key, which can't secure + /// a command reply. + pub fn encrypt_command_with_applied_key( + &mut self, + source: Eui64, + applied: AppliedKey, + key_id: NwkSecurityHeaderKeyId, + command: &ApsCommandFrame, + ) -> Option { + let link_key = match applied { + AppliedKey::Network => return None, + AppliedKey::Device => self.device_link_key(source), + AppliedKey::Global => self.global_link_key.clone(), + }; + + Some(self.encrypt_command_with_link_key(&link_key, key_id, command)) + } + /// The key allowed to APS-encrypt outgoing data frames and ACKs for a device. /// Spec 4.4.1.1 step 1a: only provisional or verified `apsDeviceKeyPairSet` /// entries may encrypt; a key issued to a device but not yet verified may not. @@ -371,13 +401,16 @@ impl ApsSecurity { /// well-known key until their key exchange completes, so retried frames may still /// use it even when a unique key is on record). Frames secured with a unique link /// key are checked against the incoming frame counter to reject replays. + /// + /// Also returns which key it was secured under, so a reply can reuse the same + /// key-pair entry (spec 4.7.3.8 step 2). fn decrypt_frame( &mut self, source: Eui64, aux_header: &ApsAuxHeader, network_key: &Key, decrypt: impl Fn(&Key) -> Option, - ) -> Option { + ) -> Option<(T, AppliedKey)> { // Spec 4.4.1.2 step 1: the maximum frame counter value is never valid if aux_header.frame_counter == u32::MAX { return None; @@ -386,7 +419,7 @@ impl ApsSecurity { let key_id = aux_header.security_control.key_id; if key_id == NwkSecurityHeaderKeyId::NetworkKey { - return decrypt(network_key); + return decrypt(network_key).map(|frame| (frame, AppliedKey::Network)); } let mut candidate_keys = vec![self.device_link_key(source)]; @@ -398,9 +431,14 @@ impl ApsSecurity { let key = Self::select_key(link_key, key_id).expect("NetworkKey is handled above"); decrypt(&key).map(|frame| (link_key, frame)) })?; + let applied = if *link_key == self.global_link_key { + AppliedKey::Global + } else { + AppliedKey::Device + }; // Spec 4.4.1.2 steps 4 and 9: replay protection applies to unique link keys - if *link_key != self.global_link_key { + if applied == AppliedKey::Device { if let Some(minimum) = self .devices .get(&source) @@ -420,7 +458,7 @@ impl ApsSecurity { .incoming_frame_counter = Some(aux_header.frame_counter + 1); } - Some(frame) + Some((frame, applied)) } pub fn decrypt_command( @@ -428,7 +466,7 @@ impl ApsSecurity { source: Eui64, frame: &EncryptedApsCommandFrame, network_key: &Key, - ) -> Option { + ) -> Option<(ApsCommandFrame, AppliedKey)> { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key).ok() }) @@ -443,6 +481,7 @@ impl ApsSecurity { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key, source).ok() }) + .map(|(frame, _key)| frame) } pub fn decrypt_ack( @@ -454,5 +493,6 @@ impl ApsSecurity { self.decrypt_frame(source, &frame.aux_header, network_key, |key| { frame.decrypt(key, source).ok() }) + .map(|(frame, _key)| frame) } } From 4fd75d69e928bde39331951bd9e228d4745b888f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:14:24 -0400 Subject: [PATCH 49/61] Await-less APS send API --- crates/ziggurat-driver/src/zigbee_stack.rs | 40 ++- .../ziggurat-driver/src/zigbee_stack/aps.rs | 264 ++++++++++++++---- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 2 +- crates/ziggurat-esp/src/api.rs | 122 ++++---- crates/ziggurat-server/src/main.rs | 16 +- 5 files changed, 333 insertions(+), 111 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index e899188..c849a81 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -270,6 +270,17 @@ pub struct ApsAckWaiter { pub(crate) ack_data: ApsAckData, } +/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end ack. +#[derive(Debug)] +pub enum ApsAckPending { + /// A caller awaits the ack via [`ZigbeeStack::wait_aps_ack`]; the ack resolves this + /// signal and the caller applies its own timeout. + Waiter(Signal<()>), + /// A fire-and-forget [`ZigbeeStack::send_aps`]: the ack (or its `deadline` passing) + /// is pushed as an [`ZigbeeNotification::ApsSendOutcome`] carrying `token`. + Notify { token: u64, deadline: CoreInstant }, +} + /// A transmit queued for the single sender task ([`ZigbeeStack::sender_task`]). The NWK /// frame is unencrypted: the sender assigns the frame counter at dequeue, so on-air order /// always matches frame-counter order regardless of priority reordering in the queue. @@ -523,7 +534,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>, @@ -740,6 +751,18 @@ pub enum ZigbeeNotification { frame_counter: u32, key_id: String, }, + /// The outcome of a fire-and-forget application APS send, correlated by the `token` + /// the caller supplied to [`ZigbeeStack::send_aps`]. + ApsSendOutcome { token: u64, result: ApsSendResult }, +} + +/// The end-to-end result of an ack-requested [`ZigbeeStack::send_aps`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApsSendResult { + /// The destination's APS ack arrived. + Delivered, + /// No APS ack arrived before the deadline (the frame may never have been routed). + AckTimeout, } #[derive(Debug, Clone)] @@ -805,6 +828,9 @@ pub struct ZigbeeStack /// Wakes the unicast-retry reactor whenever a failed unicast is parked for a later /// re-enqueue. pub(crate) unicast_retry_wake: Notify, + /// Wakes the APS-ack timeout reactor when a fire-and-forget send registers a pending + /// ack with a deadline. + pub(crate) aps_ack_wake: Notify, /// Wakes the beacon-spam reactor when a beacon request opens its spray window. pub(crate) beacon_spam_wake: Notify, /// Wakes the maintenance task when a new indirect transaction or child entry @@ -905,6 +931,7 @@ impl ZigbeeStack { link_status_received: Notify::new(), broadcast_retransmit_wake: Notify::new(), unicast_retry_wake: Notify::new(), + aps_ack_wake: Notify::new(), beacon_spam_wake: Notify::new(), maintenance_wake: Notify::new(), send_queue: Mutex::new(BinaryHeap::new()), @@ -1147,6 +1174,17 @@ impl ZigbeeStack { arc_self.unicast_retry_task().await; }); + // Times out fire-and-forget APS sends whose ack never arrived, reporting the + // outcome as a notification. + let arc_self = self + .self_weak + .upgrade() + .expect("Unable to upgrade self reference"); + + self.spawn_tracked(async move { + arc_self.aps_ack_timeout_task().await; + }); + // Sprays beacons while a beacon-spam window is open (the hack_beacon_spam_duration // hack). Idle unless beacon requests open the window. let arc_self = self diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 2891437..0ed1bfa 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -5,17 +5,21 @@ use ziggurat_zigbee::aps::frame::{ ApsAckFrame, ApsAckFrameControl, ApsDataFrame, ApsDeliveryMode, ApsFrameControl, ApsFrameType, EncryptedApsAckFrame, EncryptedApsDataFrame, }; -use ziggurat_zigbee::nwk::frame::{BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery}; +use ziggurat_zigbee::nwk::frame::{ + BROADCAST_LOW_POWER_ROUTERS, BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery, +}; use crate::signal; use alloc::collections::btree_map::Entry; use alloc::vec::Vec; use core::cmp; +use core::time::Duration; use ziggurat_phy::RadioPhy; +use ziggurat_zigbee::Instant as CoreInstant; use super::{ - ApsAck, ApsAckData, ApsAckWaiter, NwkSecurityMode, SendMode, TxPriority, ZigbeeStack, - ZigbeeStackError, + ApsAck, ApsAckData, ApsAckPending, ApsAckWaiter, ApsSendResult, NwkSecurityMode, SendMode, + TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -75,14 +79,21 @@ impl ZigbeeStack { .decrypt_ack(source, frame, &network_key) } - /// Resolve an inbound APS ACK against the pending transmissions waiting for it. + /// Resolve an inbound APS ACK against the pending transmissions waiting for it: wake an + /// awaiting caller, or push the delivery outcome for a fire-and-forget send. pub(super) fn handle_aps_ack(&self, nwk_frame: &NwkFrame, ack: &ApsAckFrame) { let ack_data = ApsAckData::from_aps_ack(nwk_frame.nwk_header.source, ack); tracing::trace!("Received APS ack: {ack_data:?}"); - let tx = self.state.pending_aps_acks.lock().remove(&ack_data); - if let Some(tx) = tx { - tx.signal(()); + match self.state.pending_aps_acks.lock().remove(&ack_data) { + Some(ApsAckPending::Waiter(tx)) => tx.signal(()), + Some(ApsAckPending::Notify { token, .. }) => { + self.push_notification(ZigbeeNotification::ApsSendOutcome { + token, + result: ApsSendResult::Delivered, + }); + } + None => {} } } @@ -166,16 +177,14 @@ impl ZigbeeStack { self.background_send_nwk_frame(aps_ack_frame, NwkSecurityMode::NetworkKey, SendMode::Route); } - /// Send an APS data frame, returning once it has been transmitted (including - /// route discovery and the NWK retry loop; for sleepy children, once the frame is - /// extracted from the indirect queue). When an APS ack was requested, the - /// returned waiter resolves the end-to-end delivery via - /// [`ZigbeeStack::wait_aps_ack`]. + /// Build the NWK frame carrying an APS data frame, plus the ack-correlation data when + /// an end-to-end ack was requested. Shared by the awaiting [`Self::send_aps_command`] + /// and the fire-and-forget [`Self::send_aps`]. /// /// `aps_security` requests APS encryption of the ASDU with the link key shared /// with that device (unicast only: link keys are pairwise). #[allow(clippy::too_many_arguments)] - pub async fn send_aps_command( + fn prepare_aps_send( &self, delivery_mode: ApsDeliveryMode, destination: Nwk, @@ -188,8 +197,7 @@ impl ZigbeeStack { aps_seq: u8, data: Vec, aps_security: Option, - priority: TxPriority, - ) -> Result, ZigbeeStackError> { + ) -> Result<(NwkFrame, Option), ZigbeeStackError> { let asdu = FrameBytes::from_slice(&data).map_err(|_| ZigbeeStackError::PayloadTooLong)?; let aps_frame = match delivery_mode { @@ -208,7 +216,7 @@ impl ZigbeeStack { profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, ApsDeliveryMode::Broadcast => ApsDataFrame { frame_control: ApsFrameControl { @@ -225,7 +233,7 @@ impl ZigbeeStack { profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, ApsDeliveryMode::Multicast => ApsDataFrame { frame_control: ApsFrameControl { @@ -242,20 +250,19 @@ impl ZigbeeStack { profile_id, source_endpoint: src_ep, counter: aps_seq, - asdu: asdu.clone(), + asdu, }, }; tracing::trace!("Prepared APS frame: {aps_frame:?}"); let aps_payload = if let Some(destination_eui64) = aps_security { - let encrypted = self + match self .core() .aib .aps_security - .encrypt_data(destination_eui64, &aps_frame); - - match encrypted { + .encrypt_data(destination_eui64, &aps_frame) + { Some(encrypted) => encrypted.to_bytes(), None => return Err(ZigbeeStackError::ApsSecurityFailed), } @@ -276,7 +283,64 @@ impl ZigbeeStack { .with_discover_route(NwkRouteDiscovery::Enable) .with_radius(cmp::max(radius, 1)); - if aps_ack == ApsAck::None { + let ack_data = (aps_ack == ApsAck::Request).then_some(ApsAckData { + src: destination, + destination_endpoint: Some(src_ep), // These are swapped + cluster_id: Some(cluster_id), + profile_id: Some(profile_id), + source_endpoint: Some(dst_ep), // These are swapped + counter: aps_seq, + }); + + Ok((nwk_frame, ack_data)) + } + + /// How long to wait for a device's APS ack: longer for a sleepy child, which only + /// sees (and acks) the frame after polling. + fn aps_ack_timeout(&self, destination: Nwk) -> Duration { + if self.sleepy_child_eui64(destination).is_some() { + self.tunables.aps_ack_timeout_indirect + } else { + self.tunables.aps_ack_timeout + } + } + + /// Send an APS data frame, returning once it has been transmitted (including + /// route discovery and the NWK retry loop; for sleepy children, once the frame is + /// extracted from the indirect queue). When an APS ack was requested, the + /// returned waiter resolves the end-to-end delivery via + /// [`ZigbeeStack::wait_aps_ack`]. + #[allow(clippy::too_many_arguments)] + pub async fn send_aps_command( + &self, + delivery_mode: ApsDeliveryMode, + destination: Nwk, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: ApsAck, + radius: u8, + aps_seq: u8, + data: Vec, + aps_security: Option, + priority: TxPriority, + ) -> Result, ZigbeeStackError> { + let (nwk_frame, ack_data) = self.prepare_aps_send( + delivery_mode, + destination, + profile_id, + cluster_id, + src_ep, + dst_ep, + aps_ack, + radius, + aps_seq, + data, + aps_security, + )?; + + let Some(ack_data) = ack_data else { self.send_nwk_frame( nwk_frame, NwkSecurityMode::NetworkKey, @@ -285,26 +349,14 @@ impl ZigbeeStack { ) .await?; return Ok(None); - } - - let ack_data = ApsAckData { - src: destination, - destination_endpoint: Some(src_ep), // These are swapped - cluster_id: Some(cluster_id), - profile_id: Some(profile_id), - source_endpoint: Some(dst_ep), // These are swapped - counter: aps_seq, }; let (ack_tx, ack_rx) = signal::channel(); - tracing::debug!("APS ACK requested, waiting for {ack_data:?}"); - { - self.state - .pending_aps_acks - .lock() - .insert(ack_data.clone(), ack_tx); - } + self.state + .pending_aps_acks + .lock() + .insert(ack_data.clone(), ApsAckPending::Waiter(ack_tx)); if let Err(err) = self .send_nwk_frame( @@ -319,20 +371,138 @@ impl ZigbeeStack { return Err(err); } - // A sleepy child only sees the frame (and acks it) after polling - let timeout = if self.sleepy_child_eui64(destination).is_some() { - self.tunables.aps_ack_timeout_indirect - } else { - self.tunables.aps_ack_timeout - }; - Ok(Some(ApsAckWaiter { receiver: ack_rx, - timeout, + timeout: self.aps_ack_timeout(destination), ack_data, })) } + /// Fire-and-forget application APS send: build and enqueue the frame, then return. + /// Delivery is driven entirely by the tables (route discovery, NWK retries, ack + /// correlation), so there is nothing to await. For an ack-requested unicast the + /// end-to-end outcome arrives later as a [`ZigbeeNotification::ApsSendOutcome`] + /// carrying `token`; a no-ack send has no further outcome (its enqueue succeeding is + /// the whole result). Returns an error only for the synchronous failures that prevent + /// enqueue (oversized payload, APS encryption). + #[allow(clippy::too_many_arguments)] + pub fn send_aps( + &self, + delivery_mode: ApsDeliveryMode, + destination: Nwk, + profile_id: u16, + cluster_id: u16, + src_ep: u8, + dst_ep: u8, + aps_ack: ApsAck, + radius: u8, + aps_seq: u8, + data: Vec, + aps_security: Option, + priority: TxPriority, + token: u64, + ) -> Result<(), ZigbeeStackError> { + let (nwk_frame, ack_data) = self.prepare_aps_send( + delivery_mode, + destination, + profile_id, + cluster_id, + src_ep, + dst_ep, + aps_ack, + radius, + aps_seq, + data, + aps_security, + )?; + + // Register the ack before enqueueing so it is caught however fast the reply comes; + // the timeout reactor reports the outcome if it never arrives. + if let Some(ack_data) = ack_data { + let deadline = self.core_now() + self.aps_ack_timeout(destination); + self.state + .pending_aps_acks + .lock() + .insert(ack_data, ApsAckPending::Notify { token, deadline }); + self.aps_ack_wake.notify_one(); + } + + // Fire-and-forget, mirroring send_nwk_frame's broadcast/unicast split without + // awaiting the outcome. + if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { + self.send_broadcast_nwk_frame(nwk_frame, NwkSecurityMode::NetworkKey, priority); + } else { + self.originate_unicast( + nwk_frame, + NwkSecurityMode::NetworkKey, + SendMode::Route, + priority, + None, + ); + } + + Ok(()) + } + + /// The APS-ack timeout reactor: sleeps to the earliest pending fire-and-forget send's + /// deadline, then reports [`ApsSendResult::AckTimeout`] for any that lapsed. Awaited + /// (`Waiter`) entries are timed out by their caller and ignored here. + pub(super) async fn aps_ack_timeout_task(&self) { + loop { + match self.earliest_aps_ack_deadline() { + Some(deadline) => { + let _ = self + .timeout_at_core(deadline, self.aps_ack_wake.notified()) + .await; + } + None => self.aps_ack_wake.notified().await, + } + + self.expire_aps_acks(); + } + } + + fn earliest_aps_ack_deadline(&self) -> Option { + self.state + .pending_aps_acks + .lock() + .values() + .filter_map(|pending| match pending { + ApsAckPending::Notify { deadline, .. } => Some(*deadline), + ApsAckPending::Waiter(_) => None, + }) + .min() + } + + fn expire_aps_acks(&self) { + let now = self.core_now(); + + let expired: Vec = { + let mut pending = self.state.pending_aps_acks.lock(); + let due: Vec<(ApsAckData, u64)> = pending + .iter() + .filter_map(|(key, p)| match p { + ApsAckPending::Notify { deadline, token } if *deadline <= now => { + Some((key.clone(), *token)) + } + _ => None, + }) + .collect(); + for (key, _) in &due { + pending.remove(key); + } + due.into_iter().map(|(_, token)| token).collect() + }; + + for token in expired { + tracing::warn!("APS ack timed out for send {token}"); + self.push_notification(ZigbeeNotification::ApsSendOutcome { + token, + result: ApsSendResult::AckTimeout, + }); + } + } + /// 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.wait()).await { diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 7b74d6d..308312f 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -446,7 +446,7 @@ impl ZigbeeStack { /// 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( + pub(super) fn originate_unicast( &self, mut nwk_frame: NwkFrame, security: NwkSecurityMode, diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 155f84e..3c075f8 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -18,12 +18,11 @@ use crate::CaptureStop; use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, TxPriority, - WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ApsSendResult, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, + TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; -use ziggurat_phy_esp::EspPhy; use ziggurat_zigbee::aps::frame::ApsDeliveryMode; use crate::{App, OUTBOUND}; @@ -241,8 +240,8 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), "send_aps" => { - // A send blocks on route discovery (seconds) and the APS-ack wait, so it - // runs on its own task. + // Fire-and-forget: enqueues and returns. A no-ack send is answered inline; an + // ack send is answered later by an `ApsSendOutcome` notification. dispatch_send_aps(app, id, params).await; return; } @@ -368,15 +367,10 @@ fn handle_get_hw_address(id: u64) -> Value { let mac = esp_hal::efuse::base_mac_address(); let mac = mac.as_bytes(); // Big-endian EUI-64: first 3 MAC bytes, FF FE, last 3 MAC bytes. - let big_endian = [ - mac[0], mac[1], mac[2], 0xff, 0xfe, mac[3], mac[4], mac[5], - ]; + let big_endian = [mac[0], mac[1], mac[2], 0xff, 0xfe, mac[3], mac[4], mac[5]]; let mut le = big_endian; le.reverse(); - response( - id, - json!({"ieee_address": eui64_to_string(Eui64(le))}), - ) + response(id, json!({"ieee_address": eui64_to_string(Eui64(le))})) } fn handle_get_network_info(app: &App, id: u64) -> Value { @@ -420,23 +414,16 @@ fn handle_get_network_info(app: &App, id: u64) -> Value { ) } -/// Validate that a stack is running, then spawn the send on its own task. Sends block -/// on route discovery and the APS-ack wait, so handling one inline in the command loop -/// would serialize the whole API behind each transmit. The task holds its own stack -/// handle(`Arc`), so it outlives this borrow. +/// Fire-and-forget APS send: build and enqueue the frame, then return. No task, no +/// blocking — delivery is driven by the stack's tables. A no-ack send is answered +/// `sent` right here; an ack send is answered later by an `ApsSendOutcome` notification +/// (keyed by the request id) once the ack arrives or times out. async fn dispatch_send_aps(app: &App, id: u64, params: Value) { let Some(stack) = app.stack.as_ref() else { emit(error_response(id, "not_configured", "no stack is running")).await; return; }; - app.spawner - .spawn(Box::pin(run_send_aps(stack.clone(), id, params))); -} - -/// Drives one send to completion (transmit, then APS-ack wait), emitting the lifecycle -/// events and terminal response itself. -async fn run_send_aps(stack: Arc>, id: u64, params: Value) { let request: SendApsRequest = match serde_json::from_value(params) { Ok(request) => request, Err(e) => return emit(error_response(id, "invalid_request", e)).await, @@ -447,12 +434,21 @@ async fn run_send_aps(stack: Arc>, id: u64, params: Value) { (Some(eui64), None) => match stack.state.core.lock().nib.address_map.nwk_for(eui64) { Some(nwk) => nwk, None => { - return emit(error_response(id, "unknown_destination_eui64", format!("{eui64:?}"))) - .await; + return emit(error_response( + id, + "unknown_destination_eui64", + format!("{eui64:?}"), + )) + .await; } }, (None, None) => { - return emit(error_response(id, "missing_destination", "no destination given")).await; + return emit(error_response( + id, + "missing_destination", + "no destination given", + )) + .await; } }; @@ -477,39 +473,33 @@ async fn run_send_aps(stack: Arc>, id: u64, params: Value) { None }; - let ack_waiter = match stack - .send_aps_command( - request.delivery_mode, - destination, - request.profile_id, - request.cluster_id, - request.src_ep, - request.dst_ep, - if request.aps_ack { - ApsAck::Request - } else { - ApsAck::None - }, - request.radius, - request.aps_seq, - asdu, - aps_security, - TxPriority(request.priority), - ) - .await - { - Ok(ack_waiter) => ack_waiter, - Err(e) => return emit(error_response(id, "transmit_failed", e)).await, + let aps_ack = if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None }; - emit(event(id, "transmitted")).await; + let outcome = stack.send_aps( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + aps_ack, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + id, + ); - let message = match ack_waiter { - None => response(id, json!({"status": "sent"})), - Some(waiter) => match stack.wait_aps_ack(waiter).await { - Ok(()) => response(id, json!({"status": "delivered"})), - Err(e) => error_response(id, "aps_ack_timeout", e), - }, + let message = match outcome { + // An ack send's terminal response arrives later as an `ApsSendOutcome`. + Ok(()) if request.aps_ack => return, + Ok(()) => response(id, json!({"status": "sent"})), + Err(e) => error_response(id, "transmit_failed", e), }; emit(message).await; } @@ -582,7 +572,12 @@ async fn handle_network_scan(app: &App, id: u64, params: Value) -> Value { break; } for beacon in batch { - emit(event_data(id, "network_found", network_beacon_json(&beacon))).await; + emit(event_data( + id, + "network_found", + network_beacon_json(&beacon), + )) + .await; } } @@ -756,9 +751,10 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { "lqi": lqi, "rssi": rssi, "data": hex::encode(data), }), ), - ZigbeeNotification::FrameCounterUpdate { frame_counter } => { - notification("frame_counter_update", json!({"frame_counter": frame_counter})) - } + ZigbeeNotification::FrameCounterUpdate { frame_counter } => notification( + "frame_counter_update", + json!({"frame_counter": frame_counter}), + ), ZigbeeNotification::LinkKeyUpdate { ieee, key } => notification( "link_key_update", json!({"ieee": eui64_to_string(ieee), "key": key_to_string(&key)}), @@ -792,5 +788,11 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { "key_id": key_id, }), ), + // A fire-and-forget `send_aps` outcome: the token is the request id, so this is + // the terminal response to that request, not a notification. + ZigbeeNotification::ApsSendOutcome { token, result } => match result { + ApsSendResult::Delivered => response(token, json!({"status": "delivered"})), + ApsSendResult::AckTimeout => error_response(token, "aps_ack_timeout", "no APS ack"), + }, } } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 985d42a..977d148 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -18,8 +18,8 @@ use tracing_subscriber::{EnvFilter, fmt}; use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, - TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ApsSendResult, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, + TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -339,6 +339,18 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json "key_id": key_id, }), ), + // The server uses the awaiting send path, so it never emits this; mapped for + // exhaustiveness. + ZigbeeNotification::ApsSendOutcome { token, result } => notification( + "aps_send_outcome", + json!({ + "token": token, + "result": match result { + ApsSendResult::Delivered => "delivered", + ApsSendResult::AckTimeout => "aps_ack_timeout", + }, + }), + ), } } From 82a28a2a1a286e2077e279f5a0bd687485735598 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:17:23 -0400 Subject: [PATCH 50/61] Increase heap allocation to 240K --- crates/ziggurat-esp/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index caa5fc0..acf05f3 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -139,7 +139,7 @@ async fn main(spawner: Spawner) -> ! { let timg0 = TimerGroup::new(peripherals.TIMG0); esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); - esp_alloc::heap_allocator!(size: 96 * 1024); + esp_alloc::heap_allocator!(size: 240 * 1024); // Bring up the UART console let log_uart = UartTx::new(peripherals.UART0, UartConfig::default().with_baudrate(460_800)) From d4876b9b79cc1ef8d069249eb4b46566e0e80cf4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:20:28 -0400 Subject: [PATCH 51/61] Ensure large logging lines can be handled --- crates/ziggurat-esp/Cargo.lock | 1 + crates/ziggurat-esp/Cargo.toml | 1 + crates/ziggurat-esp/src/log_sink.rs | 76 +++++++++++++++++++++++++---- crates/ziggurat-esp/src/main.rs | 26 +++++----- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index 5fd67af..b50edaf 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -2326,6 +2326,7 @@ dependencies = [ "esp-println", "esp-radio", "esp-rtos", + "heapless 0.9.3", "hex", "serde", "serde_json", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index db0bf4b..b2d8909 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -37,6 +37,7 @@ embassy-sync = "0.8" embassy-futures = "0.1" embedded-io-async = "0.7" +heapless = { version = "0.9", default-features = false } tracing = { version = "0.1", default-features = false } serde = { version = "1", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } diff --git a/crates/ziggurat-esp/src/log_sink.rs b/crates/ziggurat-esp/src/log_sink.rs index de5424f..9191718 100644 --- a/crates/ziggurat-esp/src/log_sink.rs +++ b/crates/ziggurat-esp/src/log_sink.rs @@ -1,15 +1,16 @@ //! A `tracing` subscriber that writes log records to the UART. -use alloc::string::String; use core::fmt::{self, Write}; +use core::mem; +use heapless::Vec; use tracing::field::{Field, Visit}; use tracing::subscriber::Interest; use tracing::{Event, Level, Metadata, Subscriber, span}; -use crate::LOG_OUTBOUND; +use crate::{LOG_CHUNK, LOG_OUTBOUND}; -const MAX_LEVEL: Level = Level::DEBUG; +const MAX_LEVEL: Level = Level::INFO; pub fn install() { let _ = tracing::subscriber::set_global_default(LogSink); @@ -29,11 +30,15 @@ impl Subscriber for LogSink { fn event(&self, event: &Event<'_>) { let metadata = event.metadata(); - let mut line = String::new(); - let _ = write!(line, "{} {}: ", metadata.level().as_str(), metadata.target()); - event.record(&mut MessageVisitor(&mut line)); - - let _ = LOG_OUTBOUND.try_send(line); + let mut writer = ChunkWriter::new(); + let _ = write!( + writer, + "{} {}: ", + metadata.level().as_str(), + metadata.target() + ); + event.record(&mut MessageVisitor(&mut writer)); + writer.finish(); } // Events-only: spans are not used by the stack, so span bookkeeping is a no-op. @@ -46,8 +51,59 @@ impl Subscriber for LogSink { fn exit(&self, _: &span::Id) {} } -/// Collects an event's `message` field (and appends any structured fields) into a string. -struct MessageVisitor<'a>(&'a mut String); +/// Formats bytes into fixed-size inline chunks, spilling each to `LOG_OUTBOUND` as it +/// fills. A single log line therefore streams out as several chunks with no heap +/// allocation. Under backpressure (channel full) the rest of the line is dropped — +/// logging must never block or panic the stack. +struct ChunkWriter { + buf: Vec, + dropped: bool, +} + +impl ChunkWriter { + fn new() -> Self { + Self { + buf: Vec::new(), + dropped: false, + } + } + + fn push_bytes(&mut self, mut bytes: &[u8]) { + while !self.dropped && !bytes.is_empty() { + let take = (LOG_CHUNK - self.buf.len()).min(bytes.len()); + // `take <= remaining capacity`, so this cannot fail. + let _ = self.buf.extend_from_slice(&bytes[..take]); + bytes = &bytes[take..]; + if self.buf.len() == LOG_CHUNK { + self.send_current(); + } + } + } + + fn send_current(&mut self) { + if LOG_OUTBOUND.try_send(mem::take(&mut self.buf)).is_err() { + self.dropped = true; + } + } + + fn finish(mut self) { + self.push_bytes(b"\r\n"); + if !self.dropped && !self.buf.is_empty() { + let _ = LOG_OUTBOUND.try_send(self.buf); + } + } +} + +impl Write for ChunkWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.push_bytes(s.as_bytes()); + Ok(()) + } +} + +/// Collects an event's `message` field (and appends any structured fields) into the +/// chunked writer. +struct MessageVisitor<'a>(&'a mut ChunkWriter); impl Visit for MessageVisitor<'_> { fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index acf05f3..fbd23d7 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -52,10 +52,13 @@ pub static OUTBOUND: Channel, INBOUND_DEPTH> = Channel::new(); -/// Plaintext log lines bound for the UART console. +pub const LOG_CHUNK: usize = 256; const LOG_OUTBOUND_DEPTH: usize = 32; -pub static LOG_OUTBOUND: Channel = - Channel::new(); +pub static LOG_OUTBOUND: Channel< + CriticalSectionRawMutex, + heapless::Vec, + LOG_OUTBOUND_DEPTH, +> = Channel::new(); /// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` /// signals it so the task exits and frees the radio. @@ -123,10 +126,8 @@ async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { #[embassy_executor::task] async fn uart_log_task(mut tx: UartTx<'static, Async>) { loop { - let line = LOG_OUTBOUND.receive().await; - let _ = tx.write_all(line.as_bytes()).await; - let _ = tx.write_all(b"\r\n").await; - // No flush needed: the UART peripheral shifts the FIFO out autonomously. + let chunk = LOG_OUTBOUND.receive().await; + let _ = tx.write_all(&chunk).await; } } @@ -142,10 +143,13 @@ async fn main(spawner: Spawner) -> ! { esp_alloc::heap_allocator!(size: 240 * 1024); // Bring up the UART console - let log_uart = UartTx::new(peripherals.UART0, UartConfig::default().with_baudrate(460_800)) - .expect("UART0 config") - .with_tx(peripherals.GPIO16) - .into_async(); + let log_uart = UartTx::new( + peripherals.UART0, + UartConfig::default().with_baudrate(460_800), + ) + .expect("UART0 config") + .with_tx(peripherals.GPIO16) + .into_async(); spawner.spawn(uart_log_task(log_uart).unwrap()); // Route the stack's `tracing` records to the UART console. From 8023d8a969171d6031bbe4c74c89c06c1afcbac0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:26:26 -0400 Subject: [PATCH 52/61] Migrate ZDP away from APS tasks --- crates/ziggurat-driver/src/zigbee_stack.rs | 14 ----- .../ziggurat-driver/src/zigbee_stack/aps.rs | 23 ++++--- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 63 +++++++------------ 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index c849a81..9e1f9a0 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1517,20 +1517,6 @@ impl ZigbeeStack { })); } - /// Spawns a tracked task that needs an owned handle to the stack. - fn spawn_tracked_self(&self, f: F) - where - F: FnOnce(Arc) -> Fut, - Fut: Future + Send + 'static, - { - let this = self - .self_weak - .upgrade() - .expect("stack dropped while running"); - - self.spawn_tracked(f(this)); - } - /// Stops all of the stack's tasks and waits for them to terminate, so that a /// replaced stack provably stops processing frames and transmitting before its /// successor takes over the shared Spinel client. diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 0ed1bfa..c7b1b42 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -85,7 +85,8 @@ impl ZigbeeStack { let ack_data = ApsAckData::from_aps_ack(nwk_frame.nwk_header.source, ack); tracing::trace!("Received APS ack: {ack_data:?}"); - match self.state.pending_aps_acks.lock().remove(&ack_data) { + let pending = self.state.pending_aps_acks.lock().remove(&ack_data); + match pending { Some(ApsAckPending::Waiter(tx)) => tx.signal(()), Some(ApsAckPending::Notify { token, .. }) => { self.push_notification(ZigbeeNotification::ApsSendOutcome { @@ -184,7 +185,7 @@ impl ZigbeeStack { /// `aps_security` requests APS encryption of the ASDU with the link key shared /// with that device (unicast only: link keys are pairwise). #[allow(clippy::too_many_arguments)] - fn prepare_aps_send( + pub(super) fn prepare_aps_send( &self, delivery_mode: ApsDeliveryMode, destination: Nwk, @@ -257,12 +258,12 @@ impl ZigbeeStack { tracing::trace!("Prepared APS frame: {aps_frame:?}"); let aps_payload = if let Some(destination_eui64) = aps_security { - match self + let encrypted = self .core() .aib .aps_security - .encrypt_data(destination_eui64, &aps_frame) - { + .encrypt_data(destination_eui64, &aps_frame); + match encrypted { Some(encrypted) => encrypted.to_bytes(), None => return Err(ZigbeeStackError::ApsSecurityFailed), } @@ -427,8 +428,13 @@ impl ZigbeeStack { self.aps_ack_wake.notify_one(); } - // Fire-and-forget, mirroring send_nwk_frame's broadcast/unicast split without - // awaiting the outcome. + self.enqueue_aps_frame(nwk_frame, priority); + Ok(()) + } + + /// Enqueue a built APS/NWK frame fire-and-forget: routes broadcasts and unicasts like + /// [`send_nwk_frame`](Self::send_nwk_frame) but never awaits the outcome. + pub(super) fn enqueue_aps_frame(&self, nwk_frame: NwkFrame, priority: TxPriority) { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { self.send_broadcast_nwk_frame(nwk_frame, NwkSecurityMode::NetworkKey, priority); } else { @@ -440,8 +446,6 @@ impl ZigbeeStack { None, ); } - - Ok(()) } /// The APS-ack timeout reactor: sleeps to the earliest pending fire-and-forget send's @@ -491,6 +495,7 @@ impl ZigbeeStack { for (key, _) in &due { pending.remove(key); } + drop(pending); due.into_iter().map(|(_, token)| token).collect() }; diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index f50ab5a..fbbf59c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -113,14 +113,9 @@ impl ZigbeeStack { neighbor_table_list: descriptors, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!("Failed to send a neighbor table response to {source:?}: {err}"); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a neighbor table response to {source:?}: {err}"); + } } /// Spec 2.4.4.3.3: answer a routing table query. @@ -175,14 +170,9 @@ impl ZigbeeStack { routing_table_list: descriptors, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!("Failed to send a routing table response to {source:?}: {err}"); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a routing table response to {source:?}: {err}"); + } } /// Spec 2.4.3.1.11.2: a (re)joined device announced its address pair. The address @@ -264,26 +254,20 @@ impl ZigbeeStack { children: claimed, }; - self.spawn_tracked_self(|arc_self| async move { - if let Err(err) = arc_self - .send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) - .await - { - tracing::warn!( - "Failed to send a parent announcement response to {source:?}: {err}" - ); - } - }); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a parent announcement response to {source:?}: {err}"); + } } - async fn send_zdp_command( + /// Build and enqueue a ZDP command fire-and-forget. + fn send_zdp_command( &self, destination: Nwk, delivery_mode: ApsDeliveryMode, tsn: u8, command: &T, ) -> Result<(), ZigbeeStackError> { - self.send_aps_command( + let (nwk_frame, _ack) = self.prepare_aps_send( delivery_mode, destination, ZDP_PROFILE_ID, @@ -295,10 +279,10 @@ impl ZigbeeStack { self.next_aps_counter(), command.serialize(tsn).unwrap(), None, - TxPriority::USER_NORMAL, - ) - .await - .map(|_| ()) + )?; + + self.enqueue_aps_frame(nwk_frame, TxPriority::USER_NORMAL); + Ok(()) } /// Spec 2.4.4.2.22.2: a router answered our parent announcement, claiming @@ -380,15 +364,12 @@ impl ZigbeeStack { let announcement = ParentAnnce { children: chunk }; let tsn = self.next_aps_counter(); - if let Err(err) = self - .send_zdp_command( - BROADCAST_ALL_ROUTERS_AND_COORDINATOR, - ApsDeliveryMode::Broadcast, - tsn, - &announcement, - ) - .await - { + if let Err(err) = self.send_zdp_command( + BROADCAST_ALL_ROUTERS_AND_COORDINATOR, + ApsDeliveryMode::Broadcast, + tsn, + &announcement, + ) { tracing::warn!("Failed to broadcast a parent announcement: {err}"); } From 97cdaf4c93c017c452b2673b867774c008bd635f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:50:02 -0400 Subject: [PATCH 53/61] Format --- crates/ziggurat-esp/src/hw_crypto.rs | 40 +++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/ziggurat-esp/src/hw_crypto.rs b/crates/ziggurat-esp/src/hw_crypto.rs index 6806cae..9f93809 100644 --- a/crates/ziggurat-esp/src/hw_crypto.rs +++ b/crates/ziggurat-esp/src/hw_crypto.rs @@ -68,8 +68,20 @@ impl CryptoBackend for EspCrypto { HW.lock(|cell| { let mut guard = cell.borrow_mut(); let dma = guard.as_mut().expect("hw_crypto::init was never called"); - run(dma, &cbc_state, &key.0, &cbc_in[..cbc_len], &mut cbc_out[..cbc_len]); - run(dma, &ctr_state, &key.0, &ctr_in[..ctr_len], &mut ctr_out[..ctr_len]); + run( + dma, + &cbc_state, + &key.0, + &cbc_in[..cbc_len], + &mut cbc_out[..cbc_len], + ); + run( + dma, + &ctr_state, + &key.0, + &ctr_in[..ctr_len], + &mut ctr_out[..ctr_len], + ); }); let tag = &cbc_out[cbc_len - 16..cbc_len]; @@ -79,7 +91,8 @@ impl CryptoBackend for EspCrypto { out.extend_from_slice(&ctr_out[16..16 + plen]) .expect("ciphertext fits a frame"); for i in 0..MIC_LENGTH { - out.push(tag[i] ^ s0[i]).expect("frame has room for the MIC"); + out.push(tag[i] ^ s0[i]) + .expect("frame has room for the MIC"); } out } @@ -110,10 +123,23 @@ impl CryptoBackend for EspCrypto { let cbc_len = HW.lock(|cell| { let mut guard = cell.borrow_mut(); let dma = guard.as_mut().expect("hw_crypto::init was never called"); - run(dma, &ctr_state, &key.0, &ctr_in[..ctr_len], &mut ctr_out[..ctr_len]); - - let cbc_len = format_cbc_mac_input(&mut cbc_in, nonce, auth_data, &ctr_out[16..16 + clen]); - run(dma, &cbc_state, &key.0, &cbc_in[..cbc_len], &mut cbc_out[..cbc_len]); + run( + dma, + &ctr_state, + &key.0, + &ctr_in[..ctr_len], + &mut ctr_out[..ctr_len], + ); + + let cbc_len = + format_cbc_mac_input(&mut cbc_in, nonce, auth_data, &ctr_out[16..16 + clen]); + run( + dma, + &cbc_state, + &key.0, + &cbc_in[..cbc_len], + &mut cbc_out[..cbc_len], + ); cbc_len }); From daceed82782158a7983bf7ca942bdb1c3b10f0b8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:14:04 -0400 Subject: [PATCH 54/61] Three stage send API to remove fire-and-forget send tasks --- crates/ziggurat-driver/src/zigbee_stack.rs | 81 ++++--- .../ziggurat-driver/src/zigbee_stack/aps.rs | 183 ++++---------- .../src/zigbee_stack/indirect.rs | 48 ++-- .../src/zigbee_stack/joining.rs | 1 + .../ziggurat-driver/src/zigbee_stack/mac.rs | 4 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 223 ++++++++++++------ .../ziggurat-driver/src/zigbee_stack/zdp.rs | 5 +- crates/ziggurat-esp/src/api.rs | 48 ++-- crates/ziggurat-server/src/main.rs | 100 ++++---- 9 files changed, 352 insertions(+), 341 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 9e1f9a0..1aa834c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -1,7 +1,7 @@ use crate::ziggurat_ieee_802154::{Ieee802154Address, Ieee802154Frame}; use crate::runtime::{Elapsed, RtInstant, Runtime, Spawn}; -use crate::signal::{Signal, SignalWaiter}; +use crate::signal::Signal; use abstract_bits::AbstractBits; use arbitrary_int::prelude::*; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; @@ -254,31 +254,30 @@ impl ApsAckData { } /// 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 = 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`]. +/// Where a transmit's terminal outcome is reported. #[derive(Debug)] -pub struct ApsAckWaiter { - pub(crate) receiver: SignalWaiter<()>, - pub(crate) timeout: Duration, - pub(crate) ack_data: ApsAckData, +pub enum TxOutcome { + /// Nobody is waiting; a failure is only logged (internal background sends). + Discard, + /// Resolve an awaiting caller's signal (internal awaiters). + Signal(TxCompletion), + /// Confirm an application send by `token`. `aps_ack` present means the end-to-end APS + /// ack is the confirmation — this hop succeeding is silent, its failure fails the + /// send; absent means next-hop acceptance is itself the confirmation. + Confirm { + token: u64, + aps_ack: Option, + }, } -/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end ack. +/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end ack, +/// confirmed (or timed out) as a [`ZigbeeNotification::SendConfirm`] carrying `token`. #[derive(Debug)] -pub enum ApsAckPending { - /// A caller awaits the ack via [`ZigbeeStack::wait_aps_ack`]; the ack resolves this - /// signal and the caller applies its own timeout. - Waiter(Signal<()>), - /// A fire-and-forget [`ZigbeeStack::send_aps`]: the ack (or its `deadline` passing) - /// is pushed as an [`ZigbeeNotification::ApsSendOutcome`] carrying `token`. - Notify { token: u64, deadline: CoreInstant }, +pub struct PendingApsAck { + pub(crate) token: u64, + pub(crate) deadline: CoreInstant, } /// A transmit queued for the single sender task ([`ZigbeeStack::sender_task`]). The NWK @@ -289,7 +288,7 @@ pub(crate) struct SendRequest { seq: u32, priority: TxPriority, pub(crate) kind: SendKind, - pub(crate) completion: Option, + pub(crate) outcome: TxOutcome, } #[derive(Debug)] @@ -322,7 +321,7 @@ pub struct PendingFrame { pub(crate) nwk_frame: NwkFrame, pub(crate) security: NwkSecurityMode, pub(crate) priority: TxPriority, - pub(crate) completion: Option, + pub(crate) outcome: TxOutcome, } /// All frames waiting on one destination's route discovery. @@ -351,6 +350,9 @@ pub struct PendingBroadcast { pub(crate) attempts_remaining: u8, /// When the next retransmission is due, unless the quorum is heard first. pub(crate) next_attempt: CoreInstant, + /// An application send awaiting confirmation: `SendConfirm { via: Quorum }` when the + /// passive-ack quorum is heard, or `Failed` when attempts run out. + pub(crate) token: Option, } /// A unicast awaiting re-transmission after a failed attempt, held by the unicast-retry @@ -373,7 +375,7 @@ pub struct PendingUnicastRetry { pub(crate) attempts_remaining: u8, /// When the re-enqueue is due. pub(crate) next_attempt: CoreInstant, - pub(crate) completion: Option, + pub(crate) outcome: TxOutcome, } impl PartialEq for SendRequest { @@ -484,7 +486,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. @@ -534,7 +536,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>, @@ -751,18 +753,29 @@ pub enum ZigbeeNotification { frame_counter: u32, key_id: String, }, - /// The outcome of a fire-and-forget application APS send, correlated by the `token` - /// the caller supplied to [`ZigbeeStack::send_aps`]. - ApsSendOutcome { token: u64, result: ApsSendResult }, + /// Stage 3 of a send: the confirmation of the application send `token` supplied to + /// [`ZigbeeStack::send_aps`]. + SendConfirm { token: u64, result: SendResult }, +} + +/// The confirmation of an application send (spec-typed by the frame). +#[derive(Debug, Clone)] +pub enum SendResult { + /// The send was confirmed; `via` names the trigger. + Confirmed { via: ConfirmTrigger }, + /// The send failed before confirmation. + Failed { reason: String }, } -/// The end-to-end result of an ack-requested [`ZigbeeStack::send_aps`]. +/// Which delivery event confirmed a send. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApsSendResult { - /// The destination's APS ack arrived. - Delivered, - /// No APS ack arrived before the deadline (the frame may never have been routed). - AckTimeout, +pub enum ConfirmTrigger { + /// Broadcast: the passive-ack quorum was heard. + Quorum, + /// Unicast with no APS ack requested: the next hop acknowledged the frame. + NextHop, + /// Unicast with an APS ack requested: the end-to-end APS ack arrived. + ApsAck, } #[derive(Debug, Clone)] diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index c7b1b42..dfca916 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -9,8 +9,8 @@ use ziggurat_zigbee::nwk::frame::{ BROADCAST_LOW_POWER_ROUTERS, BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkRouteDiscovery, }; -use crate::signal; use alloc::collections::btree_map::Entry; +use alloc::string::ToString; use alloc::vec::Vec; use core::cmp; use core::time::Duration; @@ -18,8 +18,8 @@ use ziggurat_phy::RadioPhy; use ziggurat_zigbee::Instant as CoreInstant; use super::{ - ApsAck, ApsAckData, ApsAckPending, ApsAckWaiter, ApsSendResult, NwkSecurityMode, SendMode, - TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + ApsAck, ApsAckData, ConfirmTrigger, NwkSecurityMode, PendingApsAck, SendMode, SendResult, + TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -86,15 +86,13 @@ impl ZigbeeStack { tracing::trace!("Received APS ack: {ack_data:?}"); let pending = self.state.pending_aps_acks.lock().remove(&ack_data); - match pending { - Some(ApsAckPending::Waiter(tx)) => tx.signal(()), - Some(ApsAckPending::Notify { token, .. }) => { - self.push_notification(ZigbeeNotification::ApsSendOutcome { - token, - result: ApsSendResult::Delivered, - }); - } - None => {} + if let Some(PendingApsAck { token, .. }) = pending { + self.push_notification(ZigbeeNotification::SendConfirm { + token, + result: SendResult::Confirmed { + via: ConfirmTrigger::ApsAck, + }, + }); } } @@ -306,86 +304,10 @@ impl ZigbeeStack { } } - /// Send an APS data frame, returning once it has been transmitted (including - /// route discovery and the NWK retry loop; for sleepy children, once the frame is - /// extracted from the indirect queue). When an APS ack was requested, the - /// returned waiter resolves the end-to-end delivery via - /// [`ZigbeeStack::wait_aps_ack`]. - #[allow(clippy::too_many_arguments)] - pub async fn send_aps_command( - &self, - delivery_mode: ApsDeliveryMode, - destination: Nwk, - profile_id: u16, - cluster_id: u16, - src_ep: u8, - dst_ep: u8, - aps_ack: ApsAck, - radius: u8, - aps_seq: u8, - data: Vec, - aps_security: Option, - priority: TxPriority, - ) -> Result, ZigbeeStackError> { - let (nwk_frame, ack_data) = self.prepare_aps_send( - delivery_mode, - destination, - profile_id, - cluster_id, - src_ep, - dst_ep, - aps_ack, - radius, - aps_seq, - data, - aps_security, - )?; - - let Some(ack_data) = ack_data else { - self.send_nwk_frame( - nwk_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route, - priority, - ) - .await?; - return Ok(None); - }; - - let (ack_tx, ack_rx) = signal::channel(); - tracing::debug!("APS ACK requested, waiting for {ack_data:?}"); - self.state - .pending_aps_acks - .lock() - .insert(ack_data.clone(), ApsAckPending::Waiter(ack_tx)); - - if let Err(err) = self - .send_nwk_frame( - nwk_frame, - NwkSecurityMode::NetworkKey, - SendMode::Route, - priority, - ) - .await - { - self.state.pending_aps_acks.lock().remove(&ack_data); - return Err(err); - } - - Ok(Some(ApsAckWaiter { - receiver: ack_rx, - timeout: self.aps_ack_timeout(destination), - ack_data, - })) - } - - /// Fire-and-forget application APS send: build and enqueue the frame, then return. - /// Delivery is driven entirely by the tables (route discovery, NWK retries, ack - /// correlation), so there is nothing to await. For an ack-requested unicast the - /// end-to-end outcome arrives later as a [`ZigbeeNotification::ApsSendOutcome`] - /// carrying `token`; a no-ack send has no further outcome (its enqueue succeeding is - /// the whole result). Returns an error only for the synchronous failures that prevent - /// enqueue (oversized payload, APS encryption). + /// Build and enqueue the frame, then return an accept or reject. Delivery is + /// confirmed later as a [`ZigbeeNotification::SendConfirm`] carrying `token`, + /// triggered by the frame type: passive-ack quorum for a broadcast, next-hop + /// acceptance for a no-ack unicast, or the APS ack for an ack unicast. #[allow(clippy::too_many_arguments)] pub fn send_aps( &self, @@ -417,40 +339,57 @@ impl ZigbeeStack { aps_security, )?; - // Register the ack before enqueueing so it is caught however fast the reply comes; - // the timeout reactor reports the outcome if it never arrives. - if let Some(ack_data) = ack_data { + // An APS-ack send is confirmed by the end-to-end ack: register it (with the + // deadline the timeout reactor uses) before enqueueing so a fast reply is caught. + if let Some(ack_data) = &ack_data { let deadline = self.core_now() + self.aps_ack_timeout(destination); self.state .pending_aps_acks .lock() - .insert(ack_data, ApsAckPending::Notify { token, deadline }); + .insert(ack_data.clone(), PendingApsAck { token, deadline }); self.aps_ack_wake.notify_one(); } - self.enqueue_aps_frame(nwk_frame, priority); + self.enqueue_aps_frame( + nwk_frame, + priority, + TxOutcome::Confirm { + token, + aps_ack: ack_data, + }, + ); Ok(()) } - /// Enqueue a built APS/NWK frame fire-and-forget: routes broadcasts and unicasts like - /// [`send_nwk_frame`](Self::send_nwk_frame) but never awaits the outcome. - pub(super) fn enqueue_aps_frame(&self, nwk_frame: NwkFrame, priority: TxPriority) { + /// Enqueue a built APS/NWK frame fire-and-forget, routing broadcasts and unicasts like + /// [`send_nwk_frame`](Self::send_nwk_frame). The `outcome` rides the unicast path (the + /// sender confirms next-hop acceptance / failure); a broadcast is confirmed by the + /// retransmit reactor on quorum, so only its `token` is carried over. + pub(super) fn enqueue_aps_frame( + &self, + nwk_frame: NwkFrame, + priority: TxPriority, + outcome: TxOutcome, + ) { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { - self.send_broadcast_nwk_frame(nwk_frame, NwkSecurityMode::NetworkKey, priority); + let token = match outcome { + TxOutcome::Confirm { token, .. } => Some(token), + TxOutcome::Discard | TxOutcome::Signal(_) => None, + }; + self.send_broadcast_nwk_frame(nwk_frame, NwkSecurityMode::NetworkKey, priority, token); } else { self.originate_unicast( nwk_frame, NwkSecurityMode::NetworkKey, SendMode::Route, priority, - None, + outcome, ); } } - /// The APS-ack timeout reactor: sleeps to the earliest pending fire-and-forget send's - /// deadline, then reports [`ApsSendResult::AckTimeout`] for any that lapsed. Awaited - /// (`Waiter`) entries are timed out by their caller and ignored here. + /// The APS-ack timeout reactor: sleeps to the earliest pending send's deadline, then + /// fails any whose ack never arrived. pub(super) async fn aps_ack_timeout_task(&self) { loop { match self.earliest_aps_ack_deadline() { @@ -471,10 +410,7 @@ impl ZigbeeStack { .pending_aps_acks .lock() .values() - .filter_map(|pending| match pending { - ApsAckPending::Notify { deadline, .. } => Some(*deadline), - ApsAckPending::Waiter(_) => None, - }) + .map(|pending| pending.deadline) .min() } @@ -485,12 +421,8 @@ impl ZigbeeStack { let mut pending = self.state.pending_aps_acks.lock(); let due: Vec<(ApsAckData, u64)> = pending .iter() - .filter_map(|(key, p)| match p { - ApsAckPending::Notify { deadline, token } if *deadline <= now => { - Some((key.clone(), *token)) - } - _ => None, - }) + .filter(|(_, p)| p.deadline <= now) + .map(|(key, p)| (key.clone(), p.token)) .collect(); for (key, _) in &due { pending.remove(key); @@ -501,25 +433,12 @@ impl ZigbeeStack { for token in expired { tracing::warn!("APS ack timed out for send {token}"); - self.push_notification(ZigbeeNotification::ApsSendOutcome { + self.push_notification(ZigbeeNotification::SendConfirm { token, - result: ApsSendResult::AckTimeout, + result: SendResult::Failed { + reason: "APS ack timed out".to_string(), + }, }); } } - - /// 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.wait()).await { - Ok(Ok(())) => { - tracing::debug!("APS ACK received"); - Ok(()) - } - Ok(Err(_)) | Err(_) => { - tracing::warn!("APS ACK timed out for {:?}", waiter.ack_data); - self.state.pending_aps_acks.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 15a2624..3d7dc56 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -11,28 +11,19 @@ use ziggurat_zigbee::nwk::commands::{NwkCommand, NwkLeaveCommand}; use ziggurat_zigbee::indirect::Delivery; use super::{ - DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, TxCompletion, + DeviceLeaveReason, IndirectFrame, IndirectPayload, NwkSecurityMode, SendKind, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { /// Queue a frame for a polling device (802.15.4 spec 6.7.3), under its own - /// `poll_address`. A `completion`, if supplied, is resolved with the transmit result - /// when the device extracts the frame, or with an error on expiry or eviction; a - /// fire-and-forget caller passes `None`. - pub(super) fn enqueue_indirect_frame( - &self, - frame: IndirectFrame, - completion: Option, - ) { - // The queue stores a completion unconditionally; a fire-and-forget caller gets - // a throwaway whose waiter is dropped, so resolving it later is a no-op. - let completion = completion.unwrap_or_else(|| signal::channel().0); - + /// `poll_address`. Its `outcome` is resolved when the device extracts the frame, or + /// with an error on expiry or eviction. + pub(super) fn enqueue_indirect_frame(&self, frame: IndirectFrame, outcome: TxOutcome) { self.core() .mac .indirect_queue - .push(frame.poll_address, frame, completion, self.core_now()); + .push(frame.poll_address, frame, outcome, self.core_now()); self.src_match_sync.notify_one(); self.maintenance_wake.notify_one(); @@ -47,7 +38,7 @@ impl ZigbeeStack { ) -> Result<(), ZigbeeStackError> { let destination = frame.poll_address; let (completion, waiter) = signal::channel(); - self.enqueue_indirect_frame(frame, Some(completion)); + self.enqueue_indirect_frame(frame, TxOutcome::Signal(completion)); waiter .wait() .await @@ -118,9 +109,10 @@ impl ZigbeeStack { .extract(source_eui64, source_nwk, self.core_now()); for (destination, transaction) in outcome.expired { - transaction - .completion - .signal(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } let Some(delivery) = outcome.delivery else { @@ -144,7 +136,7 @@ impl ZigbeeStack { true } - async fn transmit_indirect_transaction(&self, delivery: Delivery) { + async fn transmit_indirect_transaction(&self, delivery: Delivery) { let Delivery { destination, transaction, @@ -186,7 +178,7 @@ impl ZigbeeStack { .await { Ok(()) => { - transaction.completion.signal(Ok(())); + self.resolve_outcome(transaction.completion, Ok(())); self.remove_indirect_queue_if_empty(destination); } // 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, @@ -199,7 +191,7 @@ impl ZigbeeStack { .requeue(destination, transaction); } Err(err) => { - transaction.completion.signal(Err(err)); + self.resolve_outcome(transaction.completion, Err(err)); self.remove_indirect_queue_if_empty(destination); } } @@ -220,9 +212,10 @@ impl ZigbeeStack { } for (destination, transaction) in dropped { - transaction - .completion - .signal(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } self.src_match_sync.notify_one(); @@ -357,9 +350,10 @@ impl ZigbeeStack { for (destination, transaction) in expired { tracing::warn!("Indirect transaction to {destination:?} expired without a poll"); - transaction - .completion - .signal(Err(ZigbeeStackError::IndirectExpired { destination })); + self.resolve_outcome( + transaction.completion, + Err(ZigbeeStackError::IndirectExpired { destination }), + ); } self.src_match_sync.notify_one(); diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 2ff6147..110beff 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -287,6 +287,7 @@ impl ZigbeeStack { conflict_frame, NwkSecurityMode::NetworkKey, TxPriority::USER_NORMAL, + None, ); }); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/mac.rs b/crates/ziggurat-driver/src/zigbee_stack/mac.rs index 1eb8e99..b250824 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/mac.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/mac.rs @@ -17,7 +17,7 @@ use ziggurat_zigbee::nwk::frame::{ }; use super::{ - NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxPriority, ZigbeeStack, + NwkDeviceType, PROTOCOL_VERSION, STACK_PROFILE, SendKind, TxOutcome, TxPriority, ZigbeeStack, ZigbeeStackError, }; @@ -145,7 +145,7 @@ impl ZigbeeStack { frame: beacon_frame, }, tx_priority, - None, + TxOutcome::Discard, ); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 308312f..1369aa5 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::{ Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, }; +use alloc::string::ToString; use alloc::vec::Vec; use core::sync::atomic::Ordering as AtomicOrdering; use core::time::Duration; @@ -24,9 +25,10 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, + AddrConflictSource, ConfirmTrigger, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, SendKind, - SendMode, SendRequest, TxCompletion, TxPriority, ZigbeeStack, ZigbeeStackError, + SendMode, SendRequest, SendResult, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, + ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -141,7 +143,15 @@ impl ZigbeeStack { for key in keys { if self.broadcast_passively_acked(key) { tracing::debug!("Broadcast {key:?} passively acknowledged"); - self.state.pending_broadcasts.lock().remove(&key); + let removed = self.state.pending_broadcasts.lock().remove(&key); + if let Some(token) = removed.and_then(|broadcast| broadcast.token) { + self.push_notification(ZigbeeNotification::SendConfirm { + token, + result: SendResult::Confirmed { + via: ConfirmTrigger::Quorum, + }, + }); + } continue; } @@ -149,47 +159,69 @@ impl ZigbeeStack { // under it. let next_attempt = now + self.tunables.passive_ack_timeout + self.broadcast_jitter(); + // A single stack local, matched immediately after the lock is released, so the + // size-amplification the lint warns about does not apply. + #[allow(clippy::large_enum_variant)] + enum Next { + Idle, + Retransmit(NwkFrame, NwkSecurityMode, TxPriority), + Exhausted(Option), + } + // Decide under the lock; if a copy is due, extract it to transmit after release. - let retransmit = { + let action = { let mut pending = self.state.pending_broadcasts.lock(); let Some(broadcast) = pending.get_mut(&key) else { continue; }; if broadcast.next_attempt > now { - None + Next::Idle } else if broadcast.attempts_remaining == 0 { - tracing::debug!("Broadcast {key:?} out of retransmit attempts"); + let token = broadcast.token; pending.remove(&key); - - None + Next::Exhausted(token) } else { broadcast.attempts_remaining -= 1; broadcast.next_attempt = next_attempt; - - Some(( + Next::Retransmit( 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, - ); + match action { + Next::Idle => {} + Next::Retransmit(nwk_frame, security, priority) => { + tracing::debug!("Retransmitting broadcast {key:?}"); + self.enqueue_send( + SendKind::Broadcast { + nwk_frame, + security, + }, + priority, + TxOutcome::Discard, + ); + } + Next::Exhausted(token) => { + tracing::debug!("Broadcast {key:?} out of retransmit attempts"); + if let Some(token) = token { + self.push_notification(ZigbeeNotification::SendConfirm { + token, + result: SendResult::Failed { + reason: "passive-ack quorum not reached".to_string(), + }, + }); + } + } } } } /// Insert a broadcast into the pending-retransmit map and wake the reactor. + #[allow(clippy::too_many_arguments)] fn schedule_broadcast( &self, key: (Nwk, u8), @@ -198,8 +230,11 @@ impl ZigbeeStack { priority: TxPriority, first_delay: Duration, attempts: u8, + token: Option, ) { - if attempts == 0 { + // With a token we still track the broadcast even at zero retransmits, so the + // reactor can confirm its quorum (or fail it); untracked broadcasts just return. + if attempts == 0 && token.is_none() { return; } @@ -211,6 +246,7 @@ impl ZigbeeStack { priority, attempts_remaining: attempts, next_attempt: self.core_now() + first_delay, + token, }, ); self.broadcast_retransmit_wake.notify_one(); @@ -440,7 +476,13 @@ impl ZigbeeStack { "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); + self.originate_unicast( + nwk_frame, + security, + mode, + TxPriority::USER_NORMAL, + TxOutcome::Discard, + ); } /// Originate a unicast: assign its NWK sequence number, resolve a next hop, and @@ -452,25 +494,23 @@ impl ZigbeeStack { security: NwkSecurityMode, mode: SendMode, priority: TxPriority, - completion: Option, + outcome: TxOutcome, ) { let destination = nwk_frame.nwk_header.destination; nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); match self.resolve_next_hop(&mut nwk_frame, mode) { NextHop::Resolved(next_hop) => { - self.enqueue_unicast(nwk_frame, next_hop, security, priority, completion); + self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome); } NextHop::NeedDiscovery => { - self.enqueue_awaiting_route(nwk_frame, security, priority, completion) + self.enqueue_awaiting_route(nwk_frame, security, priority, outcome) } NextHop::Discard => { tracing::debug!( "Dropping frame to {destination:?}: no route and discovery suppressed" ); - if let Some(completion) = completion { - completion.signal(Err(ZigbeeStackError::RouteDiscoverySuppressed)); - } + self.resolve_outcome(outcome, Err(ZigbeeStackError::RouteDiscoverySuppressed)); } } } @@ -536,7 +576,7 @@ impl ZigbeeStack { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { // Broadcasts are fire-and-forget: the retransmit reactor owns delivery, and // there is no end-to-end result to await. - self.send_broadcast_nwk_frame(nwk_frame, security, priority); + self.send_broadcast_nwk_frame(nwk_frame, security, priority, None); Ok(()) } else { self.send_unicast_nwk_frame(nwk_frame, security, mode, priority) @@ -631,7 +671,13 @@ impl ZigbeeStack { priority: TxPriority, ) -> Result<(), ZigbeeStackError> { let (completion_tx, completion_rx) = signal::channel(); - self.originate_unicast(nwk_frame, security, mode, priority, Some(completion_tx)); + self.originate_unicast( + nwk_frame, + security, + mode, + priority, + TxOutcome::Signal(completion_tx), + ); completion_rx .wait() .await @@ -690,18 +736,13 @@ impl ZigbeeStack { } /// Enqueue a send into the priority queue and wake the sender task. - pub(super) fn enqueue_send( - &self, - kind: SendKind, - priority: TxPriority, - completion: Option, - ) { + pub(super) fn enqueue_send(&self, kind: SendKind, priority: TxPriority, outcome: TxOutcome) { let seq = self.send_seq.fetch_add(1, AtomicOrdering::Relaxed); self.send_queue.lock().push(SendRequest { seq, priority, kind, - completion, + outcome, }); self.send_wake.notify_one(); } @@ -718,7 +759,7 @@ impl ZigbeeStack { next_hop: Nwk, security: NwkSecurityMode, priority: TxPriority, - completion: Option, + outcome: TxOutcome, ) { if let Some(child_eui64) = self.sleepy_child_eui64(next_hop) { // The frame is left as plaintext and finished (encrypted, counter assigned) @@ -734,7 +775,7 @@ impl ZigbeeStack { }; self.increment_tx_total(); - self.enqueue_indirect_frame(frame, completion); + self.enqueue_indirect_frame(frame, outcome); return; } @@ -746,7 +787,7 @@ impl ZigbeeStack { attempts_remaining: self.tunables.unicast_retries, }, priority, - completion, + outcome, ); } @@ -757,7 +798,7 @@ impl ZigbeeStack { priority: TxPriority, ) -> Result<(), ZigbeeStackError> { let (completion_tx, completion_rx) = signal::channel(); - self.enqueue_send(kind, priority, Some(completion_tx)); + self.enqueue_send(kind, priority, TxOutcome::Signal(completion_tx)); completion_rx .wait() .await @@ -770,7 +811,7 @@ impl ZigbeeStack { nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, - completion: Option, + outcome: TxOutcome, ) { let destination = nwk_frame.nwk_header.destination; @@ -788,7 +829,7 @@ impl ZigbeeStack { nwk_frame, security, priority, - completion, + outcome, }); is_new }; @@ -893,17 +934,18 @@ impl ZigbeeStack { mut nwk_frame, security, priority, - completion, + outcome, } = queued; match self.resolve_next_hop(&mut nwk_frame, SendMode::Route) { NextHop::Resolved(next_hop) => { - self.enqueue_unicast(nwk_frame, next_hop, security, priority, completion); + self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome); } NextHop::NeedDiscovery | NextHop::Discard => { - if let Some(completion) = completion { - completion.signal(Err(ZigbeeStackError::RouteInactiveAfterDiscovery)); - } + self.resolve_outcome( + outcome, + Err(ZigbeeStackError::RouteInactiveAfterDiscovery), + ); } } } @@ -940,10 +982,11 @@ impl ZigbeeStack { "Route discovery to {destination:?} failed, dropping {} frame(s)", frames.len() ); - for PendingFrame { completion, .. } in frames { - if let Some(completion) = completion { - completion.signal(Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed))); - } + for PendingFrame { outcome, .. } in frames { + self.resolve_outcome( + outcome, + Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed)), + ); } } } @@ -969,15 +1012,15 @@ impl ZigbeeStack { security, attempts_remaining, } => { - // Owns the completion: resolves it on success or terminal - // failure, or hands it to the retry reactor. + // Owns the outcome: reports it on success or terminal failure, or + // hands it to the retry reactor. self.attempt_unicast_send( nwk_frame, next_hop, security, request.priority, attempts_remaining, - request.completion, + request.outcome, ) .await; } @@ -986,11 +1029,11 @@ impl ZigbeeStack { security, } => { let result = self.process_broadcast_send(nwk_frame, security).await; - Self::resolve_completion(request.completion, result); + self.resolve_outcome(request.outcome, result); } SendKind::Raw { frame } => { let result = self.send_802154_frame(frame).await; - Self::resolve_completion(request.completion, result); + self.resolve_outcome(request.outcome, result); } } } @@ -999,16 +1042,43 @@ impl ZigbeeStack { } } - /// Resolve a send's completion (if any) with its outcome, logging a dropped - /// background failure that nothing is waiting on. - fn resolve_completion(completion: Option, result: Result<(), ZigbeeStackError>) { - match completion { - Some(completion) => completion.signal(result), - None => { + /// Deliver a transmit's terminal outcome to wherever it is owed: log a dropped + /// background failure, wake an awaiting caller, or confirm an application send. + pub(super) fn resolve_outcome(&self, outcome: TxOutcome, result: Result<(), ZigbeeStackError>) { + match outcome { + TxOutcome::Discard => { if let Err(err) = result { tracing::warn!("Background send failed: {err}"); } } + TxOutcome::Signal(signal) => signal.signal(result), + TxOutcome::Confirm { token, aps_ack } => match result { + // Next-hop acceptance confirms a no-ack send; an ack send waits for the + // APS ack (the aps-ack table confirms it), so success here is silent. + Ok(()) => { + if aps_ack.is_none() { + self.push_notification(ZigbeeNotification::SendConfirm { + token, + result: SendResult::Confirmed { + via: ConfirmTrigger::NextHop, + }, + }); + } + } + // The frame never reached its next hop: fail the send and drop any pending + // aps-ack so a late or spurious ack cannot double-confirm. + Err(err) => { + if let Some(ack_data) = aps_ack { + self.state.pending_aps_acks.lock().remove(&ack_data); + } + self.push_notification(ZigbeeNotification::SendConfirm { + token, + result: SendResult::Failed { + reason: err.to_string(), + }, + }); + } + }, } } @@ -1023,7 +1093,7 @@ impl ZigbeeStack { security: NwkSecurityMode, priority: TxPriority, attempts_remaining: u8, - completion: Option, + outcome: TxOutcome, ) { self.apply_nwk_aux_header(&mut nwk_frame, security); let encrypted_nwk_frame = self.encrypt_nwk_frame(&mut nwk_frame, security); @@ -1048,7 +1118,7 @@ impl ZigbeeStack { self.increment_tx_total(); let Err(e) = self.send_802154_frame(ieee802154_frame).await else { - Self::resolve_completion(completion, Ok(())); + self.resolve_outcome(outcome, Ok(())); return; }; @@ -1067,7 +1137,7 @@ impl ZigbeeStack { if attempts_remaining == 0 { tracing::error!("Failed to send unicast frame after all attempts"); self.handle_unicast_send_failure(&nwk_frame, next_hop_address); - Self::resolve_completion(completion, Err(e)); + self.resolve_outcome(outcome, Err(e)); return; } @@ -1081,7 +1151,7 @@ impl ZigbeeStack { security, priority, attempts_remaining - 1, - completion, + outcome, ); } @@ -1094,7 +1164,7 @@ impl ZigbeeStack { security: NwkSecurityMode, priority: TxPriority, attempts_remaining: u8, - completion: Option, + outcome: TxOutcome, ) { let delay = self.tunables.unicast_retry_delay; @@ -1112,7 +1182,7 @@ impl ZigbeeStack { priority, attempts_remaining, next_attempt, - completion, + outcome, }); self.unicast_retry_wake.notify_one(); } @@ -1178,7 +1248,7 @@ impl ZigbeeStack { attempts_remaining: retry.attempts_remaining, }, retry.priority, - retry.completion, + retry.outcome, ); } } @@ -1243,7 +1313,7 @@ impl ZigbeeStack { self.increment_tx_total(); // Fire-and-forget: a broadcast copy has no end-to-end result to await. - self.enqueue_indirect_frame(frame, None); + self.enqueue_indirect_frame(frame, TxOutcome::Discard); } } @@ -1256,6 +1326,9 @@ impl ZigbeeStack { mut nwk_frame: NwkFrame, security: NwkSecurityMode, priority: TxPriority, + // An application send awaiting confirmation on passive-ack quorum; internal + // broadcasts pass `None`. + token: Option, ) { nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); @@ -1287,7 +1360,7 @@ impl ZigbeeStack { security, }, priority, - None, + TxOutcome::Discard, ); self.schedule_broadcast( key, @@ -1296,6 +1369,7 @@ impl ZigbeeStack { priority, self.tunables.passive_ack_timeout + self.broadcast_jitter(), self.tunables.max_broadcast_retries, + token, ); } @@ -1491,7 +1565,7 @@ impl ZigbeeStack { next_hop_address, NwkSecurityMode::NetworkKey, TxPriority::USER_NORMAL, - None, + TxOutcome::Discard, ); } @@ -1577,6 +1651,7 @@ impl ZigbeeStack { TxPriority::USER_NORMAL, self.broadcast_jitter(), self.tunables.max_broadcast_retries + 1, + None, ); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index fbbf59c..64a3fdf 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -12,7 +12,8 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, MAX_DEPTH, NwkDeviceType, TxPriority, ZigbeeStack, ZigbeeStackError, neighbors, routing, + ApsAck, MAX_DEPTH, NwkDeviceType, TxOutcome, TxPriority, ZigbeeStack, ZigbeeStackError, + neighbors, routing, }; /// EUI64s per Parent_annce frame, keeping the ASDU within the NWK payload budget. @@ -281,7 +282,7 @@ impl ZigbeeStack { None, )?; - self.enqueue_aps_frame(nwk_frame, TxPriority::USER_NORMAL); + self.enqueue_aps_frame(nwk_frame, TxPriority::USER_NORMAL, TxOutcome::Discard); Ok(()) } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 3c075f8..4c5f227 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -18,8 +18,8 @@ use crate::CaptureStop; use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ApsSendResult, NetworkBeacon, NetworkConfig, NwkDeviceType, TclkSeed, Tunables, - TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ConfirmTrigger, NetworkBeacon, NetworkConfig, NwkDeviceType, SendResult, TclkSeed, + Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -240,8 +240,8 @@ pub async fn handle_line(app: &mut App, line: &[u8]) { "get_hw_address" => handle_get_hw_address(id), "get_network_info" => handle_get_network_info(app, id), "send_aps" => { - // Fire-and-forget: enqueues and returns. A no-ack send is answered inline; an - // ack send is answered later by an `ApsSendOutcome` notification. + // Fire-and-forget: the stack accepts or rejects now, and the terminal outcome + // arrives later as a `send_confirm` notification keyed by the request id. dispatch_send_aps(app, id, params).await; return; } @@ -415,9 +415,9 @@ fn handle_get_network_info(app: &App, id: u64) -> Value { } /// Fire-and-forget APS send: build and enqueue the frame, then return. No task, no -/// blocking — delivery is driven by the stack's tables. A no-ack send is answered -/// `sent` right here; an ack send is answered later by an `ApsSendOutcome` notification -/// (keyed by the request id) once the ack arrives or times out. +/// blocking — delivery is driven by the stack's tables. The stack accepts or rejects the +/// frame now (the `accepted`/error response); its terminal outcome arrives later as a +/// `send_confirm` notification keyed by the request id. async fn dispatch_send_aps(app: &App, id: u64, params: Value) { let Some(stack) = app.stack.as_ref() else { emit(error_response(id, "not_configured", "no stack is running")).await; @@ -495,10 +495,11 @@ async fn dispatch_send_aps(app: &App, id: u64, params: Value) { id, ); + // The stack accepts the frame for transmission or rejects it now. The terminal + // delivery outcome arrives later as a `send_confirm` notification keyed by this + // request id (the send token). let message = match outcome { - // An ack send's terminal response arrives later as an `ApsSendOutcome`. - Ok(()) if request.aps_ack => return, - Ok(()) => response(id, json!({"status": "sent"})), + Ok(()) => response(id, json!({"status": "accepted"})), Err(e) => error_response(id, "transmit_failed", e), }; emit(message).await; @@ -788,11 +789,26 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { "key_id": key_id, }), ), - // A fire-and-forget `send_aps` outcome: the token is the request id, so this is - // the terminal response to that request, not a notification. - ZigbeeNotification::ApsSendOutcome { token, result } => match result { - ApsSendResult::Delivered => response(token, json!({"status": "delivered"})), - ApsSendResult::AckTimeout => error_response(token, "aps_ack_timeout", "no APS ack"), - }, + // Stage three of a send: the terminal confirmation for the `token` the client + // supplied as its `send_aps` request id. `via` names which trigger fired. + ZigbeeNotification::SendConfirm { token, result } => notification( + "send_confirm", + match result { + SendResult::Confirmed { via } => json!({ + "token": token, + "status": "confirmed", + "via": match via { + ConfirmTrigger::Quorum => "quorum", + ConfirmTrigger::NextHop => "next_hop", + ConfirmTrigger::ApsAck => "aps_ack", + }, + }), + SendResult::Failed { reason } => json!({ + "token": token, + "status": "failed", + "reason": reason, + }), + }, + ), } } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 977d148..9eec860 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -18,8 +18,9 @@ use tracing_subscriber::{EnvFilter, fmt}; use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ApsSendResult, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, - TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ConfirmTrigger, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, + SendResult, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, + ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -339,17 +340,24 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json "key_id": key_id, }), ), - // The server uses the awaiting send path, so it never emits this; mapped for - // exhaustiveness. - ZigbeeNotification::ApsSendOutcome { token, result } => notification( - "aps_send_outcome", - json!({ - "token": token, - "result": match result { - ApsSendResult::Delivered => "delivered", - ApsSendResult::AckTimeout => "aps_ack_timeout", - }, - }), + ZigbeeNotification::SendConfirm { token, result } => notification( + "send_confirm", + match result { + SendResult::Confirmed { via } => json!({ + "token": token, + "status": "confirmed", + "via": match via { + ConfirmTrigger::Quorum => "quorum", + ConfirmTrigger::NextHop => "next_hop", + ConfirmTrigger::ApsAck => "aps_ack", + }, + }), + SendResult::Failed { reason } => json!({ + "token": token, + "status": "failed", + "reason": reason, + }), + }, ), } } @@ -652,7 +660,7 @@ impl ZigguratServer { "configure" => server.handle_configure(id, params).await, "get_hw_address" => server.handle_get_hw_address(id).await, "get_network_info" => server.handle_get_network_info(id), - "send_aps" => server.handle_send_aps(id, params, &outbound).await, + "send_aps" => server.handle_send_aps(id, params), "energy_scan" => server.handle_energy_scan(id, params, &outbound).await, "network_scan" => server.handle_network_scan(id, params, &outbound).await, "permit_joins" => server.handle_permit_joins(id, params), @@ -974,12 +982,7 @@ impl ZigguratServer { } } - async fn handle_send_aps( - &self, - id: u64, - params: serde_json::Value, - outbound: &mpsc::Sender, - ) -> serde_json::Value { + fn handle_send_aps(&self, id: u64, params: serde_json::Value) -> serde_json::Value { let request: SendApsRequest = match serde_json::from_value(params) { Ok(request) => request, Err(e) => return error_response(id, "invalid_request", e), @@ -1033,41 +1036,30 @@ impl ZigguratServer { None }; - let ack_waiter = match stack - .send_aps_command( - request.delivery_mode, - destination, - request.profile_id, - request.cluster_id, - request.src_ep, - request.dst_ep, - if request.aps_ack { - ApsAck::Request - } else { - ApsAck::None - }, - request.radius, - request.aps_seq, - asdu, - aps_security, - TxPriority(request.priority), - ) - .await - { - Ok(ack_waiter) => ack_waiter, - Err(e) => return error_response(id, "transmit_failed", e), - }; - - // The frame is on the air (or extracted from the indirect queue); the - // terminal response then reports end-to-end delivery when an ack was requested - let _ = outbound.send(event(id, "transmitted")).await; - - match ack_waiter { - None => response(id, json!({"status": "sent"})), - Some(waiter) => match stack.wait_aps_ack(waiter).await { - Ok(()) => response(id, json!({"status": "delivered"})), - Err(e) => error_response(id, "aps_ack_timeout", e), + // The stack either accepts the frame for transmission or rejects it now. The + // delivery outcome arrives later as a `send_confirm` notification keyed by + // this request id (the send token). + match stack.send_aps( + request.delivery_mode, + destination, + request.profile_id, + request.cluster_id, + request.src_ep, + request.dst_ep, + if request.aps_ack { + ApsAck::Request + } else { + ApsAck::None }, + request.radius, + request.aps_seq, + asdu, + aps_security, + TxPriority(request.priority), + id, + ) { + Ok(()) => response(id, json!({"status": "accepted"})), + Err(e) => error_response(id, "transmit_failed", e), } } From c01f61751da0f297578939e90b2f427eac8f43c5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:04:28 -0400 Subject: [PATCH 55/61] Ignore the ESP32 radio LQI and derive it from RSSI --- crates/ziggurat-phy-esp/src/lib.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index a55ab6f..b3a6728 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -210,14 +210,27 @@ impl EspPhy { } } +fn rssi_to_lqi(rssi: i8) -> u8 { + if rssi < -80 { + 0 + } else if rssi > -30 { + 255 + } else { + ((i32::from(rssi) + 80) as u32 * 255 / 50) as u8 + } +} + fn raw_to_rx_frame(data: &[u8], channel: u8) -> Option { - let len = data[0] as usize; + let len = (data[0] & 0x7f) as usize; if len < 2 || 1 + len > data.len() { return None; } let psdu = &data[1..1 + len]; - let rssi = psdu[len - 2] as i8; - let lqi = psdu[len - 1]; + + // TODO: the LQI doesn't exceed 11 and esp-idf also computes it from RSSI. Why? + let rssi = (psdu[len - 2] as i8); + let lqi = rssi_to_lqi(rssi); + Some(RxFrame { psdu: psdu[..len - 2].to_vec(), channel, From 31a88e9950e27be8341348f89674b47f36ba6a97 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:10:09 -0400 Subject: [PATCH 56/61] Absorb large writes in memory --- crates/ziggurat-esp/src/api.rs | 15 +++++++- crates/ziggurat-esp/src/main.rs | 61 ++++++++++++++++----------------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 4c5f227..5e67889 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -33,7 +33,20 @@ const DEFAULT_TX_POWER: i8 = 8; /// Queue one JSON object for the serial writer task. pub async fn emit(value: Value) { if let Ok(line) = serde_json::to_string(&value) { - OUTBOUND.send(line).await; + push_outbound(line); + } +} + +fn push_outbound(mut line: String) { + loop { + match OUTBOUND.try_send(line) { + Ok(()) => break, + Err(embassy_sync::channel::TrySendError::Full(returned)) => { + line = returned; + let _ = OUTBOUND.try_receive(); + crate::OUTBOUND_DROPPED.fetch_add(1, core::sync::atomic::Ordering::Relaxed); + } + } } } diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index fbd23d7..7d32217 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -41,11 +41,11 @@ esp_bootloader_esp_idf::esp_app_desc!(); /// Outbound JSON lines (responses, events, notifications) converge here and a single /// writer task drains them to the serial port. -const OUTBOUND_DEPTH: usize = 16; +const OUTBOUND_DEPTH: usize = 256; pub static OUTBOUND: Channel = Channel::new(); -/// Complete inbound request lines, produced by `reader_task` and consumed by the +/// Complete inbound request lines, produced by `serial_reader_task` and consumed by the /// processor loop in `main`. Decoupling the read from the (slower) handling keeps the /// USB RX FIFO drained promptly: a burst of commands fills this queue instead of /// stalling the FIFO. @@ -80,13 +80,13 @@ async fn rx_task(phy: Arc) { } /// How often the reader re-checks the USB RX FIFO when no byte has arrived. Bounds the -/// recovery latency from a dropped esp-hal RX wakeup (see `reader_task`). +/// recovery latency from a dropped esp-hal RX wakeup (see `serial_reader_task`). const RX_WATCHDOG: Duration = Duration::from_millis(50); /// Drains the USB-Serial-JTAG RX continuously, splitting on newlines and queueing each /// complete line for the processor. #[embassy_executor::task] -async fn reader_task(mut rx: UsbSerialJtagRx<'static, Async>) { +async fn serial_reader_task(mut rx: UsbSerialJtagRx<'static, Async>) { let mut buf = [0u8; 256]; let mut line: Vec = Vec::with_capacity(2048); loop { @@ -109,25 +109,28 @@ async fn reader_task(mut rx: UsbSerialJtagRx<'static, Async>) { } } -/// The single serial writer: every outbound line goes through it, so concurrent -/// producers (request handlers and the notification drainer) never interleave on the bus. -#[embassy_executor::task] -async fn writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { - loop { - let line = OUTBOUND.receive().await; - let _ = tx.write_all(line.as_bytes()).await; - let _ = tx.write_all(b"\n").await; - let _ = tx.flush().await; - } -} +/// How long the writer waits for the USB host to accept a line before giving up on it. +const WRITE_TIMEOUT: Duration = Duration::from_millis(500); -/// Drains the log channel to the UART console. The actual (blocking) UART write happens -/// here, off the stack's critical path, exactly like `writer_task` does for the JSON bus. #[embassy_executor::task] -async fn uart_log_task(mut tx: UartTx<'static, Async>) { +async fn serial_writer_task(mut tx: UsbSerialJtagTx<'static, Async>) { + let mut resync = false; loop { - let chunk = LOG_OUTBOUND.receive().await; - let _ = tx.write_all(&chunk).await; + let line = OUTBOUND.receive().await; + let write = async { + if resync { + let _ = tx.write_all(b"\n").await; + } + let _ = tx.write_all(line.as_bytes()).await; + let _ = tx.write_all(b"\n").await; + let _ = tx.flush().await; + }; + match select(write, Timer::after(WRITE_TIMEOUT)).await { + Either::First(()) => resync = false, + Either::Second(()) => { + resync = true; + } + } } } @@ -140,7 +143,8 @@ async fn main(spawner: Spawner) -> ! { let timg0 = TimerGroup::new(peripherals.TIMG0); esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); - esp_alloc::heap_allocator!(size: 240 * 1024); + // ~100-router network peaks at ~86 KB heap; ceiling is ~408 KB. + esp_alloc::heap_allocator!(size: 320 * 1024); // Bring up the UART console let log_uart = UartTx::new( @@ -150,10 +154,6 @@ async fn main(spawner: Spawner) -> ! { .expect("UART0 config") .with_tx(peripherals.GPIO16) .into_async(); - spawner.spawn(uart_log_task(log_uart).unwrap()); - - // Route the stack's `tracing` records to the UART console. - log_sink::install(); // Route Zigbee crypto through the AES accelerator: CCM* runs as two DMA passes // (CBC-MAC + CTR) and AES-MMO rides the single-block path. Must happen before the @@ -177,8 +177,8 @@ async fn main(spawner: Spawner) -> ! { let phy = Arc::new(EspPhy::new(peripherals.IEEE802154)); spawner.spawn(rx_task(phy.clone()).unwrap()); - spawner.spawn(reader_task(serial_rx).unwrap()); - spawner.spawn(writer_task(serial_tx).unwrap()); + spawner.spawn(serial_reader_task(serial_rx).unwrap()); + spawner.spawn(serial_writer_task(serial_tx).unwrap()); let mut app = App { phy, @@ -189,11 +189,8 @@ async fn main(spawner: Spawner) -> ! { api::emit(api::hello_message(false)).await; - // The processor loop. `reader_task` owns the RX side and keeps the FIFO drained, so - // a slow handler here only backs up `INBOUND` (bounded) instead of stalling - // intake. `handle_line` spawns each `send_aps` on its own task so a slow transmit - // (route discovery, APS-ack wait) doesn't serialize every later command behind - // it. + // The processor loop. `serial_reader_task` owns the RX side and keeps the FIFO + // drained. loop { let line = INBOUND.receive().await; api::handle_line(&mut app, &line).await; From e8be092a1b457bba245c8687c67af1917d81e725 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:19:21 -0400 Subject: [PATCH 57/61] Bump `esp-hal` fork to fix AES mutex crash --- crates/ziggurat-esp/Cargo.lock | 71 +++++++++-------- crates/ziggurat-esp/Cargo.toml | 13 +++- crates/ziggurat-esp/src/api.rs | 1 - crates/ziggurat-esp/src/log_sink.rs | 116 ---------------------------- crates/ziggurat-esp/src/main.rs | 15 +--- 5 files changed, 47 insertions(+), 169 deletions(-) delete mode 100644 crates/ziggurat-esp/src/log_sink.rs diff --git a/crates/ziggurat-esp/Cargo.lock b/crates/ziggurat-esp/Cargo.lock index b50edaf..7a74e7a 100644 --- a/crates/ziggurat-esp/Cargo.lock +++ b/crates/ziggurat-esp/Cargo.lock @@ -789,13 +789,13 @@ dependencies = [ [[package]] name = "esp-alloc" version = "0.10.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "allocator-api2", "document-features", "enumset", - "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "linked_list_allocator", "rlsf", ] @@ -849,10 +849,10 @@ dependencies = [ [[package]] name = "esp-config" version = "0.7.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "serde", "serde_yaml", "somni-expr", @@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "esp-hal" version = "1.1.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "bitfield", "bitflags 2.13.0", @@ -881,12 +881,12 @@ dependencies = [ "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", "enumset", - "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-riscv-rt", "esp-rom-sys", - "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp32", "esp32c2", "esp32c3", @@ -909,7 +909,7 @@ dependencies = [ "static_cell", "strum", "ufmt-write", - "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "xtensa-lx-rt", ] @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "esp-hal-procmacros" version = "0.22.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", "object", @@ -950,19 +950,19 @@ checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" [[package]] name = "esp-metadata-generated" version = "0.4.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" [[package]] name = "esp-phy" version = "0.2.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", "embassy-sync 0.8.0", - "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-hal", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-wifi-sys-esp32c6", "esp32c6", ] @@ -977,13 +977,12 @@ dependencies = [ "esp-metadata-generated 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "esp-sync 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "log", - "portable-atomic", ] [[package]] name = "esp-radio" version = "1.0.0-beta.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "allocator-api2", "byte", @@ -994,15 +993,15 @@ dependencies = [ "embedded-io 0.7.1", "embedded-io-async 0.6.1", "embedded-io-async 0.7.0", - "esp-alloc 0.10.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-alloc 0.10.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-config 0.7.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-hal", - "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-hal-procmacros 0.22.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-phy", "esp-radio-rtos-driver", "esp-rom-sys", - "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp-wifi-sys-esp32c6", "esp32c6", "heapless 0.9.3", @@ -1017,16 +1016,16 @@ dependencies = [ [[package]] name = "esp-radio-rtos-driver" version = "0.3.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ - "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-sync 0.2.1 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "portable-atomic", ] [[package]] name = "esp-riscv-rt" version = "0.14.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", "riscv", @@ -1036,10 +1035,10 @@ dependencies = [ [[package]] name = "esp-rom-sys" version = "0.1.4" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "esp32c6", ] @@ -1085,15 +1084,15 @@ dependencies = [ [[package]] name = "esp-sync" version = "0.2.1" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", "embassy-sync 0.6.2", "embassy-sync 0.7.2", "embassy-sync 0.8.0", - "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "esp-metadata-generated 0.4.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "riscv", - "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", ] [[package]] @@ -2267,7 +2266,7 @@ dependencies = [ [[package]] name = "xtensa-lx" version = "0.13.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "critical-section", ] @@ -2275,17 +2274,17 @@ dependencies = [ [[package]] name = "xtensa-lx-rt" version = "0.22.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "document-features", - "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832)", + "xtensa-lx 0.13.0 (git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414)", "xtensa-lx-rt-proc-macros", ] [[package]] name = "xtensa-lx-rt-proc-macros" version = "0.5.0" -source = "git+https://github.com/puddly/esp-hal?rev=711fe337f65c6dfe1b6851c41b71bca3bf3c4832#711fe337f65c6dfe1b6851c41b71bca3bf3c4832" +source = "git+https://github.com/puddly/esp-hal?rev=aff9840b75612b20d7101236a85a09e370d4c414#aff9840b75612b20d7101236a85a09e370d4c414" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ziggurat-esp/Cargo.toml b/crates/ziggurat-esp/Cargo.toml index b2d8909..fdc6e5a 100644 --- a/crates/ziggurat-esp/Cargo.toml +++ b/crates/ziggurat-esp/Cargo.toml @@ -29,7 +29,12 @@ esp-backtrace = { version = "0.19.0", features = [ "println", ] } esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6"] } -esp-println = { version = "0.17.0", features = ["esp32c6", "log-04"] } +esp-println = { version = "0.17.0", default-features = false, features = [ + "esp32c6", + "log-04", + "uart", + "critical-section", +] } embassy-executor = "0.10" embassy-time = "0.5" @@ -59,6 +64,6 @@ codegen-units = 1 # so esp-hal must be patched alongside it to avoid two esp-hal copies; cargo resolves each # package by name within the git workspace. [patch.crates-io] -esp-hal = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } -esp-radio = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } -esp-rom-sys = { git = "https://github.com/puddly/esp-hal", rev = "711fe337f65c6dfe1b6851c41b71bca3bf3c4832" } +esp-hal = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } +esp-radio = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } +esp-rom-sys = { git = "https://github.com/puddly/esp-hal", rev = "aff9840b75612b20d7101236a85a09e370d4c414" } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 5e67889..b839d3d 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -44,7 +44,6 @@ fn push_outbound(mut line: String) { Err(embassy_sync::channel::TrySendError::Full(returned)) => { line = returned; let _ = OUTBOUND.try_receive(); - crate::OUTBOUND_DROPPED.fetch_add(1, core::sync::atomic::Ordering::Relaxed); } } } diff --git a/crates/ziggurat-esp/src/log_sink.rs b/crates/ziggurat-esp/src/log_sink.rs deleted file mode 100644 index 9191718..0000000 --- a/crates/ziggurat-esp/src/log_sink.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! A `tracing` subscriber that writes log records to the UART. - -use core::fmt::{self, Write}; -use core::mem; - -use heapless::Vec; -use tracing::field::{Field, Visit}; -use tracing::subscriber::Interest; -use tracing::{Event, Level, Metadata, Subscriber, span}; - -use crate::{LOG_CHUNK, LOG_OUTBOUND}; - -const MAX_LEVEL: Level = Level::INFO; - -pub fn install() { - let _ = tracing::subscriber::set_global_default(LogSink); -} - -struct LogSink; - -impl Subscriber for LogSink { - fn register_callsite(&self, _metadata: &Metadata<'_>) -> Interest { - Interest::sometimes() - } - - fn enabled(&self, metadata: &Metadata<'_>) -> bool { - *metadata.level() <= MAX_LEVEL - } - - fn event(&self, event: &Event<'_>) { - let metadata = event.metadata(); - - let mut writer = ChunkWriter::new(); - let _ = write!( - writer, - "{} {}: ", - metadata.level().as_str(), - metadata.target() - ); - event.record(&mut MessageVisitor(&mut writer)); - writer.finish(); - } - - // Events-only: spans are not used by the stack, so span bookkeeping is a no-op. - fn new_span(&self, _: &span::Attributes<'_>) -> span::Id { - span::Id::from_u64(1) - } - fn record(&self, _: &span::Id, _: &span::Record<'_>) {} - fn record_follows_from(&self, _: &span::Id, _: &span::Id) {} - fn enter(&self, _: &span::Id) {} - fn exit(&self, _: &span::Id) {} -} - -/// Formats bytes into fixed-size inline chunks, spilling each to `LOG_OUTBOUND` as it -/// fills. A single log line therefore streams out as several chunks with no heap -/// allocation. Under backpressure (channel full) the rest of the line is dropped — -/// logging must never block or panic the stack. -struct ChunkWriter { - buf: Vec, - dropped: bool, -} - -impl ChunkWriter { - fn new() -> Self { - Self { - buf: Vec::new(), - dropped: false, - } - } - - fn push_bytes(&mut self, mut bytes: &[u8]) { - while !self.dropped && !bytes.is_empty() { - let take = (LOG_CHUNK - self.buf.len()).min(bytes.len()); - // `take <= remaining capacity`, so this cannot fail. - let _ = self.buf.extend_from_slice(&bytes[..take]); - bytes = &bytes[take..]; - if self.buf.len() == LOG_CHUNK { - self.send_current(); - } - } - } - - fn send_current(&mut self) { - if LOG_OUTBOUND.try_send(mem::take(&mut self.buf)).is_err() { - self.dropped = true; - } - } - - fn finish(mut self) { - self.push_bytes(b"\r\n"); - if !self.dropped && !self.buf.is_empty() { - let _ = LOG_OUTBOUND.try_send(self.buf); - } - } -} - -impl Write for ChunkWriter { - fn write_str(&mut self, s: &str) -> fmt::Result { - self.push_bytes(s.as_bytes()); - Ok(()) - } -} - -/// Collects an event's `message` field (and appends any structured fields) into the -/// chunked writer. -struct MessageVisitor<'a>(&'a mut ChunkWriter); - -impl Visit for MessageVisitor<'_> { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "message" { - let _ = write!(self.0, "{value:?}"); - } else { - let _ = write!(self.0, " {}={value:?}", field.name()); - } - } -} diff --git a/crates/ziggurat-esp/src/main.rs b/crates/ziggurat-esp/src/main.rs index 7d32217..b3cce7d 100644 --- a/crates/ziggurat-esp/src/main.rs +++ b/crates/ziggurat-esp/src/main.rs @@ -10,7 +10,6 @@ extern crate alloc; mod api; mod hw_crypto; -mod log_sink; use alloc::boxed::Box; use alloc::sync::Arc; @@ -52,14 +51,6 @@ pub static OUTBOUND: Channel, INBOUND_DEPTH> = Channel::new(); -pub const LOG_CHUNK: usize = 256; -const LOG_OUTBOUND_DEPTH: usize = 32; -pub static LOG_OUTBOUND: Channel< - CriticalSectionRawMutex, - heapless::Vec, - LOG_OUTBOUND_DEPTH, -> = Channel::new(); - /// Cancels the packet-capture task. Each capture gets a fresh one; `stop_packet_capture` /// signals it so the task exits and frees the radio. pub type CaptureStop = embassy_sync::signal::Signal; @@ -146,10 +137,10 @@ async fn main(spawner: Spawner) -> ! { // ~100-router network peaks at ~86 KB heap; ceiling is ~408 KB. esp_alloc::heap_allocator!(size: 320 * 1024); - // Bring up the UART console - let log_uart = UartTx::new( + // Configure UART0 for debug logging + let _debug_uart = UartTx::new( peripherals.UART0, - UartConfig::default().with_baudrate(460_800), + UartConfig::default().with_baudrate(115200), ) .expect("UART0 config") .with_tx(peripherals.GPIO16) From 6cb0759d5b971701d6edb3aef6fb320ae1fd337e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:21:24 -0400 Subject: [PATCH 58/61] Reformat --- crates/ziggurat-driver/src/zigbee_stack.rs | 22 +++++++++++----------- crates/ziggurat-phy-esp/src/lib.rs | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 1aa834c..779c74c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -263,17 +263,18 @@ pub enum TxOutcome { Discard, /// Resolve an awaiting caller's signal (internal awaiters). Signal(TxCompletion), - /// Confirm an application send by `token`. `aps_ack` present means the end-to-end APS - /// ack is the confirmation — this hop succeeding is silent, its failure fails the - /// send; absent means next-hop acceptance is itself the confirmation. + /// Confirm an application send by `token`. `aps_ack` present means the end-to-end + /// APS ack is the confirmation: this hop succeeding is silent, its failure fails + /// the send; absent means next-hop acceptance is itself the confirmation. Confirm { token: u64, aps_ack: Option, }, } -/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end ack, -/// confirmed (or timed out) as a [`ZigbeeNotification::SendConfirm`] carrying `token`. +/// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end +/// ack, confirmed (or timed out) as a [`ZigbeeNotification::SendConfirm`] carrying +/// `token`. #[derive(Debug)] pub struct PendingApsAck { pub(crate) token: u64, @@ -281,10 +282,9 @@ pub struct PendingApsAck { } /// 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. +/// frame is unencrypted: the sender assigns the frame counter at dequeue. #[derive(Debug)] -pub(crate) struct SendRequest { +pub struct SendRequest { seq: u32, priority: TxPriority, pub(crate) kind: SendKind, @@ -808,14 +808,14 @@ pub struct ZigbeeStack pub config: NetworkConfig, pub tunables: Tunables, pub radio: Arc

, - notifications: Mutex>, + pub notifications: Mutex>, notification_wake: Notify, pub raw_frame_rx: AsyncMutex, pub reset_rx: AsyncMutex, /// Whether a network scan is collecting. The receive loop only queues beacons while /// this is set, so stray beacons outside a scan are dropped. scan_active: AtomicBool, - scan_beacons: Mutex>, + pub scan_beacons: Mutex>, scan_beacon_wake: Notify, /// Wakes the task that rewrites the RCP source address match table whenever the @@ -852,7 +852,7 @@ pub struct ZigbeeStack /// 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>, + pub send_queue: Mutex>, /// Wakes the sender task when a frame is enqueued. pub(crate) send_wake: Notify, /// Wakes the pending-route reactor when a frame is queued awaiting a route, or when a diff --git a/crates/ziggurat-phy-esp/src/lib.rs b/crates/ziggurat-phy-esp/src/lib.rs index b3a6728..6824d09 100644 --- a/crates/ziggurat-phy-esp/src/lib.rs +++ b/crates/ziggurat-phy-esp/src/lib.rs @@ -228,7 +228,7 @@ fn raw_to_rx_frame(data: &[u8], channel: u8) -> Option { let psdu = &data[1..1 + len]; // TODO: the LQI doesn't exceed 11 and esp-idf also computes it from RSSI. Why? - let rssi = (psdu[len - 2] as i8); + let rssi = psdu[len - 2] as i8; let lqi = rssi_to_lqi(rssi); Some(RxFrame { From 898f97392dcf6e55b89deeb5ddc8b8b110131917 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:22:01 -0400 Subject: [PATCH 59/61] Document ESP32 instructions in README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 976c286..7312845 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ Ziggurat can be set up as a regular ZHA radio in **Home Assistant 2026.7.0** or 4. When ZHA asks for the radio type, pick **Ziggurat** and migrate your existing network (or set a new one up). 5. Done. +### ESP32-C6 firmware +Ziggurat can also run on-chip on an ESP32-C6, using its built-in 802.15.4 radio and +exposing the same JSON API over USB-Serial-JTAG: no separate RCP radio or host. + +```bash +cd crates/ziggurat-esp +cargo build --release +espflash flash --no-stub --chip esp32c6 --port \ + target/riscv32imac-unknown-none-elf/release/ziggurat-esp +``` + +- USB-Serial-JTAG carries the JSON API; UART0 on GPIO16 @ 460800 carries logs, the debug heartbeat, and panic backtraces. + ### Development Ziggurat aims to implement the portions of a Zigbee stack used by normal Home Assistant users, not the entire binder of Zigbee specification verbatim. It is nearly feature-complete From bbca0e52105c694a7fb995d568adfe1e9ec239d1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:12:02 -0400 Subject: [PATCH 60/61] Statically type `request_id` --- crates/ziggurat-driver/src/zigbee_stack.rs | 20 +++++--- .../ziggurat-driver/src/zigbee_stack/aps.rs | 50 +++++++++++-------- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 38 +++++++------- crates/ziggurat-esp/src/api.rs | 12 ++--- crates/ziggurat-server/src/main.rs | 12 ++--- crates/ziggurat-zigbee/src/nwk/routing.rs | 16 +++--- 6 files changed, 80 insertions(+), 68 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 779c74c..0104a06 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -256,6 +256,9 @@ impl ApsAckData { /// The pending half of a transmit's outcome. pub type TxCompletion = Signal>; +/// The client's request id, supplied to `send_aps` and echoed back in its confirmation. +pub type RequestId = u32; + /// Where a transmit's terminal outcome is reported. #[derive(Debug)] pub enum TxOutcome { @@ -263,21 +266,21 @@ pub enum TxOutcome { Discard, /// Resolve an awaiting caller's signal (internal awaiters). Signal(TxCompletion), - /// Confirm an application send by `token`. `aps_ack` present means the end-to-end + /// Confirm an application send by `request_id`. `aps_ack` present means the end-to-end /// APS ack is the confirmation: this hop succeeding is silent, its failure fails /// the send; absent means next-hop acceptance is itself the confirmation. Confirm { - token: u64, + request_id: RequestId, aps_ack: Option, }, } /// An entry of [`State::pending_aps_acks`]: a sent APS frame awaiting its end-to-end /// ack, confirmed (or timed out) as a [`ZigbeeNotification::SendConfirm`] carrying -/// `token`. +/// `request_id`. #[derive(Debug)] pub struct PendingApsAck { - pub(crate) token: u64, + pub(crate) request_id: RequestId, pub(crate) deadline: CoreInstant, } @@ -352,7 +355,7 @@ pub struct PendingBroadcast { pub(crate) next_attempt: CoreInstant, /// An application send awaiting confirmation: `SendConfirm { via: Quorum }` when the /// passive-ack quorum is heard, or `Failed` when attempts run out. - pub(crate) token: Option, + pub(crate) request_id: Option, } /// A unicast awaiting re-transmission after a failed attempt, held by the unicast-retry @@ -753,9 +756,10 @@ pub enum ZigbeeNotification { frame_counter: u32, key_id: String, }, - /// Stage 3 of a send: the confirmation of the application send `token` supplied to - /// [`ZigbeeStack::send_aps`]. - SendConfirm { token: u64, result: SendResult }, + SendConfirm { + request_id: RequestId, + result: SendResult, + }, } /// The confirmation of an application send (spec-typed by the frame). diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index dfca916..a62289c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -19,7 +19,7 @@ use ziggurat_zigbee::Instant as CoreInstant; use super::{ ApsAck, ApsAckData, ConfirmTrigger, NwkSecurityMode, PendingApsAck, SendMode, SendResult, - TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + RequestId, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -86,9 +86,9 @@ impl ZigbeeStack { tracing::trace!("Received APS ack: {ack_data:?}"); let pending = self.state.pending_aps_acks.lock().remove(&ack_data); - if let Some(PendingApsAck { token, .. }) = pending { + if let Some(PendingApsAck { request_id, .. }) = pending { self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Confirmed { via: ConfirmTrigger::ApsAck, }, @@ -305,7 +305,7 @@ impl ZigbeeStack { } /// Build and enqueue the frame, then return an accept or reject. Delivery is - /// confirmed later as a [`ZigbeeNotification::SendConfirm`] carrying `token`, + /// confirmed later as a [`ZigbeeNotification::SendConfirm`] carrying `request_id`, /// triggered by the frame type: passive-ack quorum for a broadcast, next-hop /// acceptance for a no-ack unicast, or the APS ack for an ack unicast. #[allow(clippy::too_many_arguments)] @@ -323,7 +323,7 @@ impl ZigbeeStack { data: Vec, aps_security: Option, priority: TxPriority, - token: u64, + request_id: RequestId, ) -> Result<(), ZigbeeStackError> { let (nwk_frame, ack_data) = self.prepare_aps_send( delivery_mode, @@ -343,10 +343,13 @@ impl ZigbeeStack { // deadline the timeout reactor uses) before enqueueing so a fast reply is caught. if let Some(ack_data) = &ack_data { let deadline = self.core_now() + self.aps_ack_timeout(destination); - self.state - .pending_aps_acks - .lock() - .insert(ack_data.clone(), PendingApsAck { token, deadline }); + self.state.pending_aps_acks.lock().insert( + ack_data.clone(), + PendingApsAck { + request_id, + deadline, + }, + ); self.aps_ack_wake.notify_one(); } @@ -354,7 +357,7 @@ impl ZigbeeStack { nwk_frame, priority, TxOutcome::Confirm { - token, + request_id, aps_ack: ack_data, }, ); @@ -364,7 +367,7 @@ impl ZigbeeStack { /// Enqueue a built APS/NWK frame fire-and-forget, routing broadcasts and unicasts like /// [`send_nwk_frame`](Self::send_nwk_frame). The `outcome` rides the unicast path (the /// sender confirms next-hop acceptance / failure); a broadcast is confirmed by the - /// retransmit reactor on quorum, so only its `token` is carried over. + /// retransmit reactor on quorum, so only its `request_id` is carried over. pub(super) fn enqueue_aps_frame( &self, nwk_frame: NwkFrame, @@ -372,11 +375,16 @@ impl ZigbeeStack { outcome: TxOutcome, ) { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { - let token = match outcome { - TxOutcome::Confirm { token, .. } => Some(token), + let request_id = match outcome { + TxOutcome::Confirm { request_id, .. } => Some(request_id), TxOutcome::Discard | TxOutcome::Signal(_) => None, }; - self.send_broadcast_nwk_frame(nwk_frame, NwkSecurityMode::NetworkKey, priority, token); + self.send_broadcast_nwk_frame( + nwk_frame, + NwkSecurityMode::NetworkKey, + priority, + request_id, + ); } else { self.originate_unicast( nwk_frame, @@ -417,24 +425,24 @@ impl ZigbeeStack { fn expire_aps_acks(&self) { let now = self.core_now(); - let expired: Vec = { + let expired: Vec = { let mut pending = self.state.pending_aps_acks.lock(); - let due: Vec<(ApsAckData, u64)> = pending + let due: Vec<(ApsAckData, RequestId)> = pending .iter() .filter(|(_, p)| p.deadline <= now) - .map(|(key, p)| (key.clone(), p.token)) + .map(|(key, p)| (key.clone(), p.request_id)) .collect(); for (key, _) in &due { pending.remove(key); } drop(pending); - due.into_iter().map(|(_, token)| token).collect() + due.into_iter().map(|(_, request_id)| request_id).collect() }; - for token in expired { - tracing::warn!("APS ack timed out for send {token}"); + for request_id in expired { + tracing::warn!("APS ack timed out for send {request_id}"); self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Failed { reason: "APS ack timed out".to_string(), }, diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 1369aa5..a9544a0 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -27,8 +27,8 @@ use super::routing::{Route, Status as RouteStatus}; use super::{ AddrConflictSource, ConfirmTrigger, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, SendKind, - SendMode, SendRequest, SendResult, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, - ZigbeeStackError, + SendMode, SendRequest, SendResult, RequestId, TxOutcome, TxPriority, ZigbeeNotification, + ZigbeeStack, ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -144,9 +144,9 @@ impl ZigbeeStack { if self.broadcast_passively_acked(key) { tracing::debug!("Broadcast {key:?} passively acknowledged"); let removed = self.state.pending_broadcasts.lock().remove(&key); - if let Some(token) = removed.and_then(|broadcast| broadcast.token) { + if let Some(request_id) = removed.and_then(|broadcast| broadcast.request_id) { self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Confirmed { via: ConfirmTrigger::Quorum, }, @@ -165,7 +165,7 @@ impl ZigbeeStack { enum Next { Idle, Retransmit(NwkFrame, NwkSecurityMode, TxPriority), - Exhausted(Option), + Exhausted(Option), } // Decide under the lock; if a copy is due, extract it to transmit after release. @@ -178,9 +178,9 @@ impl ZigbeeStack { if broadcast.next_attempt > now { Next::Idle } else if broadcast.attempts_remaining == 0 { - let token = broadcast.token; + let request_id = broadcast.request_id; pending.remove(&key); - Next::Exhausted(token) + Next::Exhausted(request_id) } else { broadcast.attempts_remaining -= 1; broadcast.next_attempt = next_attempt; @@ -205,11 +205,11 @@ impl ZigbeeStack { TxOutcome::Discard, ); } - Next::Exhausted(token) => { + Next::Exhausted(request_id) => { tracing::debug!("Broadcast {key:?} out of retransmit attempts"); - if let Some(token) = token { + if let Some(request_id) = request_id { self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Failed { reason: "passive-ack quorum not reached".to_string(), }, @@ -230,11 +230,11 @@ impl ZigbeeStack { priority: TxPriority, first_delay: Duration, attempts: u8, - token: Option, + request_id: Option, ) { - // With a token we still track the broadcast even at zero retransmits, so the + // With a request_id we still track the broadcast even at zero retransmits, so the // reactor can confirm its quorum (or fail it); untracked broadcasts just return. - if attempts == 0 && token.is_none() { + if attempts == 0 && request_id.is_none() { return; } @@ -246,7 +246,7 @@ impl ZigbeeStack { priority, attempts_remaining: attempts, next_attempt: self.core_now() + first_delay, - token, + request_id, }, ); self.broadcast_retransmit_wake.notify_one(); @@ -1052,13 +1052,13 @@ impl ZigbeeStack { } } TxOutcome::Signal(signal) => signal.signal(result), - TxOutcome::Confirm { token, aps_ack } => match result { + TxOutcome::Confirm { request_id, aps_ack } => match result { // Next-hop acceptance confirms a no-ack send; an ack send waits for the // APS ack (the aps-ack table confirms it), so success here is silent. Ok(()) => { if aps_ack.is_none() { self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Confirmed { via: ConfirmTrigger::NextHop, }, @@ -1072,7 +1072,7 @@ impl ZigbeeStack { self.state.pending_aps_acks.lock().remove(&ack_data); } self.push_notification(ZigbeeNotification::SendConfirm { - token, + request_id, result: SendResult::Failed { reason: err.to_string(), }, @@ -1328,7 +1328,7 @@ impl ZigbeeStack { priority: TxPriority, // An application send awaiting confirmation on passive-ack quorum; internal // broadcasts pass `None`. - token: Option, + request_id: Option, ) { nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); @@ -1369,7 +1369,7 @@ impl ZigbeeStack { priority, self.tunables.passive_ack_timeout + self.broadcast_jitter(), self.tunables.max_broadcast_retries, - token, + request_id, ); } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index b839d3d..1d43ae3 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -18,8 +18,8 @@ use crate::CaptureStop; use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ConfirmTrigger, NetworkBeacon, NetworkConfig, NwkDeviceType, SendResult, TclkSeed, - Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, + ApsAck, ConfirmTrigger, NetworkBeacon, NetworkConfig, NwkDeviceType, SendResult, RequestId, + TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -504,7 +504,7 @@ async fn dispatch_send_aps(app: &App, id: u64, params: Value) { asdu, aps_security, TxPriority(request.priority), - id, + id as RequestId, ); // The stack accepts the frame for transmission or rejects it now. The terminal @@ -803,11 +803,11 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { ), // Stage three of a send: the terminal confirmation for the `token` the client // supplied as its `send_aps` request id. `via` names which trigger fired. - ZigbeeNotification::SendConfirm { token, result } => notification( + ZigbeeNotification::SendConfirm { request_id, result } => notification( "send_confirm", match result { SendResult::Confirmed { via } => json!({ - "token": token, + "id": request_id, "status": "confirmed", "via": match via { ConfirmTrigger::Quorum => "quorum", @@ -816,7 +816,7 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { }, }), SendResult::Failed { reason } => json!({ - "token": token, + "id": request_id, "status": "failed", "reason": reason, }), diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 9eec860..4449545 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -19,8 +19,8 @@ use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ ApsAck, ConfirmTrigger, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, - SendResult, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, - ZigbeeStack, + SendResult, RequestId, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, + ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -340,11 +340,11 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json "key_id": key_id, }), ), - ZigbeeNotification::SendConfirm { token, result } => notification( + ZigbeeNotification::SendConfirm { request_id, result } => notification( "send_confirm", match result { SendResult::Confirmed { via } => json!({ - "token": token, + "id": request_id, "status": "confirmed", "via": match via { ConfirmTrigger::Quorum => "quorum", @@ -353,7 +353,7 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json }, }), SendResult::Failed { reason } => json!({ - "token": token, + "id": request_id, "status": "failed", "reason": reason, }), @@ -1056,7 +1056,7 @@ impl ZigguratServer { asdu, aps_security, TxPriority(request.priority), - id, + id as RequestId, ) { Ok(()) => response(id, json!({"status": "accepted"})), Err(e) => error_response(id, "transmit_failed", e), diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 862eaf0..1320f2b 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -8,7 +8,7 @@ use ziggurat_ieee_802154::types::Nwk; use crate::nwk::frame::BROADCAST_ALL_ROUTERS_AND_COORDINATOR; -pub type RequestId = u8; +pub type RouteRequestId = u8; const UNKNOWN_NEXT_HOP: Nwk = Nwk(0xFFFF); @@ -104,7 +104,7 @@ pub struct DiscoveryEntry { /// distinct from the 16-bit Routing Sequence Number. The former is used to discern /// route requests originating in a particular router; the latter is used to /// identify stale routing information. - pub route_request_id: RequestId, + pub route_request_id: RouteRequestId, /// The 16-bit network address of the route request’s initiator. pub source_address: Nwk, /// The 16-bit network address of the device that has sent the most recent lowest @@ -165,14 +165,14 @@ pub struct Routing { mtorr_delivery_failure_threshold: u8, route_table: BTreeMap, - discovery_table: BTreeMap<(Nwk, RequestId), DiscoveryEntry>, + discovery_table: BTreeMap<(Nwk, RouteRequestId), DiscoveryEntry>, route_record_table: BTreeMap>, /// Implied from the spec: "notice that this 8-bit identifier is distinct from the /// 16-bit Routing Sequence Number. The former is used to discern route requests /// originating in a particular router; the latter is used to identify stale routing /// information." - request_sequence_number: RequestId, + request_sequence_number: RouteRequestId, } impl Routing { @@ -334,7 +334,7 @@ impl Routing { /// Prepare table state for a route discovery we originate: the routing entry enters /// `DiscoveryUnderway` and a discovery entry keyed by our own address is created. /// Returns the request identifier to put in the route request command. - pub fn begin_discovery(&mut self, destination: Nwk, now: Instant) -> RequestId { + pub fn begin_discovery(&mut self, destination: Nwk, now: Instant) -> RouteRequestId { // Expire stale discoveries before establishing the new one. A just-expired // discovery toward this same destination would otherwise tear down the // `DiscoveryUnderway` route entry created below. @@ -370,7 +370,7 @@ impl Routing { /// Register the discovery entry backing a many-to-one route advertisement, which /// is addressed to a broadcast address and never answered with a reply. - pub fn begin_many_to_one_advertisement(&mut self, now: Instant) -> RequestId { + pub fn begin_many_to_one_advertisement(&mut self, now: Instant) -> RouteRequestId { self.request_sequence_number = self.request_sequence_number.wrapping_add(1); let request_id = self.request_sequence_number; @@ -415,7 +415,7 @@ impl Routing { pub fn accept_route_request( &mut self, originator: Nwk, - request_id: RequestId, + request_id: RouteRequestId, destination: Nwk, sender: Nwk, updated_path_cost: u8, @@ -491,7 +491,7 @@ impl Routing { pub fn accept_route_reply( &mut self, originator: Nwk, - request_id: RequestId, + request_id: RouteRequestId, responder: Nwk, sender: Nwk, updated_path_cost: u8, From 065d7edf7b84ed32b854cf3801a1a00371a48aee Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:17:40 -0400 Subject: [PATCH 61/61] Split APS ACK confirmation out into a new message --- crates/ziggurat-driver/src/zigbee_stack.rs | 29 +++++----- .../ziggurat-driver/src/zigbee_stack/aps.rs | 14 ++--- .../src/zigbee_stack/indirect.rs | 7 ++- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 55 ++++++++++--------- crates/ziggurat-esp/src/api.rs | 26 ++++++--- crates/ziggurat-server/src/main.rs | 28 +++++++--- 6 files changed, 92 insertions(+), 67 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 0104a06..c7dfe63 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -760,26 +760,27 @@ pub enum ZigbeeNotification { request_id: RequestId, result: SendResult, }, + ApsAckConfirm { + request_id: RequestId, + result: ApsAckResult, + }, } -/// The confirmation of an application send (spec-typed by the frame). #[derive(Debug, Clone)] pub enum SendResult { - /// The send was confirmed; `via` names the trigger. - Confirmed { via: ConfirmTrigger }, - /// The send failed before confirmation. - Failed { reason: String }, + /// Handed off; `next_hop` is the neighbour it went to, `None` for a broadcast. + Confirmed { + next_hop: Option, + }, + Failed { + reason: String, + }, } -/// Which delivery event confirmed a send. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfirmTrigger { - /// Broadcast: the passive-ack quorum was heard. - Quorum, - /// Unicast with no APS ack requested: the next hop acknowledged the frame. - NextHop, - /// Unicast with an APS ack requested: the end-to-end APS ack arrived. - ApsAck, +#[derive(Debug, Clone)] +pub enum ApsAckResult { + Acked, + Failed { reason: String }, } #[derive(Debug, Clone)] diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index a62289c..a5c2af4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -18,8 +18,8 @@ use ziggurat_phy::RadioPhy; use ziggurat_zigbee::Instant as CoreInstant; use super::{ - ApsAck, ApsAckData, ConfirmTrigger, NwkSecurityMode, PendingApsAck, SendMode, SendResult, - RequestId, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + ApsAck, ApsAckData, ApsAckResult, NwkSecurityMode, PendingApsAck, RequestId, SendMode, + TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; impl ZigbeeStack { @@ -87,11 +87,9 @@ impl ZigbeeStack { let pending = self.state.pending_aps_acks.lock().remove(&ack_data); if let Some(PendingApsAck { request_id, .. }) = pending { - self.push_notification(ZigbeeNotification::SendConfirm { + self.push_notification(ZigbeeNotification::ApsAckConfirm { request_id, - result: SendResult::Confirmed { - via: ConfirmTrigger::ApsAck, - }, + result: ApsAckResult::Acked, }); } } @@ -441,9 +439,9 @@ impl ZigbeeStack { for request_id in expired { tracing::warn!("APS ack timed out for send {request_id}"); - self.push_notification(ZigbeeNotification::SendConfirm { + self.push_notification(ZigbeeNotification::ApsAckConfirm { request_id, - result: SendResult::Failed { + result: ApsAckResult::Failed { reason: "APS ack timed out".to_string(), }, }); diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 3d7dc56..fe77ace 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -111,6 +111,7 @@ impl ZigbeeStack { for (destination, transaction) in outcome.expired { self.resolve_outcome( transaction.completion, + None, Err(ZigbeeStackError::IndirectExpired { destination }), ); } @@ -178,7 +179,7 @@ impl ZigbeeStack { .await { Ok(()) => { - self.resolve_outcome(transaction.completion, Ok(())); + self.resolve_outcome(transaction.completion, None, Ok(())); self.remove_indirect_queue_if_empty(destination); } // 802.15.4 spec 6.7.3: a transaction is only extracted once acknowledged, @@ -191,7 +192,7 @@ impl ZigbeeStack { .requeue(destination, transaction); } Err(err) => { - self.resolve_outcome(transaction.completion, Err(err)); + self.resolve_outcome(transaction.completion, None, Err(err)); self.remove_indirect_queue_if_empty(destination); } } @@ -214,6 +215,7 @@ impl ZigbeeStack { for (destination, transaction) in dropped { self.resolve_outcome( transaction.completion, + None, Err(ZigbeeStackError::IndirectExpired { destination }), ); } @@ -352,6 +354,7 @@ impl ZigbeeStack { tracing::warn!("Indirect transaction to {destination:?} expired without a poll"); self.resolve_outcome( transaction.completion, + None, Err(ZigbeeStackError::IndirectExpired { destination }), ); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index a9544a0..06b408f 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -25,9 +25,9 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, Status as RouteStatus}; use super::{ - AddrConflictSource, ConfirmTrigger, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, - PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, SendKind, - SendMode, SendRequest, SendResult, RequestId, TxOutcome, TxPriority, ZigbeeNotification, + AddrConflictSource, IndirectFrame, IndirectPayload, MAX_DEPTH, NwkSecurityMode, + PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, PendingUnicastRetry, RequestId, + SendKind, SendMode, SendRequest, SendResult, TxOutcome, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; @@ -147,9 +147,7 @@ impl ZigbeeStack { if let Some(request_id) = removed.and_then(|broadcast| broadcast.request_id) { self.push_notification(ZigbeeNotification::SendConfirm { request_id, - result: SendResult::Confirmed { - via: ConfirmTrigger::Quorum, - }, + result: SendResult::Confirmed { next_hop: None }, }); } continue; @@ -510,7 +508,11 @@ impl ZigbeeStack { tracing::debug!( "Dropping frame to {destination:?}: no route and discovery suppressed" ); - self.resolve_outcome(outcome, Err(ZigbeeStackError::RouteDiscoverySuppressed)); + self.resolve_outcome( + outcome, + None, + Err(ZigbeeStackError::RouteDiscoverySuppressed), + ); } } } @@ -944,6 +946,7 @@ impl ZigbeeStack { NextHop::NeedDiscovery | NextHop::Discard => { self.resolve_outcome( outcome, + None, Err(ZigbeeStackError::RouteInactiveAfterDiscovery), ); } @@ -985,6 +988,7 @@ impl ZigbeeStack { for PendingFrame { outcome, .. } in frames { self.resolve_outcome( outcome, + None, Err(ZigbeeStackError::RouteDiscoveryTimeout(Elapsed)), ); } @@ -1029,11 +1033,11 @@ impl ZigbeeStack { security, } => { let result = self.process_broadcast_send(nwk_frame, security).await; - self.resolve_outcome(request.outcome, result); + self.resolve_outcome(request.outcome, None, result); } SendKind::Raw { frame } => { let result = self.send_802154_frame(frame).await; - self.resolve_outcome(request.outcome, result); + self.resolve_outcome(request.outcome, None, result); } } } @@ -1044,7 +1048,12 @@ impl ZigbeeStack { /// Deliver a transmit's terminal outcome to wherever it is owed: log a dropped /// background failure, wake an awaiting caller, or confirm an application send. - pub(super) fn resolve_outcome(&self, outcome: TxOutcome, result: Result<(), ZigbeeStackError>) { + pub(super) fn resolve_outcome( + &self, + outcome: TxOutcome, + next_hop: Option, + result: Result<(), ZigbeeStackError>, + ) { match outcome { TxOutcome::Discard => { if let Err(err) = result { @@ -1052,22 +1061,18 @@ impl ZigbeeStack { } } TxOutcome::Signal(signal) => signal.signal(result), - TxOutcome::Confirm { request_id, aps_ack } => match result { - // Next-hop acceptance confirms a no-ack send; an ack send waits for the - // APS ack (the aps-ack table confirms it), so success here is silent. + TxOutcome::Confirm { + request_id, + aps_ack, + } => match result { Ok(()) => { - if aps_ack.is_none() { - self.push_notification(ZigbeeNotification::SendConfirm { - request_id, - result: SendResult::Confirmed { - via: ConfirmTrigger::NextHop, - }, - }); - } + self.push_notification(ZigbeeNotification::SendConfirm { + request_id, + result: SendResult::Confirmed { next_hop }, + }); } - // The frame never reached its next hop: fail the send and drop any pending - // aps-ack so a late or spurious ack cannot double-confirm. Err(err) => { + // Drop any pending aps-ack so a late ack can't emit a stray ApsAckConfirm. if let Some(ack_data) = aps_ack { self.state.pending_aps_acks.lock().remove(&ack_data); } @@ -1118,7 +1123,7 @@ impl ZigbeeStack { self.increment_tx_total(); let Err(e) = self.send_802154_frame(ieee802154_frame).await else { - self.resolve_outcome(outcome, Ok(())); + self.resolve_outcome(outcome, Some(next_hop_address), Ok(())); return; }; @@ -1137,7 +1142,7 @@ impl ZigbeeStack { if attempts_remaining == 0 { tracing::error!("Failed to send unicast frame after all attempts"); self.handle_unicast_send_failure(&nwk_frame, next_hop_address); - self.resolve_outcome(outcome, Err(e)); + self.resolve_outcome(outcome, Some(next_hop_address), Err(e)); return; } diff --git a/crates/ziggurat-esp/src/api.rs b/crates/ziggurat-esp/src/api.rs index 1d43ae3..bf9517b 100644 --- a/crates/ziggurat-esp/src/api.rs +++ b/crates/ziggurat-esp/src/api.rs @@ -18,7 +18,7 @@ use crate::CaptureStop; use ziggurat_driver::runtime::Spawn; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ConfirmTrigger, NetworkBeacon, NetworkConfig, NwkDeviceType, SendResult, RequestId, + ApsAck, ApsAckResult, NetworkBeacon, NetworkConfig, NwkDeviceType, RequestId, SendResult, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; @@ -801,19 +801,13 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { "key_id": key_id, }), ), - // Stage three of a send: the terminal confirmation for the `token` the client - // supplied as its `send_aps` request id. `via` names which trigger fired. ZigbeeNotification::SendConfirm { request_id, result } => notification( "send_confirm", match result { - SendResult::Confirmed { via } => json!({ + SendResult::Confirmed { next_hop } => json!({ "id": request_id, "status": "confirmed", - "via": match via { - ConfirmTrigger::Quorum => "quorum", - ConfirmTrigger::NextHop => "next_hop", - ConfirmTrigger::ApsAck => "aps_ack", - }, + "next_hop": next_hop.map(|nwk| format!("{:04x}", nwk.0)), }), SendResult::Failed { reason } => json!({ "id": request_id, @@ -822,5 +816,19 @@ fn notification_to_json(notification_event: ZigbeeNotification) -> Value { }), }, ), + ZigbeeNotification::ApsAckConfirm { request_id, result } => notification( + "aps_ack_confirm", + match result { + ApsAckResult::Acked => json!({ + "id": request_id, + "status": "confirmed", + }), + ApsAckResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), } } diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 4449545..aae87a7 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -18,9 +18,9 @@ use tracing_subscriber::{EnvFilter, fmt}; use ziggurat_driver::runtime::TokioSpawner; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ConfirmTrigger, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, - SendResult, RequestId, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, - ZigbeeNotification, ZigbeeStack, + ApsAck, ApsAckResult, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, + RequestId, SendResult, TclkSeed, Tunables, TxPriority, WELL_KNOWN_LINK_KEY, ZigbeeNotification, + ZigbeeStack, }; use ziggurat_driver::ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; use ziggurat_phy::{RadioConfig, RadioPhy, Receiver}; @@ -343,14 +343,10 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json ZigbeeNotification::SendConfirm { request_id, result } => notification( "send_confirm", match result { - SendResult::Confirmed { via } => json!({ + SendResult::Confirmed { next_hop } => json!({ "id": request_id, "status": "confirmed", - "via": match via { - ConfirmTrigger::Quorum => "quorum", - ConfirmTrigger::NextHop => "next_hop", - ConfirmTrigger::ApsAck => "aps_ack", - }, + "next_hop": next_hop.map(|nwk| format!("{:04x}", nwk.0)), }), SendResult::Failed { reason } => json!({ "id": request_id, @@ -359,6 +355,20 @@ fn notification_to_message(notification_event: ZigbeeNotification) -> serde_json }), }, ), + ZigbeeNotification::ApsAckConfirm { request_id, result } => notification( + "aps_ack_confirm", + match result { + ApsAckResult::Acked => json!({ + "id": request_id, + "status": "confirmed", + }), + ApsAckResult::Failed { reason } => json!({ + "id": request_id, + "status": "failed", + "reason": reason, + }), + }, + ), } }