umsh_ulcp_runtime/device_node.rs
1//! The device node: a full `umsh-mac`/`umsh-node` stack running on the
2//! device itself, alongside the ULCP session.
3//!
4//! The device identity "exists even when no phone is attached" (ULCP spec
5//! §Identities); this module is what makes that true. It is an ordinary
6//! MAC + `Host` pump with the device's constraints baked into
7//! [`DeviceNodePlatform`]:
8//!
9//! - **Radio** is a `LoraphyRadio` over the node's virtual mux bundle
10//! ([`NODE_CH`], mux client B) behind the shared duty-ledger gate: the
11//! session and the node share one physical radio through `radio_mux`
12//! and draw from one combined `PROP_PHY_DUTY_LIMIT` budget. A refused
13//! transmit is shed via the MAC's CAD-backoff path rather than killing
14//! the pump.
15//! - **Rng** is a ChaCha20 CSPRNG seeded from the board's hardware TRNG
16//! at boot ([`NodeRng`]): project policy forbids non-crypto RNGs, and
17//! under BLE builds the RNG peripheral is not ours to read at runtime.
18//! - **The counter store** is the board's — the `CS` parameter — so TX
19//! reservation boundaries for the device identity and per-peer RX
20//! replay boundaries survive power cycles, flushed from inside the MAC
21//! pump one whole-map record per persist block.
22//!
23//! The node **always exists**: a device identity is generated and
24//! persisted at first boot, so bring-up is unconditional and the only
25//! question is whether the node is *transmitting*. That is configuration
26//! — the PHY enable state and the forwarding switch — plus the
27//! `NODE_ACTIVE` gate, which closes while a factory reset is in flight
28//! (the identity has been erased from storage but the running MAC still
29//! holds it until the reboot that completes the wipe).
30//!
31//! Beacon requests arrive through [`BEACON_TRIGGER`] rather than from any
32//! specific button handler: the trigger is an input, so a button press,
33//! bring-up, and the advertisement-policy timers in [`advert_loop`] all
34//! reach the radio by one path.
35//!
36//! # Board seam
37//!
38//! Embassy task functions cannot be generic, so the spawnable tasks stay
39//! in each firmware as thin shims around the `*_loop` functions here, and
40//! the board owns the two statics whose types depend on `CS`: the MAC
41//! cell and its counter store. Everything else — every static whose type
42//! is fixed, and every line of logic — is here once.
43
44extern crate alloc;
45
46use core::cell::RefCell;
47use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
48
49use embassy_futures::select::{Either, select};
50use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
51use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
52use embassy_sync::channel::Channel;
53use embassy_sync::signal::Signal;
54use embassy_sync::watch::Watch;
55use embassy_time::{Duration, Instant, Timer};
56
57use umsh_core::{ChannelKey, PublicKey};
58use umsh_crypto::CryptoEngine;
59use umsh_crypto::software::{SoftwareAes, SoftwareIdentity, SoftwareSha256};
60use umsh_hal::{CounterStore, EmbassyClock, NoKeyValueStore};
61use umsh_mac::{MacCounters, MacHandle, OperatingPolicy, RepeaterConfig, SendOptions};
62use umsh_node::location::NodeLocation;
63use umsh_node::{
64 Host, LocalNode, NodeCapabilities, NodeIdentityProfile, NodeRole, default_respond_policy,
65 never_respond_policy,
66};
67use umsh_sync::AsyncRefCell;
68use umsh_ulcp::stats::{Counter, Mirror, StatsLedger};
69use umsh_ulcp_device::{MAX_CHANNEL_KEYS, MAX_DEV_ADMINS, MAX_DEV_PEERS, MAX_DEVICE_NAME_LEN};
70
71use crate::driver::DevDomainSnapshot;
72use crate::duty_gate::DutyGatedRadio;
73use crate::log::debug_log;
74
75/// The mutex kind guarding the node's statics.
76///
77/// The nRF images run a single thread-mode executor, where
78/// `ThreadModeRawMutex` is a no-op lock — worth keeping, because these
79/// statics sit in the radio RX/TX path on boards whose BLE controller
80/// (MPSL/SDC) has hard real-time deadlines that a critical section would
81/// intrude on. Boards without that constraint take the portable default.
82///
83/// `embassy_sync` only defines `ThreadModeRawMutex` for bare-metal targets,
84/// so the host build takes the portable default regardless of the feature.
85/// Nothing on the host runs the device node — it builds there for tests and
86/// rustdoc — and the portable lock is the correct choice under a hosted OS
87/// anyway.
88#[cfg(all(feature = "node-thread-mode-mutex", target_os = "none"))]
89pub type NodeMutex = embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
90#[cfg(not(all(feature = "node-thread-mode-mutex", target_os = "none")))]
91pub type NodeMutex = CriticalSectionRawMutex;
92
93// ─── Platform ────────────────────────────────────────────────────────────────
94
95/// ChaCha20 CSPRNG adapter implementing the `rand 0.10` traits the MAC
96/// requires (`Platform::Rng: rand::CryptoRng`). Seeded once at boot from
97/// the board's hardware TRNG, exactly like the session's `IdentityRng`,
98/// while that source is still ours to read.
99pub struct NodeRng(rand_chacha::ChaCha20Rng);
100
101impl NodeRng {
102 pub fn from_seed(seed: [u8; 32]) -> Self {
103 Self(<rand_chacha::ChaCha20Rng as rand_core::SeedableRng>::from_seed(seed))
104 }
105}
106
107impl rand::TryRng for NodeRng {
108 type Error = core::convert::Infallible;
109
110 fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
111 Ok(rand_core::RngCore::next_u32(&mut self.0))
112 }
113
114 fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
115 Ok(rand_core::RngCore::next_u64(&mut self.0))
116 }
117
118 fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
119 rand_core::RngCore::fill_bytes(&mut self.0, dest);
120 Ok(())
121 }
122}
123
124// ChaCha20 is a cryptographically secure generator; the seed comes from
125// the board's hardware TRNG.
126impl rand::TryCryptoRng for NodeRng {}
127
128/// `umsh_mac::Platform` bundle for the device node, generic only over the
129/// board's counter store — the one piece of the platform that is
130/// genuinely per-board, because it is backed by that board's flash.
131pub struct DeviceNodePlatform<CS>(core::marker::PhantomData<CS>);
132
133/// The node's radio path: its virtual mux bundle behind the shared
134/// duty-ledger admission gate.
135pub type DeviceNodeRadio =
136 DutyGatedRadio<umsh_radio_loraphy::LoraphyRadio<NodeMutex, 4, 2>, EmbassyClock>;
137
138impl<CS: CounterStore + 'static> umsh_mac::Platform for DeviceNodePlatform<CS> {
139 type Identity = SoftwareIdentity;
140 type Aes = SoftwareAes;
141 type Sha = SoftwareSha256;
142 type Radio = DeviceNodeRadio;
143 type Delay = embassy_time::Delay;
144 type Clock = EmbassyClock;
145 type Rng = NodeRng;
146 type CounterStore = CS;
147 type KeyValueStore = NoKeyValueStore;
148}
149
150/// Every node the device domain names, which is what the MAC peer table
151/// has to hold: a full peer list and a full administrator list can be
152/// provisioned at once, and each needs a pairwise session.
153pub const MAX_NODE_PEERS: usize = MAX_DEV_PEERS + MAX_DEV_ADMINS;
154
155/// Device-node MAC sized to the session's device-domain tables, which are
156/// the only provisioning source it has: 1 identity (the device identity;
157/// no PFS ephemerals on the device node), `MAX_NODE_PEERS` peers,
158/// `MAX_CHANNEL_KEYS` channels (a smaller MAC table would refuse channels
159/// the property surface accepted), 4 pending ACKs, 4 TX slots (beacons
160/// and future acks — no application traffic), 255-byte frames, 32-entry
161/// dup cache. The per-channel replay maps are the RAM hot spot (~330
162/// bytes per tracked sender): 4 full-key + 2 hint-only senders per
163/// channel keeps the whole table ~2 KiB/channel; extra concurrent senders
164/// on one channel fail closed (dropped, never accepted unchecked).
165pub type DeviceNodeMac<CS> =
166 umsh_mac::Mac<DeviceNodePlatform<CS>, 1, MAX_NODE_PEERS, MAX_CHANNEL_KEYS, 4, 4, 255, 32, 4, 2>;
167pub type DeviceNodeHandle<CS> = MacHandle<
168 'static,
169 DeviceNodePlatform<CS>,
170 1,
171 MAX_NODE_PEERS,
172 MAX_CHANNEL_KEYS,
173 4,
174 4,
175 255,
176 32,
177 4,
178 2,
179>;
180pub type DeviceNodeHost<CS> = Host<DeviceNodeHandle<CS>>;
181pub type DeviceNode<CS> = LocalNode<DeviceNodeHandle<CS>>;
182
183/// The uninitialized arena a board declares for its MAC. Board-side
184/// because its type depends on `CS`, and a `static` cannot be generic.
185/// A `MaybeUninit` rather than a `StaticCell` so a board may place it in
186/// a NOLOAD region the runtime never zeroes (the Heltec V2 puts it in
187/// the classic ESP32's `dram2_seg`, where a `StaticCell`'s flag byte
188/// would come up as garbage); [`bring_up`]'s at-most-once contract is
189/// what the cell's flag used to enforce.
190pub type DeviceNodeMacArena<CS> = core::mem::MaybeUninit<AsyncRefCell<DeviceNodeMac<CS>>>;
191
192// ─── Statics ─────────────────────────────────────────────────────────────────
193
194/// The node's virtual radio bundle (mux client B). Static regardless of
195/// whether the node is running: the mux fans RX out to it either way, and
196/// a full queue just drops frames per the mux's per-client policy.
197pub static NODE_CH: umsh_radio_loraphy::Channels<NodeMutex, 4, 2> =
198 umsh_radio_loraphy::Channels::new();
199
200/// Latest-wins hand-off from the session driver to the sync loop. A
201/// `Signal` rather than a queue: intermediate table states are
202/// irrelevant, only convergence on the newest snapshot matters. On a boot
203/// that skipped node bring-up (a crash-report boot) a pending snapshot
204/// just sits here unconsumed.
205pub static DEV_SYNC: Signal<NodeMutex, DevDomainSnapshot> = Signal::new();
206
207/// Hand a snapshot to the sync loop, publishing the mirrors a reader can
208/// see synchronously on the way past.
209///
210/// Everything a display reads back after a settings change has to be true
211/// by the time this returns. The sync loop runs in its own task and does
212/// not, so a mirror updated where the snapshot is *consumed* is a value
213/// that lags the publish by however long that task takes to be scheduled
214/// — and a panel redrawing on the publish would draw the old one.
215pub fn publish_snapshot(snapshot: DevDomainSnapshot) {
216 if NODE_IS_REPEATER.swap(snapshot.repeater_enabled, Ordering::Relaxed)
217 != snapshot.repeater_enabled
218 {
219 debug_log(format_args!(
220 "node dev-sync: repeater {}",
221 if snapshot.repeater_enabled {
222 "ON"
223 } else {
224 "off"
225 }
226 ));
227 }
228 DEV_SYNC.signal(snapshot);
229}
230
231/// Whether the device node may transmit. Cleared when a snapshot reports
232/// the identity gone (factory reset); the MAC still holds the old
233/// identity until reboot, but it must stop originating traffic.
234static NODE_ACTIVE: AtomicBool = AtomicBool::new(true);
235
236/// Whether the device node is acting as a repeater
237/// (`PROP_MAC_REPEATER_ENABLED`).
238static NODE_IS_REPEATER: AtomicBool = AtomicBool::new(false);
239
240/// The public key the running MAC actually holds, published once at
241/// bring-up.
242///
243/// The session has the same value in `PROP_DEV_KEY`, but a display
244/// reading it there would be reading what the device is *configured*
245/// with; this is what it is answering to on the air, which is the thing
246/// someone comparing an address against a phone screen needs.
247///
248/// `None` until bring-up runs, and on a board that never brings a node
249/// up at all — which is the honest answer for a screen that would
250/// otherwise print a placeholder address.
251static NODE_KEY: BlockingMutex<CriticalSectionRawMutex, RefCell<Option<[u8; 32]>>> =
252 BlockingMutex::new(RefCell::new(None));
253
254/// Whether the device node forwards other nodes' frames.
255///
256/// Reads the same state the session's `PROP_MAC_REPEATER_ENABLED` names,
257/// published here so a display can glance at it without borrowing the
258/// coordinator or racing `dev_sync_loop` for the mirror — which is a
259/// single-consumer `Signal` and already has one.
260pub fn repeater_enabled() -> bool {
261 NODE_IS_REPEATER.load(Ordering::Relaxed)
262}
263
264/// The public key the running node answers to, or `None` before
265/// bring-up.
266pub fn node_key() -> Option<[u8; 32]> {
267 NODE_KEY.lock(|key| *key.borrow())
268}
269
270/// Whether bring-up ran this boot. Without it nothing answers
271/// [`IDENT_REQUEST`], and a `PROP_IDENT` read must fail rather than hang
272/// the session waiting for a task that does not exist.
273static NODE_UP: AtomicBool = AtomicBool::new(false);
274
275/// The MAC's frame tallies, republished by [`pump_loop`] after each wake
276/// cycle so a board's display can read them without borrowing the
277/// coordinator.
278///
279/// Plain atomics rather than a mutex: the readers are display code
280/// that wants a glance, not a consistent transaction, and a torn read
281/// across two of these counters is invisible at the resolution anyone
282/// looks at them. Publishing from inside the pump costs no extra wakeups —
283/// it happens on a loop that was going to run anyway.
284static MAC_TX_FRAMES: AtomicU32 = AtomicU32::new(0);
285static MAC_TX_ABANDONED: AtomicU32 = AtomicU32::new(0);
286static MAC_RX_FRAMES: AtomicU32 = AtomicU32::new(0);
287static MAC_RX_ACCEPTED: AtomicU32 = AtomicU32::new(0);
288static MAC_FORWARDED: AtomicU32 = AtomicU32::new(0);
289static MAC_FORWARD_CANCELLED: AtomicU32 = AtomicU32::new(0);
290static MAC_FORWARD_DROPPED_POLICY: AtomicU32 = AtomicU32::new(0);
291
292/// The most recently published MAC tallies.
293///
294/// All zero before the node's first wake cycle, which is
295/// indistinguishable from a node that has done nothing — and is the same
296/// thing as far as a reader is concerned.
297pub fn mac_counters() -> MacCounters {
298 MacCounters {
299 tx_frames: MAC_TX_FRAMES.load(Ordering::Relaxed),
300 tx_abandoned: MAC_TX_ABANDONED.load(Ordering::Relaxed),
301 rx_frames: MAC_RX_FRAMES.load(Ordering::Relaxed),
302 rx_accepted: MAC_RX_ACCEPTED.load(Ordering::Relaxed),
303 forwarded: MAC_FORWARDED.load(Ordering::Relaxed),
304 forward_cancelled: MAC_FORWARD_CANCELLED.load(Ordering::Relaxed),
305 forward_dropped_policy: MAC_FORWARD_DROPPED_POLICY.load(Ordering::Relaxed),
306 }
307}
308
309fn publish_mac_counters(counters: MacCounters) {
310 MAC_TX_FRAMES.store(counters.tx_frames, Ordering::Relaxed);
311 MAC_TX_ABANDONED.store(counters.tx_abandoned, Ordering::Relaxed);
312 MAC_RX_FRAMES.store(counters.rx_frames, Ordering::Relaxed);
313 MAC_RX_ACCEPTED.store(counters.rx_accepted, Ordering::Relaxed);
314 MAC_FORWARDED.store(counters.forwarded, Ordering::Relaxed);
315 MAC_FORWARD_CANCELLED.store(counters.forward_cancelled, Ordering::Relaxed);
316 MAC_FORWARD_DROPPED_POLICY.store(counters.forward_dropped_policy, Ordering::Relaxed);
317}
318
319/// The transmit power the radio was last configured with, in dBm, offset
320/// by 128 so the sentinel for "not yet configured" is a value no real
321/// setting occupies.
322static TX_POWER_DBM: AtomicU16 = AtomicU16::new(u16::MAX);
323
324/// The applied `PROP_PHY_TX_POWER`, or `None` before the first
325/// `Effect::ApplyRadio`.
326pub fn tx_power_dbm() -> Option<i8> {
327 match TX_POWER_DBM.load(Ordering::Relaxed) {
328 u16::MAX => None,
329 raw => Some((raw as i16 - 128) as i8),
330 }
331}
332
333/// Publish the transmit power the board just applied to the radio.
334pub fn set_tx_power_dbm(dbm: i8) {
335 TX_POWER_DBM.store((i16::from(dbm) + 128) as u16, Ordering::Relaxed);
336}
337
338/// The live device name, pushed by the board.
339///
340/// Pushed rather than pulled: the name lives behind a board-owned async
341/// mutex, and the profile paths that need it are called from places that
342/// cannot await one. The board writes it at boot and on every change,
343/// which is the same edge it already had to handle.
344static DEVICE_NAME: BlockingMutex<
345 CriticalSectionRawMutex,
346 RefCell<heapless::Vec<u8, MAX_DEVICE_NAME_LEN>>,
347> = BlockingMutex::new(RefCell::new(heapless::Vec::new()));
348
349/// Signalled internally by [`set_device_name`] so the Identity Request
350/// responder's profile name stays current.
351static NAME_CHANGED: Signal<NodeMutex, ()> = Signal::new();
352
353/// Publish the live device name to the node. Call at boot and whenever
354/// the session's device name changes.
355pub fn set_device_name(name: &[u8]) {
356 DEVICE_NAME.lock(|cell| {
357 let mut current = cell.borrow_mut();
358 current.clear();
359 let _ = current.extend_from_slice(&name[..name.len().min(MAX_DEVICE_NAME_LEN)]);
360 });
361 NAME_CHANGED.signal(());
362}
363
364/// Snapshot the live device name as a spec-capped (≤24-byte) Node Name
365/// identity option value, or `None` when unset/empty.
366fn profile_name() -> Option<alloc::string::String> {
367 DEVICE_NAME.lock(|cell| {
368 let current = cell.borrow();
369 core::str::from_utf8(¤t)
370 .ok()
371 .map(|name| truncate_utf8(name, 24))
372 .filter(|name| !name.is_empty())
373 .map(alloc::string::String::from)
374 })
375}
376
377/// Trim to at most `max` bytes without splitting a UTF-8 sequence.
378fn truncate_utf8(text: &str, max: usize) -> &str {
379 if text.len() <= max {
380 return text;
381 }
382 let mut end = max;
383 while !text.is_char_boundary(end) {
384 end -= 1;
385 }
386 &text[..end]
387}
388
389/// The advertised (role, capabilities) pair for the device identity.
390///
391/// Role and forwarding are separate dimensions, and this is where they
392/// meet without being conflated. Capabilities are **facts**: `REP` is set
393/// exactly when the node is actually forwarding, `MOB` exactly when the
394/// operator has said the device moves. The role is what the device
395/// *presents itself as*, which is configuration — an explicit
396/// `PROP_IDENT_ROLE` is advertised verbatim, so a mobile repeater and a
397/// fixed tracker are both expressible.
398///
399/// With no role configured the device derives one, which is what makes
400/// the default sensible rather than a lie: a forwarding node is a
401/// repeater, and anything else is a tracker.
402fn advertised_identity(
403 is_repeater: bool,
404 is_mobile: bool,
405 configured_role: Option<u8>,
406) -> (NodeRole, NodeCapabilities) {
407 let role = match configured_role {
408 Some(byte) => NodeRole::from_byte(byte),
409 None if is_repeater => NodeRole::Repeater,
410 None => NodeRole::Tracker,
411 };
412 let mut capabilities = NodeCapabilities::empty();
413 capabilities.set(NodeCapabilities::REPEATER, is_repeater);
414 capabilities.set(NodeCapabilities::MOBILE, is_mobile);
415 (role, capabilities)
416}
417
418/// The regions the node should advertise as supported, given a snapshot.
419///
420/// A device that is not forwarding makes no claim at all and omits the
421/// option (node-identity.md §Supported Regions); a forwarding device with
422/// an empty list forwards regardless of region, which is likewise not a
423/// claim about any particular region. Only a forwarding device with a
424/// configured list has something to say.
425fn advertised_regions(
426 snapshot: &DevDomainSnapshot,
427) -> Option<alloc::vec::Vec<alloc::string::String>> {
428 (snapshot.repeater_enabled && !snapshot.repeater_region_names.is_empty()).then(|| {
429 snapshot
430 .repeater_region_names
431 .iter()
432 // Validated as UTF-8 by the session that stored them.
433 .filter_map(|name| core::str::from_utf8(name).ok())
434 .map(alloc::string::String::from)
435 .collect()
436 })
437}
438
439/// Write the advertised position into a node identity profile.
440///
441/// The position comes from the device-domain mirror like everything else
442/// the profile says, so a fix, a host write, a boot restore and a
443/// `CMD_RST` all reach the identity by one path and none of them can
444/// disagree about what it advertises.
445///
446/// Both fields move together: an altitude without a position describes
447/// nothing, so one is carried only alongside the other.
448fn stamp_position(profile: &mut NodeIdentityProfile, snapshot: &DevDomainSnapshot) {
449 let location = (!snapshot.ident_location.is_empty())
450 .then(|| NodeLocation::from_bytes(&snapshot.ident_location));
451 profile.location = location;
452 profile.altitude_m = location.and(snapshot.ident_altitude_m);
453}
454
455// ─── Device-domain sync ──────────────────────────────────────────────────────
456
457/// Reconciles the node's MAC against each [`DevDomainSnapshot`]: joins
458/// newly provisioned channels, removes de-provisioned ones (dropping
459/// their replay state), and registers peers. Peer *removal* is not
460/// propagated — MAC registry entries carry no key material, so a stale
461/// entry is inert, and the registry is rebuilt from the live table at the
462/// next boot.
463pub async fn dev_sync_loop<CS: CounterStore + 'static>(
464 node: DeviceNode<CS>,
465 mac: DeviceNodeHandle<CS>,
466 node_key: [u8; 32],
467) {
468 // Channel keys currently applied to the MAC. Starts empty: the MAC is
469 // built bare at bring-up and every channel arrives through here.
470 let mut applied: heapless::Vec<[u8; 32], MAX_CHANNEL_KEYS> = heapless::Vec::new();
471 loop {
472 let snapshot = DEV_SYNC.wait().await;
473 // Published first: the administrator list gates whether an
474 // arriving request is heard at all, and its generation is what a
475 // Node Management cursor is validated against. Neither should lag
476 // a device-domain change by the length of a MAC reconciliation.
477 crate::admin_responder::publish_dev_domain(&snapshot);
478 // The gate is key equality, not mere presence. The session's live
479 // identity and the one this MAC was built around can legitimately
480 // differ — an installed `PROP_DEV_PRIVATE_KEY` takes effect at the
481 // next boot, and `CMD_CLEAR` + `CMD_RST` erases the stored key —
482 // and in every such case originating traffic would be signing as
483 // an identity the device no longer claims.
484 let matches_live = snapshot.dev_key == Some(node_key);
485 NODE_ACTIVE.store(matches_live, Ordering::Relaxed);
486 // Reconcile the forwarding switch, the forwarding policy, and the
487 // advertised role/capabilities. All are idempotent; the profile is
488 // refreshed unconditionally because the role and mobility
489 // properties can move without the forwarding flag.
490 mac.set_repeater_enabled(snapshot.repeater_enabled).await;
491 let regions: heapless::Vec<[u8; 2], 8> =
492 snapshot.repeater_region_codes.iter().copied().collect();
493 let stored = mac
494 .set_repeater_policy(
495 ®ions,
496 snapshot.repeater_default_region,
497 snapshot.repeater_min_rssi,
498 snapshot.repeater_min_snr,
499 )
500 .await;
501 if stored != regions.len() {
502 debug_log(format_args!(
503 "node dev-sync: repeater regions TRUNCATED {} of {}",
504 stored,
505 regions.len()
506 ));
507 }
508 let (role, capabilities) = advertised_identity(
509 snapshot.repeater_enabled,
510 snapshot.ident_mobile,
511 snapshot.ident_role,
512 );
513 let supported_regions = advertised_regions(&snapshot);
514 // The profile is rebuilt from scratch on every snapshot rather than
515 // patched, so the position has to be put back with everything else —
516 // otherwise any device-domain write would quietly drop the advertised
517 // location until the node next moved far enough to earn a new one.
518 let mut profile = NodeIdentityProfile::new(PublicKey(node_key), role, capabilities);
519 profile.name = profile_name();
520 profile.supported_regions = supported_regions;
521 // Identity option 3 dates each payload as it is built, so the
522 // profile carries the clock rather than a reading. A device
523 // that does not know the time omits the option, which is the
524 // same answer the default gives.
525 profile.clock = umsh_hal::wall_clock::now;
526 stamp_position(&mut profile, &snapshot);
527 // `PROP_DEV_DISCOVERABLE` gates only the *responder*. The profile
528 // stays installed either way because unsolicited advertisements are
529 // built from it, and the spec keeps those governed by advertisement
530 // policy rather than by discoverability.
531 if snapshot.discoverable {
532 node.enable_identity_responder(profile, default_respond_policy);
533 } else {
534 node.enable_identity_responder(profile, never_respond_policy);
535 }
536 // A repeater answers for its neighborhood; a node that forwards
537 // nothing has no peer repeaters to describe and stays quiet. Gated
538 // on forwarding rather than on discoverability, because the listing
539 // is about the mesh around this node, not about this node.
540 if snapshot.repeater_enabled {
541 node.enable_peer_repeaters_responder();
542 } else {
543 node.disable_peer_repeaters_responder();
544 }
545 let mut index = 0;
546 while index < applied.len() {
547 if snapshot.channel_keys.contains(&applied[index]) {
548 index += 1;
549 continue;
550 }
551 let key = applied.swap_remove(index);
552 let _ = node
553 .leave(&umsh_node::Channel::private(ChannelKey(key), ""))
554 .await;
555 debug_log(format_args!(
556 "node dev-sync: channel {:02x}{:02x}.. removed",
557 key[0], key[1]
558 ));
559 }
560 for key in snapshot.channel_keys.iter() {
561 if applied.contains(key) {
562 continue;
563 }
564 match node
565 .join(&umsh_node::Channel::private(ChannelKey(*key), ""))
566 .await
567 {
568 Ok(_) => {
569 let _ = applied.push(*key);
570 debug_log(format_args!(
571 "node dev-sync: channel {:02x}{:02x}.. joined",
572 key[0], key[1]
573 ));
574 }
575 Err(_) => debug_log(format_args!(
576 "node dev-sync: channel {:02x}{:02x}.. join FAILED",
577 key[0], key[1]
578 )),
579 }
580 }
581 // Registration is add-or-refresh; repeats are harmless. An
582 // administrator is registered alongside the peers: it is by
583 // definition a node this device must be able to unseal a request
584 // from and seal an answer to, and registering it is what lets
585 // both travel under the ordinary source hint.
586 for public_key in snapshot.peers.iter().chain(snapshot.admins.iter()) {
587 if node.peer(PublicKey(*public_key)).await.is_err() {
588 debug_log(format_args!(
589 "node dev-sync: peer {:02x}{:02x}.. register FAILED",
590 public_key[0], public_key[1]
591 ));
592 }
593 }
594 // Seed persisted RX replay boundaries for the registered peers (a
595 // repeat only refreshes each peer's initial boundary; live replay
596 // windows are untouched). Without this, a peer's replay floor
597 // would restart at zero after every power cycle.
598 if !snapshot.peers.is_empty() {
599 let _ = mac.load_all_persisted_rx_counters().await;
600 }
601 // Published last, once the profile an advertisement is built from
602 // is installed and `NODE_ACTIVE` reflects this snapshot. That
603 // ordering is the whole reason `advert_loop` waits on this rather
604 // than starting its own clock at bring-up.
605 ADVERT_POLICY.sender().send(AdvertPolicy {
606 advert_interval_s: snapshot.advert_interval_s,
607 beacon_interval_s: snapshot.beacon_interval_s,
608 startup_beacon: snapshot.startup_beacon,
609 });
610 debug_log(format_args!(
611 "node dev-sync: {} channels, {} peers, identity-matches-live={}",
612 snapshot.channel_keys.len(),
613 snapshot.peers.len(),
614 matches_live
615 ));
616 }
617}
618
619// ─── Beacon trigger input ────────────────────────────────────────────────────
620
621/// Why a beacon was requested. Carried through [`BEACON_TRIGGER`] so the
622/// send path never assumes a button.
623#[derive(Clone, Copy)]
624pub enum BeaconTrigger {
625 /// The board's primary-action button slot. Boards without one carry
626 /// the variant unused.
627 Button,
628 /// Emit a solicited advertisement — a broadcast carrying the signed
629 /// node identity payload — echoing `nonce` when set. Currently
630 /// unconstructed: the Identity Request responder that will drive it
631 /// (with a targeted unicast reply) is a follow-up; the generator is
632 /// kept for that pass.
633 Advertise { nonce: Option<u32> },
634 /// Emit an advertisement on the device's own schedule
635 /// (`PROP_ADVERT_INTERVAL`) or at bring-up.
636 ///
637 /// Distinct from [`Button`](Self::Button) because it goes out with no
638 /// flood budget: a scheduled advertisement is a standing statement
639 /// rather than an introduction, so it reaches the neighbours that can
640 /// hear the device and stops, and repeating it across the mesh every
641 /// interval would cost far more airtime than it is worth.
642 AutoAdvertise,
643 /// Emit a beacon — a broadcast with no payload at all — which
644 /// announces a path back to the device rather than who it is.
645 Beacon,
646}
647
648/// Beacon requests into the node. On a boot that skipped node bring-up
649/// the queue is never drained and requests are dropped at the `try_send`
650/// in [`request_beacon`], leaving the slot inert rather than blocking the
651/// caller.
652pub static BEACON_TRIGGER: Channel<NodeMutex, BeaconTrigger, 2> = Channel::new();
653
654/// Fire-and-forget beacon request. A full queue means a beacon (or
655/// advertisement) is already pending, so dropping the extra request loses
656/// nothing — bursts of Advertisement Requests coalesce here.
657pub fn request_beacon(trigger: BeaconTrigger) {
658 let _ = BEACON_TRIGGER.try_send(trigger);
659}
660
661/// Flood-hop budget on an unsolicited beacon.
662///
663/// A beacon exists to publish a path, so it has to travel far enough for
664/// there to be a path worth publishing.
665const BEACON_FLOOD_HOPS: u8 = 5;
666
667/// What the device announces without being asked, mirrored from the
668/// device domain's advertisement-policy properties.
669#[derive(Clone, Copy, PartialEq, Eq)]
670pub struct AdvertPolicy {
671 /// `PROP_ADVERT_INTERVAL`, in seconds. 0 disables.
672 pub advert_interval_s: u32,
673 /// `PROP_BEACON_INTERVAL`, in seconds. 0 disables.
674 pub beacon_interval_s: u32,
675 /// `PROP_STARTUP_BEACON`.
676 pub startup_beacon: bool,
677}
678
679/// The live advertisement policy.
680///
681/// A `Watch` for the same reasons [`crate::gnss`] uses one: [`advert_loop`]
682/// arrives after the first device-domain sync and must still see it, and it
683/// selects this against a timer, so the wait is dropped and rebuilt
684/// constantly and must not lose an edge it was cancelled on.
685///
686/// Publishing it from [`dev_sync_loop`] rather than from each board is
687/// what orders the startup beacon correctly: the first value can only
688/// appear once the identity profile has been built and `NODE_ACTIVE`
689/// settled, so nothing scheduled here can go out under a default profile.
690static ADVERT_POLICY: Watch<NodeMutex, AdvertPolicy, 1> = Watch::new();
691
692/// Board couplings the node cannot express itself.
693#[derive(Clone, Copy)]
694pub struct NodeHooks {
695 /// Mark a completed node transmit for a board's battery-level
696 /// estimator: voltage sampled near a transmission is sagged, not
697 /// resting OCV. Boards with no estimator pass a no-op.
698 pub note_external_load: fn(),
699 /// Confirmation feedback for a button-triggered beacon, fired when
700 /// the MAC *accepts* the send — a refusal (queue full, duty limiting)
701 /// leaves the slot silent. Boards with no indicator pass a no-op.
702 pub beacon_confirm: fn(),
703}
704
705impl Default for NodeHooks {
706 fn default() -> Self {
707 Self {
708 note_external_load: || {},
709 beacon_confirm: || {},
710 }
711 }
712}
713
714// ─── Loops ───────────────────────────────────────────────────────────────────
715
716/// Drives the device node's MAC pump. Never returns while healthy; an
717/// exit means the MAC hit an unrecoverable radio error, and rebooting
718/// through the panic handler beats silently losing the device identity.
719pub async fn pump_loop<CS: CounterStore + 'static>(
720 mut host: DeviceNodeHost<CS>,
721 mac: DeviceNodeHandle<CS>,
722 stats: Option<&'static StatsLedger>,
723) -> ! {
724 debug_log(format_args!("node pump: running"));
725 // What the MAC decides is the one part of the traffic picture the mux
726 // cannot see, so the ledger learns it here. Deltas, not absolutes:
727 // the ledger's cells belong to it, and a host's clear must not be
728 // overwritten by the next pass through this loop.
729 let mut mirrors = MacMirrors::new();
730 // `Host::run` is this loop without the republish. Spelling it out here
731 // is what lets the counters be refreshed on a schedule that already
732 // exists: one wake cycle has just completed, the coordinator's borrow
733 // is released, and nothing else had to be woken to notice.
734 loop {
735 if let Err(error) = host.pump_once().await {
736 debug_log(format_args!("node pump: EXITED error={error:?}"));
737 break;
738 }
739 let counters = mac.counters().await;
740 publish_mac_counters(counters);
741 if let Some(stats) = stats {
742 mirrors.advance(stats, counters);
743 }
744 }
745 panic!("device node host exited");
746}
747
748/// One [`Mirror`] per MAC tally the ledger carries.
749struct MacMirrors {
750 rx_accepted: Mirror,
751 forwarded: Mirror,
752 forward_dropped_policy: Mirror,
753 forward_cancelled: Mirror,
754}
755
756impl MacMirrors {
757 const fn new() -> Self {
758 Self {
759 rx_accepted: Mirror::new(),
760 forwarded: Mirror::new(),
761 forward_dropped_policy: Mirror::new(),
762 forward_cancelled: Mirror::new(),
763 }
764 }
765
766 fn advance(&mut self, stats: &StatsLedger, counters: MacCounters) {
767 self.rx_accepted
768 .advance(stats, Counter::RxAccepted, counters.rx_accepted);
769 self.forwarded
770 .advance(stats, Counter::Forwarded, counters.forwarded);
771 self.forward_dropped_policy.advance(
772 stats,
773 Counter::ForwardDropped,
774 counters.forward_dropped_policy,
775 );
776 self.forward_cancelled.advance(
777 stats,
778 Counter::ForwardCancelled,
779 counters.forward_cancelled,
780 );
781 }
782}
783
784/// Turns beacon triggers into node sends on the device identity: a signed
785/// advertisement either way — unsolicited for the button slot, echoing a
786/// nonce for an Advertisement Request.
787pub async fn beacon_loop<CS: CounterStore + 'static>(
788 node: DeviceNode<CS>,
789 identity: SoftwareIdentity,
790 hooks: NodeHooks,
791) {
792 loop {
793 let trigger = BEACON_TRIGGER.receive().await;
794 // A factory-cleared identity leaves the slot inert, exactly like
795 // an unprovisioned one.
796 if !NODE_ACTIVE.load(Ordering::Relaxed) {
797 continue;
798 }
799 match trigger {
800 BeaconTrigger::Button => {
801 // Currently a full signed identity rather than the empty
802 // trace-route beacon: a listener that has never seen this
803 // node learns nothing from a bare packet, and until the
804 // node is reachable by discovery the button is the only
805 // way to introduce it. Costs airtime a beacon does not.
806 if send_advertisement(&node, &identity, None, AdvertReach::Mesh).await {
807 (hooks.beacon_confirm)();
808 }
809 }
810 BeaconTrigger::Advertise { nonce } => {
811 let accepted = send_advertisement(&node, &identity, nonce, AdvertReach::Mesh).await;
812 debug_log(format_args!(
813 "node advert: nonce={nonce:?} accepted={accepted}"
814 ));
815 }
816 BeaconTrigger::AutoAdvertise => {
817 let accepted =
818 send_advertisement(&node, &identity, None, AdvertReach::Neighbours).await;
819 debug_log(format_args!("node advert: scheduled accepted={accepted}"));
820 }
821 BeaconTrigger::Beacon => {
822 let accepted = send_beacon(&node).await;
823 debug_log(format_args!("node beacon: accepted={accepted}"));
824 }
825 }
826 }
827}
828
829/// How far a scheduled or solicited advertisement is allowed to travel.
830#[derive(Clone, Copy, PartialEq, Eq)]
831enum AdvertReach {
832 /// Flood across the mesh under the default budget.
833 Mesh,
834 /// Direct neighbours only — no flood hops, no source route.
835 Neighbours,
836}
837
838/// Broadcast an empty beacon: no payload, so nothing identifies the sender
839/// beyond its source address, and the whole packet is the trace the
840/// options collect on the way out.
841async fn send_beacon<CS: CounterStore + 'static>(node: &DeviceNode<CS>) -> bool {
842 use umsh_node::Transport as _;
843 // Trace route to learn the path, trace signal to learn what that path
844 // costs — the pair is what makes a beacon worth more than the fact
845 // that the sender is alive.
846 let options = SendOptions::default()
847 .with_flood_hops(BEACON_FLOOD_HOPS)
848 .with_trace_route()
849 .with_trace_signal();
850 node.send_all(&[], &options).await.is_ok()
851}
852
853/// Build, sign, and broadcast a solicited advertisement: the node
854/// identity payload (role, live device name, echoed nonce) with the
855/// standalone EdDSA signature the spec prefers for broadcasts, typed as a
856/// NodeIdentity payload.
857async fn send_advertisement<CS: CounterStore + 'static>(
858 node: &DeviceNode<CS>,
859 identity: &SoftwareIdentity,
860 nonce: Option<u32>,
861 reach: AdvertReach,
862) -> bool {
863 use umsh_crypto::NodeIdentity as _;
864 use umsh_node::Transport as _;
865 // The node's own profile is the canonical statement of what this node
866 // is — kept current by `dev_sync_loop` and `identity_profile_loop` —
867 // so build the payload from it rather than assembling a second,
868 // drifting copy here.
869 let Some(payload) = node.with_identity_profile(|profile| profile.to_payload(nonce)) else {
870 return false;
871 };
872 // Payload-type byte + role/caps + name (≤26) + nonce (6) + 0xFF +
873 // 64-byte signature, plus whatever the region list fits in what is
874 // left. The signature's room is reserved up front rather than
875 // discovered afterwards, so the encoder can drop trailing regions to
876 // fit instead of framing a payload that then has nowhere to be signed.
877 const SIGNATURE_LEN: usize = 64;
878 let mut buf = [0u8; 192];
879 buf[0] = umsh_core::PayloadType::NodeIdentity as u8;
880 let signable = buf.len() - 1 - SIGNATURE_LEN;
881 let Ok(body_len) = payload.encode_for_signing(&mut buf[1..1 + signable]) else {
882 return false;
883 };
884 let mut len = 1 + body_len;
885 // The signature covers ROLE through the 0xFF terminator — the
886 // payload-type byte stays outside the signed range.
887 let Ok(signature) = identity.sign(&buf[1..len]).await else {
888 return false;
889 };
890 buf[len..len + SIGNATURE_LEN].copy_from_slice(&signature);
891 len += SIGNATURE_LEN;
892 // Full source, not a hint: the bundle's detached signature is only
893 // checkable against the sender's public key, and a broadcast carries no
894 // MIC to authenticate it otherwise. A hint-only advertisement is
895 // unverifiable by anyone who does not already hold the key, which is
896 // exactly the audience an advertisement is for.
897 let options = SendOptions::default().with_full_source();
898 let options = match reach {
899 // Trace route for the same reason a beacon carries one, and trace
900 // signal for the same reason again: the two are read as one list,
901 // and a route with no signal beside it cannot be ranked against
902 // the next one to arrive.
903 AdvertReach::Mesh => options.with_trace_route().with_trace_signal(),
904 // No flood budget and no trace: a scheduled advertisement is
905 // already the largest packet this node originates, and the path
906 // back to it is what the beacon interval is for.
907 AdvertReach::Neighbours => options.no_flood(),
908 };
909 node.send_all(&buf[..len], &options).await.is_ok()
910}
911
912/// Emits the device's unsolicited announcements: one beacon at bring-up
913/// under `PROP_STARTUP_BEACON`, then whatever `PROP_ADVERT_INTERVAL` and
914/// `PROP_BEACON_INTERVAL` ask for.
915///
916/// The two intervals run independently rather than sharing a period. They
917/// announce different things at very different costs — a beacon is a
918/// path, an advertisement is a signed identity — so a mesh normally wants
919/// the cheap one far more often than the expensive one, and one knob
920/// could not express that.
921///
922/// Every period is scattered by up to [`ANNOUNCE_JITTER_SHIFT`] of the
923/// interval. Two nodes configured alike and switched on together would
924/// otherwise stay in step indefinitely, colliding on the air every period
925/// and — worse — colliding again on each retry, since a shared schedule
926/// makes them contend from the same starting instant every time. CAD and
927/// backoff settle the individual collision; the scatter is what keeps the
928/// mesh from having to.
929///
930/// The startup beacon itself is not delayed. Devices do not power on in
931/// unison, so bring-up is already dispersed by whatever staggered them,
932/// and a node that has just come up is the one whose neighbours most need
933/// to hear from it.
934pub async fn advert_loop<CS: CounterStore + 'static>(mac: DeviceNodeHandle<CS>) {
935 let Some(mut policy_rx) = ADVERT_POLICY.receiver() else {
936 debug_assert!(false, "node: advert_loop is single-caller");
937 return;
938 };
939 // The first value doubles as the go-ahead: it cannot arrive until the
940 // device domain has been synced, which is what makes the startup
941 // beacon carry a real node rather than a half-built one.
942 let mut policy = policy_rx.changed().await;
943 if policy.startup_beacon {
944 request_beacon(BeaconTrigger::Beacon);
945 }
946
947 let mut next_advert = schedule(&mac, policy.advert_interval_s).await;
948 let mut next_beacon = schedule(&mac, policy.beacon_interval_s).await;
949 loop {
950 // A disabled interval has no deadline, so the arm is simply the
951 // other one; with both off there is nothing to wait for but a
952 // change of policy.
953 let due = match (next_advert, next_beacon) {
954 (Some(advert), Some(beacon)) => Some(advert.min(beacon)),
955 (deadline, None) | (None, deadline) => deadline,
956 };
957 match due {
958 Some(deadline) => {
959 match select(Timer::at(deadline), policy_rx.changed()).await {
960 Either::First(()) => {
961 // Both fire when they fall due together: one
962 // announces the path, the other who is on it.
963 if next_advert == Some(deadline) {
964 request_beacon(BeaconTrigger::AutoAdvertise);
965 next_advert = schedule(&mac, policy.advert_interval_s).await;
966 }
967 if next_beacon == Some(deadline) {
968 request_beacon(BeaconTrigger::Beacon);
969 next_beacon = schedule(&mac, policy.beacon_interval_s).await;
970 }
971 }
972 Either::Second(updated) => {
973 // A rewritten interval restarts from now. Keeping
974 // the old deadline would make a host that shortens
975 // the period wait out the longer one first.
976 if updated.advert_interval_s != policy.advert_interval_s {
977 next_advert = schedule(&mac, updated.advert_interval_s).await;
978 }
979 if updated.beacon_interval_s != policy.beacon_interval_s {
980 next_beacon = schedule(&mac, updated.beacon_interval_s).await;
981 }
982 policy = updated;
983 }
984 }
985 }
986 None => {
987 let updated = policy_rx.changed().await;
988 next_advert = schedule(&mac, updated.advert_interval_s).await;
989 next_beacon = schedule(&mac, updated.beacon_interval_s).await;
990 policy = updated;
991 }
992 }
993 }
994}
995
996/// How far past its interval a period may be scattered, as a right shift
997/// of the interval. Two is a quarter.
998const ANNOUNCE_JITTER_SHIFT: u32 = 2;
999
1000/// When an interval next falls due, or `None` when it is switched off.
1001///
1002/// The wait is the whole interval plus a uniform draw between zero and a
1003/// quarter of it. The scatter only ever *delays*, which is what lets
1004/// `MIN_AUTO_ANNOUNCE_INTERVAL_S` be an absolute floor: no configuration
1005/// and no draw can put an unsolicited announcement on the air sooner than
1006/// the interval a host asked for.
1007///
1008/// The randomness is the MAC's ChaCha20 generator, seeded at boot from the
1009/// board's hardware TRNG. The node has one generator and this borrows it
1010/// rather than growing a second — a scheduling scatter does not need
1011/// unpredictability, but a node that keeps a weak generator around for the
1012/// undemanding cases eventually uses it for a demanding one.
1013async fn schedule<CS: CounterStore + 'static>(
1014 mac: &DeviceNodeHandle<CS>,
1015 interval_s: u32,
1016) -> Option<Instant> {
1017 if interval_s == 0 {
1018 return None;
1019 }
1020 let mut draw = [0u8; 4];
1021 mac.fill_random(&mut draw).await;
1022 let delay_s = jittered_delay_s(interval_s, u32::from_le_bytes(draw));
1023 Some(Instant::now() + Duration::from_secs(delay_s))
1024}
1025
1026/// The seconds to wait for one period, given one random draw.
1027///
1028/// Split from [`schedule`] so the arithmetic can be checked without an
1029/// executor or a MAC: what matters about it is the bound it never
1030/// crosses, and that is a property of the numbers alone.
1031fn jittered_delay_s(interval_s: u32, draw: u32) -> u64 {
1032 // Fixed-point scaling rather than a modulus: it lands in `0..=spread`
1033 // for any spread, without a division or a rejection loop, and without
1034 // the modulo bias a `%` would leave at the top of the range.
1035 let spread = u64::from(interval_s >> ANNOUNCE_JITTER_SHIFT);
1036 let jitter = (u64::from(draw) * (spread + 1)) >> 32;
1037 u64::from(interval_s) + jitter
1038}
1039
1040/// Keeps the Identity Request responder's profile name synced to the live
1041/// device name. The responder builds replies synchronously and cannot
1042/// await, so the current name is pushed in here on each change rather
1043/// than read at reply time.
1044pub async fn identity_profile_loop<CS: CounterStore + 'static>(node: DeviceNode<CS>) {
1045 loop {
1046 NAME_CHANGED.wait().await;
1047 let name = profile_name();
1048 node.update_identity_profile(move |profile| profile.name = name);
1049 }
1050}
1051
1052/// A `PROP_IDENT` read in flight.
1053///
1054/// A request/response signal pair rather than a shared handle: the node
1055/// is single-executor `Rc`/`RefCell` state and cannot live in a `static`
1056/// at all, and this is the same shape the pairing-PIN round trip already
1057/// uses.
1058static QUIESCE_REQUEST: Signal<NodeMutex, ()> = Signal::new();
1059static QUIESCE_DONE: Signal<NodeMutex, ()> = Signal::new();
1060
1061/// How long a pending transmission may hold up a reboot.
1062///
1063/// Long enough for the MAC acknowledgment of the frame that carried the
1064/// reboot command to win one CAD-free transmit slot even at the slowest
1065/// spreading factor; short enough that a jammed channel cannot turn a
1066/// reboot command into a device that never reboots.
1067const QUIESCE_TX_DEADLINE: Duration = Duration::from_secs(3);
1068
1069/// Settle the mesh before a deliberate reset: let queued transmissions
1070/// finish, then force every frame-counter boundary to flash.
1071///
1072/// A reboot commanded over the mesh has two obligations the reset itself
1073/// would otherwise destroy. The MAC acknowledgment of the frame that
1074/// carried the command is sitting in the TX queue — a reset-class command
1075/// is answered by that acknowledgment and nothing else, so resetting
1076/// first leaves the administrator retrying into silence. And the RX
1077/// replay boundary that admitted the command is normally persisted only
1078/// every persist-block of frames — resetting inside the block re-opens
1079/// the window, and the administrator's retries of the very same command
1080/// are then accepted again after boot, one reboot per retry.
1081///
1082/// The TX drain is bounded and best-effort — an unlucky channel loses the
1083/// acknowledgment, which the sender's retry ladder is built for. The
1084/// counter flush is not optional and happens last, so any frame accepted
1085/// while the drain waited is inside the persisted boundary too.
1086///
1087/// A node that never came up has accepted nothing and holds no
1088/// acknowledgment; the call returns immediately.
1089pub async fn quiesce_for_reboot() {
1090 if !NODE_UP.load(Ordering::Relaxed) {
1091 return;
1092 }
1093 QUIESCE_DONE.reset();
1094 QUIESCE_REQUEST.signal(());
1095 QUIESCE_DONE.wait().await;
1096}
1097
1098/// Answers [`QUIESCE_REQUEST`]; see [`quiesce_for_reboot`].
1099pub async fn reboot_quiesce_loop<CS: CounterStore + 'static>(mac: DeviceNodeHandle<CS>) {
1100 loop {
1101 QUIESCE_REQUEST.wait().await;
1102 let deadline = Instant::now() + QUIESCE_TX_DEADLINE;
1103 while !mac.tx_queue_empty().await {
1104 if Instant::now() >= deadline {
1105 debug_log(format_args!("reboot quiesce: TX drain deadline expired"));
1106 break;
1107 }
1108 Timer::after_millis(20).await;
1109 }
1110 if mac.flush_frame_counters().await.is_err() {
1111 // Nothing to do but say so: the reboot must happen either
1112 // way, and the sender's retries will at worst be absorbed by
1113 // the (stale) persisted boundary plus the dedup cache.
1114 debug_log(format_args!("reboot quiesce: counter flush FAILED"));
1115 }
1116 QUIESCE_DONE.signal(());
1117 }
1118}
1119
1120static IDENT_REQUEST: Signal<NodeMutex, ()> = Signal::new();
1121static IDENT_RESPONSE: Signal<NodeMutex, Option<IdentityBlob>> = Signal::new();
1122
1123/// A complete signed node-identity blob: the canonical unsigned encoding
1124/// followed by its 64-octet detached signature.
1125type IdentityBlob = heapless::Vec<u8, 320>;
1126
1127/// Build and sign this node's identity blob into `out`, returning its
1128/// length.
1129///
1130/// This is the standalone framing of the same statement the Identity
1131/// Request responder makes — same profile, same builder — differing only
1132/// in that it carries no request nonce and is authenticated by the
1133/// signature rather than by an enclosing unicast.
1134pub async fn sign_identity_blob(out: &mut [u8]) -> Option<usize> {
1135 if !NODE_UP.load(Ordering::Relaxed) {
1136 return None;
1137 }
1138 IDENT_RESPONSE.reset();
1139 IDENT_REQUEST.signal(());
1140 let blob = IDENT_RESPONSE.wait().await?;
1141 if blob.len() > out.len() {
1142 return None;
1143 }
1144 out[..blob.len()].copy_from_slice(&blob);
1145 Some(blob.len())
1146}
1147
1148/// Answers [`IDENT_REQUEST`] with the node's current signed identity.
1149pub async fn identity_blob_loop<CS: CounterStore + 'static>(
1150 node: DeviceNode<CS>,
1151 identity: SoftwareIdentity,
1152) {
1153 use umsh_crypto::NodeIdentity as _;
1154 loop {
1155 IDENT_REQUEST.wait().await;
1156 let mut blob = IdentityBlob::new();
1157 let _ = blob.resize_default(blob.capacity());
1158 // The node's own profile is the canonical statement of what this
1159 // node is; building from it is what keeps the local-control
1160 // framing and the over-the-air framing from drifting apart.
1161 let signed = async {
1162 let payload = node.with_identity_profile(|profile| profile.to_payload(None))?;
1163 let body = payload.encode_for_signing(&mut blob).ok()?;
1164 let signature = identity.sign(blob.get(..body)?).await.ok()?;
1165 blob.get_mut(body..body + 64)?.copy_from_slice(&signature);
1166 Some(body + 64)
1167 }
1168 .await;
1169 IDENT_RESPONSE.signal(signed.map(|len| {
1170 blob.truncate(len);
1171 blob
1172 }));
1173 }
1174}
1175
1176// ─── Bring-up ────────────────────────────────────────────────────────────────
1177
1178/// Everything bring-up produced, for the board to spawn its task shims
1179/// around. Embassy tasks cannot be generic, so the spawning itself stays
1180/// board-side.
1181pub struct DeviceNodeParts<CS: CounterStore + 'static> {
1182 pub host: DeviceNodeHost<CS>,
1183 pub node: DeviceNode<CS>,
1184 pub mac: DeviceNodeHandle<CS>,
1185 /// The public key the MAC was actually built around, for the
1186 /// device-domain sync gate.
1187 pub node_key: [u8; 32],
1188}
1189
1190/// Construct the MAC around the device identity and wire up the node.
1191/// Call at most once. The identity is never absent — boot generates and
1192/// persists one when the journal is empty — so there is no
1193/// "unprovisioned" path here.
1194///
1195/// `t_frame_ms` is the worst-case airtime hint for the MAC scheduler.
1196/// The caller spawns the loops and must do so promptly: `NODE_UP` is set
1197/// here, so a `PROP_IDENT` read arriving between this returning and the
1198/// spawns would wait on the signal.
1199pub async fn bring_up<CS: CounterStore + 'static>(
1200 mac_arena: &'static mut DeviceNodeMacArena<CS>,
1201 identity_secret: &[u8; 32],
1202 node_seed: [u8; 32],
1203 t_frame_ms: u32,
1204 counters: CS,
1205 duty: &'static umsh_ulcp_device::DutyLedger,
1206 hooks: NodeHooks,
1207) -> DeviceNodeParts<CS> {
1208 // The Mac is ~37 KiB. `MaybeUninit::write` lets the compiler
1209 // construct it in place inside the arena, the same elision
1210 // `StaticCell::init_with` leaned on before the arena became
1211 // placeable; building it as a stack local transits the stack once
1212 // per move in the chain — hardware-diagnosed on the nRF images as
1213 // boot HardFaults (INVSTATE jumps to 0) and a smashed allocator when
1214 // the temporaries blew through the stack budget. Keep the
1215 // construction a single in-place expression.
1216 let mac_cell: &'static AsyncRefCell<DeviceNodeMac<CS>> =
1217 mac_arena.write(AsyncRefCell::new(DeviceNodeMac::new(
1218 DutyGatedRadio::with_load_hook(
1219 umsh_radio_loraphy::LoraphyRadio::new(&NODE_CH, t_frame_ms),
1220 duty,
1221 EmbassyClock,
1222 hooks.note_external_load,
1223 ),
1224 CryptoEngine::new(SoftwareAes, SoftwareSha256),
1225 EmbassyClock,
1226 NodeRng::from_seed(node_seed),
1227 counters,
1228 RepeaterConfig::default(),
1229 OperatingPolicy::default(),
1230 )));
1231 debug_log(format_args!("node bring-up: mac cell ready"));
1232 let identity = SoftwareIdentity::from_secret_bytes(identity_secret);
1233 // Retained for the device-domain sync gate, which compares the
1234 // session's live PROP_DEV_KEY against the key this MAC actually holds.
1235 let node_key = umsh_crypto::NodeIdentity::public_key(&identity).0;
1236 NODE_KEY.lock(|key| key.replace(Some(node_key)));
1237 let identity_id = mac_cell
1238 .try_borrow_mut()
1239 .expect("mac cell is unshared during bring-up")
1240 .add_identity(identity)
1241 .unwrap_or_else(|_| panic!("device node identity"));
1242 // Seed the identity's TX frame counter from the persisted boundary so
1243 // secured sends can never reuse counter space from a previous boot.
1244 // With nothing persisted the random initial counter stands.
1245 match MacHandle::new(mac_cell)
1246 .load_persisted_counter(identity_id)
1247 .await
1248 {
1249 Ok(counter) => debug_log(format_args!("node bring-up: tx counter {counter}")),
1250 Err(_) => debug_log(format_args!("node bring-up: tx counter load FAILED")),
1251 }
1252
1253 let mut host: DeviceNodeHost<CS> = Host::new(MacHandle::new(mac_cell));
1254 let node = host.add_node(identity_id);
1255 // Permanent observability tap: every packet the node processes is one
1256 // debug line. This is the device-domain acceptance instrument
1257 // (multicast on a provisioned device channel shows up here) and it
1258 // never consumes the packet. The subscription is leaked because the
1259 // node lives for the rest of the boot.
1260 core::mem::forget(node.on_receive(|packet| {
1261 let channel = packet
1262 .channel()
1263 .map(|info| u16::from_be_bytes(info.id().0))
1264 .unwrap_or(0);
1265 debug_log(format_args!(
1266 "node rx: {:?} ch={:04x} len={} auth={}",
1267 packet.packet_family(),
1268 channel,
1269 packet.payload().len(),
1270 packet.source_authenticated(),
1271 ));
1272 false
1273 }));
1274 // Node Management Requests. The tap only decides whether the sender
1275 // is entitled to be heard and queues what is; the exchange itself is
1276 // run by `admin_responder::responder_loop`, which the board spawns.
1277 // Never consumes: this is one more observer of every packet.
1278 core::mem::forget(node.on_receive(|packet| {
1279 crate::admin_responder::admit(packet);
1280 false
1281 }));
1282 // Identity Request observability tap. The actual reply is produced by
1283 // the built-in responder enabled below (a targeted unicast identity,
1284 // echoing any NONCE); this handler only logs, and never consumes.
1285 core::mem::forget(node.on_mac_command(|from, command| {
1286 if let umsh_node::OwnedMacCommand::IdentityRequest { options } = command {
1287 let nonce = umsh_node::mac_command::IdentityRequestFilters::new(options)
1288 .nonce()
1289 .ok()
1290 .flatten();
1291 debug_log(format_args!(
1292 "node identity-request: from {:02x}{:02x}.. nonce={:?}",
1293 from.0[0], from.0[1], nonce
1294 ));
1295 }
1296 }));
1297 // Enable the built-in Identity Request responder. Role and
1298 // capabilities start derived and are corrected by the first
1299 // device-domain sync; the name tracks the live device name via
1300 // `identity_profile_loop`. The default policy answers every request
1301 // whose filters select us, including our full source key when the
1302 // request wasn't authenticated to us. Replies are authenticated
1303 // unicast — never signed, never a broadcast fallback: a request whose
1304 // source can't be resolved to a key is dropped by the MAC before the
1305 // responder runs.
1306 {
1307 use umsh_crypto::NodeIdentity as _;
1308 let public_key = *SoftwareIdentity::from_secret_bytes(identity_secret).public_key();
1309 let mut profile =
1310 NodeIdentityProfile::new(public_key, NodeRole::Tracker, NodeCapabilities::empty());
1311 profile.name = profile_name();
1312 node.enable_identity_responder_default(profile);
1313 }
1314 // Let the node answer a brand-new requester that supplied its full
1315 // 32-byte source key: the MAC auto-registers it transiently
1316 // (LRU-evictable, never pinned) so the pairwise reply can be sealed.
1317 // Repeaters specifically must be able to respond this way.
1318 MacHandle::new(mac_cell)
1319 .set_auto_register_full_key_peers(true)
1320 .await;
1321 debug_log(format_args!("node bring-up: host ready"));
1322 NODE_UP.store(true, Ordering::Relaxed);
1323 DeviceNodeParts {
1324 host,
1325 node: node.clone(),
1326 mac: MacHandle::new(mac_cell),
1327 node_key,
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use umsh_ulcp::ids::{MAX_AUTO_ANNOUNCE_INTERVAL_S, MIN_AUTO_ANNOUNCE_INTERVAL_S};
1335
1336 /// The property the whole scheme rests on: scatter delays a period and
1337 /// never brings it forward, so a configured interval is the shortest
1338 /// gap between two unsolicited announcements no matter what is drawn.
1339 /// Without this the protocol's floor would not be a floor.
1340 #[test]
1341 fn scatter_only_ever_delays_a_period() {
1342 for interval in [
1343 MIN_AUTO_ANNOUNCE_INTERVAL_S,
1344 3_600,
1345 14_400,
1346 MAX_AUTO_ANNOUNCE_INTERVAL_S,
1347 ] {
1348 for draw in [0, 1, u32::MAX / 3, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1349 let delay = jittered_delay_s(interval, draw);
1350 assert!(
1351 delay >= u64::from(interval),
1352 "interval {interval} draw {draw} came early at {delay}"
1353 );
1354 assert!(
1355 delay <= u64::from(interval) + u64::from(interval / 4),
1356 "interval {interval} draw {draw} ran long at {delay}"
1357 );
1358 }
1359 }
1360 }
1361
1362 /// Both ends of the draw are reachable, so the scatter actually
1363 /// spreads rather than clustering at one end of its range.
1364 #[test]
1365 fn the_scatter_spans_a_quarter_of_the_interval() {
1366 let interval = 14_400;
1367 assert_eq!(jittered_delay_s(interval, 0), u64::from(interval));
1368 assert_eq!(
1369 jittered_delay_s(interval, u32::MAX),
1370 u64::from(interval) + u64::from(interval / 4)
1371 );
1372 // Midway through the draw is midway through the scatter.
1373 assert_eq!(
1374 jittered_delay_s(interval, u32::MAX / 2),
1375 u64::from(interval) + u64::from(interval / 8)
1376 );
1377 }
1378}