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}