diff --git a/src/radio.rs b/src/radio.rs index a015648..cb39047 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -1,41 +1,85 @@ //! 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 with timeout. NO event -//! │ handlers — cold_init runs uninterruptible. -//! │ → Operational on success, → Dead on fail. -//! ├── Operational super-state owning all chip-touching -//! │ │ lifecycle. PING/GET_INFO/Reset/ -//! │ │ WedgeDetected handlers live here so -//! │ │ they're STRUCTURALLY unreachable in -//! │ │ Booting and Dead by event bubbling. -//! │ ├── Unconfigured (default) no config cached → RF ops rejected +//! ├── 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 -//! │ │ ├── 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 -//! │ ├── Recovering in-place NRESET pulse + lora.init(); -//! │ │ on success → Unconfigured, on failure -//! │ │ → CheckRecoveryBudget -//! │ └── CheckRecoveryBudget bounded-retry decision: → Recovering -//! │ while under MAX_RECOVERY_ATTEMPTS, -//! │ → Dead when exhausted -//! └── Dead terminal: cold init failed OR recovery -//! exhausted. dead_drain consumes commands -//! inline replying ERadio (PING/INFO still -//! answer); only cold POR (USB unplug) -//! escapes. Per the Harel rule, the chart -//! MUST be non-terminating — radio_task's -//! run() call is followed by panic, since -//! reaching past it implies a chart bug. +//! │ │ ├── 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 @@ -53,12 +97,19 @@ use defmt::{error, info, warn}; use embassy_executor::task; -use embassy_time::{Delay, Instant}; +use embassy_time::Delay; use heapless::Vec as HVec; -use hsmc::statechart; -use lora_phy::mod_params::RadioError; +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, @@ -120,15 +171,85 @@ pub enum RadioInput { ConfigFailedRadio, PacketReceived(RxPayload), - RxFailed, StartRxOk, StartRxFailed, - TransmitDone { - airtime_us: u32, - }, - ChannelBusy, 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. @@ -186,18 +307,28 @@ const RESUME_RX_RETRY_BACKOFF_MS: u64 = 25; /// chip that is genuinely dead. const MAX_RECOVERY_ATTEMPTS: u8 = 4; -/// Wall-clock budget for `lora.init()` in the `Recovering` state. Cold -/// init normally completes in well under 100 ms, but the NRESET pulse -/// adds chip wake-up latency. 2 s is ~20× headroom for the documented -/// path; if it blows, the chip is unrecoverable and we fall through to -/// `CheckRecoveryBudget`. -const LORA_INIT_RECOVERY_TIMEOUT_MS: u64 = 2000; +/// 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; -/// Wall-clock budget for cold `LoRa::new` in the `Booting` state. Same -/// 1.5 s headroom we gave the imperative cold-init path before this -/// moved into the chart — covers a stuck-BUSY chip on first power-on. -/// On timeout the chart parks in `Dead`; user power-cycles to escape. -const LORA_COLD_INIT_TIMEOUT_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 ───────────────────────────────────────────────────────── @@ -259,6 +390,95 @@ pub struct RadioContext { /// 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, + /// 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, + /// 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, + /// 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, +} + +/// 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 { @@ -346,113 +566,239 @@ statechart! { 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 + // 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) => StartingRx; - on(CmdTx) => TransmittingFromIdle; + on(CmdRxStart) => RxArming; + on(CmdTx) => stage_tx_from_idle; + on(CmdTx) => TxCadPrep; on(CmdRxStop) => respond_ok; } - state StartingRx { - entry: try_start_rx_hw; - // Per hsmc README rule §1: leaf states need an - // active during somewhere on the path so the run - // loop has something to await. `pending()` blocks - // forever — the chart can only leave via - // StartRxOk/Failed. - during: pending(); - on(StartRxOk) => respond_ok_start_rx, Receiving; - on(StartRxFailed) => respond_err_start_rx, Idle; - } + // ── 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); - 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; - during: pending(); - on(StartRxOk) => reset_resume_retries, Receiving; - on(StartRxFailed) => bump_resume_retries; - on(ResumeRxRetry) => ResumingRx; - on(ResumeRxExhausted) => Idle; - } - - state Transmitting { - entry: announce_entered_tx; - // Per hsmc README rule §6, exit actions fire on every - // departure from Transmitting — including the - // Operational-level `on(WedgeDetected) => Recovering` - // transition that bypasses Transmitting's own - // `on(TransmitFailed) => respond_tx_failed` handler. - // Without this the host never gets a TX_DONE/TX_FAILED - // reply on a wedge mid-TX and waits its 2 s firmware- - // timeout. `respond_tx_failed` no-ops if `last_tx_tag` - // is None (the normal-completion path consumed it via - // `take()`), so it's a clean tag-correlated reply - // GUARANTEE: every CmdTx gets a response no matter - // how Transmitting is exited. - exit: respond_tx_failed; - exit: announce_packet_tx; - default(TransmittingFromIdle); - - state TransmittingFromIdle { - during: perform_tx(lora, pending_tx, cad_retry_delay_us); - on(TransmitDone { airtime_us: u32 }) => respond_tx_done; - on(TransmitDone) => Idle; - on(ChannelBusy) => respond_tx_channel_busy, Idle; - on(TransmitFailed) => respond_tx_failed, Idle; + // 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; } - state TransmittingFromRx { - during: perform_tx(lora, pending_tx, cad_retry_delay_us); - 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; + // 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; } } } @@ -545,30 +891,6 @@ where } } -/// Variant for ops with a known-larger budget (TX with long ToA, etc.). -async fn lora_setup_with( - label: &'static str, - timeout_ms: u64, - fut: F, -) -> Result -where - F: core::future::Future>, -{ - match embassy_time::with_timeout( - embassy_time::Duration::from_millis(timeout_ms), - fut, - ) - .await - { - Ok(r) => r, - Err(_) => { - warn!("lora_setup timeout: {} ({=u64} ms)", label, timeout_ms); - 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 @@ -652,15 +974,6 @@ async fn cold_init( parts: &mut Option, lora: &mut Option, ) -> RadioInput { - // ── DIAGNOSTIC PROBE 1 ── - // Per hsmc README rule §6, a state's `during:` activity STARTS after - // the state's entry actions finish. So if this fires, the chart MUST - // have already emitted `[Radio] enter __Root` and `[Radio] enter - // Booting` from `enter_state_no_descent`'s trace call. If chart - // traces are missing from the log but THIS line appears, the - // problem is specifically in the `__chart_trace!` macro's defmt - // path — not chart reachability and not the chart engine itself. - info!("[probe] cold_init enter (chart trace path SHOULD have fired BEFORE this)"); if lora.is_some() { // Already initialized — re-entry into Booting (e.g., user // injected a Reset before we even left Booting on first boot). @@ -674,148 +987,27 @@ async fn cold_init( // something forced us back. Stay Failed. return RadioInput::BootFailed; }; - match embassy_time::with_timeout( - embassy_time::Duration::from_millis(LORA_COLD_INIT_TIMEOUT_MS), - LoRa::new(p.driver, false, p.delay), - ) - .await - { - Ok(Ok(l)) => { + // 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 carried - // from a prior Recovering attempt — fresh chip, fresh state. + // 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"); - // ── DIAGNOSTIC PROBE 2 ── - // After this, we return BootOk; the chart's run loop pushes - // it into the queue and processes it on the next iteration: - // Booting → Operational. Expected trace lines AFTER this: - // [Radio] exit Booting - // [Radio] Booting -> Operational - // [Radio] enter Operational - // [Radio] enter Unconfigured - // If those don't appear, the trace path is broken. - info!("[probe] cold_init OK — about to return BootOk (chart should now exit Booting → Operational)"); RadioInput::BootOk } - Ok(Err(e)) => { + Err(e) => { error!("radio: cold init failed: {:?}", defmt::Debug2Format(&e)); RadioInput::BootFailed } - Err(_) => { - error!( - "radio: cold init TIMED OUT after {=u64} ms — chip BUSY likely stuck", - LORA_COLD_INIT_TIMEOUT_MS, - ); - RadioInput::BootFailed - } - } -} - -async fn next_packet( - lora: &mut Option, - rx_buf: &mut [u8; MAX_OTA_PAYLOAD], - config: &mut Option, -) -> RadioInput { - // Chart invariant: Receiving is unreachable from Booting/Dead, so - // lora is Some here. Defensive bail-out instead of panic so a chart - // bug self-recovers via RxFailed → ResumingRx rather than dying. - let Some(lora) = lora.as_mut() else { - return RadioInput::RxFailed; - }; - 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 = 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, - }) - } - // RX path is the long-await IRQ path that lora_setup is NOT - // wrapped around (RX legitimately waits indefinitely for a - // packet). But the helpers in to_lora_mod_params / - // create_rx_packet_params above DO go through lora_setup-style - // SPI calls — if any of THOSE wedged earlier in this poll, the - // flag is set and we should escalate to Recovering rather than - // bounce through the per-action ResumingRx loop with a still- - // wedged chip. - Err(_) => { - if take_wedge_flag() { - RadioInput::WedgeDetected - } else { - RadioInput::RxFailed - } - } - } -} - -async fn perform_tx( - lora: &mut Option, - pending: &mut Option, - cad_retry_delay_us: &mut u32, -) -> RadioInput { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(6); - // Same defensive-bail rationale as `next_packet`. - let Some(lora) = lora.as_mut() else { - return RadioInput::TransmitFailed; - }; - let Some(tx) = pending.take() else { - return RadioInput::TransmitFailed; - }; - // Scale CAD retry count to the cached per-retry sleep: fewer retries - // when each one-full-packet wait is expensive (slow SFs). When the - // one-packet wait exceeds the total budget, the helper returns - // `retries = 1` — a single CAD, bail on busy, no sleep. - let (retries, sleep_us) = cad_retries(*cad_retry_delay_us); - let start = Instant::now(); - match do_tx(lora, &tx, retries, sleep_us).await { - Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone { - airtime_us: Instant::now().duration_since(start).as_micros() as u32, - }, - Ok(TxOutcome::ChannelBusy) => RadioInput::ChannelBusy, - Err(_) => { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(15); - // Wedge takes precedence over per-action TransmitFailed so - // the chart escalates to Recovering instead of bouncing - // back to Idle/ResumingRx with a still-wedged chip. - if take_wedge_flag() { - RadioInput::WedgeDetected - } else { - RadioInput::TransmitFailed - } - } } } @@ -837,16 +1029,19 @@ async fn try_lora_init(lora: &mut Option) -> RadioInput { // toward Dead which is the right resting state. return RadioInput::RecoveryFailed; }; - match lora_setup_with( - "Recovering.lora.init", - LORA_INIT_RECOVERY_TIMEOUT_MS, - lora.init(), - ) - .await - { + // 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 wedge flag the prior failure - // had set — we know the chip is responsive again. + // Successful re-init clears any stale wedge flag from the + // prior failure that landed us here. let _ = take_wedge_flag(); RadioInput::RecoveryOk } @@ -888,6 +1083,120 @@ async fn dead_drain( } } +// ── 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) -> 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, + mdltn: &mut Option, + 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, + 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` slot (incoming_tag, last_tx_tag, etc.) @@ -1014,16 +1323,6 @@ impl RadioActions for RadioActionContext<'_> { } } - 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.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)) @@ -1061,9 +1360,60 @@ impl RadioActions for RadioActionContext<'_> { let _ = self.emit(RadioInput::StartRxFailed); return; }; - let result = match self.lora.as_mut() { - Some(lora) => start_rx(lora, &cfg).await, - None => Err(RadioError::Busy), + // 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(()) => { @@ -1130,18 +1480,6 @@ impl RadioActions for RadioActionContext<'_> { .await; } - async fn stop_rx_hw(&mut self) { - if let Some(lora) = self.lora.as_mut() { - let _ = lora_setup("stop_rx_hw.enter_standby", lora.enter_standby()).await; - } - // If the call wedged the chip, escalate to Recovering (chart - // root handler `on(WedgeDetected) => Recovering`) so the next - // operational action doesn't run against a stuck SPI bus. - if take_wedge_flag() { - let _ = self.emit(RadioInput::WedgeDetected); - } - } - // ── Reset (disconnect / inactivity timeout) ────────────────────── async fn reset_to_unconfigured(&mut self) { @@ -1185,23 +1523,6 @@ impl RadioActions for RadioActionContext<'_> { 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() { - // PendingTx.tag came from next_command, which already - // enforced non-zero — fail loudly if that invariant slipped. - self.last_tx_tag = Some( - core::num::NonZeroU16::new(pt.tag) - .expect("PendingTx.tag was 0 — next_command should have rejected"), - ); - } - self.announce(RadioEvent::EnteredTx); - } - async fn announce_packet_tx(&mut self) { self.announce(RadioEvent::PacketTx); } @@ -1263,6 +1584,421 @@ impl RadioActions for RadioActionContext<'_> { 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 = { + 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 = 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) { @@ -1303,11 +2039,6 @@ impl RadioActions for RadioActionContext<'_> { // ── LoRa helpers ──────────────────────────────────────────────────── -enum TxOutcome { - Transmitted, - ChannelBusy, -} - fn to_bw(bw: LoRaBandwidth) -> Option { use lora_phy::mod_params::Bandwidth::*; Some(match bw { @@ -1363,26 +2094,6 @@ fn to_lora_mod_params( 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, - )?; - // RX SETUP — wraps with a setup-budget timeout. The ACTUAL receive - // (lora.rx call in next_packet activity) is INTENTIONALLY un-wrapped - // because RX-continuous legitimately waits indefinitely for a packet. - lora_setup( - "start_rx.prepare_for_rx", - lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt), - ) - .await -} - async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> { lora_setup("reconfigure_radio.enter_standby", lora.enter_standby()).await?; // Calibrate image etc. via a throwaway create_modulation_params call @@ -1391,75 +2102,6 @@ async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<() Ok(()) } -async fn do_tx( - lora: &mut LoRaDriver, - tx: &PendingTx, - retries: u8, - sleep_us: u32, -) -> Result { - let mdltn = to_lora_mod_params(lora, &tx.config)?; - - if !tx.skip_cad { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(7); - lora_setup("do_tx.prepare_for_cad", lora.prepare_for_cad(&mdltn)).await?; - // Reaches here → prepare_for_cad (SPI setup) completed. - // Hang past here means lora.cad() is waiting on DIO1. - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(19); - let mut saw_activity = false; - for i in 0..retries { - // CAD with 8 symbols at SF12/BW125 is ~262 ms; 1500 ms covers - // worst-case configs + chip post-processing slack. - if !lora_setup_with("do_tx.cad", 1500, lora.cad(&mdltn)).await? { - saw_activity = false; - break; - } - saw_activity = true; - // Skip the trailing sleep: if the last CAD was busy, we're - // about to return ChannelBusy, so sleeping + re-preparing - // is wasted work. - if i + 1 == retries { - break; - } - let entropy = embassy_time::Instant::now().as_ticks() as u32; - let jittered_us = jittered_sleep_us(sleep_us, entropy); - embassy_time::Timer::after(embassy_time::Duration::from_micros(jittered_us as u64)) - .await; - lora_setup("do_tx.prepare_for_cad(retry)", 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, - )?; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(8); - lora_setup( - "do_tx.prepare_for_tx", - lora.prepare_for_tx(&mdltn, &mut tx_pkt, tx.config.tx_power_dbm as i32, &tx.data), - ) - .await?; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(9); - // TX budget = on-air ToA + 1000 ms slack for PA ramp, IRQ delivery, - // chip overhead. Skips the wedge-protection if data is huge / config - // is at SF12/BW125 — but ToA is bounded by config and known up front. - let payload_len = tx.data.len().min(u16::MAX as usize) as u16; - let toa_ms = (toa_us(&tx.config, payload_len) / 1000) as u64; - lora_setup_with("do_tx.tx", toa_ms.saturating_add(1000), lora.tx()).await?; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(10); - 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); @@ -1532,6 +2174,17 @@ pub async fn radio_task( 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