umsh_hal/
lib.rs

1#![allow(async_fn_in_trait)]
2#![cfg_attr(not(feature = "std"), no_std)]
3
4//! Minimal hardware abstraction traits used by the higher UMSH layers.
5//!
6//! This crate is intentionally independent from the rest of the workspace so
7//! platform-specific radio or storage backends can depend on it without pulling
8//! in the full protocol stack.
9
10use core::num::NonZeroU8;
11use core::task::{Context, Poll};
12
13#[cfg(feature = "embassy")]
14mod embassy_clock;
15#[cfg(feature = "embassy")]
16pub use embassy_clock::EmbassyClock;
17
18pub mod wall_clock;
19
20/// Signal-to-noise ratio represented in centibels (0.1 dB units).
21///
22/// This uses a slightly finer unit than whole decibels while still staying
23/// compact and integer-friendly. Some common LoRa radios report SNR in
24/// quarter-dB steps. Converting those readings into centibels requires
25/// rounding, introducing at most 0.5 cB (0.05 dB) of error.
26#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct Snr(i16);
28
29impl Snr {
30    /// Construct an SNR value directly from centibels.
31    pub const fn from_centibels(centibels: i16) -> Self {
32        Self(centibels)
33    }
34
35    /// Construct an SNR value from whole decibels.
36    pub const fn from_decibels(db: i8) -> Self {
37        Self((db as i16) * 10)
38    }
39
40    /// Construct an SNR value from quarter-dB steps, rounding to the nearest
41    /// centibel.
42    pub const fn from_quarter_db_steps(steps: i16) -> Self {
43        let scaled = steps * 25;
44        let rounded = if scaled >= 0 {
45            (scaled + 5) / 10
46        } else {
47            (scaled - 5) / 10
48        };
49        Self(rounded)
50    }
51
52    /// Return the stored value in centibels.
53    pub const fn as_centibels(self) -> i16 {
54        self.0
55    }
56
57    /// Return the stored value in decibels.
58    pub const fn as_decibels(self) -> i16 {
59        self.0 / 10
60    }
61
62    /// Return the stored value in quarter-dB steps, rounding to the nearest
63    /// step — the form a peer-repeater entry reports.
64    ///
65    /// The rounding inverse of [`from_quarter_db_steps`](Self::from_quarter_db_steps):
66    /// every value that came from a quarter-dB step returns that step.
67    pub const fn as_quarter_db_steps(self) -> i16 {
68        let scaled = self.0.saturating_mul(2);
69        if scaled >= 0 {
70            (scaled + 2) / 5
71        } else {
72            (scaled - 2) / 5
73        }
74    }
75}
76
77impl core::fmt::Display for Snr {
78    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
79        let db = self.as_decibels();
80        let cdb = self.0 % 10;
81        write!(f, "{}.{:01}dB", db, cdb)
82    }
83}
84
85impl core::fmt::Debug for Snr {
86    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
87        write!(f, "Snr({self})")
88    }
89}
90
91/// Where a received frame came from.
92///
93/// Only [`RxOrigin::Air`] carries real measurements. The other two arrive
94/// through paths with no radio in them, so their `rssi`, `snr`, and `lqi`
95/// carry no information and must be reported as unmeasured wherever a
96/// receiver would otherwise read them as a link quality.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub enum RxOrigin {
99    /// A reception off the air.
100    #[default]
101    Air,
102    /// A copy of a frame this device just transmitted.
103    LocalTx,
104    /// A frame handed over by an attached host across a point-to-point link.
105    Backhaul,
106}
107
108impl RxOrigin {
109    /// Whether the accompanying signal fields are measurements.
110    pub const fn is_measured(self) -> bool {
111        matches!(self, Self::Air)
112    }
113}
114
115/// Metadata returned with a received frame.
116#[derive(Clone, Copy)]
117pub struct RxInfo {
118    /// Number of bytes written into the receive buffer.
119    pub len: usize,
120    /// Received signal strength in dBm.
121    pub rssi: i16,
122    /// Signal-to-noise ratio in centibels.
123    pub snr: Snr,
124    /// Optional link-quality indicator in a radio-specific normalized scale.
125    pub lqi: Option<NonZeroU8>,
126    /// The path this frame took to reach the receiver.
127    pub origin: RxOrigin,
128}
129
130/// Channel-activity-detection (CAD) policy applied before a transmit.
131#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
132pub enum CadPolicy {
133    /// Skip CAD entirely and transmit immediately.
134    #[default]
135    Skip,
136    /// Perform CAD once and transmit only if the channel is currently clear.
137    /// Equivalent to a retry budget of zero.
138    Gate,
139    /// Retry CAD until the channel is clear or `timeout_ms` elapses.
140    RetryFor { timeout_ms: u32 },
141}
142
143/// Options controlling how a frame is transmitted.
144#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
145pub struct TxOptions {
146    /// Channel-activity-detection policy applied before this transmit.
147    pub cad: CadPolicy,
148}
149
150/// Error returned by [`Radio::transmit`].
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum TxError<E> {
153    /// CAD did not find the channel clear before the timeout expired.
154    CadTimeout,
155    /// Platform-specific radio or transport failure.
156    Io(E),
157}
158
159/// Half-duplex radio abstraction used by the MAC coordinator.
160pub trait Radio {
161    type Error;
162
163    /// Transmit a complete raw UMSH frame.
164    async fn transmit(
165        &mut self,
166        data: &[u8],
167        options: TxOptions,
168    ) -> Result<(), TxError<Self::Error>>;
169
170    /// Poll reception of one frame into `buf`.
171    ///
172    /// `Poll::Pending` means no frame is currently available right now. The
173    /// call does not reserve any receive state; a later poll after transmit
174    /// completion can resume probing immediately.
175    fn poll_receive(
176        &mut self,
177        cx: &mut Context<'_>,
178        buf: &mut [u8],
179    ) -> Poll<Result<RxInfo, Self::Error>>;
180
181    /// Return the largest supported raw frame size.
182    fn max_frame_size(&self) -> usize;
183    /// Return the approximate airtime for a maximum-length frame.
184    fn t_frame_ms(&self) -> u32;
185}
186
187/// Monotonic millisecond clock.
188pub trait Clock {
189    /// Return milliseconds since the device booted.
190    ///
191    /// Most of the stack only ever takes differences and would be happy
192    /// with any monotonic epoch, but `PROP_UPTIME` reports this value
193    /// directly, so the epoch is part of the contract: an implementation
194    /// backed by a clock that starts somewhere else MUST subtract its own
195    /// origin. A simulated device that emulates a reboot has to restart
196    /// this along with the rest of the hardware.
197    fn now_ms(&self) -> u64;
198
199    /// Poll a delay that completes when the monotonic clock reaches `deadline_ms`.
200    ///
201    /// Returns `Poll::Ready(())` if the deadline has already passed. Otherwise
202    /// the implementation MUST register `cx.waker()` with a platform timer and
203    /// return `Poll::Pending`, so the task is woken when the deadline elapses.
204    ///
205    /// The default implementation has no real timer: it schedules an immediate
206    /// re-poll (via `cx.waker().wake_by_ref()`) and returns `Poll::Pending`, causing
207    /// the caller to busy-poll until the monotonic clock reaches the deadline.
208    /// This is correct but wastes CPU; platform clocks backed by a real timer
209    /// (tokio, embassy, etc.) MUST override this to sleep efficiently. A default
210    /// that returned `Ready` unconditionally would spin just as hard; one that
211    /// returned `Pending` without waking would stall timer-driven work entirely.
212    fn poll_delay_until(&self, cx: &mut Context<'_>, deadline_ms: u64) -> Poll<()> {
213        let _ = deadline_ms;
214        cx.waker().wake_by_ref();
215        Poll::Pending
216    }
217}
218
219/// Persistent peer directory for the node layer.
220///
221/// Stores and retrieves peer records keyed by the raw 32-byte Ed25519 public
222/// key. `alias` is an optional UTF-8 label (the implementation may silently
223/// truncate values longer than 16 bytes). Implementors MUST treat `store_peer`
224/// as an upsert — calling it twice for the same key overwrites the record.
225pub trait PeerStore {
226    type Error;
227
228    /// Upsert the peer record for `key`. `alias`, if present, is a UTF-8
229    /// display label; passing `None` removes any existing alias.
230    async fn store_peer(&self, key: &[u8; 32], alias: Option<&[u8]>) -> Result<(), Self::Error>;
231
232    /// Remove the peer record for `key`. A no-op if the key is not present.
233    async fn delete_peer(&self, key: &[u8; 32]) -> Result<(), Self::Error>;
234
235    /// Invoke `f` for every persisted peer record.
236    ///
237    /// `alias` is `None` when no alias was stored for that key. Callers that
238    /// cannot handle an error mid-iteration should collect into a local buffer
239    /// first, then process asynchronously.
240    async fn for_each_peer(
241        &self,
242        f: &mut dyn FnMut(&[u8; 32], Option<&[u8]>),
243    ) -> Result<(), Self::Error>;
244}
245
246/// No-op peer store — use when peer persistence is not needed.
247pub struct NoPeerStore;
248
249impl PeerStore for NoPeerStore {
250    type Error = core::convert::Infallible;
251
252    async fn store_peer(&self, _: &[u8; 32], _: Option<&[u8]>) -> Result<(), Self::Error> {
253        Ok(())
254    }
255
256    async fn delete_peer(&self, _: &[u8; 32]) -> Result<(), Self::Error> {
257        Ok(())
258    }
259
260    async fn for_each_peer(
261        &self,
262        _: &mut dyn FnMut(&[u8; 32], Option<&[u8]>),
263    ) -> Result<(), Self::Error> {
264        Ok(())
265    }
266}
267
268/// Persistent channel directory for the node layer.
269///
270/// Stores and retrieves shared channel keys keyed by channel name (UTF-8,
271/// up to 16 bytes). Implementors MUST treat `store_channel` as an upsert.
272pub trait ChannelStore {
273    type Error;
274
275    /// Upsert the channel record for `name`.
276    async fn store_channel(&self, name: &[u8], key: &[u8; 32]) -> Result<(), Self::Error>;
277
278    /// Remove the channel record for `name`. A no-op if not present.
279    async fn delete_channel(&self, name: &[u8]) -> Result<(), Self::Error>;
280
281    /// Invoke `f` for every persisted channel record.
282    ///
283    /// `name` is UTF-8 channel name bytes; `key` is the 32-byte channel key.
284    async fn for_each_channel(
285        &self,
286        f: &mut dyn FnMut(&[u8], &[u8; 32]),
287    ) -> Result<(), Self::Error>;
288}
289
290/// No-op channel store — use when channel persistence is not needed.
291pub struct NoChannelStore;
292
293impl ChannelStore for NoChannelStore {
294    type Error = core::convert::Infallible;
295
296    async fn store_channel(&self, _: &[u8], _: &[u8; 32]) -> Result<(), Self::Error> {
297        Ok(())
298    }
299
300    async fn delete_channel(&self, _: &[u8]) -> Result<(), Self::Error> {
301        Ok(())
302    }
303
304    async fn for_each_channel(
305        &self,
306        _: &mut dyn FnMut(&[u8], &[u8; 32]),
307    ) -> Result<(), Self::Error> {
308        Ok(())
309    }
310}
311
312/// Persistent frame-counter storage.
313pub trait CounterStore {
314    type Error;
315
316    /// Load the stored counter for `context`, or `0` if missing.
317    async fn load(&self, context: &[u8]) -> Result<u32, Self::Error>;
318    /// Persist a counter value for `context`.
319    async fn store(&self, context: &[u8], value: u32) -> Result<(), Self::Error>;
320    /// Flush any buffered state to durable storage.
321    async fn flush(&self) -> Result<(), Self::Error>;
322}
323
324/// No-op counter store — counters restart from zero every boot.
325///
326/// Use only where counter persistence is not (yet) load-bearing: a node
327/// that sends nothing but unsecured broadcasts, or a bring-up stage whose
328/// durable store lands later. Reusing TX counters after a reboot breaks
329/// replay protection for secured traffic.
330pub struct NoCounterStore;
331
332impl CounterStore for NoCounterStore {
333    type Error = core::convert::Infallible;
334
335    async fn load(&self, _: &[u8]) -> Result<u32, Self::Error> {
336        Ok(0)
337    }
338
339    async fn store(&self, _: &[u8], _: u32) -> Result<(), Self::Error> {
340        Ok(())
341    }
342
343    async fn flush(&self) -> Result<(), Self::Error> {
344        Ok(())
345    }
346}
347
348/// Optional power-control hook for higher layers (e.g. the CLI).
349///
350/// Implementations request a controlled shutdown — the actual sequencing
351/// (display, storage flush, GPIO sense, entering System OFF, etc.) lives
352/// in the firmware that owns those peripherals. This call MUST return
353/// promptly; it's typically a `Signal::signal(())` to a shutdown task.
354pub trait PowerControl {
355    fn request_power_off(&self);
356
357    /// Request a soft reboot. The default implementation is a no-op; targets
358    /// without a wired reboot path can leave it as such. Like
359    /// [`request_power_off`](Self::request_power_off), this MUST return
360    /// promptly — typically a `Signal::signal(())` to a reboot task that
361    /// performs any final flushes before triggering a system reset.
362    fn request_reboot(&self) {}
363}
364
365/// No-op power control — use when shutdown is not implemented for the target.
366pub struct NoPowerControl;
367
368impl PowerControl for NoPowerControl {
369    fn request_power_off(&self) {}
370}
371
372/// Persistent key-value store used by higher layers for cached state.
373pub trait KeyValueStore {
374    type Error;
375
376    /// Load a value into `buf`, returning the stored length when present.
377    async fn load(&self, key: &[u8], buf: &mut [u8]) -> Result<Option<usize>, Self::Error>;
378    /// Store a value for `key`.
379    async fn store(&self, key: &[u8], value: &[u8]) -> Result<(), Self::Error>;
380    /// Delete any stored value for `key`.
381    async fn delete(&self, key: &[u8]) -> Result<(), Self::Error>;
382}
383
384/// No-op key-value store — loads find nothing, stores succeed silently.
385/// Use when a platform bundle needs the associated type but nothing in
386/// the deployment reads cached state back.
387pub struct NoKeyValueStore;
388
389impl KeyValueStore for NoKeyValueStore {
390    type Error = core::convert::Infallible;
391
392    async fn load(&self, _: &[u8], _: &mut [u8]) -> Result<Option<usize>, Self::Error> {
393        Ok(None)
394    }
395
396    async fn store(&self, _: &[u8], _: &[u8]) -> Result<(), Self::Error> {
397        Ok(())
398    }
399
400    async fn delete(&self, _: &[u8]) -> Result<(), Self::Error> {
401        Ok(())
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::Snr;
408
409    /// The wire form of an SNR in a peer-repeater entry is quarter-dB
410    /// steps, so a value that arrived as one has to leave as the same one
411    /// — otherwise relaying an observation would drift it a step at a
412    /// time.
413    #[test]
414    fn quarter_db_steps_round_trip_through_centibels() {
415        for steps in -128..=127i16 {
416            assert_eq!(
417                Snr::from_quarter_db_steps(steps).as_quarter_db_steps(),
418                steps,
419                "steps {steps}"
420            );
421        }
422    }
423
424    #[test]
425    fn quarter_db_steps_round_to_the_nearest_step() {
426        assert_eq!(Snr::from_decibels(0).as_quarter_db_steps(), 0);
427        assert_eq!(Snr::from_decibels(5).as_quarter_db_steps(), 20);
428        assert_eq!(Snr::from_decibels(-7).as_quarter_db_steps(), -28);
429        // 0.3 dB sits between the first and second step and rounds up.
430        assert_eq!(Snr::from_centibels(3).as_quarter_db_steps(), 1);
431    }
432}