Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3a62a30
Re-resolve held sends on every timer pass
puddly Nov 12, 2026
004f8c1
Wake the send timer when a route record changes
puddly Nov 12, 2026
f29f2ad
Remove the unused `RouteInactiveAfterDiscovery` send status
puddly Nov 12, 2026
b2cb431
Move the timer pass's routing decisions into the send tables
puddly Nov 12, 2026
c0aeb17
Fail held sends again when a device joins
puddly Nov 12, 2026
6685951
Keep a route found after the sends waiting on its discovery are gone
puddly Nov 12, 2026
353ac56
Wake held sends when a route record changes a relay's route
puddly Nov 12, 2026
55525a0
Add unit tests for the send tables and routing
puddly Nov 12, 2026
0ada509
Test that an idle discovery ends quietly and that route records repor…
puddly Nov 12, 2026
d547be5
Shorten the send table and route record tests
puddly Nov 12, 2026
adaa9be
Keep an idle discovery tracked until it lapses
puddly Nov 12, 2026
779270b
Check held sends against a model over every short sequence of operations
puddly Nov 12, 2026
574d461
Let the routing table own route discovery for held sends
puddly Nov 12, 2026
103c586
Keep an answered discovery tracked until a pass sees no frame waiting
puddly Nov 12, 2026
adc3174
Derive our route discoveries from the routing tables and keep only an…
puddly Nov 12, 2026
8be706b
Keep a routing entry whose discovery is underway when its route is re…
puddly Nov 12, 2026
e418111
Merge branch 'dev' into puddly/re-resolve-held-sends
puddly Nov 13, 2026
957de29
Move tests to `tests.rs`
puddly Nov 13, 2026
9e963ac
Align with new naming
puddly Nov 13, 2026
9fdcd2b
Do not wait on an answered discovery that a relayed route request mar…
puddly Nov 13, 2026
0c2e6da
Add relayed route requests to the discovery model and check it agains…
puddly Nov 13, 2026
8375b75
Correct why held sends fail when a device leaves or joins again
puddly Nov 13, 2026
1f7a552
Release a send held by a forced route discovery once that discovery i…
puddly Nov 13, 2026
1507ef4
Correct three comments the held send rework left behind
puddly Nov 13, 2026
a489574
Add reported route failures and forgotten devices to the discovery model
puddly Nov 13, 2026
fcc56d3
Drop the two discovery model steps that reach nothing the others do not
puddly Nov 13, 2026
16fdb6d
Record that a route reply was accepted apart from its path cost
puddly Nov 13, 2026
f233c1f
Drop a route reply that comes after its discovery lapsed
puddly Nov 13, 2026
c0b49fa
Forget an address's routing state and fail its held sends on an addre…
puddly Nov 13, 2026
8c84e1b
Keep the wire values of send statuses that are no longer sent
puddly Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions crates/ziggurat-driver/src/zigbee_stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,6 @@ pub enum EnqueueError {
pub enum DeliveryError {
#[error("route discovery timed out")]
RouteDiscoveryTimeout,
#[error("route not active after discovery completed")]
RouteInactiveAfterDiscovery,
#[error("next hop {next_hop:?} did not ACK")]
NwkNoAck { next_hop: Ieee802154Address },
#[error("transmit rejected due to CCA failure")]
Expand Down
7 changes: 4 additions & 3 deletions crates/ziggurat-driver/src/zigbee_stack/joining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,12 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
return;
}

// Routers resolve their own conflicts after hearing the notification; our
// mapping for the address is ambiguous until the keeper re-announces
// Routers resolve their own conflicts after hearing the notification. Until
// one announces itself we cannot tell which device a frame to the address
// would reach, so sends held for it fail
let mut core = self.core();
core.nib.address_map.forget_address(address);
core.nib.routing.remove_route(address);
self.forget_nwk_state_in(core, address);
}

/// The address-conflict report reactor: a single long-lived task owning the
Expand Down
53 changes: 36 additions & 17 deletions crates/ziggurat-driver/src/zigbee_stack/nwk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ 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<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
Expand All @@ -45,17 +46,17 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
return;
}

// 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());
self.forget_nwk_state_in(core, nwk);
Comment thread
puddly marked this conversation as resolved.
Resolved
}

/// 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.
/// The device at `nwk` left or joined again, or two devices claim the address, so
/// what we know about it no longer holds. Held sends fail: they wait on a route
/// request the device never heard, and forgetting the address drops that discovery.
pub(super) fn forget_nwk_state(&self, nwk: Nwk) {
let mut core = self.core();
self.forget_nwk_state_in(self.core(), nwk);
}

pub(super) 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);
Expand Down Expand Up @@ -247,18 +248,24 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
NwkCommand::RouteRecord(cmd) => {
tracing::trace!("Route record command frame received: {cmd:?}");
let source = nwk_frame.nwk_header.source;
let changed = self
let change = self
.core()
.nib
.routing
.store_route_record(source, cmd.relays.clone());

if changed {
if change.source {
self.push_notification(ZigbeeNotification::RouteRecord {
destination: source,
relays: cmd.relays.clone(),
});
}

// Sends held for the device, or for a relay on its path, can now be
// source routed
if change.any {
self.send_timer_wake.notify_one();
}
}
NwkCommand::RouteRequest(cmd) => {
self.handle_route_request(nwk_frame, cmd.clone(), sender_nwk);
Expand Down Expand Up @@ -419,8 +426,6 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
tracing::debug!("Holding frame until a route to {destination:?} is known");
let slot = Arc::new(SendSlot::default());

// The first unrouted send to a destination starts its discovery
let discovering = core.sends.has_unrouted(destination);
let (id, mut actions) = core.sends.enqueue_unrouted_unicast(
UnroutedUnicast {
frame: Frame::nwk(nwk_frame, security),
Expand All @@ -432,8 +437,22 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
now,
&self.tunables,
);
if !discovering {
actions.extend(self.start_route_discovery(&mut core, destination));

// The routing table starts a discovery unless the send can wait on
// one: a discovery of ours still underway, or one the timer pass starts
let route_request_identifier = core.nib.routing.want_route(
self.state.network_address,
destination,
now,
self.tunables.route_discovery_time(),
self.tunables.pending_route_discovery_attempts(),
);
if let Some(route_request_identifier) = route_request_identifier {
actions.extend(self.broadcast_discovery_request(
&mut core,
destination,
route_request_identifier,
));
}

(SendHandle::new(id, slot), actions)
Expand Down
18 changes: 6 additions & 12 deletions crates/ziggurat-driver/src/zigbee_stack/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
// responder seeds the first hop and each relay adds the link it forwards across.
let updated_path_cost = route_reply_cmd.path_cost;

let now = self.core_now();
let outcome = self.core().nib.routing.accept_route_reply(
self.state.network_address,
route_reply_cmd.originator_nwk,
route_reply_cmd.route_request_identifier,
route_reply_cmd.responder_nwk,
nwk_frame.nwk_header.source,
updated_path_cost,
now,
);

let (next_hop_nwk, path_cost) = match outcome.disposition {
Expand Down Expand Up @@ -435,24 +437,16 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
}
}

/// Begin or restart ad-hoc route discovery toward a destination: the routing entry
/// enters `DiscoveryUnderway` and a route request is broadcast. The send timer
/// watches the discovery on behalf of the unrouted sends to it; this only kicks it
/// off. Under a core lock the caller holds and commits.
pub(super) fn start_route_discovery(
/// Broadcast the route request of a discovery the routing table started or
/// restarted toward a destination. Under a core lock the caller holds and commits.
pub(super) fn broadcast_discovery_request(
&self,
core: &mut ZigbeeCore,
destination: Nwk,
route_request_identifier: u8,
) -> SendActions {
tracing::debug!("Sending route discovery for NWK {destination:?}");

let route_request_identifier = core.nib.routing.begin_discovery(
self.state.network_address,
destination,
self.core_now(),
self.tunables.route_discovery_time(),
);

// If we know the EUI64 corresponding to the NWK, use it
let destination_eui64 = core.nib.address_map.eui64_for(destination);

Expand Down
96 changes: 52 additions & 44 deletions crates/ziggurat-driver/src/zigbee_stack/sends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ use ziggurat_zigbee::sends::{Action, Broadcast as BroadcastRecord, Unicast as Un

use crate::runtime::Runtime;

use super::routing::Status as RouteStatus;
use super::{
Confirm, CoreGuard, DeliveryError, Frame, Nib, NwkSecurityMode, RouteDirective, Schedule,
SendActions, SendHandle, SendId, SendMode, SendSlot, Transmit, TxPolicy, ZigbeeCore,
Expand Down Expand Up @@ -178,32 +177,41 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
.map(Ieee802154Address::Eui64)
}

/// The next hop of an unrouted frame once its route is known, and the address it
/// polls under if it is a sleepy child. The route can have vanished in the race,
/// which fails the send.
fn resolve_routed_unicast(
/// Resolve the route of an unrouted frame again: its next hop and the address it
/// polls under if it is a sleepy child. `None` means no route yet.
fn resolve_unrouted_unicast(
&self,
nib: &Nib,
frame: &mut Frame,
now: CoreInstant,
) -> Result<(Nwk, Option<Ieee802154Address>), DeliveryError> {
) -> Option<(Nwk, Option<Ieee802154Address>)> {
let Frame::Nwk { nwk_frame, .. } = frame else {
unreachable!("only NWK unicasts await a route")
};

// A forced discovery holds the frame only while it is underway. Once it is
// answered or has lapsed, the frame takes any route like every other frame
let mut policy = self.next_hop_policy();
policy.force_route_discovery &= nib.routing.own_discovery_underway(
self.state.network_address,
nwk_frame.nwk_header.destination,
now,
);

let resolved = next_hop::resolve(
&nib.routing,
&nib.neighbors,
nwk_frame,
&SendMode::Route(RouteDirective::StackDecides),
self.next_hop_policy(),
policy,
now,
);

match resolved {
NextHop::Resolved(next_hop) => Ok((next_hop, Self::poll_address(nib, next_hop))),
NextHop::NeedDiscovery | NextHop::Discard => {
Err(DeliveryError::RouteInactiveAfterDiscovery)
NextHop::Resolved(next_hop) => Some((next_hop, Self::poll_address(nib, next_hop))),
NextHop::NeedDiscovery => None,
NextHop::Discard => {
unreachable!("a frame is only held for a route if it allows discovery")
}
}
}
Expand Down Expand Up @@ -298,16 +306,15 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
}
}

/// One timer pass: settle the broadcasts whose passive-ack quorum was heard, route
/// the sends whose route became active, restart or fail the discoveries that
/// lapsed, then run the table's timed transitions. Returns when the next pass is
/// due: the table's next deadline, or that of a route discovery unrouted sends wait
/// on, which lives in the routing table.
/// One timer pass: settle the broadcasts whose passive-ack quorum was heard, run
/// the send tables' pass against the routing and neighbor tables, then let the
/// routing table start a discovery for, or give up on, each destination a send
/// still waits on with none underway. Returns when the next pass is due.
fn drive_sends(&self) -> Option<CoreInstant> {
let now = self.core_now();

let mut guard = self.core();
// A plain `&mut ZigbeeCore`, so the closure below borrows `nib` beside `sends`
// A plain `&mut ZigbeeCore`, so the closures below borrow `nib` beside `sends`
let core = &mut *guard;
let mut actions = SendActions::new();

Expand All @@ -317,37 +324,38 @@ impl<P: RadioPhy, R: Runtime> ZigbeeStack<P, R> {
}
}

for destination in core.sends.unrouted_destinations() {
if core.nib.routing.route_status(destination) == Some(RouteStatus::Active) {
actions.extend(core.sends.route_unicasts(
destination,
now,
&self.tunables,
|send| self.resolve_routed_unicast(&core.nib, send, now),
));
} else if core
.nib
.routing
.discovery_deadline(destination, now)
.is_none()
{
if core.sends.retry_discovery(destination) {
actions.extend(self.start_route_discovery(core, destination));
} else {
core.nib.routing.mark_discovery_failed(destination);
actions.extend(core.sends.fail_unrouted(destination));
}
}
actions.extend(core.sends.tick(now, &self.tunables, |frame| {
self.resolve_unrouted_unicast(&core.nib, frame, now)
}));

let discoveries = core.nib.routing.tick_discoveries(
self.state.network_address,
now,
self.tunables.route_discovery_time(),
|destination| core.sends.has_unrouted(destination),
);

for (destination, route_request_identifier) in discoveries.restarted {
actions.extend(self.broadcast_discovery_request(
core,
destination,
route_request_identifier,
));
}

actions.extend(core.sends.tick(now));
for destination in discoveries.unreachable {
actions.extend(core.sends.fail_unrouted(destination));
}

let discoveries = core
.sends
.unrouted_destinations()
.into_iter()
.filter_map(|destination| core.nib.routing.discovery_deadline(destination, now));
let next_wake = core.sends.next_wake().into_iter().chain(discoveries).min();
let next_wake = [
core.sends.next_wake(),
core.nib
.routing
.next_discovery_lapse(self.state.network_address, now),
]
.into_iter()
.flatten()
.min();

// This pass is the timer task's own: waking the timer here would make it spin
self.run_send_actions(guard, actions);
Expand Down
1 change: 0 additions & 1 deletion crates/ziggurat-protocol/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,6 @@ pub(crate) const fn send_status(result: &Result<(), DeliveryError>) -> SendStatu
Ok(()) => SendStatus::Success,
Err(err) => match err {
DeliveryError::RouteDiscoveryTimeout => SendStatus::RouteDiscoveryTimeout,
DeliveryError::RouteInactiveAfterDiscovery => SendStatus::RouteInactiveAfterDiscovery,
DeliveryError::NwkNoAck { .. } => SendStatus::NwkNoAck,
DeliveryError::CcaFailure => SendStatus::CcaFailure,
DeliveryError::TransmitFailed(_) => SendStatus::TransmitFailed,
Expand Down
3 changes: 3 additions & 0 deletions crates/ziggurat-protocol/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,11 +713,14 @@ pub enum SendStatus {
Success = 0,
RouteDiscoveryTimeout = 1,
RouteDiscoveryNoEntry = 2,
/// No longer sent. Kept so a confirmation from older firmware still decodes.
RouteInactiveAfterDiscovery = 3,
NwkNoAck = 4,
CcaFailure = 5,
TransmitFailed = 6,
ApsAckTimeout = 7,
/// No longer sent. Kept so a confirmation from older firmware still decodes.
BroadcastQuorumNotReached = 8,
IndirectExpired = 9,
FrameBudgetExhausted = 10,
Cancelled = 11,
Expand Down
Loading
Loading