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