From bcfe736b30df8efc1119bc80c7b25df22a95f8a2 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 22 Apr 2026 23:56:19 -0600 Subject: [PATCH] fix(radio): preserve RX session across transient failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the v1.1.0 "SET_CONFIG during RX auto-re-arms" invariant to every failure path that can fire while a client has RX active: * `Receiving.on(RxFailed)` now transitions to `ResumingRx` instead of `Idle`. A transient `next_packet` error re-arms the chip rather than ending the session. * `TransmittingFromRx.on(TransmitFailed)` now transitions to `ResumingRx` instead of `Idle`. TX failure during RX still responds `TX_FAILED` to the caller, then returns to RX — matching the shape of the adjacent `ChannelBusy` handler, which already did this. * `ResumingRx.on(StartRxFailed)` is now a bounded retry (5 attempts, 25 ms backoff) rather than an immediate drop to `Idle`. Only after the retry budget is exhausted does the chip actually land in `Idle`, at which point it's genuinely wedged and needs reconfiguring. Observable effect: the OLED dashboard no longer bounces to Splash when a client (e.g. ai-bot) TXes while bridges are receiving. Previously any single hiccup in the RX/TX/ResumeRX cycle would announce `RadioEvent::Idle`, bouncing the OLED, and the radio would sit in Idle with no client aware enough to re-issue RX_START — stranding the session. The invariant should have held for the whole RX window between `CMD_RX_START` and `CMD_RX_STOP`; now it does. Implementation notes: * Two new internal `RadioInput` variants: `ResumeRxRetry` drives the `ResumingRx` self-transition; `ResumeRxExhausted` breaks out to Idle after too many failed tries. * New `resume_retries: u8` field on `RadioContext`, reset to 0 on every successful `StartRxOk`, bumped by `bump_resume_retries`. * Constants `MAX_RESUME_RETRIES = 5` and `RESUME_RX_RETRY_BACKOFF_MS = 25` at the top of `radio.rs`. * Removed the dead `announce_rx_failed` helper — its only caller was the now-deleted `Receiving.on(RxFailed) => Idle` edge. No behavior change for the three paths where `Idle` remains the correct landing state: initial `CmdRxStart` failure in `StartingRx`, `CmdRxStop` in `Receiving`, and all three `TransmittingFromIdle` outcomes (where RX wasn't running to begin with). --- CHANGELOG.md | 31 ++++++++++++++++++++++ src/radio.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554d17e..8e9a7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- **Radio no longer drops to Idle mid-RX-session on transient failures.** + Extends the v1.1.0 invariant ("`SET_CONFIG` during RX auto-re-arms") + to all failure paths that can occur while a client has RX active: + - `Receiving.on(RxFailed) => Idle` now transitions to `ResumingRx` + instead of `Idle`. A transient `next_packet` error re-arms the chip + rather than ending the RX session. + - `TransmittingFromRx.on(TransmitFailed) => Idle` now transitions to + `ResumingRx`. TX failure during an active RX session returns + `TX_FAILED` to the caller and resumes RX, matching the shape of the + adjacent `ChannelBusy` handler. + - `ResumingRx.on(StartRxFailed) => Idle` is now a bounded retry + (5 × 25 ms backoff via a new `bump_resume_retries` action and a + `ResumeRxRetry` / `ResumeRxExhausted` pair of internal events). + The chip only lands in `Idle` after exhausting the retry budget — + which means it's genuinely wedged and needs reconfiguring. + + Visible effect: the OLED dashboard no longer bounces to Splash when + `ai-bot` (or any other client that TXes while RX is running) sends a + packet. Previously any of the three transitions above would fire + `RadioEvent::Idle`, which the display uses as its `Dashboard → Splash` + trigger; the radio would land in `Idle` with no client aware enough + to re-send `RX_START`, stranding the dashboard until the session was + manually torn down and restarted. + +- Removed `announce_rx_failed` helper action: its only purpose was + emitting `RadioEvent::Idle` on the now-deleted `Receiving.on(RxFailed) + => Idle` edge. No replacement needed — `ResumingRx` handles recovery + silently. + ## [1.2.0] - 2026-04-22 ### Added diff --git a/src/radio.rs b/src/radio.rs index fc2eb45..1479222 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -102,8 +102,26 @@ pub enum RadioInput { }, ChannelBusy, TransmitFailed, + + /// Internal: emitted by `bump_resume_retries` when another + /// `try_start_rx_hw` attempt is allowed. Drives a self-transition on + /// `ResumingRx` so the entry action runs again after a short backoff. + ResumeRxRetry, + /// Internal: emitted by `bump_resume_retries` after + /// `MAX_RESUME_RETRIES` attempts have failed — the chip is genuinely + /// wedged; fall through to `Idle` so the host can reconfigure. + ResumeRxExhausted, } +/// How many back-to-back `try_start_rx_hw` failures to tolerate in +/// `ResumingRx` before giving up and parking in `Idle`. 5 × 25 ms ≈ +/// 125 ms worst-case resume window, enough to ride out SX1262 +/// post-TX timing flukes without pegging the CPU on a dead chip. +const MAX_RESUME_RETRIES: u8 = 5; + +/// Backoff between `ResumingRx` retry attempts. +const RESUME_RX_RETRY_BACKOFF_MS: u64 = 25; + // ── Context ───────────────────────────────────────────────────────── pub struct RadioContext { @@ -130,6 +148,10 @@ pub struct RadioContext { /// Tag of a pending `RX_START`, pulled by `respond_ok_start_rx` / /// `respond_err_start_rx` once the chip reports back. pub pending_rx_start_tag: Option, + /// Number of consecutive `try_start_rx_hw` failures while in + /// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES` + /// for the limit before falling through to `Idle`. + pub resume_retries: u8, } impl RadioContext { @@ -199,7 +221,12 @@ statechart! { during: next_packet(lora, rx_buf, config); on(CmdRxStart) => respond_ok; on(PacketReceived(rx: RxPayload)) => record_packet; - on(RxFailed) => announce_rx_failed, Idle; + // 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; @@ -212,10 +239,18 @@ statechart! { on(ConfigApplied) => ResumingRx; } + // Bounded-retry RX re-arm. Entered after every TX while + // already receiving, on RX driver errors, and on mid-session + // SET_CONFIG. If `try_start_rx_hw` succeeds we land cleanly + // in Receiving; if it fails we back off and retry, only + // conceding to Idle after MAX_RESUME_RETRIES attempts (chip + // is genuinely wedged at that point). state ResumingRx { entry: try_start_rx_hw; - on(StartRxOk) => Receiving; - on(StartRxFailed) => Idle; + on(StartRxOk) => reset_resume_retries, Receiving; + on(StartRxFailed) => bump_resume_retries; + on(ResumeRxRetry) => ResumingRx; + on(ResumeRxExhausted) => Idle; } state Transmitting { @@ -235,8 +270,12 @@ statechart! { during: perform_tx(lora, pending_tx); on(TransmitDone { airtime_us: u32 }) => respond_tx_done; on(TransmitDone) => ResumingRx; + // Even on failure, we were receiving before the TX — + // return to RX (the client still gets a TX_FAILED + // reply via respond_tx_failed). Dropping to Idle here + // would break the "RX session stays up" invariant. on(ChannelBusy) => respond_tx_channel_busy, ResumingRx; - on(TransmitFailed) => respond_tx_failed, Idle; + on(TransmitFailed) => respond_tx_failed, ResumingRx; } } } @@ -465,6 +504,28 @@ impl RadioActions for RadioActionContext<'_> { } } + async fn reset_resume_retries(&mut self) { + self.resume_retries = 0; + } + + async fn bump_resume_retries(&mut self) { + self.resume_retries = self.resume_retries.saturating_add(1); + if self.resume_retries >= MAX_RESUME_RETRIES { + warn!( + "ResumingRx: {} failed attempts, giving up to Idle", + self.resume_retries + ); + self.resume_retries = 0; + let _ = self.emit(RadioInput::ResumeRxExhausted); + return; + } + // Short backoff before the next `try_start_rx_hw` cycle. The + // self-transition on ResumingRx (driven by ResumeRxRetry) will + // re-run the entry action. + embassy_time::Timer::after_millis(RESUME_RX_RETRY_BACKOFF_MS).await; + let _ = self.emit(RadioInput::ResumeRxRetry); + } + async fn respond_ok(&mut self) { let tag = self.incoming_tag; self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; @@ -530,10 +591,6 @@ impl RadioActions for RadioActionContext<'_> { self.announce(RadioEvent::PacketTx); } - async fn announce_rx_failed(&mut self) { - self.announce(RadioEvent::Idle); - } - // ── Packet bookkeeping ──────────────────────────────────────────── async fn record_packet(&mut self, rx: RxPayload) { @@ -748,6 +805,7 @@ pub async fn radio_task( incoming_tag: 0, last_tx_tag: None, pending_rx_start_tag: None, + resume_retries: 0, }; static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<