From 626477d6d0d0b600a204a527c19f9f1bce2a7a54 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:04:13 +0000 Subject: [PATCH 01/23] Forget node state on rejoin --- .../src/zigbee_stack/joining.rs | 8 +++-- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 35 +++++++++++++++++-- crates/ziggurat-zigbee/src/nwk/routing.rs | 8 +++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 19ef6e0..6596f26 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1035,8 +1035,10 @@ impl ZigbeeStack { }); } ApsUpdateDeviceStatus::DeviceLeft => { - // Spec 4.4.3.2.3: informative only, no action is taken beyond - // notifying the client + // Spec 4.4.3.2.3: informative only. The device is gone, so our state + // for its address is stale. + self.forget_nwk_state(update.device_short_address); + let router_ieee = nwk_frame .nwk_header .source_ieee @@ -1305,7 +1307,7 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept around so that the // device can rejoin later self.drop_indirect_transactions(source_ieee, source); - self.core().nib.routing.remove_route(source); + self.forget_nwk_state(source); self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 6d77f27..e88ced0 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -32,7 +32,10 @@ use super::{ }; impl ZigbeeStack { - pub fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { + /// A device joined or rejoined as `nwk`: the address starts a new session. + pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { + self.forget_nwk_state(nwk); + let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); if conflict { @@ -40,6 +43,22 @@ impl ZigbeeStack { } } + /// The device at `nwk` left or joined again, so what we know about the address + /// belongs to its previous session. Held sends fail: they wait on a route request + /// the device never heard, and would block a new one until it expires. The device + /// may restart its APS counter, so its duplicate entries go too. + pub(super) fn forget_nwk_state(&self, nwk: Nwk) { + self.state + .aps_duplicates + .lock() + .retain(|&(source, _), _| source != nwk); + + let mut core = self.core(); + core.nib.routing.forget(nwk); + let actions = core.sends.fail_unrouted(nwk); + self.commit_sends(core, actions); + } + /// Filter broadcast frames based on the NWK broadcast transaction table pub fn filter_broadcast(&self, nwk_frame: &NwkFrame, sender_nwk: Nwk) -> bool { let now = self.core_now(); @@ -148,9 +167,19 @@ impl ZigbeeStack { ); } - // Update the address cache + // Update the address cache. Hearing a device is not a join, so the address keeps + // its session state. if let Some(src_eui64) = nwk_frame.nwk_header.source_ieee { - self.update_nwk_eui64_mapping(nwk_frame.nwk_header.source, src_eui64); + let source = nwk_frame.nwk_header.source; + let conflict = self + .core() + .nib + .address_map + .update_mapping(src_eui64, source); + + if conflict { + self.handle_address_conflict(source, AddrConflictSource::Local); + } } // Spec 3.6.1.10.2: a frame addressed to our network address but a different diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index a34a79a..3858029 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -284,6 +284,14 @@ impl Routing { self.route_table.remove(&destination).is_some() } + /// The device at `nwk` left or joined again, clear associated state. + pub fn forget(&mut self, nwk: Nwk) { + self.route_table.remove(&nwk); + self.route_record_table.remove(&nwk); + self.discovery_table + .retain(|_, entry| entry.destination_address != nwk && entry.source_address != nwk); + } + /// 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) { From 447a83d0c3c6fb4db3432b511b9db6ee95c65c53 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:48:15 +0000 Subject: [PATCH 02/23] Keep around our original route discovery table entry for deduplication --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 2 +- crates/ziggurat-zigbee/src/nwk/routing.rs | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index e88ced0..8701b70 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -54,7 +54,7 @@ impl ZigbeeStack { .retain(|&(source, _), _| source != nwk); let mut core = self.core(); - core.nib.routing.forget(nwk); + core.nib.routing.forget(self.state.network_address, nwk); let actions = core.sends.fail_unrouted(nwk); self.commit_sends(core, actions); } diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index 3858029..a106668 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -285,11 +285,16 @@ impl Routing { } /// The device at `nwk` left or joined again, clear associated state. - pub fn forget(&mut self, nwk: Nwk) { + pub fn forget(&mut self, own_address: Nwk, nwk: Nwk) { self.route_table.remove(&nwk); self.route_record_table.remove(&nwk); - self.discovery_table - .retain(|_, entry| entry.destination_address != nwk && entry.source_address != nwk); + + // Only our own discoveries toward it are dropped. Discoveries we relay stay + // until they expire: they are the only duplicate check for late copies of + // their route requests. + self.discovery_table.retain(|_, entry| { + entry.source_address != own_address || entry.destination_address != nwk + }); } /// Seed an active routing entry restored by the client on startup. Unlike a route From 67a1fe4b65a0e7e7d5b2cbe743d9800b0b142ccc Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:50:26 +0000 Subject: [PATCH 03/23] Ignore route requests we originate --- crates/ziggurat-driver/src/zigbee_stack/route.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index a8f9636..3079d42 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -124,6 +124,13 @@ impl ZigbeeStack { let network_address = self.state.network_address; let many_to_one = route_request_cmd.many_to_one != NwkRouteRequestManyToOne::NotManyToOne; + // Neighbors relay our own route requests back to us. Our discovery entry may + // already be gone, so it cannot be what filters them. + if nwk_frame.nwk_header.source == network_address { + tracing::debug!("Ignoring our own route request relayed by {sender_nwk:?}"); + return; + } + // We need to know who sent the frame let sender_link = self.core().nib.neighbors.link(sender_nwk); From 5a873059e64ffd2868035b2cb190e1bf0e820a55 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:21:54 +0000 Subject: [PATCH 04/23] Drop indirect transmissions if a child leaves --- crates/ziggurat-driver/src/zigbee_stack/joining.rs | 5 ++--- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 10 +++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 6596f26..8170503 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1037,7 +1037,7 @@ impl ZigbeeStack { ApsUpdateDeviceStatus::DeviceLeft => { // Spec 4.4.3.2.3: informative only. The device is gone, so our state // for its address is stale. - self.forget_nwk_state(update.device_short_address); + self.forget_nwk_state(Some(update.device_address), update.device_short_address); let router_ieee = nwk_frame .nwk_header @@ -1306,8 +1306,7 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept around so that the // device can rejoin later - self.drop_indirect_transactions(source_ieee, source); - self.forget_nwk_state(source); + self.forget_nwk_state(source_ieee, source); self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 8701b70..fb4e781 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -34,7 +34,7 @@ use super::{ impl ZigbeeStack { /// A device joined or rejoined as `nwk`: the address starts a new session. pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { - self.forget_nwk_state(nwk); + self.forget_nwk_state(Some(eui64), nwk); let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); @@ -47,7 +47,7 @@ impl ZigbeeStack { /// belongs to its previous session. Held sends fail: they wait on a route request /// the device never heard, and would block a new one until it expires. The device /// may restart its APS counter, so its duplicate entries go too. - pub(super) fn forget_nwk_state(&self, nwk: Nwk) { + pub(super) fn forget_nwk_state(&self, eui64: Option, nwk: Nwk) { self.state .aps_duplicates .lock() @@ -55,7 +55,11 @@ impl ZigbeeStack { let mut core = self.core(); core.nib.routing.forget(self.state.network_address, nwk); - let actions = core.sends.fail_unrouted(nwk); + + // Frames awaiting its poll fail as well: a leave queued for a stale child must + // not reach the device after it has rejoined. + let mut actions = core.sends.fail_unrouted(nwk); + actions.extend(core.sends.drop_indirect_unicasts(eui64, nwk)); self.commit_sends(core, actions); } From 68518f78e562a44b39d1b03079a13e81019fa26d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:27:38 +0000 Subject: [PATCH 05/23] Don't react to `DeviceLeft` if the device is our child --- .../src/zigbee_stack/joining.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 8170503..2a75edd 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1035,6 +1035,24 @@ impl ZigbeeStack { }); } ApsUpdateDeviceStatus::DeviceLeft => { + // An end device has one parent. If it is our child now, it left the + // router before it joined us: the report is late and its state is + // current. + let is_our_child = self + .core() + .nib + .neighbors + .end_device_children() + .contains(&update.device_address); + + if is_our_child { + tracing::debug!( + "Ignoring late leave report for our child {:?} from {router_nwk:?}", + update.device_address + ); + return; + } + // Spec 4.4.3.2.3: informative only. The device is gone, so our state // for its address is stale. self.forget_nwk_state(Some(update.device_address), update.device_short_address); From e83369f5798d0b0741b4095a6221572ba1770089 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:47:21 +0000 Subject: [PATCH 06/23] Allow broadcasts and route discoveries to be cancelled --- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 6 +- .../ziggurat-driver/src/zigbee_stack/route.rs | 9 ++- .../ziggurat-zigbee/src/sends/broadcasts.rs | 28 ++++++++ crates/ziggurat-zigbee/src/sends/mod.rs | 16 ++++- crates/ziggurat-zigbee/src/sends/unrouted.rs | 72 +++++++++++++------ 5 files changed, 100 insertions(+), 31 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index fb4e781..e7025e1 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -109,8 +109,8 @@ impl ZigbeeStack { nwk_frame: NwkFrame, attempts: u8, initial_delay: Duration, - ) -> SendActions { - let (_, actions) = self.enqueue_broadcast_in( + ) -> (SendId, SendActions) { + let (handle, actions) = self.enqueue_broadcast_in( core, Frame::nwk(nwk_frame, NwkSecurityMode::NetworkKey), TxPolicy { @@ -124,7 +124,7 @@ impl ZigbeeStack { }, ); - actions + (handle.id(), actions) } /// A random retransmission jitter in `[0, max_broadcast_jitter)` (spec 3.6.6). diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 3079d42..739931d 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -270,7 +270,7 @@ impl ZigbeeStack { let attempts = self.tunables.rreq_retries() + 1; let mut core = self.core(); - let actions = + let (_, actions) = self.broadcast_route_request(&mut core, relayed_route_request_cmd, attempts, jitter); self.commit_sends(core, actions); } @@ -493,11 +493,14 @@ impl ZigbeeStack { // Spec 3.6.4.5.1: the initial broadcast is repeated `nwkcInitialRREQRetries` // times, separated by the retry interval - self.broadcast_route_request( + let (request, actions) = self.broadcast_route_request( core, route_request_frame, self.tunables.initial_rreq_retries() + 1, Duration::ZERO, - ) + ); + core.sends.route_request_sent(destination, request); + + actions } } diff --git a/crates/ziggurat-zigbee/src/sends/broadcasts.rs b/crates/ziggurat-zigbee/src/sends/broadcasts.rs index 280b868..df5b1bf 100644 --- a/crates/ziggurat-zigbee/src/sends/broadcasts.rs +++ b/crates/ziggurat-zigbee/src/sends/broadcasts.rs @@ -306,6 +306,34 @@ impl> Broadcasts { Some(actions) } + /// Send no further copies of a broadcast nobody waits on any more. A copy in the + /// radio right now cannot be recalled: its schedule is emptied instead, so the + /// record settles when it next comes due (a `Once` schedule settles on the copy's + /// own report). + pub(super) fn stop(&mut self, id: SendId) -> Actions { + let mut actions = Actions::new(); + + let Some(record) = self.records.get_mut(&id) else { + return actions; + }; + + if record.copy == CopyState::Transmitting { + match &mut record.schedule { + Schedule::Once => {} + Schedule::PassiveAck { + copies_remaining, .. + } + | Schedule::FixedInterval { + copies_remaining, .. + } => *copies_remaining = 0, + } + } else { + self.settle(id, Err(SendFailure::Cancelled.into()), &mut actions); + } + + actions + } + /// Queue copies that are due, complete or fail schedules that ran out, and fail /// records past their deadline. pub(super) fn tick(&mut self, now: Instant) -> Actions { diff --git a/crates/ziggurat-zigbee/src/sends/mod.rs b/crates/ziggurat-zigbee/src/sends/mod.rs index d270fda..e87d00f 100644 --- a/crates/ziggurat-zigbee/src/sends/mod.rs +++ b/crates/ziggurat-zigbee/src/sends/mod.rs @@ -417,10 +417,22 @@ impl> Sends { self.unrouted_unicasts.retry_discovery(destination) } + /// The driver broadcast `request`, the route request of `destination`'s discovery. + pub fn route_request_sent(&mut self, destination: Nwk, request: SendId) { + self.unrouted_unicasts + .route_request_sent(destination, request); + } + /// Fail every unrouted send to `destination`: its discovery is over without a - /// route. + /// route. Copies of its route request still to come are not sent. pub fn fail_unrouted(&mut self, destination: Nwk) -> Actions { - self.unrouted_unicasts.fail(destination) + let (request, mut actions) = self.unrouted_unicasts.fail(destination); + + if let Some(request) = request { + actions.extend(self.broadcasts.stop(request)); + } + + actions } /// A route to `destination` is known: move every unrouted send to it to the unicasts, diff --git a/crates/ziggurat-zigbee/src/sends/unrouted.rs b/crates/ziggurat-zigbee/src/sends/unrouted.rs index f324de5..a0b950a 100644 --- a/crates/ziggurat-zigbee/src/sends/unrouted.rs +++ b/crates/ziggurat-zigbee/src/sends/unrouted.rs @@ -2,7 +2,8 @@ //! //! Discovery runs once per destination and the whole bucket moves on together, so ten //! frames to one device ride a single discovery. The discovery itself lives in the -//! routing table; this table only counts the restarts it has left. +//! routing table; this table counts the restarts it has left and remembers the +//! broadcast of its route request, to stop it if the discovery is abandoned. use alloc::vec::Vec; use core::marker::PhantomData; @@ -37,12 +38,21 @@ pub(super) struct Record { pub(super) deadline: Instant, } +/// What this table tracks of a destination's route discovery. +#[derive(Debug)] +struct Discovery { + /// Restarts left. The first discovery is the driver's, started at admission; these + /// are the ones after it. + restarts_left: u8, + /// The broadcast of the latest route request. + request: Option, +} + #[derive(Debug)] pub(super) struct UnroutedUnicasts { records: FlatMap>, - /// Discovery restarts left, per destination with unrouted sends. The first - /// discovery is the driver's, started at admission; these are the ones after it. - retries: FlatMap, + /// One per destination with unrouted sends. + discoveries: FlatMap, _error: PhantomData E>, } @@ -50,7 +60,7 @@ impl Default for UnroutedUnicasts { fn default() -> Self { Self { records: FlatMap::new(), - retries: FlatMap::new(), + discoveries: FlatMap::new(), _error: PhantomData, } } @@ -62,7 +72,7 @@ impl> UnroutedUnicasts { } pub(super) fn has_unrouted(&self, destination: Nwk) -> bool { - self.retries.contains_key(&destination) + self.discoveries.contains_key(&destination) } /// Hold a send. It fails if still unrouted within the handoff timeout. The first @@ -87,24 +97,32 @@ impl> UnroutedUnicasts { }, ); - if !self.retries.contains_key(&destination) { - // The tunable counts discoveries; the first one is not a restart - self.retries.insert( + if !self.discoveries.contains_key(&destination) { + self.discoveries.insert( destination, - tunables - .pending_route_discovery_attempts() - .saturating_sub(1), + Discovery { + // The tunable counts discoveries; the first one is not a restart + restarts_left: tunables + .pending_route_discovery_attempts() + .saturating_sub(1), + request: None, + }, ); } } pub(super) fn destinations(&self) -> Vec { - self.retries.keys().copied().collect() + self.discoveries.keys().copied().collect() + } + + /// The driver broadcast a route request for `destination`'s discovery. + pub(super) fn route_request_sent(&mut self, destination: Nwk, request: SendId) { + self.discoveries.get_mut(&destination).unwrap().request = Some(request); } /// Take every send to `destination`: its route is known. pub(super) fn route(&mut self, destination: Nwk) -> Vec<(SendId, Record)> { - self.retries.remove(&destination); + self.discoveries.remove(&destination); self.take(destination) } @@ -138,28 +156,36 @@ impl> UnroutedUnicasts { .values() .any(|send| send.destination == destination); - let retries = self.retries.get_mut(&destination).unwrap(); + let discovery = self.discoveries.get_mut(&destination).unwrap(); // A discovery nothing waits on any more is not restarted - if *retries == 0 || !waiting { + if discovery.restarts_left == 0 || !waiting { return false; } - *retries -= 1; + discovery.restarts_left -= 1; true } - /// Fail every send to `destination`: its discovery is over without a route. - pub(super) fn fail(&mut self, destination: Nwk) -> Actions { - self.retries.remove(&destination); - self.take(destination) + /// Fail every send to `destination`: its discovery is over without a route. Returns + /// the broadcast of its route request, for the caller to stop. + pub(super) fn fail(&mut self, destination: Nwk) -> (Option, Actions) { + let request = self + .discoveries + .remove(&destination) + .and_then(|discovery| discovery.request); + + let actions = self + .take(destination) .into_iter() .map(|(_, send)| Self::settled(send, SendFailure::RouteDiscoveryTimeout)) - .collect() + .collect(); + + (request, actions) } /// Drop every record and return its waiter: the stack is shutting down. pub(super) fn abandon(&mut self) -> Vec { - self.retries.clear(); + self.discoveries.clear(); let mut waiters = Vec::new(); while let Some((_, send)) = self.records.pop_first() { waiters.push(send.waiter); From 85741a1d070383d4f1ab13b3f67ba2a6a0a41825 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:40:51 +0000 Subject: [PATCH 07/23] Forget the previous address of a device that rejoins under a new one --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index e7025e1..667bbb1 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -34,6 +34,15 @@ use super::{ impl ZigbeeStack { /// A device joined or rejoined as `nwk`: the address starts a new session. pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { + // A device that comes back under a new address leaves the old one behind + let previous_nwk = self.core().nib.address_map.nwk_for(eui64); + + if let Some(previous_nwk) = previous_nwk + && previous_nwk != nwk + { + self.forget_nwk_state(Some(eui64), previous_nwk); + } + self.forget_nwk_state(Some(eui64), nwk); let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); From d5b7112d581c2a5872974469d7bd23fa0992ce67 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:41:00 +0000 Subject: [PATCH 08/23] Stop the route request that a discovery restart replaces --- crates/ziggurat-driver/src/zigbee_stack/route.rs | 4 ++-- crates/ziggurat-zigbee/src/sends/mod.rs | 12 +++++++++--- crates/ziggurat-zigbee/src/sends/unrouted.rs | 15 ++++++++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/route.rs b/crates/ziggurat-driver/src/zigbee_stack/route.rs index 739931d..b4743d5 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/route.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/route.rs @@ -493,13 +493,13 @@ impl ZigbeeStack { // Spec 3.6.4.5.1: the initial broadcast is repeated `nwkcInitialRREQRetries` // times, separated by the retry interval - let (request, actions) = self.broadcast_route_request( + let (request, mut actions) = self.broadcast_route_request( core, route_request_frame, self.tunables.initial_rreq_retries() + 1, Duration::ZERO, ); - core.sends.route_request_sent(destination, request); + actions.extend(core.sends.route_request_sent(destination, request)); actions } diff --git a/crates/ziggurat-zigbee/src/sends/mod.rs b/crates/ziggurat-zigbee/src/sends/mod.rs index e87d00f..0816d1a 100644 --- a/crates/ziggurat-zigbee/src/sends/mod.rs +++ b/crates/ziggurat-zigbee/src/sends/mod.rs @@ -418,9 +418,15 @@ impl> Sends { } /// The driver broadcast `request`, the route request of `destination`'s discovery. - pub fn route_request_sent(&mut self, destination: Nwk, request: SendId) { - self.unrouted_unicasts - .route_request_sent(destination, request); + /// Copies of the request it replaces still to come are not sent. + pub fn route_request_sent(&mut self, destination: Nwk, request: SendId) -> Actions { + match self + .unrouted_unicasts + .route_request_sent(destination, request) + { + Some(previous) => self.broadcasts.stop(previous), + None => Actions::new(), + } } /// Fail every unrouted send to `destination`: its discovery is over without a diff --git a/crates/ziggurat-zigbee/src/sends/unrouted.rs b/crates/ziggurat-zigbee/src/sends/unrouted.rs index a0b950a..65eb637 100644 --- a/crates/ziggurat-zigbee/src/sends/unrouted.rs +++ b/crates/ziggurat-zigbee/src/sends/unrouted.rs @@ -115,9 +115,18 @@ impl> UnroutedUnicasts { self.discoveries.keys().copied().collect() } - /// The driver broadcast a route request for `destination`'s discovery. - pub(super) fn route_request_sent(&mut self, destination: Nwk, request: SendId) { - self.discoveries.get_mut(&destination).unwrap().request = Some(request); + /// The driver broadcast a route request for `destination`'s discovery. Returns the + /// broadcast of the request it replaces, for the caller to stop. + pub(super) fn route_request_sent( + &mut self, + destination: Nwk, + request: SendId, + ) -> Option { + self.discoveries + .get_mut(&destination) + .unwrap() + .request + .replace(request) } /// Take every send to `destination`: its route is known. From 97337773eef79db53f96cc8cad5c8f14f264cca7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:41:08 +0000 Subject: [PATCH 09/23] Drop held frames that a poll extracted but the radio has not sent --- crates/ziggurat-zigbee/src/sends/unicasts.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ziggurat-zigbee/src/sends/unicasts.rs b/crates/ziggurat-zigbee/src/sends/unicasts.rs index cce4e4b..e73bc81 100644 --- a/crates/ziggurat-zigbee/src/sends/unicasts.rs +++ b/crates/ziggurat-zigbee/src/sends/unicasts.rs @@ -351,7 +351,9 @@ impl> Unicasts { actions } - /// Fail every frame awaiting a device's poll: it is no longer a child. + /// Fail every frame awaiting a device's poll: it is no longer a child. This + /// includes a frame a poll extracted that is not in the radio yet. One in the radio + /// cannot be recalled. pub(super) fn drop_indirect(&mut self, eui64: Option, nwk: Nwk) -> Actions { let mut actions = Actions::new(); @@ -359,7 +361,7 @@ impl> Unicasts { .records .iter() .filter_map(|(id, record)| match (record.poll_address, record.stage) { - (Some(address), Stage::AwaitingPoll) + (Some(address), Stage::AwaitingPoll | Stage::Queued) if address == Ieee802154Address::Nwk(nwk) || eui64.is_some_and(|e| address == Ieee802154Address::Eui64(e)) => { From ea5246bd6905004c23b6b908c52e7c3473f7f659 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:41:15 +0000 Subject: [PATCH 10/23] Note that a late `DeviceLeft` from a former parent is not detected --- crates/ziggurat-driver/src/zigbee_stack/joining.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 2a75edd..fbe59d6 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1055,6 +1055,10 @@ impl ZigbeeStack { // Spec 4.4.3.2.3: informative only. The device is gone, so our state // for its address is stale. + // + // Known limitation: we do not track the parent of a device that is not + // our child. A late report from a router the device has since left for + // another router is taken as current. self.forget_nwk_state(Some(update.device_address), update.device_short_address); let router_ieee = nwk_frame From 64b2ef372fd622b7b593f0577109d416b709f461 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:20:56 +0000 Subject: [PATCH 11/23] Update with spec wording about `DeviceLeft` --- crates/ziggurat-driver/src/zigbee_stack/joining.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index fbe59d6..1d78fcc 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1053,8 +1053,13 @@ impl ZigbeeStack { return; } - // Spec 4.4.3.2.3: informative only. The device is gone, so our state - // for its address is stale. + // Spec 4.4.3.2.3: "A Device Left is considered informative but SHOULD + // NOT be considered authoritative. Security related actions SHALL not + // be taken on receipt of this. No further processing SHALL be done." + // + // We go past that wording, but take no security action: keys, frame + // counters and the address map are kept. What we forget rebuilds + // itself, so a false report costs one route discovery. // // Known limitation: we do not track the parent of a device that is not // our child. A late report from a router the device has since left for From dd6941b5c8f4a53b8ae8b7667f0924c2937c1f87 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:02:12 +0000 Subject: [PATCH 12/23] Do not update the address cache for `RejoinRequest` --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 667bbb1..2fe2fda 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -181,8 +181,14 @@ impl ZigbeeStack { } // Update the address cache. Hearing a device is not a join, so the address keeps - // its session state. - if let Some(src_eui64) = nwk_frame.nwk_header.source_ieee { + // its session state. A rejoin request is left to its handler, which must still + // see the device's previous address to forget it. + let is_rejoin_request = matches!( + &nwk_frame.payload, + NwkPayload::Command(NwkCommand::RejoinRequest(_)) + ); + + if !is_rejoin_request && let Some(src_eui64) = nwk_frame.nwk_header.source_ieee { let source = nwk_frame.nwk_header.source; let conflict = self .core() From d4f7038df36c3b774e93b32d79e562d5cbc05128 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:00:37 +0000 Subject: [PATCH 13/23] Reject an unauthorized unsecured rejoin before it changes any state --- .../src/zigbee_stack/joining.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 1d78fcc..dd862ad 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1151,6 +1151,18 @@ impl ZigbeeStack { return; }; + // Spec 4.7.3.6: an unsecured rejoin re-delivers the network key encrypted + // with the well-known key unless the device holds a unique link key. Reject + // when it has neither, so the network key is never exposed to anyone who + // knows the well-known key. Nobody authenticated this frame, so it is rejected + // before it changes any state. + if !secured && !self.may_rejoin_unsecured(source_ieee) { + tracing::warn!( + "Rejecting unsecured rejoin from {source_ieee:?} without a unique link key" + ); + return; + } + let requested_nwk = nwk_frame.nwk_header.source; let capability = &rejoin_request.capability_information; @@ -1240,16 +1252,6 @@ impl ZigbeeStack { // A trust center rejoin means the device no longer has the network key if !secured { - // Spec 4.7.3.6: an unsecured rejoin re-delivers the network key encrypted - // with the well-known key unless the device holds a unique link key. Reject - // when it has neither, so the network key is never exposed to anyone who - // knows the well-known key. - if !self.may_rejoin_unsecured(source_ieee) { - tracing::warn!( - "Rejecting unsecured rejoin from {source_ieee:?} without a unique link key" - ); - return; - } // The join is announced only once the key transport lands let stack = self .self_weak From a32288e0a5b0c32965cd5533db7cc90a8eaa3e62 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:00:43 +0000 Subject: [PATCH 14/23] Keep the state of an address that a joining device conflicts with --- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 2fe2fda..7ce97f4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -34,9 +34,22 @@ use super::{ impl ZigbeeStack { /// A device joined or rejoined as `nwk`: the address starts a new session. pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { - // A device that comes back under a new address leaves the old one behind - let previous_nwk = self.core().nib.address_map.nwk_for(eui64); + let (previous_nwk, conflict) = { + let mut core = self.core(); + + ( + core.nib.address_map.nwk_for(eui64), + core.nib.address_map.update_mapping(eui64, nwk), + ) + }; + + // `nwk` belongs to another device, whose state must stay + if conflict { + self.handle_address_conflict(nwk, AddrConflictSource::Local); + return; + } + // A device that comes back under a new address leaves the old one behind if let Some(previous_nwk) = previous_nwk && previous_nwk != nwk { @@ -44,12 +57,6 @@ impl ZigbeeStack { } self.forget_nwk_state(Some(eui64), nwk); - - let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); - - if conflict { - self.handle_address_conflict(nwk, AddrConflictSource::Local); - } } /// The device at `nwk` left or joined again, so what we know about the address From 5cd8006cee21fade2f6bbff51b1a37a5edf8747a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:25:05 +0000 Subject: [PATCH 15/23] Ignore a `DeviceLeft` short address that another device holds --- crates/ziggurat-driver/src/zigbee_stack/joining.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index dd862ad..0ae01ae 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1064,7 +1064,18 @@ impl ZigbeeStack { // Known limitation: we do not track the parent of a device that is not // our child. A late report from a router the device has since left for // another router is taken as current. - self.forget_nwk_state(Some(update.device_address), update.device_short_address); + // + // The report names the device by both addresses. A short address that + // another device holds is not this report's to clear. + let claimed_by_other = self + .core() + .nib + .address_map + .claimed_by_other(update.device_short_address, update.device_address); + + if !claimed_by_other { + self.forget_nwk_state(Some(update.device_address), update.device_short_address); + } let router_ieee = nwk_frame .nwk_header From 516aca0cdc0dc1d9cc5412fe0611b7cf3fa4ba56 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:25:13 +0000 Subject: [PATCH 16/23] Note that forgetting a device drops replies to relayed discoveries --- crates/ziggurat-zigbee/src/nwk/routing.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ziggurat-zigbee/src/nwk/routing.rs b/crates/ziggurat-zigbee/src/nwk/routing.rs index a106668..f3b294f 100644 --- a/crates/ziggurat-zigbee/src/nwk/routing.rs +++ b/crates/ziggurat-zigbee/src/nwk/routing.rs @@ -286,6 +286,9 @@ impl Routing { /// The device at `nwk` left or joined again, clear associated state. pub fn forget(&mut self, own_address: Nwk, nwk: Nwk) { + // TODO: a discovery toward `nwk` that we relay for another router loses its + // route entry here, so `accept_route_reply` drops the reply as coming from an + // unknown responder. That router gets no route through us for that discovery. self.route_table.remove(&nwk); self.route_record_table.remove(&nwk); From 4035203813d9771290db6459aba3ebb047d9a71a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:27:42 +0000 Subject: [PATCH 17/23] Clarify wording --- .../src/zigbee_stack/joining.rs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 0ae01ae..59041ab 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1037,7 +1037,8 @@ impl ZigbeeStack { ApsUpdateDeviceStatus::DeviceLeft => { // An end device has one parent. If it is our child now, it left the // router before it joined us: the report is late and its state is - // current. + // current. A device that moved to another router is not detected + // because we do not track its parent. let is_our_child = self .core() .nib @@ -1053,18 +1054,6 @@ impl ZigbeeStack { return; } - // Spec 4.4.3.2.3: "A Device Left is considered informative but SHOULD - // NOT be considered authoritative. Security related actions SHALL not - // be taken on receipt of this. No further processing SHALL be done." - // - // We go past that wording, but take no security action: keys, frame - // counters and the address map are kept. What we forget rebuilds - // itself, so a false report costs one route discovery. - // - // Known limitation: we do not track the parent of a device that is not - // our child. A late report from a router the device has since left for - // another router is taken as current. - // // The report names the device by both addresses. A short address that // another device holds is not this report's to clear. let claimed_by_other = self @@ -1074,6 +1063,9 @@ impl ZigbeeStack { .claimed_by_other(update.device_short_address, update.device_address); if !claimed_by_other { + // Spec 4.4.3.2.3 calls this report informative, not authoritative, + // and asks for no further processing. We still forget, but only + // state that rebuilds itself: keys and the address map are kept. self.forget_nwk_state(Some(update.device_address), update.device_short_address); } From 3eb7a218f308f85339cda93f351f9404f97b0c07 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:47:52 +0000 Subject: [PATCH 18/23] Revert forgetting a device on a router-reported `DeviceLeft` --- .../src/zigbee_stack/joining.rs | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 59041ab..6a327e7 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1035,40 +1035,8 @@ impl ZigbeeStack { }); } ApsUpdateDeviceStatus::DeviceLeft => { - // An end device has one parent. If it is our child now, it left the - // router before it joined us: the report is late and its state is - // current. A device that moved to another router is not detected - // because we do not track its parent. - let is_our_child = self - .core() - .nib - .neighbors - .end_device_children() - .contains(&update.device_address); - - if is_our_child { - tracing::debug!( - "Ignoring late leave report for our child {:?} from {router_nwk:?}", - update.device_address - ); - return; - } - - // The report names the device by both addresses. A short address that - // another device holds is not this report's to clear. - let claimed_by_other = self - .core() - .nib - .address_map - .claimed_by_other(update.device_short_address, update.device_address); - - if !claimed_by_other { - // Spec 4.4.3.2.3 calls this report informative, not authoritative, - // and asks for no further processing. We still forget, but only - // state that rebuilds itself: keys and the address map are kept. - self.forget_nwk_state(Some(update.device_address), update.device_short_address); - } - + // Spec 4.4.3.2.3: informative only, no action is taken beyond + // notifying the client let router_ieee = nwk_frame .nwk_header .source_ieee From acbaa5a795330d39b53f460344bd2075e8c97d96 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:48:16 +0000 Subject: [PATCH 19/23] Revert dropping held frames when a device is forgotten --- crates/ziggurat-driver/src/zigbee_stack/joining.rs | 3 ++- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 12 ++++-------- crates/ziggurat-zigbee/src/sends/unicasts.rs | 6 ++---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 6a327e7..f35450e 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -1306,7 +1306,8 @@ impl ZigbeeStack { // The address map entry and any negotiated link key are kept around so that the // device can rejoin later - self.forget_nwk_state(source_ieee, source); + self.drop_indirect_transactions(source_ieee, source); + self.forget_nwk_state(source); self.push_notification(ZigbeeNotification::DeviceLeft { nwk: source, diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 7ce97f4..554bba6 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -53,17 +53,17 @@ impl ZigbeeStack { if let Some(previous_nwk) = previous_nwk && previous_nwk != nwk { - self.forget_nwk_state(Some(eui64), previous_nwk); + self.forget_nwk_state(previous_nwk); } - self.forget_nwk_state(Some(eui64), nwk); + self.forget_nwk_state(nwk); } /// The device at `nwk` left or joined again, so what we know about the address /// belongs to its previous session. Held sends fail: they wait on a route request /// the device never heard, and would block a new one until it expires. The device /// may restart its APS counter, so its duplicate entries go too. - pub(super) fn forget_nwk_state(&self, eui64: Option, nwk: Nwk) { + pub(super) fn forget_nwk_state(&self, nwk: Nwk) { self.state .aps_duplicates .lock() @@ -71,11 +71,7 @@ impl ZigbeeStack { let mut core = self.core(); core.nib.routing.forget(self.state.network_address, nwk); - - // Frames awaiting its poll fail as well: a leave queued for a stale child must - // not reach the device after it has rejoined. - let mut actions = core.sends.fail_unrouted(nwk); - actions.extend(core.sends.drop_indirect_unicasts(eui64, nwk)); + let actions = core.sends.fail_unrouted(nwk); self.commit_sends(core, actions); } diff --git a/crates/ziggurat-zigbee/src/sends/unicasts.rs b/crates/ziggurat-zigbee/src/sends/unicasts.rs index e73bc81..cce4e4b 100644 --- a/crates/ziggurat-zigbee/src/sends/unicasts.rs +++ b/crates/ziggurat-zigbee/src/sends/unicasts.rs @@ -351,9 +351,7 @@ impl> Unicasts { actions } - /// Fail every frame awaiting a device's poll: it is no longer a child. This - /// includes a frame a poll extracted that is not in the radio yet. One in the radio - /// cannot be recalled. + /// Fail every frame awaiting a device's poll: it is no longer a child. pub(super) fn drop_indirect(&mut self, eui64: Option, nwk: Nwk) -> Actions { let mut actions = Actions::new(); @@ -361,7 +359,7 @@ impl> Unicasts { .records .iter() .filter_map(|(id, record)| match (record.poll_address, record.stage) { - (Some(address), Stage::AwaitingPoll | Stage::Queued) + (Some(address), Stage::AwaitingPoll) if address == Ieee802154Address::Nwk(nwk) || eui64.is_some_and(|e| address == Ieee802154Address::Eui64(e)) => { From 36ddc3550a9a36ed6dd79fc9a8aa81a098a4aa95 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:48:37 +0000 Subject: [PATCH 20/23] Revert forgetting the previous address of a device that rejoins --- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 554bba6..cd152ca 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -34,14 +34,7 @@ use super::{ impl ZigbeeStack { /// A device joined or rejoined as `nwk`: the address starts a new session. pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { - let (previous_nwk, conflict) = { - let mut core = self.core(); - - ( - core.nib.address_map.nwk_for(eui64), - core.nib.address_map.update_mapping(eui64, nwk), - ) - }; + let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); // `nwk` belongs to another device, whose state must stay if conflict { @@ -49,13 +42,6 @@ impl ZigbeeStack { return; } - // A device that comes back under a new address leaves the old one behind - if let Some(previous_nwk) = previous_nwk - && previous_nwk != nwk - { - self.forget_nwk_state(previous_nwk); - } - self.forget_nwk_state(nwk); } @@ -184,14 +170,8 @@ impl ZigbeeStack { } // Update the address cache. Hearing a device is not a join, so the address keeps - // its session state. A rejoin request is left to its handler, which must still - // see the device's previous address to forget it. - let is_rejoin_request = matches!( - &nwk_frame.payload, - NwkPayload::Command(NwkCommand::RejoinRequest(_)) - ); - - if !is_rejoin_request && let Some(src_eui64) = nwk_frame.nwk_header.source_ieee { + // its session state. + if let Some(src_eui64) = nwk_frame.nwk_header.source_ieee { let source = nwk_frame.nwk_header.source; let conflict = self .core() From e71b7485606df0c7add8b21e35ad74d7730dc77c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:48:52 +0000 Subject: [PATCH 21/23] Keep APS duplicate entries when a device is forgotten --- crates/ziggurat-driver/src/zigbee_stack/nwk.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index cd152ca..dec4698 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -47,14 +47,8 @@ impl ZigbeeStack { /// The device at `nwk` left or joined again, so what we know about the address /// belongs to its previous session. Held sends fail: they wait on a route request - /// the device never heard, and would block a new one until it expires. The device - /// may restart its APS counter, so its duplicate entries go too. + /// the device never heard, and would block a new one until it expires. pub(super) fn forget_nwk_state(&self, nwk: Nwk) { - self.state - .aps_duplicates - .lock() - .retain(|&(source, _), _| source != nwk); - let mut core = self.core(); core.nib.routing.forget(self.state.network_address, nwk); let actions = core.sends.fail_unrouted(nwk); From 9d10e675186f1edb465ebc7a2699ce6633cbc9d0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:13:00 +0000 Subject: [PATCH 22/23] Update an address mapping and forget its state under one lock --- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 96f968f..9689bdf 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -26,30 +26,37 @@ use ziggurat_zigbee::nwk::next_hop::{self, NextHop}; use ziggurat_zigbee::sends::{Unicast as UnicastRecord, UnroutedUnicast}; use super::{ - AddrConflictSource, Broadcast, Confirm, DeliveryError, EnqueueError, Frame, MAX_DEPTH, Nib, - NwkSecurityMode, PROTOCOL_VERSION, RouteDirective, Schedule, SendActions, SendHandle, SendId, - SendMode, SendSlot, TxPolicy, TxPriority, Unicast, ZigbeeCore, ZigbeeNotification, ZigbeeStack, + AddrConflictSource, Broadcast, Confirm, CoreGuard, DeliveryError, EnqueueError, Frame, + MAX_DEPTH, Nib, NwkSecurityMode, PROTOCOL_VERSION, RouteDirective, Schedule, SendActions, + SendHandle, SendId, SendMode, SendSlot, TxPolicy, TxPriority, Unicast, ZigbeeCore, + ZigbeeNotification, ZigbeeStack, }; impl ZigbeeStack { - /// A device joined or rejoined as `nwk`: the address starts a new session. + /// A device joined or rejoined as `nwk`: the address starts a new session. The + /// mapping and the state it replaces change under one lock, so no send can be + /// enqueued against the old session in between. pub(super) fn update_nwk_eui64_mapping(&self, nwk: Nwk, eui64: Eui64) { - let conflict = self.core().nib.address_map.update_mapping(eui64, nwk); + let mut core = self.core(); // `nwk` belongs to another device, whose state must stay - if conflict { + if core.nib.address_map.update_mapping(eui64, nwk) { + drop(core); self.handle_address_conflict(nwk, AddrConflictSource::Local); return; } - self.forget_nwk_state(nwk); + self.forget_nwk_state_in(core, nwk); } /// The device at `nwk` left or joined again, so what we know about the address /// belongs to its previous session. Held sends fail: they wait on a route request /// the device never heard, and would block a new one until it expires. pub(super) fn forget_nwk_state(&self, nwk: Nwk) { - let mut core = self.core(); + self.forget_nwk_state_in(self.core(), nwk); + } + + fn forget_nwk_state_in(&self, mut core: CoreGuard<'_>, nwk: Nwk) { core.nib.routing.forget(self.state.network_address, nwk); let actions = core.sends.fail_unrouted(nwk); self.commit_sends(core, actions); From 3845c275ed33c59e3bed48acaa419a3c0658fc1b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:13:41 +0000 Subject: [PATCH 23/23] Restart discovery for sends held when a device joins, instead of failing them --- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 9689bdf..36c5503 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -26,10 +26,9 @@ use ziggurat_zigbee::nwk::next_hop::{self, NextHop}; use ziggurat_zigbee::sends::{Unicast as UnicastRecord, UnroutedUnicast}; use super::{ - AddrConflictSource, Broadcast, Confirm, CoreGuard, DeliveryError, EnqueueError, Frame, - MAX_DEPTH, Nib, NwkSecurityMode, PROTOCOL_VERSION, RouteDirective, Schedule, SendActions, - SendHandle, SendId, SendMode, SendSlot, TxPolicy, TxPriority, Unicast, ZigbeeCore, - ZigbeeNotification, ZigbeeStack, + AddrConflictSource, Broadcast, Confirm, DeliveryError, EnqueueError, Frame, MAX_DEPTH, Nib, + NwkSecurityMode, PROTOCOL_VERSION, RouteDirective, Schedule, SendActions, SendHandle, SendId, + SendMode, SendSlot, TxPolicy, TxPriority, Unicast, ZigbeeCore, ZigbeeNotification, ZigbeeStack, }; impl ZigbeeStack { @@ -46,17 +45,17 @@ impl ZigbeeStack { return; } - self.forget_nwk_state_in(core, nwk); + // The device can be reached again, so sends held for it are kept. The route + // request they wait on went out before it joined. With that discovery gone, the + // timer pass starts a new one. + core.nib.routing.forget(self.state.network_address, nwk); + self.commit_sends(core, SendActions::new()); } - /// The device at `nwk` left or joined again, so what we know about the address - /// belongs to its previous session. Held sends fail: they wait on a route request - /// the device never heard, and would block a new one until it expires. + /// The device at `nwk` left, so what we know about the address belongs to a session + /// that is over. Held sends fail: nothing will answer a route request for it. pub(super) fn forget_nwk_state(&self, nwk: Nwk) { - self.forget_nwk_state_in(self.core(), nwk); - } - - fn forget_nwk_state_in(&self, mut core: CoreGuard<'_>, nwk: Nwk) { + let mut core = self.core(); core.nib.routing.forget(self.state.network_address, nwk); let actions = core.sends.fail_unrouted(nwk); self.commit_sends(core, actions);