refactor: drive display via hsmc statechart
Replaces the ad-hoc display loop with a declarative hsmc statechart
(Off/On.{Splash.{LearnMore,Info}, Dashboard.{Listening,Transmitting}})
and swaps the Radio→Display channel from a RadioStatus watch to a
typed RadioEvent queue. The radio task now emits discrete mode and
packet events instead of publishing full status snapshots; the
display consumes them through the statechart, which also handles the
5 s splash alternation and 1 s sparkline tick via `on(after …)` /
`on(every …)` transitions.
Also:
- Add hsmc dep and embassy/tokio feature shims (hsmc proc macro
evaluates `cfg(feature = "embassy")` in this crate).
- Fix USB disconnect path in `host/usb.rs` so the DTR branch always
runs on read error (was early-continuing, starving display/radio
reset).
This commit is contained in:
7 files changed
+661
-347
No files matched your search
Generated
+21
@@ -514,6 +514,7 @@ dependencies = [
|
|||||||
"esp-println",
|
"esp-println",
|
||||||
"esp-rtos",
|
"esp-rtos",
|
||||||
"heapless 0.9.2",
|
"heapless 0.9.2",
|
||||||
|
"hsmc",
|
||||||
"lora-phy",
|
"lora-phy",
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
@@ -1398,6 +1399,26 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hsmc"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"embassy-futures",
|
||||||
|
"embassy-sync 0.8.0",
|
||||||
|
"embassy-time",
|
||||||
|
"heapless 0.8.0",
|
||||||
|
"hsmc-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hsmc-macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|||||||
+15
@@ -30,6 +30,7 @@ embassy-usb-driver = "0.2"
|
|||||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||||
heapless = "0.9"
|
heapless = "0.9"
|
||||||
donglora-protocol = { path = "../protocol" }
|
donglora-protocol = { path = "../protocol" }
|
||||||
|
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
|
||||||
lora-phy = "3.0"
|
lora-phy = "3.0"
|
||||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||||
static_cell = "2.1"
|
static_cell = "2.1"
|
||||||
@@ -88,31 +89,43 @@ minijinja = "2.18"
|
|||||||
owo-colors = "4.3"
|
owo-colors = "4.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
# The `embassy` feature gates the async-actions codepath inside the `hsmc`
|
||||||
|
# statechart macro. Its `#[cfg(feature = "embassy")]` attributes are
|
||||||
|
# evaluated in *this* crate's context (a proc-macro cfg quirk), so every
|
||||||
|
# board feature must enable it. `tokio` is declared only to silence the
|
||||||
|
# macro's `#[cfg(feature = "tokio")]` checks.
|
||||||
|
embassy = []
|
||||||
|
tokio = []
|
||||||
heltec_v3 = [
|
heltec_v3 = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
heltec_v3_uart = [
|
heltec_v3_uart = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
heltec_v4 = [
|
heltec_v4 = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
elecrow_thinknode_m2 = [
|
elecrow_thinknode_m2 = [
|
||||||
|
"embassy",
|
||||||
"dep:esp-backtrace",
|
"dep:esp-backtrace",
|
||||||
"dep:esp-hal",
|
"dep:esp-hal",
|
||||||
"dep:esp-println",
|
"dep:esp-println",
|
||||||
"dep:esp-rtos",
|
"dep:esp-rtos",
|
||||||
]
|
]
|
||||||
rak_wisblock_4631 = [
|
rak_wisblock_4631 = [
|
||||||
|
"embassy",
|
||||||
"dep:cortex-m",
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
"cortex-m-rt/set-vtor",
|
"cortex-m-rt/set-vtor",
|
||||||
@@ -123,6 +136,7 @@ rak_wisblock_4631 = [
|
|||||||
"embassy-executor/executor-thread",
|
"embassy-executor/executor-thread",
|
||||||
]
|
]
|
||||||
wio_tracker_l1 = [
|
wio_tracker_l1 = [
|
||||||
|
"embassy",
|
||||||
"dep:cortex-m",
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
"cortex-m-rt/set-vtor",
|
"cortex-m-rt/set-vtor",
|
||||||
@@ -133,6 +147,7 @@ wio_tracker_l1 = [
|
|||||||
"embassy-executor/executor-thread",
|
"embassy-executor/executor-thread",
|
||||||
]
|
]
|
||||||
waveshare_rp2040_lora = [
|
waveshare_rp2040_lora = [
|
||||||
|
"embassy",
|
||||||
"dep:cortex-m",
|
"dep:cortex-m",
|
||||||
"dep:cortex-m-rt",
|
"dep:cortex-m-rt",
|
||||||
"dep:defmt-rtt",
|
"dep:defmt-rtt",
|
||||||
|
|||||||
+21
-35
@@ -1,14 +1,12 @@
|
|||||||
//! Inter-task communication channels.
|
//! Inter-task communication channels.
|
||||||
//!
|
//!
|
||||||
//! Each channel connects exactly two tasks:
|
|
||||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
//! - [`CommandChannel`]: USB → Radio (host commands)
|
||||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
||||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
||||||
//! - [`StatusWatch`]: Radio → Display (observable radio state)
|
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
|
||||||
|
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::channel::Channel;
|
use embassy_sync::channel::Channel;
|
||||||
use embassy_sync::watch::Watch;
|
|
||||||
|
|
||||||
use crate::protocol::{Command, RadioConfig, Response};
|
use crate::protocol::{Command, RadioConfig, Response};
|
||||||
|
|
||||||
@@ -30,39 +28,27 @@ pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
|||||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
||||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
||||||
|
|
||||||
/// Observable radio status for the display task (2 receivers max).
|
/// Radio → downstream typed events. The radio task emits one of these at
|
||||||
/// Display always sees the latest state — intermediate updates may be
|
/// every mode transition and every RF packet. Mode events and data events
|
||||||
/// skipped, which is fine for a status display.
|
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
|
||||||
pub type StatusWatch = Watch<CriticalSectionRawMutex, RadioStatus, 2>;
|
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
|
||||||
|
/// happened* without implying a mode change.
|
||||||
/// Current radio state exposed to observers (e.g. display).
|
|
||||||
#[derive(Debug, Clone, defmt::Format)]
|
#[derive(Debug, Clone, defmt::Format)]
|
||||||
pub struct RadioStatus {
|
pub enum RadioEvent {
|
||||||
pub state: RadioState,
|
/// Radio is now in RX mode.
|
||||||
pub config: Option<RadioConfig>,
|
EnteredRx,
|
||||||
pub rx_count: u32,
|
/// Radio is now in TX mode.
|
||||||
pub tx_count: u32,
|
EnteredTx,
|
||||||
pub last_rssi: Option<i16>,
|
/// Radio is now idle.
|
||||||
pub last_snr: Option<i16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Radio state machine states.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
|
|
||||||
pub enum RadioState {
|
|
||||||
Idle,
|
Idle,
|
||||||
Receiving,
|
/// A packet was received with these link metrics.
|
||||||
Transmitting,
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
|
/// A packet was transmitted successfully.
|
||||||
|
PacketTx,
|
||||||
|
/// `SetConfig` applied a new validated config.
|
||||||
|
ConfigChanged(RadioConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RadioStatus {
|
/// Radio → Display event stream (depth 32: buffer RX bursts while the
|
||||||
fn default() -> Self {
|
/// display drains slowly).
|
||||||
Self {
|
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||||
state: RadioState::Idle,
|
|
||||||
config: None,
|
|
||||||
rx_count: 0,
|
|
||||||
tx_count: 0,
|
|
||||||
last_rssi: None,
|
|
||||||
last_snr: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+474
-198
@@ -1,37 +1,121 @@
|
|||||||
//! OLED display dashboard: radio status, sparkline, splash screen.
|
//! OLED display dashboard, driven by a hsmc statechart.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Display
|
||||||
|
//! ├── Off display blank, LED off
|
||||||
|
//! └── On
|
||||||
|
//! ├── Splash radio idle / no activity
|
||||||
|
//! │ ├── LearnMore (default) QR code + "donglora.com"
|
||||||
|
//! │ └── Info firmware version + MAC
|
||||||
|
//! └── Dashboard
|
||||||
|
//! ├── Listening (default) radio in RX
|
||||||
|
//! └── Transmitting radio in TX
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The two Splash substates alternate on a 5 s timer via `on(after …)`
|
||||||
|
//! transitions pointing at each other. No ticker, no timer task — hsmc
|
||||||
|
//! handles the flip entirely inside the statechart.
|
||||||
|
//!
|
||||||
|
//! `CmdReset` (sent by the host task on USB disconnect) runs a reset
|
||||||
|
//! action and re-enters `Splash`. This is order-idempotent with the
|
||||||
|
//! `RadioIdle` that arrives from the radio at the same moment: whichever
|
||||||
|
//! event the machine sees first, we end up in `Splash.LearnMore` with a
|
||||||
|
//! fresh timer and a cleared run state.
|
||||||
|
//!
|
||||||
|
//! Display subscribes to [`RadioEventChannel`] (published by the radio task)
|
||||||
|
//! and [`DisplayCommandChannel`] (published by the host task). Two trivial
|
||||||
|
//! forwarder futures translate each source into [`DisplayEvent`]s on the
|
||||||
|
//! machine's own channel — the machine is the single source of truth.
|
||||||
|
|
||||||
|
use core::time::Duration;
|
||||||
|
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
use embassy_futures::select::{select3, Either3};
|
use embassy_futures::join::join3;
|
||||||
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
|
use embassy_sync::channel::Channel;
|
||||||
use embassy_time::Timer;
|
use embassy_time::Timer;
|
||||||
use embedded_graphics::draw_target::DrawTarget;
|
use hsmc::statechart;
|
||||||
use embedded_graphics::pixelcolor::BinaryColor;
|
|
||||||
|
|
||||||
use crate::board::{Board, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch};
|
use crate::channel::{
|
||||||
|
DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::RadioConfig;
|
||||||
|
|
||||||
use render::RSSI_HISTORY_LEN;
|
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
||||||
|
|
||||||
const BOARD_NAME: &str = Board::NAME;
|
const BOARD_NAME: &str = Board::NAME;
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history.
|
|
||||||
const SPARK_SLOT_MS: u64 = 1000;
|
|
||||||
|
|
||||||
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
/// Sentinel: no packet received in this slot. Below SX1262 sensitivity
|
||||||
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
/// floor (-120 dBm), so it cannot be confused with a real RSSI value.
|
||||||
const NO_SIGNAL: i16 = -121;
|
const NO_SIGNAL: i16 = -121;
|
||||||
|
|
||||||
struct DisplayState {
|
/// Which dashboard badge is in view (drives the RX/TX indicator only).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Badge {
|
||||||
|
Rx,
|
||||||
|
Tx,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fine-grained events driving the `Display` statechart.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DisplayEvent {
|
||||||
|
CmdOn,
|
||||||
|
CmdOff,
|
||||||
|
CmdReset,
|
||||||
|
/// Radio moved into RX mode.
|
||||||
|
EnteredRx,
|
||||||
|
/// Radio moved into TX mode.
|
||||||
|
EnteredTx,
|
||||||
|
/// Radio returned to Idle.
|
||||||
|
RadioIdle,
|
||||||
|
/// A packet was received (data only — does not imply a mode change).
|
||||||
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
|
/// A packet was transmitted (data only — does not imply a mode change).
|
||||||
|
PacketTx,
|
||||||
|
ConfigChanged(RadioConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RadioEvent> for DisplayEvent {
|
||||||
|
fn from(ev: RadioEvent) -> Self {
|
||||||
|
match ev {
|
||||||
|
RadioEvent::EnteredRx => DisplayEvent::EnteredRx,
|
||||||
|
RadioEvent::EnteredTx => DisplayEvent::EnteredTx,
|
||||||
|
RadioEvent::Idle => DisplayEvent::RadioIdle,
|
||||||
|
RadioEvent::PacketRx { rssi, snr } => DisplayEvent::PacketRx { rssi, snr },
|
||||||
|
RadioEvent::PacketTx => DisplayEvent::PacketTx,
|
||||||
|
RadioEvent::ConfigChanged(cfg) => DisplayEvent::ConfigChanged(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DISPLAY_EVENTS: Channel<CriticalSectionRawMutex, DisplayEvent, 8> = Channel::new();
|
||||||
|
|
||||||
|
// ── Context ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Peripherals the display task owns for its entire lifetime.
|
||||||
|
struct Peripherals {
|
||||||
|
display: DisplayDriver,
|
||||||
|
led: LedDriver,
|
||||||
|
mac_str: heapless::String<18>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything `CmdReset` should clear — the dashboard's "view" of the radio.
|
||||||
|
struct RunState {
|
||||||
rssi_history: [i16; RSSI_HISTORY_LEN],
|
rssi_history: [i16; RSSI_HISTORY_LEN],
|
||||||
tx_history: [bool; RSSI_HISTORY_LEN],
|
tx_history: [bool; RSSI_HISTORY_LEN],
|
||||||
rssi_count: usize,
|
rssi_count: usize,
|
||||||
current_slot_rssi: i16,
|
current_slot_rssi: i16,
|
||||||
current_slot_tx: bool,
|
current_slot_tx: bool,
|
||||||
display_on: bool,
|
config: Option<RadioConfig>,
|
||||||
disconnected: bool,
|
rx_count: u32,
|
||||||
last_status: RadioStatus,
|
tx_count: u32,
|
||||||
|
last_rssi: Option<i16>,
|
||||||
|
last_snr: Option<i16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DisplayState {
|
impl RunState {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN],
|
||||||
@@ -39,25 +123,20 @@ impl DisplayState {
|
|||||||
rssi_count: 0,
|
rssi_count: 0,
|
||||||
current_slot_rssi: NO_SIGNAL,
|
current_slot_rssi: NO_SIGNAL,
|
||||||
current_slot_tx: false,
|
current_slot_tx: false,
|
||||||
display_on: true,
|
config: None,
|
||||||
disconnected: false,
|
rx_count: 0,
|
||||||
last_status: RadioStatus::default(),
|
tx_count: 0,
|
||||||
|
last_rssi: None,
|
||||||
|
last_snr: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an RSSI sample in the current time slot (keep best).
|
|
||||||
fn record_rssi(&mut self, rssi: i16) {
|
fn record_rssi(&mut self, rssi: i16) {
|
||||||
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi {
|
||||||
self.current_slot_rssi = rssi;
|
self.current_slot_rssi = rssi;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark the current time slot as having a transmit.
|
|
||||||
fn record_tx(&mut self) {
|
|
||||||
self.current_slot_tx = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advance to the next time slot, committing the current slot's data.
|
|
||||||
fn advance_slot(&mut self) {
|
fn advance_slot(&mut self) {
|
||||||
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
let idx = self.rssi_count % RSSI_HISTORY_LEN;
|
||||||
self.rssi_history[idx] = self.current_slot_rssi;
|
self.rssi_history[idx] = self.current_slot_rssi;
|
||||||
@@ -66,54 +145,208 @@ impl DisplayState {
|
|||||||
self.current_slot_rssi = NO_SIGNAL;
|
self.current_slot_rssi = NO_SIGNAL;
|
||||||
self.current_slot_tx = false;
|
self.current_slot_tx = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the active dashboard should be shown (RX or TX mode).
|
pub struct DisplayContext {
|
||||||
fn is_active(&self) -> bool {
|
peripherals: Peripherals,
|
||||||
matches!(
|
run: RunState,
|
||||||
self.last_status.state,
|
}
|
||||||
RadioState::Receiving | RadioState::Transmitting
|
|
||||||
)
|
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 };
|
||||||
|
render::info(display, &info);
|
||||||
|
let _ = display.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush_learn_more(&mut self) {
|
||||||
|
let display = &mut self.peripherals.display;
|
||||||
|
render::learn_more(display);
|
||||||
|
let _ = display.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
render::dashboard(
|
||||||
|
display,
|
||||||
|
badge,
|
||||||
|
self.run.config,
|
||||||
|
self.run.rx_count,
|
||||||
|
self.run.tx_count,
|
||||||
|
self.run.last_rssi,
|
||||||
|
self.run.last_snr,
|
||||||
|
&self.run.rssi_history,
|
||||||
|
&self.run.tx_history,
|
||||||
|
self.run.rssi_count,
|
||||||
|
self.run.current_slot_rssi,
|
||||||
|
self.run.current_slot_tx,
|
||||||
|
&info,
|
||||||
|
);
|
||||||
|
let _ = display.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flush_blank(&mut self) {
|
||||||
|
render::blank(&mut self.peripherals.display);
|
||||||
|
let _ = self.peripherals.display.flush().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn led_off(&mut self) {
|
||||||
|
self.peripherals.led.set_rgb(0, 0, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
self.peripherals.led.set_rgb(0, 0, 0).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Statechart ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
statechart! {
|
||||||
|
Display {
|
||||||
|
context: DisplayContext;
|
||||||
|
events: DisplayEvent;
|
||||||
|
default(On);
|
||||||
|
|
||||||
|
state Off {
|
||||||
|
entry: blank_display, turn_led_off;
|
||||||
|
on(CmdOn) => On;
|
||||||
|
// Stay off, but scrub any stale run state so reconnecting
|
||||||
|
// doesn't surface old counters/sparkline.
|
||||||
|
on(CmdReset) => reset_run_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state On {
|
||||||
|
default(Splash);
|
||||||
|
on(CmdOff) => Off;
|
||||||
|
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
||||||
|
// makes either ordering land in the same place. Reset the run
|
||||||
|
// state, turn off any blink-in-progress, and re-enter Splash —
|
||||||
|
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
|
||||||
|
on(CmdReset) => reset_run_state, turn_led_off, Splash;
|
||||||
|
// Config can arrive before the dashboard is up; cache it anywhere.
|
||||||
|
on(ConfigChanged(cfg: RadioConfig)) => save_new_config;
|
||||||
|
|
||||||
|
state Splash {
|
||||||
|
default(LearnMore);
|
||||||
|
on(EnteredRx) => Listening;
|
||||||
|
on(EnteredTx) => Transmitting;
|
||||||
|
|
||||||
|
state LearnMore {
|
||||||
|
entry: paint_learn_more;
|
||||||
|
on(after Duration::from_secs(5)) => Info;
|
||||||
|
}
|
||||||
|
|
||||||
|
state Info {
|
||||||
|
entry: paint_info;
|
||||||
|
on(after Duration::from_secs(5)) => LearnMore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state Dashboard {
|
||||||
|
default(Listening);
|
||||||
|
on(RadioIdle) => Splash;
|
||||||
|
|
||||||
|
// Packet events are pure data + one-shot LED feedback.
|
||||||
|
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||||
|
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||||
|
|
||||||
|
state Listening {
|
||||||
|
entry: paint_listening;
|
||||||
|
on(EnteredTx) => Transmitting;
|
||||||
|
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
|
||||||
|
}
|
||||||
|
|
||||||
|
state Transmitting {
|
||||||
|
entry: paint_transmitting;
|
||||||
|
on(EnteredRx) => Listening;
|
||||||
|
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Map SNR (dB) to LED brightness (4..64). Stronger signal = brighter.
|
/// Map SNR (dB) to LED brightness (4..64). Stronger signal = brighter.
|
||||||
fn snr_brightness(snr: Option<i16>) -> u8 {
|
fn snr_brightness(snr: Option<i16>) -> u8 {
|
||||||
let snr = snr.unwrap_or(-10);
|
let snr = snr.unwrap_or(-10);
|
||||||
let clamped = snr.clamp(-20, 15) as i32;
|
let clamped = snr.clamp(-20, 15) as i32;
|
||||||
// -20 → 4, +15 → 64
|
|
||||||
((clamped + 20) * 60 / 35 + 4) as u8
|
((clamped + 20) * 60 / 35 + 4) as u8
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the appropriate screen for the current state into the display buffer.
|
impl DisplayActions for DisplayActionContext<'_> {
|
||||||
fn render_current(
|
async fn blank_display(&mut self) {
|
||||||
display: &mut impl DrawTarget<Color = BinaryColor>,
|
self.flush_blank().await;
|
||||||
state: &DisplayState,
|
}
|
||||||
board: &render::BoardInfo<'_>,
|
|
||||||
) {
|
async fn turn_led_off(&mut self) {
|
||||||
if state.is_active() {
|
self.led_off().await;
|
||||||
render::dashboard(
|
}
|
||||||
display,
|
|
||||||
&state.last_status,
|
async fn reset_run_state(&mut self) {
|
||||||
&state.rssi_history,
|
self.run = RunState::new();
|
||||||
&state.tx_history,
|
}
|
||||||
state.rssi_count,
|
|
||||||
state.current_slot_rssi,
|
async fn paint_info(&mut self) {
|
||||||
state.current_slot_tx,
|
self.flush_info().await;
|
||||||
board,
|
}
|
||||||
);
|
|
||||||
} else {
|
async fn paint_learn_more(&mut self) {
|
||||||
render::splash(display, board);
|
self.flush_learn_more().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn paint_listening(&mut self) {
|
||||||
|
self.flush_dashboard(Badge::Rx).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn paint_transmitting(&mut self) {
|
||||||
|
self.flush_dashboard(Badge::Tx).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_rx_packet(&mut self, rssi: i16, snr: Option<i16>) {
|
||||||
|
self.run.rx_count = self.run.rx_count.wrapping_add(1);
|
||||||
|
self.run.last_rssi = Some(rssi);
|
||||||
|
self.run.last_snr = snr;
|
||||||
|
self.run.record_rssi(rssi);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_tx_packet(&mut self) {
|
||||||
|
self.run.tx_count = self.run.tx_count.wrapping_add(1);
|
||||||
|
self.run.current_slot_tx = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blink_rx_led(&mut self, _rssi: i16, snr: Option<i16>) {
|
||||||
|
let brightness = snr_brightness(snr);
|
||||||
|
self.led_blip(0, brightness, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blink_tx_led(&mut self) {
|
||||||
|
self.led_blip(32, 0, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_new_config(&mut self, cfg: RadioConfig) {
|
||||||
|
self.run.config = Some(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn advance_sparkline(&mut self) {
|
||||||
|
self.run.advance_slot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task entry point ───────────────────────────────────────────────
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn display_task(
|
pub async fn display_task(
|
||||||
parts: DisplayParts,
|
parts: DisplayParts,
|
||||||
mut led: LedDriver,
|
mut led: LedDriver,
|
||||||
status: &'static StatusWatch,
|
radio_events: &'static RadioEventChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
) {
|
) {
|
||||||
// Format MAC address as "XX:XX:XX:XX:XX:XX"
|
|
||||||
let mut mac_str: heapless::String<18> = heapless::String::new();
|
let mut mac_str: heapless::String<18> = heapless::String::new();
|
||||||
let m = Board::mac_address();
|
let m = Board::mac_address();
|
||||||
let _ = core::fmt::Write::write_fmt(
|
let _ = core::fmt::Write::write_fmt(
|
||||||
@@ -124,13 +357,7 @@ pub async fn display_task(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let board_info = render::BoardInfo {
|
let Some(display) = board::create_display(parts.i2c).await else {
|
||||||
name: BOARD_NAME,
|
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
|
||||||
mac: &mac_str,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(mut display) = crate::board::create_display(parts.i2c).await else {
|
|
||||||
defmt::error!("display init failed, running LED-only loop");
|
defmt::error!("display init failed, running LED-only loop");
|
||||||
loop {
|
loop {
|
||||||
match display_commands.receive().await {
|
match display_commands.receive().await {
|
||||||
@@ -142,97 +369,44 @@ pub async fn display_task(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut state = DisplayState::new();
|
let ctx = DisplayContext {
|
||||||
let Some(mut receiver) = status.receiver() else {
|
peripherals: Peripherals {
|
||||||
defmt::error!("no watch receiver available for display");
|
display,
|
||||||
return;
|
led,
|
||||||
|
mac_str,
|
||||||
|
},
|
||||||
|
run: RunState::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show splash/waiting screen
|
let mut machine = Display::new(ctx, &DISPLAY_EVENTS);
|
||||||
render::splash(&mut display, &board_info);
|
let sender = DisplaySender::from_channel(&DISPLAY_EVENTS);
|
||||||
let _ = display.flush().await;
|
|
||||||
|
|
||||||
loop {
|
let run = async {
|
||||||
match select3(
|
let _ = machine.run().await;
|
||||||
receiver.changed(),
|
};
|
||||||
display_commands.receive(),
|
|
||||||
Timer::after_millis(SPARK_SLOT_MS),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Either3::First(radio_status) => {
|
|
||||||
if state.disconnected {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rx_packet = radio_status.rx_count != state.last_status.rx_count;
|
|
||||||
let tx_packet = radio_status.tx_count != state.last_status.tx_count;
|
|
||||||
|
|
||||||
if let Some(rssi) = radio_status.last_rssi {
|
let command_forwarder = async {
|
||||||
if rx_packet {
|
loop {
|
||||||
state.record_rssi(rssi);
|
let ev = match display_commands.receive().await {
|
||||||
}
|
DisplayCommand::On => DisplayEvent::CmdOn,
|
||||||
}
|
DisplayCommand::Off => DisplayEvent::CmdOff,
|
||||||
if tx_packet {
|
DisplayCommand::Reset => DisplayEvent::CmdReset,
|
||||||
state.record_tx();
|
};
|
||||||
}
|
let _ = sender.send(ev).await;
|
||||||
let last_snr = radio_status.last_snr;
|
|
||||||
state.last_status = radio_status;
|
|
||||||
|
|
||||||
if state.display_on {
|
|
||||||
render_current(&mut display, &state, &board_info);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
|
|
||||||
// Brief LED blink: red on TX, green scaled by SNR on RX
|
|
||||||
if tx_packet {
|
|
||||||
led.set_rgb(32, 0, 0).await;
|
|
||||||
Timer::after_millis(50).await;
|
|
||||||
led.set_rgb(0, 0, 0).await;
|
|
||||||
} else if rx_packet {
|
|
||||||
let b = snr_brightness(last_snr);
|
|
||||||
led.set_rgb(0, b, 0).await;
|
|
||||||
Timer::after_millis(50).await;
|
|
||||||
led.set_rgb(0, 0, 0).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Either3::Second(cmd) => match cmd {
|
|
||||||
DisplayCommand::Off => {
|
|
||||||
state.disconnected = false;
|
|
||||||
state.display_on = false;
|
|
||||||
render::blank(&mut display);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
led.set_rgb(0, 0, 0).await;
|
|
||||||
}
|
|
||||||
DisplayCommand::On => {
|
|
||||||
state.disconnected = false;
|
|
||||||
state.display_on = true;
|
|
||||||
if let Some(s) = receiver.try_get() {
|
|
||||||
state.last_status = s;
|
|
||||||
}
|
|
||||||
render_current(&mut display, &state, &board_info);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
}
|
|
||||||
DisplayCommand::Reset => {
|
|
||||||
state = DisplayState::new();
|
|
||||||
state.disconnected = true;
|
|
||||||
render::splash(&mut display, &board_info);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
led.set_rgb(0, 0, 0).await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Either3::Third(()) => {
|
|
||||||
// Timer tick: advance sparkline slot
|
|
||||||
state.advance_slot();
|
|
||||||
if state.display_on && state.is_active() {
|
|
||||||
render_current(&mut display, &state, &board_info);
|
|
||||||
let _ = display.flush().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
let radio_event_forwarder = async {
|
||||||
|
loop {
|
||||||
|
let ev = radio_events.receive().await;
|
||||||
|
let _ = sender.send(ev.into()).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
join3(run, command_forwarder, radio_event_forwarder).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ───────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
mod render {
|
mod render {
|
||||||
use core::fmt::Write;
|
use core::fmt::Write;
|
||||||
@@ -246,35 +420,106 @@ mod render {
|
|||||||
use embedded_graphics::text::{Alignment, Text};
|
use embedded_graphics::text::{Alignment, Text};
|
||||||
use heapless::String;
|
use heapless::String;
|
||||||
|
|
||||||
use crate::channel::{RadioState, RadioStatus};
|
use super::Badge;
|
||||||
use crate::protocol::Bandwidth;
|
use crate::protocol::{Bandwidth, RadioConfig};
|
||||||
|
|
||||||
/// RSSI ring buffer length — supports displays up to 256px wide (2px per bar).
|
|
||||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||||
|
|
||||||
// Font metrics (FONT_6X10)
|
|
||||||
const CHAR_W: i32 = 6;
|
const CHAR_W: i32 = 6;
|
||||||
const FONT_H: i32 = 10;
|
const FONT_H: i32 = 10;
|
||||||
|
const MODE_BOX_W: i32 = 2 * CHAR_W + 2;
|
||||||
|
|
||||||
// Mode box: 2 chars + 1px padding each side
|
|
||||||
const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px
|
|
||||||
|
|
||||||
// RSSI mapping range (dBm)
|
|
||||||
const RSSI_MIN: i16 = -120;
|
const RSSI_MIN: i16 = -120;
|
||||||
const RSSI_MAX: i16 = 0;
|
const RSSI_MAX: i16 = 0;
|
||||||
|
|
||||||
/// Static board identity info for display rendering.
|
/// Hand-authored 64 × 64 pixel-perfect QR bitmap for the splash
|
||||||
|
/// "learn more" screen. 1 bit/pixel, MSB-first, row-major. Bit `1` is
|
||||||
|
/// "light" (OLED pixel on), bit `0` is "dark" (OLED pixel off), so
|
||||||
|
/// when drawn with `BinaryColor::On` = lit, the QR appears with the
|
||||||
|
/// 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,
|
||||||
|
];
|
||||||
|
|
||||||
pub struct BoardInfo<'a> {
|
pub struct BoardInfo<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
pub version: &'a str,
|
pub version: &'a str,
|
||||||
pub mac: &'a str,
|
pub mac: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the active dashboard (shown when radio is in RX or TX mode).
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn dashboard(
|
pub fn dashboard(
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||||
status: &RadioStatus,
|
badge: Badge,
|
||||||
|
config: Option<RadioConfig>,
|
||||||
|
rx_count: u32,
|
||||||
|
tx_count: u32,
|
||||||
|
last_rssi: Option<i16>,
|
||||||
|
last_snr: Option<i16>,
|
||||||
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
rssi_history: &[i16; RSSI_HISTORY_LEN],
|
||||||
tx_history: &[bool; RSSI_HISTORY_LEN],
|
tx_history: &[bool; RSSI_HISTORY_LEN],
|
||||||
rssi_count: usize,
|
rssi_count: usize,
|
||||||
@@ -288,10 +533,10 @@ mod render {
|
|||||||
let title_x = MODE_BOX_W + 1;
|
let title_x = MODE_BOX_W + 1;
|
||||||
let title_w = w - title_x;
|
let title_w = w - title_x;
|
||||||
let header_h = 2 * FONT_H;
|
let header_h = 2 * FONT_H;
|
||||||
let sep1_y = header_h + 3; // 3px gap below header text (clears descenders)
|
let sep1_y = header_h + 3;
|
||||||
let info_y = sep1_y + 1; // 1px line, info text tight below separator
|
let info_y = sep1_y + 1;
|
||||||
let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders)
|
let sep2_y = info_y + 2 * FONT_H + 3;
|
||||||
let spark_top = sep2_y + 2; // 1px line + 1px gap above graph
|
let spark_top = sep2_y + 2;
|
||||||
let spark_h = h - spark_top;
|
let spark_h = h - spark_top;
|
||||||
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN);
|
||||||
|
|
||||||
@@ -299,14 +544,12 @@ mod render {
|
|||||||
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
|
||||||
let mut buf: String<32> = String::new();
|
let mut buf: String<32> = String::new();
|
||||||
|
|
||||||
// ── Mode box (left column, 2 rows tall) ─────────────────────────
|
|
||||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||||
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off);
|
||||||
inv_style.set_background_color(Some(BinaryColor::On));
|
inv_style.set_background_color(Some(BinaryColor::On));
|
||||||
|
|
||||||
match status.state {
|
match badge {
|
||||||
RadioState::Receiving => {
|
Badge::Rx => {
|
||||||
// "RX" inverted on row 0
|
|
||||||
Rectangle::new(
|
Rectangle::new(
|
||||||
Point::new(0, 0),
|
Point::new(0, 0),
|
||||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||||
@@ -318,8 +561,7 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
RadioState::Transmitting => {
|
Badge::Tx => {
|
||||||
// "TX" inverted on row 1
|
|
||||||
Rectangle::new(
|
Rectangle::new(
|
||||||
Point::new(0, FONT_H),
|
Point::new(0, FONT_H),
|
||||||
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
Size::new(MODE_BOX_W as u32, FONT_H as u32),
|
||||||
@@ -331,19 +573,15 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
RadioState::Idle => {} // Not shown on active screen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical separator between mode box and title box
|
|
||||||
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y))
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// ── Title box (right of mode box) ───────────────────────────────
|
|
||||||
let title_center_x = title_x + title_w / 2;
|
let title_center_x = title_x + title_w / 2;
|
||||||
|
|
||||||
// Row 0: "DongLoRa v{version}"
|
|
||||||
buf.clear();
|
buf.clear();
|
||||||
let _ = write!(buf, "DongLoRa v{}", board.version);
|
let _ = write!(buf, "DongLoRa v{}", board.version);
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
@@ -355,8 +593,7 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Row 1: radio settings "{freq}/{bw}/{sf}/{cr}"
|
if let Some(cfg) = config {
|
||||||
if let Some(cfg) = status.config {
|
|
||||||
buf.clear();
|
buf.clear();
|
||||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||||
@@ -387,22 +624,19 @@ mod render {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── First separator line ────────────────────────────────────────
|
|
||||||
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y))
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// ── Info rows (centered) ────────────────────────────────────────
|
|
||||||
let center_x = w / 2;
|
let center_x = w / 2;
|
||||||
|
|
||||||
// Row 2: packet counters
|
|
||||||
buf.clear();
|
buf.clear();
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
buf,
|
buf,
|
||||||
"RX:{} TX:{}",
|
"RX:{} TX:{}",
|
||||||
compact_count(status.rx_count),
|
compact_count(rx_count),
|
||||||
compact_count(status.tx_count)
|
compact_count(tx_count)
|
||||||
);
|
);
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
&buf,
|
&buf,
|
||||||
@@ -413,9 +647,8 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Row 3: RSSI + SNR
|
|
||||||
buf.clear();
|
buf.clear();
|
||||||
match (status.last_rssi, status.last_snr) {
|
match (last_rssi, last_snr) {
|
||||||
(Some(rssi), Some(snr)) => {
|
(Some(rssi), Some(snr)) => {
|
||||||
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr);
|
||||||
}
|
}
|
||||||
@@ -435,13 +668,11 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// ── Second separator line ───────────────────────────────────────
|
|
||||||
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y))
|
||||||
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
|
||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// ── Sparkline bar graph ─────────────────────────────────────────
|
|
||||||
if spark_h > 0 {
|
if spark_h > 0 {
|
||||||
rssi_sparkline(
|
rssi_sparkline(
|
||||||
target,
|
target,
|
||||||
@@ -457,8 +688,7 @@ mod render {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the splash/waiting screen (shown when idle or no config).
|
pub fn info(target: &mut impl DrawTarget<Color = BinaryColor>, board: &BoardInfo) {
|
||||||
pub fn splash(target: &mut impl DrawTarget<Color = BinaryColor>, board: &BoardInfo) {
|
|
||||||
let bb = target.bounding_box();
|
let bb = target.bounding_box();
|
||||||
let w = bb.size.width as i32;
|
let w = bb.size.width as i32;
|
||||||
|
|
||||||
@@ -469,7 +699,6 @@ mod render {
|
|||||||
|
|
||||||
let center_x = w / 2;
|
let center_x = w / 2;
|
||||||
|
|
||||||
// Row 1: "DongLoRa" bold left, version small right
|
|
||||||
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
Text::new("DongLoRa", Point::new(4, 15), title_style)
|
||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
@@ -479,7 +708,6 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Row 2: board name
|
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
board.name,
|
board.name,
|
||||||
Point::new(center_x, 28),
|
Point::new(center_x, 28),
|
||||||
@@ -489,7 +717,6 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Row 3: MAC address
|
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
board.mac,
|
board.mac,
|
||||||
Point::new(center_x, 41),
|
Point::new(center_x, 41),
|
||||||
@@ -499,7 +726,6 @@ mod render {
|
|||||||
.draw(target)
|
.draw(target)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
// Row 4: status
|
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
"Waiting for host...",
|
"Waiting for host...",
|
||||||
Point::new(center_x, 54),
|
Point::new(center_x, 54),
|
||||||
@@ -510,7 +736,68 @@ mod render {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a u32 count compactly for display.
|
/// Alternate splash screen: 64 × 64 pixel-perfect QR bitmap on the
|
||||||
|
/// right (with its own quiet zone baked in), centered text column on
|
||||||
|
/// the left inviting the user to visit the URL.
|
||||||
|
pub fn learn_more(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||||
|
use embedded_graphics::image::{Image, ImageRaw};
|
||||||
|
use embedded_graphics::mono_font::ascii::{FONT_5X8, FONT_7X14_BOLD};
|
||||||
|
|
||||||
|
let bb = target.bounding_box();
|
||||||
|
let w = bb.size.width as i32;
|
||||||
|
|
||||||
|
let _ = target.clear(BinaryColor::Off);
|
||||||
|
|
||||||
|
// ── QR code: flush to the right edge, 1:1 pixel rendering ───
|
||||||
|
let qr_x = w - QR_WIDTH_PX as i32;
|
||||||
|
let qr_raw: ImageRaw<BinaryColor> = ImageRaw::new(&QR_BITMAP, QR_WIDTH_PX);
|
||||||
|
Image::new(&qr_raw, Point::new(qr_x, 0))
|
||||||
|
.draw(target)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// ── Text column (everything left of the QR) ─────────────────
|
||||||
|
let col_w = qr_x;
|
||||||
|
let col_center = col_w / 2;
|
||||||
|
|
||||||
|
let title_style = MonoTextStyle::new(&FONT_7X14_BOLD, BinaryColor::On);
|
||||||
|
let text_style = MonoTextStyle::new(&FONT_5X8, BinaryColor::On);
|
||||||
|
|
||||||
|
// Layout on a 64 px display:
|
||||||
|
// blank
|
||||||
|
// DongLoRa baseline 22
|
||||||
|
// blank
|
||||||
|
// Learn more: baseline 42
|
||||||
|
// donglora.com baseline 52
|
||||||
|
// blank
|
||||||
|
|
||||||
|
Text::with_alignment(
|
||||||
|
"DongLoRa",
|
||||||
|
Point::new(col_center, 22),
|
||||||
|
title_style,
|
||||||
|
Alignment::Center,
|
||||||
|
)
|
||||||
|
.draw(target)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Text::with_alignment(
|
||||||
|
"Learn more:",
|
||||||
|
Point::new(col_center, 42),
|
||||||
|
text_style,
|
||||||
|
Alignment::Center,
|
||||||
|
)
|
||||||
|
.draw(target)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Text::with_alignment(
|
||||||
|
"donglora.com",
|
||||||
|
Point::new(col_center, 52),
|
||||||
|
text_style,
|
||||||
|
Alignment::Center,
|
||||||
|
)
|
||||||
|
.draw(target)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
fn compact_count(n: u32) -> String<10> {
|
fn compact_count(n: u32) -> String<10> {
|
||||||
let mut s: String<10> = String::new();
|
let mut s: String<10> = String::new();
|
||||||
if n > 9_999_999 {
|
if n > 9_999_999 {
|
||||||
@@ -523,10 +810,6 @@ mod render {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the RSSI history as a bar-chart sparkline.
|
|
||||||
///
|
|
||||||
/// The current (uncommitted) slot is rendered at the rightmost position
|
|
||||||
/// so that incoming packets appear on the graph immediately.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn rssi_sparkline(
|
fn rssi_sparkline(
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||||
@@ -554,11 +837,8 @@ mod render {
|
|||||||
|
|
||||||
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
let fill = PrimitiveStyle::with_fill(BinaryColor::On);
|
||||||
|
|
||||||
// Draw committed history
|
|
||||||
for i in 0..hist_slots {
|
for i in 0..hist_slots {
|
||||||
let idx = if count <= RSSI_HISTORY_LEN {
|
let idx = if count <= RSSI_HISTORY_LEN {
|
||||||
// Buffer hasn't wrapped: slots occupy indices 0..committed-1.
|
|
||||||
// Skip oldest entries that don't fit on screen.
|
|
||||||
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
let effective_bars = if live { visible_bars - 1 } else { visible_bars };
|
||||||
i + committed.saturating_sub(effective_bars)
|
i + committed.saturating_sub(effective_bars)
|
||||||
} else {
|
} else {
|
||||||
@@ -579,7 +859,6 @@ mod render {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw live (current) bar at the rightmost position
|
|
||||||
if live {
|
if live {
|
||||||
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) {
|
||||||
let x = (visible_bars - 1) as i32 * 2;
|
let x = (visible_bars - 1) as i32 * 2;
|
||||||
@@ -588,7 +867,6 @@ mod render {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the pixel height for a sparkline bar, or None to skip.
|
|
||||||
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
|
||||||
let h = if rssi <= RSSI_MIN {
|
let h = if rssi <= RSSI_MIN {
|
||||||
if is_tx {
|
if is_tx {
|
||||||
@@ -607,7 +885,6 @@ mod render {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a single sparkline bar (solid for RX, dotted for TX).
|
|
||||||
fn draw_bar(
|
fn draw_bar(
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||||
x: i32,
|
x: i32,
|
||||||
@@ -635,7 +912,6 @@ mod render {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the display (display-off state).
|
|
||||||
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
pub fn blank(target: &mut impl DrawTarget<Color = BinaryColor>) {
|
||||||
let _ = target.clear(BinaryColor::Off);
|
let _ = target.clear(BinaryColor::Off);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -97,17 +97,8 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either3::First(result) => {
|
Either3::First(Ok(0)) => {}
|
||||||
let n = match result {
|
Either3::First(Ok(n)) => {
|
||||||
Ok(0) => continue,
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(_) => {
|
|
||||||
decoder.reset();
|
|
||||||
embassy_time::Timer::after_millis(100).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
let mut cmds = heapless::Vec::<_, 4>::new();
|
||||||
decoder.feed(&read_buf[..n], |cmd| {
|
decoder.feed(&read_buf[..n], |cmd| {
|
||||||
let _ = cmds.push(cmd);
|
let _ = cmds.push(cmd);
|
||||||
@@ -124,6 +115,15 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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`.
|
||||||
|
decoder.reset();
|
||||||
|
embassy_time::Timer::after_millis(100).await;
|
||||||
|
}
|
||||||
Either3::Second(response) => {
|
Either3::Second(response) => {
|
||||||
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)
|
||||||
|
|||||||
+5
-4
@@ -37,7 +37,7 @@ use embassy_executor::Spawner;
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::board::LoRaBoard;
|
use crate::board::LoRaBoard;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch};
|
use crate::channel::{CommandChannel, DisplayCommandChannel, RadioEventChannel, ResponseChannel};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
@@ -55,7 +55,7 @@ static COMMANDS: CommandChannel = CommandChannel::new();
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static STATUS: StatusWatch = StatusWatch::new();
|
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||||
|
|
||||||
@@ -80,7 +80,8 @@ async fn run(spawner: Spawner) {
|
|||||||
let parts = board.into_parts();
|
let parts = board.into_parts();
|
||||||
|
|
||||||
spawner.spawn(
|
spawner.spawn(
|
||||||
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS).expect("spawn radio_task"),
|
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &RADIO_EVENTS)
|
||||||
|
.expect("spawn radio_task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
spawner.spawn(
|
spawner.spawn(
|
||||||
@@ -98,7 +99,7 @@ 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
|
#[allow(clippy::unit_arg)] // LedDriver is () for boards without LEDs
|
||||||
spawner.spawn(
|
spawner.spawn(
|
||||||
display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS)
|
display::display_task(dp, parts.led, &RADIO_EVENTS, &DISPLAY_COMMANDS)
|
||||||
.expect("spawn display_task"),
|
.expect("spawn display_task"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-99
@@ -1,8 +1,9 @@
|
|||||||
//! LoRa radio task: SX1262 state machine driven by host commands.
|
//! LoRa radio task: SX1262 state machine driven by host commands.
|
||||||
//!
|
//!
|
||||||
//! Owns the radio peripheral exclusively. Receives [`Command`]s from the USB
|
//! Owns the radio peripheral exclusively. Receives [`Command`]s from the
|
||||||
//! task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s back.
|
//! host task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s
|
||||||
//! Publishes [`RadioStatus`] to the display task via a watch channel.
|
//! back. Publishes typed [`RadioEvent`]s to the display task at every state
|
||||||
|
//! transition and RF event — no shared status snapshot, no diffing consumer.
|
||||||
//!
|
//!
|
||||||
//! # State machine
|
//! # State machine
|
||||||
//!
|
//!
|
||||||
@@ -11,11 +12,6 @@
|
|||||||
//! │ │
|
//! │ │
|
||||||
//! └──Transmit──► Transmitting ──TxDone──► (previous state)
|
//! └──Transmit──► Transmitting ──TxDone──► (previous state)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
|
||||||
//! # Invariants
|
|
||||||
//!
|
|
||||||
//! - This task never panics. All errors are reported to the host or logged.
|
|
||||||
//! - Config is validated before use (see [`RadioConfig::validate`]).
|
|
||||||
|
|
||||||
use defmt::{error, info, warn};
|
use defmt::{error, info, warn};
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
@@ -25,40 +21,58 @@ 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};
|
||||||
use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch};
|
use crate::channel::{CommandChannel, RadioEvent, RadioEventChannel, ResponseChannel};
|
||||||
use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response};
|
use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response};
|
||||||
|
|
||||||
const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD;
|
const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD;
|
||||||
// TX power range comes from the board trait: Board::TX_POWER_RANGE
|
|
||||||
|
|
||||||
type Radio = LoRa<RadioDriver, Delay>;
|
type Radio = LoRa<RadioDriver, Delay>;
|
||||||
|
|
||||||
// ── Fixed LoRa radio parameters ─────────────────────────────────────
|
// ── Fixed LoRa radio parameters ─────────────────────────────────────
|
||||||
//
|
|
||||||
// These are intentionally not host-configurable. They are sensible
|
|
||||||
// defaults for a general-purpose LoRa pipe. Making them configurable
|
|
||||||
// would add protocol complexity for marginal benefit.
|
|
||||||
|
|
||||||
/// Explicit header mode (variable-length packets).
|
|
||||||
const IMPLICIT_HEADER: bool = false;
|
const IMPLICIT_HEADER: bool = false;
|
||||||
|
|
||||||
/// Enable CRC on received/transmitted packets.
|
|
||||||
const CRC_ON: bool = true;
|
const CRC_ON: bool = true;
|
||||||
|
|
||||||
/// Standard IQ polarity (not inverted).
|
|
||||||
const IQ_INVERTED: bool = false;
|
const IQ_INVERTED: bool = false;
|
||||||
|
|
||||||
// ── Task entry point ────────────────────────────────────────────────
|
/// Internal mode, just for tracking what the radio is doing right now.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
Idle,
|
||||||
|
Receiving,
|
||||||
|
Transmitting,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime state not exposed outside this module.
|
||||||
|
struct RadioState {
|
||||||
|
mode: Mode,
|
||||||
|
config: Option<RadioConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: Mode::Idle,
|
||||||
|
config: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a radio event to the display subscriber. Best-effort: if the
|
||||||
|
/// downstream queue is full we'd rather drop a UI update than stall the
|
||||||
|
/// radio task.
|
||||||
|
fn emit(events: &RadioEventChannel, ev: RadioEvent) {
|
||||||
|
if events.try_send(ev).is_err() {
|
||||||
|
warn!("radio event dropped: downstream queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn radio_task(
|
pub async fn radio_task(
|
||||||
parts: RadioParts,
|
parts: RadioParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
responses: &'static ResponseChannel,
|
||||||
status: &'static StatusWatch,
|
events: &'static RadioEventChannel,
|
||||||
) {
|
) {
|
||||||
let mut state = RadioStatus::default();
|
let mut state = RadioState::new();
|
||||||
status.sender().send(state.clone());
|
|
||||||
|
|
||||||
let mut lora = match LoRa::new(parts.driver, false, parts.delay).await {
|
let mut lora = match LoRa::new(parts.driver, false, parts.delay).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -85,14 +99,15 @@ pub async fn radio_task(
|
|||||||
let mut rx_buf = [0u8; MAX_PAYLOAD];
|
let mut rx_buf = [0u8; MAX_PAYLOAD];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if state.state == RadioState::Receiving {
|
if state.mode == Mode::Receiving {
|
||||||
// Defensive: config must be Some when Receiving. If not, recover.
|
// `Mode::Receiving` is only set after `start_rx` succeeded with
|
||||||
|
// a validated config; we rely on that here.
|
||||||
let cfg = match state.config {
|
let cfg = match state.config {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
warn!("BUG: receiving without config, returning to idle");
|
warn!("BUG: receiving without config, returning to idle");
|
||||||
state.state = RadioState::Idle;
|
state.mode = Mode::Idle;
|
||||||
status.sender().send(state.clone());
|
emit(events, RadioEvent::Idle);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -100,17 +115,19 @@ pub async fn radio_task(
|
|||||||
match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await {
|
match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await {
|
||||||
Either::First(rx_result) => match rx_result {
|
Either::First(rx_result) => match rx_result {
|
||||||
Ok((len, pkt_status)) => {
|
Ok((len, pkt_status)) => {
|
||||||
state.rx_count = state.rx_count.wrapping_add(1);
|
emit(
|
||||||
state.last_rssi = Some(pkt_status.rssi);
|
events,
|
||||||
state.last_snr = Some(pkt_status.snr);
|
RadioEvent::PacketRx {
|
||||||
status.sender().send(state.clone());
|
rssi: pkt_status.rssi,
|
||||||
|
snr: Some(pkt_status.snr),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let copy_len = (len as usize).min(MAX_PAYLOAD);
|
let copy_len = (len as usize).min(MAX_PAYLOAD);
|
||||||
if (len as usize) > copy_len {
|
if (len as usize) > copy_len {
|
||||||
warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD);
|
warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD);
|
||||||
}
|
}
|
||||||
let mut payload = heapless::Vec::new();
|
let mut payload = heapless::Vec::new();
|
||||||
// copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail.
|
|
||||||
let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
|
let _ = payload.extend_from_slice(&rx_buf[..copy_len]);
|
||||||
|
|
||||||
if responses
|
if responses
|
||||||
@@ -126,39 +143,39 @@ pub async fn radio_task(
|
|||||||
|
|
||||||
if let Err(e) = start_rx(&mut lora, &cfg).await {
|
if let Err(e) = start_rx(&mut lora, &cfg).await {
|
||||||
warn!("restart RX failed: {}", e);
|
warn!("restart RX failed: {}", e);
|
||||||
state.state = RadioState::Idle;
|
state.mode = Mode::Idle;
|
||||||
status.sender().send(state.clone());
|
emit(events, RadioEvent::Idle);
|
||||||
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("RX error: {}", e);
|
warn!("RX error: {}", e);
|
||||||
if start_rx(&mut lora, &cfg).await.is_err() {
|
if start_rx(&mut lora, &cfg).await.is_err() {
|
||||||
state.state = RadioState::Idle;
|
state.mode = Mode::Idle;
|
||||||
status.sender().send(state.clone());
|
emit(events, RadioEvent::Idle);
|
||||||
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
let _ = responses.try_send(Response::Error(ErrorCode::RadioBusy));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Either::Second(cmd) => {
|
Either::Second(cmd) => {
|
||||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
handle_cmd(cmd, &mut lora, &mut state, responses, events).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let cmd = commands.receive().await;
|
let cmd = commands.receive().await;
|
||||||
handle_cmd(cmd, &mut lora, &mut state, responses, status).await;
|
handle_cmd(cmd, &mut lora, &mut state, responses, events).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command handler ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn handle_cmd(
|
async fn handle_cmd(
|
||||||
cmd: Command,
|
cmd: Command,
|
||||||
lora: &mut Radio,
|
lora: &mut Radio,
|
||||||
state: &mut RadioStatus,
|
state: &mut RadioState,
|
||||||
responses: &ResponseChannel,
|
responses: &ResponseChannel,
|
||||||
status: &StatusWatch,
|
events: &RadioEventChannel,
|
||||||
) {
|
) {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::Ping => {
|
Command::Ping => {
|
||||||
@@ -180,9 +197,9 @@ async fn handle_cmd(
|
|||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
.send(Response::Error(ErrorCode::InvalidConfig))
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
// Resolve TX_POWER_MAX sentinel to board's actual max
|
let resolved = cfg.resolve(Board::TX_POWER_RANGE);
|
||||||
state.config = Some(cfg.resolve(Board::TX_POWER_RANGE));
|
state.config = Some(resolved);
|
||||||
status.sender().send(state.clone());
|
emit(events, RadioEvent::ConfigChanged(resolved));
|
||||||
responses.send(Response::Ok).await;
|
responses.send(Response::Ok).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,8 +207,8 @@ async fn handle_cmd(
|
|||||||
if let Some(cfg) = state.config {
|
if let Some(cfg) = state.config {
|
||||||
match start_rx(lora, &cfg).await {
|
match start_rx(lora, &cfg).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
state.state = RadioState::Receiving;
|
state.mode = Mode::Receiving;
|
||||||
status.sender().send(state.clone());
|
emit(events, RadioEvent::EnteredRx);
|
||||||
responses.send(Response::Ok).await;
|
responses.send(Response::Ok).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -208,13 +225,11 @@ async fn handle_cmd(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::StopRx => {
|
Command::StopRx => {
|
||||||
// Best-effort: if standby fails, radio is in unknown state
|
|
||||||
// but there's nothing useful we can do except continue.
|
|
||||||
let _ = lora.enter_standby().await;
|
let _ = lora.enter_standby().await;
|
||||||
state.state = RadioState::Idle;
|
if state.mode != Mode::Idle {
|
||||||
status.sender().send(state.clone());
|
state.mode = Mode::Idle;
|
||||||
// try_send: StopRx may be sent internally on USB disconnect
|
emit(events, RadioEvent::Idle);
|
||||||
// when the host isn't draining responses.
|
}
|
||||||
let _ = responses.try_send(Response::Ok);
|
let _ = responses.try_send(Response::Ok);
|
||||||
}
|
}
|
||||||
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {}
|
Command::DisplayOn | Command::DisplayOff | Command::GetMac => {}
|
||||||
@@ -222,50 +237,54 @@ async fn handle_cmd(
|
|||||||
let tx_config = config
|
let tx_config = config
|
||||||
.map(|c| c.resolve(Board::TX_POWER_RANGE))
|
.map(|c| c.resolve(Board::TX_POWER_RANGE))
|
||||||
.or(state.config);
|
.or(state.config);
|
||||||
if let Some(cfg) = tx_config {
|
let Some(cfg) = tx_config else {
|
||||||
if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) {
|
|
||||||
warn!("TX config rejected: {}", reason);
|
|
||||||
responses
|
|
||||||
.send(Response::Error(ErrorCode::InvalidConfig))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let was_receiving = state.state == RadioState::Receiving;
|
|
||||||
state.state = RadioState::Transmitting;
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
|
|
||||||
match do_tx(lora, &cfg, &payload).await {
|
|
||||||
Ok(()) => {
|
|
||||||
state.tx_count = state.tx_count.wrapping_add(1);
|
|
||||||
responses.send(Response::TxDone).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("TX failed: {}", e);
|
|
||||||
responses.send(Response::Error(ErrorCode::TxTimeout)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore previous state. If we were receiving, restart RX
|
|
||||||
// using the stored config — NOT the per-packet TX override.
|
|
||||||
state.state = if was_receiving {
|
|
||||||
// state.config is always Some when was_receiving (invariant).
|
|
||||||
let rx_cfg = state.config.unwrap_or(cfg);
|
|
||||||
match start_rx(lora, &rx_cfg).await {
|
|
||||||
Ok(()) => RadioState::Receiving,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("post-TX RX restart failed: {}", e);
|
|
||||||
RadioState::Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RadioState::Idle
|
|
||||||
};
|
|
||||||
status.sender().send(state.clone());
|
|
||||||
} else {
|
|
||||||
responses
|
responses
|
||||||
.send(Response::Error(ErrorCode::NotConfigured))
|
.send(Response::Error(ErrorCode::NotConfigured))
|
||||||
.await;
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let was_receiving = state.mode == Mode::Receiving;
|
||||||
|
state.mode = Mode::Transmitting;
|
||||||
|
emit(events, RadioEvent::EnteredTx);
|
||||||
|
|
||||||
|
let tx_result = do_tx(lora, &cfg, &payload).await;
|
||||||
|
match tx_result {
|
||||||
|
Ok(()) => {
|
||||||
|
emit(events, RadioEvent::PacketTx);
|
||||||
|
responses.send(Response::TxDone).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("TX failed: {}", e);
|
||||||
|
responses.send(Response::Error(ErrorCode::TxTimeout)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore previous mode — and announce the transition explicitly
|
||||||
|
// so downstream consumers don't have to infer it.
|
||||||
|
if was_receiving {
|
||||||
|
let rx_cfg = state.config.unwrap_or(cfg);
|
||||||
|
match start_rx(lora, &rx_cfg).await {
|
||||||
|
Ok(()) => {
|
||||||
|
state.mode = Mode::Receiving;
|
||||||
|
emit(events, RadioEvent::EnteredRx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("post-TX RX restart failed: {}", e);
|
||||||
|
state.mode = Mode::Idle;
|
||||||
|
emit(events, RadioEvent::Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.mode = Mode::Idle;
|
||||||
|
emit(events, RadioEvent::Idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +340,6 @@ fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create modulation parameters from a validated config.
|
|
||||||
fn modulation_params(
|
fn modulation_params(
|
||||||
lora: &mut Radio,
|
lora: &mut Radio,
|
||||||
cfg: &RadioConfig,
|
cfg: &RadioConfig,
|
||||||
@@ -359,20 +377,17 @@ async fn rx_once(
|
|||||||
lora.rx(&pkt, buf).await
|
lora.rx(&pkt, buf).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum CAD attempts before transmitting anyway.
|
|
||||||
const CAD_MAX_RETRIES: u8 = 10;
|
const CAD_MAX_RETRIES: u8 = 10;
|
||||||
|
|
||||||
async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> {
|
||||||
let mdltn = modulation_params(lora, cfg)?;
|
let mdltn = modulation_params(lora, cfg)?;
|
||||||
|
|
||||||
// Listen-before-talk: check channel is clear before transmitting.
|
|
||||||
if cfg.cad_enabled() {
|
if cfg.cad_enabled() {
|
||||||
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? {
|
||||||
break; // Channel is clear
|
break;
|
||||||
}
|
}
|
||||||
// Channel busy — wait briefly and retry
|
|
||||||
embassy_time::Timer::after_millis(10).await;
|
embassy_time::Timer::after_millis(10).await;
|
||||||
lora.prepare_for_cad(&mdltn).await?;
|
lora.prepare_for_cad(&mdltn).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user