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}