umsh_ulcp_runtime/
driver.rs

1//! The board-agnostic ULCP session driver (Phase 5, increment C).
2//!
3//! This is the extraction of the nRF firmware's `device_task` select loop, its
4//! `apply_effect` radio-effect dispatcher, and the `Emitter` frame stager —
5//! the one copy of the session-driving logic shared by every device
6//! firmware (T-Echo, T-1000E, Heltec V3). The board personalities that used
7//! to be `cfg(feature = "t1000e")` forks inside the loop are expressed as
8//! [`DeviceEnv`] hooks with no-op defaults, so a new board supplies exactly the
9//! couplings it has and nothing else.
10//!
11//! The split of responsibilities:
12//!
13//! - **This module** owns the protocol loop: transport arbitration, frame
14//!   handling, radio RX/TX-completion processing, every deferred `Effect`
15//!   arm (save/clear/wipe/provision/PIN/RSSI/battery/drain), asynchronous
16//!   property publication ([`DeviceEnv::battery_event`]), and the
17//!   device-domain mirror.
18//! - **The board** owns the edges: transport tasks feeding [`InEvent`]s and
19//!   draining [`TransportChannels`], the radio runner + mux serving the
20//!   session's virtual [`Channels`] bundle, and an [`DeviceEnv`] implementation
21//!   wiring persistence, entropy, pairing, and indicators to its hardware.
22
23use core::sync::atomic::{AtomicU32, Ordering};
24
25use embassy_futures::select::{Either, Either4, select, select4};
26use embassy_sync::blocking_mutex::raw::RawMutex;
27use embassy_sync::channel::Channel;
28use embassy_time::{Instant, Timer};
29
30use umsh_crypto::software::SoftwareIdentity;
31use umsh_crypto::{AesProvider, NodeIdentity as _, Sha256Provider};
32use umsh_journal_store::proto;
33use umsh_radio_loraphy::{
34    CadPolicy, Channels, DeviceControl, DeviceSettings, MAX_PAYLOAD, RxFrame, TxRequest,
35    bandwidth_from_hz, coding_rate_from_denom, spreading_factor_from_u8,
36};
37use umsh_ulcp_device::{
38    Effect, IdentitySource, MAX_CHANNEL_KEYS, MAX_DEV_PEERS, MAX_REPEATER_REGIONS, SNAPSHOT_MAX,
39    SavedStatus, Session, TxOutcome, TxPower,
40};
41
42use crate::transport_policy::{SessionArbitration, Transport};
43
44/// Derive a device identity's public key and its persisted record from a
45/// raw Ed25519 secret.
46///
47/// Shared by first-boot generation and `Effect::ProvisionIdentity` so the
48/// two can never disagree about the derivation or the record layout.
49///
50/// The caller supplies `secret` and owns the question this function
51/// cannot answer: it **MUST** come from a cryptographic RNG with real
52/// entropy behind it. On the nRF boards that is the hardware TRNG with
53/// bias correction enabled; on Espressif it is `EspCryptoRng`, which
54/// refuses to exist unless the RF noise source is live.
55pub fn device_identity_record(secret: &[u8; 32]) -> ([u8; 32], [u8; proto::IDENTITY_PAYLOAD_LEN]) {
56    let public_key = SoftwareIdentity::from_secret_bytes(secret).public_key().0;
57    (public_key, proto::encode_identity(secret, &public_key))
58}
59
60/// How many older snapshot generations boot will try after the newest
61/// one is rejected.
62///
63/// Bounded deliberately. Corruption is expected to affect one record, so
64/// a handful of generations covers it; a payload that is *systematically*
65/// undecodable is a firmware bug, and walking the whole journal for it on
66/// every boot would only delay booting bare and reporting the fact.
67pub const SNAPSHOT_FALLBACK_LIMIT: usize = 4;
68
69/// Buffer the driver hands `DeviceEnv::sign_identity`: a node-identity
70/// payload plus its 64-octet detached signature, with room for the
71/// descriptive options.
72pub const IDENTITY_BLOB_MAX: usize = 320;
73
74/// Largest raw ULCP frame accepted from a transport.
75pub const FRAME_IN_MAX: usize = 300;
76/// Largest ULCP frame the session emits (CMD_STR_RECV around a
77/// full-MTU payload).
78pub const FRAME_OUT_MAX: usize = 300;
79
80/// One raw ULCP frame moving through the driver.
81pub type FrameBuf = heapless::Vec<u8, FRAME_IN_MAX>;
82
83/// Framing-free receive path and connection edges into the driver.
84pub enum InEvent {
85    Attached(Transport),
86    Detached(Transport),
87    Frame(Transport, FrameBuf),
88    /// Someone cancelled a running locate alert at the device — the
89    /// button press of whoever found the radio. Ignored when no alert is
90    /// running, so a board may report the press unconditionally.
91    CancelAlert,
92    /// The receiver switch was flipped at the device, on a board that
93    /// offers GNSS as a user-facing control. Ignored without `CAP_GNSS`,
94    /// so a board may report the press unconditionally.
95    ToggleGnss,
96}
97
98/// The inbound event channel the board's tasks feed: every transport
99/// task, and whatever owns the buttons on a board with `CAP_ALERT`.
100pub type InputChannel<M> = Channel<M, InEvent, 8>;
101
102/// One raw ULCP frame in a transport output queue, stamped with the
103/// session generation that produced it so a displaced session's frames
104/// are dropped at the transport edge (`transport_policy::generation_checked`).
105pub struct OutFrame {
106    pub generation: u32,
107    pub frame: FrameBuf,
108}
109
110/// The per-transport outbound frame queues, drained by the board's
111/// transport output tasks. `wired` is the physical-possession transport
112/// (USB-CDC or UART), `ble` the bonded GATT transport — the same pairing
113/// `transport_policy::Transport` names.
114pub struct TransportChannels<M: RawMutex> {
115    pub wired: Channel<M, OutFrame, 4>,
116    pub ble: Channel<M, OutFrame, 4>,
117}
118
119impl<M: RawMutex> TransportChannels<M> {
120    pub const fn new() -> Self {
121        Self {
122            wired: Channel::new(),
123            ble: Channel::new(),
124        }
125    }
126
127    fn for_transport(&self, transport: Transport) -> &Channel<M, OutFrame, 4> {
128        match transport {
129            Transport::Usb => &self.wired,
130            Transport::Ble => &self.ble,
131        }
132    }
133}
134
135/// The session's device-domain tables, mirrored to the board's device
136/// node whenever their generation moves (device-node plan increment 3).
137pub struct DevDomainSnapshot {
138    pub channel_keys: heapless::Vec<[u8; 32], MAX_CHANNEL_KEYS>,
139    pub peers: heapless::Vec<[u8; 32], MAX_DEV_PEERS>,
140    /// The session's live `PROP_DEV_KEY`.
141    ///
142    /// The device node compares this against the key its MAC was built
143    /// with rather than merely checking that *some* identity exists. The
144    /// two can disagree — a newly installed `PROP_DEV_PRIVATE_KEY` takes
145    /// effect at the next boot, and `CMD_CLEAR` + `CMD_RST` erases the
146    /// stored one — and in every such case the running node is no longer
147    /// the identity the session describes and must stop originating
148    /// traffic under it.
149    pub dev_key: Option<[u8; 32]>,
150    /// `PROP_MAC_REPEATER_ENABLED`: whether the device node should
151    /// forward overheard routable frames. Advertised as the `REP`
152    /// capability bit — a fact about what the node does, not a choice
153    /// about what it calls itself.
154    pub repeater_enabled: bool,
155    /// `PROP_MAC_REPEATER_REGIONS`: the flood-forwarding region filter,
156    /// as concatenated 2-octet codes. Empty imposes no restriction.
157    ///
158    /// Carried in wire order so the device node can both configure the
159    /// MAC's region filter and hand the same bytes to the Supported
160    /// Regions identity option without reshaping either.
161    pub repeater_regions: heapless::Vec<u8, { MAX_REPEATER_REGIONS * 2 }>,
162    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code the node inserts
163    /// into an untagged flood packet, or `None` to never tag.
164    pub repeater_default_region: Option<[u8; 2]>,
165    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum RSSI in dBm to
166    /// flood-forward, or `None` for no threshold.
167    pub repeater_min_rssi: Option<i16>,
168    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum SNR in whole dB to
169    /// flood-forward, or `None` for no threshold.
170    pub repeater_min_snr: Option<i8>,
171    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to
172    /// derive it from the forwarding state.
173    pub ident_role: Option<u8>,
174    /// `PROP_IDENT_MOBILE`: whether to advertise the `MOB` capability
175    /// bit.
176    pub ident_mobile: bool,
177    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
178    /// Identity Requests.
179    pub discoverable: bool,
180    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited
181    /// advertisements, 0 for none.
182    pub advert_interval_s: u32,
183    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
184    /// none.
185    pub beacon_interval_s: u32,
186    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
187    pub startup_beacon: bool,
188    /// `PROP_TZ_OFFSET`: minutes east of UTC, for whatever renders a
189    /// local time.
190    pub tz_offset_min: i16,
191    /// `PROP_GNSS_ENABLED`: whether the receiver should be powered.
192    /// Always false on a board without `CAP_GNSS`.
193    ///
194    /// Carried here rather than as an effect of its own so that a host
195    /// write, a boot restore, and a `CMD_RST` all reach the receiver by
196    /// the same path — the mirror is published whenever the device domain
197    /// moves, which is exactly the set of moments this can change.
198    pub gnss_enabled: bool,
199    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
200    /// node identity's location.
201    pub gnss_ident_update: bool,
202    /// `PROP_GNSS_IDENT_PRECISION`: how far the advertised location is
203    /// clamped down from the fix.
204    pub gnss_ident_precision: u8,
205    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
206    /// wall clock.
207    pub gnss_time_trust: bool,
208}
209
210/// A device-initiated property publication, yielded by
211/// [`DeviceEnv::publish_event`].
212///
213/// The driver has exactly one select arm for everything the board pushes
214/// unasked, because a hook per property would need one `&mut self` borrow
215/// per arm and no two of those can coexist. One arm, one enum, and the
216/// board decides internally which of its sources woke it.
217pub enum PublishEvent {
218    /// An unsolicited `PROP_BATTERY`.
219    Battery(umsh_ulcp::battery::BatteryStatus),
220    /// An unsolicited `PROP_TIME`; `None` is a clock that has gone back
221    /// to not knowing what time it is.
222    Time(Option<u32>),
223    /// An unsolicited positioning property, named by its key, encoded
224    /// from the accompanying snapshot.
225    Gnss(u32, umsh_ulcp::gnss::GnssSnapshot),
226}
227
228/// Board couplings of the session driver. Everything the loop needs from
229/// the platform, expressed as one trait so the driver itself stays free
230/// of HAL types and `cfg` board forks. Hooks a board doesn't have keep
231/// their no-op defaults (e.g. only the T-1000E implements the attention
232/// indicator and transmit-load hooks today).
233// Single-executor embedded consumers; `Send` futures are irrelevant here,
234// same as the embassy ecosystem's own async traits.
235#[allow(async_fn_in_trait)]
236pub trait DeviceEnv {
237    /// Durably persist the encoded protocol snapshot (CMD_SAVE / host wipe).
238    async fn persist_snapshot(&mut self, bytes: &[u8]) -> Result<(), ()>;
239    /// Tombstone the snapshot journal (CMD_CLEAR).
240    async fn clear_snapshot(&mut self) -> Result<(), ()>;
241    /// Copy the newest committed snapshot generation strictly older than
242    /// the one last handed to the driver into `out`, returning its
243    /// length.
244    ///
245    /// Called only after a payload is rejected, so the cost is paid on a
246    /// boot that is already going wrong. Implementations re-scan the
247    /// journal rather than retaining a runner-up, keeping the mount
248    /// path's "never buffers a second copy" discipline. The default
249    /// refuses, which makes rejection terminal for boards whose journal
250    /// cannot walk back.
251    async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
252        let _ = out;
253        None
254    }
255    /// A stored snapshot was rejected at boot. Boards with an indicator
256    /// surface it locally: the host-visible report reaches nobody on an
257    /// unattended repeater, which is exactly the deployment this
258    /// matters for.
259    fn report_snapshot_rejected(&mut self, fell_back: bool) {
260        let _ = fell_back;
261    }
262    /// Durably persist the encoded device identity.
263    async fn persist_identity(&mut self, bytes: &[u8]) -> Result<(), ()>;
264    /// Tombstone the identity journal (CMD_CLEAR).
265    async fn clear_identity(&mut self) -> Result<(), ()>;
266    /// Drop persisted frame-counter boundaries after a successful
267    /// identity clear. Boards without a device node keep the default.
268    async fn clear_counters(&mut self) {}
269    /// Fill `secret` from the platform's cryptographic RNG. Fails closed:
270    /// an error refuses identity generation rather than degrading.
271    fn fill_secret(&mut self, secret: &mut [u8; 32]) -> Result<(), ()>;
272    /// One fresh battery measurement (`Effect::SampleBattery`). Only
273    /// emitted when the board's `SessionConfig::battery` advertises
274    /// fields, so the default refuses.
275    async fn sample_battery(&mut self) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
276        Err(())
277    }
278    /// One fresh ambient light measurement in millilux
279    /// (`Effect::SampleIlluminance`). Only emitted on a board whose
280    /// `SessionConfig::illuminance` is set, so the default reports nothing.
281    ///
282    /// `None` is a legitimate answer — the sensor exists but could not be
283    /// read — and reaches the host as the empty value rather than an error.
284    async fn sample_illuminance(&mut self) -> Option<u32> {
285        None
286    }
287    /// Wait for a battery measurement the board considers worth
288    /// announcing, for publication as an unsolicited `PROP_BATTERY`
289    /// (`Session::publish_battery`).
290    ///
291    /// The board owns the whole policy: the sampling cadence, the
292    /// charge-state edges, and which changes matter. It is the only layer
293    /// that sees every sample, so filtering there keeps the session free
294    /// of cached readings and keeps this hook's contract simple — every
295    /// value it yields is published.
296    ///
297    /// Cancellation-safe: the driver drops and re-creates this future on
298    /// every other loop iteration, so an implementation must not lose an
299    /// update it was cancelled on (an `embassy_sync::watch::Watch`
300    /// receiver behaves correctly here; a bare `Signal` does not).
301    ///
302    /// The default never completes, so boards without battery push add
303    /// nothing to the select.
304    async fn battery_event(&mut self) -> umsh_ulcp::battery::BatteryStatus {
305        core::future::pending().await
306    }
307    /// Wait for anything the board wants to publish unasked, across every
308    /// property it pushes.
309    ///
310    /// This is the driver's single select arm for device-initiated
311    /// publication. The default delegates to
312    /// [`battery_event`](Self::battery_event), so a board that pushes only
313    /// battery measurements implements that and nothing else. A board that
314    /// also pushes time or position overrides this instead and selects
315    /// over its own sources — which it can do without fighting the
316    /// borrow checker, since those are its own fields rather than three
317    /// `&mut self` calls.
318    ///
319    /// Cancellation-safe on the same terms as
320    /// [`battery_event`](Self::battery_event).
321    async fn publish_event(&mut self) -> PublishEvent {
322        PublishEvent::Battery(self.battery_event().await)
323    }
324    /// Read the platform wall clock (`Effect::ReadTime`): Unix seconds,
325    /// or `None` when the device does not know what time it is.
326    ///
327    /// Not knowing is the honest answer for a board that has never had a
328    /// fix and was never told, and it is what stops a display from
329    /// showing a clock. The default is exactly that, so a board without
330    /// `CAP_TIME` never has to implement it.
331    async fn read_time(&mut self) -> Option<u32> {
332        None
333    }
334    /// Apply a `PROP_TIME` write (`Effect::ApplyTime`): set the wall
335    /// clock, or return it to not knowing.
336    ///
337    /// A manual set outranks every receiver-derived one, so this applies
338    /// regardless of `PROP_GNSS_TIME_TRUST`.
339    async fn apply_time(&mut self, epoch: Option<u32>) {
340        let _ = epoch;
341    }
342    /// Sample the receiver's current view of position and constellation
343    /// (`Effect::SampleGnss`). Only emitted on a board whose
344    /// `SessionConfig::gnss` advertises the capability, so the default
345    /// refuses.
346    async fn sample_gnss(&mut self) -> Result<umsh_ulcp::gnss::GnssSnapshot, ()> {
347        Err(())
348    }
349    /// Build and sign the device identity's node-identity blob into
350    /// `out` (`Effect::SignIdentity`), returning its length.
351    ///
352    /// The board owns both halves the session does not: the signing key,
353    /// and the advertised profile the Identity Request responder uses.
354    /// Boards without a device node keep the default, which refuses.
355    async fn sign_identity(&mut self, out: &mut [u8]) -> Option<usize> {
356        let _ = out;
357        None
358    }
359    /// Apply a `PROP_BLE_PAIRING_PIN` write against the bond journal and
360    /// the live BLE stack; `true` when it took effect.
361    async fn apply_pairing_pin(&mut self, pin: Option<u32>) -> bool;
362    /// `CMD_FACTORY_RESET`: erase EVERY piece of persistent state the
363    /// platform owns — saved snapshot, device identity, frame-counter
364    /// boundaries, BLE bonds, pairing PIN, and any other journal — then
365    /// reboot. Never returns: the reset discards in-RAM state and the
366    /// board comes back factory-fresh. There is no separate "clear bonds"
367    /// hook because a reboot reloads bonds from the (now-erased) journal,
368    /// so the live BLE stack never has to be touched.
369    async fn factory_reset(&mut self) -> !;
370    /// Publish the transport-arbitration advertising policy (a wired
371    /// attach suppresses BLE advertising). Diagnostic builds may
372    /// deliberately ignore `allowed`.
373    fn set_advertising_allowed(&mut self, allowed: bool);
374    /// Publish the session's device name to the board's consumers
375    /// (advertising data, device node, UI).
376    async fn publish_device_name(&mut self, name: &str);
377    /// Deliver a device-domain mirror to the board's device node.
378    fn publish_dev_domain(&mut self, snapshot: DevDomainSnapshot);
379    /// Start or stop the board's locate indication (`PROP_ALERT`).
380    ///
381    /// Carries the authoritative state and is called for every
382    /// transition — host write, local cancellation, and deadline — so an
383    /// implementation can treat it as idempotent and needs no notion of
384    /// *why* the alert ended. `AlertState::Locate` must override a local
385    /// silence setting without clearing it (spec §PROP_ALERT); boards
386    /// without `CAP_ALERT` never see this and keep the default.
387    fn set_alert(&mut self, state: umsh_ulcp::alert::AlertState) {
388        let _ = state;
389    }
390    /// The receiver switch was flipped at the device, and is now
391    /// `enabled`.
392    ///
393    /// Only for the local gesture: a host write already knows what it
394    /// asked for, and a board that indicated one would announce the
395    /// phone's own settings screen back at it. Carries the resulting
396    /// state rather than the fact of a press, because "on" and "off"
397    /// are what the operator needs told apart.
398    fn gnss_switched(&mut self, enabled: bool) {
399        let _ = enabled;
400    }
401    /// A covered frame was queued for an attached-or-future host
402    /// (T-1000E: request the attention LED).
403    fn request_attention(&mut self) {}
404    /// The host-facing queue drained to empty (T-1000E: clear it).
405    fn clear_attention(&mut self) {}
406    /// A transmit is about to start; boards with a battery-level
407    /// estimator mark the load spike.
408    fn note_transmit_load(&mut self) {}
409    /// Diagnostic trace line (routed to the board's debug channel; the
410    /// default discards).
411    fn trace(&mut self, args: core::fmt::Arguments<'_>) {
412        let _ = args;
413    }
414}
415
416/// The driver's `'static` wiring: the channels and control blocks the
417/// loop shares with the board's transport and radio tasks.
418pub struct DeviceRuntime<M: RawMutex + 'static, const RX: usize, const TX: usize> {
419    /// Inbound frames and connection edges from every transport task.
420    pub input: &'static InputChannel<M>,
421    /// The session's radio endpoint — its private virtual `Channels`
422    /// bundle served by the board's radio mux (never the real radio
423    /// bundle directly).
424    pub radio: &'static Channels<M, RX, TX>,
425    /// Runtime radio settings / RSSI sampling into the radio runner.
426    pub ctl: &'static DeviceControl<M>,
427    /// Outbound frame queues drained by the transport output tasks.
428    pub out: &'static TransportChannels<M>,
429    /// Published session epoch, checked by each transport at framing
430    /// edges (`transport_policy::generation_checked`).
431    pub session_gen: &'static AtomicU32,
432}
433
434/// Collects frames emitted synchronously by the session, then flushes
435/// them to the active transport's output queue asynchronously. The
436/// session emits at most one frame per call; two slots give headroom.
437struct Emitter {
438    bufs: [[u8; FRAME_OUT_MAX]; 2],
439    lens: [usize; 2],
440    count: usize,
441}
442
443impl Emitter {
444    const fn new() -> Self {
445        Self {
446            bufs: [[0; FRAME_OUT_MAX]; 2],
447            lens: [0; 2],
448            count: 0,
449        }
450    }
451
452    /// Copy one raw ULCP frame into the next slot.
453    ///
454    /// The session is expected to emit at most `bufs.len()` frames per call
455    /// and every frame is expected to fit `FRAME_OUT_MAX`. Both invariants are
456    /// asserted in debug builds so a future session change that violates
457    /// them is caught rather than silently dropping a response.
458    fn push(&mut self, frame: &[u8]) {
459        if self.count >= self.bufs.len() {
460            debug_assert!(
461                false,
462                "Emitter overflow: session emitted more frames per call than staging slots"
463            );
464            return;
465        }
466        if frame.len() <= FRAME_OUT_MAX {
467            self.bufs[self.count][..frame.len()].copy_from_slice(frame);
468            self.lens[self.count] = frame.len();
469            self.count += 1;
470        } else {
471            debug_assert!(false, "Emitter: ULCP frame exceeds FRAME_OUT_MAX");
472        }
473    }
474
475    /// Queue all staged frames for the active transport output task.
476    async fn flush<M: RawMutex>(
477        &mut self,
478        destination: Option<(Transport, u32)>,
479        out: &TransportChannels<M>,
480    ) {
481        if let Some((transport, generation)) = destination {
482            for index in 0..self.count {
483                let mut frame: FrameBuf = heapless::Vec::new();
484                if frame
485                    .extend_from_slice(&self.bufs[index][..self.lens[index]])
486                    .is_err()
487                {
488                    // FRAME_OUT_MAX == FrameBuf capacity, so this cannot
489                    // happen; assert in debug rather than silently drop.
490                    debug_assert!(false, "Emitter frame copy exceeded FrameBuf capacity");
491                    continue;
492                }
493                out.for_transport(transport)
494                    .send(OutFrame { generation, frame })
495                    .await;
496            }
497        }
498        self.count = 0;
499    }
500}
501
502/// Execute a radio side effect requested by the session.
503async fn apply_effect<A, S, const TXQ: usize, M, const RX: usize, const TX: usize, E>(
504    session: &Session<A, S, TXQ>,
505    effect: Option<Effect>,
506    rt: &DeviceRuntime<M, RX, TX>,
507    env: &mut E,
508) where
509    A: AesProvider,
510    S: Sha256Provider,
511    M: RawMutex,
512    E: DeviceEnv,
513{
514    match effect {
515        Some(Effect::ApplyRadio(settings)) => {
516            env.publish_device_name(session.device_name()).await;
517            // The session validates values against the same discrete
518            // sets these converters accept, so None here is
519            // unreachable; bail out defensively rather than panic.
520            let (Some(sf), Some(bw), Some(cr)) = (
521                spreading_factor_from_u8(settings.sf),
522                bandwidth_from_hz(settings.bw_hz),
523                coding_rate_from_denom(settings.cr_denom),
524            ) else {
525                return;
526            };
527            rt.ctl.apply(DeviceSettings {
528                enabled: settings.enabled,
529                freq_hz: settings.freq_khz.saturating_mul(1_000),
530                sf,
531                bw,
532                cr,
533                power_dbm: i32::from(settings.tx_power_dbm),
534            });
535            // Published for anything that wants to show what the radio is
536            // actually set to — a board's stats page, in particular —
537            // without having to hold the session to ask. The statics live
538            // with the device node, which not every driver consumer
539            // builds.
540            #[cfg(feature = "device-node")]
541            crate::device_node::set_tx_power_dbm(settings.tx_power_dbm);
542        }
543        Some(Effect::StartTransmit) => {
544            let mut data: heapless::Vec<u8, MAX_PAYLOAD> = heapless::Vec::new();
545            if data.extend_from_slice(session.tx_data()).is_err() {
546                env.trace(format_args!(
547                    "radio tx staging=FAILED len={}",
548                    session.tx_data().len()
549                ));
550                return;
551            }
552            let power_dbm = match session.tx_power() {
553                TxPower::Default => None,
554                TxPower::Max => Some(i32::from(session.max_tx_power_dbm())),
555                TxPower::Dbm(dbm) => Some(i32::from(dbm)),
556            };
557            // Mark the load for the board's battery level estimator (the
558            // radio runner transmits within milliseconds of this).
559            env.note_transmit_load();
560            let cad = if session.tx_nocca() {
561                CadPolicy::Skip
562            } else {
563                CadPolicy::Gate
564            };
565            rt.radio
566                .tx
567                .send(TxRequest {
568                    data,
569                    power_dbm,
570                    cad,
571                })
572                .await;
573        }
574        Some(Effect::DeviceNameChanged) => {
575            env.publish_device_name(session.device_name()).await;
576        }
577        Some(Effect::ApplyAlert(state)) => {
578            env.set_alert(state);
579        }
580        Some(Effect::ApplyTime { epoch }) => {
581            env.apply_time(epoch).await;
582        }
583        // Deferred effects needing `&mut Session` + the emitter are
584        // handled inline in the run loop rather than here.
585        Some(Effect::SampleRssi { .. })
586        | Some(Effect::SignIdentity { .. })
587        | Some(Effect::SampleBattery { .. })
588        | Some(Effect::SampleIlluminance { .. })
589        | Some(Effect::ReadTime { .. })
590        | Some(Effect::SampleGnss { .. })
591        | Some(Effect::SetPairingPin { .. })
592        | Some(Effect::DrainQueue)
593        | Some(Effect::SaveSnapshot { .. })
594        | Some(Effect::ClearSaved { .. })
595        | Some(Effect::ProvisionIdentity { .. })
596        | Some(Effect::FactoryReset)
597        | None => {}
598    }
599}
600
601/// Mirror the session's device-domain node tables to the device node
602/// when their generation moved (device-node plan increment 3).
603/// `synced_version` is the caller's cache of the last published
604/// generation. Cheap when nothing changed — one u32 compare — so the
605/// loop runs it after every session interaction.
606/// Generate, persist, and install a fresh device identity.
607///
608/// The counterpart to first-boot generation, for the one runtime path
609/// that can leave the session without one: `CMD_CLEAR` followed by the
610/// `CMD_RST` that completes a factory reset. A device identity is not a
611/// commissioning step, so there is no state in which the operator has to
612/// supply one.
613///
614/// A failure to draw entropy or to persist leaves the session
615/// identityless, which is a worse outcome than either but not one this
616/// layer can repair: it is reported and the next boot regenerates.
617async fn regenerate_device_identity<A, S, const TXQ: usize, E>(
618    session: &mut Session<A, S, TXQ>,
619    env: &mut E,
620) where
621    A: AesProvider,
622    S: Sha256Provider,
623    E: DeviceEnv,
624{
625    let mut secret = [0u8; 32];
626    if env.fill_secret(&mut secret).is_err() {
627        env.trace(format_args!("device identity regenerate: entropy FAILED"));
628        return;
629    }
630    let (public_key, payload) = device_identity_record(&secret);
631    match env.persist_identity(&payload).await {
632        Ok(()) => {
633            session.set_boot_identity(public_key);
634            env.trace(format_args!(
635                "device identity regenerated after clear+reset"
636            ));
637        }
638        Err(()) => env.trace(format_args!(
639            "device identity regenerate: persist FAILED — none in effect"
640        )),
641    }
642}
643
644fn sync_dev_domain<A, S, const TXQ: usize, E>(
645    session: &Session<A, S, TXQ>,
646    synced_version: &mut u32,
647    env: &mut E,
648) where
649    A: AesProvider,
650    S: Sha256Provider,
651    E: DeviceEnv,
652{
653    if session.dev_domain_version() == *synced_version {
654        return;
655    }
656    *synced_version = session.dev_domain_version();
657    let mut snapshot = DevDomainSnapshot {
658        channel_keys: heapless::Vec::new(),
659        peers: heapless::Vec::new(),
660        dev_key: session.dev_key().copied(),
661        repeater_enabled: session.repeater_enabled(),
662        repeater_regions: heapless::Vec::from_slice(session.repeater_regions()).unwrap_or_default(),
663        repeater_default_region: session.repeater_default_region(),
664        repeater_min_rssi: session.repeater_min_rssi(),
665        repeater_min_snr: session.repeater_min_snr(),
666        ident_role: session.ident_role(),
667        ident_mobile: session.ident_mobile(),
668        discoverable: session.dev_discoverable(),
669        advert_interval_s: session.advert_interval_s(),
670        beacon_interval_s: session.beacon_interval_s(),
671        startup_beacon: session.startup_beacon(),
672        tz_offset_min: session.tz_offset_min(),
673        gnss_enabled: session.gnss_enabled(),
674        gnss_ident_update: session.gnss_ident_update(),
675        gnss_ident_precision: session.gnss_ident_precision(),
676        gnss_time_trust: session.gnss_time_trust(),
677    };
678    for key in session.dev_channel_keys() {
679        let _ = snapshot.channel_keys.push(key);
680    }
681    for public_key in session.dev_peers() {
682        let _ = snapshot.peers.push(public_key);
683    }
684    env.publish_dev_domain(snapshot);
685}
686
687/// Drive the ULCP session forever: restore persisted state, then
688/// select over host frames, radio receptions, and transmit completions,
689/// executing every session effect through the board's [`DeviceEnv`].
690///
691/// The caller constructs the [`Session`] with its board profile
692/// (`SessionConfig`) and boot status, mounts its journals, and hands the
693/// stored payloads in; the driver owns everything after that.
694pub async fn run<A, S, const TXQ: usize, M, const RX: usize, const TX: usize, E>(
695    mut session: Session<A, S, TXQ>,
696    boot_snapshot: Option<&[u8]>,
697    boot_identity: Option<[u8; 32]>,
698    rt: DeviceRuntime<M, RX, TX>,
699    mut env: E,
700) -> !
701where
702    A: AesProvider,
703    S: Sha256Provider,
704    M: RawMutex,
705    E: DeviceEnv,
706{
707    let mut emitter = Emitter::new();
708    let mut arbitration = SessionArbitration::new(rt.session_gen.load(Ordering::Acquire));
709    // Last device-domain generation mirrored to the device node.
710    // Matches the session's initial value; the first mutation (or a
711    // boot restore) publishes the first snapshot.
712    let mut dev_domain_synced: u32 = session.dev_domain_version();
713    // Shared staging buffer for the durable-write effect arms
714    // (save/wipe). Held across their persist awaits, so as a
715    // loop-lifetime local it costs one future slot instead of one
716    // per arm.
717    let mut snapshot_buf = [0u8; SNAPSHOT_MAX];
718
719    // The device identity is persisted independently of snapshots;
720    // its post-reset value is whatever the identity journal holds.
721    if let Some(public_key) = boot_identity {
722        session.set_boot_identity(public_key);
723    }
724
725    // Restore a stored snapshot before processing any host command:
726    // the saved configuration is applied, the PHY re-enabled if it
727    // was enabled when saved, and detached operation begins
728    // immediately.
729    //
730    // A payload that does not decode is not the end of it. The journal
731    // is multi-record and newest-generation-wins, so an older readable
732    // generation usually sits behind the rejected one — and for an
733    // unattended repeater, falling back to it is the only outcome that
734    // keeps the device forwarding. Walk back a bounded number of
735    // generations: a systematically undecodable payload is a firmware
736    // bug rather than corruption, and re-walking the whole journal on
737    // every boot would just be slower about it.
738    if let Some(payload) = boot_snapshot {
739        let mut generation = 0usize;
740        let mut restored = session.restore_at_boot(payload);
741        while let Err(error) = restored {
742            env.trace(format_args!(
743                "proto-store boot-restore generation=-{generation} REJECTED error={error:?}"
744            ));
745            session.note_snapshot_rejected();
746            generation += 1;
747            if generation > SNAPSHOT_FALLBACK_LIMIT {
748                env.trace(format_args!(
749                    "proto-store boot-restore fallback=EXHAUSTED limit={SNAPSHOT_FALLBACK_LIMIT}"
750                ));
751                break;
752            }
753            let Some(len) = env.older_snapshot(&mut snapshot_buf).await else {
754                env.trace(format_args!("proto-store boot-restore fallback=NONE"));
755                break;
756            };
757            restored = session.restore_at_boot(&snapshot_buf[..len]);
758        }
759        match restored {
760            Ok(effect) => {
761                if generation > 0 {
762                    env.trace(format_args!(
763                        "proto-store boot-restore=FALLBACK generation=-{generation}"
764                    ));
765                    env.report_snapshot_rejected(true);
766                } else {
767                    env.trace(format_args!("proto-store boot-restore=ok"));
768                }
769                apply_effect(&session, Some(effect), &rt, &mut env).await;
770            }
771            Err(_) => {
772                env.trace(format_args!("proto-store boot-restore=BARE"));
773                env.report_snapshot_rejected(false);
774            }
775        }
776    }
777
778    // Publish the device domain once before any host interaction, on every
779    // boot path rather than only after a successful restore.
780    //
781    // Two things depend on it. Detached multicast processing needs the
782    // restored tables without waiting for an attach — the original reason.
783    // And anything that waits for the domain to be published before acting
784    // needs that publication to happen on a device that has never been
785    // configured, where the answer is "the post-reset defaults" rather than
786    // silence: the boot-time GNSS clock read waits on exactly this, and on
787    // a bare device it would otherwise wait for a host that may never come.
788    sync_dev_domain(&session, &mut dev_domain_synced, &mut env);
789
790    loop {
791        // Resolve the next event in its own statement so the select's
792        // futures — one of which mutably borrows `env` — are dropped
793        // before the arms below use `env` again. A `match select4(..)`
794        // scrutinee would hold them for the whole match.
795        let event = {
796            // Only wait for a TX completion while one is outstanding,
797            // so a spurious tx_done can never be consumed early.
798            let tx_done = async {
799                if session.has_pending_tx() {
800                    rt.radio.tx_done.wait().await
801                } else {
802                    core::future::pending().await
803                }
804            };
805            // The locate alert's deadline. Enforced here rather than by
806            // each board so that "a device MUST bound how long it will
807            // remain in ALERT_LOCATE" holds for every board that
808            // advertises CAP_ALERT, including ones whose UX layer has no
809            // timer of its own. Idle (never completes) while no alert is
810            // running. It borrows `session` immutably, alongside
811            // `tx_done` — only `publish_event` touches `env`.
812            let alert_deadline = async {
813                match session.alert_deadline_ms() {
814                    Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
815                    None => core::future::pending().await,
816                }
817            };
818            select4(
819                rt.input.receive(),
820                rt.radio.rx.receive(),
821                tx_done,
822                select(env.publish_event(), alert_deadline),
823            )
824            .await
825        };
826
827        match event {
828            Either4::First(InEvent::Attached(transport)) => {
829                // Fresh session state for the new host session; the
830                // device domain (PHY configuration and enable state,
831                // device name, duty accounting) is deliberately
832                // untouched and nothing is emitted (full-protocol
833                // attach semantics).
834                arbitration.attach(transport);
835                rt.session_gen
836                    .store(arbitration.generation(), Ordering::Release);
837                env.set_advertising_allowed(arbitration.advertising_allowed());
838                // Both transports meet their provisioning-security
839                // binding here: the wired transport by physical
840                // possession, BLE because the ULCP GATT service
841                // refuses any access outside an encrypted LESC-bonded
842                // link.
843                session.attach(true);
844            }
845            Either4::First(InEvent::Detached(transport)) => {
846                // Only the active transport's detach ends the
847                // session; a displaced transport's stale detach
848                // must not clear the successor's session state.
849                if arbitration.detach(transport) {
850                    env.set_advertising_allowed(true);
851                    session.detach();
852                }
853            }
854            Either4::First(InEvent::CancelAlert) => {
855                // Whoever found the radio silenced it. Publishing the
856                // transition is not conditional on a host being
857                // attached — `cancel_alert` handles that — and a press
858                // with no alert running is simply nothing.
859                let effect = session.cancel_alert(&mut |frame: &[u8]| emitter.push(frame));
860                emitter.flush(arbitration.destination(), rt.out).await;
861                apply_effect(&session, effect, &rt, &mut env).await;
862            }
863            Either4::First(InEvent::ToggleGnss) => {
864                // The switch itself reaches the receiver through the
865                // device-domain mirror at the bottom of this loop, like
866                // every other write to it.
867                if let Some(enabled) = session.toggle_gnss(&mut |frame: &[u8]| emitter.push(frame))
868                {
869                    emitter.flush(arbitration.destination(), rt.out).await;
870                    env.gnss_switched(enabled);
871                    // Keep an existing snapshot in step, so a switch the
872                    // operator flipped is still flipped after a reboot.
873                    // A device with nothing saved gets nothing saved:
874                    // manufacturing a snapshot from a button press would
875                    // persist every other live-only value with it.
876                    if session.saved_status() != SavedStatus::None
877                        && let Some(len) = session.encode_snapshot(&mut snapshot_buf)
878                        && env.persist_snapshot(&snapshot_buf[..len]).await.is_ok()
879                    {
880                        session.note_snapshot_saved();
881                    }
882                    crate::log::debug_log(format_args!(
883                        "ulcp: gnss {} at the device",
884                        if enabled { "ON" } else { "off" }
885                    ));
886                }
887            }
888            Either4::First(InEvent::Frame(transport, frame_bytes)) => {
889                if arbitration.accepts_frame(transport) {
890                    let now_ms = Instant::now().as_millis();
891                    let effect =
892                        session.handle_frame(&frame_bytes, now_ms, &mut |frame: &[u8]| {
893                            emitter.push(frame)
894                        });
895                    emitter.flush(arbitration.destination(), rt.out).await;
896                    match effect {
897                        Some(Effect::SampleRssi { tid }) => {
898                            // Round-trip to the radio runner for an
899                            // instantaneous RSSI sample, then answer the
900                            // deferred PROP_PHY_RSSI get.
901                            rt.ctl.request_rssi();
902                            let sample = rt.ctl.wait_rssi().await;
903                            session
904                                .respond_rssi(tid, sample, &mut |frame: &[u8]| emitter.push(frame));
905                            emitter.flush(arbitration.destination(), rt.out).await;
906                        }
907                        Some(Effect::SignIdentity { tid }) => {
908                            // The session holds no signing key, so the
909                            // platform builds the canonical node-identity
910                            // payload from the same profile the Identity
911                            // Request responder advertises and signs it
912                            // with the device identity.
913                            let mut blob = [0u8; IDENTITY_BLOB_MAX];
914                            let signed = env.sign_identity(&mut blob).await;
915                            session.respond_identity_blob(
916                                tid,
917                                signed.map(|len| &blob[..len]).ok_or(()),
918                                &mut |frame: &[u8]| emitter.push(frame),
919                            );
920                            emitter.flush(arbitration.destination(), rt.out).await;
921                        }
922                        Some(Effect::SampleBattery { tid }) => {
923                            // Round-trip to the platform battery
924                            // source for a fresh measurement, then
925                            // answer the deferred PROP_BATTERY get.
926                            let sample = env.sample_battery().await;
927                            session.respond_battery(tid, sample, &mut |frame: &[u8]| {
928                                emitter.push(frame)
929                            });
930                            emitter.flush(arbitration.destination(), rt.out).await;
931                        }
932                        Some(Effect::SampleIlluminance { tid }) => {
933                            let millilux = env.sample_illuminance().await;
934                            session.respond_illuminance(tid, millilux, &mut |frame: &[u8]| {
935                                emitter.push(frame)
936                            });
937                            emitter.flush(arbitration.destination(), rt.out).await;
938                        }
939                        Some(Effect::ReadTime { tid }) => {
940                            let epoch = env.read_time().await;
941                            session
942                                .respond_time(tid, epoch, &mut |frame: &[u8]| emitter.push(frame));
943                            emitter.flush(arbitration.destination(), rt.out).await;
944                        }
945                        Some(Effect::SampleGnss { tid, key }) => {
946                            let sample = env.sample_gnss().await;
947                            session.respond_gnss(tid, key, sample, &mut |frame: &[u8]| {
948                                emitter.push(frame)
949                            });
950                            emitter.flush(arbitration.destination(), rt.out).await;
951                        }
952                        Some(Effect::DrainQueue) => {
953                            // Deliver the covered frames one per
954                            // step, flushing between steps so the
955                            // two-slot emitter never overflows and
956                            // the transport applies backpressure.
957                            loop {
958                                let more = session.drain_step(
959                                    Instant::now().as_millis(),
960                                    &mut |frame: &[u8]| emitter.push(frame),
961                                );
962                                emitter.flush(arbitration.destination(), rt.out).await;
963                                if !more {
964                                    break;
965                                }
966                            }
967                            env.clear_attention();
968                        }
969                        Some(Effect::SaveSnapshot { tid }) => {
970                            let result = match session.encode_snapshot(&mut snapshot_buf) {
971                                Some(len) => env.persist_snapshot(&snapshot_buf[..len]).await,
972                                None => Err(()),
973                            };
974                            session
975                                .respond_save(tid, result, &mut |frame: &[u8]| emitter.push(frame));
976                            emitter.flush(arbitration.destination(), rt.out).await;
977                        }
978                        Some(Effect::ClearSaved { tid }) => {
979                            // CMD_CLEAR covers all persisted
980                            // provisioning: the snapshot and the
981                            // independently persisted device
982                            // identity. Each journal's tombstone is
983                            // individually atomic; an interruption
984                            // between them reports failure and the
985                            // host's retry completes the erase.
986                            let result = match env.clear_snapshot().await {
987                                Ok(()) => env.clear_identity().await,
988                                Err(()) => Err(()),
989                            };
990                            // With the identity durably gone, its
991                            // counter boundaries are dead weight;
992                            // drop them with it. (Kept if the
993                            // identity clear failed — the identity
994                            // then survives the reboot and still
995                            // needs its TX boundary.)
996                            if result.is_ok() {
997                                env.clear_counters().await;
998                            }
999                            session.respond_clear(tid, result, &mut |frame: &[u8]| {
1000                                emitter.push(frame)
1001                            });
1002                            emitter.flush(arbitration.destination(), rt.out).await;
1003                        }
1004                        Some(Effect::ProvisionIdentity { tid }) => {
1005                            // Build the keypair (drawing a fresh
1006                            // secret from the platform RNG for
1007                            // on-device generation), persist it, and
1008                            // only then report the public key.
1009                            let result = match session.identity_request() {
1010                                Some(source) => {
1011                                    let secret = match source {
1012                                        IdentitySource::Install(secret) => Ok(secret),
1013                                        IdentitySource::Generate => {
1014                                            let mut secret = [0u8; 32];
1015                                            env.fill_secret(&mut secret).map(|()| secret)
1016                                        }
1017                                    };
1018                                    match secret {
1019                                        Ok(secret) => {
1020                                            let (public_key, payload) =
1021                                                device_identity_record(&secret);
1022                                            env.persist_identity(&payload)
1023                                                .await
1024                                                .map(|()| public_key)
1025                                        }
1026                                        Err(()) => Err(()),
1027                                    }
1028                                }
1029                                None => Err(()),
1030                            };
1031                            session.respond_identity(tid, result, &mut |frame: &[u8]| {
1032                                emitter.push(frame)
1033                            });
1034                            emitter.flush(arbitration.destination(), rt.out).await;
1035                        }
1036                        Some(Effect::SetPairingPin { tid, pin }) => {
1037                            let applied = env.apply_pairing_pin(pin).await;
1038                            session.respond_pin_set(
1039                                tid,
1040                                applied.then_some(()).ok_or(()),
1041                                &mut |frame: &[u8]| emitter.push(frame),
1042                            );
1043                            emitter.flush(arbitration.destination(), rt.out).await;
1044                        }
1045                        Some(Effect::FactoryReset) => {
1046                            // Hand off to the platform, which erases every
1047                            // persistent journal and reboots. This never
1048                            // returns; no acknowledgement is sent because the
1049                            // reset drops the link. Any frames the session
1050                            // already staged were flushed above.
1051                            env.trace(format_args!("CMD_FACTORY_RESET: wiping all state + reboot"));
1052                            env.factory_reset().await
1053                        }
1054                        other => apply_effect(&session, other, &rt, &mut env).await,
1055                    }
1056                    // A device identity always exists. `CMD_CLEAR`
1057                    // erases the stored one without touching live state,
1058                    // and the `CMD_RST` that completes a factory reset
1059                    // is where the live copy catches up — leaving the
1060                    // device with none, which is the one state the
1061                    // invariant forbids. Regenerate here, exactly as
1062                    // first boot would, so the only way to reach an
1063                    // identityless device is to physically remove it
1064                    // from existence.
1065                    //
1066                    // The running device node keeps the *previous* key
1067                    // in its MAC until the next boot (identity is
1068                    // fixed at bring-up), and stops originating traffic
1069                    // because the dev-domain sync gate compares keys
1070                    // rather than counting them.
1071                    if session.dev_key().is_none() {
1072                        regenerate_device_identity(&mut session, &mut env).await;
1073                    }
1074                    if session.queued_frame_count() == 0 {
1075                        env.clear_attention();
1076                    }
1077                }
1078            }
1079            Either4::Second(RxFrame { data, info }) => {
1080                // While detached this may stage a delegated MAC
1081                // acknowledgement (Effect::StartTransmit).
1082                let queued_before = session.queued_frame_count();
1083                let effect = session.on_radio_rx(
1084                    &data,
1085                    info.rssi,
1086                    info.snr.as_centibels(),
1087                    info.lqi,
1088                    Instant::now().as_millis(),
1089                    &mut |frame: &[u8]| emitter.push(frame),
1090                );
1091                if session.queued_frame_count() > queued_before {
1092                    env.request_attention();
1093                }
1094                emitter.flush(arbitration.destination(), rt.out).await;
1095                apply_effect(&session, effect, &rt, &mut env).await;
1096            }
1097            Either4::Third(result) => {
1098                let now_ms = Instant::now().as_millis();
1099                let outcome = match result {
1100                    Ok(()) => TxOutcome::Sent,
1101                    Err(umsh_hal::TxError::CadTimeout) => TxOutcome::ChannelBusy,
1102                    Err(umsh_hal::TxError::Io(_)) => TxOutcome::Failed,
1103                };
1104                let effect =
1105                    session.on_tx_result(outcome, now_ms, &mut |frame: &[u8]| emitter.push(frame));
1106                emitter.flush(arbitration.destination(), rt.out).await;
1107                apply_effect(&session, effect, &rt, &mut env).await;
1108            }
1109            Either4::Fourth(Either::First(event)) => {
1110                // The board decided this is worth announcing; publish it
1111                // unsolicited. Dropped silently while no host is
1112                // attached, and no effect can result — a publication is
1113                // not an operation.
1114                let emit = &mut |frame: &[u8]| emitter.push(frame);
1115                match event {
1116                    PublishEvent::Battery(sample) => {
1117                        session.publish_battery(sample, emit);
1118                    }
1119                    PublishEvent::Time(epoch) => {
1120                        session.publish_time(epoch, emit);
1121                    }
1122                    PublishEvent::Gnss(key, snapshot) => {
1123                        session.publish_gnss(key, &snapshot, emit);
1124                    }
1125                }
1126                emitter.flush(arbitration.destination(), rt.out).await;
1127            }
1128            Either4::Fourth(Either::Second(())) => {
1129                // The alert outlived its deadline: stop the indication
1130                // and publish the transition the host did not command.
1131                let effect = session
1132                    .poll_alert(Instant::now().as_millis(), &mut |frame: &[u8]| {
1133                        emitter.push(frame)
1134                    });
1135                emitter.flush(arbitration.destination(), rt.out).await;
1136                apply_effect(&session, effect, &rt, &mut env).await;
1137            }
1138        }
1139        // Any of the arms may have moved the device-domain tables
1140        // (property mutation, CMD_RST, CMD_RESTORE); one u32
1141        // compare when they did not.
1142        sync_dev_domain(&session, &mut dev_domain_synced, &mut env);
1143    }
1144}