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