From 05a322d8e2259185223ba0f2ae467b7d5181df7f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:50:46 -0400 Subject: [PATCH 1/3] Granular network state tracking --- crates/ziggurat-driver/src/zigbee_stack.rs | 56 +++++++++++++++++- .../ziggurat-driver/src/zigbee_stack/aps.rs | 6 +- .../src/zigbee_stack/joining.rs | 16 ++++++ .../ziggurat-driver/src/zigbee_stack/nwk.rs | 24 +++++++- crates/ziggurat-protocol/src/bridge.rs | 40 ++++++++++++- crates/ziggurat-protocol/src/wire.rs | 37 ++++++++++++ crates/ziggurat-zigbee/src/nwk/neighbors.rs | 7 +++ crates/ziggurat-zigbee/src/nwk/routing.rs | 57 ++++++++++++++++--- 8 files changed, 230 insertions(+), 13 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index f7f1bea..ef482fd 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -788,8 +788,28 @@ pub enum ZigbeeNotification { /// A unique trust center link key was negotiated with a device; the client should /// persist it so the device survives a stack restart LinkKeyUpdate { ieee: Eui64, key: Key }, - /// A device joined or rejoined the network, directly or through a router - DeviceJoined { nwk: Nwk, ieee: Eui64, parent: Nwk }, + /// A device joined or rejoined the network, directly or through a router. The + /// capability is known for direct association/rejoin (our children) and `None` for + /// joins learned second-hand via an APS Update-Device from a router. + DeviceJoined { + nwk: Nwk, + ieee: Eui64, + parent: Nwk, + device_type: Option, + rx_on_when_idle: bool, + }, + /// A routing table entry's next hop changed, or the route was removed; the client + /// persists the next-hop cache to warm-start routing on restart + RouteChanged { + destination: Nwk, + next_hop: Nwk, + removed: bool, + }, + /// A source route (relay list) to a destination was learned or cleared + RouteRecord { destination: Nwk, relays: Vec }, + /// The outgoing APS security frame counter has advanced; the client persists it to + /// prevent a rollback on restart + ApsFrameCounterUpdate { frame_counter: u32 }, /// A device left the network. The EUI64 is unknown when the leaving device never /// made it into the address map. `reason` records how we learned of the departure. DeviceLeft { @@ -920,6 +940,9 @@ pub struct ZigbeeStack pub(crate) pending_route_wake: Notify, /// Monotonic tiebreaker giving equal-priority sends FIFO order in `send_queue`. pub(crate) send_seq: AtomicU32, + /// The outgoing APS frame counter last streamed to the client, so a persist + /// notification only fires every `FRAME_COUNTER_NOTIFY_INTERVAL` frames. + last_persisted_aps_counter: 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 @@ -1015,6 +1038,7 @@ impl ZigbeeStack { send_wake: Notify::new(), pending_route_wake: Notify::new(), send_seq: AtomicU32::new(0), + last_persisted_aps_counter: AtomicU32::new(0), spawner, cancels: Mutex::new(FlatMap::new()), next_task_id: AtomicU32::new(0), @@ -1091,6 +1115,34 @@ impl ZigbeeStack { self.notification_wake.notify_one(); } + /// The `(device_type, rx_on_when_idle)` for a `DeviceJoined` notification, read back + /// from the neighbor entry. Known for a direct association/rejoin (the device is our + /// child); `(None, true)` for a join learned second-hand via a router's Update-Device. + fn device_join_capability(&self, ieee: Eui64) -> (Option, bool) { + let device_capability = self.core().nib.neighbors.device_capability(ieee); + match device_capability { + Some((device_type, rx_on_when_idle)) => (Some(device_type), rx_on_when_idle), + None => (None, true), + } + } + + /// Stream the outgoing APS frame counter every `FRAME_COUNTER_NOTIFY_INTERVAL` + /// frames so a restart can't roll it back. Called after each APS encryption. + fn maybe_notify_aps_frame_counter(&self) { + let counter = self.core().aib.aps_security.outgoing_frame_counter(); + let last = self + .last_persisted_aps_counter + .load(AtomicOrdering::Relaxed); + + if counter.wrapping_sub(last) >= FRAME_COUNTER_NOTIFY_INTERVAL { + self.last_persisted_aps_counter + .store(counter, AtomicOrdering::Relaxed); + self.push_notification(ZigbeeNotification::ApsFrameCounterUpdate { + frame_counter: counter, + }); + } + } + /// Wait for and take all queued network events. pub async fn next_notifications(&self) -> Vec { loop { diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index 0db77e9..b77d165 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -162,6 +162,7 @@ impl ZigbeeStack { return; }; + self.maybe_notify_aps_frame_counter(); encrypted.to_bytes() } else { ack_frame.to_bytes() @@ -261,7 +262,10 @@ impl ZigbeeStack { .aps_security .encrypt_data(destination_eui64, &aps_frame); match encrypted { - Some(encrypted) => encrypted.to_bytes(), + Some(encrypted) => { + self.maybe_notify_aps_frame_counter(); + encrypted.to_bytes() + } None => return Err(ZigbeeStackError::ApsSecurityFailed), } } else { diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index a45cc08..94092fb 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -494,10 +494,13 @@ impl ZigbeeStack { self.background_send_nwk_frame(nwk_frame, NwkSecurityMode::Unsecured, SendMode::Direct); + let (device_type, rx_on_when_idle) = self.device_join_capability(destination_eui64); self.push_notification(ZigbeeNotification::DeviceJoined { nwk: destination, ieee: destination_eui64, parent: self.state.network_address, + device_type, + rx_on_when_idle, }); } @@ -917,6 +920,9 @@ impl ZigbeeStack { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, + // Learned via the router's Update-Device, which carries no capability + device_type: None, + rx_on_when_idle: true, }); } ApsUpdateDeviceStatus::StandardDeviceTrustCenterRejoin => { @@ -938,6 +944,9 @@ impl ZigbeeStack { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, + // Learned via the router's Update-Device, which carries no capability + device_type: None, + rx_on_when_idle: true, }); } ApsUpdateDeviceStatus::StandardDeviceSecuredRejoin => { @@ -947,6 +956,9 @@ impl ZigbeeStack { nwk: update.device_short_address, ieee: update.device_address, parent: router_nwk, + // Learned via the router's Update-Device, which carries no capability + device_type: None, + rx_on_when_idle: true, }); } ApsUpdateDeviceStatus::DeviceLeft => { @@ -1139,10 +1151,14 @@ impl ZigbeeStack { // `send_network_key` also emits the join notification self.send_network_key(assigned_nwk, source_ieee, JoinKind::Rejoin); } else { + let (device_type, rx_on_when_idle) = self.device_join_capability(source_ieee); + self.push_notification(ZigbeeNotification::DeviceJoined { nwk: assigned_nwk, ieee: source_ieee, parent: self.state.network_address, + device_type, + rx_on_when_idle, }); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 87653b9..7d255b8 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -412,10 +412,19 @@ impl ZigbeeStack { NwkCommand::RouteReply(cmd) => self.handle_route_reply(nwk_frame, cmd.clone()), NwkCommand::RouteRecord(cmd) => { tracing::trace!("Route record command frame received: {cmd:?}"); - self.core() + let source = nwk_frame.nwk_header.source; + let changed = self + .core() .nib .routing - .store_route_record(nwk_frame.nwk_header.source, cmd.relays.clone()); + .store_route_record(source, cmd.relays.clone()); + + if changed { + self.push_notification(ZigbeeNotification::RouteRecord { + destination: source, + relays: cmd.relays.clone(), + }); + } } NwkCommand::RouteRequest(cmd) => { self.handle_route_request(nwk_frame, cmd.clone(), sender_nwk); @@ -449,6 +458,17 @@ impl ZigbeeStack { } } } + + // Stream any next-hop cache changes this frame produced so the client can + // persist them for a warm restart. + let route_changes = self.core().nib.routing.drain_route_changes(); + for (destination, next_hop) in route_changes { + self.push_notification(ZigbeeNotification::RouteChanged { + destination, + next_hop: next_hop.unwrap_or(Nwk(0xFFFF)), + removed: next_hop.is_none(), + }); + } } /// A NWK command frame originated by us, with stack-wide defaults: secured, route diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index 823eca0..0cb2604 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -350,11 +350,28 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { }; Notification::ApsAckConfirm(*request_id as u16, ApsAckConfirmPayload { acked, reason }) } - ZigbeeNotification::DeviceJoined { nwk, ieee, parent } => { + ZigbeeNotification::DeviceJoined { + nwk, + ieee, + parent, + device_type, + rx_on_when_idle, + } => { + let device_type = match device_type { + Some(NwkDeviceType::Router) => ChildDeviceType::Router, + Some(NwkDeviceType::EndDevice) => ChildDeviceType::EndDevice, + // `None` (learned via a router's Update-Device) or the nonsensical + // Coordinator both map to Unknown + _ => ChildDeviceType::Unknown, + }; Notification::DeviceJoined(DeviceJoinedPayload { nwk: *nwk, ieee: *ieee, parent: *parent, + flags: ChildFlags { + rx_on_when_idle: *rx_on_when_idle, + device_type, + }, }) } ZigbeeNotification::DeviceLeft { nwk, ieee, reason } => { @@ -395,6 +412,27 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { ieee: *ieee, key: key.clone(), }), + ZigbeeNotification::RouteChanged { + destination, + next_hop, + removed, + } => Notification::RouteChanged(RouteChangedPayload { + destination: *destination, + next_hop: *next_hop, + removed: *removed, + }), + ZigbeeNotification::RouteRecord { + destination, + relays, + } => Notification::RouteRecord(RouteRecordPayload { + destination: *destination, + relays: relays.clone(), + }), + ZigbeeNotification::ApsFrameCounterUpdate { frame_counter } => { + Notification::ApsFrameCounter(ApsFrameCounterPayload { + frame_counter: *frame_counter, + }) + } ZigbeeNotification::ApsDecryptionFailure { source, source_ieee, diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index 1542ec2..6514c3c 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -61,6 +61,9 @@ pub enum CommandId { LinkKey = 0x36, ApsDecryptFailure = 0x37, LastReset = 0x38, + RouteChanged = 0x39, + RouteRecord = 0x3A, + ApsFrameCounter = 0x3B, } impl From for u8 { @@ -524,6 +527,7 @@ pub struct DeviceJoinedPayload { pub nwk: Nwk, pub ieee: Eui64, pub parent: Nwk, + pub flags: ChildFlags, } #[abstract_bits] @@ -553,6 +557,30 @@ pub struct LinkKeyPayload { pub key: Key, } +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteChangedPayload { + pub destination: Nwk, + pub next_hop: Nwk, + pub removed: bool, + pub reserved: u7, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteRecordPayload { + pub destination: Nwk, + pub relay_count: u8, + #[abstract_bits(length_from = relay_count)] + pub relays: Vec, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct ApsFrameCounterPayload { + pub frame_counter: u32, +} + #[abstract_bits] #[derive(Debug, Clone)] pub struct ApsDecryptFailPayload { @@ -750,6 +778,9 @@ pub enum Notification { FrameCounter(FrameCounterPayload), LinkKey(LinkKeyPayload), ApsDecryptFailure(ApsDecryptFailPayload), + RouteChanged(RouteChangedPayload), + RouteRecord(RouteRecordPayload), + ApsFrameCounter(ApsFrameCounterPayload), } impl Notification { @@ -765,6 +796,9 @@ impl Notification { Self::FrameCounter(_) => (CommandId::FrameCounter, 0), Self::LinkKey(_) => (CommandId::LinkKey, 0), Self::ApsDecryptFailure(_) => (CommandId::ApsDecryptFailure, 0), + Self::RouteChanged(_) => (CommandId::RouteChanged, 0), + Self::RouteRecord(_) => (CommandId::RouteRecord, 0), + Self::ApsFrameCounter(_) => (CommandId::ApsFrameCounter, 0), }; let mut bytes = envelope(FrameType::Notification, command.into(), request_id); let fits = match self { @@ -778,6 +812,9 @@ impl Notification { Self::FrameCounter(payload) => append(&mut bytes, payload), Self::LinkKey(payload) => append(&mut bytes, payload), Self::ApsDecryptFailure(payload) => append(&mut bytes, payload), + Self::RouteChanged(payload) => append(&mut bytes, payload), + Self::RouteRecord(payload) => append(&mut bytes, payload), + Self::ApsFrameCounter(payload) => append(&mut bytes, payload), }; fits.then_some(bytes) } diff --git a/crates/ziggurat-zigbee/src/nwk/neighbors.rs b/crates/ziggurat-zigbee/src/nwk/neighbors.rs index 5321ff7..5b4bc21 100644 --- a/crates/ziggurat-zigbee/src/nwk/neighbors.rs +++ b/crates/ziggurat-zigbee/src/nwk/neighbors.rs @@ -267,6 +267,13 @@ impl Neighbors { self.table.contains_key(&eui64) } + /// The device type and rx-on-when-idle of a table entry, for join notifications. + pub fn device_capability(&self, eui64: Eui64) -> Option<(NwkDeviceType, bool)> { + self.table + .get(&eui64) + .map(|entry| (entry.device_type, entry.rx_on_when_idle)) + } + /// The number of end device children, for join admission and beacon capacity /// decisions. pub fn end_device_child_count(&self) -> usize { diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index a1c71cd..66a3e15 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -86,14 +86,20 @@ impl TableEntry { /// Update this entry to route through `next_hop` at advertised `cost`, honoring the /// spec 3.6.4.5.3 suitability rule: an ACTIVE entry is only replaced by a strictly /// cheaper route, so a worse advertisement never clobbers a good route or forms a - /// loop. - fn consider_route(&mut self, next_hop: Nwk, cost: u8) { + /// loop. Returns the new next hop when the active route actually changed (a fresh + /// establishment or a different hop), for change tracking. + fn consider_route(&mut self, next_hop: Nwk, cost: u8) -> Option { if self.status == Status::Active && cost >= self.path_cost { - return; + return None; } + + let previous_next_hop = (self.status == Status::Active).then_some(self.next_hop_address); + self.status = Status::Active; self.next_hop_address = next_hop; self.path_cost = cost; + + (previous_next_hop != Some(next_hop)).then_some(next_hop) } } @@ -163,6 +169,11 @@ pub struct Routing { discovery_table: FlatMap<(Nwk, RouteRequestId), DiscoveryEntry>, route_record_table: FlatMap>, + /// Persist-worthy next-hop changes since the last drain: destination -> its new + /// active next hop, or `None` if the route was removed. Keyed by destination so + /// repeated changes to the same route within a batch collapse to the latest. + route_changes: FlatMap>, + /// 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 @@ -250,10 +261,25 @@ impl Routing { } pub fn remove_route(&mut self, destination: Nwk) -> bool { - self.route_table.remove(&destination).is_some() + let removed = self.route_table.remove(&destination).is_some(); + if removed { + self.route_changes.insert(destination, None); + } + removed + } + + /// Take the accumulated next-hop changes for the client to persist. Each entry is a + /// destination and its new active next hop, or `None` if the route was removed. + pub fn drain_route_changes(&mut self) -> Vec<(Nwk, Option)> { + core::mem::take(&mut self.route_changes) + .iter() + .map(|(&destination, &next_hop)| (destination, next_hop)) + .collect() } - pub fn store_route_record(&mut self, source: Nwk, relays: Vec) { + /// Returns whether the stored relay list for `source` changed, so the client only + /// persists a genuinely new source route (most route records are redundant). + pub fn store_route_record(&mut self, source: Nwk, relays: Vec) -> bool { // Spec 3.6.4.5.5: the new route also replaces any existing source routes to // the intermediary relays. The relays between relay `i` and us form its own // route; the last relay delivered the record to us directly. @@ -265,7 +291,10 @@ impl Routing { self.route_record_table.insert(last, Vec::new()); } + let changed = self.route_record_table.get(&source) != Some(&relays); self.route_record_table.insert(source, relays); + + changed } pub fn remove_route_record(&mut self, destination: Nwk) -> bool { @@ -443,7 +472,8 @@ impl Routing { .route_table .entry(originator) .or_insert_with(|| TableEntry::new(originator, Status::Inactive, UNKNOWN_NEXT_HOP)); - originator_entry.consider_route(sender, updated_path_cost); + + let route_change = originator_entry.consider_route(sender, updated_path_cost); // Spec 3.6.4.5.1.2: a many-to-one request marks its originator as a // concentrator; spec 3.6.4.5.1.8: a later plain request never clears it @@ -454,6 +484,10 @@ impl Routing { == NwkRouteRequestManyToOne::ManyToOneSenderDoesntSupportRouteRecordTable; } + if let Some(next_hop) = route_change { + self.route_changes.insert(originator, Some(next_hop)); + } + true } @@ -512,11 +546,17 @@ impl Routing { routing_entry.status, ); + let previous_next_hop = + (routing_entry.status == Status::Active).then_some(routing_entry.next_hop_address); routing_entry.status = Status::Active; routing_entry.next_hop_address = sender; routing_entry.path_cost = updated_path_cost; discovery_entry.residual_cost = updated_path_cost; + if previous_next_hop != Some(sender) { + self.route_changes.insert(responder, Some(sender)); + } + return RouteReplyDisposition::Established; } @@ -547,7 +587,10 @@ impl Routing { .route_table .entry(originator) .or_insert_with(|| TableEntry::new(originator, Status::Inactive, UNKNOWN_NEXT_HOP)); - reverse_entry.consider_route(next_hop, forward_cost); + + if let Some(new_next_hop) = reverse_entry.consider_route(next_hop, forward_cost) { + self.route_changes.insert(originator, Some(new_next_hop)); + } RouteReplyDisposition::Relay { next_hop, From 37359f20b0b122595b98a77e58c99403d9dc6c48 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:44:16 -0400 Subject: [PATCH 2/3] Commands to load route and source route tables --- crates/ziggurat-protocol/src/bridge.rs | 24 +++++++++++++++++ crates/ziggurat-protocol/src/wire.rs | 33 +++++++++++++++++++++++ crates/ziggurat-server/src/main.rs | 8 ++++++ crates/ziggurat-zigbee/src/nwk/routing.rs | 8 ++++++ 4 files changed, 73 insertions(+) diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index 0cb2604..ead2672 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -216,6 +216,30 @@ pub fn apply_address_cache( } } +pub fn apply_route_table( + stack: &ZigbeeStack, + payload: LoadRouteTablePayload, +) { + let mut core = stack.state.core.lock(); + for entry in payload.entries { + core.nib + .routing + .restore_route(entry.destination, entry.next_hop, entry.path_cost); + } +} + +pub fn apply_source_routes( + stack: &ZigbeeStack, + payload: LoadSourceRoutesPayload, +) { + let mut core = stack.state.core.lock(); + for entry in payload.entries { + core.nib + .routing + .store_route_record(entry.destination, entry.relays); + } +} + impl From<&NetworkBeacon> for BeaconPayload { fn from(beacon: &NetworkBeacon) -> Self { Self { diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index 6514c3c..47fbaad 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -35,6 +35,8 @@ pub enum CommandId { LoadChildren = 0x12, LoadAddressCache = 0x13, StartNetwork = 0x14, + LoadRouteTable = 0x15, + LoadSourceRoutes = 0x16, GetNetworkInfo = 0x18, ScanKeyTable = 0x19, ScanChildren = 0x1A, @@ -296,6 +298,31 @@ pub struct LoadAddressCachePayload { pub entries: Vec, } +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct LoadRouteTablePayload { + pub count: u16, + #[abstract_bits(length_from = count)] + pub entries: Vec, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SourceRouteEntry { + pub destination: Nwk, + pub relay_count: u8, + #[abstract_bits(length_from = relay_count)] + pub relays: Vec, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct LoadSourceRoutesPayload { + pub count: u16, + #[abstract_bits(length_from = count)] + pub entries: Vec, +} + #[abstract_bits] #[derive(Debug, Clone)] pub struct NetworkInfoPayload { @@ -603,6 +630,8 @@ pub enum Request { LoadKeyTable(LoadKeyTablePayload), LoadChildren(LoadChildrenPayload), LoadAddressCache(LoadAddressCachePayload), + LoadRouteTable(LoadRouteTablePayload), + LoadSourceRoutes(LoadSourceRoutesPayload), StartNetwork, GetNetworkInfo, ScanKeyTable, @@ -636,6 +665,10 @@ impl Request { CommandId::LoadAddressCache => { Self::LoadAddressCache(require(payload, "addr entries")?) } + CommandId::LoadRouteTable => Self::LoadRouteTable(require(payload, "route entries")?), + CommandId::LoadSourceRoutes => { + Self::LoadSourceRoutes(require(payload, "source route entries")?) + } CommandId::StartNetwork => Self::StartNetwork, CommandId::GetNetworkInfo => Self::GetNetworkInfo, CommandId::ScanKeyTable => Self::ScanKeyTable, diff --git a/crates/ziggurat-server/src/main.rs b/crates/ziggurat-server/src/main.rs index 95cbf38..daa2528 100644 --- a/crates/ziggurat-server/src/main.rs +++ b/crates/ziggurat-server/src/main.rs @@ -455,6 +455,14 @@ impl ZigguratServer { proto::apply_address_cache(&*self.loadable()?, payload); Ok(proto::Response::Empty) } + R::LoadRouteTable(payload) => { + proto::apply_route_table(&*self.loadable()?, payload); + Ok(proto::Response::Empty) + } + R::LoadSourceRoutes(payload) => { + proto::apply_source_routes(&*self.loadable()?, payload); + Ok(proto::Response::Empty) + } R::StartNetwork => self.handle_start_network().await, R::GetNetworkInfo => Ok(proto::Response::NetworkInfo(proto::network_info_payload( &*self.configured()?, diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 66a3e15..3f1b82b 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -268,6 +268,14 @@ impl Routing { removed } + /// Seed an active routing entry restored by the client on startup. Unlike a route + /// learned at runtime, this does not record a change (the client already has it). + pub fn restore_route(&mut self, destination: Nwk, next_hop: Nwk, path_cost: u8) { + let mut entry = TableEntry::new(destination, Status::Active, next_hop); + entry.path_cost = path_cost; + self.route_table.insert(destination, entry); + } + /// Take the accumulated next-hop changes for the client to persist. Each entry is a /// destination and its new active next hop, or `None` if the route was removed. pub fn drain_route_changes(&mut self) -> Vec<(Nwk, Option)> { From 4c499b702f95658731c4250d4fec5ab9b032ceb9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:10:56 -0400 Subject: [PATCH 3/3] Notify immediately on route changes and split out `RouteRemoved` --- crates/ziggurat-driver/src/zigbee_stack.rs | 7 +- .../src/zigbee_stack/indirect.rs | 4 +- .../src/zigbee_stack/joining.rs | 19 ++- .../src/zigbee_stack/neighbor.rs | 6 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 20 ++- .../ziggurat-driver/src/zigbee_stack/route.rs | 20 ++- crates/ziggurat-protocol/src/bridge.rs | 9 +- crates/ziggurat-protocol/src/wire.rs | 13 +- crates/ziggurat-zigbee/src/nwk/routing.rs | 132 ++++++++++++------ 9 files changed, 158 insertions(+), 72 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index ef482fd..03e7cb3 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -798,13 +798,14 @@ pub enum ZigbeeNotification { device_type: Option, rx_on_when_idle: bool, }, - /// A routing table entry's next hop changed, or the route was removed; the client - /// persists the next-hop cache to warm-start routing on restart + /// A routing table entry's active route changed. RouteChanged { destination: Nwk, next_hop: Nwk, - removed: bool, + path_cost: u8, }, + /// A routing table entry was removed; the client drops it from its persisted cache + RouteRemoved { destination: Nwk }, /// A source route (relay list) to a destination was learned or cleared RouteRecord { destination: Nwk, relays: Vec }, /// The outgoing APS security frame counter has advanced; the client persists it to diff --git a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs index 2744107..3f14123 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/indirect.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/indirect.rs @@ -337,7 +337,9 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept so that the // device can rejoin later (mirrors `handle_leave`) self.drop_indirect_transactions(Some(eui64), nwk); - self.core().nib.routing.remove_route(nwk); + if self.core().nib.routing.remove_route(nwk) { + self.push_notification(ZigbeeNotification::RouteRemoved { destination: nwk }); + } self.push_notification(ZigbeeNotification::DeviceLeft { nwk, diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 94092fb..0755420 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -248,9 +248,16 @@ impl ZigbeeStack { // Routers resolve their own conflicts after hearing the notification; our // mapping for the address is ambiguous until the keeper re-announces - let mut core = self.core(); - core.nib.address_map.forget_address(address); - core.nib.routing.remove_route(address); + let removed = { + let mut core = self.core(); + core.nib.address_map.forget_address(address); + core.nib.routing.remove_route(address) + }; + if removed { + self.push_notification(ZigbeeNotification::RouteRemoved { + destination: address, + }); + } } /// The address-conflict report reactor: a single long-lived task owning the @@ -1223,7 +1230,11 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept around so that the // device can rejoin later self.drop_indirect_transactions(source_ieee, source); - self.core().nib.routing.remove_route(source); + if self.core().nib.routing.remove_route(source) { + self.push_notification(ZigbeeNotification::RouteRemoved { + destination: source, + }); + } self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, diff --git a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs index 4a6a807..5892afe 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/neighbor.rs @@ -8,7 +8,7 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFram use crate::frame_token::TrafficClass; -use super::{NwkSecurityMode, TxPolicy, TxPriority, ZigbeeStack}; +use super::{NwkSecurityMode, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack}; /// Maximum number of link status entries that can be carried in a single frame. const MAX_LINK_STATUSES: usize = 7; @@ -33,7 +33,9 @@ impl ZigbeeStack { tracing::info!("Child {eui64:?} ({nwk:?}) is now parented by {new_parent:?}"); self.drop_indirect_transactions(Some(eui64), nwk); - self.core().nib.routing.remove_route(nwk); + if self.core().nib.routing.remove_route(nwk) { + self.push_notification(ZigbeeNotification::RouteRemoved { destination: nwk }); + } } /// Drop our child entry for a device known to have attached to another parent. diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 7d255b8..93c8e51 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -26,7 +26,7 @@ use ziggurat_zigbee::nwk::frame::{ NwkSecurityLevel, NwkSourceRoute, }; -use super::routing::{Route, Status as RouteStatus}; +use super::routing::{Route, RouteUpdate, Status as RouteStatus}; use super::{ AddrConflictSource, BroadcastSchedule, IndirectFrame, IndirectPayload, JoinKind, MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, @@ -458,15 +458,21 @@ impl ZigbeeStack { } } } + } - // Stream any next-hop cache changes this frame produced so the client can - // persist them for a warm restart. - let route_changes = self.core().nib.routing.drain_route_changes(); - for (destination, next_hop) in route_changes { + /// Notify the client of an established or re-pointed route so it can update its + /// warm-start next-hop cache in real time. + pub(crate) fn notify_route_update(&self, update: Option) { + if let Some(RouteUpdate { + destination, + next_hop, + path_cost, + }) = update + { self.push_notification(ZigbeeNotification::RouteChanged { destination, - next_hop: next_hop.unwrap_or(Nwk(0xFFFF)), - removed: next_hop.is_none(), + next_hop, + path_cost, }); } } diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index a825128..4b8f85c 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -14,7 +14,10 @@ use ziggurat_zigbee::nwk::frame::{BROADCAST_ALL_ROUTERS_AND_COORDINATOR, NwkFram use super::routing::RouteReplyDisposition; use crate::frame_token::TrafficClass; -use super::{AddrConflictSource, NwkSecurityMode, SendMode, TxPolicy, TxPriority, ZigbeeStack}; +use super::{ + AddrConflictSource, NwkSecurityMode, SendMode, TxPolicy, TxPriority, ZigbeeNotification, + ZigbeeStack, +}; impl ZigbeeStack { #[allow(clippy::significant_drop_tightening)] @@ -44,7 +47,7 @@ impl ZigbeeStack { // responder seeds the first hop and each relay adds the link it forwards across. let updated_path_cost = route_reply_cmd.path_cost; - let disposition = self.core().nib.routing.accept_route_reply( + let outcome = self.core().nib.routing.accept_route_reply( self.state.network_address, route_reply_cmd.originator_nwk, route_reply_cmd.route_request_identifier, @@ -53,7 +56,9 @@ impl ZigbeeStack { updated_path_cost, ); - let (next_hop_nwk, path_cost) = match disposition { + self.notify_route_update(outcome.update); + + let (next_hop_nwk, path_cost) = match outcome.disposition { RouteReplyDisposition::Drop => return, RouteReplyDisposition::Established => { self.pending_route_wake.notify_one(); @@ -143,7 +148,7 @@ impl ZigbeeStack { // Deduplicate route requests and track the best path back to the originator. // Only requests advertising a strictly better forward cost are processed // further; this also stops our own requests from echoing back at us. - let accepted = self.core().nib.routing.accept_route_request( + let outcome = self.core().nib.routing.accept_route_request( nwk_frame.nwk_header.source, route_request_cmd.route_request_identifier, route_request_cmd.destination_address, @@ -154,7 +159,9 @@ impl ZigbeeStack { self.tunables.route_discovery_time(), ); - if !accepted { + self.notify_route_update(outcome.update); + + if !outcome.accepted { return; } @@ -422,6 +429,9 @@ impl ZigbeeStack { "Removed failed route to {:?}", network_status_cmd.network_address ); + self.push_notification(ZigbeeNotification::RouteRemoved { + destination: network_status_cmd.network_address, + }); } if removed_record { tracing::info!( diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index ead2672..dd9ab4d 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -439,12 +439,17 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { ZigbeeNotification::RouteChanged { destination, next_hop, - removed, + path_cost, } => Notification::RouteChanged(RouteChangedPayload { destination: *destination, next_hop: *next_hop, - removed: *removed, + path_cost: *path_cost, }), + ZigbeeNotification::RouteRemoved { destination } => { + Notification::RouteRemoved(RouteRemovedPayload { + destination: *destination, + }) + } ZigbeeNotification::RouteRecord { destination, relays, diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index 47fbaad..3a92b24 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -66,6 +66,7 @@ pub enum CommandId { RouteChanged = 0x39, RouteRecord = 0x3A, ApsFrameCounter = 0x3B, + RouteRemoved = 0x3C, } impl From for u8 { @@ -589,8 +590,13 @@ pub struct LinkKeyPayload { pub struct RouteChangedPayload { pub destination: Nwk, pub next_hop: Nwk, - pub removed: bool, - pub reserved: u7, + pub path_cost: u8, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteRemovedPayload { + pub destination: Nwk, } #[abstract_bits] @@ -812,6 +818,7 @@ pub enum Notification { LinkKey(LinkKeyPayload), ApsDecryptFailure(ApsDecryptFailPayload), RouteChanged(RouteChangedPayload), + RouteRemoved(RouteRemovedPayload), RouteRecord(RouteRecordPayload), ApsFrameCounter(ApsFrameCounterPayload), } @@ -830,6 +837,7 @@ impl Notification { Self::LinkKey(_) => (CommandId::LinkKey, 0), Self::ApsDecryptFailure(_) => (CommandId::ApsDecryptFailure, 0), Self::RouteChanged(_) => (CommandId::RouteChanged, 0), + Self::RouteRemoved(_) => (CommandId::RouteRemoved, 0), Self::RouteRecord(_) => (CommandId::RouteRecord, 0), Self::ApsFrameCounter(_) => (CommandId::ApsFrameCounter, 0), }; @@ -846,6 +854,7 @@ impl Notification { Self::LinkKey(payload) => append(&mut bytes, payload), Self::ApsDecryptFailure(payload) => append(&mut bytes, payload), Self::RouteChanged(payload) => append(&mut bytes, payload), + Self::RouteRemoved(payload) => append(&mut bytes, payload), Self::RouteRecord(payload) => append(&mut bytes, payload), Self::ApsFrameCounter(payload) => append(&mut bytes, payload), }; diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 3f1b82b..38f6de3 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -86,11 +86,11 @@ impl TableEntry { /// Update this entry to route through `next_hop` at advertised `cost`, honoring the /// spec 3.6.4.5.3 suitability rule: an ACTIVE entry is only replaced by a strictly /// cheaper route, so a worse advertisement never clobbers a good route or forms a - /// loop. Returns the new next hop when the active route actually changed (a fresh - /// establishment or a different hop), for change tracking. - fn consider_route(&mut self, next_hop: Nwk, cost: u8) -> Option { + /// loop. Returns whether the active route actually changed (a fresh establishment or + /// a different hop), for change tracking. + fn consider_route(&mut self, next_hop: Nwk, cost: u8) -> bool { if self.status == Status::Active && cost >= self.path_cost { - return None; + return false; } let previous_next_hop = (self.status == Status::Active).then_some(self.next_hop_address); @@ -99,7 +99,7 @@ impl TableEntry { self.next_hop_address = next_hop; self.path_cost = cost; - (previous_next_hop != Some(next_hop)).then_some(next_hop) + previous_next_hop != Some(next_hop) } } @@ -155,6 +155,31 @@ pub enum RouteReplyDisposition { Relay { next_hop: Nwk, path_cost: u8 }, } +/// A route established or re-pointed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RouteUpdate { + pub destination: Nwk, + pub next_hop: Nwk, + pub path_cost: u8, +} + +/// The result of processing a received route request: whether it was accepted (only +/// accepted requests are replied to / relayed) and any route it established. +#[derive(Debug)] +pub struct RouteRequestOutcome { + pub accepted: bool, + pub update: Option, +} + +/// The result of processing a received route reply: what to do with it and any route +/// it established. +#[derive(Debug)] +#[must_use = "the driver must act on the disposition and forward the route update"] +pub struct RouteReplyOutcome { + pub disposition: RouteReplyDisposition, + pub update: Option, +} + /// The NWK routing layer: route table, route discovery table, and route record table, /// with the decision logic for route request/reply processing. #[derive(Debug, Default)] @@ -169,11 +194,6 @@ pub struct Routing { discovery_table: FlatMap<(Nwk, RouteRequestId), DiscoveryEntry>, route_record_table: FlatMap>, - /// Persist-worthy next-hop changes since the last drain: destination -> its new - /// active next hop, or `None` if the route was removed. Keyed by destination so - /// repeated changes to the same route within a batch collapse to the latest. - route_changes: FlatMap>, - /// 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 @@ -260,12 +280,9 @@ impl Routing { } } + /// Remove the route to a destination, returning whether an entry was removed. pub fn remove_route(&mut self, destination: Nwk) -> bool { - let removed = self.route_table.remove(&destination).is_some(); - if removed { - self.route_changes.insert(destination, None); - } - removed + self.route_table.remove(&destination).is_some() } /// Seed an active routing entry restored by the client on startup. Unlike a route @@ -276,15 +293,6 @@ impl Routing { self.route_table.insert(destination, entry); } - /// Take the accumulated next-hop changes for the client to persist. Each entry is a - /// destination and its new active next hop, or `None` if the route was removed. - pub fn drain_route_changes(&mut self) -> Vec<(Nwk, Option)> { - core::mem::take(&mut self.route_changes) - .iter() - .map(|(&destination, &next_hop)| (destination, next_hop)) - .collect() - } - /// Returns whether the stored relay list for `source` changed, so the client only /// persists a genuinely new source route (most route records are redundant). pub fn store_route_record(&mut self, source: Nwk, relays: Vec) -> bool { @@ -445,14 +453,17 @@ impl Routing { many_to_one: NwkRouteRequestManyToOne, now: Instant, route_discovery_time: Duration, - ) -> bool { + ) -> RouteRequestOutcome { self.expire_discoveries(now); match self.discovery_table.get_mut(&(originator, request_id)) { Some(discovery_entry) => { if updated_path_cost > discovery_entry.forward_cost { tracing::debug!("Ignoring route request with a worse cost"); - return false; + return RouteRequestOutcome { + accepted: false, + update: None, + }; } discovery_entry.forward_cost = updated_path_cost; @@ -481,7 +492,7 @@ impl Routing { .entry(originator) .or_insert_with(|| TableEntry::new(originator, Status::Inactive, UNKNOWN_NEXT_HOP)); - let route_change = originator_entry.consider_route(sender, updated_path_cost); + let route_changed = originator_entry.consider_route(sender, updated_path_cost); // Spec 3.6.4.5.1.2: a many-to-one request marks its originator as a // concentrator; spec 3.6.4.5.1.8: a later plain request never clears it @@ -492,11 +503,16 @@ impl Routing { == NwkRouteRequestManyToOne::ManyToOneSenderDoesntSupportRouteRecordTable; } - if let Some(next_hop) = route_change { - self.route_changes.insert(originator, Some(next_hop)); - } + let update = route_changed.then_some(RouteUpdate { + destination: originator, + next_hop: sender, + path_cost: updated_path_cost, + }); - true + RouteRequestOutcome { + accepted: true, + update, + } } /// Before relaying a route request: track that discovery toward the destination is @@ -525,15 +541,21 @@ impl Routing { responder: Nwk, sender: Nwk, updated_path_cost: u8, - ) -> RouteReplyDisposition { + ) -> RouteReplyOutcome { let Some(discovery_entry) = self.discovery_table.get_mut(&(originator, request_id)) else { tracing::debug!("Route reply for unknown route discovery, ignoring"); - return RouteReplyDisposition::Drop; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Drop, + update: None, + }; }; let Some(routing_entry) = self.route_table.get_mut(&responder) else { tracing::debug!("Route reply with unknown responder, ignoring"); - return RouteReplyDisposition::Drop; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Drop, + update: None, + }; }; // If we are the originator, handling is simplified @@ -546,7 +568,10 @@ impl Routing { updated_path_cost, discovery_entry.residual_cost ); - return RouteReplyDisposition::Drop; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Drop, + update: None, + }; } tracing::debug!( @@ -561,11 +586,16 @@ impl Routing { routing_entry.path_cost = updated_path_cost; discovery_entry.residual_cost = updated_path_cost; - if previous_next_hop != Some(sender) { - self.route_changes.insert(responder, Some(sender)); - } + let update = (previous_next_hop != Some(sender)).then_some(RouteUpdate { + destination: responder, + next_hop: sender, + path_cost: updated_path_cost, + }); - return RouteReplyDisposition::Established; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Established, + update, + }; } // Otherwise, we need to decide if we need to update our own routes and possibly @@ -576,7 +606,10 @@ impl Routing { updated_path_cost, discovery_entry.residual_cost ); - return RouteReplyDisposition::Drop; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Drop, + update: None, + }; } routing_entry.status = Status::Active; @@ -596,13 +629,20 @@ impl Routing { .entry(originator) .or_insert_with(|| TableEntry::new(originator, Status::Inactive, UNKNOWN_NEXT_HOP)); - if let Some(new_next_hop) = reverse_entry.consider_route(next_hop, forward_cost) { - self.route_changes.insert(originator, Some(new_next_hop)); - } + let update = reverse_entry + .consider_route(next_hop, forward_cost) + .then_some(RouteUpdate { + destination: originator, + next_hop, + path_cost: forward_cost, + }); - RouteReplyDisposition::Relay { - next_hop, - path_cost: updated_path_cost, + RouteReplyOutcome { + disposition: RouteReplyDisposition::Relay { + next_hop, + path_cost: updated_path_cost, + }, + update, } }