fix(radio): preserve RX session across transient failures
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).
This commit is contained in:
2 files changed
+97
-8
No files matched your search
@@ -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
|
||||
|
||||
+66
-8
@@ -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<u16>,
|
||||
/// 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<
|
||||
|
||||
Reference in new issue
Block a user