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}