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