umsh_radio_loraphy/lib.rs
1//! lora-phy-backed LoRa radio driver implementing `umsh_hal::Radio`.
2//!
3//! Works with any chip that implements `lora_phy::mod_traits::RadioKind`
4//! (SX126x, LR11xx, etc.). Per-board parameters (frequency, modulation,
5//! preamble, TCXO, RF switch) are supplied by the caller — this crate
6//! only owns the RX/TX state machine.
7//!
8//! # Architecture
9//!
10//! Two concurrent actors share a [`Channels`] bundle:
11//!
12//! 1. **[`runner`]** — an Embassy task that owns the `lora_phy::LoRa` instance.
13//! It loops between continuous RX and TX: when a TX request arrives on the
14//! TX channel it exits RX, transmits, signals the result, then re-enters RX.
15//! A request that arrives while a frame is being received is refused with
16//! [`TxError::CadTimeout`] (up to [`RX_GATE_MAX_STRIKES`] times) instead of
17//! tearing down the reception — the channel is busy either way, and the MAC
18//! already backs off and retries on that error.
19//!
20//! 2. **[`LoraphyRadio`]** — a lightweight handle used by the MAC coordinator.
21//! It borrows `&'static Channels` for `transmit()` (sends request, awaits
22//! result signal) and `poll_receive()` (non-blocking probe of the RX channel
23//! with waker registration via `AtomicWaker`).
24//!
25//! # Usage
26//!
27//! ```ignore
28//! use umsh_radio_loraphy::{Channels, LoraphyRadio};
29//! use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
30//!
31//! static RADIO_CH: Channels<ThreadModeRawMutex, 4, 2> = Channels::new();
32//! // Spawn runner(lora, &RADIO_CH, mdltn, rx_pkt, tx_pkt, power_dbm).
33//! // Pass LoraphyRadio::new(&RADIO_CH, t_frame_ms) to the MAC.
34//! ```
35
36#![no_std]
37#![allow(async_fn_in_trait)]
38
39use core::sync::atomic::{AtomicBool, Ordering};
40use core::task::{Context, Poll};
41
42use embassy_futures::select::{Either, select};
43use embassy_sync::{
44 blocking_mutex::raw::RawMutex, channel::Channel, signal::Signal, waitqueue::AtomicWaker,
45};
46use heapless::Vec;
47use lora_phy::{
48 LoRa, RxMode,
49 mod_params::{
50 Bandwidth, CodingRate, DutyCycleParams, ModulationParams, PacketParams, RadioError,
51 SpreadingFactor,
52 },
53 mod_traits::{IrqState, RadioKind},
54};
55pub use umsh_hal::{CadPolicy, TxError};
56use umsh_hal::{RxInfo, RxOrigin, Snr, TxOptions};
57/// The vetted PHY profiles, re-exported so a board crate that depends
58/// only on this one can name a profile without a manifest entry.
59pub use umsh_ulcp::profiles;
60use umsh_ulcp::profiles::PhyProfile;
61use umsh_ulcp::stats::{Counter, StatsLedger};
62
63/// Tally a reception the demodulator rejected on CRC.
64///
65/// A board with no ledger passes `None` and the counting costs nothing;
66/// the bringup consoles do exactly that.
67fn note_bad_crc(stats: Option<&'static StatsLedger>) {
68 if let Some(stats) = stats {
69 stats.bump(Counter::RxBadCrc);
70 }
71}
72
73/// Maximum SX1262 LoRa payload: 255 bytes.
74pub const MAX_PAYLOAD: usize = 255;
75
76// ─── Frame types ─────────────────────────────────────────────────────────────
77
78/// A received frame plus signal metadata.
79pub struct RxFrame {
80 pub data: Vec<u8, MAX_PAYLOAD>,
81 pub info: RxInfo,
82}
83
84/// A queued transmit request from the MAC to the runner task.
85pub struct TxRequest {
86 pub data: Vec<u8, MAX_PAYLOAD>,
87 /// Per-frame TX power override in dBm; `None` uses the runner's
88 /// configured power.
89 pub power_dbm: Option<i32>,
90 /// Channel-activity-detection policy applied by the runner before
91 /// keying up. A busy channel completes the request with
92 /// [`TxError::CadTimeout`] instead of transmitting.
93 pub cad: CadPolicy,
94}
95
96// ─── Channels ────────────────────────────────────────────────────────────────
97
98/// Shared state between [`LoraphyRadio`] and [`runner`]. Place in a `static`.
99///
100/// - `M`: raw mutex type (e.g. `ThreadModeRawMutex` for single-core Embassy).
101/// - `RX`: depth of the receive queue.
102/// - `TX`: depth of the transmit-request queue.
103pub struct Channels<M: RawMutex, const RX: usize, const TX: usize> {
104 pub rx: Channel<M, RxFrame, RX>,
105 pub tx: Channel<M, TxRequest, TX>,
106 pub tx_done: Signal<M, Result<(), TxError<RadioError>>>,
107 pub rx_waker: AtomicWaker,
108}
109
110impl<M: RawMutex, const RX: usize, const TX: usize> Channels<M, RX, TX> {
111 pub const fn new() -> Self {
112 Self {
113 rx: Channel::new(),
114 tx: Channel::new(),
115 tx_done: Signal::new(),
116 rx_waker: AtomicWaker::new(),
117 }
118 }
119}
120
121// ─── LoraphyRadio ────────────────────────────────────────────────────────────
122
123/// Implements `umsh_hal::Radio` over the shared [`Channels`].
124///
125/// The actual TX power and modulation params live on the `runner` side (it
126/// owns the `LoRa` driver). This handle only carries:
127/// - the channel pair used to talk to the runner,
128/// - a precomputed worst-case airtime so the MAC's scheduler doesn't have
129/// to recompute it.
130pub struct LoraphyRadio<M: RawMutex + 'static, const RX: usize, const TX: usize> {
131 ch: &'static Channels<M, RX, TX>,
132 t_frame_ms: u32,
133}
134
135impl<M: RawMutex + 'static, const RX: usize, const TX: usize> LoraphyRadio<M, RX, TX> {
136 /// Use [`airtime_ms`] with your modulation settings to compute `t_frame_ms`.
137 pub fn new(ch: &'static Channels<M, RX, TX>, t_frame_ms: u32) -> Self {
138 Self { ch, t_frame_ms }
139 }
140}
141
142impl<M: RawMutex + 'static, const RX: usize, const TX: usize> umsh_hal::Radio
143 for LoraphyRadio<M, RX, TX>
144{
145 type Error = RadioError;
146
147 async fn transmit(
148 &mut self,
149 data: &[u8],
150 options: TxOptions,
151 ) -> Result<(), TxError<Self::Error>> {
152 let mut frame_data: Vec<u8, MAX_PAYLOAD> = Vec::new();
153 frame_data
154 .extend_from_slice(data)
155 .map_err(|_| TxError::Io(RadioError::PayloadSizeUnexpected(data.len())))?;
156 self.ch
157 .tx
158 .send(TxRequest {
159 data: frame_data,
160 power_dbm: None,
161 cad: options.cad,
162 })
163 .await;
164 self.ch.tx_done.wait().await
165 }
166
167 fn poll_receive(
168 &mut self,
169 cx: &mut Context<'_>,
170 buf: &mut [u8],
171 ) -> Poll<Result<RxInfo, Self::Error>> {
172 // Fast path: frame already in queue.
173 if let Ok(frame) = self.ch.rx.try_receive() {
174 return Poll::Ready(Ok(copy_frame(frame, buf)));
175 }
176 // Register waker then double-check to close the TOCTOU race between
177 // the try_receive above and the runner pushing a frame.
178 self.ch.rx_waker.register(cx.waker());
179 if let Ok(frame) = self.ch.rx.try_receive() {
180 return Poll::Ready(Ok(copy_frame(frame, buf)));
181 }
182 Poll::Pending
183 }
184
185 fn max_frame_size(&self) -> usize {
186 MAX_PAYLOAD
187 }
188
189 fn t_frame_ms(&self) -> u32 {
190 self.t_frame_ms
191 }
192}
193
194/// Copy a received frame into a caller-provided buffer, truncating if the
195/// caller's buffer is smaller than the frame.
196fn copy_frame(frame: RxFrame, buf: &mut [u8]) -> RxInfo {
197 let n = frame.data.len().min(buf.len());
198 buf[..n].copy_from_slice(&frame.data[..n]);
199 frame.info
200}
201
202// ─── Runner ──────────────────────────────────────────────────────────────────
203
204/// Maximum consecutive TX requests refused with [`TxError::CadTimeout`] while
205/// a reception appears to be in progress (preamble seen, frame not finished).
206///
207/// Refusing without touching the radio keeps the in-flight frame receivable —
208/// tearing down RX for the CAD gate would lose it, and the gate would report
209/// busy anyway. The chip raises no IRQ when a detected preamble turns out to
210/// be noise, so the flag can go stale; after this many refusals the next
211/// request falls through to the radio's own CAD, which either confirms the
212/// channel is busy or clears the way (the TX path re-prepares RX afterwards,
213/// resetting the gate).
214pub const RX_GATE_MAX_STRIKES: u8 = 3;
215
216/// Execute one transmit request: CAD gate (listen-before-talk) per the
217/// request's [`CadPolicy`], then transmit.
218///
219/// `CadPolicy::RetryFor` is intentionally handled as a single gate, like
220/// `Gate`: the runner has no time source, and the MAC coordinator already
221/// owns retry pacing by backing off on [`TxError::CadTimeout`] and
222/// re-queueing the frame.
223///
224/// NOT cancel-safe (`cad`, `prepare_for_tx`, and `tx` must all run to
225/// completion) — call outside any `select` branch, like the TX arm it
226/// replaces.
227async fn perform_tx<RK, DLY>(
228 lora: &mut LoRa<RK, DLY>,
229 mdltn: &ModulationParams,
230 tx_pkt: &mut PacketParams,
231 default_power_dbm: i32,
232 tx_req: &TxRequest,
233) -> Result<(), TxError<RadioError>>
234where
235 RK: RadioKind,
236 DLY: embedded_hal_async::delay::DelayNs,
237{
238 if !matches!(tx_req.cad, CadPolicy::Skip) {
239 let busy = async {
240 lora.prepare_for_cad(mdltn).await?;
241 lora.cad(mdltn).await
242 }
243 .await
244 .map_err(TxError::Io)?;
245 if busy {
246 return Err(TxError::CadTimeout);
247 }
248 }
249 let power = tx_req.power_dbm.unwrap_or(default_power_dbm);
250 async {
251 lora.prepare_for_tx(mdltn, tx_pkt, power, &tx_req.data)
252 .await?;
253 lora.tx().await
254 }
255 .await
256 .map_err(TxError::Io)
257}
258
259/// Background loop: owns the `lora_phy::LoRa` instance, switches between
260/// continuous RX and TX as requests arrive. Never returns.
261///
262/// Wrap this in a `#[embassy_executor::task]` in the binary crate so the
263/// concrete monomorphisation is visible to the linker.
264///
265/// # Cancellation safety
266///
267/// `wait_for_irq` is the only `await` point that may be cancelled (it just
268/// awaits a DIO edge and is safe to drop). `process_irq_event`,
269/// `prepare_for_tx`, and `tx` all run to completion outside any `select`
270/// branch — cancelling those leaves the radio in a wedged state from which
271/// `prepare_for_tx` will hang forever (lora-phy explicitly warns against
272/// dropping `process_irq_event` futures). The convenience `lora.rx()`
273/// helper internally calls `complete_rx`/`process_irq_event`, so it is
274/// **not** safe inside a `select` either; we hand-roll the IRQ loop here
275/// to keep cancellation pinned to `wait_for_irq`.
276pub async fn runner<RK, DLY, M, const RX: usize, const TX: usize>(
277 mut lora: LoRa<RK, DLY>,
278 ch: &'static Channels<M, RX, TX>,
279 mdltn: ModulationParams,
280 rx_pkt: PacketParams,
281 mut tx_pkt: PacketParams,
282 power_dbm: i32,
283 stats: Option<&'static StatsLedger>,
284) -> !
285where
286 RK: RadioKind,
287 DLY: embedded_hal_async::delay::DelayNs,
288 M: RawMutex,
289{
290 let mut rx_buf = [0u8; MAX_PAYLOAD];
291
292 'outer: loop {
293 if lora
294 .prepare_for_rx(RxMode::Continuous, &mdltn, &rx_pkt)
295 .await
296 .is_err()
297 {
298 continue;
299 }
300 if lora.start_rx().await.is_err() {
301 continue;
302 }
303
304 // Inner loop: stay in continuous RX, handling partial-packet IRQs
305 // (PreambleReceived) without re-preparing. Break back to the outer
306 // loop to re-prepare RX after a completed frame, an error, or a TX.
307 let mut rx_in_progress = false;
308 let mut rx_gate_strikes: u8 = 0;
309 loop {
310 match select(lora.wait_for_irq(), ch.tx.receive()).await {
311 Either::First(Ok(())) => {
312 // process_irq_event is NOT cancel-safe — it MUST run to
313 // completion. The public method passes clear_interrupts=false
314 // (unlike complete_rx's internal call), so we explicitly
315 // clear afterwards or DIO1 stays latched high on LR1110.
316 let irq_result = lora.process_irq_event().await;
317 let _ = lora.clear_irq_status().await;
318
319 match irq_result {
320 Ok(Some(IrqState::Done)) => {
321 if let Ok((len, status)) =
322 lora.get_rx_result(&rx_pkt, &mut rx_buf).await
323 {
324 let mut data: Vec<u8, MAX_PAYLOAD> = Vec::new();
325 let _ = data.extend_from_slice(&rx_buf[..len as usize]);
326 let info = RxInfo {
327 len: len as usize,
328 rssi: status.rssi,
329 snr: Snr::from_decibels(status.snr as i8),
330 lqi: None,
331 origin: RxOrigin::Air,
332 };
333 if ch.rx.try_send(RxFrame { data, info }).is_ok() {
334 ch.rx_waker.wake();
335 }
336 }
337 continue 'outer; // re-prepare RX for the next frame
338 }
339 Ok(Some(IrqState::PreambleReceived)) => {
340 rx_in_progress = true; // gate TX until the frame resolves
341 continue;
342 }
343 Ok(_) => continue, // no-op IRQ: stay in RX
344 // A failed payload CRC lands here, and this is the
345 // only place it can be caught: repeaters cannot check
346 // a MIC or a signature, so a corrupt frame that gets
347 // past this point is forwarded, and its damaged bytes
348 // give it a duplicate-cache identity no node in the
349 // mesh has seen. The frame is dropped and RX
350 // re-prepared; the bytes are left unread — but it is
351 // counted, because a climbing CRC tally beside a flat
352 // packet count is the signature of interference and
353 // is otherwise invisible from anywhere above here.
354 Err(RadioError::CrcError) => {
355 note_bad_crc(stats);
356 continue 'outer;
357 }
358 // Everything else is the driver or the bus failing,
359 // not the air. Counting it as a bad CRC would put
360 // SPI trouble in a column an operator reads as
361 // interference.
362 Err(_) => continue 'outer,
363 }
364 }
365 Either::First(Err(_)) => continue 'outer,
366 Either::Second(tx_req) => {
367 // A reception is in flight: refuse instead of tearing down
368 // RX for the CAD gate (which would lose the frame and
369 // report busy anyway). The MAC treats this like any other
370 // busy verdict and retries after backoff.
371 if rx_in_progress && rx_gate_strikes < RX_GATE_MAX_STRIKES {
372 rx_gate_strikes += 1;
373 ch.tx_done.signal(Err(TxError::CadTimeout));
374 continue;
375 }
376 // TX is also NOT cancel-safe — run the CAD gate and
377 // prepare_for_tx + tx to completion outside any select.
378 let result =
379 perform_tx(&mut lora, &mdltn, &mut tx_pkt, power_dbm, &tx_req).await;
380 ch.tx_done.signal(result);
381 continue 'outer; // chip is left in standby — re-prepare RX
382 }
383 }
384 }
385 }
386}
387
388// ─── ULCP device runner ──────────────────────────────────────────────────────
389
390/// Radio settings applied at runtime by the ULCP session.
391#[derive(Clone, Copy, Debug, PartialEq)]
392pub struct DeviceSettings {
393 pub enabled: bool,
394 pub freq_hz: u32,
395 pub sf: SpreadingFactor,
396 pub bw: Bandwidth,
397 pub cr: CodingRate,
398 pub power_dbm: i32,
399}
400
401/// Control handle for [`device_runner`]: latest-wins settings updates and
402/// on-demand instantaneous-RSSI sampling. Place in a `static` next to the
403/// [`Channels`].
404pub struct DeviceControl<M: RawMutex> {
405 settings: Signal<M, DeviceSettings>,
406 rssi_req: Signal<M, ()>,
407 rssi_resp: Signal<M, Result<i16, ()>>,
408 shutdown: AtomicBool,
409}
410
411impl<M: RawMutex> DeviceControl<M> {
412 pub const fn new() -> Self {
413 Self {
414 settings: Signal::new(),
415 rssi_req: Signal::new(),
416 rssi_resp: Signal::new(),
417 shutdown: AtomicBool::new(false),
418 }
419 }
420
421 /// Put the radio into chip sleep and stop the runner.
422 ///
423 /// Terminal, and meant for the board's own power-off path: the
424 /// runner parks forever once it observes this, so nothing after it
425 /// can transmit or receive. The SX1262 keeps its own supply while
426 /// the host MCU is in deep sleep, so skipping this would leave the
427 /// chip receiving and dominate the sleeping board's current draw.
428 ///
429 /// The flag is what the runner acts on; the settings signal only
430 /// exists to break it out of RX so it can look.
431 pub fn shutdown(&self) {
432 self.shutdown.store(true, Ordering::Release);
433 self.settings.signal(DeviceSettings {
434 enabled: false,
435 freq_hz: profiles::DEFAULT.freq_khz * 1_000,
436 sf: SpreadingFactor::_7,
437 bw: Bandwidth::_125KHz,
438 cr: CodingRate::_4_5,
439 power_dbm: 0,
440 });
441 }
442
443 /// Apply new settings. The runner picks them up at its next await
444 /// point and rebuilds modulation/packet params.
445 pub fn apply(&self, settings: DeviceSettings) {
446 self.settings.signal(settings);
447 }
448
449 /// Request an instantaneous-RSSI sample from the runner. Pair with
450 /// [`wait_rssi`](Self::wait_rssi). Only meaningful while the radio is in RX
451 /// (i.e. enabled); the caller is responsible for that gating.
452 pub fn request_rssi(&self) {
453 self.rssi_resp.reset();
454 self.rssi_req.signal(());
455 }
456
457 /// Await the RSSI sample requested via [`request_rssi`](Self::request_rssi),
458 /// in dBm. `Err(())` means the read failed at the radio.
459 pub async fn wait_rssi(&self) -> Result<i16, ()> {
460 self.rssi_resp.wait().await
461 }
462}
463
464impl<M: RawMutex> Default for DeviceControl<M> {
465 fn default() -> Self {
466 Self::new()
467 }
468}
469
470// ─── RX strategy ─────────────────────────────────────────────────────────────
471
472/// How the runner keeps the radio listening between frames.
473#[derive(Clone, Copy, Debug, PartialEq)]
474pub enum RxStrategy {
475 /// The chip sits in continuous RX. Works on every supported radio;
476 /// costs the chip's full RX current around the clock.
477 Continuous,
478 /// SX126x-style `SetRxDutyCycle` preamble sniffing: the chip's own
479 /// sequencer alternates short RX windows with sleep, sized against
480 /// the sender's TX preamble so no frame is missed (see
481 /// [`duty_cycle_rx_mode`] for the sizing rule). The MCU sees exactly
482 /// the same DIO1 IRQs as in continuous mode.
483 ///
484 /// Only for chips whose driver implements `RxMode::DutyCycle`
485 /// (SX126x, LR11xx). An SX127x rejects it with
486 /// `DutyCycleUnsupported` at RX setup, which the runner's
487 /// prepare-retry loop turns into a busy spin — SX127x boards must
488 /// pass [`RxStrategy::Continuous`].
489 PreambleDutyCycle,
490}
491
492/// One `SetRxDutyCycle` timer unit is 15.625 µs (24-bit registers).
493const DUTY_CYCLE_UNIT_NS: u64 = 15_625;
494
495/// Time the SX126x spends restarting its TCXO on each duty-cycle wake,
496/// mirroring lora-phy's `BRD_TCXO_WAKEUP_TIME` (5 ms since fork rev
497/// bddfba7a, not exported — keep the two in lockstep). Each RX window
498/// is inflated by this much so the sniff window survives even if the
499/// chip bills the TCXO settling time against `rx_time`; if the chip
500/// instead settles before starting the window timer, the extra is a
501/// small power cost, never a missed frame.
502const TCXO_WAKEUP_NS: u64 = 5_000_000;
503
504/// Pick the RX mode for a strategy at the given modulation settings.
505///
506/// The duty-cycle windows treat `rx_preamble` (the receiver's configured
507/// acquisition length) as the number of preamble symbols that must land
508/// inside a single RX window for reliable detection. With
509/// `rx = rx_preamble + 1` symbols awake and
510/// `sleep = tx_preamble - 2*rx_preamble - 1` symbols asleep, the worst
511/// preamble alignment still puts `rx_preamble` symbols in one window
512/// with a symbol to spare: a preamble that starts too late in one
513/// window meets the next one after `sleep + rx` symbols, leaving
514/// `tx_preamble - sleep - rx - 1 >= rx_preamble` symbols of it to hear.
515/// (The chip's detector actually fires on fewer symbols than the full
516/// acquisition length, so the real margin is wider.)
517///
518/// Falls back to continuous RX when the TX preamble is too short to
519/// leave any sleep (`tx_preamble < 2*rx_preamble + 2` — the LR1110's
520/// 16-symbol acquisition against the 32-symbol MeshCore preamble lands
521/// here) or when a window overflows the chip's 24-bit timers.
522pub fn duty_cycle_rx_mode(
523 sf: SpreadingFactor,
524 bw: Bandwidth,
525 rx_preamble: u16,
526 tx_preamble: u16,
527) -> RxMode {
528 let det = rx_preamble as u64;
529 let tx = tx_preamble as u64;
530 if tx < 2 * det + 2 {
531 return RxMode::Continuous;
532 }
533 let rx_syms = det + 1;
534 let sleep_syms = tx - 2 * det - 1;
535
536 // Symbol duration in nanoseconds: t_sym = 2^SF / BW.
537 let t_sym_ns = (1u64 << sf_value(sf)) * 1_000_000_000 / bw_value_hz(bw) as u64;
538 let rx_time = (rx_syms * t_sym_ns + TCXO_WAKEUP_NS) / DUTY_CYCLE_UNIT_NS;
539 let sleep_time = sleep_syms * t_sym_ns / DUTY_CYCLE_UNIT_NS;
540 if rx_time > 0x00FF_FFFF || sleep_time > 0x00FF_FFFF || sleep_time == 0 {
541 return RxMode::Continuous;
542 }
543 RxMode::DutyCycle(DutyCycleParams {
544 rx_time: rx_time as u32,
545 sleep_time: sleep_time as u32,
546 })
547}
548
549/// Convert a bandwidth in Hz (the ULCP representation)
550/// to the lora-phy enum. Returns `None` for unsupported values.
551pub fn bandwidth_from_hz(hz: u32) -> Option<Bandwidth> {
552 Some(match hz {
553 7_810 => Bandwidth::_7KHz,
554 10_420 => Bandwidth::_10KHz,
555 15_630 => Bandwidth::_15KHz,
556 20_830 => Bandwidth::_20KHz,
557 31_250 => Bandwidth::_31KHz,
558 41_670 => Bandwidth::_41KHz,
559 62_500 => Bandwidth::_62KHz,
560 125_000 => Bandwidth::_125KHz,
561 250_000 => Bandwidth::_250KHz,
562 500_000 => Bandwidth::_500KHz,
563 _ => return None,
564 })
565}
566
567/// Convert a numeric spreading factor (5-12) to the lora-phy enum.
568pub fn spreading_factor_from_u8(sf: u8) -> Option<SpreadingFactor> {
569 Some(match sf {
570 5 => SpreadingFactor::_5,
571 6 => SpreadingFactor::_6,
572 7 => SpreadingFactor::_7,
573 8 => SpreadingFactor::_8,
574 9 => SpreadingFactor::_9,
575 10 => SpreadingFactor::_10,
576 11 => SpreadingFactor::_11,
577 12 => SpreadingFactor::_12,
578 _ => return None,
579 })
580}
581
582/// Convert a coding-rate denominator (5 for 4/5 .. 8 for 4/8) to the
583/// lora-phy enum.
584pub fn coding_rate_from_denom(cr: u8) -> Option<CodingRate> {
585 Some(match cr {
586 5 => CodingRate::_4_5,
587 6 => CodingRate::_4_6,
588 7 => CodingRate::_4_7,
589 8 => CodingRate::_4_8,
590 _ => return None,
591 })
592}
593
594/// Device variant of [`runner`]: same RX/TX state machine, but the
595/// modulation parameters, frequency, and power come from an
596/// [`DeviceControl`] at runtime instead of being fixed at spawn.
597///
598/// The radio starts idle (in standby) until the first enabled settings
599/// arrive. While disabled, TX requests stay queued — the ULCP session
600/// rejects transmits with `STATUS_INVALID_STATE` before they reach
601/// this queue, so nothing accumulates in practice.
602///
603/// `rx_strategy` picks how the radio listens between frames; the
604/// duty-cycle windows are recomputed from each new set of modulation
605/// settings (see [`duty_cycle_rx_mode`]).
606///
607/// Cancellation-safety analysis is identical to [`runner`]: only
608/// `wait_for_irq` and the two channel/signal waits are cancelled by the
609/// select; IRQ processing and TX always run to completion.
610pub async fn device_runner<RK, DLY, M, const RX: usize, const TX: usize>(
611 mut lora: LoRa<RK, DLY>,
612 ch: &'static Channels<M, RX, TX>,
613 ctl: &'static DeviceControl<M>,
614 rx_preamble: u16,
615 tx_preamble: u16,
616 rx_strategy: RxStrategy,
617 stats: Option<&'static StatsLedger>,
618) -> !
619where
620 RK: RadioKind,
621 DLY: embedded_hal_async::delay::DelayNs,
622 M: RawMutex,
623{
624 use embassy_futures::select::{Either4, select4};
625
626 let mut rx_buf = [0u8; MAX_PAYLOAD];
627 let mut settings: Option<DeviceSettings> = None;
628
629 // Wait for new settings while idle, failing any RSSI request that
630 // arrives meanwhile so the requester never hangs. The session gates
631 // RSSI reads on `enabled`, but enable→RX is asynchronous (and the
632 // params-failure path below idles while the session still believes
633 // the radio is enabled), so a request can race into an idle window.
634 async fn wait_settings_while_idle<M: RawMutex>(ctl: &DeviceControl<M>) -> DeviceSettings {
635 loop {
636 match select(ctl.settings.wait(), ctl.rssi_req.wait()).await {
637 Either::First(new_settings) => return new_settings,
638 Either::Second(()) => ctl.rssi_resp.signal(Err(())),
639 }
640 }
641 }
642
643 'reconfigure: loop {
644 // Idle until we have an enabled configuration.
645 let active = loop {
646 // Checked here rather than in the selects below because every
647 // path that observes new settings passes through this loop:
648 // `shutdown` wakes an RX-parked runner with a settings signal,
649 // and an already-idle one leaves `wait_settings_while_idle`
650 // for the same reason.
651 if ctl.shutdown.load(Ordering::Acquire) {
652 let _ = lora.sleep(false).await;
653 loop {
654 core::future::pending::<()>().await;
655 }
656 }
657 match settings {
658 Some(current) if current.enabled => break current,
659 _ => settings = Some(wait_settings_while_idle(ctl).await),
660 }
661 };
662
663 // Build params for the active settings. The session validates
664 // values before applying, so failures here indicate a
665 // chip-level rejection: drop back to idle until new settings
666 // arrive rather than hot-looping.
667 let params = (|| {
668 let mdltn =
669 lora.create_modulation_params(active.sf, active.bw, active.cr, active.freq_hz)?;
670 let rx_pkt = lora.create_rx_packet_params(
671 rx_preamble,
672 false, // explicit header
673 MAX_PAYLOAD as u8,
674 true, // CRC on
675 false, // IQ normal
676 &mdltn,
677 )?;
678 let tx_pkt = lora.create_tx_packet_params(tx_preamble, false, true, false, &mdltn)?;
679 Ok::<_, RadioError>((mdltn, rx_pkt, tx_pkt))
680 })();
681 let Ok((mdltn, rx_pkt, mut tx_pkt)) = params else {
682 settings = Some(wait_settings_while_idle(ctl).await);
683 continue 'reconfigure;
684 };
685
686 let rx_mode = match rx_strategy {
687 RxStrategy::Continuous => RxMode::Continuous,
688 RxStrategy::PreambleDutyCycle => {
689 duty_cycle_rx_mode(active.sf, active.bw, rx_preamble, tx_preamble)
690 }
691 };
692
693 'rx: loop {
694 if lora.prepare_for_rx(rx_mode, &mdltn, &rx_pkt).await.is_err() {
695 continue;
696 }
697 if lora.start_rx().await.is_err() {
698 continue;
699 }
700
701 let mut rx_in_progress = false;
702 let mut rx_gate_strikes: u8 = 0;
703 loop {
704 match select4(
705 lora.wait_for_irq(),
706 ch.tx.receive(),
707 ctl.settings.wait(),
708 ctl.rssi_req.wait(),
709 )
710 .await
711 {
712 Either4::First(Ok(())) => {
713 // Same discipline as `runner`: process_irq_event
714 // must run to completion, then clear interrupts.
715 let irq_result = lora.process_irq_event().await;
716 let _ = lora.clear_irq_status().await;
717
718 match irq_result {
719 Ok(Some(IrqState::Done)) => {
720 if let Ok((len, status)) =
721 lora.get_rx_result(&rx_pkt, &mut rx_buf).await
722 {
723 let mut data: Vec<u8, MAX_PAYLOAD> = Vec::new();
724 let _ = data.extend_from_slice(&rx_buf[..len as usize]);
725 let info = RxInfo {
726 len: len as usize,
727 rssi: status.rssi,
728 snr: Snr::from_decibels(status.snr as i8),
729 lqi: None,
730 origin: RxOrigin::Air,
731 };
732 if ch.rx.try_send(RxFrame { data, info }).is_ok() {
733 ch.rx_waker.wake();
734 }
735 }
736 continue 'rx;
737 }
738 Ok(Some(IrqState::PreambleReceived)) => {
739 rx_in_progress = true; // gate TX until the frame resolves
740 continue;
741 }
742 Ok(_) => continue,
743 // See `runner`: a failed payload CRC is only
744 // catchable here, and it is the one radio-level
745 // drop worth a number of its own.
746 Err(RadioError::CrcError) => {
747 note_bad_crc(stats);
748 continue 'rx;
749 }
750 Err(_) => continue 'rx,
751 }
752 }
753 Either4::First(Err(_)) => continue 'rx,
754 Either4::Second(tx_req) => {
755 // Same RX gate as `runner`: don't tear down an
756 // in-flight reception for the CAD gate.
757 if rx_in_progress && rx_gate_strikes < RX_GATE_MAX_STRIKES {
758 rx_gate_strikes += 1;
759 ch.tx_done.signal(Err(TxError::CadTimeout));
760 continue;
761 }
762 let result =
763 perform_tx(&mut lora, &mdltn, &mut tx_pkt, active.power_dbm, &tx_req)
764 .await;
765 ch.tx_done.signal(result);
766 continue 'rx;
767 }
768 Either4::Third(new_settings) => {
769 settings = Some(new_settings);
770 continue 'reconfigure;
771 }
772 Either4::Fourth(()) => {
773 // Sample the instantaneous channel RSSI. Like TX,
774 // `get_rssi` runs to completion outside the select
775 // (only `wait_for_irq` and the channel/signal waits are
776 // cancel-safe). In continuous RX the read does not
777 // disturb reception, so we stay in the inner loop. In
778 // duty-cycled RX the SPI traffic wakes the chip out of
779 // its sniff sequence (and the sample is only
780 // meaningful if it caught an RX window), so re-arm RX
781 // afterwards.
782 let sample = lora.get_rssi().await.map_err(|_| ());
783 ctl.rssi_resp.signal(sample);
784 if rx_mode != RxMode::Continuous {
785 continue 'rx;
786 }
787 }
788 }
789 }
790 }
791 }
792}
793
794// ─── Parameter builders ───────────────────────────────────────────────────────
795
796/// Build modulation and packet parameters for a vetted PHY profile.
797///
798/// The private sync word the profiles specify is not set here: it is
799/// fixed at `LoRa::new` time by `enable_public_network = false`. CRC is
800/// on and IQ normal, matching what MeshCore transmits.
801///
802/// `rx_preamble_symbols` stays a caller decision because it is a
803/// property of the receiving chip rather than the profile: 8 suffices on
804/// an SX126x, while the LR1110 needs the full transmitted length to
805/// detect reliably.
806///
807/// Returns `(ModulationParams, rx_PacketParams, tx_PacketParams)`.
808pub fn profile_params<RK, DLY>(
809 lora: &mut LoRa<RK, DLY>,
810 profile: &PhyProfile,
811 rx_preamble_symbols: u16,
812) -> Result<(ModulationParams, PacketParams, PacketParams), RadioError>
813where
814 RK: RadioKind,
815 DLY: embedded_hal_async::delay::DelayNs,
816{
817 let sf = spreading_factor_from_u8(profile.sf).ok_or(RadioError::UnavailableSpreadingFactor)?;
818 let bw = bandwidth_from_hz(profile.bw_hz).ok_or(RadioError::UnavailableBandwidth)?;
819 let cr = coding_rate_from_denom(profile.cr_denom).ok_or(RadioError::InvalidConfiguration)?;
820 build_params(
821 lora,
822 sf,
823 bw,
824 cr,
825 profile.freq_khz * 1_000,
826 rx_preamble_symbols,
827 profile.tx_preamble_symbols,
828 )
829}
830
831/// Shared helper: build modulation + RX/TX packet params.
832///
833/// `rx_preamble`: LoRa preamble length configured for RX packet parameters.
834/// `tx_preamble`: LoRa preamble length configured for TX packet parameters.
835fn build_params<RK, DLY>(
836 lora: &mut LoRa<RK, DLY>,
837 sf: SpreadingFactor,
838 bw: Bandwidth,
839 cr: CodingRate,
840 frequency_hz: u32,
841 rx_preamble: u16,
842 tx_preamble: u16,
843) -> Result<(ModulationParams, PacketParams, PacketParams), RadioError>
844where
845 RK: RadioKind,
846 DLY: embedded_hal_async::delay::DelayNs,
847{
848 let mdltn = lora.create_modulation_params(sf, bw, cr, frequency_hz)?;
849
850 let rx_pkt = lora.create_rx_packet_params(
851 rx_preamble,
852 false, // explicit (variable-length) header
853 MAX_PAYLOAD as u8,
854 true, // CRC on
855 false, // IQ normal
856 &mdltn,
857 )?;
858
859 let tx_pkt = lora.create_tx_packet_params(
860 tx_preamble,
861 false, // explicit header
862 true, // CRC on
863 false, // IQ normal
864 &mdltn,
865 )?;
866
867 Ok((mdltn, rx_pkt, tx_pkt))
868}
869
870// ─── Airtime estimate ─────────────────────────────────────────────────────────
871
872/// Conservative upper bound on LoRa on-air time in milliseconds.
873///
874/// Uses the standard LoRa airtime formula: explicit header, CRC on, CR 4/5,
875/// auto-LDRO. Call this with `MAX_PAYLOAD` to get the worst-case figure for
876/// `t_frame_ms`.
877pub fn airtime_ms(sf: SpreadingFactor, bw: Bandwidth, payload_bytes: usize) -> u32 {
878 let sf_val: u32 = sf_value(sf);
879 let bw_hz: u64 = bw_value_hz(bw) as u64;
880
881 // Symbol duration in microseconds: t_sym = 2^SF / BW.
882 let t_sym_us: u64 = (1u64 << sf_val) * 1_000_000 / bw_hz;
883
884 // LDRO required when t_sym > 16 ms (SF11/BW125 or SF12/BW125 or BW250).
885 let ldro: u64 = if t_sym_us > 16_000 { 1 } else { 0 };
886
887 // Number of payload symbols (LoRa spec, CR=4/5, explicit header, CRC on).
888 let sf = sf_val as i64;
889 let pl = payload_bytes as i64;
890 let num = (8 * pl - 4 * sf + 44 + 20 - 16 * ldro as i64).max(0);
891 let denom = 4 * (sf - 2 * ldro as i64);
892 // Manual ceiling division for i64 (div_ceil is still nightly-only).
893 let ceil = (num + denom - 1) / denom;
894 let n_pay_sym = 8 + ceil * 5; // CR 4/5 → 5 coding overhead per ceiling block
895
896 // Total: preamble (8 symbols + 4.25, approximated as 12) + payload.
897 let total_sym = 12 + n_pay_sym as u64;
898
899 ((total_sym * t_sym_us) / 1_000) as u32
900}
901
902/// The spreading factor as its numeric value (5–12).
903fn sf_value(sf: SpreadingFactor) -> u32 {
904 match sf {
905 SpreadingFactor::_5 => 5,
906 SpreadingFactor::_6 => 6,
907 SpreadingFactor::_7 => 7,
908 SpreadingFactor::_8 => 8,
909 SpreadingFactor::_9 => 9,
910 SpreadingFactor::_10 => 10,
911 SpreadingFactor::_11 => 11,
912 SpreadingFactor::_12 => 12,
913 }
914}
915
916/// The bandwidth in Hz.
917fn bw_value_hz(bw: Bandwidth) -> u32 {
918 match bw {
919 Bandwidth::_7KHz => 7_810,
920 Bandwidth::_10KHz => 10_420,
921 Bandwidth::_15KHz => 15_630,
922 Bandwidth::_20KHz => 20_830,
923 Bandwidth::_31KHz => 31_250,
924 Bandwidth::_41KHz => 41_670,
925 Bandwidth::_62KHz => 62_500,
926 Bandwidth::_125KHz => 125_000,
927 Bandwidth::_250KHz => 250_000,
928 Bandwidth::_500KHz => 500_000,
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 fn expect_duty_cycle(mode: RxMode) -> DutyCycleParams {
937 match mode {
938 RxMode::DutyCycle(params) => params,
939 _ => panic!("expected duty-cycle RX mode"),
940 }
941 }
942
943 /// The shipping SX1262 configuration: 8-symbol acquisition against
944 /// the 32-symbol MeshCore TX preamble at SF7/125 kHz.
945 #[test]
946 fn duty_cycle_sf7_bw125() {
947 let params = expect_duty_cycle(duty_cycle_rx_mode(
948 SpreadingFactor::_7,
949 Bandwidth::_125KHz,
950 8,
951 32,
952 ));
953 // t_sym = 1.024 ms. RX window: 9 symbols + the 5 ms TCXO
954 // wake = 14.216 ms; sleep: 15 symbols = 15.36 ms — in units of
955 // 15.625 µs.
956 assert_eq!(params.rx_time, 909);
957 assert_eq!(params.sleep_time, 983);
958 }
959
960 /// Worst-case preamble alignment still lands a full acquisition
961 /// window inside one RX slot, across the whole parameter space.
962 #[test]
963 fn duty_cycle_worst_case_alignment_is_covered() {
964 for (det, tx) in [(4u64, 16u64), (8, 32), (8, 20), (12, 32)] {
965 if tx < 2 * det + 2 {
966 continue;
967 }
968 let rx_syms = det + 1;
969 let sleep_syms = tx - 2 * det - 1;
970 // A preamble that starts too late for one window (more than
971 // `rx - det` symbols in) meets the next window after
972 // `sleep + rx` symbols and must still have `det` symbols
973 // left to hear. The sizing is boundary-exact: the two
974 // coverage paths overlap by the one extra RX symbol, and
975 // the real-world margin comes from the chip's detector
976 // firing on fewer than `det` symbols plus the TCXO
977 // inflation of the RX window.
978 assert!(sleep_syms + rx_syms + det <= tx);
979 }
980 }
981
982 /// The LR1110's 16-symbol acquisition leaves no sleep budget
983 /// against a 32-symbol preamble: fall back to continuous RX.
984 #[test]
985 fn duty_cycle_falls_back_when_preamble_too_short() {
986 assert!(matches!(
987 duty_cycle_rx_mode(SpreadingFactor::_7, Bandwidth::_125KHz, 16, 32),
988 RxMode::Continuous
989 ));
990 }
991
992 /// The slowest vetted modulation must not overflow the chip's
993 /// 24-bit window timers.
994 #[test]
995 fn duty_cycle_slowest_modulation_fits_timers() {
996 let params = expect_duty_cycle(duty_cycle_rx_mode(
997 SpreadingFactor::_12,
998 Bandwidth::_7KHz,
999 8,
1000 32,
1001 ));
1002 assert!(params.rx_time <= 0x00FF_FFFF);
1003 assert!(params.sleep_time <= 0x00FF_FFFF);
1004 assert!(params.sleep_time > 0);
1005 }
1006}