diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index f7f1bea..03e7cb3 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -788,8 +788,29 @@ 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 active route changed. + RouteChanged { + destination: Nwk, + next_hop: Nwk, + path_cost: u8, + }, + /// A routing table entry was removed; the client drops it from its persisted cache + RouteRemoved { destination: Nwk }, + /// A source route (relay list) to a destination was learned or cleared + RouteRecord { destination: Nwk, relays: Vec }, + /// The outgoing APS security frame counter has advanced; the client persists it to + /// 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 +941,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 +1039,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 +1116,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/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 a45cc08..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 @@ -494,10 +501,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 +927,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 +951,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 +963,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 +1158,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, }); } } @@ -1207,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 87653b9..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, @@ -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); @@ -451,6 +460,23 @@ impl ZigbeeStack { } } + /// Notify the client of an established or re-pointed route so it can update its + /// warm-start next-hop cache in real time. + pub(crate) fn notify_route_update(&self, update: Option) { + if let Some(RouteUpdate { + destination, + next_hop, + path_cost, + }) = update + { + self.push_notification(ZigbeeNotification::RouteChanged { + destination, + next_hop, + path_cost, + }); + } + } + /// A NWK command frame originated by us, with stack-wide defaults: secured, route /// discovery suppressed, radius `2 * max_depth`, sequence number assigned on send, /// our EUI64 as the extended source. Deviations chain `with_*` overrides. 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 823eca0..dd9ab4d 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 { @@ -350,11 +374,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 +436,32 @@ pub fn notification_frame(update: &ZigbeeNotification) -> Option> { ieee: *ieee, key: key.clone(), }), + ZigbeeNotification::RouteChanged { + destination, + next_hop, + path_cost, + } => Notification::RouteChanged(RouteChangedPayload { + destination: *destination, + next_hop: *next_hop, + path_cost: *path_cost, + }), + ZigbeeNotification::RouteRemoved { destination } => { + Notification::RouteRemoved(RouteRemovedPayload { + destination: *destination, + }) + } + ZigbeeNotification::RouteRecord { + destination, + relays, + } => 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..3a92b24 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, @@ -61,6 +63,10 @@ pub enum CommandId { LinkKey = 0x36, ApsDecryptFailure = 0x37, LastReset = 0x38, + RouteChanged = 0x39, + RouteRecord = 0x3A, + ApsFrameCounter = 0x3B, + RouteRemoved = 0x3C, } impl From for u8 { @@ -293,6 +299,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 { @@ -524,6 +555,7 @@ pub struct DeviceJoinedPayload { pub nwk: Nwk, pub ieee: Eui64, pub parent: Nwk, + pub flags: ChildFlags, } #[abstract_bits] @@ -553,6 +585,35 @@ pub struct LinkKeyPayload { pub key: Key, } +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteChangedPayload { + pub destination: Nwk, + pub next_hop: Nwk, + pub path_cost: u8, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteRemovedPayload { + pub destination: Nwk, +} + +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct RouteRecordPayload { + 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 { @@ -575,6 +636,8 @@ pub enum Request { LoadKeyTable(LoadKeyTablePayload), LoadChildren(LoadChildrenPayload), LoadAddressCache(LoadAddressCachePayload), + LoadRouteTable(LoadRouteTablePayload), + LoadSourceRoutes(LoadSourceRoutesPayload), StartNetwork, GetNetworkInfo, ScanKeyTable, @@ -608,6 +671,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, @@ -750,6 +817,10 @@ pub enum Notification { FrameCounter(FrameCounterPayload), LinkKey(LinkKeyPayload), ApsDecryptFailure(ApsDecryptFailPayload), + RouteChanged(RouteChangedPayload), + RouteRemoved(RouteRemovedPayload), + RouteRecord(RouteRecordPayload), + ApsFrameCounter(ApsFrameCounterPayload), } impl Notification { @@ -765,6 +836,10 @@ impl Notification { Self::FrameCounter(_) => (CommandId::FrameCounter, 0), Self::LinkKey(_) => (CommandId::LinkKey, 0), Self::ApsDecryptFailure(_) => (CommandId::ApsDecryptFailure, 0), + Self::RouteChanged(_) => (CommandId::RouteChanged, 0), + Self::RouteRemoved(_) => (CommandId::RouteRemoved, 0), + Self::RouteRecord(_) => (CommandId::RouteRecord, 0), + Self::ApsFrameCounter(_) => (CommandId::ApsFrameCounter, 0), }; let mut bytes = envelope(FrameType::Notification, command.into(), request_id); let fits = match self { @@ -778,6 +853,10 @@ 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::RouteRemoved(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-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/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..38f6de3 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 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; + return false; } + + 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) } } @@ -149,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)] @@ -249,11 +280,22 @@ impl Routing { } } + /// Remove the route to a destination, returning whether an entry was removed. pub fn remove_route(&mut self, destination: Nwk) -> bool { self.route_table.remove(&destination).is_some() } - pub fn store_route_record(&mut self, source: Nwk, relays: Vec) { + /// 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); + } + + /// 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 +307,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 { @@ -408,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; @@ -443,7 +491,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_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 @@ -454,7 +503,16 @@ impl Routing { == NwkRouteRequestManyToOne::ManyToOneSenderDoesntSupportRouteRecordTable; } - true + let update = route_changed.then_some(RouteUpdate { + destination: originator, + next_hop: sender, + path_cost: updated_path_cost, + }); + + RouteRequestOutcome { + accepted: true, + update, + } } /// Before relaying a route request: track that discovery toward the destination is @@ -483,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 @@ -504,7 +568,10 @@ impl Routing { updated_path_cost, discovery_entry.residual_cost ); - return RouteReplyDisposition::Drop; + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Drop, + update: None, + }; } tracing::debug!( @@ -512,12 +579,23 @@ 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; - return RouteReplyDisposition::Established; + let update = (previous_next_hop != Some(sender)).then_some(RouteUpdate { + destination: responder, + next_hop: sender, + path_cost: updated_path_cost, + }); + + return RouteReplyOutcome { + disposition: RouteReplyDisposition::Established, + update, + }; } // Otherwise, we need to decide if we need to update our own routes and possibly @@ -528,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; @@ -547,11 +628,21 @@ 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); - RouteReplyDisposition::Relay { - next_hop, - path_cost: updated_path_cost, + let update = reverse_entry + .consider_route(next_hop, forward_cost) + .then_some(RouteUpdate { + destination: originator, + next_hop, + path_cost: forward_cost, + }); + + RouteReplyOutcome { + disposition: RouteReplyDisposition::Relay { + next_hop, + path_cost: updated_path_cost, + }, + update, } }