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_hal::RxOrigin;
33use umsh_journal_store::proto;
34use umsh_radio_loraphy::{
35    CadPolicy, Channels, DeviceControl, DeviceSettings, MAX_PAYLOAD, RxFrame, TxRequest,
36    bandwidth_from_hz, coding_rate_from_denom, spreading_factor_from_u8,
37};
38use umsh_ulcp_device::{
39    Effect, IdentitySource, MAX_CHANNEL_KEYS, MAX_DEV_ADMINS, MAX_DEV_PEERS, MAX_REPEATER_REGIONS,
40    REGION_STRING_MAX_LEN, RadioRxInfo, SNAPSHOT_MAX, SavedStatus, Session, TxOutcome, TxPower,
41};
42
43/// The session sizes its snapshots and the journal sizes its records
44/// independently. This is the only place both are visible, so it is
45/// where a snapshot growing past what a record can carry is caught.
46const _: () = assert!(
47    SNAPSHOT_MAX <= proto::MAX_PAYLOAD,
48    "SNAPSHOT_MAX outgrew what a journal record can carry"
49);
50
51use crate::transport_policy::{SessionArbitration, Transport};
52
53/// Derive a device identity's public key and its persisted record from a
54/// raw Ed25519 secret.
55///
56/// Shared by first-boot generation and `Effect::ProvisionIdentity` so the
57/// two can never disagree about the derivation or the record layout.
58///
59/// The caller supplies `secret` and owns the question this function
60/// cannot answer: it **MUST** come from a cryptographic RNG with real
61/// entropy behind it. On the nRF boards that is the hardware TRNG with
62/// bias correction enabled; on Espressif it is `EspCryptoRng`, which
63/// refuses to exist unless the RF noise source is live.
64pub fn device_identity_record(secret: &[u8; 32]) -> ([u8; 32], [u8; proto::IDENTITY_PAYLOAD_LEN]) {
65    let public_key = SoftwareIdentity::from_secret_bytes(secret).public_key().0;
66    (public_key, proto::encode_identity(secret, &public_key))
67}
68
69/// How many older snapshot generations boot will try after the newest
70/// one is rejected.
71///
72/// Bounded deliberately. Corruption is expected to affect one record, so
73/// a handful of generations covers it; a payload that is *systematically*
74/// undecodable is a firmware bug, and walking the whole journal for it on
75/// every boot would only delay booting bare and reporting the fact.
76pub const SNAPSHOT_FALLBACK_LIMIT: usize = 4;
77
78/// Buffer the driver hands `DeviceEnv::sign_identity`: a node-identity
79/// payload plus its 64-octet detached signature, with room for the
80/// descriptive options.
81pub const IDENTITY_BLOB_MAX: usize = 320;
82
83/// Largest raw ULCP frame accepted from a transport.
84pub const FRAME_IN_MAX: usize = 300;
85/// Largest ULCP frame the session emits (CMD_STR_RECV around a
86/// full-MTU payload).
87pub const FRAME_OUT_MAX: usize = 300;
88
89/// One raw ULCP frame moving through the driver.
90pub type FrameBuf = heapless::Vec<u8, FRAME_IN_MAX>;
91
92/// A Node Management Request's ULCP frame on its way into the session,
93/// and the response frame on its way back out.
94///
95/// The exchange crosses the driver's event loop rather than borrowing the
96/// session, because the session is exclusively owned by [`run`] and an
97/// exchange can await several platform round trips before it is finished.
98pub type AdminFrame = FrameBuf;
99
100/// One-slot return path for [`InEvent::Admin`].
101///
102/// A single static rather than a channel per request: there is one
103/// session driver per device, it serves one event at a time, and the
104/// responder that feeds it holds one exchange open at a time. An empty
105/// response — which is what a reset-class command produces — is
106/// distinguished by its length, so the responder always receives exactly
107/// one message per request and never has to time the loop out.
108pub static ADMIN_REPLY: Channel<
109    embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex,
110    AdminFrame,
111    1,
112> = Channel::new();
113
114/// Framing-free receive path and connection edges into the driver.
115pub enum InEvent {
116    Attached(Transport),
117    Detached(Transport),
118    Frame(Transport, FrameBuf),
119    /// One ULCP frame from a mesh administrator, whose response must fit
120    /// `reply_budget` octets. The driver answers on [`ADMIN_REPLY`].
121    ///
122    /// Authorization has already happened: the responder checked that the
123    /// packet arrived by unicast or blind unicast, that its source is
124    /// authenticated, and that the source key is listed in
125    /// `PROP_DEV_ADMINS`. Nothing that fails those checks reaches here.
126    Admin {
127        frame: AdminFrame,
128        reply_budget: usize,
129    },
130    /// Someone cancelled a running locate alert at the device — the
131    /// button press of whoever found the radio. Ignored when no alert is
132    /// running, so a board may report the press unconditionally.
133    CancelAlert,
134    /// A switch was flipped at the device, on a board that offers the
135    /// setting as a user-facing control. Ignored when the device lacks
136    /// the capability behind it, so a board may report the press
137    /// unconditionally.
138    Toggle(Setting),
139    /// The hold-through-power-on ceremony fired: `PROP_BLE_ENABLED` must
140    /// end up on, whatever it was. Not a toggle — the same gesture on a
141    /// device already reachable would otherwise strand it — and ignored
142    /// entirely when Bluetooth is already on or the device has none.
143    ForceBluetoothOn,
144}
145
146/// A device-domain switch a board may offer as a control the operator
147/// can reach — a menu entry, a button, a gesture.
148///
149/// Each names a property rather than a piece of hardware: what the
150/// device does with it is the platform's business, and a board that
151/// cannot perform one simply never sends it.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum Setting {
154    /// `PROP_BLE_ENABLED`: whether the device is reachable over
155    /// Bluetooth.
156    Bluetooth,
157    /// `PROP_GNSS_ENABLED`: whether the receiver is powered.
158    Gnss,
159    /// `PROP_GNSS_IDENT_UPDATE`: whether position goes out in what the
160    /// device advertises.
161    ShareLocation,
162    /// `PROP_MAC_REPEATER_ENABLED`: whether the node forwards other
163    /// nodes' frames.
164    Forwarding,
165}
166
167/// The inbound event channel the board's tasks feed: every transport
168/// task, and whatever owns the buttons on a board with `CAP_ALERT`.
169pub type InputChannel<M> = Channel<M, InEvent, 8>;
170
171/// One raw ULCP frame in a transport output queue, stamped with the
172/// session generation that produced it so a displaced session's frames
173/// are dropped at the transport edge (`transport_policy::generation_checked`).
174pub struct OutFrame {
175    pub generation: u32,
176    pub frame: FrameBuf,
177}
178
179/// The per-transport outbound frame queues, drained by the board's
180/// transport output tasks. `wired` is the physical-possession transport
181/// (USB-CDC or UART), `ble` the bonded GATT transport — the same pairing
182/// `transport_policy::Transport` names.
183pub struct TransportChannels<M: RawMutex> {
184    pub wired: Channel<M, OutFrame, 4>,
185    pub ble: Channel<M, OutFrame, 4>,
186}
187
188impl<M: RawMutex> TransportChannels<M> {
189    pub const fn new() -> Self {
190        Self {
191            wired: Channel::new(),
192            ble: Channel::new(),
193        }
194    }
195
196    fn for_transport(&self, transport: Transport) -> &Channel<M, OutFrame, 4> {
197        match transport {
198            Transport::Usb => &self.wired,
199            Transport::Ble => &self.ble,
200        }
201    }
202}
203
204/// The session's device-domain tables, mirrored to the board's device
205/// node whenever their generation moves (device-node plan increment 3).
206pub struct DevDomainSnapshot {
207    /// The session's device-domain generation when this snapshot was
208    /// taken. Every mutation moves it, which is what a Node Management
209    /// cursor encodes so that a position taken before a change is
210    /// detected rather than served wrong.
211    pub version: u32,
212    pub channel_keys: heapless::Vec<[u8; 32], MAX_CHANNEL_KEYS>,
213    pub peers: heapless::Vec<[u8; 32], MAX_DEV_PEERS>,
214    /// `PROP_DEV_ADMINS`: the nodes allowed to manage this device over
215    /// the mesh. Mirrored here because authorization is checked against
216    /// the arriving frame's source key before anything reaches the
217    /// session, and the responder holds no session borrow.
218    pub admins: heapless::Vec<[u8; 32], MAX_DEV_ADMINS>,
219    /// The session's live `PROP_DEV_KEY`.
220    ///
221    /// The device node compares this against the key its MAC was built
222    /// with rather than merely checking that *some* identity exists. The
223    /// two can disagree — a newly installed `PROP_DEV_PRIVATE_KEY` takes
224    /// effect at the next boot, and `CMD_CLEAR` + `CMD_RST` erases the
225    /// stored one — and in every such case the running node is no longer
226    /// the identity the session describes and must stop originating
227    /// traffic under it.
228    pub dev_key: Option<[u8; 32]>,
229    /// `PROP_MAC_REPEATER_ENABLED`: whether the device node should
230    /// forward overheard routable frames. Advertised as the `REP`
231    /// capability bit — a fact about what the node does, not a choice
232    /// about what it calls itself.
233    pub repeater_enabled: bool,
234    /// `PROP_MAC_REPEATER_REGIONS` as the MAC's forwarding filter needs
235    /// it: the 2-octet code derived from each configured region. Empty
236    /// imposes no restriction.
237    pub repeater_region_codes: heapless::Vec<[u8; 2], MAX_REPEATER_REGIONS>,
238    /// The same regions as the identity advertises them: the string form
239    /// of each, which is what a reader can make sense of — a hash-derived
240    /// code cannot be turned back into a name.
241    pub repeater_region_names:
242        heapless::Vec<heapless::Vec<u8, REGION_STRING_MAX_LEN>, MAX_REPEATER_REGIONS>,
243    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the code the node inserts
244    /// into an untagged flood packet, or `None` to never tag.
245    pub repeater_default_region: Option<[u8; 2]>,
246    /// `PROP_MAC_REPEATER_MIN_RSSI`: minimum RSSI in dBm to
247    /// flood-forward, or `None` for no threshold.
248    pub repeater_min_rssi: Option<i16>,
249    /// `PROP_MAC_REPEATER_MIN_SNR`: minimum SNR in whole dB to
250    /// flood-forward, or `None` for no threshold.
251    pub repeater_min_snr: Option<i8>,
252    /// `PROP_IDENT_ROLE`: the advertised `ROLE` byte, or `None` to
253    /// derive it from the forwarding state.
254    pub ident_role: Option<u8>,
255    /// `PROP_IDENT_MOBILE`: whether to advertise the `MOB` capability
256    /// bit.
257    pub ident_mobile: bool,
258    /// `PROP_IDENT_LOCATION`: the position the advertised identity
259    /// carries, in the variable-precision encoding, or empty for none.
260    pub ident_location: heapless::Vec<u8, { umsh_ulcp::gnss::MAX_LOCATION_LEN }>,
261    /// `PROP_IDENT_ALTITUDE`: meters above the WGS-84 ellipsoid, or
262    /// `None`.
263    pub ident_altitude_m: Option<i32>,
264    /// `PROP_DEV_DISCOVERABLE`: whether the device identity answers
265    /// Identity Requests.
266    pub discoverable: bool,
267    /// `PROP_ADVERT_INTERVAL`: seconds between unsolicited
268    /// advertisements, 0 for none.
269    pub advert_interval_s: u32,
270    /// `PROP_BEACON_INTERVAL`: seconds between unsolicited beacons, 0 for
271    /// none.
272    pub beacon_interval_s: u32,
273    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
274    pub startup_beacon: bool,
275    /// `PROP_TZ_OFFSET`: minutes east of UTC, for whatever renders a
276    /// local time.
277    pub tz_offset_min: i16,
278    /// `PROP_GNSS_ENABLED`: whether the receiver should be powered.
279    /// Always false on a board without `CAP_GNSS`.
280    ///
281    /// Carried here rather than as an effect of its own so that a host
282    /// write, a boot restore, and a `CMD_RST` all reach the receiver by
283    /// the same path — the mirror is published whenever the device domain
284    /// moves, which is exactly the set of moments this can change.
285    pub gnss_enabled: bool,
286    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
287    /// node identity's location.
288    pub gnss_ident_update: bool,
289    /// `PROP_GNSS_IDENT_PRECISION`: how far the advertised location is
290    /// clamped down from the fix.
291    pub gnss_ident_precision: u8,
292    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
293    /// wall clock.
294    pub gnss_time_trust: bool,
295    /// `PROP_BLE_ENABLED`: whether the device is reachable over
296    /// Bluetooth. Always false on a board without `CAP_BLE`.
297    ///
298    /// Carried here for the same reason the receiver switch is: the
299    /// mirror is published whenever the device domain moves, which is
300    /// exactly the set of moments this can change, so the transport
301    /// hears about a host write, a boot restore, a `CMD_RST` and a menu
302    /// entry by one path instead of four.
303    pub ble_enabled: bool,
304}
305
306/// A device-initiated property publication, yielded by
307/// [`DeviceEnv::publish_event`].
308///
309/// The driver has exactly one select arm for everything the board pushes
310/// unasked, because a hook per property would need one `&mut self` borrow
311/// per arm and no two of those can coexist. One arm, one enum, and the
312/// board decides internally which of its sources woke it.
313pub enum PublishEvent {
314    /// An unsolicited `PROP_BATTERY`.
315    Battery(umsh_ulcp::battery::BatteryStatus),
316    /// An unsolicited `PROP_TIME`; `None` is a clock that has gone back
317    /// to not knowing what time it is.
318    Time(Option<u32>),
319    /// An unsolicited positioning property, named by its key, encoded
320    /// from the accompanying snapshot.
321    Gnss(u32, umsh_ulcp::gnss::GnssSnapshot),
322    /// A fix offered to the advertised identity, in the
323    /// variable-precision encoding. Not a publication at all — it is an
324    /// input the session may act on — but it arrives on the same arm
325    /// because it comes from the same receiver.
326    IdentityFix(
327        heapless::Vec<u8, { umsh_ulcp::gnss::MAX_LOCATION_LEN }>,
328        Option<i32>,
329    ),
330    /// How many bonds the Bluetooth transport now holds. Pairing and
331    /// forgetting both happen below the session, so this is the only way
332    /// `PROP_BLE_BOND_COUNT` learns it moved.
333    BleBondCount(u8),
334    /// How far the Bluetooth transport has got with whoever is on the
335    /// other end of it. Connecting and walking away both happen below the
336    /// session, so this is the only way `PROP_BLE_LINK` learns it moved.
337    BleLink(umsh_ulcp::ble::BleLinkState),
338    /// Whether a pairing window is open. The window closes on its own —
339    /// a new bond, a timeout — and opens from the device's own menu and
340    /// boot gesture too, so `PROP_BLE_PAIRING` moves without the host
341    /// asking more often than because it asked.
342    BlePairing(bool),
343}
344
345/// Board couplings of the session driver. Everything the loop needs from
346/// the platform, expressed as one trait so the driver itself stays free
347/// of HAL types and `cfg` board forks. Hooks a board doesn't have keep
348/// their no-op defaults (e.g. only the T-1000E implements the attention
349/// indicator and transmit-load hooks today).
350// Single-executor embedded consumers; `Send` futures are irrelevant here,
351// same as the embassy ecosystem's own async traits.
352#[allow(async_fn_in_trait)]
353pub trait DeviceEnv {
354    /// Durably persist the encoded protocol snapshot (CMD_SAVE / host wipe).
355    async fn persist_snapshot(&mut self, bytes: &[u8]) -> Result<(), ()>;
356    /// Tombstone the snapshot journal (CMD_CLEAR).
357    async fn clear_snapshot(&mut self) -> Result<(), ()>;
358    /// Copy the newest committed snapshot generation strictly older than
359    /// the one last handed to the driver into `out`, returning its
360    /// length.
361    ///
362    /// Called only after a payload is rejected, so the cost is paid on a
363    /// boot that is already going wrong. Implementations re-scan the
364    /// journal rather than retaining a runner-up, keeping the mount
365    /// path's "never buffers a second copy" discipline. The default
366    /// refuses, which makes rejection terminal for boards whose journal
367    /// cannot walk back.
368    async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
369        let _ = out;
370        None
371    }
372    /// A stored snapshot was rejected at boot. Boards with an indicator
373    /// surface it locally: the host-visible report reaches nobody on an
374    /// unattended repeater, which is exactly the deployment this
375    /// matters for.
376    fn report_snapshot_rejected(&mut self, fell_back: bool) {
377        let _ = fell_back;
378    }
379    /// Durably persist the encoded device identity.
380    async fn persist_identity(&mut self, bytes: &[u8]) -> Result<(), ()>;
381    /// Tombstone the identity journal (CMD_CLEAR).
382    async fn clear_identity(&mut self) -> Result<(), ()>;
383    /// Drop persisted frame-counter boundaries after a successful
384    /// identity clear. Boards without a device node keep the default.
385    async fn clear_counters(&mut self) {}
386    /// Fill `secret` from the platform's cryptographic RNG. Fails closed:
387    /// an error refuses identity generation rather than degrading.
388    fn fill_secret(&mut self, secret: &mut [u8; 32]) -> Result<(), ()>;
389    /// One fresh battery measurement (`Effect::SampleBattery`). Only
390    /// emitted when the board's `SessionConfig::battery` advertises
391    /// fields, so the default refuses.
392    async fn sample_battery(&mut self) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
393        Err(())
394    }
395    /// One fresh ambient light measurement in millilux
396    /// (`Effect::SampleIlluminance`). Only emitted on a board whose
397    /// `SessionConfig::illuminance` is set, so the default reports nothing.
398    ///
399    /// `None` is a legitimate answer — the sensor exists but could not be
400    /// read — and reaches the host as the empty value rather than an error.
401    async fn sample_illuminance(&mut self) -> Option<u32> {
402        None
403    }
404    /// Wait for a battery measurement the board considers worth
405    /// announcing, for publication as an unsolicited `PROP_BATTERY`
406    /// (`Session::publish_battery`).
407    ///
408    /// The board owns the whole policy: the sampling cadence, the
409    /// charge-state edges, and which changes matter. It is the only layer
410    /// that sees every sample, so filtering there keeps the session free
411    /// of cached readings and keeps this hook's contract simple — every
412    /// value it yields is published.
413    ///
414    /// Cancellation-safe: the driver drops and re-creates this future on
415    /// every other loop iteration, so an implementation must not lose an
416    /// update it was cancelled on (an `embassy_sync::watch::Watch`
417    /// receiver behaves correctly here; a bare `Signal` does not).
418    ///
419    /// The default never completes, so boards without battery push add
420    /// nothing to the select.
421    async fn battery_event(&mut self) -> umsh_ulcp::battery::BatteryStatus {
422        core::future::pending().await
423    }
424    /// Wait for anything the board wants to publish unasked, across every
425    /// property it pushes.
426    ///
427    /// This is the driver's single select arm for device-initiated
428    /// publication. The default delegates to
429    /// [`battery_event`](Self::battery_event), so a board that pushes only
430    /// battery measurements implements that and nothing else. A board that
431    /// also pushes time or position overrides this instead and selects
432    /// over its own sources — which it can do without fighting the
433    /// borrow checker, since those are its own fields rather than three
434    /// `&mut self` calls.
435    ///
436    /// Cancellation-safe on the same terms as
437    /// [`battery_event`](Self::battery_event).
438    async fn publish_event(&mut self) -> PublishEvent {
439        PublishEvent::Battery(self.battery_event().await)
440    }
441    /// Read the platform wall clock (`Effect::ReadTime`): Unix seconds,
442    /// or `None` when the device does not know what time it is.
443    ///
444    /// Not knowing is the honest answer for a board that has never had a
445    /// fix and was never told, and it is what stops a display from
446    /// showing a clock. The default is exactly that, so a board without
447    /// `CAP_TIME` never has to implement it.
448    async fn read_time(&mut self) -> Option<u32> {
449        None
450    }
451    /// Apply a `PROP_TIME` write (`Effect::ApplyTime`): set the wall
452    /// clock, or return it to not knowing.
453    ///
454    /// A manual set outranks every receiver-derived one, so this applies
455    /// regardless of `PROP_GNSS_TIME_TRUST`.
456    async fn apply_time(&mut self, epoch: Option<u32>) {
457        let _ = epoch;
458    }
459    /// Sample the receiver's current view of position and constellation
460    /// (`Effect::SampleGnss`). Only emitted on a board whose
461    /// `SessionConfig::gnss` advertises the capability, so the default
462    /// refuses.
463    async fn sample_gnss(&mut self) -> Result<umsh_ulcp::gnss::GnssSnapshot, ()> {
464        Err(())
465    }
466    /// Build and sign the device identity's node-identity blob into
467    /// `out` (`Effect::SignIdentity`), returning its length.
468    ///
469    /// The board owns both halves the session does not: the signing key,
470    /// and the advertised profile the Identity Request responder uses.
471    /// Boards without a device node keep the default, which refuses.
472    async fn sign_identity(&mut self, out: &mut [u8]) -> Option<usize> {
473        let _ = out;
474        None
475    }
476    /// Apply a `PROP_BLE_PAIRING_PIN` write against the bond journal and
477    /// the live BLE stack; `true` when it took effect.
478    async fn apply_pairing_pin(&mut self, pin: Option<u32>) -> bool;
479    /// `CMD_BLE_CLEAR_BONDS`: delete every stored bond, the pairing PIN,
480    /// and the pairing lockout, then open a pairing window. `true` once
481    /// the deletion is durable.
482    ///
483    /// Unlike the factory reset below, this runs with the board still up,
484    /// so the live BLE stack has to be emptied alongside the journal — a
485    /// bond forgotten on flash but still held in RAM would keep working
486    /// until the next boot. Boards that do not manage their own bonds
487    /// never see this and keep the default, which refuses.
488    async fn clear_ble_bonds(&mut self) -> bool {
489        false
490    }
491    /// A `PROP_BLE_PAIRING` write: open (or renew) the pairing window, or
492    /// close it. `false` when the requested state cannot be entered —
493    /// only ever an open the board must refuse, because it is locked out
494    /// after repeated pairing failures or its Bluetooth is off; a close
495    /// always succeeds.
496    async fn set_ble_pairing(&mut self, open: bool) -> bool {
497        let _ = open;
498        false
499    }
500    /// `CMD_FACTORY_RESET`: erase EVERY piece of persistent state the
501    /// platform owns — saved snapshot, device identity, frame-counter
502    /// boundaries, BLE bonds, pairing PIN, and any other journal — then
503    /// reboot. Never returns: the reset discards in-RAM state and the
504    /// board comes back factory-fresh. Unlike
505    /// [`clear_ble_bonds`](Self::clear_ble_bonds) it need not empty the
506    /// live BLE stack, because the reboot reloads bonds from the
507    /// now-erased journal.
508    async fn factory_reset(&mut self) -> !;
509    /// `CMD_REBOOT`: restart the hardware, keeping every persisted
510    /// journal intact. Never returns. Only reached on a board whose
511    /// `SessionConfig::reboot` advertises the capability, so there is no
512    /// default — a board that sets the flag owes an implementation.
513    ///
514    /// A board with a mesh node owes it two courtesies before the reset
515    /// (`device_node::quiesce_for_reboot` provides both): airing the MAC
516    /// acknowledgment of the frame that carried the command — a
517    /// reset-class command is answered by that acknowledgment and
518    /// nothing else — and forcing the frame-counter boundaries to
519    /// durable storage. Skipping the flush re-opens the replay window
520    /// the command was admitted through, and the administrator's
521    /// retries of that same command are then accepted again after boot:
522    /// one reboot per retry.
523    async fn reboot(&mut self) -> !;
524    /// Publish the transport-arbitration advertising policy (a wired
525    /// attach suppresses BLE advertising). Diagnostic builds may
526    /// deliberately ignore `allowed`.
527    fn set_advertising_allowed(&mut self, allowed: bool);
528    /// Publish the session's device name to the board's consumers
529    /// (advertising data, device node, UI).
530    async fn publish_device_name(&mut self, name: &str);
531    /// Deliver a device-domain mirror to the board's device node.
532    fn publish_dev_domain(&mut self, snapshot: DevDomainSnapshot);
533    /// Start or stop the board's locate indication (`PROP_ALERT`).
534    ///
535    /// Carries the authoritative state and is called for every
536    /// transition — host write, local cancellation, and deadline — so an
537    /// implementation can treat it as idempotent and needs no notion of
538    /// *why* the alert ended. `AlertState::Locate` must override a local
539    /// silence setting without clearing it (spec §PROP_ALERT); boards
540    /// without `CAP_ALERT` never see this and keep the default.
541    fn set_alert(&mut self, state: umsh_ulcp::alert::AlertState) {
542        let _ = state;
543    }
544    /// The receiver switch was flipped at the device, and is now
545    /// `enabled`.
546    ///
547    /// Only for the local gesture: a host write already knows what it
548    /// asked for, and a board that indicated one would announce the
549    /// phone's own settings screen back at it. Carries the resulting
550    /// state rather than the fact of a press, because "on" and "off"
551    /// are what the operator needs told apart.
552    fn gnss_switched(&mut self, enabled: bool) {
553        let _ = enabled;
554    }
555    /// Make the Bluetooth transport reachable, or stop it being so.
556    ///
557    /// Called from the device-domain mirror rather than from any one
558    /// gesture, so it arrives for a host write, a boot restore, a
559    /// `CMD_RST` and a menu entry alike — and arrives again whenever
560    /// anything else in the domain moves. Implementations must therefore
561    /// be idempotent, and boards without `CAP_BLE` never see anything
562    /// but the default.
563    ///
564    /// Disabled means unreachable, not powered down: dropping the
565    /// attached host and stopping advertising is what a user turns this
566    /// off for, and a stack that cannot be torn down at runtime is no
567    /// reason to refuse them that.
568    fn set_ble_enabled(&mut self, enabled: bool) {
569        let _ = enabled;
570    }
571    /// A covered frame was queued for an attached-or-future host
572    /// (T-1000E: request the attention LED).
573    fn request_attention(&mut self) {}
574    /// The host-facing queue drained to empty (T-1000E: clear it).
575    fn clear_attention(&mut self) {}
576    /// A transmit is about to start; boards with a battery-level
577    /// estimator mark the load spike.
578    fn note_transmit_load(&mut self) {}
579    /// Diagnostic trace line (routed to the board's debug channel; the
580    /// default discards).
581    fn trace(&mut self, args: core::fmt::Arguments<'_>) {
582        let _ = args;
583    }
584}
585
586/// The driver's `'static` wiring: the channels and control blocks the
587/// loop shares with the board's transport and radio tasks.
588pub struct DeviceRuntime<M: RawMutex + 'static, const RX: usize, const TX: usize> {
589    /// Inbound frames and connection edges from every transport task.
590    pub input: &'static InputChannel<M>,
591    /// The session's radio endpoint — its private virtual `Channels`
592    /// bundle served by the board's radio mux (never the real radio
593    /// bundle directly).
594    pub radio: &'static Channels<M, RX, TX>,
595    /// Runtime radio settings / RSSI sampling into the radio runner.
596    pub ctl: &'static DeviceControl<M>,
597    /// Outbound frame queues drained by the transport output tasks.
598    pub out: &'static TransportChannels<M>,
599    /// Published session epoch, checked by each transport at framing
600    /// edges (`transport_policy::generation_checked`).
601    pub session_gen: &'static AtomicU32,
602}
603
604/// Collects frames emitted synchronously by the session, then flushes
605/// them to the active transport's output queue asynchronously. The
606/// session emits at most one frame per call; two slots give headroom.
607struct Emitter {
608    bufs: [[u8; FRAME_OUT_MAX]; 2],
609    lens: [usize; 2],
610    count: usize,
611}
612
613impl Emitter {
614    const fn new() -> Self {
615        Self {
616            bufs: [[0; FRAME_OUT_MAX]; 2],
617            lens: [0; 2],
618            count: 0,
619        }
620    }
621
622    /// Copy one raw ULCP frame into the next slot.
623    ///
624    /// The session is expected to emit at most `bufs.len()` frames per call
625    /// and every frame is expected to fit `FRAME_OUT_MAX`. Both invariants are
626    /// asserted in debug builds so a future session change that violates
627    /// them is caught rather than silently dropping a response.
628    fn push(&mut self, frame: &[u8]) {
629        if self.count >= self.bufs.len() {
630            debug_assert!(
631                false,
632                "Emitter overflow: session emitted more frames per call than staging slots"
633            );
634            return;
635        }
636        if frame.len() <= FRAME_OUT_MAX {
637            self.bufs[self.count][..frame.len()].copy_from_slice(frame);
638            self.lens[self.count] = frame.len();
639            self.count += 1;
640        } else {
641            debug_assert!(false, "Emitter: ULCP frame exceeds FRAME_OUT_MAX");
642        }
643    }
644
645    /// Hand all staged frames to whoever is being answered.
646    async fn flush<M: RawMutex>(&mut self, sink: &mut ReplySink<'_, M>) {
647        for index in 0..self.count {
648            let frame = &self.bufs[index][..self.lens[index]];
649            match sink {
650                ReplySink::Transport {
651                    destination: Some((transport, generation)),
652                    out,
653                } => {
654                    let mut copy: FrameBuf = heapless::Vec::new();
655                    if copy.extend_from_slice(frame).is_err() {
656                        // FRAME_OUT_MAX == FrameBuf capacity, so this
657                        // cannot happen; assert in debug rather than
658                        // silently drop.
659                        debug_assert!(false, "Emitter frame copy exceeded FrameBuf capacity");
660                        continue;
661                    }
662                    out.for_transport(*transport)
663                        .send(OutFrame {
664                            generation: *generation,
665                            frame: copy,
666                        })
667                        .await;
668                }
669                // Nobody attached: the response has nowhere to go.
670                ReplySink::Transport {
671                    destination: None, ..
672                } => {}
673                ReplySink::Admin { reply } => {
674                    // An exchange is one request and one response. A
675                    // second frame would mean the session emitted
676                    // something unsolicited, which this binding does not
677                    // carry, so keep the first and account for the rest.
678                    if reply.is_empty() {
679                        let _ = reply.extend_from_slice(frame);
680                    } else {
681                        debug_assert!(false, "admin exchange emitted more than one frame");
682                    }
683                }
684            }
685        }
686        self.count = 0;
687    }
688}
689
690/// Where the frames a session emits while serving one command are
691/// delivered.
692///
693/// The command paths do not know which they are feeding — that is the
694/// point. A deferred property read makes the same `respond_*` call
695/// whether the value is going to an attached host over USB or back to an
696/// administrator across the mesh.
697enum ReplySink<'a, M: RawMutex> {
698    /// The attached host's output queue, or nowhere when none is
699    /// attached.
700    Transport {
701        destination: Option<(Transport, u32)>,
702        out: &'a TransportChannels<M>,
703    },
704    /// The response frame of a Node Management exchange, staged for the
705    /// responder that will envelope and transmit it. Empty when the
706    /// command produced no response, which is what a reset does.
707    Admin { reply: &'a mut AdminFrame },
708}
709
710/// Execute a radio side effect requested by the session.
711async fn apply_effect<A, S, const TXQ: usize, M, const RX: usize, const TX: usize, E>(
712    session: &Session<A, S, TXQ>,
713    effect: Option<Effect>,
714    rt: &DeviceRuntime<M, RX, TX>,
715    env: &mut E,
716) where
717    A: AesProvider,
718    S: Sha256Provider,
719    M: RawMutex,
720    E: DeviceEnv,
721{
722    match effect {
723        Some(Effect::ApplyRadio(settings)) => {
724            env.publish_device_name(session.device_name()).await;
725            // The session validates values against the same discrete
726            // sets these converters accept, so None here is
727            // unreachable; bail out defensively rather than panic.
728            let (Some(sf), Some(bw), Some(cr)) = (
729                spreading_factor_from_u8(settings.sf),
730                bandwidth_from_hz(settings.bw_hz),
731                coding_rate_from_denom(settings.cr_denom),
732            ) else {
733                return;
734            };
735            rt.ctl.apply(DeviceSettings {
736                enabled: settings.enabled,
737                freq_hz: settings.freq_khz.saturating_mul(1_000),
738                sf,
739                bw,
740                cr,
741                power_dbm: i32::from(settings.tx_power_dbm),
742            });
743            // Published for anything that wants to show what the radio is
744            // actually set to — a board's stats page, in particular —
745            // without having to hold the session to ask. The statics live
746            // with the device node, which not every driver consumer
747            // builds.
748            #[cfg(feature = "device-node")]
749            crate::device_node::set_tx_power_dbm(settings.tx_power_dbm);
750        }
751        Some(Effect::StartTransmit) => {
752            let mut data: heapless::Vec<u8, MAX_PAYLOAD> = heapless::Vec::new();
753            if data.extend_from_slice(session.tx_data()).is_err() {
754                env.trace(format_args!(
755                    "radio tx staging=FAILED len={}",
756                    session.tx_data().len()
757                ));
758                return;
759            }
760            let power_dbm = match session.tx_power() {
761                TxPower::Default => None,
762                TxPower::Max => Some(i32::from(session.max_tx_power_dbm())),
763                TxPower::Dbm(dbm) => Some(i32::from(dbm)),
764            };
765            // Mark the load for the board's battery level estimator (the
766            // radio runner transmits within milliseconds of this).
767            env.note_transmit_load();
768            let cad = if session.tx_nocca() {
769                CadPolicy::Skip
770            } else {
771                CadPolicy::Gate
772            };
773            rt.radio
774                .tx
775                .send(TxRequest {
776                    data,
777                    power_dbm,
778                    cad,
779                })
780                .await;
781        }
782        Some(Effect::DeviceNameChanged) => {
783            env.publish_device_name(session.device_name()).await;
784        }
785        Some(Effect::ApplyAlert(state)) => {
786            env.set_alert(state);
787        }
788        Some(Effect::ApplyTime { epoch }) => {
789            env.apply_time(epoch).await;
790        }
791        Some(Effect::ApplyBackhaul { enabled }) => {
792            crate::radio_mux::MUX_MODE.set_backhaul(enabled);
793        }
794        // Deferred effects needing `&mut Session` + the emitter are
795        // handled inline in the run loop rather than here.
796        Some(Effect::SampleRssi { .. })
797        | Some(Effect::SignIdentity { .. })
798        | Some(Effect::SampleBattery { .. })
799        | Some(Effect::SampleIlluminance { .. })
800        | Some(Effect::ReadTime { .. })
801        | Some(Effect::SampleGnss { .. })
802        | Some(Effect::SetPairingPin { .. })
803        | Some(Effect::BleClearBonds { .. })
804        | Some(Effect::SetBlePairing { .. })
805        | Some(Effect::DrainQueue)
806        | Some(Effect::SaveSnapshot { .. })
807        | Some(Effect::ClearSaved { .. })
808        | Some(Effect::ProvisionIdentity { .. })
809        | Some(Effect::FactoryReset)
810        | Some(Effect::Reboot)
811        | None => {}
812    }
813}
814
815/// Mirror the session's device-domain node tables to the device node
816/// when their generation moved (device-node plan increment 3).
817/// `synced_version` is the caller's cache of the last published
818/// generation. Cheap when nothing changed — one u32 compare — so the
819/// loop runs it after every session interaction.
820/// Generate, persist, and install a fresh device identity.
821///
822/// The counterpart to first-boot generation, for the one runtime path
823/// that can leave the session without one: `CMD_CLEAR` followed by the
824/// `CMD_RST` that completes a factory reset. A device identity is not a
825/// commissioning step, so there is no state in which the operator has to
826/// supply one.
827///
828/// A failure to draw entropy or to persist leaves the session
829/// identityless, which is a worse outcome than either but not one this
830/// layer can repair: it is reported and the next boot regenerates.
831async fn regenerate_device_identity<A, S, const TXQ: usize, E>(
832    session: &mut Session<A, S, TXQ>,
833    env: &mut E,
834) where
835    A: AesProvider,
836    S: Sha256Provider,
837    E: DeviceEnv,
838{
839    let mut secret = [0u8; 32];
840    if env.fill_secret(&mut secret).is_err() {
841        env.trace(format_args!("device identity regenerate: entropy FAILED"));
842        return;
843    }
844    let (public_key, payload) = device_identity_record(&secret);
845    match env.persist_identity(&payload).await {
846        Ok(()) => {
847            session.set_boot_identity(public_key);
848            env.trace(format_args!(
849                "device identity regenerated after clear+reset"
850            ));
851        }
852        Err(()) => env.trace(format_args!(
853            "device identity regenerate: persist FAILED — none in effect"
854        )),
855    }
856}
857
858fn sync_dev_domain<A, S, const TXQ: usize, E>(
859    session: &Session<A, S, TXQ>,
860    synced_version: &mut u32,
861    env: &mut E,
862) where
863    A: AesProvider,
864    S: Sha256Provider,
865    E: DeviceEnv,
866{
867    if session.dev_domain_version() == *synced_version {
868        return;
869    }
870    *synced_version = session.dev_domain_version();
871    let mut snapshot = DevDomainSnapshot {
872        version: *synced_version,
873        channel_keys: heapless::Vec::new(),
874        peers: heapless::Vec::new(),
875        admins: heapless::Vec::new(),
876        dev_key: session.dev_key().copied(),
877        repeater_enabled: session.repeater_enabled(),
878        repeater_region_codes: session.repeater_region_codes().collect(),
879        repeater_region_names: session
880            .repeater_region_names()
881            .filter_map(|name| heapless::Vec::from_slice(name.as_bytes()).ok())
882            .collect(),
883        repeater_default_region: session.repeater_default_region(),
884        repeater_min_rssi: session.repeater_min_rssi(),
885        repeater_min_snr: session.repeater_min_snr(),
886        ident_role: session.ident_role(),
887        ident_mobile: session.ident_mobile(),
888        ident_location: heapless::Vec::from_slice(session.ident_location()).unwrap_or_default(),
889        ident_altitude_m: session.ident_altitude_m(),
890        discoverable: session.dev_discoverable(),
891        advert_interval_s: session.advert_interval_s(),
892        beacon_interval_s: session.beacon_interval_s(),
893        startup_beacon: session.startup_beacon(),
894        tz_offset_min: session.tz_offset_min(),
895        gnss_enabled: session.gnss_enabled(),
896        gnss_ident_update: session.gnss_ident_update(),
897        gnss_ident_precision: session.gnss_ident_precision(),
898        gnss_time_trust: session.gnss_time_trust(),
899        ble_enabled: session.ble_enabled(),
900    };
901    for key in session.dev_channel_keys() {
902        let _ = snapshot.channel_keys.push(key);
903    }
904    for public_key in session.dev_peers() {
905        let _ = snapshot.peers.push(public_key);
906    }
907    for public_key in session.dev_admins() {
908        let _ = snapshot.admins.push(public_key);
909    }
910    // Ahead of the mirror: the mirror is consumed by the device node,
911    // and Bluetooth reachability is the transport's business rather than
912    // the node's.
913    env.set_ble_enabled(snapshot.ble_enabled);
914    env.publish_dev_domain(snapshot);
915}
916
917/// One ULCP frame to serve, and what its binding needs to know.
918enum Exchange<'a> {
919    /// A frame from the attached host. The reply is bounded by the
920    /// transport frame, which is what the session assumes by default.
921    Local(&'a [u8]),
922    /// A frame from a mesh administrator, whose reply must fit
923    /// `reply_budget` octets of Node Management payload.
924    Admin {
925        frame: &'a [u8],
926        reply_budget: usize,
927    },
928}
929
930/// Serve one ULCP frame to completion, deferred platform round trips
931/// included, delivering everything the session emits to `sink`.
932///
933/// This is the whole of the driver's command path, and it is deliberately
934/// one function for both bindings. A multi-property command is served
935/// entry by entry: each deferred value returns here for its platform
936/// round trip, and `resume_multi` hands back the next one until the reply
937/// is emitted. For every other command `resume_multi` answers `None` and
938/// the loop runs exactly once. None of that changes because the answer is
939/// going to the mesh instead of to a cable — the only thing that changes
940/// is where the frames go, which is `sink`'s business.
941async fn serve_frame<A, S, const TXQ: usize, M, const RX: usize, const TX: usize, E>(
942    session: &mut Session<A, S, TXQ>,
943    exchange: Exchange<'_>,
944    emitter: &mut Emitter,
945    sink: &mut ReplySink<'_, M>,
946    snapshot_buf: &mut [u8; SNAPSHOT_MAX],
947    rt: &DeviceRuntime<M, RX, TX>,
948    env: &mut E,
949) where
950    A: AesProvider,
951    S: Sha256Provider,
952    M: RawMutex,
953    E: DeviceEnv,
954{
955    let now_ms = Instant::now().as_millis();
956    let mut pending = match exchange {
957        Exchange::Local(bytes) => {
958            session.handle_frame(bytes, now_ms, &mut |frame: &[u8]| emitter.push(frame))
959        }
960        Exchange::Admin {
961            frame,
962            reply_budget,
963        } => session.handle_admin_frame(frame, now_ms, reply_budget, &mut |frame: &[u8]| {
964            emitter.push(frame)
965        }),
966    };
967    emitter.flush(sink).await;
968    while pending.is_some() {
969        match pending.take() {
970            Some(Effect::SampleRssi { tid }) => {
971                // Round-trip to the radio runner for an
972                // instantaneous RSSI sample, then answer the
973                // deferred PROP_PHY_RSSI get.
974                rt.ctl.request_rssi();
975                let sample = rt.ctl.wait_rssi().await;
976                session.respond_rssi(tid, sample, &mut |frame: &[u8]| emitter.push(frame));
977                emitter.flush(sink).await;
978            }
979            Some(Effect::SignIdentity { tid }) => {
980                // The session holds no signing key, so the
981                // platform builds the canonical node-identity
982                // payload from the same profile the Identity
983                // Request responder advertises and signs it
984                // with the device identity.
985                let mut blob = [0u8; IDENTITY_BLOB_MAX];
986                let signed = env.sign_identity(&mut blob).await;
987                session.respond_identity_blob(
988                    tid,
989                    signed.map(|len| &blob[..len]).ok_or(()),
990                    &mut |frame: &[u8]| emitter.push(frame),
991                );
992                emitter.flush(sink).await;
993            }
994            Some(Effect::SampleBattery { tid }) => {
995                // Round-trip to the platform battery
996                // source for a fresh measurement, then
997                // answer the deferred PROP_BATTERY get.
998                let sample = env.sample_battery().await;
999                session.respond_battery(tid, sample, &mut |frame: &[u8]| emitter.push(frame));
1000                emitter.flush(sink).await;
1001            }
1002            Some(Effect::SampleIlluminance { tid }) => {
1003                let millilux = env.sample_illuminance().await;
1004                session.respond_illuminance(tid, millilux, &mut |frame: &[u8]| emitter.push(frame));
1005                emitter.flush(sink).await;
1006            }
1007            Some(Effect::ReadTime { tid }) => {
1008                let epoch = env.read_time().await;
1009                session.respond_time(tid, epoch, &mut |frame: &[u8]| emitter.push(frame));
1010                emitter.flush(sink).await;
1011            }
1012            Some(Effect::SampleGnss { tid, key }) => {
1013                let sample = env.sample_gnss().await;
1014                session.respond_gnss(tid, key, sample, &mut |frame: &[u8]| emitter.push(frame));
1015                emitter.flush(sink).await;
1016            }
1017            Some(Effect::DrainQueue) => {
1018                // Deliver the covered frames one per
1019                // step, flushing between steps so the
1020                // two-slot emitter never overflows and
1021                // the transport applies backpressure.
1022                loop {
1023                    let more = session
1024                        .drain_step(Instant::now().as_millis(), &mut |frame: &[u8]| {
1025                            emitter.push(frame)
1026                        });
1027                    emitter.flush(sink).await;
1028                    if !more {
1029                        break;
1030                    }
1031                }
1032                env.clear_attention();
1033            }
1034            Some(Effect::SaveSnapshot { tid }) => {
1035                let result = match session.encode_snapshot(snapshot_buf) {
1036                    Some(len) => env.persist_snapshot(&snapshot_buf[..len]).await,
1037                    None => Err(()),
1038                };
1039                session.respond_save(tid, result, &mut |frame: &[u8]| emitter.push(frame));
1040                emitter.flush(sink).await;
1041            }
1042            Some(Effect::ClearSaved { tid }) => {
1043                // CMD_CLEAR covers all persisted
1044                // provisioning: the snapshot and the
1045                // independently persisted device
1046                // identity. Each journal's tombstone is
1047                // individually atomic; an interruption
1048                // between them reports failure and the
1049                // host's retry completes the erase.
1050                let result = match env.clear_snapshot().await {
1051                    Ok(()) => env.clear_identity().await,
1052                    Err(()) => Err(()),
1053                };
1054                // With the identity durably gone, its
1055                // counter boundaries are dead weight;
1056                // drop them with it. (Kept if the
1057                // identity clear failed — the identity
1058                // then survives the reboot and still
1059                // needs its TX boundary.)
1060                if result.is_ok() {
1061                    env.clear_counters().await;
1062                }
1063                session.respond_clear(tid, result, &mut |frame: &[u8]| emitter.push(frame));
1064                emitter.flush(sink).await;
1065            }
1066            Some(Effect::ProvisionIdentity { tid }) => {
1067                // Build the keypair (drawing a fresh
1068                // secret from the platform RNG for
1069                // on-device generation), persist it, and
1070                // only then report the public key.
1071                let result = match session.identity_request() {
1072                    Some(source) => {
1073                        let secret = match source {
1074                            IdentitySource::Install(secret) => Ok(secret),
1075                            IdentitySource::Generate => {
1076                                let mut secret = [0u8; 32];
1077                                env.fill_secret(&mut secret).map(|()| secret)
1078                            }
1079                        };
1080                        match secret {
1081                            Ok(secret) => {
1082                                let (public_key, payload) = device_identity_record(&secret);
1083                                env.persist_identity(&payload).await.map(|()| public_key)
1084                            }
1085                            Err(()) => Err(()),
1086                        }
1087                    }
1088                    None => Err(()),
1089                };
1090                session.respond_identity(tid, result, &mut |frame: &[u8]| emitter.push(frame));
1091                emitter.flush(sink).await;
1092            }
1093            Some(Effect::SetPairingPin { tid, pin }) => {
1094                let applied = env.apply_pairing_pin(pin).await;
1095                session.respond_pin_set(
1096                    tid,
1097                    applied.then_some(()).ok_or(()),
1098                    &mut |frame: &[u8]| emitter.push(frame),
1099                );
1100                emitter.flush(sink).await;
1101            }
1102            Some(Effect::BleClearBonds { tid }) => {
1103                // The answer goes out before the bonds are gone from the
1104                // live stack only in the sense that the flush below races
1105                // the disconnect; the platform does the durable work
1106                // first, so a host that hears OK has really been
1107                // forgotten. Over Bluetooth this reply is the last thing
1108                // the sender hears — dropping its bond drops its link.
1109                env.trace(format_args!("CMD_BLE_CLEAR_BONDS: forgetting every host"));
1110                let cleared = env.clear_ble_bonds().await;
1111                session.respond_ble_clear_bonds(
1112                    tid,
1113                    cleared.then_some(()).ok_or(()),
1114                    &mut |frame: &[u8]| emitter.push(frame),
1115                );
1116                emitter.flush(sink).await;
1117            }
1118            Some(Effect::SetBlePairing { tid, open }) => {
1119                let applied = env.set_ble_pairing(open).await;
1120                env.trace(format_args!(
1121                    "PROP_BLE_PAIRING <- {open}: {}",
1122                    if applied { "applied" } else { "refused" }
1123                ));
1124                session.respond_ble_pairing(
1125                    tid,
1126                    applied.then_some(open).ok_or(()),
1127                    &mut |frame: &[u8]| emitter.push(frame),
1128                );
1129                emitter.flush(sink).await;
1130            }
1131            Some(Effect::FactoryReset) => {
1132                // Hand off to the platform, which erases every
1133                // persistent journal and reboots. This never
1134                // returns; no acknowledgement is sent because the
1135                // reset drops the link. Any frames the session
1136                // already staged were flushed above.
1137                env.trace(format_args!("CMD_FACTORY_RESET: wiping all state + reboot"));
1138                env.factory_reset().await
1139            }
1140            Some(Effect::Reboot) => {
1141                // Same shape as the factory reset above and none of the
1142                // erasing: the platform restarts and the device comes
1143                // back as itself. Never returns, and answers nothing —
1144                // the reboot drops the link.
1145                env.trace(format_args!("CMD_REBOOT: restarting"));
1146                env.reboot().await
1147            }
1148            other => apply_effect(session, other, rt, env).await,
1149        }
1150        pending = session.resume_multi(Instant::now().as_millis(), &mut |frame: &[u8]| {
1151            emitter.push(frame)
1152        });
1153        emitter.flush(sink).await;
1154    }
1155    // Whatever this exchange was, the next frame is served on its own
1156    // terms. Restoring the local binding here rather than at the next
1157    // frame's arrival keeps every other emitting path — a publication, a
1158    // transmit completion, an alert deadline — reading the binding it
1159    // expects.
1160    session.end_admin_exchange();
1161    // A device identity always exists. `CMD_CLEAR` erases the stored one
1162    // without touching live state, and the `CMD_RST` that completes a
1163    // factory reset is where the live copy catches up — leaving the device
1164    // with none, which is the one state the invariant forbids. Regenerate
1165    // here, exactly as first boot would, so the only way to reach an
1166    // identityless device is to physically remove it from existence.
1167    //
1168    // The running device node keeps the *previous* key in its MAC until
1169    // the next boot (identity is fixed at bring-up), and stops
1170    // originating traffic because the dev-domain sync gate compares keys
1171    // rather than counting them.
1172    if session.dev_key().is_none() {
1173        regenerate_device_identity(session, env).await;
1174    }
1175    if session.queued_frame_count() == 0 {
1176        env.clear_attention();
1177    }
1178}
1179
1180/// Drive the ULCP session forever: restore persisted state, then
1181/// select over host frames, radio receptions, and transmit completions,
1182/// executing every session effect through the board's [`DeviceEnv`].
1183///
1184/// The caller constructs the [`Session`] with its board profile
1185/// (`SessionConfig`) and boot status, mounts its journals, and hands the
1186/// stored payloads in; the driver owns everything after that.
1187pub async fn run<A, S, const TXQ: usize, M, const RX: usize, const TX: usize, E>(
1188    mut session: Session<A, S, TXQ>,
1189    boot_snapshot: Option<&[u8]>,
1190    boot_identity: Option<[u8; 32]>,
1191    rt: DeviceRuntime<M, RX, TX>,
1192    mut env: E,
1193) -> !
1194where
1195    A: AesProvider,
1196    S: Sha256Provider,
1197    M: RawMutex,
1198    E: DeviceEnv,
1199{
1200    let mut emitter = Emitter::new();
1201    let mut arbitration = SessionArbitration::new(rt.session_gen.load(Ordering::Acquire));
1202    // Last device-domain generation mirrored to the device node.
1203    // Matches the session's initial value; the first mutation (or a
1204    // boot restore) publishes the first snapshot.
1205    let mut dev_domain_synced: u32 = session.dev_domain_version();
1206    // Shared staging buffer for the durable-write effect arms
1207    // (save/wipe). Held across their persist awaits, so as a
1208    // loop-lifetime local it costs one future slot instead of one
1209    // per arm.
1210    let mut snapshot_buf = [0u8; SNAPSHOT_MAX];
1211
1212    // The device identity is persisted independently of snapshots;
1213    // its post-reset value is whatever the identity journal holds.
1214    if let Some(public_key) = boot_identity {
1215        session.set_boot_identity(public_key);
1216    }
1217
1218    // Restore a stored snapshot before processing any host command:
1219    // the saved configuration is applied, the PHY re-enabled if it
1220    // was enabled when saved, and detached operation begins
1221    // immediately.
1222    //
1223    // A payload that does not decode is not the end of it. The journal
1224    // is multi-record and newest-generation-wins, so an older readable
1225    // generation usually sits behind the rejected one — and for an
1226    // unattended repeater, falling back to it is the only outcome that
1227    // keeps the device forwarding. Walk back a bounded number of
1228    // generations: a systematically undecodable payload is a firmware
1229    // bug rather than corruption, and re-walking the whole journal on
1230    // every boot would just be slower about it.
1231    if let Some(payload) = boot_snapshot {
1232        let mut generation = 0usize;
1233        let mut restored = session.restore_at_boot(payload);
1234        while let Err(error) = restored {
1235            env.trace(format_args!(
1236                "proto-store boot-restore generation=-{generation} REJECTED error={error:?}"
1237            ));
1238            session.note_snapshot_rejected();
1239            generation += 1;
1240            if generation > SNAPSHOT_FALLBACK_LIMIT {
1241                env.trace(format_args!(
1242                    "proto-store boot-restore fallback=EXHAUSTED limit={SNAPSHOT_FALLBACK_LIMIT}"
1243                ));
1244                break;
1245            }
1246            let Some(len) = env.older_snapshot(&mut snapshot_buf).await else {
1247                env.trace(format_args!("proto-store boot-restore fallback=NONE"));
1248                break;
1249            };
1250            restored = session.restore_at_boot(&snapshot_buf[..len]);
1251        }
1252        match restored {
1253            Ok(effect) => {
1254                if generation > 0 {
1255                    env.trace(format_args!(
1256                        "proto-store boot-restore=FALLBACK generation=-{generation}"
1257                    ));
1258                    env.report_snapshot_rejected(true);
1259                } else {
1260                    env.trace(format_args!("proto-store boot-restore=ok"));
1261                }
1262                apply_effect(&session, Some(effect), &rt, &mut env).await;
1263            }
1264            Err(_) => {
1265                env.trace(format_args!("proto-store boot-restore=BARE"));
1266                env.report_snapshot_rejected(false);
1267            }
1268        }
1269    }
1270
1271    // Publish the device domain once before any host interaction, on every
1272    // boot path rather than only after a successful restore.
1273    //
1274    // Two things depend on it. Detached multicast processing needs the
1275    // restored tables without waiting for an attach — the original reason.
1276    // And anything that waits for the domain to be published before acting
1277    // needs that publication to happen on a device that has never been
1278    // configured, where the answer is "the post-reset defaults" rather than
1279    // silence: the boot-time GNSS clock read waits on exactly this, and on
1280    // a bare device it would otherwise wait for a host that may never come.
1281    sync_dev_domain(&session, &mut dev_domain_synced, &mut env);
1282
1283    loop {
1284        // Resolve the next event in its own statement so the select's
1285        // futures — one of which mutably borrows `env` — are dropped
1286        // before the arms below use `env` again. A `match select4(..)`
1287        // scrutinee would hold them for the whole match.
1288        let event = {
1289            // Only wait for a TX completion while one is outstanding,
1290            // so a spurious tx_done can never be consumed early.
1291            let tx_done = async {
1292                if session.has_pending_tx() {
1293                    rt.radio.tx_done.wait().await
1294                } else {
1295                    core::future::pending().await
1296                }
1297            };
1298            // The locate alert's deadline. Enforced here rather than by
1299            // each board so that "a device MUST bound how long it will
1300            // remain in ALERT_LOCATE" holds for every board that
1301            // advertises CAP_ALERT, including ones whose UX layer has no
1302            // timer of its own. Idle (never completes) while no alert is
1303            // running. It borrows `session` immutably, alongside
1304            // `tx_done` — only `publish_event` touches `env`.
1305            let alert_deadline = async {
1306                match session.alert_deadline_ms() {
1307                    Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
1308                    None => core::future::pending().await,
1309                }
1310            };
1311            select4(
1312                rt.input.receive(),
1313                rt.radio.rx.receive(),
1314                tx_done,
1315                select(env.publish_event(), alert_deadline),
1316            )
1317            .await
1318        };
1319
1320        match event {
1321            Either4::First(InEvent::Attached(transport)) => {
1322                // Fresh session state for the new host session; the
1323                // device domain (PHY configuration and enable state,
1324                // device name, duty accounting) is deliberately
1325                // untouched and nothing is emitted (full-protocol
1326                // attach semantics).
1327                arbitration.attach(transport);
1328                rt.session_gen
1329                    .store(arbitration.generation(), Ordering::Release);
1330                env.set_advertising_allowed(arbitration.advertising_allowed());
1331                // Both transports meet their provisioning-security
1332                // binding here: the wired transport by physical
1333                // possession, BLE because the ULCP GATT service
1334                // refuses any access outside an encrypted LESC-bonded
1335                // link.
1336                session.attach(true);
1337                // PROP_MAC_BACKHAUL is session-scoped but its routing
1338                // lives in the mux, which the session-state reset above
1339                // cannot reach. A displaced session attaches without any
1340                // detach in between, so this is the reset that keeps a
1341                // predecessor's backhaul mode from leaving the new
1342                // session deaf to the air while its property reads 0.
1343                crate::radio_mux::MUX_MODE.set_backhaul(false);
1344            }
1345            Either4::First(InEvent::Detached(transport)) => {
1346                // Only the active transport's detach ends the
1347                // session; a displaced transport's stale detach
1348                // must not clear the successor's session state.
1349                if arbitration.detach(transport) {
1350                    env.set_advertising_allowed(true);
1351                    session.detach();
1352                    // The session that enabled backhaul is gone; without
1353                    // this, detached operation would keep the mux routing
1354                    // for a host that no longer exists and the device
1355                    // would stop queueing what it hears off the air.
1356                    crate::radio_mux::MUX_MODE.set_backhaul(false);
1357                }
1358            }
1359            Either4::First(InEvent::CancelAlert) => {
1360                // Whoever found the radio silenced it. Publishing the
1361                // transition is not conditional on a host being
1362                // attached — `cancel_alert` handles that — and a press
1363                // with no alert running is simply nothing.
1364                let effect = session.cancel_alert(&mut |frame: &[u8]| emitter.push(frame));
1365                emitter
1366                    .flush(&mut ReplySink::Transport {
1367                        destination: arbitration.destination(),
1368                        out: rt.out,
1369                    })
1370                    .await;
1371                apply_effect(&session, effect, &rt, &mut env).await;
1372            }
1373            Either4::First(InEvent::Toggle(setting)) => {
1374                // The switch itself reaches the platform through the
1375                // device-domain mirror at the bottom of this loop, like
1376                // every other write to it.
1377                let flipped = match setting {
1378                    Setting::Bluetooth => {
1379                        session.toggle_ble(&mut |frame: &[u8]| emitter.push(frame))
1380                    }
1381                    Setting::Gnss => session.toggle_gnss(&mut |frame: &[u8]| emitter.push(frame)),
1382                    Setting::ShareLocation => {
1383                        session.toggle_gnss_ident_update(&mut |frame: &[u8]| emitter.push(frame))
1384                    }
1385                    Setting::Forwarding => {
1386                        session.toggle_repeater(&mut |frame: &[u8]| emitter.push(frame))
1387                    }
1388                };
1389                if let Some(enabled) = flipped {
1390                    emitter
1391                        .flush(&mut ReplySink::Transport {
1392                            destination: arbitration.destination(),
1393                            out: rt.out,
1394                        })
1395                        .await;
1396                    if setting == Setting::Gnss {
1397                        env.gnss_switched(enabled);
1398                    }
1399                    // Keep an existing snapshot in step, so a switch the
1400                    // operator flipped is still flipped after a reboot.
1401                    // A device with nothing saved gets nothing saved:
1402                    // manufacturing a snapshot from a button press would
1403                    // persist every other live-only value with it.
1404                    if session.saved_status() != SavedStatus::None
1405                        && let Some(len) = session.encode_snapshot(&mut snapshot_buf)
1406                        && env.persist_snapshot(&snapshot_buf[..len]).await.is_ok()
1407                    {
1408                        session.note_snapshot_saved();
1409                    }
1410                    crate::log::debug_log(format_args!(
1411                        "ulcp: {:?} {} at the device",
1412                        setting,
1413                        if enabled { "ON" } else { "off" }
1414                    ));
1415                }
1416            }
1417            Either4::First(InEvent::ForceBluetoothOn) => {
1418                if session
1419                    .force_ble_on(&mut |frame: &[u8]| emitter.push(frame))
1420                    .is_some()
1421                {
1422                    emitter
1423                        .flush(&mut ReplySink::Transport {
1424                            destination: arbitration.destination(),
1425                            out: rt.out,
1426                        })
1427                        .await;
1428                    // Persisted like a toggle: the gesture is the device's
1429                    // own control for the property, and a radio rescued by
1430                    // it should stay rescued across the next reboot.
1431                    if session.saved_status() != SavedStatus::None
1432                        && let Some(len) = session.encode_snapshot(&mut snapshot_buf)
1433                        && env.persist_snapshot(&snapshot_buf[..len]).await.is_ok()
1434                    {
1435                        session.note_snapshot_saved();
1436                    }
1437                    crate::log::debug_log(format_args!(
1438                        "ulcp: Bluetooth forced ON by the power-on gesture"
1439                    ));
1440                }
1441            }
1442            Either4::First(InEvent::Frame(transport, frame_bytes)) => {
1443                if arbitration.accepts_frame(transport) {
1444                    let mut sink = ReplySink::Transport {
1445                        destination: arbitration.destination(),
1446                        out: rt.out,
1447                    };
1448                    serve_frame(
1449                        &mut session,
1450                        Exchange::Local(&frame_bytes),
1451                        &mut emitter,
1452                        &mut sink,
1453                        &mut snapshot_buf,
1454                        &rt,
1455                        &mut env,
1456                    )
1457                    .await;
1458                }
1459            }
1460            Either4::First(InEvent::Admin {
1461                frame,
1462                reply_budget,
1463            }) => {
1464                // The responder is blocked on the reply channel, so this
1465                // arm must always answer. An empty reply is the answer
1466                // for a reset-class command, and for anything the session
1467                // declined to respond to at all.
1468                let mut reply = AdminFrame::new();
1469                {
1470                    let mut sink = ReplySink::Admin { reply: &mut reply };
1471                    serve_frame(
1472                        &mut session,
1473                        Exchange::Admin {
1474                            frame: &frame,
1475                            reply_budget,
1476                        },
1477                        &mut emitter,
1478                        &mut sink,
1479                        &mut snapshot_buf,
1480                        &rt,
1481                        &mut env,
1482                    )
1483                    .await;
1484                }
1485                ADMIN_REPLY.send(reply).await;
1486            }
1487            Either4::Second(RxFrame { data, info }) => {
1488                // While detached this may stage a delegated MAC
1489                // acknowledgement (Effect::StartTransmit).
1490                let queued_before = session.queued_frame_count();
1491                let rx_info = match info.origin {
1492                    RxOrigin::Air => {
1493                        RadioRxInfo::measured(info.rssi, info.snr.as_centibels(), info.lqi)
1494                    }
1495                    // Backhaul frames travel host-to-node, so the session
1496                    // never sees one arriving.
1497                    RxOrigin::LocalTx | RxOrigin::Backhaul => RadioRxInfo::self_transmitted(),
1498                };
1499                let effect = session.on_radio_rx(
1500                    &data,
1501                    &rx_info,
1502                    Instant::now().as_millis(),
1503                    &mut |frame: &[u8]| emitter.push(frame),
1504                );
1505                if session.queued_frame_count() > queued_before {
1506                    env.request_attention();
1507                }
1508                emitter
1509                    .flush(&mut ReplySink::Transport {
1510                        destination: arbitration.destination(),
1511                        out: rt.out,
1512                    })
1513                    .await;
1514                apply_effect(&session, effect, &rt, &mut env).await;
1515            }
1516            Either4::Third(result) => {
1517                let now_ms = Instant::now().as_millis();
1518                let outcome = match result {
1519                    Ok(()) => TxOutcome::Sent,
1520                    Err(umsh_hal::TxError::CadTimeout) => TxOutcome::ChannelBusy,
1521                    Err(umsh_hal::TxError::Io(_)) => TxOutcome::Failed,
1522                };
1523                let effect =
1524                    session.on_tx_result(outcome, now_ms, &mut |frame: &[u8]| emitter.push(frame));
1525                emitter
1526                    .flush(&mut ReplySink::Transport {
1527                        destination: arbitration.destination(),
1528                        out: rt.out,
1529                    })
1530                    .await;
1531                apply_effect(&session, effect, &rt, &mut env).await;
1532            }
1533            Either4::Fourth(Either::First(event)) => {
1534                // The board decided this is worth announcing; publish it
1535                // unsolicited. Dropped silently while no host is
1536                // attached, and no effect can result — a publication is
1537                // not an operation.
1538                let emit = &mut |frame: &[u8]| emitter.push(frame);
1539                match event {
1540                    PublishEvent::Battery(sample) => {
1541                        session.publish_battery(sample, emit);
1542                    }
1543                    PublishEvent::Time(epoch) => {
1544                        session.publish_time(epoch, emit);
1545                    }
1546                    PublishEvent::Gnss(key, snapshot) => {
1547                        session.publish_gnss(key, &snapshot, emit);
1548                    }
1549                    PublishEvent::BleBondCount(count) => {
1550                        session.set_ble_bond_count(count, emit);
1551                    }
1552                    PublishEvent::BleLink(state) => {
1553                        session.set_ble_link(state, emit);
1554                    }
1555                    PublishEvent::BlePairing(open) => {
1556                        session.set_ble_pairing(open, emit);
1557                    }
1558                    PublishEvent::IdentityFix(location, altitude_m) => {
1559                        // The session clamps, compares, and bumps the
1560                        // device-domain version if the advertised
1561                        // position actually moved; the sync at the foot
1562                        // of this loop carries it to the identity.
1563                        session.absorb_ident_fix(&location, altitude_m);
1564                    }
1565                }
1566                emitter
1567                    .flush(&mut ReplySink::Transport {
1568                        destination: arbitration.destination(),
1569                        out: rt.out,
1570                    })
1571                    .await;
1572            }
1573            Either4::Fourth(Either::Second(())) => {
1574                // The alert outlived its deadline: stop the indication
1575                // and publish the transition the host did not command.
1576                let effect = session
1577                    .poll_alert(Instant::now().as_millis(), &mut |frame: &[u8]| {
1578                        emitter.push(frame)
1579                    });
1580                emitter
1581                    .flush(&mut ReplySink::Transport {
1582                        destination: arbitration.destination(),
1583                        out: rt.out,
1584                    })
1585                    .await;
1586                apply_effect(&session, effect, &rt, &mut env).await;
1587            }
1588        }
1589        // Any of the arms may have moved the device-domain tables
1590        // (property mutation, CMD_RST, CMD_RESTORE); one u32
1591        // compare when they did not.
1592        sync_dev_domain(&session, &mut dev_domain_synced, &mut env);
1593    }
1594}