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(&current)
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                &regions,
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}