refactor: complete hsmc conversion and add debug-checkpoint feature
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 = ()`.
This commit is contained in:
7 files changed
+902
-438
No files matched your search
@@ -96,6 +96,13 @@ owo-colors = "4.3"
|
|||||||
# macro's `#[cfg(feature = "tokio")]` checks.
|
# macro's `#[cfg(feature = "tokio")]` checks.
|
||||||
embassy = []
|
embassy = []
|
||||||
tokio = []
|
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 = [
|
heltec_v3 = [
|
||||||
"embassy",
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-75
@@ -37,9 +37,7 @@ use embassy_time::Timer;
|
|||||||
use hsmc::statechart;
|
use hsmc::statechart;
|
||||||
|
|
||||||
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||||
use crate::channel::{
|
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||||
DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel,
|
|
||||||
};
|
|
||||||
use crate::protocol::RadioConfig;
|
use crate::protocol::RadioConfig;
|
||||||
|
|
||||||
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
||||||
@@ -71,7 +69,10 @@ pub enum DisplayEvent {
|
|||||||
/// Radio returned to Idle.
|
/// Radio returned to Idle.
|
||||||
RadioIdle,
|
RadioIdle,
|
||||||
/// A packet was received (data only — does not imply a mode change).
|
/// A packet was received (data only — does not imply a mode change).
|
||||||
PacketRx { rssi: i16, snr: Option<i16> },
|
PacketRx {
|
||||||
|
rssi: i16,
|
||||||
|
snr: Option<i16>,
|
||||||
|
},
|
||||||
/// A packet was transmitted (data only — does not imply a mode change).
|
/// A packet was transmitted (data only — does not imply a mode change).
|
||||||
PacketTx,
|
PacketTx,
|
||||||
ConfigChanged(RadioConfig),
|
ConfigChanged(RadioConfig),
|
||||||
@@ -154,8 +155,14 @@ pub struct DisplayContext {
|
|||||||
|
|
||||||
impl DisplayContext {
|
impl DisplayContext {
|
||||||
async fn flush_info(&mut self) {
|
async fn flush_info(&mut self) {
|
||||||
let Peripherals { display, mac_str, .. } = &mut self.peripherals;
|
let Peripherals {
|
||||||
let info = BoardInfo { name: BOARD_NAME, version: VERSION, mac: mac_str };
|
display, mac_str, ..
|
||||||
|
} = &mut self.peripherals;
|
||||||
|
let info = BoardInfo {
|
||||||
|
name: BOARD_NAME,
|
||||||
|
version: VERSION,
|
||||||
|
mac: mac_str,
|
||||||
|
};
|
||||||
render::info(display, &info);
|
render::info(display, &info);
|
||||||
let _ = display.flush().await;
|
let _ = display.flush().await;
|
||||||
}
|
}
|
||||||
@@ -167,8 +174,14 @@ impl DisplayContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn flush_dashboard(&mut self, badge: Badge) {
|
async fn flush_dashboard(&mut self, badge: Badge) {
|
||||||
let Peripherals { display, mac_str, .. } = &mut self.peripherals;
|
let Peripherals {
|
||||||
let info = BoardInfo { name: BOARD_NAME, version: VERSION, mac: mac_str };
|
display, mac_str, ..
|
||||||
|
} = &mut self.peripherals;
|
||||||
|
let info = BoardInfo {
|
||||||
|
name: BOARD_NAME,
|
||||||
|
version: VERSION,
|
||||||
|
mac: mac_str,
|
||||||
|
};
|
||||||
render::dashboard(
|
render::dashboard(
|
||||||
display,
|
display,
|
||||||
badge,
|
badge,
|
||||||
@@ -192,10 +205,16 @@ impl DisplayContext {
|
|||||||
let _ = self.peripherals.display.flush().await;
|
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) {
|
async fn led_off(&mut self) {
|
||||||
self.peripherals.led.set_rgb(0, 0, 0).await;
|
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) {
|
async fn led_blip(&mut self, r: u8, g: u8, b: u8) {
|
||||||
self.peripherals.led.set_rgb(r, g, b).await;
|
self.peripherals.led.set_rgb(r, g, b).await;
|
||||||
Timer::after_millis(50).await;
|
Timer::after_millis(50).await;
|
||||||
@@ -439,70 +458,41 @@ mod render {
|
|||||||
/// traditional dark-on-light look including a proper quiet zone.
|
/// traditional dark-on-light look including a proper quiet zone.
|
||||||
const QR_WIDTH_PX: u32 = 64;
|
const QR_WIDTH_PX: u32 = 64;
|
||||||
const QR_BITMAP: [u8; 512] = [
|
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, 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,
|
||||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F, 0xFE, 0x7F, 0xE6,
|
||||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F, 0xFE, 0x60,
|
||||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F, 0xFE,
|
||||||
0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F,
|
0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
|
||||||
0xFE, 0x00, 0x07, 0x9F, 0xFE, 0x60, 0x00, 0x7F,
|
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F, 0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06,
|
||||||
0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F,
|
0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F, 0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67,
|
||||||
0xFE, 0x7F, 0xE6, 0x18, 0x19, 0xE7, 0xFE, 0x7F,
|
0xFE, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x66,
|
||||||
0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F,
|
0x60, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
|
||||||
0xFE, 0x60, 0x67, 0x81, 0xE6, 0x66, 0x06, 0x7F,
|
0x61, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x00, 0x60,
|
||||||
0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
|
0x06, 0x01, 0x99, 0x99, 0xFF, 0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFE, 0x01,
|
||||||
0xFE, 0x60, 0x66, 0x19, 0x87, 0xE6, 0x06, 0x7F,
|
0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF, 0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF,
|
||||||
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F,
|
0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F, 0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
|
||||||
0xFE, 0x60, 0x67, 0xE6, 0x1F, 0xE6, 0x06, 0x7F,
|
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60,
|
||||||
0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F,
|
0x7F, 0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79,
|
||||||
0xFE, 0x7F, 0xE6, 0x67, 0x80, 0x67, 0xFE, 0x7F,
|
0x99, 0xFF, 0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF, 0xFE, 0x7E, 0x01, 0xFF, 0x80,
|
||||||
0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F,
|
0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x01, 0xFF, 0x80, 0x18, 0x18, 0x7F, 0xFE, 0x7E, 0x19, 0x9E,
|
||||||
0xFE, 0x00, 0x06, 0x66, 0x66, 0x60, 0x00, 0x7F,
|
0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x7E, 0x19, 0x9E, 0x1E, 0x78, 0x7E, 0x7F, 0xFE, 0x60, 0x06,
|
||||||
0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF,
|
0x66, 0x66, 0x00, 0x67, 0xFF, 0xFE, 0x60, 0x06, 0x66, 0x66, 0x00, 0x67, 0xFF, 0xFF, 0xFF,
|
||||||
0xFF, 0xFF, 0xFF, 0xFE, 0x61, 0xFF, 0xFF, 0xFF,
|
0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFE, 0x67, 0xF8, 0x7E, 0x1F, 0xFF, 0xFE,
|
||||||
0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF,
|
0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F, 0xFE, 0x00, 0x06, 0x00, 0x7E, 0x66, 0x60, 0x7F,
|
||||||
0xFE, 0x00, 0x60, 0x06, 0x01, 0x99, 0x99, 0xFF,
|
0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E, 0x7F, 0xFE, 0x7F, 0xE7, 0x99, 0xE6, 0x7E, 0x1E,
|
||||||
0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF,
|
0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00, 0x60, 0x7F, 0xFE, 0x60, 0x66, 0x61, 0xF8, 0x00,
|
||||||
0xFE, 0x01, 0x98, 0x1F, 0x9E, 0x79, 0xF9, 0xFF,
|
0x60, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01, 0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x06, 0x01,
|
||||||
0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F,
|
0x86, 0x00, 0x7F, 0xFE, 0x60, 0x66, 0x67, 0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x60, 0x66, 0x67,
|
||||||
0xFF, 0xE0, 0x66, 0x18, 0x61, 0x9F, 0x98, 0x7F,
|
0x86, 0x7F, 0x86, 0x7F, 0xFE, 0x7F, 0xE6, 0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x7F, 0xE6,
|
||||||
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
|
0x66, 0x18, 0x00, 0x1E, 0x7F, 0xFE, 0x00, 0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFE, 0x00,
|
||||||
0xFF, 0x98, 0x1E, 0x01, 0xE7, 0x99, 0xFE, 0x7F,
|
0x06, 0x66, 0x67, 0x98, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||||
0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F,
|
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||||
0xFF, 0xE1, 0xE6, 0x19, 0xF8, 0x06, 0x60, 0x7F,
|
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||||
0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF,
|
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||||
0xFE, 0x19, 0xF8, 0x06, 0x1E, 0x79, 0x99, 0xFF,
|
0xFF, 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> {
|
pub struct BoardInfo<'a> {
|
||||||
@@ -751,9 +741,7 @@ mod render {
|
|||||||
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
|
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
|
||||||
let qr_x = w - QR_WIDTH_PX as i32;
|
let qr_x = w - QR_WIDTH_PX as i32;
|
||||||
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
|
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
|
||||||
Image::new(&qr_raw, Point::new(qr_x, 0))
|
Image::new(&qr_raw, Point::new(qr_x, 0)).draw(target).ok();
|
||||||
.draw(target)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// ── Text column (everything left of the QR) ─────────────────
|
// ── Text column (everything left of the QR) ─────────────────
|
||||||
let col_w = qr_x;
|
let col_w = qr_x;
|
||||||
|
|||||||
@@ -67,11 +67,16 @@ pub async fn host_task(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Either::Second(response) => {
|
Either::Second(response) => {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(13);
|
||||||
if let Some(frame) =
|
if let Some(frame) =
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||||
{
|
{
|
||||||
if tx.write_all(frame).await.is_err() {
|
if tx.write_all(frame).await.is_err() {
|
||||||
warn!("UART write failed, response dropped");
|
warn!("UART write failed, response dropped");
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(14);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-14
@@ -1,10 +1,33 @@
|
|||||||
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
|
//! 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 defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
use embassy_futures::join::join;
|
use embassy_futures::join::join;
|
||||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||||
use embassy_usb::Builder;
|
use embassy_usb::Builder;
|
||||||
|
use hsmc::{statechart, Duration};
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||||
@@ -13,6 +36,77 @@ use crate::protocol::Command;
|
|||||||
|
|
||||||
const MAX_PACKET: usize = 64;
|
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]
|
#[task]
|
||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UsbParts,
|
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};
|
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 read_buf = [0u8; MAX_PACKET];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf = [0u8; MAX_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||||
let mut decoder = CobsDecoder::new();
|
let mut decoder = CobsDecoder::new();
|
||||||
let mut was_connected = false;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match select3(
|
match select3(
|
||||||
@@ -116,11 +217,10 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Either3::First(Err(_)) => {
|
Either3::First(Err(_)) => {
|
||||||
// read errored — usually a USB disconnect. Drop the
|
// Read errored — usually a USB disconnect. Drop decoder
|
||||||
// decoder state and back off briefly so we don't spin at
|
// scratch and back off briefly so we don't spin at I/O
|
||||||
// I/O rate. Crucially: DO NOT `continue` — we need the
|
// rate. Do NOT `continue`: the DTR check below must
|
||||||
// DTR check below to run so the display gets its
|
// still run so the machine's exit action fires.
|
||||||
// `CmdReset` and the radio gets its `StopRx`.
|
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
embassy_time::Timer::after_millis(100).await;
|
embassy_time::Timer::after_millis(100).await;
|
||||||
}
|
}
|
||||||
@@ -139,16 +239,27 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
Either3::Third(()) => {}
|
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();
|
let connected = receiver.dtr();
|
||||||
if was_connected && !connected {
|
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||||
let _ = commands.try_send(Command::StopRx);
|
match (was_connected, connected) {
|
||||||
if has_display {
|
(false, true) => {
|
||||||
display_commands.send(DisplayCommand::Reset).await;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-5
@@ -19,6 +19,8 @@
|
|||||||
mod board;
|
mod board;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod channel;
|
mod channel;
|
||||||
|
#[cfg(all(not(test), feature = "debug-checkpoint"))]
|
||||||
|
mod debug_blink;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod display;
|
mod display;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
@@ -97,10 +99,21 @@ async fn run(spawner: Spawner) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(dp) = parts.display {
|
if let Some(dp) = parts.display {
|
||||||
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
spawner.spawn(
|
{
|
||||||
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
// LED is taken over by the checkpoint blinker; the display
|
||||||
.expect("spawn display_task"),
|
// 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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+455
-344
@@ -5,32 +5,35 @@
|
|||||||
//! ├── Unconfigured (default) no config cached → no RF ops possible
|
//! ├── Unconfigured (default) no config cached → no RF ops possible
|
||||||
//! └── Configured
|
//! └── Configured
|
||||||
//! ├── Idle (default) ready, waiting
|
//! ├── 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
|
//! ├── Receiving continuous RX
|
||||||
|
//! ├── ResumingRx brief: post-TX HW re-arm; silently
|
||||||
|
//! │ → Receiving (Ok) or Idle (Err)
|
||||||
//! └── Transmitting
|
//! └── Transmitting
|
||||||
//! ├── FromIdle (default) TransmitDone → Idle
|
//! ├── FromIdle (default) TxDone/TxFailed → Idle
|
||||||
//! └── FromRx TransmitDone → Receiving
|
//! └── FromRx TxDone → ResumingRx, TxFailed → Idle
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The machine owns two pieces of radio state: which mode we're in and
|
//! The task body is a single `machine.run().await`. All I/O lives inside
|
||||||
//! the cached [`RadioConfig`]. Entry/exit actions broadcast typed
|
//! state-scoped `during:` activities:
|
||||||
//! [`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 `Transmitting` substates encode "where to return after TX" as
|
//! * Root-level `during: next_command(commands)` continuously reads the
|
||||||
//! state identity; no `was_receiving` bookkeeping. Radio init happens
|
//! host CommandChannel and converts each command into a `RadioInput`
|
||||||
//! **before** the machine ever steps — success falls through into the
|
//! event.
|
||||||
//! default `Unconfigured`, failure early-returns into a small
|
//! * `Receiving.during: next_packet(lora, rx_buf)` runs the SX1262 RX
|
||||||
//! Ping-only degraded loop that doesn't involve the machine at all.
|
//! 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 defmt::{error, info, warn};
|
||||||
use embassy_executor::task;
|
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 embassy_time::Delay;
|
||||||
use hsmc::{statechart, Duration};
|
use hsmc::statechart;
|
||||||
use lora_phy::mod_params::{PacketStatus, RadioError};
|
use lora_phy::mod_params::RadioError;
|
||||||
use lora_phy::{LoRa, RxMode};
|
use lora_phy::{LoRa, RxMode};
|
||||||
|
|
||||||
use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts};
|
use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts};
|
||||||
@@ -46,34 +49,63 @@ const IMPLICIT_HEADER: bool = false;
|
|||||||
const CRC_ON: bool = true;
|
const CRC_ON: bool = true;
|
||||||
const IQ_INVERTED: bool = false;
|
const IQ_INVERTED: bool = false;
|
||||||
|
|
||||||
/// Internal events that drive `Radio` state transitions. Kept distinct
|
/// A pending TX request staged by an incoming `Transmit` command, consumed
|
||||||
/// from [`crate::channel::RadioEvent`], which is the display-facing
|
/// by the `perform_tx` during activity when the machine enters a
|
||||||
/// broadcast bus.
|
/// `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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum RadioInput {
|
pub struct PendingTx {
|
||||||
/// Host issued `SetConfig`; driver already validated + resolved.
|
pub config: Option<RadioConfig>,
|
||||||
ConfigSet(RadioConfig),
|
pub payload: heapless::Vec<u8, MAX_PAYLOAD>,
|
||||||
/// `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 },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Machine context — config cache + the outbound event channel. The
|
/// Events driving the radio statechart. Host commands arrive as `Cmd*`
|
||||||
/// `LoRa` driver, RX buffer, and command-validation plumbing stay local
|
/// variants via the `next_command` during; low-level I/O results arrive
|
||||||
/// to `radio_task` because their async operations can't live inside
|
/// as internal variants via the `next_packet` / `perform_tx` durings.
|
||||||
/// statechart actions.
|
#[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<u8, MAX_PAYLOAD>,
|
||||||
|
},
|
||||||
|
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 {
|
pub struct RadioContext {
|
||||||
events: &'static RadioEventChannel,
|
pub commands: &'static CommandChannel,
|
||||||
config: Option<RadioConfig>,
|
pub responses: &'static ResponseChannel,
|
||||||
|
pub events: &'static RadioEventChannel,
|
||||||
|
pub lora: LoRaDriver,
|
||||||
|
pub rx_buf: [u8; MAX_PAYLOAD],
|
||||||
|
pub config: Option<RadioConfig>,
|
||||||
|
pub pending_tx: Option<PendingTx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioContext {
|
impl RadioContext {
|
||||||
@@ -90,114 +122,408 @@ statechart! {
|
|||||||
events: RadioInput;
|
events: RadioInput;
|
||||||
default(Unconfigured);
|
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 {
|
state Unconfigured {
|
||||||
// Two declarations for the same event: the first catches the
|
during: next_command(commands);
|
||||||
// payload to cache it, the second transitions. `ConfigSet`
|
on(CmdSetConfig(cfg: RadioConfig)) => cache_config;
|
||||||
// from this state always means "first config ever".
|
on(CmdSetConfig) => Configured;
|
||||||
on(ConfigSet(cfg: RadioConfig)) => cache_config;
|
// Pre-configuration, only Ping/GetConfig make sense.
|
||||||
on(ConfigSet) => Configured;
|
on(CmdStartRx) => respond_not_configured;
|
||||||
|
on(CmdStopRx) => respond_not_configured;
|
||||||
|
on(CmdTransmit) => respond_not_configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
state Configured {
|
state Configured {
|
||||||
// Re-configure from any Configured substate (driver rejects
|
on(CmdSetConfig(cfg: RadioConfig)) => cache_config;
|
||||||
// mid-TX, so this never fires during Transmitting).
|
|
||||||
on(ConfigSet(cfg: RadioConfig)) => cache_config;
|
|
||||||
default(Idle);
|
default(Idle);
|
||||||
|
|
||||||
state Idle {
|
state Idle {
|
||||||
entry: announce_idle;
|
entry: announce_idle;
|
||||||
on(StartRxOk) => Receiving;
|
during: next_command(commands);
|
||||||
on(TransmitBegin) => TransmittingFromIdle;
|
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 {
|
state Receiving {
|
||||||
entry: announce_entered_rx;
|
entry: announce_entered_rx;
|
||||||
on(PacketReceived { rssi: i16, snr: i16 }) => announce_packet_rx;
|
during: next_command(commands);
|
||||||
on(StopRxOk) => Idle;
|
during: next_packet(lora, rx_buf, config);
|
||||||
on(TransmitBegin) => TransmittingFromRx;
|
// Idempotent ack: a redundant StartRx (same client re-issuing,
|
||||||
on(RxRestartFailed) => Idle;
|
// 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<u8, MAX_PAYLOAD>,
|
||||||
|
}) => 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 {
|
state Transmitting {
|
||||||
entry: announce_entered_tx;
|
entry: announce_entered_tx;
|
||||||
exit: announce_packet_tx;
|
exit: announce_packet_tx;
|
||||||
default(TransmittingFromIdle);
|
default(TransmittingFromIdle);
|
||||||
|
|
||||||
state TransmittingFromIdle {
|
state TransmittingFromIdle {
|
||||||
|
during: perform_tx(lora, pending_tx);
|
||||||
|
on(TransmitDone) => respond_tx_done;
|
||||||
on(TransmitDone) => Idle;
|
on(TransmitDone) => Idle;
|
||||||
|
on(TransmitFailed(err: &'static str)) => respond_tx_failed;
|
||||||
|
on(TransmitFailed) => Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
state TransmittingFromRx {
|
state TransmittingFromRx {
|
||||||
on(TransmitDone) => Receiving;
|
during: perform_tx(lora, pending_tx);
|
||||||
on(RxRestartFailed) => Idle;
|
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<RadioConfig>,
|
||||||
|
) -> 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<u8, MAX_PAYLOAD> = 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<PendingTx>) -> 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<'_> {
|
impl RadioActions for RadioActionContext<'_> {
|
||||||
async fn cache_config(&mut self, cfg: RadioConfig) {
|
// ── responses ────────────────────────────────────────────────────
|
||||||
self.config = Some(cfg);
|
async fn respond_pong(&mut self) {
|
||||||
self.announce(RadioEvent::ConfigChanged(cfg));
|
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) {
|
async fn announce_idle(&mut self) {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(4);
|
||||||
self.announce(RadioEvent::Idle);
|
self.announce(RadioEvent::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn announce_entered_rx(&mut self) {
|
async fn announce_entered_rx(&mut self) {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(5);
|
||||||
self.announce(RadioEvent::EnteredRx);
|
self.announce(RadioEvent::EnteredRx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn announce_entered_tx(&mut self) {
|
async fn announce_entered_tx(&mut self) {
|
||||||
self.announce(RadioEvent::EnteredTx);
|
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<u8, MAX_PAYLOAD>,
|
||||||
|
) {
|
||||||
self.announce(RadioEvent::PacketRx {
|
self.announce(RadioEvent::PacketRx {
|
||||||
rssi,
|
rssi,
|
||||||
snr: Some(snr),
|
snr: Some(snr),
|
||||||
});
|
});
|
||||||
}
|
let response = Response::RxPacket {
|
||||||
|
rssi,
|
||||||
async fn announce_packet_tx(&mut self) {
|
snr,
|
||||||
self.announce(RadioEvent::PacketTx);
|
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`.
|
// ── Debug-checkpoint mapping (see `crate::debug_blink`) ─────────────
|
||||||
async fn drain(m: &mut Radio) {
|
//
|
||||||
while m.has_pending_events() {
|
// Boot / steady states (latch is INACTIVE — values overwrite normally):
|
||||||
m.step(Duration::ZERO).await;
|
// 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)
|
||||||
/// Push an event into the machine's internal queue and immediately drain
|
// 5 in Receiving state (announce_entered_rx ran — HW is in RX)
|
||||||
/// so `current_state()` reflects the transition before we return.
|
//
|
||||||
/// Overflow here is a real bug — the queue is capacity 8 and we never
|
// TX phases 6–15 (linear; latch ENGAGES only on `set(6)`; only deeper
|
||||||
/// batch — so we panic rather than silently drop.
|
// TX-phase writes can update; 14/15 are terminal and freeze the latch):
|
||||||
async fn push(m: &mut Radio, ev: RadioInput) {
|
// 6 TX phase 1: entered perform_tx
|
||||||
m.send(ev).expect("radio machine queue overflow");
|
// 7 TX phase 2: in CAD (prepare_for_cad / cad loop)
|
||||||
drain(m).await;
|
// 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)
|
||||||
/// Post-TX invariant: if the machine transitioned back into `Receiving`,
|
// 11 TX phase 6: respond_tx_done handler entered
|
||||||
/// the hardware is still in standby from `do_tx`; put it back in RX. On
|
// 12 TX phase 7: responses.send(TxDone) completed (radio side done)
|
||||||
/// failure, tell the machine to fall through to `Idle`.
|
// 13 TX phase 8: host_task received the response from channel (set in host/uart.rs)
|
||||||
async fn restore_rx_if_needed(machine: &mut Radio, lora: &mut LoRaDriver) {
|
// 14 TX phase 9: host_task wrote response to UART — TERMINAL success
|
||||||
if machine.current_state() != RadioState::Receiving {
|
// 15 TX errored somewhere — TERMINAL error
|
||||||
return;
|
//
|
||||||
}
|
// Boot-time errors (can only be seen before any TX engages the latch):
|
||||||
let Some(cfg) = machine.context().config else {
|
// 16 LoRa::new failed at boot (degraded_loop branch)
|
||||||
// Can't happen: Receiving implies config is cached.
|
// 17 try_start_rx_hw failed (radio HW init returned Err)
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(e) = start_rx(lora, &cfg).await {
|
|
||||||
warn!("post-TX RX restart failed: {}", e);
|
|
||||||
push(machine, RadioInput::RxRestartFailed).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn radio_task(
|
pub async fn radio_task(
|
||||||
@@ -206,16 +532,23 @@ pub async fn radio_task(
|
|||||||
responses: &'static ResponseChannel,
|
responses: &'static ResponseChannel,
|
||||||
events: &'static RadioEventChannel,
|
events: &'static RadioEventChannel,
|
||||||
) {
|
) {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(1);
|
||||||
|
|
||||||
// Init the radio BEFORE the machine exists in any meaningful way —
|
// Init the radio BEFORE the machine exists in any meaningful way —
|
||||||
// a failure here never touches the statechart; we just run a
|
// a failure here never touches the statechart; we just run a
|
||||||
// Ping-only loop forever.
|
// 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) => {
|
Ok(l) => {
|
||||||
info!("radio initialized");
|
info!("radio initialized");
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(2);
|
||||||
l
|
l
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("radio init failed: {}", e);
|
error!("radio init failed: {}", e);
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(16);
|
||||||
responses
|
responses
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||||
.await;
|
.await;
|
||||||
@@ -224,67 +557,25 @@ pub async fn radio_task(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut machine = Radio::new_local(RadioContext {
|
let ctx = RadioContext {
|
||||||
|
commands,
|
||||||
|
responses,
|
||||||
events,
|
events,
|
||||||
|
lora,
|
||||||
|
rx_buf: [0u8; MAX_PAYLOAD],
|
||||||
config: None,
|
config: None,
|
||||||
});
|
pending_tx: None,
|
||||||
// First step enters the default path: Unconfigured.
|
};
|
||||||
machine.step(Duration::ZERO).await;
|
|
||||||
|
|
||||||
let mut rx_buf = [0u8; MAX_PAYLOAD];
|
// `run()` under the embassy feature requires a bound channel even when
|
||||||
|
// no external task injects events. Radio inputs come from the root
|
||||||
loop {
|
// `during: next_command(commands)` activity and from internal durings;
|
||||||
match machine.current_state() {
|
// this static is kept solely to satisfy the constructor.
|
||||||
RadioState::Unconfigured | RadioState::Idle => {
|
static RADIO_INTERNAL_CH: Channel<CriticalSectionRawMutex, RadioInput, 8> = Channel::new();
|
||||||
let cmd = commands.receive().await;
|
let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH);
|
||||||
handle_cmd(cmd, &mut machine, &mut lora, responses).await;
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
}
|
crate::debug_blink::set(3);
|
||||||
RadioState::Receiving => {
|
let _ = machine.run().await;
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Init-failure fallback. Answers Ping; everything else errors.
|
/// 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<RadioConfig>,
|
|
||||||
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 ────────────────────────────────────────────────────
|
// ── LoRa helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth {
|
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
|
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;
|
const CAD_MAX_RETRIES: u8 = 10;
|
||||||
|
|
||||||
async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
||||||
let mdltn = modulation_params(lora, cfg)?;
|
let mdltn = modulation_params(lora, cfg)?;
|
||||||
|
|
||||||
if cfg.cad_enabled() {
|
if cfg.cad_enabled() {
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(7);
|
||||||
lora.prepare_for_cad(&mdltn).await?;
|
lora.prepare_for_cad(&mdltn).await?;
|
||||||
for _ in 0..CAD_MAX_RETRIES {
|
for _ in 0..CAD_MAX_RETRIES {
|
||||||
if !lora.cad(&mdltn).await? {
|
if !lora.cad(&mdltn).await? {
|
||||||
@@ -580,7 +687,11 @@ async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Resu
|
|||||||
IQ_INVERTED,
|
IQ_INVERTED,
|
||||||
&mdltn,
|
&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)
|
lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload)
|
||||||
.await?;
|
.await?;
|
||||||
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
|
crate::debug_blink::set(9);
|
||||||
lora.tx().await
|
lora.tx().await
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user