From db8f1f36cf894e8e3f527cd7bedd7b426bebc402 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:37:00 -0400 Subject: [PATCH] Routing hints --- crates/ziggurat-driver/src/zigbee_stack.rs | 37 ++++++++-- .../ziggurat-driver/src/zigbee_stack/aps.rs | 15 ++-- .../src/zigbee_stack/joining.rs | 6 +- .../ziggurat-driver/src/zigbee_stack/nwk.rs | 69 +++++++++++++------ .../ziggurat-driver/src/zigbee_stack/zdp.rs | 5 +- crates/ziggurat-protocol/src/bridge.rs | 36 +++++++++- crates/ziggurat-protocol/src/wire.rs | 35 ++++++++++ 7 files changed, 167 insertions(+), 36 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack.rs b/crates/ziggurat-driver/src/zigbee_stack.rs index 03e7cb3..0491ab4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack.rs +++ b/crates/ziggurat-driver/src/zigbee_stack.rs @@ -145,15 +145,44 @@ pub enum NwkSecurityMode { } /// How the MAC next hop for an outgoing unicast is chosen. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SendMode { /// The destination is its own next hop: transmit straight to it with no routing /// lookup (and route discovery suppressed). Used for frames to a one-hop neighbor, /// e.g. delivering the network key to a joining device. Direct, - /// Resolve the next hop through the routing layer: the route table or an applicable - /// source route, discovering a route first if none is known. - Route, + /// Resolve the next hop through the routing layer, subject to a host [`RouteDirective`]: + /// the route table or an applicable source route, discovering a route first if none is + /// known. + Route(RouteDirective), +} + +/// How the host wants an originated unicast routed. +/// +/// Hints and forces are per-frame: they steer a single send and are never written into +/// the routing tables (so they never echo back through the route-change notification +/// stream). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RouteDirective { + /// The stack resolves the route entirely on its own (source route, route table, or + /// discovery). What all stack-originated traffic passes. + StackDecides, + /// Use the supplied route only when the stack has no route of its own; it stands in + /// for route discovery rather than triggering it. A known route still wins. + Hint(HostRoute), + /// Use the supplied route unconditionally, overriding even a known route and + /// suppressing discovery. + Force(HostRoute), +} + +/// A route the host supplies alongside a send. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostRoute { + /// Hand the frame to this next hop and let it route onward. + NextHop(Nwk), + /// The ordered relay path to embed as a source route; the MAC destination is the + /// last relay. An empty list means direct delivery to the destination. + SourceRoute(Vec), } /// Whether a unicast APS data frame requests an end-to-end acknowledgement. When it diff --git a/crates/ziggurat-driver/src/zigbee_stack/aps.rs b/crates/ziggurat-driver/src/zigbee_stack/aps.rs index b77d165..29feb10 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/aps.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/aps.rs @@ -18,8 +18,8 @@ use ziggurat_zigbee::Instant as CoreInstant; use ziggurat_zigbee::flat_map::Entry; use super::{ - ApsAck, ApsAckData, ApsAckResult, NwkSecurityMode, PendingApsAck, RequestId, SendMode, - TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + ApsAck, ApsAckData, ApsAckResult, NwkSecurityMode, PendingApsAck, RequestId, RouteDirective, + SendMode, TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; use crate::frame_token::TrafficClass; @@ -173,7 +173,11 @@ impl ZigbeeStack { .nwk_data_frame(nwk_frame.nwk_header.source, payload) .with_discover_route(NwkRouteDiscovery::Enable); - self.background_send_nwk_frame(aps_ack_frame, NwkSecurityMode::NetworkKey, SendMode::Route); + self.background_send_nwk_frame( + aps_ack_frame, + NwkSecurityMode::NetworkKey, + SendMode::Route(RouteDirective::StackDecides), + ); } /// Build the NWK frame carrying an APS data frame, plus the ack-correlation data when @@ -327,6 +331,7 @@ impl ZigbeeStack { aps_security: Option, sleepy_destination: bool, priority: TxPriority, + route: RouteDirective, request_id: RequestId, ) -> Result<(), ZigbeeStackError> { let (nwk_frame, ack_data) = self.prepare_aps_send( @@ -369,6 +374,7 @@ impl ZigbeeStack { request_id, aps_ack: ack_data, }, + SendMode::Route(route), ); Ok(()) } @@ -382,6 +388,7 @@ impl ZigbeeStack { nwk_frame: NwkFrame, policy: TxPolicy, outcome: TxOutcome, + mode: SendMode, ) { if nwk_frame.nwk_header.destination.as_u16() >= BROADCAST_LOW_POWER_ROUTERS.as_u16() { let request_id = match outcome { @@ -402,7 +409,7 @@ impl ZigbeeStack { self.originate_unicast( nwk_frame, NwkSecurityMode::NetworkKey, - SendMode::Route, + mode, policy, outcome, ); diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 0755420..a8677e5 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -36,8 +36,8 @@ use ziggurat_zigbee::nwk::commands::{ use super::{ AddrConflictSource, DeviceLeaveReason, IndirectFrame, IndirectPayload, JoinKind, NwkDeviceType, - NwkSecurityMode, RadioPhy, SendMode, TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, - ZigbeeStack, neighbors, + NwkSecurityMode, RadioPhy, RouteDirective, SendMode, TxOutcome, TxPolicy, TxPriority, + ZigbeeNotification, ZigbeeStack, neighbors, }; impl ZigbeeStack { @@ -628,7 +628,7 @@ impl ZigbeeStack { if self.is_neighbor(destination) { SendMode::Direct } else { - SendMode::Route + SendMode::Route(RouteDirective::StackDecides) }, ); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 93c8e51..37fb8d6 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -28,10 +28,10 @@ use ziggurat_zigbee::nwk::frame::{ use super::routing::{Route, RouteUpdate, Status as RouteStatus}; use super::{ - AddrConflictSource, BroadcastSchedule, IndirectFrame, IndirectPayload, JoinKind, MAX_DEPTH, - NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, - PendingUnicastRetry, RequestId, SendKind, SendMode, SendRequest, SendResult, TxOutcome, - TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, + AddrConflictSource, BroadcastSchedule, HostRoute, IndirectFrame, IndirectPayload, JoinKind, + MAX_DEPTH, NwkSecurityMode, PROTOCOL_VERSION, PendingBroadcast, PendingFrame, PendingRoute, + PendingUnicastRetry, RequestId, RouteDirective, SendKind, SendMode, SendRequest, SendResult, + TxOutcome, TxPolicy, TxPriority, ZigbeeNotification, ZigbeeStack, ZigbeeStackError, }; /// The outcome of resolving a unicast's MAC next hop without blocking (see @@ -603,7 +603,7 @@ impl ZigbeeStack { let destination = nwk_frame.nwk_header.destination; nwk_frame.nwk_header.sequence_number = self.next_nwk_sequence_number(); - match self.resolve_next_hop(&mut nwk_frame, mode) { + match self.resolve_next_hop(&mut nwk_frame, &mode) { NextHop::Resolved(next_hop) => { self.enqueue_unicast( nwk_frame, @@ -633,11 +633,17 @@ impl ZigbeeStack { /// Resolve the MAC next hop for a unicast without ever blocking. A source-routed /// result rewrites `nwk_frame`'s header in place (spec 3.6.4.3.1). When no route is /// known the frame's `discover_route` flag decides between discovery and discard. - fn resolve_next_hop(&self, nwk_frame: &mut NwkFrame, mode: SendMode) -> NextHop { + fn resolve_next_hop(&self, nwk_frame: &mut NwkFrame, mode: &SendMode) -> NextHop { let destination = nwk_frame.nwk_header.destination; - if mode == SendMode::Direct { - return NextHop::Resolved(destination); + let directive = match mode { + SendMode::Direct => return NextHop::Resolved(destination), + SendMode::Route(directive) => directive, + }; + + // A forced host route overrides everything, including a known route. + if let RouteDirective::Force(route) = directive { + return self.apply_host_route(nwk_frame, route); } // End device children never route-discover; their parent delivers directly. @@ -649,16 +655,7 @@ impl ZigbeeStack { match self.outbound_route(destination) { Some(Route::NextHop(next_hop)) => return NextHop::Resolved(next_hop), Some(Route::SourceRouted(relays)) => { - // Spec 3.6.4.3.1: the MAC destination is the relay closest to us, listed - // last; the relay index starts one below the relay count. - let next_hop = *relays.last().unwrap(); - nwk_frame.nwk_header.frame_control.source_route = true; - nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; - nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { - relay_index: relays.len() as u8 - 1, - relays, - }); - return NextHop::Resolved(next_hop); + return self.apply_source_route(nwk_frame, relays); } None => {} } @@ -673,7 +670,12 @@ impl ZigbeeStack { } } - // No usable route. Spec 3.6.3.3: only initiate discovery if the frame allows it. + // No usable route. A host hint stands in for discovery rather than triggering it. + if let RouteDirective::Hint(route) = directive { + return self.apply_host_route(nwk_frame, route); + } + + // Spec 3.6.3.3: only initiate discovery if the frame allows it. if nwk_frame.nwk_header.frame_control.discover_route == NwkRouteDiscovery::Suppress { NextHop::Discard } else { @@ -681,6 +683,28 @@ impl ZigbeeStack { } } + /// Apply a host-supplied route to a frame we originate, resolving its MAC next hop. + fn apply_host_route(&self, nwk_frame: &mut NwkFrame, route: &HostRoute) -> NextHop { + match route { + // The next hop routes onward itself, so `discover_route` is left as built: + // an intermediate hop still needs it to reach the final destination. + HostRoute::NextHop(next_hop) => NextHop::Resolved(*next_hop), + HostRoute::SourceRoute(relays) => self.apply_source_route(nwk_frame, relays.clone()), + } + } + + /// Rewrite `nwk_frame` to carry a source route. + fn apply_source_route(&self, nwk_frame: &mut NwkFrame, relays: Vec) -> NextHop { + let next_hop = *relays.last().unwrap(); + nwk_frame.nwk_header.frame_control.source_route = true; + nwk_frame.nwk_header.frame_control.discover_route = NwkRouteDiscovery::Suppress; + nwk_frame.nwk_header.source_route = Some(NwkSourceRoute { + relay_index: relays.len() as u8 - 1, + relays, + }); + NextHop::Resolved(next_hop) + } + pub async fn send_nwk_frame( &self, nwk_frame: NwkFrame, @@ -1085,7 +1109,10 @@ impl ZigbeeStack { token, } = queued; - match self.resolve_next_hop(&mut nwk_frame, SendMode::Route) { + match self.resolve_next_hop( + &mut nwk_frame, + &SendMode::Route(RouteDirective::StackDecides), + ) { NextHop::Resolved(next_hop) => { self.enqueue_unicast(nwk_frame, next_hop, security, priority, outcome, token); } @@ -1928,7 +1955,7 @@ impl ZigbeeStack { self.background_send_nwk_frame( network_status_frame, NwkSecurityMode::NetworkKey, - SendMode::Route, + SendMode::Route(RouteDirective::StackDecides), ); } diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 3bc7c45..088a371 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -12,8 +12,8 @@ use ziggurat_zigbee::zdp::{ }; use super::{ - ApsAck, MAX_DEPTH, NwkDeviceType, TxOutcome, TxPolicy, TxPriority, ZigbeeStack, - ZigbeeStackError, neighbors, routing, + ApsAck, MAX_DEPTH, NwkDeviceType, RouteDirective, SendMode, TxOutcome, TxPolicy, TxPriority, + ZigbeeStack, ZigbeeStackError, neighbors, routing, }; use crate::frame_token::TrafficClass; @@ -291,6 +291,7 @@ impl ZigbeeStack { class: TrafficClass::Host, }, TxOutcome::Discard, + SendMode::Route(RouteDirective::StackDecides), ); Ok(()) } diff --git a/crates/ziggurat-protocol/src/bridge.rs b/crates/ziggurat-protocol/src/bridge.rs index dd9ab4d..b220460 100644 --- a/crates/ziggurat-protocol/src/bridge.rs +++ b/crates/ziggurat-protocol/src/bridge.rs @@ -11,8 +11,9 @@ use core::time::Duration; use ziggurat_driver::runtime::Runtime; use ziggurat_driver::zigbee_stack::aps_security::TclkFlavor; use ziggurat_driver::zigbee_stack::{ - ApsAck, ApsAckResult, DeviceLeaveReason, NetworkBeacon, NetworkConfig, NwkDeviceType, - RequestId as StackRequestId, SendResult, TclkSeed, TxPriority, ZigbeeNotification, ZigbeeStack, + ApsAck, ApsAckResult, DeviceLeaveReason, HostRoute, NetworkBeacon, NetworkConfig, + NwkDeviceType, RequestId as StackRequestId, RouteDirective, SendResult, TclkSeed, TxPriority, + ZigbeeNotification, ZigbeeStack, }; use ziggurat_ieee_802154::types::{Eui64, Key, Nwk}; use ziggurat_phy::RadioPhy; @@ -277,6 +278,8 @@ pub fn send_aps( ApsAck::None }; + let route = route_directive(payload.route, payload.next_hop, payload.relays)?; + stack .send_aps( payload.flags.delivery_mode, @@ -292,11 +295,40 @@ pub fn send_aps( aps_security, payload.flags.sleepy_destination, TxPriority::from_host(payload.priority as i8), + route, StackRequestId::from(request_id), ) .map_err(|e| Error::new(Status::TransmitFailed, &e.to_string())) } +/// Build the driver's [`RouteDirective`] from the wire route control. +fn route_directive( + control: RouteControl, + next_hop: Option, + relays: Option, +) -> Result { + let host_source_route = |relays: SourceRouteRelays| -> Result { + if relays.relays.is_empty() { + Err(Error::new( + Status::InvalidRequest, + "a source route must contain at least one relay", + )) + } else { + Ok(HostRoute::SourceRoute(relays.relays)) + } + }; + + Ok(match control { + RouteControl::StackDecides => RouteDirective::StackDecides, + RouteControl::HintNextHop => RouteDirective::Hint(HostRoute::NextHop(next_hop.unwrap())), + RouteControl::ForceNextHop => RouteDirective::Force(HostRoute::NextHop(next_hop.unwrap())), + RouteControl::HintSourceRoute => RouteDirective::Hint(host_source_route(relays.unwrap())?), + RouteControl::ForceSourceRoute => { + RouteDirective::Force(host_source_route(relays.unwrap())?) + } + }) +} + /// Cancel an in-flight send by the `request_id` it was issued under. Best-effort: the /// reply reports whether a still-cancellable (pre-delivery) send was found and removed. pub fn cancel_request( diff --git a/crates/ziggurat-protocol/src/wire.rs b/crates/ziggurat-protocol/src/wire.rs index 3a92b24..0f8e9a8 100644 --- a/crates/ziggurat-protocol/src/wire.rs +++ b/crates/ziggurat-protocol/src/wire.rs @@ -360,6 +360,32 @@ pub struct SendApsFlags { pub reserved: u2, } +/// How the host wants a unicast routed. +#[abstract_bits(bits = 8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] +#[repr(u8)] +pub enum RouteControl { + /// Leave routing entirely to the stack. No route data follows. + StackDecides = 0, + /// Use `next_hop` only if the stack has no route of its own (stands in for discovery). + HintNextHop = 1, + /// Use `next_hop` unconditionally, overriding a known route and suppressing discovery. + ForceNextHop = 2, + /// Use `relays` as a source route only if the stack has no route of its own. + HintSourceRoute = 3, + /// Use `relays` as a source route unconditionally, overriding a known route. + ForceSourceRoute = 4, +} + +/// The ordered relay path of a host-supplied source route (excluding the destination). +#[abstract_bits] +#[derive(Debug, Clone)] +pub struct SourceRouteRelays { + pub relay_count: u8, + #[abstract_bits(length_from = relay_count)] + pub relays: Vec, +} + #[abstract_bits] #[derive(Debug, Clone)] pub struct SendApsPayload { @@ -373,6 +399,15 @@ pub struct SendApsPayload { pub aps_seq: u8, pub radius: u8, pub priority: u8, // i8 two's complement + pub route: RouteControl, + #[abstract_bits( + presence_from = matches!(route, RouteControl::HintNextHop | RouteControl::ForceNextHop) + )] + pub next_hop: Option, + #[abstract_bits( + presence_from = matches!(route, RouteControl::HintSourceRoute | RouteControl::ForceSourceRoute) + )] + pub relays: Option, pub asdu_len: u16, #[abstract_bits(length_from = asdu_len)] pub asdu: Vec,