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<