From 388d07e90f71a349a397f8dca9ce29a5e8ba4dec Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 16 Apr 2026 09:55:22 -0600 Subject: [PATCH] refactor: complete hsmc conversion and add debug-checkpoint feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the hsmc migration that was staged across the prior two commits: - Radio task is now a single `machine.run().await`; all I/O lives in state-scoped `during:` activities (`next_command`, `next_packet`, `perform_tx`). The old `push/drain` helpers, the imperative top-level `select`, `handle_cmd`, `handle_rx_result`, and `execute_transmit` are gone. `StartingRx` / `ResumingRx` intermediate states model the host-visible difference between a responding and a silent RX re-arm. - Host/USB task is now an hsmc statechart (Disconnected/Connected) with entry/exit actions handling display wake, radio StopRx, and display reset — replacing the open-coded `was_connected` edge detector. The `protocol_loop` keeps its imperative read path because `read_packet` blocks. - New `debug-checkpoint` cargo feature ships a dedicated `debug_blink.rs` task and a shared atomic counter that code along the boot/dispatch/TX path bumps at notable points. A ladder-latching write policy preserves the deepest TX phase reached on the first attempt until reboot — diagnostic output for UART-only boards where defmt would corrupt the host protocol. Zero footprint when the feature is off. - Silence a stray `clippy::unit_arg` on `led_off` / `led_blip` in the display task for boards with `LedDriver = ()`. --- Cargo.toml | 7 + src/debug_blink.rs | 229 +++++++++++++ src/display.rs | 138 ++++---- src/host/uart.rs | 5 + src/host/usb.rs | 139 +++++++- src/main.rs | 23 +- src/radio.rs | 799 ++++++++++++++++++++++++++------------------- 7 files changed, 902 insertions(+), 438 deletions(-) create mode 100644 src/debug_blink.rs diff --git a/Cargo.toml b/Cargo.toml index 405c7f0..2ea79d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,13 @@ owo-colors = "4.3" # macro's `#[cfg(feature = "tokio")]` checks. embassy = [] tokio = [] +# When enabled, replaces the display task with a LED-based diagnostic +# blinker driven by `crate::debug_blink::CHECKPOINT`. Used for diagnosing +# the firmware on UART-only boards where defmt output would corrupt the +# host protocol on the same UART. The LED flashes N times then pauses — +# a human watches the pattern and reports N to determine where execution +# reached / stopped. +debug-checkpoint = [] heltec_v3 = [ "embassy", "dep:esp-backtrace", diff --git a/src/debug_blink.rs b/src/debug_blink.rs new file mode 100644 index 0000000..9be3e65 --- /dev/null +++ b/src/debug_blink.rs @@ -0,0 +1,229 @@ +//! LED-based diagnostic blinker (opt-in via the `debug-checkpoint` cargo +//! feature). +//! +//! On boards whose UART is also the host protocol (heltec_v3_uart, any +//! UART-only variant), defmt output would corrupt the COBS-framed host +//! protocol. We can't use `defmt::info!` for live debugging on those +//! boards while the host is connected. Instead we expose a single global +//! atomic checkpoint; code sets it at interesting points in the boot / +//! dispatch flow, and a dedicated task continuously flashes the board +//! LED N times, pauses, and repeats — so a human can count the flashes +//! and report "it got to N." +//! +//! Blink pattern: +//! * Each flash: 400 ms ON, 400 ms OFF. +//! * After N flashes: 2000 ms pause (LED held OFF). +//! * Repeat indefinitely with the current `CHECKPOINT` value. +//! +//! Usage: +//! ```ignore +//! #[cfg(feature = "debug-checkpoint")] +//! crate::debug_blink::set(3); // "we reached checkpoint 3" +//! ``` +//! +//! The task replaces `display_task` when the `debug-checkpoint` feature is +//! enabled — it takes the board LED and drives it exclusively. +//! +//! # Current checkpoint map +//! +//! Defined by the `crate::radio` module. Updated here any time the +//! numbering changes there. +//! +//! | Flashes | Meaning | +//! |--------:|--------------------------------------------------------| +//! | 1 | `radio_task` entered (pre-`LoRa::new`) | +//! | 2 | `LoRa::new` succeeded | +//! | 3 | Machine constructed; about to enter `run()` | +//! | 4 | In `Idle` state | +//! | 5 | In `Receiving` state | +//! | 6 | TX phase 1: entered `perform_tx` (engages latch) | +//! | 7 | TX phase 2: in CAD | +//! | 8 | TX phase 3: in `prepare_for_tx` | +//! | 9 | TX phase 4: awaiting `tx()` | +//! | 10 | TX phase 5: `do_tx` returned OK | +//! | 11 | TX phase 6: `respond_tx_done` handler entered | +//! | 12 | TX phase 7: `responses.send(TxDone)` returned | +//! | 13 | TX phase 8: `host_task` received the response from chan | +//! | 14 | TX phase 9: `host_task` wrote response bytes to UART — TERMINAL SUCCESS | +//! | 15 | TX errored somewhere — TERMINAL ERROR | +//! | 16 | Boot error: `LoRa::new` failed | +//! | 17 | Boot error: `start_rx` failed inside `start_rx_hw` | +//! +//! # Latching +//! +//! * Latch engages **only** on `set(6)` (entry to `perform_tx`). +//! * While latched, only TX-phase values (6–15) can update `CHECKPOINT`, +//! and only if strictly deeper than the current value. +//! * Terminals (14 success, 15 error) freeze the latch until reboot. +//! * `set(13)` and `set(14)` called from `host_task` are **no-ops before +//! the latch engages** — this prevents unrelated SetConfig/Ok responses +//! from polluting the display before any TX has happened. +//! +//! # Diagnostic logic +//! +//! If ai-bot reports a timeout but the LED shows: +//! * **10** → `do_tx` ok, but `TransmitDone` event never dispatched. +//! * **11** → handler entered, but `responses.send(...)` hung. +//! * **12** → send pushed to channel, but `host_task` never picked it up. +//! * **13** → `host_task` picked it up, but `tx.write_all` to UART hung. +//! * **14** → full end-to-end firmware success. Hang is on the host side +//! of the UART (mux or wire). +//! * **15** → TX errored somewhere along the way. +//! +//! # Building with the feature +//! +//! The `just` build/flash recipes pass a fixed feature list and don't +//! surface an `extra-features` argument. To build a debug image: +//! +//! ```sh +//! cd firmware +//! . ~/export-esp.sh # if building for xtensa (esp32s3) +//! cargo +esp build --release \ +//! --target xtensa-esp32s3-none-elf \ +//! --features heltec_v3_uart,debug-checkpoint \ +//! -Zbuild-std=core,alloc +//! cp target/xtensa-esp32s3-none-elf/release/donglora \ +//! builds/donglora-heltec_v3_uart-v$(sed -n 's/^version = "\(.*\)"/\1/p' \ +//! Cargo.toml).elf +//! espflash flash -p /dev/ttyUSB0 builds/donglora-heltec_v3_uart-v0.4.0.elf +//! ``` +//! +//! The normal `just flash heltec_v3_uart` command builds without the +//! feature, so the debug code has zero footprint on release firmware. + +use core::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +use embassy_executor::task; +use embassy_time::{Duration, Timer}; + +use crate::board::{LedDriver, RgbLed}; + +/// Current diagnostic checkpoint — read by the blinker task. Zero means +/// "no checkpoint set yet." +/// +/// Write policy (see [`set`]): once a TX-phase value (6, 11, 12, 13) is +/// stored, the latch engages and: +/// * subsequent TX-phase writes update the value only if strictly deeper, +/// * non-TX-phase writes (including the 7/10 terminal markers and the +/// steady-state 4/5 post-TX writes) are **ignored** so the deepest phase +/// reached on the FIRST TX attempt persists. +/// +/// Rationale: a stuck-then-recovered TX attempt would normally flicker +/// through the display too quickly to count. Latching preserves the +/// evidence until reboot. +pub static CHECKPOINT: AtomicU8 = AtomicU8::new(0); + +/// Has the TX-phase latch engaged yet? Written by [`set`], checked by +/// [`set`]. Not intended for external inspection. +static HAS_LATCHED: AtomicBool = AtomicBool::new(false); + +#[inline] +fn is_host_phase(n: u8) -> bool { + // Values set only from `host_task` — meaningless before a TX has + // started, so we refuse to overwrite with them when the latch is + // not yet engaged. + n == 13 || n == 14 +} + +/// Ordered depth of each TX-related checkpoint. Returns `0` for values +/// that are NOT TX phases (e.g. boot / steady-state / error indicators). +/// +/// Value 18 ("next_command received CmdTransmit") is intentionally the +/// SHALLOWEST TX phase — it fires before `perform_tx` entry (value 6) +/// but is numerically higher because it was added last. The latch uses +/// `phase_depth` rather than raw numeric comparison so 18 can engage +/// the latch at depth 1 and later TX phases (6..=15) deepen over it. +#[inline] +fn phase_depth(n: u8) -> u8 { + match n { + 18 => 1, // pre-TX: CmdTransmit just read from commands channel + 6 => 2, // perform_tx entered + 7 => 3, // in CAD + 8 => 4, // in prepare_for_tx + 9 => 5, // in tx().await + 10 => 6, // do_tx returned Ok + 11 => 7, // respond_tx_done entered + 12 => 8, // respond_tx_done: responses.send returned + 13 => 9, // host_task received response from channel + 14 => 10, // host_task wrote response to UART (terminal success) + 15 => 11, // TX errored (terminal error) + _ => 0, // not a TX phase + } +} + +#[inline] +fn is_tx_phase(n: u8) -> bool { + phase_depth(n) > 0 +} + +#[inline] +fn is_tx_terminal(n: u8) -> bool { + n == 14 || n == 15 +} + +/// Record that we reached checkpoint `n`. A no-op in terms of visible +/// behavior unless the blinker task is spawned; the store itself is very +/// cheap so leaving these calls in non-debug builds is harmless. +/// +/// Latch semantics: +/// * `set(18)` (CmdTransmit received by `next_command`) or `set(6)` +/// (entry to `perform_tx`) engage the latch. +/// * While latched, only strictly-deeper TX phases (by `phase_depth`) +/// update the checkpoint. +/// * Terminal values (14 success, 15 error) freeze the latch until +/// reboot. +/// * Host-phase values (13, 14) called from `host_task` are ignored +/// before the latch engages, so unrelated `Response::Ok` writes from +/// SetConfig don't pollute the LED reading before any TX. +#[inline] +pub fn set(n: u8) { + if HAS_LATCHED.load(Ordering::Relaxed) { + let cur = CHECKPOINT.load(Ordering::Relaxed); + if is_tx_terminal(cur) { + return; + } + if phase_depth(n) > phase_depth(cur) { + CHECKPOINT.store(n, Ordering::Relaxed); + } + return; + } + + // Latch not yet engaged. + if is_host_phase(n) { + // `host_task` is draining the response channel before any TX — + // these values would be misleading. Skip them. + return; + } + + CHECKPOINT.store(n, Ordering::Relaxed); + if is_tx_phase(n) { + HAS_LATCHED.store(true, Ordering::Relaxed); + } +} + +/// Dedicated task that continuously flashes the LED to show the current +/// checkpoint. Owns the board's LED driver exclusively — on boards where +/// the display task would normally own the LED for RX/TX blips, spawn +/// *either* this task *or* `display_task`, not both. +#[task] +pub async fn debug_blink_task(mut led: LedDriver) { + // Start with the LED off so the first flash is unambiguous. + led.set_rgb(0, 0, 0).await; + + loop { + let n = CHECKPOINT.load(Ordering::Relaxed); + if n == 0 { + // No checkpoint yet — wait and re-check. + Timer::after(Duration::from_millis(200)).await; + continue; + } + for _ in 0..n { + led.set_rgb(255, 255, 255).await; + Timer::after(Duration::from_millis(400)).await; + led.set_rgb(0, 0, 0).await; + Timer::after(Duration::from_millis(400)).await; + } + // Long pause to separate cycles so the human can count. + Timer::after(Duration::from_millis(2000)).await; + } +} diff --git a/src/display.rs b/src/display.rs index b7a5eba..7fe2e16 100644 --- a/src/display.rs +++ b/src/display.rs @@ -37,9 +37,7 @@ use embassy_time::Timer; use hsmc::statechart; use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed}; -use crate::channel::{ - DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel, -}; +use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel}; use crate::protocol::RadioConfig; use render::{BoardInfo, RSSI_HISTORY_LEN}; @@ -71,7 +69,10 @@ pub enum DisplayEvent { /// Radio returned to Idle. RadioIdle, /// A packet was received (data only — does not imply a mode change). - PacketRx { rssi: i16, snr: Option }, + PacketRx { + rssi: i16, + snr: Option, + }, /// A packet was transmitted (data only — does not imply a mode change). PacketTx, ConfigChanged(RadioConfig), @@ -154,8 +155,14 @@ pub struct DisplayContext { impl DisplayContext { async fn flush_info(&mut self) { - let Peripherals { display, mac_str, .. } = &mut self.peripherals; - let info = BoardInfo { name: BOARD_NAME, version: VERSION, mac: mac_str }; + let Peripherals { + display, mac_str, .. + } = &mut self.peripherals; + let info = BoardInfo { + name: BOARD_NAME, + version: VERSION, + mac: mac_str, + }; render::info(display, &info); let _ = display.flush().await; } @@ -167,8 +174,14 @@ impl DisplayContext { } async fn flush_dashboard(&mut self, badge: Badge) { - let Peripherals { display, mac_str, .. } = &mut self.peripherals; - let info = BoardInfo { name: BOARD_NAME, version: VERSION, mac: mac_str }; + let Peripherals { + display, mac_str, .. + } = &mut self.peripherals; + let info = BoardInfo { + name: BOARD_NAME, + version: VERSION, + mac: mac_str, + }; render::dashboard( display, badge, @@ -192,10 +205,16 @@ impl DisplayContext { let _ = self.peripherals.display.flush().await; } + // Boards without an LED use `LedDriver = ()`; the `RgbLed for ()` impl + // makes `set_rgb` a noop returning `()`. Clippy's unit_arg lint + // misreads method calls on unit values as "passing a unit value to a + // function" — silence it here where the noop is intentional. + #[allow(clippy::unit_arg)] async fn led_off(&mut self) { self.peripherals.led.set_rgb(0, 0, 0).await; } + #[allow(clippy::unit_arg)] async fn led_blip(&mut self, r: u8, g: u8, b: u8) { self.peripherals.led.set_rgb(r, g, b).await; Timer::after_millis(50).await; @@ -439,70 +458,41 @@ mod render { /// traditional dark-on-light look including a proper quiet zone. const QR_WIDTH_PX: u32 = 64; const QR_BITMAP: [u8; 512] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, - 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, - 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, - 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, - 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, - 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, - 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, - 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, - 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, - 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, - 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, - 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, - 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, - 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, - 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, - 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, - 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, - 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, - 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, - 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, - 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, - 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, - 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, - 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, - 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, - 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, - 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, - 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, - 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, - 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, - 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, - 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, - 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, - 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, - 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, - 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, - 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, - 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, - 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, - 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, - 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, - 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, - 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, - 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, - 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, - 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, - 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, - 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, - 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0x9F, + 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6, + 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60, + 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, + 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, + 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, + 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, + 0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, + 0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60, + 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01, + 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, + 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, + 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, + 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, + 0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80, + 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, + 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06, + 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF, + 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE, + 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, + 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, + 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, + 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, + 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67, + 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6, + 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00, + 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, ]; pub struct BoardInfo<'a> { @@ -751,9 +741,7 @@ mod render { // ── QR code: flush to the right edge, 1:1 pixel rendering ─── let qr_x = w - QR_WIDTH_PX as i32; let qr_raw: ImageRaw = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX); - Image::new(&qr_raw, Point::new(qr_x, 0)) - .draw(target) - .ok(); + Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok(); // ── Text column (everything left of the QR) ───────────────── let col_w = qr_x; diff --git a/src/host/uart.rs b/src/host/uart.rs index e461216..6e100e3 100644 --- a/src/host/uart.rs +++ b/src/host/uart.rs @@ -67,11 +67,16 @@ pub async fn host_task( } } Either::Second(response) => { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(13); if let Some(frame) = framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf) { if tx.write_all(frame).await.is_err() { warn!("UART write failed, response dropped"); + } else { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(14); } } } diff --git a/src/host/usb.rs b/src/host/usb.rs index 6a64c24..70df836 100644 --- a/src/host/usb.rs +++ b/src/host/usb.rs @@ -1,10 +1,33 @@ //! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol. +//! +//! ```text +//! Host +//! ├── Disconnected (default) no DTR +//! └── Connected host attached +//! entry: wake_display +//! exit: stop_radio, reset_display +//! ``` +//! +//! The statechart tracks exactly one thing: DTR. Entry/exit actions +//! replace the old `was_connected` edge-detector and emit the display +//! wake / radio-stop / display-reset side-effects that used to be open +//! coded around the `select3` loop. +//! +//! The data plane (COBS decoding, command routing, response encoding) +//! stays imperative in `protocol_loop` for the same reason the radio's +//! `rx_once` stayed out of its chart: `read_packet` blocks and can't +//! live inside a statechart action. Host-contract lifecycle (one +//! outstanding command, unsolicited `RxPacket`, synchronous local +//! responses) is deliberately *not* modeled — any finer chart here +//! would desynchronise from the concurrent `select3` reality and lie +//! about what the firmware is actually doing. use defmt::warn; use embassy_executor::task; use embassy_futures::join::join; use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State}; use embassy_usb::Builder; +use hsmc::{statechart, Duration}; use static_cell::StaticCell; use super::framing::{self, CobsDecoder, MAX_FRAME}; @@ -13,6 +36,77 @@ use crate::protocol::Command; const MAX_PACKET: usize = 64; +/// Internal events driving `Host` state transitions. +#[derive(Debug, Clone)] +pub enum HostInput { + /// DTR rose: host opened the serial port. + DtrRaised, + /// DTR fell: host closed or disconnected. + DtrDropped, +} + +/// Machine context — channel handles the actions need to broadcast +/// DTR-edge side-effects. The COBS decoder, read/write scratch, and +/// USB endpoints stay local to `protocol_loop` because their async +/// operations can't live inside statechart actions. +pub struct HostContext { + commands: &'static CommandChannel, + display_commands: &'static DisplayCommandChannel, + has_display: bool, +} + +impl HostContext { + async fn wake_display_if_present(&self) { + if self.has_display { + self.display_commands.send(DisplayCommand::On).await; + } + } + + async fn reset_display_if_present(&self) { + if self.has_display { + self.display_commands.send(DisplayCommand::Reset).await; + } + } + + fn stop_radio_best_effort(&self) { + if self.commands.try_send(Command::StopRx).is_err() { + warn!("host DTR-drop: StopRx dropped (command channel full)"); + } + } +} + +statechart! { + Host { + context: HostContext; + events: HostInput; + default(Disconnected); + + state Disconnected { + on(DtrRaised) => Connected; + } + + state Connected { + entry: wake_display; + exit: stop_radio, reset_display; + on(DtrDropped) => Disconnected; + } + } +} + +impl HostActions for HostActionContext<'_> { + async fn wake_display(&mut self) { + self.wake_display_if_present().await; + } + + async fn stop_radio(&mut self) { + self.stop_radio_best_effort(); + } + + async fn reset_display(&mut self) { + self.reset_display_if_present().await; + } +} + #[task] pub async fn host_task( parts: crate::board::UsbParts, @@ -83,11 +177,18 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( ) { use embassy_futures::select::{select3, Either3}; + let mut machine = Host::new_local(HostContext { + commands, + display_commands, + has_display, + }); + // Enter the default: Disconnected. + machine.step(Duration::ZERO).await; + let mut read_buf = [0u8; MAX_PACKET]; let mut write_buf = [0u8; MAX_FRAME]; let mut cobs_encode_buf = [0u8; MAX_FRAME]; let mut decoder = CobsDecoder::new(); - let mut was_connected = false; loop { match select3( @@ -116,11 +217,10 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( } } Either3::First(Err(_)) => { - // read errored — usually a USB disconnect. Drop the - // decoder state and back off briefly so we don't spin at - // I/O rate. Crucially: DO NOT `continue` — we need the - // DTR check below to run so the display gets its - // `CmdReset` and the radio gets its `StopRx`. + // Read errored — usually a USB disconnect. Drop decoder + // scratch and back off briefly so we don't spin at I/O + // rate. Do NOT `continue`: the DTR check below must + // still run so the machine's exit action fires. decoder.reset(); embassy_time::Timer::after_millis(100).await; } @@ -139,16 +239,27 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( Either3::Third(()) => {} } + // Edge-detect DTR against the machine's current state. The + // machine's entry/exit actions own the side-effects. dispatch() + // pushes the event AND drains the internal queue, so + // current_state() reflects the post-dispatch state before we + // loop back around. let connected = receiver.dtr(); - if was_connected && !connected { - let _ = commands.try_send(Command::StopRx); - if has_display { - display_commands.send(DisplayCommand::Reset).await; + let was_connected = matches!(machine.current_state(), HostState::Connected); + match (was_connected, connected) { + (false, true) => { + let _ = machine.dispatch(HostInput::DtrRaised).await; } + (true, false) => { + // Belt-and-braces: reset decoder here too. The error + // arm above typically fires on disconnect and already + // resets it, but this makes intent explicit and + // survives embassy-usb behavior changes where a + // disconnect doesn't surface as a read error. + decoder.reset(); + let _ = machine.dispatch(HostInput::DtrDropped).await; + } + _ => {} } - if !was_connected && connected && has_display { - display_commands.send(DisplayCommand::On).await; - } - was_connected = connected; } } diff --git a/src/main.rs b/src/main.rs index 1035a4e..5c66341 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,8 @@ mod board; #[cfg(not(test))] mod channel; +#[cfg(all(not(test), feature = "debug-checkpoint"))] +mod debug_blink; #[cfg(not(test))] mod display; #[cfg(not(test))] @@ -97,10 +99,21 @@ async fn run(spawner: Spawner) { ); if let Some(dp) = parts.display { - #[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs - spawner.spawn( - display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS) - .expect("spawn display_task"), - ); + #[cfg(feature = "debug-checkpoint")] + { + // LED is taken over by the checkpoint blinker; the display + // init struct is dropped unused for this debug build. + let _ = dp; + spawner + .spawn(debug_blink::debug_blink_task(parts.led).expect("spawn debug_blink_task")); + } + #[cfg(not(feature = "debug-checkpoint"))] + { + #[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs + spawner.spawn( + display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS) + .expect("spawn display_task"), + ); + } } } diff --git a/src/radio.rs b/src/radio.rs index ee41c9f..c3f1c12 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -5,32 +5,35 @@ //! ├── Unconfigured (default) no config cached → no RF ops possible //! └── Configured //! ├── Idle (default) ready, waiting +//! ├── StartingRx brief: HW init in response to host +//! │ CmdStartRx; sends Ok/Error then → +//! │ Receiving (Ok) or Idle (Err) //! ├── Receiving continuous RX +//! ├── ResumingRx brief: post-TX HW re-arm; silently +//! │ → Receiving (Ok) or Idle (Err) //! └── Transmitting -//! ├── FromIdle (default) TransmitDone → Idle -//! └── FromRx TransmitDone → Receiving +//! ├── FromIdle (default) TxDone/TxFailed → Idle +//! └── FromRx TxDone → ResumingRx, TxFailed → Idle //! ``` //! -//! The machine owns two pieces of radio state: which mode we're in and -//! the cached [`RadioConfig`]. Entry/exit actions broadcast typed -//! [`RadioEvent`]s to the display. RF I/O (`rx_once`, `do_tx`, -//! `enter_standby`) can't live in actions — `rx_once` blocks -//! indefinitely — so the task's main loop stays imperative and drives -//! the machine via `machine.send` + `drain`, branching on -//! `machine.current_state()`. +//! The task body is a single `machine.run().await`. All I/O lives inside +//! state-scoped `during:` activities: //! -//! The `Transmitting` substates encode "where to return after TX" as -//! state identity; no `was_receiving` bookkeeping. Radio init happens -//! **before** the machine ever steps — success falls through into the -//! default `Unconfigured`, failure early-returns into a small -//! Ping-only degraded loop that doesn't involve the machine at all. +//! * Root-level `during: next_command(commands)` continuously reads the +//! host CommandChannel and converts each command into a `RadioInput` +//! event. +//! * `Receiving.during: next_packet(lora, rx_buf)` runs the SX1262 RX +//! and yields `PacketReceived` events. +//! * `TransmittingFromIdle/FromRx.during: perform_tx(lora, pending_tx)` +//! drives `do_tx` and yields `TransmitDone`/`TransmitFailed`. use defmt::{error, info, warn}; use embassy_executor::task; -use embassy_futures::select::{select, Either}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; use embassy_time::Delay; -use hsmc::{statechart, Duration}; -use lora_phy::mod_params::{PacketStatus, RadioError}; +use hsmc::statechart; +use lora_phy::mod_params::RadioError; use lora_phy::{LoRa, RxMode}; use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts}; @@ -46,34 +49,63 @@ const IMPLICIT_HEADER: bool = false; const CRC_ON: bool = true; const IQ_INVERTED: bool = false; -/// Internal events that drive `Radio` state transitions. Kept distinct -/// from [`crate::channel::RadioEvent`], which is the display-facing -/// broadcast bus. +/// A pending TX request staged by an incoming `Transmit` command, consumed +/// by the `perform_tx` during activity when the machine enters a +/// `Transmitting.*` state. `config` is `None` when the command asked to +/// reuse the cached config; the `stage_tx` handler resolves the effective +/// config. #[derive(Debug, Clone)] -pub enum RadioInput { - /// Host issued `SetConfig`; driver already validated + resolved. - ConfigSet(RadioConfig), - /// `start_rx` returned `Ok(())` — radio is now in RX mode. - StartRxOk, - /// `enter_standby` completed. - StopRxOk, - /// About to call `do_tx` — parks the machine in `Transmitting.*`. - TransmitBegin, - /// `do_tx` returned (success or failure). Either way, leave Transmitting. - TransmitDone, - /// `start_rx` failed mid-Receiving; fall back to Idle. - RxRestartFailed, - /// A packet was just received off-air with these link metrics. - PacketReceived { rssi: i16, snr: i16 }, +pub struct PendingTx { + pub config: Option, + pub payload: heapless::Vec, } -/// Machine context — config cache + the outbound event channel. The -/// `LoRa` driver, RX buffer, and command-validation plumbing stay local -/// to `radio_task` because their async operations can't live inside -/// statechart actions. +/// Events driving the radio statechart. Host commands arrive as `Cmd*` +/// variants via the `next_command` during; low-level I/O results arrive +/// as internal variants via the `next_packet` / `perform_tx` durings. +#[derive(Debug, Clone)] +pub enum RadioInput { + // Commands flowing in from the host via CommandChannel: + CmdPing, + CmdGetConfig, + CmdSetConfig(RadioConfig), + CmdStartRx, + CmdStopRx, + CmdTransmit(PendingTx), + + // Internal events from the RX during activity: + PacketReceived { + rssi: i16, + snr: i16, + len: u8, + data: heapless::Vec, + }, + RxFailed(&'static str), + + // Internal events from `try_start_rx_hw` (the entry action of + // StartingRx and ResumingRx). Two distinct states consume these, + // and their differing transitions are how the chart encodes whether + // the original trigger was a host CmdStartRx (must respond) or a + // post-TX RX re-arm (must NOT respond). + StartRxOk, + StartRxFailed(&'static str), + + // Internal events from the TX during activity: + TransmitDone, + TransmitFailed(&'static str), +} + +/// Root context. Owns the LoRa driver, the RX scratch buffer, the cached +/// configuration, the outbound event + response channels, and a staging +/// slot for pending TX requests. pub struct RadioContext { - events: &'static RadioEventChannel, - config: Option, + pub commands: &'static CommandChannel, + pub responses: &'static ResponseChannel, + pub events: &'static RadioEventChannel, + pub lora: LoRaDriver, + pub rx_buf: [u8; MAX_PAYLOAD], + pub config: Option, + pub pending_tx: Option, } impl RadioContext { @@ -90,114 +122,408 @@ statechart! { events: RadioInput; default(Unconfigured); + // Commands handled the same way in every state. The actual command + // read — `during: next_command(commands)` — is placed only on states + // where commands are safe to interleave (NOT on Transmitting.*: + // canceling perform_tx mid-flight would abort a LoRa TX). + on(CmdPing) => respond_pong; + on(CmdGetConfig) => respond_get_config; + state Unconfigured { - // Two declarations for the same event: the first catches the - // payload to cache it, the second transitions. `ConfigSet` - // from this state always means "first config ever". - on(ConfigSet(cfg: RadioConfig)) => cache_config; - on(ConfigSet) => Configured; + during: next_command(commands); + on(CmdSetConfig(cfg: RadioConfig)) => cache_config; + on(CmdSetConfig) => Configured; + // Pre-configuration, only Ping/GetConfig make sense. + on(CmdStartRx) => respond_not_configured; + on(CmdStopRx) => respond_not_configured; + on(CmdTransmit) => respond_not_configured; } state Configured { - // Re-configure from any Configured substate (driver rejects - // mid-TX, so this never fires during Transmitting). - on(ConfigSet(cfg: RadioConfig)) => cache_config; + on(CmdSetConfig(cfg: RadioConfig)) => cache_config; default(Idle); state Idle { entry: announce_idle; - on(StartRxOk) => Receiving; - on(TransmitBegin) => TransmittingFromIdle; + during: next_command(commands); + on(CmdStartRx) => StartingRx; + on(CmdTransmit(p: PendingTx)) => stage_tx; + on(CmdTransmit) => TransmittingFromIdle; + on(CmdStopRx) => respond_ok; + } + + // Brief intermediate state: a host `StartRx` is in flight, + // hardware is being configured. `try_start_rx_hw` (entry) + // emits `StartRxOk` or `StartRxFailed`; the transitions from + // here are what tell the host whether RX actually started. + // No `during: next_command` — this state is self-driven and + // we don't want to interleave other commands with the ack. + state StartingRx { + entry: try_start_rx_hw; + on(StartRxOk) => respond_ok, Receiving; + on(StartRxFailed(err: &'static str)) => respond_start_rx_error; + on(StartRxFailed) => Idle; } state Receiving { entry: announce_entered_rx; - on(PacketReceived { rssi: i16, snr: i16 }) => announce_packet_rx; - on(StopRxOk) => Idle; - on(TransmitBegin) => TransmittingFromRx; - on(RxRestartFailed) => Idle; + during: next_command(commands); + during: next_packet(lora, rx_buf, config); + // Idempotent ack: a redundant StartRx (same client re-issuing, + // or another client's request that mux still chose to forward) + // gets `Ok` without re-touching hardware. + on(CmdStartRx) => respond_ok; + on(PacketReceived { + rssi: i16, + snr: i16, + len: u8, + data: heapless::Vec, + }) => record_packet; + on(RxFailed(err: &'static str)) => announce_rx_failed; + on(RxFailed) => Idle; + on(CmdStopRx) => stop_rx_hw, respond_ok; + on(CmdStopRx) => Idle; + on(CmdTransmit(p: PendingTx)) => stage_tx; + on(CmdTransmit) => TransmittingFromRx; } + // Brief intermediate state: a TX from `Receiving` just + // finished and the radio needs to be put back into RX mode. + // Same `try_start_rx_hw` entry as `StartingRx`, but the + // transitions DO NOT send a response — `respond_tx_done` was + // already sent by the TransmittingFromRx → here transition. + state ResumingRx { + entry: try_start_rx_hw; + on(StartRxOk) => Receiving; + on(StartRxFailed) => Idle; + } + + // Transmitting.* deliberately has no `next_command` during: the + // TX operation inside `perform_tx` cannot be safely cancelled + // by an incoming host command. Commands arriving during TX + // queue on the CommandChannel and are processed by the next + // state that reads them (Idle or Receiving). state Transmitting { entry: announce_entered_tx; exit: announce_packet_tx; default(TransmittingFromIdle); state TransmittingFromIdle { + during: perform_tx(lora, pending_tx); + on(TransmitDone) => respond_tx_done; on(TransmitDone) => Idle; + on(TransmitFailed(err: &'static str)) => respond_tx_failed; + on(TransmitFailed) => Idle; } state TransmittingFromRx { - on(TransmitDone) => Receiving; - on(RxRestartFailed) => Idle; + during: perform_tx(lora, pending_tx); + on(TransmitDone) => respond_tx_done; + on(TransmitDone) => ResumingRx; + on(TransmitFailed(err: &'static str)) => respond_tx_failed; + on(TransmitFailed) => Idle; } } } } } +// ── during: activities ────────────────────────────────────────────── + +/// Bridges the host's `CommandChannel` (typed `Command`) into the +/// machine's event stream. Each awaited command becomes one `RadioInput`. +async fn next_command(commands: &mut &'static CommandChannel) -> RadioInput { + let cmd = commands.receive().await; + match cmd { + Command::Ping => RadioInput::CmdPing, + Command::GetConfig => RadioInput::CmdGetConfig, + Command::SetConfig(cfg) => RadioInput::CmdSetConfig(cfg), + Command::StartRx => RadioInput::CmdStartRx, + Command::StopRx => RadioInput::CmdStopRx, + Command::Transmit { config, payload } => { + // Mark the moment we learned about the Transmit command. + // This runs before the latch engages, so it's visible as a + // distinct LED value (18) when the firmware is sitting in + // Receiving (5) waiting for something to process — if LED + // stays on 5, the command never arrived at the firmware + // (mux / UART delay); if LED flashes through 18 then deeper, + // the command arrived normally. + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(18); + // The `stage_tx` handler resolves the effective config from + // either the inline `config` or the cached one. + RadioInput::CmdTransmit(PendingTx { config, payload }) + } + Command::DisplayOn | Command::DisplayOff | Command::GetMac => { + // These are dispatched elsewhere in the host; if they land + // here it's a routing bug. Map to Ping → Pong for minimum + // harm (better: surface an error; there's no "unknown + // command" variant today). + RadioInput::CmdPing + } + } +} + +/// Runs one iteration of continuous RX. Produces a `PacketReceived` event +/// on success or `RxFailed` on error. The radio was configured for +/// continuous RX by `try_start_rx_hw` (entry action of `StartingRx` / +/// `ResumingRx`) before the chart entered `Receiving`; each iteration +/// of this during awaits the next packet. +async fn next_packet( + lora: &mut LoRaDriver, + rx_buf: &mut [u8; MAX_PAYLOAD], + config: &mut Option, +) -> RadioInput { + let Some(cfg) = config.as_ref() else { + return RadioInput::RxFailed("no cached config"); + }; + let mdltn = match modulation_params(lora, cfg) { + Ok(m) => m, + Err(_) => return RadioInput::RxFailed("mdltn"), + }; + let pkt = match lora.create_rx_packet_params( + cfg.preamble_len, + IMPLICIT_HEADER, + MAX_PAYLOAD as u8, + CRC_ON, + IQ_INVERTED, + &mdltn, + ) { + Ok(p) => p, + Err(_) => return RadioInput::RxFailed("pkt_params"), + }; + match lora.rx(&pkt, rx_buf).await { + Ok((len, status)) => { + let copy_len = (len as usize).min(MAX_PAYLOAD); + let mut data: heapless::Vec = heapless::Vec::new(); + let _ = data.extend_from_slice(&rx_buf[..copy_len]); + RadioInput::PacketReceived { + rssi: status.rssi, + snr: status.snr, + len, + data, + } + } + Err(_e) => RadioInput::RxFailed("rx"), + } +} + +/// Drives one TX operation using a staged `PendingTx`. Produces +/// `TransmitDone` on success or `TransmitFailed` on error. +async fn perform_tx(lora: &mut LoRaDriver, pending: &mut Option) -> RadioInput { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(6); + let Some(tx) = pending.take() else { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(15); + return RadioInput::TransmitFailed("no pending tx"); + }; + let Some(cfg) = tx.config.as_ref() else { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(15); + return RadioInput::TransmitFailed("no effective config"); + }; + match do_tx(lora, cfg, &tx.payload).await { + Ok(()) => { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(10); + RadioInput::TransmitDone + } + Err(_) => { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(15); + RadioInput::TransmitFailed("tx") + } + } +} + +// ── Actions ───────────────────────────────────────────────────────── + impl RadioActions for RadioActionContext<'_> { - async fn cache_config(&mut self, cfg: RadioConfig) { - self.config = Some(cfg); - self.announce(RadioEvent::ConfigChanged(cfg)); + // ── responses ──────────────────────────────────────────────────── + async fn respond_pong(&mut self) { + self.responses.send(Response::Pong).await; } + async fn respond_get_config(&mut self) { + match self.config { + Some(cfg) => self.responses.send(Response::Config(cfg)).await, + None => { + self.responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + } + } + } + + async fn respond_ok(&mut self) { + // try_send: StopRx after RX with full response channel is + // tolerable — host is disconnected. + let _ = self.responses.try_send(Response::Ok); + } + + async fn respond_not_configured(&mut self) { + self.responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + } + + async fn respond_tx_done(&mut self) { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(11); + self.responses.send(Response::TxDone).await; + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(12); + } + + async fn respond_tx_failed(&mut self, _err: &'static str) { + self.responses + .send(Response::Error(ErrorCode::TxTimeout)) + .await; + } + + // ── state bookkeeping ───────────────────────────────────────────── + async fn cache_config(&mut self, cfg: RadioConfig) { + if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { + warn!("SetConfig rejected: {}", reason); + self.responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + return; + } + let resolved = cfg.resolve(Board::TX_POWER_RANGE); + self.config = Some(resolved); + self.announce(RadioEvent::ConfigChanged(resolved)); + self.responses.send(Response::Ok).await; + } + + async fn stage_tx(&mut self, p: PendingTx) { + let effective = p.config.or(self.config); + let Some(cfg) = effective else { + self.responses + .send(Response::Error(ErrorCode::NotConfigured)) + .await; + return; + }; + if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { + warn!("TX config rejected: {}", reason); + self.responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + return; + } + let resolved = cfg.resolve(Board::TX_POWER_RANGE); + self.pending_tx = Some(PendingTx { + config: Some(resolved), + payload: p.payload, + }); + } + + // ── RX hardware ────────────────────────────────────────────────── + + /// Entry action for `StartingRx` and `ResumingRx`. Performs HW init + /// and emits `StartRxOk` on success or `StartRxFailed` on failure. + /// Whether the host receives a Response::Ok / Response::Error is + /// decided by the parent state (StartingRx responds; ResumingRx is + /// silent — `respond_tx_done` already went out). + async fn try_start_rx_hw(&mut self) { + let Some(cfg) = self.config else { + error!("try_start_rx_hw: no config cached — state invariant broken"); + let _ = self.emit(RadioInput::StartRxFailed("no config")); + return; + }; + match start_rx(&mut self.lora, &cfg).await { + Ok(()) => { + let _ = self.emit(RadioInput::StartRxOk); + } + Err(e) => { + warn!("start_rx failed: {}", e); + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(17); + let _ = self.emit(RadioInput::StartRxFailed("start_rx")); + } + } + } + + async fn respond_start_rx_error(&mut self, _err: &'static str) { + self.responses + .send(Response::Error(ErrorCode::InvalidConfig)) + .await; + } + + async fn stop_rx_hw(&mut self) { + let _ = self.lora.enter_standby().await; + } + + // ── announcements ──────────────────────────────────────────────── async fn announce_idle(&mut self) { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(4); self.announce(RadioEvent::Idle); } - async fn announce_entered_rx(&mut self) { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(5); self.announce(RadioEvent::EnteredRx); } - async fn announce_entered_tx(&mut self) { self.announce(RadioEvent::EnteredTx); } + async fn announce_packet_tx(&mut self) { + self.announce(RadioEvent::PacketTx); + } + async fn announce_rx_failed(&mut self, _err: &'static str) { + self.announce(RadioEvent::Idle); + } - async fn announce_packet_rx(&mut self, rssi: i16, snr: i16) { + // ── packet bookkeeping ──────────────────────────────────────────── + async fn record_packet( + &mut self, + rssi: i16, + snr: i16, + _len: u8, + data: heapless::Vec, + ) { self.announce(RadioEvent::PacketRx { rssi, snr: Some(snr), }); - } - - async fn announce_packet_tx(&mut self) { - self.announce(RadioEvent::PacketTx); + let response = Response::RxPacket { + rssi, + snr, + payload: data, + }; + if self.responses.try_send(response).is_err() { + warn!("RX packet dropped: response channel full"); + } } } -/// Drain the machine's internal queue. Call after every `push`. -async fn drain(m: &mut Radio) { - while m.has_pending_events() { - m.step(Duration::ZERO).await; - } -} - -/// Push an event into the machine's internal queue and immediately drain -/// so `current_state()` reflects the transition before we return. -/// Overflow here is a real bug — the queue is capacity 8 and we never -/// batch — so we panic rather than silently drop. -async fn push(m: &mut Radio, ev: RadioInput) { - m.send(ev).expect("radio machine queue overflow"); - drain(m).await; -} - -/// Post-TX invariant: if the machine transitioned back into `Receiving`, -/// the hardware is still in standby from `do_tx`; put it back in RX. On -/// failure, tell the machine to fall through to `Idle`. -async fn restore_rx_if_needed(machine: &mut Radio, lora: &mut LoRaDriver) { - if machine.current_state() != RadioState::Receiving { - return; - } - let Some(cfg) = machine.context().config else { - // Can't happen: Receiving implies config is cached. - return; - }; - if let Err(e) = start_rx(lora, &cfg).await { - warn!("post-TX RX restart failed: {}", e); - push(machine, RadioInput::RxRestartFailed).await; - } -} +// ── Debug-checkpoint mapping (see `crate::debug_blink`) ───────────── +// +// Boot / steady states (latch is INACTIVE — values overwrite normally): +// 1 boot: radio_task entered +// 2 boot: LoRa::new ok (driver is live) +// 3 boot: machine constructed, about to enter run() +// 4 in Idle state (announce_idle ran) +// 5 in Receiving state (announce_entered_rx ran — HW is in RX) +// +// TX phases 6–15 (linear; latch ENGAGES only on `set(6)`; only deeper +// TX-phase writes can update; 14/15 are terminal and freeze the latch): +// 6 TX phase 1: entered perform_tx +// 7 TX phase 2: in CAD (prepare_for_cad / cad loop) +// 8 TX phase 3: in prepare_for_tx (SPI config write) +// 9 TX phase 4: awaiting tx() completion (on-air transmit) +// 10 TX phase 5: do_tx returned Ok (back in perform_tx) +// 11 TX phase 6: respond_tx_done handler entered +// 12 TX phase 7: responses.send(TxDone) completed (radio side done) +// 13 TX phase 8: host_task received the response from channel (set in host/uart.rs) +// 14 TX phase 9: host_task wrote response to UART — TERMINAL success +// 15 TX errored somewhere — TERMINAL error +// +// Boot-time errors (can only be seen before any TX engages the latch): +// 16 LoRa::new failed at boot (degraded_loop branch) +// 17 try_start_rx_hw failed (radio HW init returned Err) #[task] pub async fn radio_task( @@ -206,16 +532,23 @@ pub async fn radio_task( responses: &'static ResponseChannel, events: &'static RadioEventChannel, ) { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(1); + // Init the radio BEFORE the machine exists in any meaningful way — // a failure here never touches the statechart; we just run a // Ping-only loop forever. - let mut lora = match LoRa::new(parts.driver, false, parts.delay).await { + let lora = match LoRa::new(parts.driver, false, parts.delay).await { Ok(l) => { info!("radio initialized"); + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(2); l } Err(e) => { error!("radio init failed: {}", e); + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(16); responses .send(Response::Error(ErrorCode::InvalidConfig)) .await; @@ -224,67 +557,25 @@ pub async fn radio_task( } }; - let mut machine = Radio::new_local(RadioContext { + let ctx = RadioContext { + commands, + responses, events, + lora, + rx_buf: [0u8; MAX_PAYLOAD], config: None, - }); - // First step enters the default path: Unconfigured. - machine.step(Duration::ZERO).await; + pending_tx: None, + }; - let mut rx_buf = [0u8; MAX_PAYLOAD]; - - loop { - match machine.current_state() { - RadioState::Unconfigured | RadioState::Idle => { - let cmd = commands.receive().await; - handle_cmd(cmd, &mut machine, &mut lora, responses).await; - } - RadioState::Receiving => { - // Config presence is a state invariant: we can't be in - // Receiving without having passed through ConfigSet. - let cfg = machine - .context() - .config - .expect("Receiving requires cached config"); - match select( - rx_once(&mut lora, &cfg, &mut rx_buf), - commands.receive(), - ) - .await - { - Either::First(rx_result) => { - handle_rx_result( - rx_result, - &mut machine, - &mut lora, - &cfg, - &mut rx_buf, - responses, - ) - .await; - } - Either::Second(cmd) => { - handle_cmd(cmd, &mut machine, &mut lora, responses).await; - } - } - } - // `Transmitting.*` is transient — entered and exited inside - // `handle_cmd`'s Transmit branch. `Configured` and - // `Transmitting` are composite states that `current_state()` - // never returns once `descend_defaults` has completed. If - // any of these land here it's a bug; log + yield so we - // don't tight-spin. - RadioState::TransmittingFromIdle - | RadioState::TransmittingFromRx - | RadioState::Configured - | RadioState::Transmitting => { - warn!( - "radio main loop reached transient/composite state; yielding" - ); - embassy_time::Timer::after_millis(10).await; - } - } - } + // `run()` under the embassy feature requires a bound channel even when + // no external task injects events. Radio inputs come from the root + // `during: next_command(commands)` activity and from internal durings; + // this static is kept solely to satisfy the constructor. + static RADIO_INTERNAL_CH: Channel = Channel::new(); + let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH); + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(3); + let _ = machine.run().await; } /// Init-failure fallback. Answers Ping; everything else errors. @@ -301,175 +592,6 @@ async fn degraded_loop(commands: &CommandChannel, responses: &ResponseChannel) { } } -async fn handle_cmd( - cmd: Command, - machine: &mut Radio, - lora: &mut LoRaDriver, - responses: &ResponseChannel, -) { - match cmd { - Command::Ping => { - responses.send(Response::Pong).await; - } - Command::GetConfig => match machine.context().config { - Some(cfg) => responses.send(Response::Config(cfg)).await, - None => { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - } - }, - Command::SetConfig(cfg) => { - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("SetConfig rejected: {}", reason); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - } else { - let resolved = cfg.resolve(Board::TX_POWER_RANGE); - push(machine, RadioInput::ConfigSet(resolved)).await; - responses.send(Response::Ok).await; - } - } - Command::StartRx => { - let Some(cfg) = machine.context().config else { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - return; - }; - match start_rx(lora, &cfg).await { - Ok(()) => { - push(machine, RadioInput::StartRxOk).await; - responses.send(Response::Ok).await; - } - Err(e) => { - warn!("StartRx failed: {}", e); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - } - } - } - Command::StopRx => { - let _ = lora.enter_standby().await; - push(machine, RadioInput::StopRxOk).await; - // try_send: StopRx may be sent internally on USB disconnect - // when the host isn't draining responses. - let _ = responses.try_send(Response::Ok); - } - Command::DisplayOn | Command::DisplayOff | Command::GetMac => {} - Command::Transmit { - config: tx_cfg, - payload, - } => execute_transmit(tx_cfg, &payload, machine, lora, responses).await, - } -} - -/// Validate the effective TX config, enter `Transmitting.*`, run -/// `do_tx`, and leave — restoring RX if the machine returned to -/// `Receiving`. Isolated from `handle_cmd` because it's the only -/// command that crosses the statechart in two hops and has a -/// non-trivial post-condition (RX hardware re-arm). -async fn execute_transmit( - tx_cfg: Option, - payload: &[u8], - machine: &mut Radio, - lora: &mut LoRaDriver, - responses: &ResponseChannel, -) { - let eff_cfg = tx_cfg - .map(|c| c.resolve(Board::TX_POWER_RANGE)) - .or(machine.context().config); - let Some(cfg) = eff_cfg else { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - return; - }; - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("TX config rejected: {}", reason); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - return; - } - - // Per-state handlers pick TransmittingFromIdle vs TransmittingFromRx. - push(machine, RadioInput::TransmitBegin).await; - - let tx_result = do_tx(lora, &cfg, payload).await; - - // Fire TransmitDone regardless — we must leave Transmitting. The - // exit action announces PacketTx to the display. - push(machine, RadioInput::TransmitDone).await; - - match tx_result { - Ok(()) => responses.send(Response::TxDone).await, - Err(e) => { - warn!("TX failed: {}", e); - responses - .send(Response::Error(ErrorCode::TxTimeout)) - .await; - } - } - - restore_rx_if_needed(machine, lora).await; -} - -async fn handle_rx_result( - rx_result: Result<(u8, PacketStatus), RadioError>, - machine: &mut Radio, - lora: &mut LoRaDriver, - cfg: &RadioConfig, - rx_buf: &mut [u8; MAX_PAYLOAD], - responses: &ResponseChannel, -) { - match rx_result { - Ok((len, pkt_status)) => { - push( - machine, - RadioInput::PacketReceived { - rssi: pkt_status.rssi, - snr: pkt_status.snr, - }, - ) - .await; - - let copy_len = (len as usize).min(MAX_PAYLOAD); - if (len as usize) > copy_len { - warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD); - } - let mut payload = heapless::Vec::new(); - let _ = payload.extend_from_slice(&rx_buf[..copy_len]); - - if responses - .try_send(Response::RxPacket { - rssi: pkt_status.rssi, - snr: pkt_status.snr, - payload, - }) - .is_err() - { - warn!("RX packet dropped: response channel full"); - } - - if let Err(e) = start_rx(lora, cfg).await { - warn!("restart RX failed: {}", e); - push(machine, RadioInput::RxRestartFailed).await; - let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy)); - } - } - Err(e) => { - warn!("RX error: {}", e); - if start_rx(lora, cfg).await.is_err() { - push(machine, RadioInput::RxRestartFailed).await; - let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy)); - } - } - } -} - // ── LoRa helpers ──────────────────────────────────────────────────── fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth { @@ -540,29 +662,14 @@ async fn start_rx(lora: &mut LoRaDriver, cfg: &RadioConfig) -> Result<(), RadioE lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await } -async fn rx_once( - lora: &mut LoRaDriver, - cfg: &RadioConfig, - buf: &mut [u8], -) -> Result<(u8, PacketStatus), RadioError> { - let mdltn = modulation_params(lora, cfg)?; - let pkt = lora.create_rx_packet_params( - cfg.preamble_len, - IMPLICIT_HEADER, - MAX_PAYLOAD as u8, - CRC_ON, - IQ_INVERTED, - &mdltn, - )?; - lora.rx(&pkt, buf).await -} - const CAD_MAX_RETRIES: u8 = 10; async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> { let mdltn = modulation_params(lora, cfg)?; if cfg.cad_enabled() { + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(7); lora.prepare_for_cad(&mdltn).await?; for _ in 0..CAD_MAX_RETRIES { if !lora.cad(&mdltn).await? { @@ -580,7 +687,11 @@ async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Resu IQ_INVERTED, &mdltn, )?; + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(8); lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload) .await?; + #[cfg(feature = "debug-checkpoint")] + crate::debug_blink::set(9); lora.tx().await }