Extends the v1.1.0 "SET_CONFIG during RX auto-re-arms" invariant to every failure path that can fire while a client has RX active: * `Receiving.on(RxFailed)` now transitions to `ResumingRx` instead of `Idle`. A transient `next_packet` error re-arms the chip rather than ending the session. * `TransmittingFromRx.on(TransmitFailed)` now transitions to `ResumingRx` instead of `Idle`. TX failure during RX still responds `TX_FAILED` to the caller, then returns to RX — matching the shape of the adjacent `ChannelBusy` handler, which already did this. * `ResumingRx.on(StartRxFailed)` is now a bounded retry (5 attempts, 25 ms backoff) rather than an immediate drop to `Idle`. Only after the retry budget is exhausted does the chip actually land in `Idle`, at which point it's genuinely wedged and needs reconfiguring. Observable effect: the OLED dashboard no longer bounces to Splash when a client (e.g. ai-bot) TXes while bridges are receiving. Previously any single hiccup in the RX/TX/ResumeRX cycle would announce `RadioEvent::Idle`, bouncing the OLED, and the radio would sit in Idle with no client aware enough to re-issue RX_START — stranding the session. The invariant should have held for the whole RX window between `CMD_RX_START` and `CMD_RX_STOP`; now it does. Implementation notes: * Two new internal `RadioInput` variants: `ResumeRxRetry` drives the `ResumingRx` self-transition; `ResumeRxExhausted` breaks out to Idle after too many failed tries. * New `resume_retries: u8` field on `RadioContext`, reset to 0 on every successful `StartRxOk`, bumped by `bump_resume_retries`. * Constants `MAX_RESUME_RETRIES = 5` and `RESUME_RX_RETRY_BACKOFF_MS = 25` at the top of `radio.rs`. * Removed the dead `announce_rx_failed` helper — its only caller was the now-deleted `Receiving.on(RxFailed) => Idle` edge. No behavior change for the three paths where `Idle` remains the correct landing state: initial `CmdRxStart` failure in `StartingRx`, `CmdRxStop` in `Receiving`, and all three `TransmittingFromIdle` outcomes (where RX wasn't running to begin with).
819 lines
31 KiB
Rust
819 lines
31 KiB
Rust
//! LoRa radio task: SX1262 statechart driven by DongLoRa Protocol v2 host commands.
|
||
//!
|
||
//! ```text
|
||
//! Radio
|
||
//! ├── Unconfigured (default) no config cached → RF ops rejected
|
||
//! └── Configured
|
||
//! ├── Idle (default) ready, waiting
|
||
//! ├── StartingRx brief: HW init in response to host
|
||
//! │ RxStart; responds Ok/Err then →
|
||
//! │ Receiving (Ok) or Idle (Err)
|
||
//! ├── Receiving continuous RX
|
||
//! ├── ResumingRx brief: post-TX HW re-arm; silent →
|
||
//! │ Receiving (Ok) or Idle (Err)
|
||
//! └── Transmitting
|
||
//! ├── FromIdle (default) TxDone → Idle
|
||
//! └── FromRx TxDone → ResumingRx
|
||
//! ```
|
||
//!
|
||
//! The task body is a single `machine.run().await`. Host commands flow in
|
||
//! via the `CommandChannel` as `InboundCommand { tag, cmd }` envelopes;
|
||
//! outbound D→H messages flow out via `OutboundChannel` as `OutboundFrame
|
||
//! { tag, msg }` envelopes (tag `0` for async RX / async ERR, echoed tag
|
||
//! for tag-correlated OK / ERR / TX_DONE).
|
||
//!
|
||
//! The `RadioInput` event variants intentionally do NOT carry the
|
||
//! command tag. Tags are routing metadata — the `next_command` during
|
||
//! activity stashes `incoming_tag` into context before emitting the
|
||
//! corresponding event, and action handlers read it back from context
|
||
//! when building responses. This keeps the hsmc DSL free of
|
||
//! bindings-with-transition combos, which hsmc's macro forbids.
|
||
|
||
use defmt::{error, info, warn};
|
||
use embassy_executor::task;
|
||
use embassy_time::{Delay, Instant};
|
||
use heapless::Vec as HVec;
|
||
use hsmc::statechart;
|
||
use lora_phy::mod_params::RadioError;
|
||
use lora_phy::{LoRa, RxMode};
|
||
|
||
use crate::board::{RadioDriver, RadioParts};
|
||
use crate::channel::{
|
||
CommandChannel, InboundEvent, OutboundChannel, OutboundFrame, RadioEvent, RadioEventChannel,
|
||
};
|
||
use crate::protocol::{
|
||
Command, DeviceMessage, ErrorCode, Info, LoRaBandwidth, LoRaCodingRate, LoRaConfig,
|
||
LoRaHeaderMode, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult,
|
||
SetConfigResultCode, TxDonePayload, TxResult, MAX_OTA_PAYLOAD,
|
||
};
|
||
|
||
const CAD_MAX_RETRIES: u8 = 10;
|
||
|
||
type LoRaDriver = LoRa<RadioDriver, Delay>;
|
||
|
||
// ── Pending TX staging ──────────────────────────────────────────────
|
||
|
||
/// A TX request accepted by the radio task but not yet on the air.
|
||
/// Carries the tag so the eventual `TX_DONE` echoes it.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PendingTx {
|
||
pub tag: u16,
|
||
pub skip_cad: bool,
|
||
pub data: HVec<u8, MAX_OTA_PAYLOAD>,
|
||
pub config: LoRaConfig,
|
||
}
|
||
|
||
// ── Events driving the statechart ───────────────────────────────────
|
||
//
|
||
// Per-event tags are NOT carried on these variants (see module doc):
|
||
// `next_command` stashes `ctx.incoming_tag` before emitting.
|
||
|
||
#[allow(clippy::large_enum_variant)]
|
||
#[derive(Debug, Clone)]
|
||
pub enum RadioInput {
|
||
CmdPing,
|
||
CmdGetInfo,
|
||
CmdSetConfig(Modulation),
|
||
/// TX request. The stashed tag + flags + data sit on the context's
|
||
/// `pending_tx` slot (filled by `next_command`); this event merely
|
||
/// wakes the chart.
|
||
CmdTx,
|
||
CmdRxStart,
|
||
CmdRxStop,
|
||
|
||
/// Host-transport disconnect / liveness timeout: park the radio.
|
||
Reset,
|
||
|
||
/// Internal: emitted by `apply_set_config` on a successful LoRa
|
||
/// configuration apply. Drives the Unconfigured → Configured
|
||
/// transition. No-op when dispatched in Configured.
|
||
ConfigApplied,
|
||
/// Internal: emitted by `apply_set_config` when the SPI write to the
|
||
/// radio failed mid-configuration (spec §6.3). Drives Configured →
|
||
/// Unconfigured; no-op in Unconfigured.
|
||
ConfigFailedRadio,
|
||
|
||
PacketReceived(RxPayload),
|
||
RxFailed,
|
||
StartRxOk,
|
||
StartRxFailed,
|
||
TransmitDone {
|
||
airtime_us: u32,
|
||
},
|
||
ChannelBusy,
|
||
TransmitFailed,
|
||
|
||
/// Internal: emitted by `bump_resume_retries` when another
|
||
/// `try_start_rx_hw` attempt is allowed. Drives a self-transition on
|
||
/// `ResumingRx` so the entry action runs again after a short backoff.
|
||
ResumeRxRetry,
|
||
/// Internal: emitted by `bump_resume_retries` after
|
||
/// `MAX_RESUME_RETRIES` attempts have failed — the chip is genuinely
|
||
/// wedged; fall through to `Idle` so the host can reconfigure.
|
||
ResumeRxExhausted,
|
||
}
|
||
|
||
/// How many back-to-back `try_start_rx_hw` failures to tolerate in
|
||
/// `ResumingRx` before giving up and parking in `Idle`. 5 × 25 ms ≈
|
||
/// 125 ms worst-case resume window, enough to ride out SX1262
|
||
/// post-TX timing flukes without pegging the CPU on a dead chip.
|
||
const MAX_RESUME_RETRIES: u8 = 5;
|
||
|
||
/// Backoff between `ResumingRx` retry attempts.
|
||
const RESUME_RX_RETRY_BACKOFF_MS: u64 = 25;
|
||
|
||
// ── Context ─────────────────────────────────────────────────────────
|
||
|
||
pub struct RadioContext {
|
||
pub commands: &'static CommandChannel,
|
||
pub outbound: &'static OutboundChannel,
|
||
pub events: &'static RadioEventChannel,
|
||
pub info: &'static Info,
|
||
pub lora: LoRaDriver,
|
||
pub rx_buf: [u8; MAX_OTA_PAYLOAD],
|
||
/// Active LoRa config. `None` in `Unconfigured`; `Some(cfg)` in
|
||
/// `Configured` (LoRa is the only modulation the firmware drives
|
||
/// today; others are rejected upstream with `EMODULATION`).
|
||
pub config: Option<LoRaConfig>,
|
||
pub pending_tx: Option<PendingTx>,
|
||
/// Packets dropped since the previous successfully-delivered RX event.
|
||
pub packets_dropped: u16,
|
||
/// Tag of the command currently being handled. Written by
|
||
/// `next_command` just before emitting the event; read by action
|
||
/// handlers to construct tag-echoed responses.
|
||
pub incoming_tag: u16,
|
||
/// Tag of the TX currently on the wire. Captured on entry to
|
||
/// `Transmitting`; consumed by `respond_tx_*` handlers.
|
||
pub last_tx_tag: Option<u16>,
|
||
/// Tag of a pending `RX_START`, pulled by `respond_ok_start_rx` /
|
||
/// `respond_err_start_rx` once the chip reports back.
|
||
pub pending_rx_start_tag: Option<u16>,
|
||
/// Number of consecutive `try_start_rx_hw` failures while in
|
||
/// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES`
|
||
/// for the limit before falling through to `Idle`.
|
||
pub resume_retries: u8,
|
||
}
|
||
|
||
impl RadioContext {
|
||
fn announce(&self, ev: RadioEvent) {
|
||
if self.events.try_send(ev).is_err() {
|
||
warn!("radio event dropped: downstream queue full");
|
||
}
|
||
}
|
||
|
||
async fn send(&self, tag: u16, msg: DeviceMessage) {
|
||
self.outbound.send(OutboundFrame { tag, msg }).await;
|
||
}
|
||
}
|
||
|
||
// ── Statechart ─────────────────────────────────────────────────────
|
||
|
||
statechart! {
|
||
Radio {
|
||
context: RadioContext;
|
||
events: RadioInput;
|
||
default(Unconfigured);
|
||
|
||
// Globals: PING always answers; GET_INFO always answers; Reset
|
||
// forces Unconfigured.
|
||
on(CmdPing) => respond_pong;
|
||
on(CmdGetInfo) => respond_info;
|
||
on(Reset) => reset_to_unconfigured, Unconfigured;
|
||
|
||
state Unconfigured {
|
||
during: next_command(commands, incoming_tag, pending_tx);
|
||
on(CmdSetConfig(m: Modulation)) => apply_set_config;
|
||
on(ConfigApplied) => Configured;
|
||
on(CmdTx) => reject_tx_not_configured;
|
||
on(CmdRxStart) => reject_not_configured;
|
||
on(CmdRxStop) => reject_not_configured;
|
||
}
|
||
|
||
state Configured {
|
||
on(CmdSetConfig(m: Modulation)) => apply_set_config;
|
||
on(ConfigFailedRadio) => Unconfigured;
|
||
// Default post-reconfigure landing is Idle. The RX ring is
|
||
// always cleared as part of apply_set_config. Receiving
|
||
// overrides this handler to auto-resume RX with the new
|
||
// parameters (spec §3.5) so callers do not need to re-issue
|
||
// RX_START after SET_CONFIG.
|
||
on(ConfigApplied) => Idle;
|
||
default(Idle);
|
||
|
||
state Idle {
|
||
entry: announce_idle;
|
||
during: next_command(commands, incoming_tag, pending_tx);
|
||
on(CmdRxStart) => stage_rx_start;
|
||
on(CmdRxStart) => StartingRx;
|
||
on(CmdTx) => TransmittingFromIdle;
|
||
on(CmdRxStop) => respond_ok;
|
||
}
|
||
|
||
state StartingRx {
|
||
entry: try_start_rx_hw;
|
||
on(StartRxOk) => respond_ok_start_rx, Receiving;
|
||
on(StartRxFailed) => respond_err_start_rx, Idle;
|
||
}
|
||
|
||
state Receiving {
|
||
entry: announce_entered_rx;
|
||
during: next_command(commands, incoming_tag, pending_tx);
|
||
during: next_packet(lora, rx_buf, config);
|
||
on(CmdRxStart) => respond_ok;
|
||
on(PacketReceived(rx: RxPayload)) => record_packet;
|
||
// Transient driver error during `next_packet` — re-arm
|
||
// the chip via ResumingRx rather than parking in Idle
|
||
// (which would bounce the OLED and leave the radio
|
||
// waiting for a client-initiated RX_START that will
|
||
// never come because clients already think RX is up).
|
||
on(RxFailed) => ResumingRx;
|
||
on(CmdRxStop) => stop_rx_hw, respond_ok;
|
||
on(CmdRxStop) => Idle;
|
||
on(CmdTx) => TransmittingFromRx;
|
||
// Spec §3.5: a SET_CONFIG issued while receiving clears
|
||
// the RX ring (already handled by apply_set_config) but
|
||
// auto-rearms RX with the new parameters — the caller
|
||
// does not need to send a fresh RX_START. Override the
|
||
// parent's `on(ConfigApplied) => Idle` by landing in
|
||
// ResumingRx, whose entry kicks the chip back into RX.
|
||
on(ConfigApplied) => ResumingRx;
|
||
}
|
||
|
||
// Bounded-retry RX re-arm. Entered after every TX while
|
||
// already receiving, on RX driver errors, and on mid-session
|
||
// SET_CONFIG. If `try_start_rx_hw` succeeds we land cleanly
|
||
// in Receiving; if it fails we back off and retry, only
|
||
// conceding to Idle after MAX_RESUME_RETRIES attempts (chip
|
||
// is genuinely wedged at that point).
|
||
state ResumingRx {
|
||
entry: try_start_rx_hw;
|
||
on(StartRxOk) => reset_resume_retries, Receiving;
|
||
on(StartRxFailed) => bump_resume_retries;
|
||
on(ResumeRxRetry) => ResumingRx;
|
||
on(ResumeRxExhausted) => Idle;
|
||
}
|
||
|
||
state Transmitting {
|
||
entry: announce_entered_tx;
|
||
exit: announce_packet_tx;
|
||
default(TransmittingFromIdle);
|
||
|
||
state TransmittingFromIdle {
|
||
during: perform_tx(lora, pending_tx);
|
||
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
||
on(TransmitDone) => Idle;
|
||
on(ChannelBusy) => respond_tx_channel_busy, Idle;
|
||
on(TransmitFailed) => respond_tx_failed, Idle;
|
||
}
|
||
|
||
state TransmittingFromRx {
|
||
during: perform_tx(lora, pending_tx);
|
||
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
||
on(TransmitDone) => ResumingRx;
|
||
// Even on failure, we were receiving before the TX —
|
||
// return to RX (the client still gets a TX_FAILED
|
||
// reply via respond_tx_failed). Dropping to Idle here
|
||
// would break the "RX session stays up" invariant.
|
||
on(ChannelBusy) => respond_tx_channel_busy, ResumingRx;
|
||
on(TransmitFailed) => respond_tx_failed, ResumingRx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── during: activities ──────────────────────────────────────────────
|
||
|
||
/// Read the next host command, stash its tag into context, and (for TX)
|
||
/// stage a placeholder pending-TX struct before emitting the corresponding
|
||
/// event. The `apply_set_config` / `try_start_rx_hw` / TX-staging actions
|
||
/// patch in the real config from ctx when they run.
|
||
async fn next_command(
|
||
commands: &mut &'static CommandChannel,
|
||
incoming_tag: &mut u16,
|
||
pending_tx: &mut Option<PendingTx>,
|
||
) -> RadioInput {
|
||
match commands.receive().await {
|
||
InboundEvent::Reset => RadioInput::Reset,
|
||
InboundEvent::Command { tag, cmd } => {
|
||
*incoming_tag = tag;
|
||
match cmd {
|
||
Command::Ping => RadioInput::CmdPing,
|
||
Command::GetInfo => RadioInput::CmdGetInfo,
|
||
Command::SetConfig(m) => RadioInput::CmdSetConfig(m),
|
||
Command::Tx { flags, data } => {
|
||
// Placeholder config: the real `ctx.config` is patched
|
||
// in by `announce_entered_tx`. `reject_tx_not_configured`
|
||
// (Unconfigured state) consumes this slot without
|
||
// touching the config field.
|
||
*pending_tx = Some(PendingTx {
|
||
tag,
|
||
skip_cad: flags.skip_cad,
|
||
data,
|
||
config: dummy_lora_config(),
|
||
});
|
||
RadioInput::CmdTx
|
||
}
|
||
Command::RxStart => RadioInput::CmdRxStart,
|
||
Command::RxStop => RadioInput::CmdRxStop,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn next_packet(
|
||
lora: &mut LoRaDriver,
|
||
rx_buf: &mut [u8; MAX_OTA_PAYLOAD],
|
||
config: &mut Option<LoRaConfig>,
|
||
) -> RadioInput {
|
||
let Some(cfg) = config.as_ref() else {
|
||
return RadioInput::RxFailed;
|
||
};
|
||
let mdltn = match to_lora_mod_params(lora, cfg) {
|
||
Ok(m) => m,
|
||
Err(_) => return RadioInput::RxFailed,
|
||
};
|
||
let pkt = match lora.create_rx_packet_params(
|
||
cfg.preamble_len,
|
||
matches!(cfg.header_mode, LoRaHeaderMode::Implicit),
|
||
MAX_OTA_PAYLOAD as u8,
|
||
cfg.payload_crc,
|
||
cfg.iq_invert,
|
||
&mdltn,
|
||
) {
|
||
Ok(p) => p,
|
||
Err(_) => return RadioInput::RxFailed,
|
||
};
|
||
match lora.rx(&pkt, rx_buf).await {
|
||
Ok((len, status)) => {
|
||
let ts_us = Instant::now().as_micros();
|
||
let copy_len = (len as usize).min(MAX_OTA_PAYLOAD);
|
||
let mut data: HVec<u8, MAX_OTA_PAYLOAD> = HVec::new();
|
||
let _ = data.extend_from_slice(&rx_buf[..copy_len]);
|
||
RadioInput::PacketReceived(RxPayload {
|
||
rssi_tenths_dbm: (status.rssi as i32 * 10) as i16,
|
||
snr_tenths_db: (status.snr as i32 * 10) as i16,
|
||
// lora-phy 3.0's RxStatus doesn't expose FreqErr; 0 for now.
|
||
freq_err_hz: 0,
|
||
timestamp_us: ts_us,
|
||
// SX126x silently drops bad-CRC packets at the chip; any
|
||
// packet arriving here passed CRC.
|
||
crc_valid: true,
|
||
packets_dropped: 0, // filled in by record_packet
|
||
origin: RxOrigin::Ota,
|
||
data,
|
||
})
|
||
}
|
||
Err(_) => RadioInput::RxFailed,
|
||
}
|
||
}
|
||
|
||
async fn perform_tx(lora: &mut LoRaDriver, pending: &mut Option<PendingTx>) -> RadioInput {
|
||
let Some(tx) = pending.take() else {
|
||
return RadioInput::TransmitFailed;
|
||
};
|
||
let start = Instant::now();
|
||
match do_tx(lora, &tx).await {
|
||
Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone {
|
||
airtime_us: Instant::now().duration_since(start).as_micros() as u32,
|
||
},
|
||
Ok(TxOutcome::ChannelBusy) => RadioInput::ChannelBusy,
|
||
Err(_) => RadioInput::TransmitFailed,
|
||
}
|
||
}
|
||
|
||
// ── Actions ─────────────────────────────────────────────────────────
|
||
|
||
impl RadioActions for RadioActionContext<'_> {
|
||
// ── PING / GET_INFO ───────────────────────────────────────────────
|
||
|
||
async fn respond_pong(&mut self) {
|
||
let tag = self.incoming_tag;
|
||
self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await;
|
||
}
|
||
|
||
async fn respond_info(&mut self) {
|
||
let tag = self.incoming_tag;
|
||
let info = *self.info;
|
||
self.send(tag, DeviceMessage::Ok(OkPayload::Info(info)))
|
||
.await;
|
||
}
|
||
|
||
// ── SET_CONFIG ────────────────────────────────────────────────────
|
||
|
||
async fn apply_set_config(&mut self, m: Modulation) {
|
||
let tag = self.incoming_tag;
|
||
match m {
|
||
Modulation::LoRa(cfg) => {
|
||
if let Err(code) = validate_lora(&cfg, self.info) {
|
||
// Validation failure: radio + state unchanged (spec §3.5).
|
||
self.send(tag, DeviceMessage::Err(code)).await;
|
||
return;
|
||
}
|
||
// Cancel any queued-but-not-started TX per spec §3.5.
|
||
if let Some(cancelled) = self.pending_tx.take() {
|
||
let td = TxDonePayload {
|
||
result: TxResult::Cancelled,
|
||
airtime_us: 0,
|
||
};
|
||
self.send(cancelled.tag, DeviceMessage::TxDone(td)).await;
|
||
}
|
||
if let Err(_e) = reconfigure_radio(&mut self.lora, &cfg).await {
|
||
// Hardware failure: drop to Unconfigured (spec §6.3).
|
||
self.config = None;
|
||
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
|
||
let _ = self.emit(RadioInput::ConfigFailedRadio);
|
||
return;
|
||
}
|
||
self.config = Some(cfg);
|
||
self.packets_dropped = 0;
|
||
self.announce(RadioEvent::ConfigChanged(cfg));
|
||
let result = SetConfigResult {
|
||
result: SetConfigResultCode::Applied,
|
||
owner: Owner::Mine,
|
||
current: Modulation::LoRa(cfg),
|
||
};
|
||
self.send(tag, DeviceMessage::Ok(OkPayload::SetConfig(result)))
|
||
.await;
|
||
// Drive the Unconfigured → Configured transition (no-op in
|
||
// Configured — there's no handler for this event there).
|
||
let _ = self.emit(RadioInput::ConfigApplied);
|
||
}
|
||
_ => {
|
||
self.send(tag, DeviceMessage::Err(ErrorCode::EModulation))
|
||
.await;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── TX ────────────────────────────────────────────────────────────
|
||
|
||
async fn reject_tx_not_configured(&mut self) {
|
||
if let Some(pt) = self.pending_tx.take() {
|
||
self.send(pt.tag, DeviceMessage::Err(ErrorCode::ENotConfigured))
|
||
.await;
|
||
}
|
||
}
|
||
|
||
async fn respond_tx_done(&mut self, airtime_us: u32) {
|
||
if let Some(tag) = self.last_tx_tag.take() {
|
||
let td = TxDonePayload {
|
||
result: TxResult::Transmitted,
|
||
airtime_us,
|
||
};
|
||
self.send(tag, DeviceMessage::TxDone(td)).await;
|
||
}
|
||
}
|
||
|
||
async fn respond_tx_channel_busy(&mut self) {
|
||
if let Some(tag) = self.last_tx_tag.take() {
|
||
let td = TxDonePayload {
|
||
result: TxResult::ChannelBusy,
|
||
airtime_us: 0,
|
||
};
|
||
self.send(tag, DeviceMessage::TxDone(td)).await;
|
||
}
|
||
}
|
||
|
||
async fn respond_tx_failed(&mut self) {
|
||
if let Some(tag) = self.last_tx_tag.take() {
|
||
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
|
||
}
|
||
}
|
||
|
||
// ── RX_START / RX_STOP ────────────────────────────────────────────
|
||
|
||
async fn stage_rx_start(&mut self) {
|
||
self.pending_rx_start_tag = Some(self.incoming_tag);
|
||
}
|
||
|
||
async fn try_start_rx_hw(&mut self) {
|
||
let Some(cfg) = self.config else {
|
||
error!("try_start_rx_hw: no config cached — state invariant broken");
|
||
let _ = self.emit(RadioInput::StartRxFailed);
|
||
return;
|
||
};
|
||
match start_rx(&mut self.lora, &cfg).await {
|
||
Ok(()) => {
|
||
let _ = self.emit(RadioInput::StartRxOk);
|
||
}
|
||
Err(e) => {
|
||
warn!("start_rx failed: {}", e);
|
||
let _ = self.emit(RadioInput::StartRxFailed);
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn reset_resume_retries(&mut self) {
|
||
self.resume_retries = 0;
|
||
}
|
||
|
||
async fn bump_resume_retries(&mut self) {
|
||
self.resume_retries = self.resume_retries.saturating_add(1);
|
||
if self.resume_retries >= MAX_RESUME_RETRIES {
|
||
warn!(
|
||
"ResumingRx: {} failed attempts, giving up to Idle",
|
||
self.resume_retries
|
||
);
|
||
self.resume_retries = 0;
|
||
let _ = self.emit(RadioInput::ResumeRxExhausted);
|
||
return;
|
||
}
|
||
// Short backoff before the next `try_start_rx_hw` cycle. The
|
||
// self-transition on ResumingRx (driven by ResumeRxRetry) will
|
||
// re-run the entry action.
|
||
embassy_time::Timer::after_millis(RESUME_RX_RETRY_BACKOFF_MS).await;
|
||
let _ = self.emit(RadioInput::ResumeRxRetry);
|
||
}
|
||
|
||
async fn respond_ok(&mut self) {
|
||
let tag = self.incoming_tag;
|
||
self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await;
|
||
}
|
||
|
||
async fn respond_ok_start_rx(&mut self) {
|
||
if let Some(tag) = self.pending_rx_start_tag.take() {
|
||
self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await;
|
||
}
|
||
}
|
||
|
||
async fn respond_err_start_rx(&mut self) {
|
||
if let Some(tag) = self.pending_rx_start_tag.take() {
|
||
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
|
||
}
|
||
}
|
||
|
||
async fn reject_not_configured(&mut self) {
|
||
let tag = self.incoming_tag;
|
||
self.send(tag, DeviceMessage::Err(ErrorCode::ENotConfigured))
|
||
.await;
|
||
}
|
||
|
||
async fn stop_rx_hw(&mut self) {
|
||
let _ = self.lora.enter_standby().await;
|
||
}
|
||
|
||
// ── Reset (disconnect / inactivity timeout) ──────────────────────
|
||
|
||
async fn reset_to_unconfigured(&mut self) {
|
||
self.pending_tx = None;
|
||
self.last_tx_tag = None;
|
||
self.pending_rx_start_tag = None;
|
||
self.config = None;
|
||
self.packets_dropped = 0;
|
||
let _ = self.lora.enter_standby().await;
|
||
self.announce(RadioEvent::Idle);
|
||
}
|
||
|
||
// ── Announcements ─────────────────────────────────────────────────
|
||
|
||
async fn announce_idle(&mut self) {
|
||
self.announce(RadioEvent::Idle);
|
||
}
|
||
|
||
async fn announce_entered_rx(&mut self) {
|
||
self.announce(RadioEvent::EnteredRx);
|
||
}
|
||
|
||
async fn announce_entered_tx(&mut self) {
|
||
// Patch the staged TX's config to whatever's active now. The
|
||
// tag and data stay exactly as the host sent them.
|
||
if let (Some(cfg), Some(pt)) = (self.config, self.pending_tx.as_mut()) {
|
||
pt.config = cfg;
|
||
}
|
||
if let Some(pt) = self.pending_tx.as_ref() {
|
||
self.last_tx_tag = Some(pt.tag);
|
||
}
|
||
self.announce(RadioEvent::EnteredTx);
|
||
}
|
||
|
||
async fn announce_packet_tx(&mut self) {
|
||
self.announce(RadioEvent::PacketTx);
|
||
}
|
||
|
||
// ── Packet bookkeeping ────────────────────────────────────────────
|
||
|
||
async fn record_packet(&mut self, rx: RxPayload) {
|
||
let rssi_dbm = rx.rssi_tenths_dbm / 10;
|
||
let snr_db = Some(rx.snr_tenths_db / 10);
|
||
self.announce(RadioEvent::PacketRx {
|
||
rssi: rssi_dbm,
|
||
snr: snr_db,
|
||
});
|
||
let mut rx = rx;
|
||
rx.packets_dropped = self.packets_dropped;
|
||
let delivered = self
|
||
.outbound
|
||
.try_send(OutboundFrame {
|
||
tag: 0,
|
||
msg: DeviceMessage::Rx(rx),
|
||
})
|
||
.is_ok();
|
||
if delivered {
|
||
self.packets_dropped = 0;
|
||
} else {
|
||
warn!("RX dropped: outbound queue full");
|
||
self.packets_dropped = self.packets_dropped.saturating_add(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── LoRa helpers ────────────────────────────────────────────────────
|
||
|
||
enum TxOutcome {
|
||
Transmitted,
|
||
ChannelBusy,
|
||
}
|
||
|
||
fn to_bw(bw: LoRaBandwidth) -> Option<lora_phy::mod_params::Bandwidth> {
|
||
use lora_phy::mod_params::Bandwidth::*;
|
||
Some(match bw {
|
||
LoRaBandwidth::Khz7 => _7KHz,
|
||
LoRaBandwidth::Khz10 => _10KHz,
|
||
LoRaBandwidth::Khz15 => _15KHz,
|
||
LoRaBandwidth::Khz20 => _20KHz,
|
||
LoRaBandwidth::Khz31 => _31KHz,
|
||
LoRaBandwidth::Khz41 => _41KHz,
|
||
LoRaBandwidth::Khz62 => _62KHz,
|
||
LoRaBandwidth::Khz125 => _125KHz,
|
||
LoRaBandwidth::Khz250 => _250KHz,
|
||
LoRaBandwidth::Khz500 => _500KHz,
|
||
LoRaBandwidth::Khz200
|
||
| LoRaBandwidth::Khz400
|
||
| LoRaBandwidth::Khz800
|
||
| LoRaBandwidth::Khz1600 => return None,
|
||
})
|
||
}
|
||
|
||
fn to_sf(sf: u8) -> Option<lora_phy::mod_params::SpreadingFactor> {
|
||
use lora_phy::mod_params::SpreadingFactor::*;
|
||
Some(match sf {
|
||
5 => _5,
|
||
6 => _6,
|
||
7 => _7,
|
||
8 => _8,
|
||
9 => _9,
|
||
10 => _10,
|
||
11 => _11,
|
||
12 => _12,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
fn to_cr(cr: LoRaCodingRate) -> lora_phy::mod_params::CodingRate {
|
||
use lora_phy::mod_params::CodingRate::*;
|
||
match cr {
|
||
LoRaCodingRate::Cr4_5 => _4_5,
|
||
LoRaCodingRate::Cr4_6 => _4_6,
|
||
LoRaCodingRate::Cr4_7 => _4_7,
|
||
LoRaCodingRate::Cr4_8 => _4_8,
|
||
}
|
||
}
|
||
|
||
fn to_lora_mod_params(
|
||
lora: &mut LoRaDriver,
|
||
cfg: &LoRaConfig,
|
||
) -> Result<lora_phy::mod_params::ModulationParams, RadioError> {
|
||
let sf = to_sf(cfg.sf).ok_or(RadioError::UnavailableSpreadingFactor)?;
|
||
let bw = to_bw(cfg.bw).ok_or(RadioError::UnavailableBandwidth)?;
|
||
let cr = to_cr(cfg.cr);
|
||
lora.create_modulation_params(sf, bw, cr, cfg.freq_hz)
|
||
}
|
||
|
||
async fn start_rx(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> {
|
||
let mdltn = to_lora_mod_params(lora, cfg)?;
|
||
let pkt = lora.create_rx_packet_params(
|
||
cfg.preamble_len,
|
||
matches!(cfg.header_mode, LoRaHeaderMode::Implicit),
|
||
MAX_OTA_PAYLOAD as u8,
|
||
cfg.payload_crc,
|
||
cfg.iq_invert,
|
||
&mdltn,
|
||
)?;
|
||
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await
|
||
}
|
||
|
||
async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> {
|
||
lora.enter_standby().await?;
|
||
// Calibrate image etc. via a throwaway create_modulation_params call
|
||
// — inside the SET_CONFIG OK window so first TX/RX is fast.
|
||
let _ = to_lora_mod_params(lora, cfg)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn do_tx(lora: &mut LoRaDriver, tx: &PendingTx) -> Result<TxOutcome, RadioError> {
|
||
let mdltn = to_lora_mod_params(lora, &tx.config)?;
|
||
|
||
if !tx.skip_cad {
|
||
lora.prepare_for_cad(&mdltn).await?;
|
||
let mut saw_activity = false;
|
||
for _ in 0..CAD_MAX_RETRIES {
|
||
if !lora.cad(&mdltn).await? {
|
||
saw_activity = false;
|
||
break;
|
||
}
|
||
saw_activity = true;
|
||
embassy_time::Timer::after_millis(10).await;
|
||
lora.prepare_for_cad(&mdltn).await?;
|
||
}
|
||
if saw_activity {
|
||
return Ok(TxOutcome::ChannelBusy);
|
||
}
|
||
}
|
||
|
||
let mut tx_pkt = lora.create_tx_packet_params(
|
||
tx.config.preamble_len,
|
||
matches!(tx.config.header_mode, LoRaHeaderMode::Implicit),
|
||
tx.config.payload_crc,
|
||
tx.config.iq_invert,
|
||
&mdltn,
|
||
)?;
|
||
lora.prepare_for_tx(&mdltn, &mut tx_pkt, tx.config.tx_power_dbm as i32, &tx.data)
|
||
.await?;
|
||
lora.tx().await?;
|
||
Ok(TxOutcome::Transmitted)
|
||
}
|
||
|
||
fn validate_lora(cfg: &LoRaConfig, info: &Info) -> Result<(), ErrorCode> {
|
||
if cfg.freq_hz < info.freq_min_hz || cfg.freq_hz > info.freq_max_hz {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
if !(5..=12).contains(&cfg.sf) {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
if info.supported_sf_bitmap & (1u16 << cfg.sf) == 0 {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
if info.supported_bw_bitmap & (1u16 << cfg.bw.as_u8()) == 0 {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
if cfg.tx_power_dbm < info.tx_power_min_dbm || cfg.tx_power_dbm > info.tx_power_max_dbm {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
if cfg.preamble_len < 6 {
|
||
return Err(ErrorCode::EParam);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn dummy_lora_config() -> LoRaConfig {
|
||
LoRaConfig {
|
||
freq_hz: 915_000_000,
|
||
sf: 7,
|
||
bw: LoRaBandwidth::Khz125,
|
||
cr: LoRaCodingRate::Cr4_5,
|
||
preamble_len: 8,
|
||
sync_word: 0x1424,
|
||
tx_power_dbm: 0,
|
||
header_mode: LoRaHeaderMode::Explicit,
|
||
payload_crc: true,
|
||
iq_invert: false,
|
||
}
|
||
}
|
||
|
||
// ── Task entry ──────────────────────────────────────────────────────
|
||
|
||
#[task]
|
||
pub async fn radio_task(
|
||
parts: RadioParts,
|
||
commands: &'static CommandChannel,
|
||
outbound: &'static OutboundChannel,
|
||
events: &'static RadioEventChannel,
|
||
info: &'static Info,
|
||
) {
|
||
let lora = match LoRa::new(parts.driver, false, parts.delay).await {
|
||
Ok(l) => {
|
||
info!("radio initialized");
|
||
l
|
||
}
|
||
Err(e) => {
|
||
error!("radio init failed: {}", e);
|
||
return;
|
||
}
|
||
};
|
||
|
||
let ctx = RadioContext {
|
||
commands,
|
||
outbound,
|
||
events,
|
||
info,
|
||
lora,
|
||
rx_buf: [0u8; MAX_OTA_PAYLOAD],
|
||
config: None,
|
||
pending_tx: None,
|
||
packets_dropped: 0,
|
||
incoming_tag: 0,
|
||
last_tx_tag: None,
|
||
pending_rx_start_tag: None,
|
||
resume_retries: 0,
|
||
};
|
||
|
||
static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<
|
||
embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex,
|
||
RadioInput,
|
||
8,
|
||
> = embassy_sync::channel::Channel::new();
|
||
let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH);
|
||
let _ = machine.run().await;
|
||
}
|