5 Commits
Author SHA1 Message Date
swaits 6867439044 chore: release 1.2.1 2026-04-22 23:59:44 -06:00
swaits bcfe736b30 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).
2026-04-22 23:59:12 -06:00
swaits 0033bbb941 chore: release 1.2.0 2026-04-22 20:50:11 -06:00
swaits 42a6b1142d feat(display): OLED dims a few seconds after last activity
Splash holds bright for 10 s on (re-)entry, then drops to dim while
LearnMore/Info keep alternating. The dashboard stays bright for 3 s
after the last packet or mode change, then dims in place without
disturbing the Listening/Transmitting state or the 1 s sparkline
timer. Cross-leaf TX↔RX hops stay bright throughout — `set_dim` is
fired by the 3 s timer handler as an action rather than as a sibling
`exit:` action, so mode-change leaf-crossings no longer flash
dim→bright on every swap.

Two driver families (`ssd1306::Ssd1306Async` and the in-tree
`Sh1106`) wrapped behind a single `DisplayBrightness` trait in
`src/driver/mod.rs`; the board trait's `DisplayDriver` associated
type now requires it. Dim/bright contrast values centralized as
`CONTRAST_BRIGHT` / `CONTRAST_DIM` constants — tune in one place.

`On.on(CmdReset)` now targets `LearnMore` directly rather than the
ancestor `Splash`. Under hsmc 0.2 up-transition semantics, targeting
an already-active ancestor unwinds only below it without re-descent;
the USB-disconnect reset path was previously relying on the old
re-enter-and-descend behavior and would have parked at `Splash`
with a dead alternation timer.

Bumps `hsmc` 0.1.0 → 0.2 for the up-transition semantic fix.
2026-04-22 19:38:42 -06:00
swaits fa1eeceb7c fix(boards): Elecrow ThinkNode-M2 uses CH340K, not CP2102
The shipping revision of this board has a WCH CH340K USB-UART bridge
(1a86:7522), not a Silicon Labs CP2102 as the original board-module
comment claimed. Update the doc header, and add the CH340K +
CH340-standard VID:PIDs to the `just flash` port list so we stop
falling through to espflash auto-detect on these boards.
2026-04-22 19:32:34 -06:00
9 changed files with 293 additions and 29 deletions

No files matched your search

+91
View File
@@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.2.1] - 2026-04-23
### 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
- **OLED auto-dim after inactivity.** The display is bright on any
activity — splash entry, mode change into a dashboard leaf, packet
RX/TX — and dims after a short idle window. Splash holds bright for
10 s, then drops to dim while the LearnMore / Info carousel keeps
alternating. The dashboard stays bright for 3 s after the last
packet or mode change, then dims in place without changing
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
bright→bright with no intermediate dim — `set_dim` is fired as an
action by the 3 s timer handler rather than as a sibling `exit:`
action, so mode-change leaf-crossings never see a flash.
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
and the in-tree `Sh1106` driver. Contrast values centralized as
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
the two levels can be tuned in one place.
### Changed
- `src/display.rs` statechart restructured:
- `Splash` gains `entry: set_bright` plus an action-only
`on(after 10s) => set_dim` handler so the splash bright window
is time-scoped without disturbing the LM↔Info alternation.
- `Dashboard` `Listening` and `Transmitting` each gain a nested
`*Bright` substate owning the `entry: set_bright` action. Packet
handlers moved into the leaves and target the leaf's `*Bright` —
sibling-to-sibling within the leaf restarts the 3 s timer without
re-entering the leaf itself (paint / 1 s sparkline timer
preserved). The 3 s timeout handler fires `set_dim` as an action
and up-transitions to the leaf, parking the machine at the leaf
with no active child.
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
would only unwind below `Splash` and park with no active child,
leaving the LM↔Info alternation timer dead and the QR frozen on
screen.
- `LoRaBoard::DisplayDriver` trait bound extended with
`+ DisplayBrightness` so brightness is part of the board contract
alongside `DrawTarget<Color = BinaryColor>`.
### Fixed
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
the CH340K / CH340 VID:PIDs directly instead of falling through
to espflash auto-detect.
### Dependencies
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
to an already-active ancestor unwinds only the subtree strictly
below the target; no exit/entry on the target; no default descent).
## [1.1.0] - 2026-04-22
Small but high-value behavior change: `SET_CONFIG` issued while the
Generated
+5 -5
View File
@@ -486,7 +486,7 @@ dependencies = [
[[package]]
name = "donglora"
version = "1.1.0"
version = "1.2.1"
dependencies = [
"cfg-if",
"cortex-m",
@@ -1404,9 +1404,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d8cb8820e7ff5fa4fa4b5dbb78b9f59d26ccc8e3bcf69d8f4c4ce29ee01b93a"
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
dependencies = [
"embassy-futures",
"embassy-sync 0.8.0",
@@ -1417,9 +1417,9 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4fa4dad2c05309fae4f6d15b74d9b37c1fd7325a96701972fdad89637635160"
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
dependencies = [
"proc-macro2",
"quote",
+3 -3
View File
@@ -3,7 +3,7 @@ members = ["."]
[package]
name = "donglora"
version = "1.1.0"
version = "1.2.1"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
@@ -30,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
donglora-protocol = "1.1.0"
hsmc = { version = "0.1.0", default-features = false, features = ["embassy"] }
donglora-protocol = "1"
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
lora-phy = "3.0"
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
static_cell = "2.1"
+1 -1
View File
@@ -77,7 +77,7 @@ flash board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
case "$target" in \
xtensa-*) \
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
if [ -n "$port" ]; then \
echo "Using $port"; \
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
+3 -3
View File
@@ -1,8 +1,8 @@
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
//!
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
//! OLED + CP2102 UART bridge + 1000 mAh Li-Po. Sold pre-loaded with Meshtastic;
//! this firmware turns it into a DongLoRa radio bridge.
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
//!
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
//! and PR meshtastic/firmware#6354):
@@ -11,7 +11,7 @@
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
//! UART: TX=43, RX=44 via CP2102 bridge
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
//! LED: GPIO6 (active HIGH indicator)
//!
//! Notable differences from the Heltec ESP32-S3 boards:
+5 -2
View File
@@ -9,6 +9,8 @@
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
use crate::driver::DisplayBrightness;
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
pub trait RgbLed {
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
@@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized {
/// Display peripheral bundle (I2C bus for display init).
type DisplayParts;
/// Concrete display driver type (must implement DrawTarget for rendering).
type DisplayDriver: DrawTarget<Color = BinaryColor>;
/// Concrete display driver type (must implement DrawTarget for rendering
/// and DisplayBrightness for the two-level dim control).
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
/// RGB LED driver. Boards without an LED use `()`.
type LedDriver: RgbLed;
+66 -7
View File
@@ -37,6 +37,7 @@ use embassy_time::Timer;
use hsmc::statechart;
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
use crate::driver::DisplayBrightness;
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
use crate::protocol::LoRaConfig;
@@ -220,6 +221,14 @@ impl DisplayContext {
Timer::after_millis(50).await;
self.peripherals.led.set_rgb(0, 0, 0).await;
}
async fn oled_bright(&mut self) {
self.peripherals.display.set_bright().await;
}
async fn oled_dim(&mut self) {
self.peripherals.display.set_dim().await;
}
}
// ── Statechart ─────────────────────────────────────────────────────
@@ -243,16 +252,26 @@ statechart! {
on(CmdOff) => Off;
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
// makes either ordering land in the same place. Reset the run
// state, turn off any blink-in-progress, and re-enter Splash —
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
on(CmdReset) => reset_run_state, turn_led_off, Splash;
// state, turn off any blink-in-progress, and land in LearnMore
// with a fresh 5 s timer. We target `LearnMore` (not `Splash`)
// because under hsmc 0.2 up-transition semantics, targeting a
// state that's already on the active path would only unwind
// below it — we'd park at `Splash` with no active child and
// the alternation timer would never restart.
on(CmdReset) => reset_run_state, turn_led_off, LearnMore;
// Config can arrive before the dashboard is up; cache it anywhere.
on(ConfigChanged(cfg: LoRaConfig)) => save_new_config;
// Splash is bright for the first 10 s of (re-)entry, then the
// action-only `on(after 10s)` handler drops the OLED to dim.
// LearnMore ↔ Info keep alternating underneath — their own
// timers are independent of the 10 s window.
state Splash {
entry: set_bright;
default(LearnMore);
on(EnteredRx) => Listening;
on(EnteredTx) => Transmitting;
on(after Duration::from_secs(10)) => set_dim;
state LearnMore {
entry: paint_learn_more;
@@ -265,24 +284,56 @@ statechart! {
}
}
// Dashboard brightness pattern: each mode-leaf holds a `Bright`
// substate. `entry: set_bright` owns the backlight going on;
// `set_dim` is fired **as an action** by the 3 s timeout
// handler, immediately before the up-transition that parks
// the machine at the leaf. The symmetric `exit: set_dim`
// would also fire on mode-change leaf-crossings (e.g.
// ListeningBright → TransmittingBright via EnteredTx), and
// the subsequent default descent would fire `set_bright`
// again — producing a visible dim/bright flash on every
// TX↔RX swap. Anchoring dim to the *timer* handler instead
// means leaf-crossings go bright→bright with no intermediate
// dim action. CmdOff/CmdReset exit paths don't fire set_dim
// either, which is fine — the display is blanked or walked
// back to Splash, which re-asserts its own brightness.
state Dashboard {
default(Listening);
on(RadioIdle) => Splash;
// Packet events are pure data + one-shot LED feedback.
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketTx) => record_tx_packet, blink_tx_led;
state Listening {
entry: paint_listening;
default(ListeningBright);
on(EnteredTx) => Transmitting;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
// Split from the transition below because hsmc rejects
// payload bindings on transition-bearing handlers.
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketRx) => ListeningBright;
on(PacketTx) => record_tx_packet, blink_tx_led;
on(PacketTx) => ListeningBright;
state ListeningBright {
entry: set_bright;
on(after Duration::from_secs(3)) => set_dim, Listening;
}
}
state Transmitting {
entry: paint_transmitting;
default(TransmittingBright);
on(EnteredRx) => Listening;
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
on(PacketRx) => TransmittingBright;
on(PacketTx) => record_tx_packet, blink_tx_led;
on(PacketTx) => TransmittingBright;
state TransmittingBright {
entry: set_bright;
on(after Duration::from_secs(3)) => set_dim, Transmitting;
}
}
}
}
@@ -355,6 +406,14 @@ impl DisplayActions for DisplayActionContext<'_> {
async fn advance_sparkline(&mut self) {
self.run.advance_slot();
}
async fn set_bright(&mut self) {
self.oled_bright().await;
}
async fn set_dim(&mut self) {
self.oled_dim().await;
}
}
// ── Task entry point ───────────────────────────────────────────────
+53
View File
@@ -10,3 +10,56 @@ pub mod sh1106;
feature = "waveshare_rp2040_lora"
))]
pub mod simple_led;
// ── Display brightness ────────────────────────────────────────────────
//
// The display statechart dims the OLED a few seconds after the last
// activity. Our two driver families (`ssd1306` crate and our in-tree
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
// single async trait that the state-machine actions can call uniformly.
/// Two-level backlight control used by the display dashboard.
pub trait DisplayBrightness {
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
}
// Contrast values for the two brightness levels, picked to be common across
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
const CONTRAST_BRIGHT: u8 = 0xFF;
const CONTRAST_DIM: u8 = 0x08;
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
where
DI: display_interface::AsyncWriteOnlyDataCommand,
SIZE: ssd1306::size::DisplaySizeAsync,
{
async fn set_bright(&mut self) {
// `Brightness::custom(precharge, contrast)`. Matches the shape of
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
let _ = self
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
.await;
}
async fn set_dim(&mut self) {
// Low precharge pairs with low contrast — same combination the
// crate's DIMMEST preset uses.
let _ = self
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
.await;
}
}
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
impl<I> DisplayBrightness for sh1106::Sh1106<I>
where
I: embedded_hal_async::i2c::I2c,
{
async fn set_bright(&mut self) {
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
}
async fn set_dim(&mut self) {
let _ = self.set_brightness(CONTRAST_DIM).await;
}
}
+66 -8
View File
@@ -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<