From 16374ca241e03e07cc2b9801ea8018405d25d294 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 27 Apr 2026 23:44:32 -0600 Subject: [PATCH] refactor(radio): make every lora-phy operation cancel-safe via chart structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the Operational subtree of the radio chart so that every cancel-unsafe lora-phy future (lora.rx() / lora.cad() / lora.tx() / lora.init()) runs to natural completion, honoring lora-phy's documented 'must run to completion' contract. Architectural pivot: every cancel-unsafe future is the body of a leaf- state during whose host state declares NO outbound event handlers other than events the during itself emits on natural completion. Per hsmc spec §6, the chart cannot drop the during because no transition can fire. RX subtree (RxSession super-state): - irq_pump (cancel-safe: wait_for_irq + get_irq_state + clear_irq_status) runs at RxSession scope, emits Dio1Asserted(IrqState) to chart - try_start_rx_hw chains prepare_for_rx -> start_rx so the chip is actually receiving when RxActive is entered (both single SPI ops) - RxActive's collect_rx_packet action does post-IRQ get_rx_result work - RxDraining's enter_standby cleanly stops RX before transitions out TX subtree (TxSession super-state): - TxCadPrep / TxCadWait / TxBackoff / TxPrep / TxOnAir / TxFanout leaf states each owning one cancel-safe SPI op or one sandboxed cancel-unsafe await - lora.cad() / lora.tx() called AS-IS in states with zero non-self event handlers - chart timers (on(after Duration)) set context flags for wedge detection; the during checks the flag at its next cancel-safe wakeup and returns cleanly - mark_pending_reset action defers Reset events until natural completion (per spec §5 emit() semantics) Removes lora_setup_with wrappers around lora.cad(), lora.tx(), lora.init() -- those wrappers used embassy_time::with_timeout which DROPS the wrapped future on timeout, the proximate cause of every 'lora_setup timeout: do_tx.cad' warn we've seen. Keeps lora_setup around enter_standby / prepare_for_rx / prepare_for_cad / prepare_for_tx -- those are short SPI sequences where drop is documented-safe. Documented latency budget: chart-cycle overhead <100us; regulatory LBT (CAD-to-TX <50ms) well within budget. --- src/radio.rs | 1565 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 1109 insertions(+), 456 deletions(-) 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