Files
firmware/src/radio.rs
T
swaits 0b9f9ee882 fix(radio): apply LoRaConfig.sync_word to the chip on SET_CONFIG
`reconfigure_radio` previously consumed `cfg.sync_word` from the host
in name only — it called `enter_standby` and constructed a fresh set
of LoRa modulation params (which carry sf/bw/cr/freq but not the sync
word), then returned. lora-phy v3 has no public sync-word setter for
SX126x, and there's no codepath in this crate that touches the chip's
LoRaSyncword registers (0x0740/0x0741) after boot. The chip therefore
sat on whatever `LoRa::new(_, false, _)` set at cold init —
`LORAWAN_PRIVATE_SYNCWORD` (byte 0x12, register pair 0x1424) — for the
entire session, regardless of what the host requested.

That happened to work for every existing user because everyone wants
0x1424 (MeshCore's sync byte). It manifested as soon as a host actually
needed a different value: `anon_ping.py` survived because MeshCore
matches, but any custom mesh / non-MeshCore network would get silent
wrong-sync-word TXes that no peer could hear and no host-side diagnostic
could pin down.

This commit applies the host-supplied sync word inside
`reconfigure_radio`, between the existing `enter_standby` and the
mod-params construction:

  - `cfg.sync_word` (the protocol's u16, RadioLib-style; e.g.
    0x1424 == byte 0x12, 0x3444 == byte 0x34) is decoded back to its
    underlying single byte: `byte = (hi & 0xF0) | (lo >> 4)` after
    `to_be_bytes()`, the inverse of lora-phy's `convert_sync_word`
    nibble-expansion.

  - That byte is handed to lora-phy's new
    `LoRa<RK,DLY>::set_lora_sync_word` (added in the matching
    lora-phy commit), which honours SX1261/2 datasheet §8.3.1 by
    driving `ensure_ready` + conditional `set_standby` before
    writing the register pair, and caches the new value in the
    `LoRa` struct so a later cold-reset NRESET (Recovering state)
    resumes with the host's sync word rather than reverting to the
    construction-time default.

  - Failure of the sync-word write lands the same way `enter_standby`
    failure already did: `apply_set_config` wipes `self.config`,
    sends `ERR(ERadio)`, and routes the chart to `Recovering` to
    NRESET + cold-init. No new error-handling plumbing required.

Cargo.lock bumps lora-phy from `fixed#e8a52ebd` to `fixed#8d66d1c4`,
which is the merge of three pre-existing `fix/*` branches plus a
new `feat/set-sync-word` branch carrying the lora-phy API addition.

Verified on hardware with a two-dongle bench test
(`examples/sync_word_test.py`): one dongle pinned to sync 0x5464,
the other rotating through six sync words. RX hears exactly the
matching cycles with realistic RSSI / SNR; non-matching cycles are
correctly filtered by the SX126x packet engine before reaching the
host.
2026-05-11 11:10:47 -06:00

2233 lines
101 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! LoRa radio task: SX1262 statechart driven by DongLoRa Protocol v2 host commands.
//!
//! ## Cancel-safety design rule
//!
//! lora-phy explicitly documents `lora.rx()` / `lora.cad()` / `lora.tx()` /
//! `complete_rx()` / `process_irq_event()` as "not safe to drop or cancel —
//! must run to completion". Dropping a cancel-unsafe future mid-await leaves
//! the chip in an indeterminate state from which only an NRESET pulse can
//! recover. Per hsmc spec §6, a state's `during:` activity is dropped when
//! the state is exited by a transition.
//!
//! The chart honors lora-phy's contract by ensuring **every cancel-unsafe
//! lora-phy future runs to natural completion**:
//!
//! 1. **RX uses cancel-safe primitives only.** `irq_pump` (RxSession's during)
//! composes only `wait_for_irq` (GPIO-sense), `get_irq_state` (single SPI
//! read), and `clear_irq_status` (single SPI write) — all documented
//! cancel-safe. The post-IRQ `get_rx_result` is in the `collect_rx_packet`
//! action, also cancel-safe (single SPI read pair). RxActive transitions
//! to RxDraining on CmdRxStop/CmdTx/ConfigApplied — RxSession's `irq_pump`
//! is dropped; RxDraining's entry calls `enter_standby` (cancel-safe;
//! forces the chip out of RX cleanly per SX1262 §8.5).
//!
//! 2. **TX sandboxes cancel-unsafe `lora.cad()` and `lora.tx()` AS-IS.**
//! `TxCadWait` and `TxOnAir` declare zero outbound event handlers other
//! than the events the during itself emits on natural completion. The
//! chart cannot drop these futures via a transition because no handler
//! can fire. Wedge timers (`on(after CAD_OVERLONG_MS) => mark_attempt_overlong`)
//! set context flags only — no transition. The during checks the flag at
//! its next cancel-safe wakeup and returns cleanly.
//!
//! 3. **Reset arriving mid-TX is DEFERRED.** TxSession's `on(Reset) =>
//! mark_pending_reset` only sets a flag. `route_tx_outcome` (TxFanout's
//! entry) checks the flag AFTER natural TX completion and emits
//! RadioInput::Reset then — Operational's `on(Reset) =>
//! reset_to_unconfigured` runs against a chip in known-clean state.
//!
//! ## Chart shape
//!
//! ```text
//! Radio
//! ├── Booting (default) cold LoRa::new — uninterruptible.
//! │ NO event handlers; LoRa::new runs
//! │ to natural completion. → Operational
//! │ on success, → Dead on fail.
//! ├── Operational owns all chip-touching lifecycle.
//! │ │ PING/GET_INFO/Reset/WedgeDetected
//! │ │ handlers live here, structurally
//! │ │ unreachable in Booting and Dead.
//! │ ├── Unconfigured (default) no config cached → RF ops rejected
//! │ ├── Configured
//! │ │ ├── Idle (default) ready, waiting
//! │ │ ├── RxSession super-state owning irq_pump
//! │ │ │ │ (cancel-safe IRQ wakeup loop)
//! │ │ │ ├── RxArming (default) brief: HW arming for RX_START
//! │ │ │ ├── RxActive continuous RX; collect_rx_packet
//! │ │ │ │ fires on each Dio1RxDone
//! │ │ │ ├── RxDraining cancel-safe RX wind-down;
//! │ │ │ │ enter_standby clears chip state
//! │ │ │ └── RxResuming bounded-retry HW re-arm post-TX
//! │ │ └── TxSession super-state owning TX lifecycle;
//! │ │ │ exit:respond_tx_failed guarantees
//! │ │ │ tag-correlated reply
//! │ │ ├── TxCadPrep (default) SPI prep for CAD (or skip)
//! │ │ ├── TxCadWait cancel-unsafe `lora.cad()` runs in
//! │ │ │ during; no preempting handlers
//! │ │ ├── TxBackoff jittered inter-CAD sleep (timer)
//! │ │ ├── TxPrep SPI prep for TX
//! │ │ ├── TxOnAir cancel-unsafe `lora.tx()` runs in
//! │ │ │ during; no preempting handlers
//! │ │ └── TxFanout route post-TX: Idle | RxResuming |
//! │ │ pending-Reset honored here
//! │ ├── Recovering in-place NRESET + lora.init() (also
//! │ │ cancel-unsafe; no preempting events
//! │ │ in Recovering — runs to completion).
//! │ └── CheckRecoveryBudget bounded-retry: Recovering vs Dead.
//! └── Dead terminal: cold init failed OR
//! recovery exhausted. dead_drain
//! replies ERadio inline. Cold POR
//! is the only escape. radio_task
//! panics if run() returns (chart
//! is non-terminating by design).
//! ```
//!
//! 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;
use heapless::Vec as HVec;
use hsmc::{statechart, Duration};
use lora_phy::mod_params::{ModulationParams, PacketParams, RadioError};
use lora_phy::mod_traits::IrqState;
use lora_phy::{LoRa, RxMode};
// `IrqState` is opaque to RadioInput (doesn't derive Debug, which
// RadioInput requires). irq_pump matches on it locally and emits
// Dio1RxDone / Dio1RxIntermediate accordingly. ModulationParams and
// PacketParams are cached on RadioContext so the new RX/TX
// cancel-safe subtree's actions don't have to rebuild them.
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,
};
// LBT retry timing lives in `crate::lbt` — the math is pure and
// host-testable, so it's split out of this (HW-gated) module.
use crate::lbt::{
cad_retries, jittered_sleep_us, toa_us, CAD_ASSUMED_PEER_PAYLOAD_B, CAD_BUDGET_US,
};
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),
StartRxOk,
StartRxFailed,
TransmitFailed,
// ── New cancel-safe RX/TX subtree events ──
/// Emitted by `irq_pump` (RxSession's during) on a DIO1 rising
/// edge where the chip's IRQ state read back as `IrqState::Done`
/// (RX completed, packet ready). RxActive dispatches to
/// `collect_rx_packet` action which calls `lora.get_rx_result()`.
Dio1RxDone,
/// Emitted by `irq_pump` on a DIO1 rising edge for non-completion
/// IrqStates (currently just `PreambleReceived`). The chart
/// self-transitions on this — irq_pump just re-runs.
Dio1RxIntermediate,
/// Emitted by `irq_pump` when wait_for_irq or get_irq_state
/// returned Err. Drives RxActive → RxResuming for retry.
IrqWaitFailed,
/// Emitted by `drain_rx_hw` after enter_standby completes. Carries
/// the destination state (Idle vs TxCadPrep vs Reconfiguring) that
/// was stashed in ctx.rx_drain_dest by the RxActive handler that
/// triggered the drain.
RxDrained,
/// Emitted by `cad_one_step` when CAD completed and channel was
/// clear (CAD didn't detect activity). Drives TxCadWait → TxPrep.
CadIdle,
/// Emitted by `cad_one_step` when CAD completed and detected
/// activity. Drives TxCadWait → TxBackoff (or TxFanout if max
/// attempts reached).
CadBusy,
/// Emitted by `prep_for_cad` action on success. Drives
/// TxCadPrep → TxCadWait.
CadPrepOk,
/// Emitted by `prep_for_tx` action on success. Drives
/// TxPrep → TxOnAir.
TxPrepOk,
/// Emitted by `jittered_sleep_during` (TxBackoff's during) when
/// the inter-CAD sleep elapses. Drives TxBackoff → TxCadPrep
/// (re-prepare for next CAD attempt).
CadBackoffDone,
/// Emitted by `tx_complete` (TxOnAir's during) on successful TX
/// completion. Drives TxOnAir → TxFanout. Carries airtime so the
/// host's TX_DONE response includes it.
TxOnAirOk { airtime_us: u32 },
/// Emitted by `route_tx_outcome` (TxFanout's entry action) when
/// the TX session entered from Idle. Drives TxFanout → Idle.
TxFanoutToIdle,
/// Emitted by `route_tx_outcome` (TxFanout's entry action) when
/// the TX session entered from RX. Drives TxFanout → RxResuming
/// to re-arm RX with the current config.
TxFanoutToRx,
/// Emitted by `prep_for_cad_act` when the in-flight TX has
/// `skip_cad=true`. Drives TxCadPrep → TxPrep, bypassing CAD.
TxCadSkipped,
/// Emitted by `bump_cad_attempt` when cad_attempts_used reaches
/// cad_attempts_max. Drives TxBackoff → TxFanout via stage_tx_busy
/// (the host gets ChannelBusy).
CadBackoffMaxed,
// ── Routing events from check_overlong_route_* actions ──
/// Emitted by check_overlong_route_idle (no overlong) — channel
/// clear, proceed to TxPrep.
TxCadGoToPrep,
/// Emitted by check_overlong_route_busy (no overlong) — channel
/// busy, back off and retry.
TxCadGoToBackoff,
/// Emitted by check_overlong_route_fail OR by any overlong route
/// — go to TxFanout with TxOutcomeKind::Failed staged.
TxCadGoToFanoutFailed,
// ── Routing events from route_after_rx_drain ──
/// Emitted by route_after_rx_drain when ctx.rx_drain_dest=Idle.
RxRouteToIdle,
/// Emitted by route_after_rx_drain when ctx.rx_drain_dest=Tx.
RxRouteToTx,
/// Emitted by route_after_rx_drain when ctx.rx_drain_dest=ResumingRx.
RxRouteToResume,
/// 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,
// ── Cold-boot / Wedge / Recovery / Dead lifecycle (chart-modeled) ──
/// Internal: emitted by `Booting`'s `during:` after `LoRa::new`
/// succeeded. Drives `Booting → Unconfigured`.
BootOk,
/// Internal: emitted by `Booting`'s `during:` after `LoRa::new`
/// failed or timed out. Drives `Booting → Dead`.
BootFailed,
/// Emitted by an action / `during:` activity when it observes a
/// `lora_setup*` timeout (i.e. `take_wedge_flag()` returned true on
/// the failure path). Drives the chart from any operational state
/// into `Recovering` for an in-place NRESET pulse + `lora.init()`.
WedgeDetected,
/// Internal: emitted by `Recovering`'s `during:` when `lora.init()`
/// succeeded. Drives `Recovering → Unconfigured` (host re-issues
/// SET_CONFIG to re-arm).
RecoveryOk,
/// Internal: emitted by `Recovering`'s `during:` when `lora.init()`
/// failed (or itself wedged). Drives `Recovering →
/// CheckRecoveryBudget` so the chart decides to retry or give up.
RecoveryFailed,
/// Internal: emitted by `assess_recovery_budget` when the recovery
/// counter is still under `MAX_RECOVERY_ATTEMPTS`. Drives
/// `CheckRecoveryBudget → Recovering` for another attempt.
RetryRecovery,
/// Internal: emitted by `assess_recovery_budget` when the recovery
/// counter has reached `MAX_RECOVERY_ATTEMPTS`. Drives
/// `CheckRecoveryBudget → Dead`.
RecoveryExhausted,
}
/// 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;
/// How many back-to-back `Recovering` (NRESET-pulse + `lora.init()`)
/// attempts to tolerate before declaring the chip permanently dead and
/// parking the chart in `Dead`. Each attempt is bounded to ~2 s by the
/// `lora_setup_with` budget in `try_lora_init`, so 4 attempts give the
/// chart ~8 s of recovery effort before going terminal — long enough
/// to ride out a transient hardware glitch without burning CPU on a
/// chip that is genuinely dead.
const MAX_RECOVERY_ATTEMPTS: u8 = 4;
/// Soft-warning budget for a single CAD attempt. Exceeded only on
/// chips that are misbehaving (typical SF12/BW125 CAD ≈ 262 ms; lower
/// SF much faster). When the chart timer fires, `mark_attempt_overlong`
/// sets a context flag and logs — NO transition. The during keeps
/// running. The flag is checked by routing actions when the during
/// finally completes naturally.
const CAD_OVERLONG_MS: u64 = 1500;
/// Hard-abort budget for a single CAD attempt. After this, the chart
/// timer sets `cad_must_abort` — `cad_one_step`'s NEXT call (after
/// natural completion of the current `lora.cad()`) sees the flag,
/// cancel-safely walks the chip back to standby, and returns
/// TransmitFailed. The chart THEN routes to Recovering. Generous
/// because the chip MAY be slowly servicing the CAD request and we
/// want to honor lora-phy's "must run to completion" contract before
/// escalating.
const CAD_MUST_ABORT_MS: u64 = 15_000;
/// Hard-abort budget for `lora.tx()`. Worst-case ToA at SF12/BW125
/// for MAX_OTA_PAYLOAD ≈ 6 s; +PA ramp + IRQ latency + chart overhead
/// = ≤10 s realistic. 30 s gives 3× safety multiple before escalation.
const TX_MUST_ABORT_MS: u64 = 30_000;
// ── Context ─────────────────────────────────────────────────────────
pub struct RadioContext {
pub commands: &'static CommandChannel,
pub outbound: &'static OutboundChannel,
pub events: &'static RadioEventChannel,
pub info: &'static Info,
/// Cold-init parts (driver pins + delay). `Some` at boot; consumed
/// by `Booting`'s `during:` (`cold_init`) which calls `LoRa::new`.
/// `None` afterwards — pin ownership has moved into `lora`.
pub parts: Option<RadioParts>,
/// Live LoRa driver. `None` while in `Booting` (before `LoRa::new`
/// returns) or after `Booting → Dead` (cold init failed). `Some`
/// in every operational state. Action paths invariant: chart never
/// dispatches operational actions when `lora` is `None`, so an
/// `Option::expect` there would indicate a chart bug — actions
/// instead defensive-unwrap and emit a failure event so the chart
/// self-recovers rather than panicking.
pub lora: Option<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.
///
/// Type-level guard: `None` means "no command is currently being
/// handled" — actions that read this MUST be reachable only as a
/// consequence of a chart event dispatched by `next_command`. If an
/// action reads `None`, the chart's invariant ("commands always go
/// through next_command first") was violated; we `.expect()` with a
/// clear panic message rather than silently continuing with tag=0.
/// `NonZeroU16` because protocol §2.2 reserves tag=0 for async-only
/// frames; tag-correlated commands are NEVER 0.
pub incoming_tag: Option<core::num::NonZeroU16>,
/// Tag of the TX currently on the wire. Captured on entry to
/// `Transmitting`; consumed by `respond_tx_*` handlers.
pub last_tx_tag: Option<core::num::NonZeroU16>,
/// 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<core::num::NonZeroU16>,
/// 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,
/// Number of `Recovering` attempts the chart has burned this wedge
/// session. Bumped on `Recovering` entry; cleared on `RecoveryOk`
/// (chip came back). When it reaches `MAX_RECOVERY_ATTEMPTS` the
/// chart transitions to `Dead` (terminal until cold POR).
pub recovery_attempts: u8,
/// Per-CAD-retry sleep in microseconds: time-on-air for a
/// `CAD_ASSUMED_PEER_PAYLOAD_B`-byte packet under the current
/// config. Recomputed on every successful `SET_CONFIG`; `0` while
/// `Unconfigured` (the CAD path is unreachable without a config).
pub cad_retry_delay_us: u32,
// ── Cancel-safe RX/TX subtree state ──
/// Cached `ModulationParams` derived from `config`. Built once in
/// `apply_set_config` so the new TX subtree's actions don't have
/// to rebuild it (every CAD attempt would otherwise call
/// `lora.create_modulation_params` — pure compute, but redundant).
/// Cleared when chart leaves `Configured`.
pub mdltn: Option<ModulationParams>,
/// Cached `PacketParams` for RX, derived from `config`. Built at
/// `RxArming` entry, used by the `collect_rx_packet` action when
/// `Dio1RxDone` fires. Cleared when chart leaves `RxSession`.
pub rx_pkt: Option<PacketParams>,
/// Number of CAD attempts in the current TX session. Bumped on
/// every TxBackoff entry; compared against the `cad_retries` budget
/// from `lbt::cad_retries(...)` to decide TxBackoff → TxCadPrep
/// vs TxFanout(ChannelBusy).
pub cad_attempts_used: u8,
/// Total CAD attempts allowed for the current TX session. Computed
/// once in `snapshot_pending_tx` (TxSession entry) from the active
/// config via `lbt::cad_retries`.
pub cad_attempts_max: u8,
/// Soft-warning flag: a single CAD attempt ran past the
/// `CAD_OVERLONG_MS` chart-timer budget. `mark_attempt_overlong`
/// sets it; the routing actions (`check_overlong_*`) read it AFTER
/// the during's natural completion to decide whether to escalate
/// to `WedgeDetected`. Reset on every TxCadPrep entry.
pub cad_attempt_overlong: bool,
/// Hard-abort flag: chart timer at `CAD_MUST_ABORT_MS` set this.
/// `cad_one_step` checks the flag at its next cancel-safe wakeup,
/// calls `lora.enter_standby()` (cancel-safe) and returns
/// `TransmitFailed`. The chart then routes to `Recovering` AFTER
/// the during has cleanly returned — honors lora-phy's "must run
/// to completion" contract.
pub cad_must_abort: bool,
/// Same shape as `cad_must_abort` but for TxOnAir's `tx_complete`
/// during. Set by `TxOnAir`'s `TX_MUST_ABORT_MS` chart timer.
pub tx_must_abort: bool,
/// Was the current TX session entered from RX (so TxFanout routes
/// to RxResuming) or from Idle (routes to Idle)? Set by the
/// `snapshot_pending_tx` entry action by inspecting the chart
/// transition trace; consumed by `route_tx_outcome` in TxFanout.
pub from_rx_session: bool,
/// Staged TX outcome to be routed by TxFanout. Set by the various
/// TxCadWait/TxOnAir handlers (CadIdle/CadBusy/TxOnAirOk/...) just
/// before they transition to TxFanout; consumed by
/// `route_tx_outcome`.
pub tx_outcome: Option<TxOutcomeKind>,
/// Set by `mark_pending_reset` action when Reset bubbles to
/// `TxSession` mid-TX. After natural completion of the in-flight
/// op, `route_tx_outcome` checks this and emits `RadioInput::Reset`
/// AFTER the chart leaves TxSession via the normal fanout path,
/// so reset_to_unconfigured runs against a chip that lora-phy has
/// finished talking to.
pub pending_reset: bool,
/// Destination state for the `RxDraining → ?` transition. Set by
/// the `RxActive` event handler that fires `=> RxDraining` (e.g.,
/// CmdTx sets this to TxCadPrep; CmdRxStop sets to Idle;
/// ConfigApplied sets to RxResuming). Consumed by
/// `route_after_rx_drain`.
pub rx_drain_dest: Option<RxDrainDest>,
}
/// Outcome of a TX session, staged by handlers in TxCadWait/TxOnAir
/// and consumed by `route_tx_outcome` in TxFanout.
#[derive(Debug, Clone, Copy)]
pub enum TxOutcomeKind {
/// TX completed on the air. `airtime_us` was reported via
/// `respond_tx_done` in the same handler chain that set this.
Transmitted,
/// All CAD attempts saw activity; we never transmitted.
/// `respond_tx_channel_busy` already fired.
ChannelBusy,
/// SPI prep, CAD itself, or TX itself errored. `respond_tx_failed`
/// already fired (or will via TxSession's exit guarantee).
Failed,
}
/// Destination for the `RxDraining → ?` transition, stashed by the
/// RxActive handler that triggered the drain.
#[derive(Debug, Clone, Copy)]
pub enum RxDrainDest {
/// CmdRxStop — host wants RX off; fanout to Idle.
Idle,
/// CmdTx during RX — fanout to TxCadPrep to start a TX session.
/// `from_rx_session` is set so post-TX returns to RxResuming.
Tx,
/// ConfigApplied during RX — config changed; fanout to RxResuming
/// which re-arms RX with the new config.
ResumingRx,
}
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;
// Auto-trace every state entry/exit, action invocation, and
// transition. Backend (defmt / log / tracing / no-op) is picked
// by which `hsmc/trace-*` cargo feature is enabled — see
// `Cargo.toml`. When no backend feature is enabled this line
// expands to zero code at every emission site.
trace;
default(Booting);
// ── Top-level: three states. Booting (cold init), Operational
// (everything that touches the chip), Dead (terminal until
// cold POR). Operational is a super-state so that PING /
// GET_INFO / Reset / WedgeDetected are STRUCTURALLY
// UNREACHABLE in Booting — cold_init's `during:` cannot be
// cancelled mid-`LoRa::new` by a stray host event, so the
// cancel-safety hazard of `parts.take()` before the await
// is unreachable by chart shape, not by defensive code.
// Dead handles its own commands inline via `dead_drain`.
// Cold init lives in the chart, not in `radio_task`. The chart
// takes ownership of `RadioParts` from context, calls
// `LoRa::new` with a wall-clock budget, and routes the outcome:
// success → `Operational/Unconfigured`; failure → `Dead`. No
// `sys_reset`, no GPREGRET retry. Booting has NO event handlers
// on purpose: nothing can interrupt cold_init.
state Booting {
during: cold_init(parts, lora);
on(BootOk) => Operational;
on(BootFailed) => Dead;
}
// Operational: every state where the chart is allowed to touch
// the radio chip. Handlers that only make sense AFTER cold init
// succeeded live here, not at root, so they cannot fire in
// Booting or Dead by event bubbling. Per hsmc README rule §3
// (event bubbling, leaf-first), an event with no handler in
// Booting bubbles to root and silently drops — the right
// semantics for "host sent something while we were starting up."
state Operational {
// PING / GET_INFO at this level: always answer while
// operational. In Booting they drop (host retries). In
// Dead, dead_drain answers them inline.
on(CmdPing) => respond_pong;
on(CmdGetInfo) => respond_info;
// Reset at this level: drives the Operational subtree back
// to Unconfigured. Resets in Booting drop (cold init must
// complete first). Resets in Dead drop (chart's terminal).
on(Reset) => reset_to_unconfigured, Unconfigured;
// Wedge detected mid-operation: enter Recovering for
// NRESET-pulse + lora.init() retry. Same reachability
// guards as Reset.
on(WedgeDetected) => Recovering;
default(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.
// RxActive 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);
// ── Idle ──
// Quiescent leaf. Receives host commands via
// next_command, routes RxStart → RxArming (entering
// RxSession super-state), Tx → snapshot_tx_from_idle +
// TxCadPrep (entering TxSession), RxStop → respond_ok
// (idempotent ack).
state Idle {
entry: announce_idle;
during: next_command(commands, incoming_tag, pending_tx);
on(CmdRxStart) => stage_rx_start;
on(CmdRxStart) => RxArming;
on(CmdTx) => stage_tx_from_idle;
on(CmdTx) => TxCadPrep;
on(CmdRxStop) => respond_ok;
}
// ── RxSession ──
// Super-state owning the cancel-safe IRQ pump. Active
// across all RX leaf states. Per hsmc spec §3, the
// during runs while ANY descendant is active. `irq_pump`
// is composed entirely of cancel-safe lora-phy
// primitives (`wait_for_irq`, `get_irq_state`,
// `clear_irq_status`), so dropping it on RxSession exit
// never wedges the chip — `RxDraining`'s
// `enter_standby` cleans up RX-mode regardless.
//
// `cache_rx_pkt` builds the PacketParams once on entry
// so the per-IRQ `collect_rx_packet` action doesn't
// rebuild it. `clear_rx_pkt` clears on exit.
state RxSession {
entry: cache_rx_pkt;
exit: clear_rx_pkt;
during: irq_pump(lora);
default(RxArming);
// Brief: chip prep for RX. Same role as the old
// StartingRx — entry calls try_start_rx_hw which
// ultimately emits StartRxOk or StartRxFailed.
state RxArming {
entry: try_start_rx_hw;
on(StartRxOk) => respond_ok_start_rx, RxActive;
on(StartRxFailed) => respond_err_start_rx, Idle;
}
// Continuous-RX active state. The parent's
// `irq_pump` drives `Dio1RxDone` /
// `Dio1RxIntermediate` events; the local
// `next_command` during pulls host commands
// concurrently. CmdTx / CmdRxStop / ConfigApplied
// all route through `RxDraining` (with the
// destination stashed in ctx.rx_drain_dest) so the
// chip is cleanly walked back to standby BEFORE
// any TX prep — honors the SX1262's "RX → standby
// → CAD/TX" sequencing per datasheet §8.
state RxActive {
entry: announce_entered_rx;
during: next_command(commands, incoming_tag, pending_tx);
on(Dio1RxDone) => collect_rx_packet;
on(Dio1RxIntermediate) => RxActive;
on(PacketReceived(rx: RxPayload)) => record_packet;
on(IrqWaitFailed) => RxResuming;
on(CmdRxStart) => respond_ok;
on(CmdRxStop) => stash_drain_dest_idle;
on(CmdRxStop) => respond_ok;
on(CmdRxStop) => RxDraining;
on(CmdTx) => stash_tx_from_rx;
on(CmdTx) => stash_drain_dest_tx;
on(CmdTx) => RxDraining;
// Spec §3.5: a SET_CONFIG issued while
// receiving clears the RX ring (handled by
// apply_set_config) but auto-rearms RX with the
// new parameters via the drain → resume path.
on(ConfigApplied) => stash_drain_dest_resuming_rx;
on(ConfigApplied) => RxDraining;
}
// Cancel-safe RX wind-down. Entry calls
// `enter_standby()` (single SPI; cancel-safe;
// forces the chip out of RX-continuous and clears
// pending DIO assertions per SX1262 §8.5). Then
// emits `RxDrained`; routing action reads
// ctx.rx_drain_dest to decide where to go.
state RxDraining {
entry: drain_rx_hw;
during: pending();
on(RxDrained) => route_after_rx_drain;
on(RxRouteToIdle) => Idle;
on(RxRouteToTx) => TxCadPrep;
on(RxRouteToResume) => RxResuming;
}
// Bounded-retry RX re-arm. Entered after every TX
// while we were originally receiving, on RX driver
// errors (IrqWaitFailed), and on mid-session
// SET_CONFIG via the drain path. If
// `try_start_rx_hw` succeeds we land cleanly in
// RxActive; 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 RxResuming {
entry: try_start_rx_hw;
during: pending();
on(StartRxOk) => reset_resume_retries, RxActive;
on(StartRxFailed) => bump_resume_retries;
on(ResumeRxRetry) => RxResuming;
on(ResumeRxExhausted) => Idle;
}
}
// ── TxSession ──
// Super-state owning the TX lifecycle. Entry stages
// the in-flight TX (cad_attempts_max, last_tx_tag,
// tx_outcome=None). Exit guarantees a tag-correlated
// reply: respond_tx_failed is a no-op if the success
// path consumed last_tx_tag via .take(), so it's safe
// to fire unconditionally.
//
// Reset arriving mid-TX is STASHED via
// mark_pending_reset (chart can't preempt the cancel-
// unsafe lora.cad/tx without violating lora-phy's
// contract). The deferred Reset is honored by
// route_tx_outcome AFTER natural TX completion, by
// emitting RadioInput::Reset which Operational handles
// normally — the chip is in a known-clean state at
// that point.
state TxSession {
entry: snapshot_pending_tx;
exit: respond_tx_failed;
exit: announce_packet_tx;
on(Reset) => mark_pending_reset;
on(CmdTx) => reject_tx_busy;
default(TxCadPrep);
// SPI prep for CAD. `prep_for_cad_act` calls
// `lora.prepare_for_cad()` (wrapped in lora_setup
// — single SPI sequence; cancel-safe drop). On
// success emits CadPrepOk; on the SKIP_CAD flag
// emits TxCadSkipped (jumps directly to TxPrep);
// on failure emits TransmitFailed → TxFanout.
state TxCadPrep {
entry: clear_cad_overlong;
entry: prep_for_cad_act;
during: pending();
on(CadPrepOk) => TxCadWait;
on(TxCadSkipped) => TxPrep;
on(TransmitFailed) => stage_tx_failed;
on(TransmitFailed) => TxFanout;
}
// The cancel-UNSAFE `lora.cad()` runs in this
// state's during. ZERO event handlers other than
// those `cad_one_step` itself emits on natural
// completion — ensures the chart cannot drop the
// future via a preempting transition.
//
// Wedge timers fire WHILE the during runs; they
// only set context flags (mark_attempt_overlong /
// mark_cad_must_abort), no transition. The during
// checks `cad_must_abort` at its next cancel-safe
// wakeup (start of next cad_one_step call) and
// bails cleanly.
state TxCadWait {
on(after Duration::from_millis(CAD_OVERLONG_MS)) => mark_attempt_overlong;
on(after Duration::from_millis(CAD_MUST_ABORT_MS)) => mark_cad_must_abort;
during: cad_one_step(lora, mdltn, cad_must_abort);
on(CadIdle) => check_overlong_route_idle;
on(CadBusy) => check_overlong_route_busy;
on(TransmitFailed) => check_overlong_route_fail;
on(WedgeDetected) => Recovering;
on(TxCadGoToPrep) => TxPrep;
on(TxCadGoToBackoff) => TxBackoff;
on(TxCadGoToFanoutFailed) => stage_tx_failed;
on(TxCadGoToFanoutFailed) => TxFanout;
}
// Inter-CAD jittered sleep. `bump_cad_attempt`
// increments cad_attempts_used; if it reaches
// cad_attempts_max, emits CadBackoffMaxed →
// ChannelBusy fanout. Otherwise the during sleeps
// and emits CadBackoffDone → next CAD prep.
state TxBackoff {
entry: bump_cad_attempt;
during: jittered_sleep_during(cad_retry_delay_us);
on(CadBackoffDone) => TxCadPrep;
on(CadBackoffMaxed) => stage_tx_busy;
on(CadBackoffMaxed) => TxFanout;
}
// SPI prep for TX. Same shape as TxCadPrep —
// single SPI sequence wrapped in lora_setup, on
// success emits TxPrepOk → TxOnAir.
state TxPrep {
entry: prep_for_tx_act;
during: pending();
on(TxPrepOk) => TxOnAir;
on(TransmitFailed) => stage_tx_failed;
on(TransmitFailed) => TxFanout;
}
// The cancel-UNSAFE `lora.tx()` runs in this
// state's during. Same sandboxing pattern as
// TxCadWait. On success carries airtime in
// TxOnAirOk; the chart fires respond_tx_done with
// it before staging TxOutcomeKind::Transmitted and
// routing through TxFanout.
state TxOnAir {
on(after Duration::from_millis(TX_MUST_ABORT_MS)) => mark_tx_must_abort;
during: tx_complete(lora, tx_must_abort);
on(TxOnAirOk { airtime_us: u32 }) => respond_tx_done;
on(TxOnAirOk) => stage_tx_transmitted;
on(TxOnAirOk) => TxFanout;
on(TransmitFailed) => stage_tx_failed;
on(TransmitFailed) => TxFanout;
on(WedgeDetected) => Recovering;
}
// Terminal of the TX sub-FSM. `route_tx_outcome`
// reads ctx.from_rx_session and ctx.pending_reset
// to decide whether to emit TxFanoutToIdle (post-
// TX-from-Idle), TxFanoutToRx (post-TX-from-RX
// → re-arm RX), or Reset (pending Reset stashed
// mid-TX, now safe to honor).
state TxFanout {
entry: route_tx_outcome;
during: pending();
on(TxFanoutToIdle) => Idle;
on(TxFanoutToRx) => RxResuming;
}
}
}
// In-place wedge recovery: NRESET pulse + lora.init()
// retry. Entered from any Operational sub-state via the
// Operational-level `on(WedgeDetected)` handler when a
// `lora_setup*` op blew past its wall-clock budget (chip
// BUSY stuck high). NOT a sys_reset — we keep the embassy
// task alive, just kick the chip. STRUCTURALLY UNREACHABLE
// from Booting (Booting can't emit WedgeDetected; cold_init
// doesn't go through lora_setup).
state Recovering {
entry: bump_recovery_attempt;
during: try_lora_init(lora);
on(RecoveryOk) => clear_recovery_attempts, Unconfigured;
on(RecoveryFailed) => CheckRecoveryBudget;
}
// Decide whether to attempt another recovery or give up.
// Lives as its own state (rather than inline guard logic)
// so the budget policy is readable from the chart shape —
// see `assess_recovery_budget` for the threshold check.
state CheckRecoveryBudget {
entry: assess_recovery_budget;
during: pending();
on(RetryRecovery) => Recovering;
on(RecoveryExhausted) => Dead;
}
}
// Terminal: cold init failed OR chart-internal recovery
// exhausted. Stays here until cold POR (USB unplug → fresh
// boot, fresh chart). Drains the commands channel inline
// replying ERadio for any radio-touching command and Ok for
// PING/GET_INFO so the host protocol stays alive even though
// the chip is unreachable. No event handlers — `dead_drain`
// owns the entire command-response loop and never returns.
state Dead {
entry: announce_dead;
during: dead_drain(commands, outbound, info);
}
}
}
// ── Lora setup-call wedge protection ──────────────────────────────────
//
// Background: lora-phy's IV calls `wait_on_busy().await` before every
// SPI command. That's a GPIO sense interrupt with NO TIMEOUT in the
// upstream-clean version of lora-phy (which we keep upstreamable and
// thus un-modified here). If the SX1262's BUSY pin is ever stuck high
// — sticky chip state from a prior wedge, hardware glitch, etc. — any
// lora-phy SPI op blocks the radio_task forever.
//
// `lora_setup` wraps a SHORT-DURATION lora-phy op (one that should
// complete in well under 100 ms per datasheet §13) with a generous wall-
// clock bound. On timeout it returns `Err(RadioError::Busy)` — the same
// shape the existing `?` chains in chart actions already handle, so the
// chart's failure events (TxFailed, ConfigFailedRadio, RxFailed,
// StartRxFailed) carry the firmware back to a recoverable state.
//
// Budget rationale: 500 ms = ~5× the worst documented BUSY-high window
// per SX1262 datasheet §13, with slack for cold-boot calibration variance,
// post-TX hot-PA settling, and SPI/DMA arbitration jitter under embassy
// executor load. Genuine wedge (BUSY stuck high for hardware reasons) is
// the only thing this rejects.
//
// Do NOT wrap `lora.rx(&pkt, rx_buf)` — single-packet RX legitimately
// waits indefinitely for an incoming packet. The wedge we're guarding
// against is in setup paths, not the await-IRQ path.
const LORA_SETUP_TIMEOUT_MS: u64 = 500;
async fn lora_setup<T, F>(label: &'static str, fut: F) -> Result<T, RadioError>
where
F: core::future::Future<Output = Result<T, RadioError>>,
{
match embassy_time::with_timeout(
embassy_time::Duration::from_millis(LORA_SETUP_TIMEOUT_MS),
fut,
)
.await
{
Ok(r) => r,
Err(_) => {
warn!("lora_setup timeout: {}", label);
crate::channel::LORA_WEDGED.store(true, portable_atomic::Ordering::Release);
Err(RadioError::Busy)
}
}
}
/// True if a `lora_setup*` timeout fired since the last call. Consumes
/// the flag (clears on read) so the next call sees a fresh signal.
/// Use immediately after a lora helper returned `Err(_)` to decide
/// whether to emit `WedgeDetected` (chart enters `Recovering`) or the
/// per-action failure event (chart self-recovers in place).
fn take_wedge_flag() -> bool {
crate::channel::LORA_WEDGED.swap(false, portable_atomic::Ordering::AcqRel)
}
// ── during: activities ──────────────────────────────────────────────
/// Forever-pending activity. Used as a `during:` for chart states that
/// have no other concurrent activity, to defeat hsmc's run-loop exit
/// condition (`next.is_none() && !has_any_during`). The chart can only
/// leave the state via an explicit transition triggered by an emitted
/// event. Spec property: the radio chart never exits silently.
async fn pending() -> RadioInput {
core::future::pending::<RadioInput>().await
}
/// 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 Option<core::num::NonZeroU16>,
pending_tx: &mut Option<PendingTx>,
) -> RadioInput {
match commands.receive().await {
InboundEvent::Reset => {
// Reset isn't tag-correlated — clear the slot so any stray
// action that reads incoming_tag panics with a clear msg
// rather than echoing a stale tag from a prior command.
*incoming_tag = None;
RadioInput::Reset
}
InboundEvent::Command { tag, cmd } => {
// Protocol §2.2: tag=0 is rejected by the host framing
// layer before reaching the commands channel. If we see 0
// here it's a serious framing-layer bug — fail loudly.
*incoming_tag = Some(
core::num::NonZeroU16::new(tag)
.expect("protocol §2.2: command tag must be non-zero (host framing bug)"),
);
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,
}
}
}
}
/// `during:` for the `Booting` state. Owns the cold `LoRa::new` call:
/// takes the consumed-once driver pins out of context, hands them to
/// lora-phy with a wall-clock budget, and stores the result back in
/// context. Emits `BootOk` on success (chart proceeds to `Unconfigured`
/// where the host can `SET_CONFIG`); `BootFailed` on any failure
/// (chart parks in `Dead`; user power-cycles the dongle to escape).
///
/// This replaces the imperative `radio_dead_loop` /
/// `handle_radio_init_failure` / GPREGRET-retry machinery that used to
/// live in `radio_task`. Cold init is now a chart state, not a function.
async fn cold_init(
parts: &mut Option<RadioParts>,
lora: &mut Option<LoRaDriver>,
) -> RadioInput {
if lora.is_some() {
// Already initialized — re-entry into Booting (e.g., user
// injected a Reset before we even left Booting on first boot).
// Just acknowledge what's already true.
return RadioInput::BootOk;
}
let Some(p) = parts.take() else {
// No parts AND no lora — we already failed cold init in a
// previous Booting visit (parts was consumed by `take()` then).
// Chart should have transitioned to Dead; if we're here again
// something forced us back. Stay Failed.
return RadioInput::BootFailed;
};
// Cancel-safe by chart construction: Booting has zero event
// handlers, so `LoRa::new` cannot be dropped mid-await by a
// preempting transition. lora-phy's "must run to completion"
// contract is honored. If the chip's BUSY pin is stuck high on
// first power-on, this future hangs forever — Stephen's rule 5
// ("hang in a state longer than we want is acceptable"). The
// user power-cycles to escape; there is no chart-level recovery
// for cold init that can't talk to the chip at all.
match LoRa::new(p.driver, false, p.delay).await {
Ok(l) => {
*lora = Some(l);
// Successful cold init clears any stale wedge flag from
// a prior Recovering attempt — fresh chip, fresh state.
let _ = take_wedge_flag();
info!("radio: cold init OK");
RadioInput::BootOk
}
Err(e) => {
error!("radio: cold init failed: {:?}", defmt::Debug2Format(&e));
RadioInput::BootFailed
}
}
}
/// `during:` for `Recovering`. Pulses NRESET and re-runs lora-phy's cold
/// init sequence (radio_kind.reset → ensure_ready → set_standby →
/// init_lora → set_tx_power → set_irq_params). Wraps with a generous
/// 2 s budget — `lora.init()` itself contains multiple SPI commands
/// that each can wedge if the chip is genuinely dead.
///
/// Emits `RecoveryOk` on success, `RecoveryFailed` on any error
/// (including a fresh wedge inside this attempt). The chart counts
/// attempts via `bump_recovery_attempt` (entry) and gives up to `Dead`
/// after `MAX_RECOVERY_ATTEMPTS`.
async fn try_lora_init(lora: &mut Option<LoRaDriver>) -> RadioInput {
let Some(lora) = lora.as_mut() else {
// Recovering reachable only from operational states (which had
// a live driver) — None here means the chart is in a bad place.
// Treat as RecoveryFailed; CheckRecoveryBudget will route us
// toward Dead which is the right resting state.
return RadioInput::RecoveryFailed;
};
// Cancel-safe by chart construction: Recovering has no event
// handlers other than the events this during emits on natural
// completion. The chart cannot drop `lora.init()` mid-await via a
// preempting transition. lora.init() is the cancel-UNSAFE NRESET +
// cold-init sequence; we honor lora-phy's "must run to completion"
// contract by letting it run to completion. If the chip is wedged
// hard enough that lora.init() never returns, the chart sits in
// Recovering forever — Stephen's rule 5 ("hang around in a state
// longer than we want is acceptable").
match lora.init().await {
Ok(()) => {
// Successful re-init clears any stale wedge flag from the
// prior failure that landed us here.
let _ = take_wedge_flag();
RadioInput::RecoveryOk
}
Err(_) => RadioInput::RecoveryFailed,
}
}
/// `during:` for `Dead`. Drains the commands channel forever, replying
/// `Ok` to PING / GET_INFO and `ERadio` to anything else. Never returns
/// — the chart stays in `Dead` until cold POR (USB unplug clears
/// GPREGRET; next boot re-runs cold init from scratch).
///
/// Mirrors the imperative `radio_dead_loop` semantics so the host-side
/// protocol stays alive whether the chip died at cold init (handled by
/// `radio_dead_loop`) or mid-operation (handled here).
async fn dead_drain(
commands: &mut &'static CommandChannel,
outbound: &mut &'static OutboundChannel,
info: &mut &'static Info,
) -> RadioInput {
loop {
match commands.receive().await {
InboundEvent::Reset => {
// Reset in Dead is a no-op: there's no chip-state to
// park. Clear the dedup flag so the host chart can
// queue a fresh Reset later if it wants to.
crate::channel::RESET_PENDING
.store(false, portable_atomic::Ordering::Release);
}
InboundEvent::Command { tag, cmd } => {
let msg = match cmd {
Command::Ping => DeviceMessage::Ok(OkPayload::Empty),
Command::GetInfo => DeviceMessage::Ok(OkPayload::Info(**info)),
_ => DeviceMessage::Err(ErrorCode::ERadio),
};
outbound.send(OutboundFrame { tag, msg }).await;
}
}
}
}
// ── Cancel-safe RX/TX during functions ──────────────────────────────
//
// These replace `next_packet` (RX) and `perform_tx` (TX). Each one is
// either purely composed of cancel-safe lora-phy primitives (irq_pump,
// jittered_sleep_during) or hosts a single cancel-unsafe call inside a
// chart state with no preempting handlers (cad_one_step, tx_complete).
// Per hsmc README rule §6 ("durings are cancelled before exits"), the
// chart can drop these futures only when their state is exited; we
// build the chart shape so that exit cannot happen mid-await for the
// cancel-unsafe ones.
/// `during:` for `RxSession` super-state. Awaits the chip's DIO1 IRQ,
/// reads the IRQ state, clears, emits the corresponding chart event.
/// Cancel-safe by construction: every `.await` is on a cancel-safe
/// lora-phy primitive (`wait_for_irq` is GPIO-sense; `get_irq_state`
/// and `clear_irq_status` are single SPI ops). Drop at any point
/// leaves the chip in RX-continuous with the IRQ either just-cleared
/// or pending — `RxDraining`'s `enter_standby` cleans up either way.
async fn irq_pump(lora: &mut Option<LoRaDriver>) -> RadioInput {
let Some(lora) = lora.as_mut() else {
// Defensive: chart invariant says RxSession is unreachable
// when lora is None, but if it slips, fail to RxResuming.
return RadioInput::IrqWaitFailed;
};
if lora.wait_for_irq().await.is_err() {
return RadioInput::IrqWaitFailed;
}
let st = match lora.get_irq_state().await {
Ok(s) => s,
Err(_) => return RadioInput::IrqWaitFailed,
};
let _ = lora.clear_irq_status().await;
match st {
Some(IrqState::Done) => RadioInput::Dio1RxDone,
Some(IrqState::PreambleReceived) | None => RadioInput::Dio1RxIntermediate,
}
}
/// `during:` for `TxCadWait`. Hosts the cancel-UNSAFE `lora.cad()`
/// call AS-IS — sandboxed because `TxCadWait` declares ZERO event
/// handlers other than the events this function emits on natural
/// completion. The chart cannot drop this future via a transition
/// because no handler can fire in TxCadWait.
///
/// Wedge escalation: chart timer at `CAD_MUST_ABORT_MS` sets
/// `cad_must_abort` (no transition). On the during's NEXT call (after
/// natural completion of the current `cad()`), it sees the flag,
/// cancel-safely walks the chip back to standby, returns
/// `TransmitFailed`. The chart THEN routes to Recovering AFTER the
/// during has cleanly returned — honors lora-phy's "must run to
/// completion" contract.
async fn cad_one_step(
lora: &mut Option<LoRaDriver>,
mdltn: &mut Option<ModulationParams>,
cad_must_abort: &mut bool,
) -> RadioInput {
let Some(lora) = lora.as_mut() else {
return RadioInput::TransmitFailed;
};
let Some(m) = mdltn.as_ref() else {
return RadioInput::TransmitFailed;
};
if *cad_must_abort {
// Wedge timer set this between calls. Cancel-safely abort:
// enter_standby is a single SPI command. Then return failure;
// chart's check_overlong_* sees overlong flag and routes to
// Recovering.
let _ = lora.enter_standby().await;
*cad_must_abort = false;
return RadioInput::TransmitFailed;
}
match lora.cad(m).await {
Ok(true) => RadioInput::CadBusy,
Ok(false) => RadioInput::CadIdle,
Err(_) => RadioInput::TransmitFailed,
}
}
/// `during:` for `TxOnAir`. Same sandboxing as `cad_one_step`: hosts
/// the cancel-UNSAFE `lora.tx()` call AS-IS in a state with no
/// preempting handlers. On natural completion emits
/// `TxOnAirOk { airtime_us }` carrying the elapsed-on-air time for
/// the host's TX_DONE response.
async fn tx_complete(
lora: &mut Option<LoRaDriver>,
tx_must_abort: &mut bool,
) -> RadioInput {
let Some(lora) = lora.as_mut() else {
return RadioInput::TransmitFailed;
};
if *tx_must_abort {
let _ = lora.enter_standby().await;
*tx_must_abort = false;
return RadioInput::TransmitFailed;
}
let start = embassy_time::Instant::now();
match lora.tx().await {
Ok(()) => RadioInput::TxOnAirOk {
airtime_us: embassy_time::Instant::now().duration_since(start).as_micros() as u32,
},
Err(_) => RadioInput::TransmitFailed,
}
}
/// `during:` for `TxBackoff`. Pure timer — sleep for the jittered
/// per-CAD-retry interval, then emit `CadBackoffDone`. Cancel-safe
/// (timer drop is harmless).
async fn jittered_sleep_during(cad_retry_delay_us: &mut u32) -> RadioInput {
let entropy = embassy_time::Instant::now().as_ticks() as u32;
let jittered_us = jittered_sleep_us(*cad_retry_delay_us, entropy);
embassy_time::Timer::after(embassy_time::Duration::from_micros(jittered_us as u64)).await;
RadioInput::CadBackoffDone
}
// ── Actions ─────────────────────────────────────────────────────────
/// Unwrap an `Option<NonZeroU16>` slot (incoming_tag, last_tx_tag, etc.)
/// to a raw u16 for sending over the wire, panicking with a clear
/// message if the slot is `None`. Reaching `None` here means a chart
/// invariant was violated — an action that constructs a tag-echoed
/// response ran without a preceding `next_command` having stashed the
/// tag, OR the tag-echo path read a slot before the producer wrote it.
#[inline]
fn tag_or_panic(t: Option<core::num::NonZeroU16>, slot: &'static str) -> u16 {
t.unwrap_or_else(|| {
defmt::panic!("chart invariant violated: tag slot `{}` was None", slot);
})
.get()
}
impl RadioActions for RadioActionContext<'_> {
// ── PING / GET_INFO ───────────────────────────────────────────────
async fn respond_pong(&mut self) {
let tag = tag_or_panic(self.incoming_tag, "respond_pong");
self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await;
}
async fn respond_info(&mut self) {
let tag = tag_or_panic(self.incoming_tag, "respond_info");
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 = tag_or_panic(self.incoming_tag, "apply_set_config");
info!("apply_set_config: tag={=u16}", 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;
}
let recfg_result = match self.lora.as_mut() {
Some(lora) => reconfigure_radio(lora, &cfg).await,
None => Err(RadioError::Busy),
};
if recfg_result.is_err() {
// Hardware failure: drop to Unconfigured (spec §6.3).
// Clear all per-session state — including any in-flight
// RxStart tag — so the next CmdRxStart in Unconfigured
// is rejected cleanly (rather than racing a stale tag
// through respond_err_start_rx on an unrelated path).
self.config = None;
self.cad_retry_delay_us = 0;
self.pending_rx_start_tag = None;
info!("config cleared (apply_set_config failure tag={=u16})", tag);
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
// Wedge takes precedence over ConfigFailedRadio so the
// chart enters Recovering (NRESET + lora.init()) instead
// of bouncing back to Unconfigured with a still-wedged
// chip that the next SET_CONFIG would also fail on.
if take_wedge_flag() {
let _ = self.emit(RadioInput::WedgeDetected);
} else {
let _ = self.emit(RadioInput::ConfigFailedRadio);
}
return;
}
self.config = Some(cfg);
info!("config set (apply_set_config success tag={=u16})", tag);
self.cad_retry_delay_us = toa_us(&cfg, CAD_ASSUMED_PEER_PAYLOAD_B);
if self.cad_retry_delay_us > CAD_BUDGET_US {
defmt::debug!(
"LBT degraded: assumed-packet ToA {=u32} us > budget {=u32} us; single-CAD-and-bail",
self.cad_retry_delay_us,
CAD_BUDGET_US
);
}
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.get(), DeviceMessage::TxDone(td)).await;
}
}
async fn respond_tx_failed(&mut self) {
if let Some(tag) = self.last_tx_tag.take() {
self.send(tag.get(), DeviceMessage::Err(ErrorCode::ERadio))
.await;
}
}
// ── RX_START / RX_STOP ────────────────────────────────────────────
async fn stage_rx_start(&mut self) {
// incoming_tag is already Option<NonZeroU16>; copy directly.
// If None, the chart invariant was violated (CmdRxStart fired
// without next_command) — propagate the None so the eventual
// respond_*_start_rx site silently does nothing rather than
// echoing a stale tag.
self.pending_rx_start_tag = self.incoming_tag;
}
async fn try_start_rx_hw(&mut self) {
let Some(cfg) = self.config else {
// Defensive: shouldn't happen on the documented chart paths
// (StartingRx is only entered from Idle which requires
// Configured; ResumingRx is only entered from Receiving or
// self-loop, both of which had a valid config when reached).
// If it does fire, the chart self-recovers via StartRxFailed
// → respond_err_start_rx (replies ERadio to host) → Idle.
// Logged at WARN with diagnostic tag context so the next soak
// log captures the chart-state at the moment of the violation.
warn!(
"try_start_rx_hw: no config; incoming_tag={=u16}, pending_rx_start_tag={=u16}, last_tx_tag={=u16} (0 = None); emitting StartRxFailed (chart will recover to Idle)",
self.incoming_tag.map(|t| t.get()).unwrap_or(0),
self.pending_rx_start_tag.map(|t| t.get()).unwrap_or(0),
self.last_tx_tag.map(|t| t.get()).unwrap_or(0),
);
let _ = self.emit(RadioInput::StartRxFailed);
return;
};
// Inline what the old `start_rx` helper did: build mod params,
// build packet params, call prepare_for_rx wrapped in lora_setup.
// ALL these primitives are cancel-safe (single SPI sequences);
// the cancel-unsafe `complete_rx`/`rx()` loop is now the chart's
// RxActive `irq_pump` + `collect_rx_packet` (cancel-safe by
// construction).
let result: Result<(), RadioError> = {
let ctx: &mut RadioContext = &mut *self;
match ctx.lora.as_mut() {
Some(lora) => match to_lora_mod_params(lora, &cfg) {
Ok(mdltn) => 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(pkt) => {
let prep = lora_setup(
"try_start_rx_hw.prepare_for_rx",
lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt),
)
.await;
// prepare_for_rx only stages mod/packet/IRQ
// params; the chip is not actually receiving
// until start_rx issues the do_rx command.
// Both are single SPI sequences (cancel-safe);
// chain them so a successful prepare always
// arms the receiver before we declare RX up.
match prep {
Ok(()) => {
let started = lora_setup(
"try_start_rx_hw.start_rx",
lora.start_rx(),
)
.await;
if started.is_ok() {
// Cache so RxSession's
// collect_rx_packet doesn't rebuild.
ctx.mdltn = Some(mdltn);
ctx.rx_pkt = Some(pkt);
}
started
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
},
Err(e) => Err(e),
},
None => Err(RadioError::Busy),
}
};
match result {
Ok(()) => {
let _ = self.emit(RadioInput::StartRxOk);
}
Err(e) => {
warn!("start_rx failed: {}", e);
// Wedge → Recovering takes precedence over the per-action
// self-recover-to-Idle path, so a stuck chip doesn't get
// re-armed for RX with the still-stuck SPI bus.
if take_wedge_flag() {
let _ = self.emit(RadioInput::WedgeDetected);
} else {
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 = tag_or_panic(self.incoming_tag, "respond_ok");
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.get(), 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.get(), DeviceMessage::Err(ErrorCode::ERadio))
.await;
}
}
async fn reject_not_configured(&mut self) {
let tag = tag_or_panic(self.incoming_tag, "reject_not_configured");
self.send(tag, DeviceMessage::Err(ErrorCode::ENotConfigured))
.await;
}
// ── Reset (disconnect / inactivity timeout) ──────────────────────
async fn reset_to_unconfigured(&mut self) {
info!("config cleared (reset_to_unconfigured)");
// Clear the dedup flag — host_task can now queue another Reset
// when needed. Done at the START of this action so even if the
// chip-side enter_standby below times out / errors, future
// Resets aren't blocked.
crate::channel::RESET_PENDING
.store(false, portable_atomic::Ordering::Release);
self.pending_tx = None;
self.last_tx_tag = None;
self.pending_rx_start_tag = None;
self.config = None;
self.packets_dropped = 0;
// Start each Reset cycle from a clean wedge state — old timeout
// signal from a prior session shouldn't auto-escalate this one.
let _ = take_wedge_flag();
if let Some(lora) = self.lora.as_mut() {
let _ =
lora_setup("reset_to_unconfigured.enter_standby", lora.enter_standby()).await;
}
// If THIS standby attempt wedged, escalate to Recovering so the
// chart doesn't loop reset_to_unconfigured every host-inactivity
// cycle against a chip that's stuck. The root WedgeDetected
// handler routes us into NRESET-pulse + lora.init() recovery
// before we re-enter Unconfigured.
if take_wedge_flag() {
let _ = self.emit(RadioInput::WedgeDetected);
}
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_packet_tx(&mut self) {
self.announce(RadioEvent::PacketTx);
}
// ── Recovery / Dead lifecycle ─────────────────────────────────────
async fn bump_recovery_attempt(&mut self) {
self.recovery_attempts = self.recovery_attempts.saturating_add(1);
info!(
"Recovering: attempt {=u8}/{=u8}",
self.recovery_attempts, MAX_RECOVERY_ATTEMPTS,
);
}
async fn clear_recovery_attempts(&mut self) {
if self.recovery_attempts > 0 {
info!(
"Recovering: success after {=u8} attempt(s); chip back online",
self.recovery_attempts,
);
}
self.recovery_attempts = 0;
// A successful recovery means the prior config no longer applies
// to the chip — clear it so the chart's Unconfigured invariant
// ("config is None") holds when we land there.
self.config = None;
self.cad_retry_delay_us = 0;
self.pending_tx = None;
self.last_tx_tag = None;
self.pending_rx_start_tag = None;
self.announce(RadioEvent::Idle);
}
async fn assess_recovery_budget(&mut self) {
if self.recovery_attempts >= MAX_RECOVERY_ATTEMPTS {
error!(
"Recovering: exhausted ({=u8} attempts); transitioning to Dead",
self.recovery_attempts,
);
let _ = self.emit(RadioInput::RecoveryExhausted);
} else {
let _ = self.emit(RadioInput::RetryRecovery);
}
}
async fn announce_dead(&mut self) {
error!(
"radio chart entered Dead state — chip permanently wedged after {=u8} recovery attempts. Power-cycle the dongle (USB unplug) to reset.",
self.recovery_attempts,
);
// Mirror the cold-init dead path: park display + flush any
// session state so subsequent host commands get clean ERadio
// replies (handled by the dead_drain `during:`).
self.config = None;
self.cad_retry_delay_us = 0;
self.pending_tx = None;
self.last_tx_tag = None;
self.pending_rx_start_tag = None;
self.announce(RadioEvent::Idle);
}
// ── Cancel-safe RX/TX subtree actions ───────────────────────────
/// Idle's `on(CmdTx)` handler chain — sets `from_rx_session = false`
/// before the transition to TxCadPrep. snapshot_pending_tx (TxSession
/// entry) reads this to know whether to fanout to Idle (true) or
/// RxResuming (false) post-TX.
async fn stage_tx_from_idle(&mut self) {
self.from_rx_session = false;
}
/// RxActive's `on(CmdTx)` handler chain — sets `from_rx_session =
/// true`. The post-TX fanout will route back to RxResuming so the
/// RX session resumes after the TX completes (spec §3.5: TX during
/// RX returns to RX).
async fn stash_tx_from_rx(&mut self) {
self.from_rx_session = true;
}
/// RxActive handlers stash the post-drain destination in context.
/// `route_after_rx_drain` reads this to decide where to go after
/// `enter_standby` cleans up the chip.
async fn stash_drain_dest_idle(&mut self) {
self.rx_drain_dest = Some(RxDrainDest::Idle);
}
async fn stash_drain_dest_tx(&mut self) {
self.rx_drain_dest = Some(RxDrainDest::Tx);
}
async fn stash_drain_dest_resuming_rx(&mut self) {
self.rx_drain_dest = Some(RxDrainDest::ResumingRx);
}
/// RxSession entry. Builds and caches the RxPacketParams from
/// the active config so collect_rx_packet doesn't have to rebuild
/// them on every IRQ. Skipped if no config (defensive — chart
/// shouldn't reach RxSession without Configured).
async fn cache_rx_pkt(&mut self) {
// Split-borrow self via DerefMut to get disjoint field
// mutable borrows (lora) + immutable reads (config) + Option
// writes (mdltn, rx_pkt) — all on the same RadioContext.
let ctx: &mut RadioContext = &mut *self;
let Some(lora) = ctx.lora.as_mut() else {
return;
};
let Some(cfg) = ctx.config else {
return;
};
let Ok(mdltn) = to_lora_mod_params(lora, &cfg) else {
return;
};
if let Ok(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,
) {
ctx.rx_pkt = Some(pkt);
ctx.mdltn = Some(mdltn);
}
}
/// RxSession exit. Drops the cached RxPacketParams; next entry
/// to RxSession will rebuild them via cache_rx_pkt.
async fn clear_rx_pkt(&mut self) {
self.rx_pkt = None;
}
/// RxActive's Dio1RxDone handler. Reads the just-arrived packet
/// via lora.get_rx_result (cancel-safe SPI read pair), builds the
/// RxPayload, and emits PacketReceived which routes to record_packet.
async fn collect_rx_packet(&mut self) {
// Build the payload inside an inner scope where ctx holds the
// disjoint field borrows. Then emit AFTER ctx is dropped.
let rx_opt: Option<RxPayload> = {
let ctx: &mut RadioContext = &mut *self;
match (ctx.lora.as_mut(), ctx.rx_pkt.as_ref()) {
(Some(lora), Some(pkt)) => {
match lora.get_rx_result(pkt, &mut ctx.rx_buf).await {
Ok((len, status)) => {
let ts_us = embassy_time::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(&ctx.rx_buf[..copy_len]);
Some(RxPayload {
rssi_tenths_dbm: (status.rssi as i32 * 10) as i16,
snr_tenths_db: (status.snr as i32 * 10) as i16,
freq_err_hz: 0,
timestamp_us: ts_us,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
})
}
Err(_) => None,
}
}
_ => None,
}
};
match rx_opt {
Some(rx) => {
let _ = self.emit(RadioInput::PacketReceived(rx));
}
None => {
let _ = self.emit(RadioInput::IrqWaitFailed);
}
}
}
/// RxDraining entry. Cancel-safely walks the chip out of RX into
/// Standby before the chart proceeds to whichever drain destination
/// was stashed in ctx.rx_drain_dest. enter_standby is single SPI;
/// per SX1262 §8.5 entering STDBY_RC clears any pending DIO
/// assertions, leaving the chip in a known-clean state for the
/// next CAD or RX prep.
async fn drain_rx_hw(&mut self) {
if let Some(lora) = self.lora.as_mut() {
let _ = lora_setup("drain_rx_hw.enter_standby", lora.enter_standby()).await;
}
let _ = self.emit(RadioInput::RxDrained);
}
/// RxDraining's RxDrained handler. Reads ctx.rx_drain_dest and
/// emits the corresponding routing event. The chart's transition
/// arms in RxDraining match these.
async fn route_after_rx_drain(&mut self) {
let dest = self.rx_drain_dest.take();
match dest {
Some(RxDrainDest::Idle) => {
let _ = self.emit(RadioInput::RxRouteToIdle);
}
Some(RxDrainDest::Tx) => {
let _ = self.emit(RadioInput::RxRouteToTx);
}
Some(RxDrainDest::ResumingRx) => {
let _ = self.emit(RadioInput::RxRouteToResume);
}
None => {
// Defensive: chart bug if we got here without a dest.
// Fall back to Idle so chart isn't stuck in RxDraining.
warn!("route_after_rx_drain: no rx_drain_dest stashed; falling back to Idle");
let _ = self.emit(RadioInput::RxRouteToIdle);
}
}
}
/// TxSession entry. Captures the in-flight TX's tag, reads the
/// CAD retry budget for the current config, and resets per-session
/// counters. Patches pending_tx.config from the active config
/// (next_command staged a dummy). Announces RadioEvent::EnteredTx
/// for the display.
async fn snapshot_pending_tx(&mut self) {
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(
core::num::NonZeroU16::new(pt.tag)
.expect("PendingTx.tag was 0 — next_command should have rejected"),
);
}
let (max, _sleep) = cad_retries(self.cad_retry_delay_us);
self.cad_attempts_max = max;
self.cad_attempts_used = 0;
self.cad_attempt_overlong = false;
self.cad_must_abort = false;
self.tx_must_abort = false;
self.tx_outcome = None;
self.pending_reset = false;
self.announce(RadioEvent::EnteredTx);
}
/// TxSession's `on(Reset) => mark_pending_reset` handler. Records
/// that a Reset arrived during a long TX op. The chart cannot
/// honor Reset immediately (would drop the cancel-unsafe lora.cad
/// or lora.tx future). route_tx_outcome (TxFanout entry) checks
/// this flag AFTER natural TX completion and emits RadioInput::Reset
/// then — Operational's `on(Reset) => reset_to_unconfigured`
/// handles it normally with the chip in a clean state.
async fn mark_pending_reset(&mut self) {
self.pending_reset = true;
info!("Reset received during TX; deferred until current TX completes");
}
/// TxSession's `on(CmdTx)` handler. A second CmdTx arrived while
/// the first is still in flight. Reject the new one with a clean
/// TxDone{Failed} so the host gets a tag-correlated reply.
async fn reject_tx_busy(&mut self) {
if let Some(pt) = self.pending_tx.take() {
// No "TxDone with failure" code in the protocol; use
// Err(ERadio) which the host already handles as
// "TX did not complete".
self.send(pt.tag, DeviceMessage::Err(ErrorCode::ERadio))
.await;
}
}
/// TxCadPrep entry — clears overlong/abort flags so the new CAD
/// attempt starts with a clean slate. Chart timers in TxCadWait
/// will set them again if needed.
async fn clear_cad_overlong(&mut self) {
self.cad_attempt_overlong = false;
self.cad_must_abort = false;
}
/// TxCadPrep entry. Either skips CAD (skip_cad flag) and emits
/// TxCadSkipped → TxPrep, or calls lora.prepare_for_cad and emits
/// CadPrepOk → TxCadWait. SPI failure escalates via take_wedge_flag.
async fn prep_for_cad_act(&mut self) {
let skip = self.pending_tx.as_ref().is_some_and(|pt| pt.skip_cad);
if skip {
let _ = self.emit(RadioInput::TxCadSkipped);
return;
}
let result = {
let ctx: &mut RadioContext = &mut *self;
match (ctx.lora.as_mut(), ctx.mdltn.as_ref()) {
(Some(lora), Some(mdltn)) => {
lora_setup("prep_for_cad_act", lora.prepare_for_cad(mdltn)).await
}
_ => Err(RadioError::Busy),
}
};
match result {
Ok(()) => {
let _ = self.emit(RadioInput::CadPrepOk);
}
Err(_) => {
if take_wedge_flag() {
let _ = self.emit(RadioInput::WedgeDetected);
} else {
let _ = self.emit(RadioInput::TransmitFailed);
}
}
}
}
/// Stages the TX outcome as Failed AND sends the Err(ERadio) reply
/// to the host. After this fires, last_tx_tag is None so
/// TxSession's exit:respond_tx_failed safety net is a no-op.
async fn stage_tx_failed(&mut self) {
self.tx_outcome = Some(TxOutcomeKind::Failed);
if let Some(tag) = self.last_tx_tag.take() {
self.send(tag.get(), DeviceMessage::Err(ErrorCode::ERadio))
.await;
}
}
/// Stages the TX outcome as ChannelBusy AND sends the
/// TxDone{ChannelBusy} reply. Same tag-consume + safety-net pattern
/// as stage_tx_failed.
async fn stage_tx_busy(&mut self) {
self.tx_outcome = Some(TxOutcomeKind::ChannelBusy);
if let Some(tag) = self.last_tx_tag.take() {
let td = TxDonePayload {
result: TxResult::ChannelBusy,
airtime_us: 0,
};
self.send(tag.get(), DeviceMessage::TxDone(td)).await;
}
}
/// Stages the TX outcome as Transmitted. The TxOnAirOk handler
/// chain calls respond_tx_done BEFORE this fires, so last_tx_tag
/// is already None — this just records the outcome for fanout
/// routing.
async fn stage_tx_transmitted(&mut self) {
self.tx_outcome = Some(TxOutcomeKind::Transmitted);
}
/// TxCadWait timer (CAD_OVERLONG_MS) action. Soft warning that
/// the in-flight CAD has been running longer than the typical
/// per-attempt budget. Sets context flag — NO transition. The
/// during keeps running. Routing actions check this flag at the
/// during's natural completion to decide whether to escalate.
async fn mark_attempt_overlong(&mut self) {
if !self.cad_attempt_overlong {
warn!(
"TxCadWait: CAD attempt running >{=u64} ms (chip may be wedged)",
CAD_OVERLONG_MS
);
self.cad_attempt_overlong = true;
}
}
/// TxCadWait timer (CAD_MUST_ABORT_MS) action. Hard signal that
/// the in-flight CAD has been running unacceptably long. Sets
/// cad_must_abort which `cad_one_step` checks at its NEXT
/// cancel-safe wakeup; on seeing the flag it walks the chip back
/// to standby (cancel-safely) and returns TransmitFailed. The
/// chart THEN routes to Recovering.
async fn mark_cad_must_abort(&mut self) {
warn!("TxCadWait: CAD attempt exceeded {=u64} ms — must-abort signaled", CAD_MUST_ABORT_MS);
self.cad_must_abort = true;
}
/// TxOnAir timer (TX_MUST_ABORT_MS) action. Same pattern as
/// mark_cad_must_abort but for the TX phase.
async fn mark_tx_must_abort(&mut self) {
warn!("TxOnAir: TX exceeded {=u64} ms — must-abort signaled", TX_MUST_ABORT_MS);
self.tx_must_abort = true;
}
/// TxCadWait CadIdle handler. Channel was clear → proceed to TxPrep.
/// Overlong flag doesn't matter here: we got a real answer in time
/// for CAD even if it was slow.
async fn check_overlong_route_idle(&mut self) {
let _ = self.emit(RadioInput::TxCadGoToPrep);
}
/// TxCadWait CadBusy handler. Channel saw activity → back off and
/// retry, OR fanout with ChannelBusy if max attempts reached.
/// `bump_cad_attempt` (TxBackoff entry) does the cad_attempts_used
/// bump and emits CadBackoffMaxed if exhausted.
async fn check_overlong_route_busy(&mut self) {
let _ = self.emit(RadioInput::TxCadGoToBackoff);
}
/// TxCadWait TransmitFailed handler. CAD itself errored. If the
/// overlong flag is set, escalate to WedgeDetected (the chip is
/// almost certainly stuck). Otherwise just fail this TX with a
/// clean error reply.
async fn check_overlong_route_fail(&mut self) {
if self.cad_attempt_overlong {
let _ = self.emit(RadioInput::WedgeDetected);
} else {
let _ = self.emit(RadioInput::TxCadGoToFanoutFailed);
}
}
/// TxBackoff entry. Increments cad_attempts_used; if exhausted,
/// emits CadBackoffMaxed which routes to TxFanout via stage_tx_busy.
/// Otherwise the during (jittered_sleep_during) runs and emits
/// CadBackoffDone → next CAD attempt.
async fn bump_cad_attempt(&mut self) {
self.cad_attempts_used = self.cad_attempts_used.saturating_add(1);
if self.cad_attempts_used >= self.cad_attempts_max {
let _ = self.emit(RadioInput::CadBackoffMaxed);
}
}
/// TxPrep entry. Calls lora.prepare_for_tx (writes payload, sets
/// TX power, etc.). On success → TxOnAir. On failure → TxFanout
/// via stage_tx_failed.
async fn prep_for_tx_act(&mut self) {
let result = {
let ctx: &mut RadioContext = &mut *self;
// Need lora (mut), mdltn (ref), pending_tx (ref) — all
// disjoint fields of ctx so split-borrow works.
match (ctx.lora.as_mut(), ctx.mdltn.as_ref(), ctx.pending_tx.as_ref()) {
(Some(lora), Some(mdltn), Some(pt)) => {
let cfg = pt.config;
match lora.create_tx_packet_params(
cfg.preamble_len,
matches!(cfg.header_mode, LoRaHeaderMode::Implicit),
cfg.payload_crc,
cfg.iq_invert,
mdltn,
) {
Ok(mut tx_pkt) => {
lora_setup(
"prep_for_tx_act",
lora.prepare_for_tx(
mdltn,
&mut tx_pkt,
cfg.tx_power_dbm as i32,
&pt.data,
),
)
.await
}
Err(e) => Err(e),
}
}
_ => Err(RadioError::Busy),
}
};
match result {
Ok(()) => {
let _ = self.emit(RadioInput::TxPrepOk);
}
Err(_) => {
if take_wedge_flag() {
let _ = self.emit(RadioInput::WedgeDetected);
} else {
let _ = self.emit(RadioInput::TransmitFailed);
}
}
}
}
/// TxFanout entry. Reads ctx.from_rx_session and ctx.pending_reset
/// to decide where to go after TX completion (success, busy, or
/// failed — all paths converge here). Emits one of three routing
/// events the chart consumes via TxFanout's transition arms.
async fn route_tx_outcome(&mut self) {
// If a Reset was deferred during the in-flight TX, honor it
// now — the chip is in a clean post-TX state, so reset_to_
// unconfigured can run without the cancel-safety hazard.
if self.pending_reset {
self.pending_reset = false;
let _ = self.emit(RadioInput::Reset);
return;
}
if self.from_rx_session {
let _ = self.emit(RadioInput::TxFanoutToRx);
} else {
let _ = self.emit(RadioInput::TxFanoutToIdle);
}
}
// ── 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,
});
// Backpressure: if the host transport's IN endpoint is observed-
// dead (host_task set RADIO_THROTTLED on a write_packet failure),
// skip the outbound push entirely. Otherwise we'd just fill the
// queue and the frame would fall out the bottom of the depth-64
// ring — the drop is the same outcome with extra log noise.
// Cleared by host_task on the first successful write_packet.
if crate::channel::RADIO_THROTTLED.load(portable_atomic::Ordering::Acquire) {
self.packets_dropped = self.packets_dropped.saturating_add(1);
return;
}
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 ────────────────────────────────────────────────────
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 reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> {
lora_setup("reconfigure_radio.enter_standby", lora.enter_standby()).await?;
// Apply the host-supplied LoRa sync word. The protocol field is a
// u16 holding the SX126x register pair in big-endian order
// (RadioLib convention: 0x1424 == byte 0x12 == LoRa "private" /
// MeshCore; 0x3444 == byte 0x34 == LoRaWAN "public"). lora-phy's
// set_lora_sync_word takes the underlying single-byte form, so
// undo the nibble encoding: convert_sync_word(byte) puts the
// chip's mandatory 0x4 in the low nibble of each register byte,
// and the high nibble of each register byte holds the original
// byte's high/low nibble respectively.
let [hi, lo] = cfg.sync_word.to_be_bytes();
let sync_byte = (hi & 0xF0) | (lo >> 4);
lora_setup(
"reconfigure_radio.set_lora_sync_word",
lora.set_lora_sync_word(sync_byte),
)
.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(())
}
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,
) {
// The chart owns everything: cold init, operational lifecycle,
// wedge recovery, dead-loop. radio_task is just a thin shim that
// hands `parts` (consumable pin ownership) into context and runs
// the chart. See `Radio` chart's `Booting` / `Recovering` / `Dead`
// states for the lifecycle policy.
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(1);
let ctx = RadioContext {
commands,
outbound,
events,
info,
parts: Some(parts),
lora: None,
rx_buf: [0u8; MAX_OTA_PAYLOAD],
config: None,
pending_tx: None,
packets_dropped: 0,
incoming_tag: None,
last_tx_tag: None,
pending_rx_start_tag: None,
resume_retries: 0,
recovery_attempts: 0,
cad_retry_delay_us: 0,
mdltn: None,
rx_pkt: None,
cad_attempts_used: 0,
cad_attempts_max: 0,
cad_attempt_overlong: false,
cad_must_abort: false,
tx_must_abort: false,
from_rx_session: false,
tx_outcome: None,
pending_reset: false,
rx_drain_dest: None,
};
// Per hsmc 0.3 README: channel capacity MUST match the machine's
// internal event-deque capacity. Both are 32 here (default is 8):
// action chains can emit a transient burst (e.g. apply_set_config
// emits ConfigApplied + possibly WedgeDetected; reset_to_unconfigured
// may emit WedgeDetected; etc.). 32 is comfortable headroom before
// hsmc's `take_overflow()` would surface Err(QueueFull) out of run().
static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<
embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex,
RadioInput,
32,
> = embassy_sync::channel::Channel::new();
let mut machine =
Radio::with_queue_capacity::<_, 32>(ctx, &RADIO_INTERNAL_CH);
// Per the chart's Harel structure: the radio chart is a non-
// terminating reactive system. There is no `terminate:` event
// declared, and Booting/Operational/Dead all have `during:`
// activities that prevent the run loop's silent-exit path. The
// ONLY way `run()` can return is `Err(QueueFull)` — which would
// be a chart-design bug, not a runtime condition to recover from.
// Halt loud so the bug is visible at the source rather than
// letting the task end silently and leaving the dongle limping.
let result = machine.run().await;
defmt::panic!(
"radio chart returned: {:?} — this is structurally impossible per chart design",
defmt::Debug2Format(&result),
);
}