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}