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, ModulationParams, PacketParams, RadioError, SpreadingFactor,
51    },
52    mod_traits::{IrqState, RadioKind},
53};
54pub use umsh_hal::{CadPolicy, TxError};
55use umsh_hal::{RxInfo, Snr, TxOptions};
56
57/// Maximum SX1262 LoRa payload: 255 bytes.
58pub const MAX_PAYLOAD: usize = 255;
59
60// ─── Frame types ─────────────────────────────────────────────────────────────
61
62/// A received frame plus signal metadata.
63pub struct RxFrame {
64    pub data: Vec<u8, MAX_PAYLOAD>,
65    pub info: RxInfo,
66}
67
68/// A queued transmit request from the MAC to the runner task.
69pub struct TxRequest {
70    pub data: Vec<u8, MAX_PAYLOAD>,
71    /// Per-frame TX power override in dBm; `None` uses the runner's
72    /// configured power.
73    pub power_dbm: Option<i32>,
74    /// Channel-activity-detection policy applied by the runner before
75    /// keying up. A busy channel completes the request with
76    /// [`TxError::CadTimeout`] instead of transmitting.
77    pub cad: CadPolicy,
78}
79
80// ─── Channels ────────────────────────────────────────────────────────────────
81
82/// Shared state between [`LoraphyRadio`] and [`runner`]. Place in a `static`.
83///
84/// - `M`: raw mutex type (e.g. `ThreadModeRawMutex` for single-core Embassy).
85/// - `RX`: depth of the receive queue.
86/// - `TX`: depth of the transmit-request queue.
87pub struct Channels<M: RawMutex, const RX: usize, const TX: usize> {
88    pub rx: Channel<M, RxFrame, RX>,
89    pub tx: Channel<M, TxRequest, TX>,
90    pub tx_done: Signal<M, Result<(), TxError<RadioError>>>,
91    pub rx_waker: AtomicWaker,
92}
93
94impl<M: RawMutex, const RX: usize, const TX: usize> Channels<M, RX, TX> {
95    pub const fn new() -> Self {
96        Self {
97            rx: Channel::new(),
98            tx: Channel::new(),
99            tx_done: Signal::new(),
100            rx_waker: AtomicWaker::new(),
101        }
102    }
103}
104
105// ─── LoraphyRadio ────────────────────────────────────────────────────────────
106
107/// Implements `umsh_hal::Radio` over the shared [`Channels`].
108///
109/// The actual TX power and modulation params live on the `runner` side (it
110/// owns the `LoRa` driver). This handle only carries:
111///   - the channel pair used to talk to the runner,
112///   - a precomputed worst-case airtime so the MAC's scheduler doesn't have
113///     to recompute it.
114pub struct LoraphyRadio<M: RawMutex + 'static, const RX: usize, const TX: usize> {
115    ch: &'static Channels<M, RX, TX>,
116    t_frame_ms: u32,
117}
118
119impl<M: RawMutex + 'static, const RX: usize, const TX: usize> LoraphyRadio<M, RX, TX> {
120    /// Use [`airtime_ms`] with your modulation settings to compute `t_frame_ms`.
121    pub fn new(ch: &'static Channels<M, RX, TX>, t_frame_ms: u32) -> Self {
122        Self { ch, t_frame_ms }
123    }
124}
125
126impl<M: RawMutex + 'static, const RX: usize, const TX: usize> umsh_hal::Radio
127    for LoraphyRadio<M, RX, TX>
128{
129    type Error = RadioError;
130
131    async fn transmit(
132        &mut self,
133        data: &[u8],
134        options: TxOptions,
135    ) -> Result<(), TxError<Self::Error>> {
136        let mut frame_data: Vec<u8, MAX_PAYLOAD> = Vec::new();
137        frame_data
138            .extend_from_slice(data)
139            .map_err(|_| TxError::Io(RadioError::PayloadSizeUnexpected(data.len())))?;
140        self.ch
141            .tx
142            .send(TxRequest {
143                data: frame_data,
144                power_dbm: None,
145                cad: options.cad,
146            })
147            .await;
148        self.ch.tx_done.wait().await
149    }
150
151    fn poll_receive(
152        &mut self,
153        cx: &mut Context<'_>,
154        buf: &mut [u8],
155    ) -> Poll<Result<RxInfo, Self::Error>> {
156        // Fast path: frame already in queue.
157        if let Ok(frame) = self.ch.rx.try_receive() {
158            return Poll::Ready(Ok(copy_frame(frame, buf)));
159        }
160        // Register waker then double-check to close the TOCTOU race between
161        // the try_receive above and the runner pushing a frame.
162        self.ch.rx_waker.register(cx.waker());
163        if let Ok(frame) = self.ch.rx.try_receive() {
164            return Poll::Ready(Ok(copy_frame(frame, buf)));
165        }
166        Poll::Pending
167    }
168
169    fn max_frame_size(&self) -> usize {
170        MAX_PAYLOAD
171    }
172
173    fn t_frame_ms(&self) -> u32 {
174        self.t_frame_ms
175    }
176}
177
178/// Copy a received frame into a caller-provided buffer, truncating if the
179/// caller's buffer is smaller than the frame.
180fn copy_frame(frame: RxFrame, buf: &mut [u8]) -> RxInfo {
181    let n = frame.data.len().min(buf.len());
182    buf[..n].copy_from_slice(&frame.data[..n]);
183    frame.info
184}
185
186// ─── Runner ──────────────────────────────────────────────────────────────────
187
188/// Maximum consecutive TX requests refused with [`TxError::CadTimeout`] while
189/// a reception appears to be in progress (preamble seen, frame not finished).
190///
191/// Refusing without touching the radio keeps the in-flight frame receivable —
192/// tearing down RX for the CAD gate would lose it, and the gate would report
193/// busy anyway. The chip raises no IRQ when a detected preamble turns out to
194/// be noise, so the flag can go stale; after this many refusals the next
195/// request falls through to the radio's own CAD, which either confirms the
196/// channel is busy or clears the way (the TX path re-prepares RX afterwards,
197/// resetting the gate).
198pub const RX_GATE_MAX_STRIKES: u8 = 3;
199
200/// Execute one transmit request: CAD gate (listen-before-talk) per the
201/// request's [`CadPolicy`], then transmit.
202///
203/// `CadPolicy::RetryFor` is intentionally handled as a single gate, like
204/// `Gate`: the runner has no time source, and the MAC coordinator already
205/// owns retry pacing by backing off on [`TxError::CadTimeout`] and
206/// re-queueing the frame.
207///
208/// NOT cancel-safe (`cad`, `prepare_for_tx`, and `tx` must all run to
209/// completion) — call outside any `select` branch, like the TX arm it
210/// replaces.
211async fn perform_tx<RK, DLY>(
212    lora: &mut LoRa<RK, DLY>,
213    mdltn: &ModulationParams,
214    tx_pkt: &mut PacketParams,
215    default_power_dbm: i32,
216    tx_req: &TxRequest,
217) -> Result<(), TxError<RadioError>>
218where
219    RK: RadioKind,
220    DLY: embedded_hal_async::delay::DelayNs,
221{
222    if !matches!(tx_req.cad, CadPolicy::Skip) {
223        let busy = async {
224            lora.prepare_for_cad(mdltn).await?;
225            lora.cad(mdltn).await
226        }
227        .await
228        .map_err(TxError::Io)?;
229        if busy {
230            return Err(TxError::CadTimeout);
231        }
232    }
233    let power = tx_req.power_dbm.unwrap_or(default_power_dbm);
234    async {
235        lora.prepare_for_tx(mdltn, tx_pkt, power, &tx_req.data)
236            .await?;
237        lora.tx().await
238    }
239    .await
240    .map_err(TxError::Io)
241}
242
243/// Background loop: owns the `lora_phy::LoRa` instance, switches between
244/// continuous RX and TX as requests arrive. Never returns.
245///
246/// Wrap this in a `#[embassy_executor::task]` in the binary crate so the
247/// concrete monomorphisation is visible to the linker.
248///
249/// # Cancellation safety
250///
251/// `wait_for_irq` is the only `await` point that may be cancelled (it just
252/// awaits a DIO edge and is safe to drop). `process_irq_event`,
253/// `prepare_for_tx`, and `tx` all run to completion outside any `select`
254/// branch — cancelling those leaves the radio in a wedged state from which
255/// `prepare_for_tx` will hang forever (lora-phy explicitly warns against
256/// dropping `process_irq_event` futures). The convenience `lora.rx()`
257/// helper internally calls `complete_rx`/`process_irq_event`, so it is
258/// **not** safe inside a `select` either; we hand-roll the IRQ loop here
259/// to keep cancellation pinned to `wait_for_irq`.
260pub async fn runner<RK, DLY, M, const RX: usize, const TX: usize>(
261    mut lora: LoRa<RK, DLY>,
262    ch: &'static Channels<M, RX, TX>,
263    mdltn: ModulationParams,
264    rx_pkt: PacketParams,
265    mut tx_pkt: PacketParams,
266    power_dbm: i32,
267) -> !
268where
269    RK: RadioKind,
270    DLY: embedded_hal_async::delay::DelayNs,
271    M: RawMutex,
272{
273    let mut rx_buf = [0u8; MAX_PAYLOAD];
274
275    'outer: loop {
276        if lora
277            .prepare_for_rx(RxMode::Continuous, &mdltn, &rx_pkt)
278            .await
279            .is_err()
280        {
281            continue;
282        }
283        if lora.start_rx().await.is_err() {
284            continue;
285        }
286
287        // Inner loop: stay in continuous RX, handling partial-packet IRQs
288        // (PreambleReceived) without re-preparing. Break back to the outer
289        // loop to re-prepare RX after a completed frame, an error, or a TX.
290        let mut rx_in_progress = false;
291        let mut rx_gate_strikes: u8 = 0;
292        loop {
293            match select(lora.wait_for_irq(), ch.tx.receive()).await {
294                Either::First(Ok(())) => {
295                    // process_irq_event is NOT cancel-safe — it MUST run to
296                    // completion. The public method passes clear_interrupts=false
297                    // (unlike complete_rx's internal call), so we explicitly
298                    // clear afterwards or DIO1 stays latched high on LR1110.
299                    let irq_result = lora.process_irq_event().await;
300                    let _ = lora.clear_irq_status().await;
301
302                    match irq_result {
303                        Ok(Some(IrqState::Done)) => {
304                            if let Ok((len, status)) =
305                                lora.get_rx_result(&rx_pkt, &mut rx_buf).await
306                            {
307                                let mut data: Vec<u8, MAX_PAYLOAD> = Vec::new();
308                                let _ = data.extend_from_slice(&rx_buf[..len as usize]);
309                                let info = RxInfo {
310                                    len: len as usize,
311                                    rssi: status.rssi,
312                                    snr: Snr::from_decibels(status.snr as i8),
313                                    lqi: None,
314                                };
315                                if ch.rx.try_send(RxFrame { data, info }).is_ok() {
316                                    ch.rx_waker.wake();
317                                }
318                            }
319                            continue 'outer; // re-prepare RX for the next frame
320                        }
321                        Ok(Some(IrqState::PreambleReceived)) => {
322                            rx_in_progress = true; // gate TX until the frame resolves
323                            continue;
324                        }
325                        Ok(_) => continue,         // no-op IRQ: stay in RX
326                        Err(_) => continue 'outer, // CRC / header error: full re-prepare
327                    }
328                }
329                Either::First(Err(_)) => continue 'outer,
330                Either::Second(tx_req) => {
331                    // A reception is in flight: refuse instead of tearing down
332                    // RX for the CAD gate (which would lose the frame and
333                    // report busy anyway). The MAC treats this like any other
334                    // busy verdict and retries after backoff.
335                    if rx_in_progress && rx_gate_strikes < RX_GATE_MAX_STRIKES {
336                        rx_gate_strikes += 1;
337                        ch.tx_done.signal(Err(TxError::CadTimeout));
338                        continue;
339                    }
340                    // TX is also NOT cancel-safe — run the CAD gate and
341                    // prepare_for_tx + tx to completion outside any select.
342                    let result =
343                        perform_tx(&mut lora, &mdltn, &mut tx_pkt, power_dbm, &tx_req).await;
344                    ch.tx_done.signal(result);
345                    continue 'outer; // chip is left in standby — re-prepare RX
346                }
347            }
348        }
349    }
350}
351
352// ─── ULCP device runner ──────────────────────────────────────────────────────
353
354/// Radio settings applied at runtime by the ULCP session.
355#[derive(Clone, Copy, Debug, PartialEq)]
356pub struct DeviceSettings {
357    pub enabled: bool,
358    pub freq_hz: u32,
359    pub sf: SpreadingFactor,
360    pub bw: Bandwidth,
361    pub cr: CodingRate,
362    pub power_dbm: i32,
363}
364
365/// Control handle for [`device_runner`]: latest-wins settings updates and
366/// on-demand instantaneous-RSSI sampling. Place in a `static` next to the
367/// [`Channels`].
368pub struct DeviceControl<M: RawMutex> {
369    settings: Signal<M, DeviceSettings>,
370    rssi_req: Signal<M, ()>,
371    rssi_resp: Signal<M, Result<i16, ()>>,
372    shutdown: AtomicBool,
373}
374
375impl<M: RawMutex> DeviceControl<M> {
376    pub const fn new() -> Self {
377        Self {
378            settings: Signal::new(),
379            rssi_req: Signal::new(),
380            rssi_resp: Signal::new(),
381            shutdown: AtomicBool::new(false),
382        }
383    }
384
385    /// Put the radio into chip sleep and stop the runner.
386    ///
387    /// Terminal, and meant for the board's own power-off path: the
388    /// runner parks forever once it observes this, so nothing after it
389    /// can transmit or receive. The SX1262 keeps its own supply while
390    /// the host MCU is in deep sleep, so skipping this would leave the
391    /// chip receiving and dominate the sleeping board's current draw.
392    ///
393    /// The flag is what the runner acts on; the settings signal only
394    /// exists to break it out of RX so it can look.
395    pub fn shutdown(&self) {
396        self.shutdown.store(true, Ordering::Release);
397        self.settings.signal(DeviceSettings {
398            enabled: false,
399            freq_hz: UMSH_FREQUENCY_HZ,
400            sf: SpreadingFactor::_7,
401            bw: Bandwidth::_125KHz,
402            cr: CodingRate::_4_5,
403            power_dbm: 0,
404        });
405    }
406
407    /// Apply new settings. The runner picks them up at its next await
408    /// point and rebuilds modulation/packet params.
409    pub fn apply(&self, settings: DeviceSettings) {
410        self.settings.signal(settings);
411    }
412
413    /// Request an instantaneous-RSSI sample from the runner. Pair with
414    /// [`wait_rssi`](Self::wait_rssi). Only meaningful while the radio is in RX
415    /// (i.e. enabled); the caller is responsible for that gating.
416    pub fn request_rssi(&self) {
417        self.rssi_resp.reset();
418        self.rssi_req.signal(());
419    }
420
421    /// Await the RSSI sample requested via [`request_rssi`](Self::request_rssi),
422    /// in dBm. `Err(())` means the read failed at the radio.
423    pub async fn wait_rssi(&self) -> Result<i16, ()> {
424        self.rssi_resp.wait().await
425    }
426}
427
428impl<M: RawMutex> Default for DeviceControl<M> {
429    fn default() -> Self {
430        Self::new()
431    }
432}
433
434/// Convert a bandwidth in Hz (the ULCP representation)
435/// to the lora-phy enum. Returns `None` for unsupported values.
436pub fn bandwidth_from_hz(hz: u32) -> Option<Bandwidth> {
437    Some(match hz {
438        7_810 => Bandwidth::_7KHz,
439        10_420 => Bandwidth::_10KHz,
440        15_630 => Bandwidth::_15KHz,
441        20_830 => Bandwidth::_20KHz,
442        31_250 => Bandwidth::_31KHz,
443        41_670 => Bandwidth::_41KHz,
444        62_500 => Bandwidth::_62KHz,
445        125_000 => Bandwidth::_125KHz,
446        250_000 => Bandwidth::_250KHz,
447        500_000 => Bandwidth::_500KHz,
448        _ => return None,
449    })
450}
451
452/// Convert a numeric spreading factor (5-12) to the lora-phy enum.
453pub fn spreading_factor_from_u8(sf: u8) -> Option<SpreadingFactor> {
454    Some(match sf {
455        5 => SpreadingFactor::_5,
456        6 => SpreadingFactor::_6,
457        7 => SpreadingFactor::_7,
458        8 => SpreadingFactor::_8,
459        9 => SpreadingFactor::_9,
460        10 => SpreadingFactor::_10,
461        11 => SpreadingFactor::_11,
462        12 => SpreadingFactor::_12,
463        _ => return None,
464    })
465}
466
467/// Convert a coding-rate denominator (5 for 4/5 .. 8 for 4/8) to the
468/// lora-phy enum.
469pub fn coding_rate_from_denom(cr: u8) -> Option<CodingRate> {
470    Some(match cr {
471        5 => CodingRate::_4_5,
472        6 => CodingRate::_4_6,
473        7 => CodingRate::_4_7,
474        8 => CodingRate::_4_8,
475        _ => return None,
476    })
477}
478
479/// Device variant of [`runner`]: same RX/TX state machine, but the
480/// modulation parameters, frequency, and power come from an
481/// [`DeviceControl`] at runtime instead of being fixed at spawn.
482///
483/// The radio starts idle (in standby) until the first enabled settings
484/// arrive. While disabled, TX requests stay queued — the ULCP session
485/// rejects transmits with `STATUS_INVALID_STATE` before they reach
486/// this queue, so nothing accumulates in practice.
487///
488/// Cancellation-safety analysis is identical to [`runner`]: only
489/// `wait_for_irq` and the two channel/signal waits are cancelled by the
490/// select; IRQ processing and TX always run to completion.
491pub async fn device_runner<RK, DLY, M, const RX: usize, const TX: usize>(
492    mut lora: LoRa<RK, DLY>,
493    ch: &'static Channels<M, RX, TX>,
494    ctl: &'static DeviceControl<M>,
495    rx_preamble: u16,
496    tx_preamble: u16,
497) -> !
498where
499    RK: RadioKind,
500    DLY: embedded_hal_async::delay::DelayNs,
501    M: RawMutex,
502{
503    use embassy_futures::select::{Either4, select4};
504
505    let mut rx_buf = [0u8; MAX_PAYLOAD];
506    let mut settings: Option<DeviceSettings> = None;
507
508    // Wait for new settings while idle, failing any RSSI request that
509    // arrives meanwhile so the requester never hangs. The session gates
510    // RSSI reads on `enabled`, but enable→RX is asynchronous (and the
511    // params-failure path below idles while the session still believes
512    // the radio is enabled), so a request can race into an idle window.
513    async fn wait_settings_while_idle<M: RawMutex>(ctl: &DeviceControl<M>) -> DeviceSettings {
514        loop {
515            match select(ctl.settings.wait(), ctl.rssi_req.wait()).await {
516                Either::First(new_settings) => return new_settings,
517                Either::Second(()) => ctl.rssi_resp.signal(Err(())),
518            }
519        }
520    }
521
522    'reconfigure: loop {
523        // Idle until we have an enabled configuration.
524        let active = loop {
525            // Checked here rather than in the selects below because every
526            // path that observes new settings passes through this loop:
527            // `shutdown` wakes an RX-parked runner with a settings signal,
528            // and an already-idle one leaves `wait_settings_while_idle`
529            // for the same reason.
530            if ctl.shutdown.load(Ordering::Acquire) {
531                let _ = lora.sleep(false).await;
532                loop {
533                    core::future::pending::<()>().await;
534                }
535            }
536            match settings {
537                Some(current) if current.enabled => break current,
538                _ => settings = Some(wait_settings_while_idle(ctl).await),
539            }
540        };
541
542        // Build params for the active settings. The session validates
543        // values before applying, so failures here indicate a
544        // chip-level rejection: drop back to idle until new settings
545        // arrive rather than hot-looping.
546        let params = (|| {
547            let mdltn =
548                lora.create_modulation_params(active.sf, active.bw, active.cr, active.freq_hz)?;
549            let rx_pkt = lora.create_rx_packet_params(
550                rx_preamble,
551                false, // explicit header
552                MAX_PAYLOAD as u8,
553                true,  // CRC on
554                false, // IQ normal
555                &mdltn,
556            )?;
557            let tx_pkt = lora.create_tx_packet_params(tx_preamble, false, true, false, &mdltn)?;
558            Ok::<_, RadioError>((mdltn, rx_pkt, tx_pkt))
559        })();
560        let Ok((mdltn, rx_pkt, mut tx_pkt)) = params else {
561            settings = Some(wait_settings_while_idle(ctl).await);
562            continue 'reconfigure;
563        };
564
565        'rx: loop {
566            if lora
567                .prepare_for_rx(RxMode::Continuous, &mdltn, &rx_pkt)
568                .await
569                .is_err()
570            {
571                continue;
572            }
573            if lora.start_rx().await.is_err() {
574                continue;
575            }
576
577            let mut rx_in_progress = false;
578            let mut rx_gate_strikes: u8 = 0;
579            loop {
580                match select4(
581                    lora.wait_for_irq(),
582                    ch.tx.receive(),
583                    ctl.settings.wait(),
584                    ctl.rssi_req.wait(),
585                )
586                .await
587                {
588                    Either4::First(Ok(())) => {
589                        // Same discipline as `runner`: process_irq_event
590                        // must run to completion, then clear interrupts.
591                        let irq_result = lora.process_irq_event().await;
592                        let _ = lora.clear_irq_status().await;
593
594                        match irq_result {
595                            Ok(Some(IrqState::Done)) => {
596                                if let Ok((len, status)) =
597                                    lora.get_rx_result(&rx_pkt, &mut rx_buf).await
598                                {
599                                    let mut data: Vec<u8, MAX_PAYLOAD> = Vec::new();
600                                    let _ = data.extend_from_slice(&rx_buf[..len as usize]);
601                                    let info = RxInfo {
602                                        len: len as usize,
603                                        rssi: status.rssi,
604                                        snr: Snr::from_decibels(status.snr as i8),
605                                        lqi: None,
606                                    };
607                                    if ch.rx.try_send(RxFrame { data, info }).is_ok() {
608                                        ch.rx_waker.wake();
609                                    }
610                                }
611                                continue 'rx;
612                            }
613                            Ok(Some(IrqState::PreambleReceived)) => {
614                                rx_in_progress = true; // gate TX until the frame resolves
615                                continue;
616                            }
617                            Ok(_) => continue,
618                            Err(_) => continue 'rx,
619                        }
620                    }
621                    Either4::First(Err(_)) => continue 'rx,
622                    Either4::Second(tx_req) => {
623                        // Same RX gate as `runner`: don't tear down an
624                        // in-flight reception for the CAD gate.
625                        if rx_in_progress && rx_gate_strikes < RX_GATE_MAX_STRIKES {
626                            rx_gate_strikes += 1;
627                            ch.tx_done.signal(Err(TxError::CadTimeout));
628                            continue;
629                        }
630                        let result =
631                            perform_tx(&mut lora, &mdltn, &mut tx_pkt, active.power_dbm, &tx_req)
632                                .await;
633                        ch.tx_done.signal(result);
634                        continue 'rx;
635                    }
636                    Either4::Third(new_settings) => {
637                        settings = Some(new_settings);
638                        continue 'reconfigure;
639                    }
640                    Either4::Fourth(()) => {
641                        // Sample the instantaneous channel RSSI. We are in
642                        // continuous RX here, so GetRssiInst is valid. Like TX,
643                        // `get_rssi` runs to completion outside the select
644                        // (only `wait_for_irq` and the channel/signal waits are
645                        // cancel-safe). Reading RSSI does not disturb RX, so we
646                        // stay in the inner loop rather than re-preparing.
647                        let sample = lora.get_rssi().await.map_err(|_| ());
648                        ctl.rssi_resp.signal(sample);
649                    }
650                }
651            }
652        }
653    }
654}
655
656// ─── Parameter builders ───────────────────────────────────────────────────────
657
658/// Frequency used by default for UMSH in the 915 MHz ISM band.
659pub const UMSH_FREQUENCY_HZ: u32 = 915_000_000;
660
661/// Build the default modulation and packet parameters for UMSH bringup.
662///
663/// SF7 / BW125 / CR4-5 at 915 MHz.
664///
665/// Returns `(ModulationParams, rx_PacketParams, tx_PacketParams)`.
666pub fn default_params<RK, DLY>(
667    lora: &mut LoRa<RK, DLY>,
668) -> Result<(ModulationParams, PacketParams, PacketParams), RadioError>
669where
670    RK: RadioKind,
671    DLY: embedded_hal_async::delay::DelayNs,
672{
673    build_params(
674        lora,
675        SpreadingFactor::_7,
676        Bandwidth::_125KHz,
677        UMSH_FREQUENCY_HZ,
678        8,
679        8,
680    )
681}
682
683/// MeshCore US band frequency (confirmed from MeshCore source).
684pub const MESHCORE_US_FREQUENCY_HZ: u32 = 910_525_000;
685
686/// Build modulation + packet parameters matching MeshCore US (915 MHz band).
687///
688/// Sourced from MeshCore's `CustomSX1262.h` and `platformio.ini`:
689///   - 910.525 MHz / SF7 / BW62.5 kHz / CR4/5
690///   - 16-symbol TX preamble (matched against MeshCore nodes in the field)
691///   - Private sync word 0x1424 (via `enable_public_network = false` in LoRa::new)
692///   - CRC enabled, IQ normal
693///
694/// Returns `(ModulationParams, rx_PacketParams, tx_PacketParams)`.
695pub fn meshcore_us_params<RK, DLY>(
696    lora: &mut LoRa<RK, DLY>,
697) -> Result<(ModulationParams, PacketParams, PacketParams), RadioError>
698where
699    RK: RadioKind,
700    DLY: embedded_hal_async::delay::DelayNs,
701{
702    // RX preamble detection uses 8 symbols (MeshCore TX sends 16; the SX1262
703    // starts decoding after detecting the minimum threshold, so setting 8 here
704    // is correct and robust against slight timing variations).
705    build_params(
706        lora,
707        SpreadingFactor::_7,
708        Bandwidth::_62KHz,
709        MESHCORE_US_FREQUENCY_HZ,
710        8,
711        16,
712    )
713}
714
715/// Shared helper: build modulation + RX/TX packet params.
716///
717/// `rx_preamble`: minimum preamble symbols for RX detection.
718/// `tx_preamble`: preamble symbols emitted on TX.
719fn build_params<RK, DLY>(
720    lora: &mut LoRa<RK, DLY>,
721    sf: SpreadingFactor,
722    bw: Bandwidth,
723    frequency_hz: u32,
724    rx_preamble: u16,
725    tx_preamble: u16,
726) -> Result<(ModulationParams, PacketParams, PacketParams), RadioError>
727where
728    RK: RadioKind,
729    DLY: embedded_hal_async::delay::DelayNs,
730{
731    let mdltn = lora.create_modulation_params(sf, bw, CodingRate::_4_5, frequency_hz)?;
732
733    let rx_pkt = lora.create_rx_packet_params(
734        rx_preamble,
735        false, // explicit (variable-length) header
736        MAX_PAYLOAD as u8,
737        true,  // CRC on
738        false, // IQ normal
739        &mdltn,
740    )?;
741
742    let tx_pkt = lora.create_tx_packet_params(
743        tx_preamble,
744        false, // explicit header
745        true,  // CRC on
746        false, // IQ normal
747        &mdltn,
748    )?;
749
750    Ok((mdltn, rx_pkt, tx_pkt))
751}
752
753// ─── Airtime estimate ─────────────────────────────────────────────────────────
754
755/// Conservative upper bound on LoRa on-air time in milliseconds.
756///
757/// Uses the standard LoRa airtime formula: explicit header, CRC on, CR 4/5,
758/// auto-LDRO. Call this with `MAX_PAYLOAD` to get the worst-case figure for
759/// `t_frame_ms`.
760pub fn airtime_ms(sf: SpreadingFactor, bw: Bandwidth, payload_bytes: usize) -> u32 {
761    let sf_val: u32 = match sf {
762        SpreadingFactor::_5 => 5,
763        SpreadingFactor::_6 => 6,
764        SpreadingFactor::_7 => 7,
765        SpreadingFactor::_8 => 8,
766        SpreadingFactor::_9 => 9,
767        SpreadingFactor::_10 => 10,
768        SpreadingFactor::_11 => 11,
769        SpreadingFactor::_12 => 12,
770    };
771    let bw_hz: u64 = match bw {
772        Bandwidth::_7KHz => 7_810,
773        Bandwidth::_10KHz => 10_420,
774        Bandwidth::_15KHz => 15_630,
775        Bandwidth::_20KHz => 20_830,
776        Bandwidth::_31KHz => 31_250,
777        Bandwidth::_41KHz => 41_670,
778        Bandwidth::_62KHz => 62_500,
779        Bandwidth::_125KHz => 125_000,
780        Bandwidth::_250KHz => 250_000,
781        Bandwidth::_500KHz => 500_000,
782    };
783
784    // Symbol duration in microseconds: t_sym = 2^SF / BW.
785    let t_sym_us: u64 = (1u64 << sf_val) * 1_000_000 / bw_hz;
786
787    // LDRO required when t_sym > 16 ms (SF11/BW125 or SF12/BW125 or BW250).
788    let ldro: u64 = if t_sym_us > 16_000 { 1 } else { 0 };
789
790    // Number of payload symbols (LoRa spec, CR=4/5, explicit header, CRC on).
791    let sf = sf_val as i64;
792    let pl = payload_bytes as i64;
793    let num = (8 * pl - 4 * sf + 44 + 20 - 16 * ldro as i64).max(0);
794    let denom = 4 * (sf - 2 * ldro as i64);
795    // Manual ceiling division for i64 (div_ceil is still nightly-only).
796    let ceil = (num + denom - 1) / denom;
797    let n_pay_sym = 8 + ceil * 5; // CR 4/5 → 5 coding overhead per ceiling block
798
799    // Total: preamble (8 symbols + 4.25, approximated as 12) + payload.
800    let total_sym = 12 + n_pay_sym as u64;
801
802    ((total_sym * t_sym_us) / 1_000) as u32
803}