Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
No files matched your search
+128
@@ -7,6 +7,134 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **OLED auto-dim after inactivity.** The display is bright on any
|
||||||
|
activity — splash entry, mode change into a dashboard leaf, packet
|
||||||
|
RX/TX — and dims after a short idle window. Splash holds bright for
|
||||||
|
10 s, then drops to dim while the LearnMore / Info carousel keeps
|
||||||
|
alternating. The dashboard stays bright for 3 s after the last
|
||||||
|
packet or mode change, then dims in place without changing
|
||||||
|
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
|
||||||
|
bright→bright with no intermediate dim — `set_dim` is fired as an
|
||||||
|
action by the 3 s timer handler rather than as a sibling `exit:`
|
||||||
|
action, so mode-change leaf-crossings never see a flash.
|
||||||
|
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
|
||||||
|
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
|
||||||
|
and the in-tree `Sh1106` driver. Contrast values centralized as
|
||||||
|
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
|
||||||
|
the two levels can be tuned in one place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/display.rs` statechart restructured:
|
||||||
|
- `Splash` gains `entry: set_bright` plus an action-only
|
||||||
|
`on(after 10s) => set_dim` handler so the splash bright window
|
||||||
|
is time-scoped without disturbing the LM↔Info alternation.
|
||||||
|
- `Dashboard` `Listening` and `Transmitting` each gain a nested
|
||||||
|
`*Bright` substate owning the `entry: set_bright` action. Packet
|
||||||
|
handlers moved into the leaves and target the leaf's `*Bright` —
|
||||||
|
sibling-to-sibling within the leaf restarts the 3 s timer without
|
||||||
|
re-entering the leaf itself (paint / 1 s sparkline timer
|
||||||
|
preserved). The 3 s timeout handler fires `set_dim` as an action
|
||||||
|
and up-transitions to the leaf, parking the machine at the leaf
|
||||||
|
with no active child.
|
||||||
|
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
|
||||||
|
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
|
||||||
|
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
|
||||||
|
would only unwind below `Splash` and park with no active child,
|
||||||
|
leaving the LM↔Info alternation timer dead and the QR frozen on
|
||||||
|
screen.
|
||||||
|
- `LoRaBoard::DisplayDriver` trait bound extended with
|
||||||
|
`+ DisplayBrightness` so brightness is part of the board contract
|
||||||
|
alongside `DrawTarget<Color = BinaryColor>`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
|
||||||
|
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
|
||||||
|
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
|
||||||
|
the CH340K / CH340 VID:PIDs directly instead of falling through
|
||||||
|
to espflash auto-detect.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
|
||||||
|
to an already-active ancestor unwinds only the subtree strictly
|
||||||
|
below the target; no exit/entry on the target; no default descent).
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-22
|
||||||
|
|
||||||
|
Small but high-value behavior change: `SET_CONFIG` issued while the
|
||||||
|
radio is in continuous RX no longer drops the radio (and the OLED)
|
||||||
|
back to idle. The radio now auto-re-arms RX with the new modulation
|
||||||
|
once reconfiguration completes, matching DongLoRa Protocol 1.1's
|
||||||
|
§3.5 / §6.3 amendments.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/radio.rs`: inside `state Receiving`, add `on(ConfigApplied)
|
||||||
|
=> ResumingRx` to shadow the parent `Configured` state's
|
||||||
|
`on(ConfigApplied) => Idle` handler. The reconfigure path now
|
||||||
|
runs `apply_set_config` → `ConfigApplied` → `ResumingRx.entry:
|
||||||
|
try_start_rx_hw` → `StartRxOk` → `Receiving` without ever
|
||||||
|
entering `Idle`.
|
||||||
|
- The OLED's `Dashboard → Splash` transition was previously driven
|
||||||
|
by `RadioEvent::Idle` firing on `Configured.Idle.entry`. That
|
||||||
|
event no longer fires during a SET_CONFIG-from-Receiving
|
||||||
|
sequence, so the display stays on Dashboard.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `donglora-protocol` bumped to `1.1.0` (spec-only change; matches
|
||||||
|
the new normative text the firmware now implements).
|
||||||
|
|
||||||
|
### Use-case unlock
|
||||||
|
|
||||||
|
- Scanner / time-division / dynamic-modulation workflows that
|
||||||
|
rotate LoRa parameters mid-RX no longer need to re-issue
|
||||||
|
`RX_START` after every `SET_CONFIG`. One call, radio continues
|
||||||
|
to receive.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-04-22
|
||||||
|
|
||||||
|
The 1.0 release. Firmware now speaks DongLoRa Protocol v2 end to end,
|
||||||
|
depends on the freshly-published `donglora-protocol` 1.0.0 and `hsmc`
|
||||||
|
0.1.0 from crates.io, and ships GitHub Release binaries for every
|
||||||
|
supported board.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- **DongLoRa Protocol v2 wire format** (wire-incompatible with 0.x
|
||||||
|
clients and firmware). Every `type_id`, tag-correlation rule, and
|
||||||
|
field layout follows `PROTOCOL.md` v1.0.
|
||||||
|
- **Host framing replaced.** `src/host/framing.rs` now drives a
|
||||||
|
streaming `FrameDecoder` from `donglora-protocol` and emits
|
||||||
|
`(type_id, tag, payload)` frames; the 0.x COBS-per-response shim is
|
||||||
|
gone.
|
||||||
|
- **Radio task reshape.** `src/radio.rs` emits v2 `TX_DONE` /
|
||||||
|
`SetConfigResult` / `RxPayload` / `RxOrigin` shapes directly; no 0.x
|
||||||
|
translation layer remains.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `hsmc` switched from path-dep on the local workspace to
|
||||||
|
`version = "0.1.0"` from crates.io.
|
||||||
|
- `donglora-protocol` switched from path-dep on `../protocol` to
|
||||||
|
`version = "1.0.0"` from crates.io; firmware builds are now
|
||||||
|
reproducible from a clean checkout with no sibling repo required.
|
||||||
|
- `PROTOCOL.md` updated to the DongLoRa Protocol v2 normative spec,
|
||||||
|
kept byte-for-byte in sync with the `donglora-protocol` crate's
|
||||||
|
copy.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Cargo.toml `repository` + `homepage` now point at the canonical
|
||||||
|
`https://github.com/donglora/firmware` (the `swaits/donglora`
|
||||||
|
repo is archived).
|
||||||
|
|
||||||
## [0.5.0] - 2026-04-16
|
## [0.5.0] - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+11
-4
@@ -486,7 +486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.5.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cortex-m",
|
"cortex-m",
|
||||||
@@ -527,7 +527,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donglora-protocol"
|
name = "donglora-protocol"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 1.0.1",
|
"defmt 1.0.1",
|
||||||
"heapless 0.9.2",
|
"heapless 0.9.2",
|
||||||
@@ -1389,6 +1391,7 @@ version = "0.9.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"defmt 1.0.1",
|
||||||
"hash32",
|
"hash32",
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
@@ -1401,7 +1404,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc"
|
name = "hsmc"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"embassy-futures",
|
"embassy-futures",
|
||||||
"embassy-sync 0.8.0",
|
"embassy-sync 0.8.0",
|
||||||
@@ -1412,7 +1417,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hsmc-macros"
|
name = "hsmc-macros"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+5
-4
@@ -3,13 +3,14 @@ members = ["."]
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.5.0"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
|
||||||
repository = "https://github.com/swaits/donglora"
|
repository = "https://github.com/donglora/firmware"
|
||||||
|
homepage = "https://github.com/donglora/firmware"
|
||||||
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
|
||||||
categories = ["embedded", "hardware-support", "no-std"]
|
categories = ["embedded", "hardware-support", "no-std"]
|
||||||
|
|
||||||
@@ -29,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
|
|||||||
embassy-usb-driver = "0.2"
|
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 = "1"
|
||||||
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
|
hsmc = { version = "0.2", 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"
|
||||||
|
|||||||
+1901
-269
File diff suppressed because it is too large.
Load diff
@@ -77,7 +77,7 @@ flash board:
|
|||||||
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
@read -r feat target chip <<< "$(just _info {{board}})"; \
|
||||||
case "$target" in \
|
case "$target" in \
|
||||||
xtensa-*) \
|
xtensa-*) \
|
||||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||||
if [ -n "$port" ]; then \
|
if [ -n "$port" ]; then \
|
||||||
echo "Using $port"; \
|
echo "Using $port"; \
|
||||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||||
//!
|
//!
|
||||||
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||||
//! OLED + CP2102 UART bridge + 1000 mAh Li-Po. Sold pre-loaded with Meshtastic;
|
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||||
//! this firmware turns it into a DongLoRa radio bridge.
|
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||||
//!
|
//!
|
||||||
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||||
//! and PR meshtastic/firmware#6354):
|
//! and PR meshtastic/firmware#6354):
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||||
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||||
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||||
//! UART: TX=43, RX=44 via CP2102 bridge
|
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||||
//! LED: GPIO6 (active HIGH indicator)
|
//! LED: GPIO6 (active HIGH indicator)
|
||||||
//!
|
//!
|
||||||
//! Notable differences from the Heltec ESP32-S3 boards:
|
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||||
|
|||||||
+20
-2
@@ -9,6 +9,8 @@
|
|||||||
use embedded_graphics::draw_target::DrawTarget;
|
use embedded_graphics::draw_target::DrawTarget;
|
||||||
use embedded_graphics::pixelcolor::BinaryColor;
|
use embedded_graphics::pixelcolor::BinaryColor;
|
||||||
|
|
||||||
|
use crate::driver::DisplayBrightness;
|
||||||
|
|
||||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||||
pub trait RgbLed {
|
pub trait RgbLed {
|
||||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||||
@@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized {
|
|||||||
/// Display peripheral bundle (I2C bus for display init).
|
/// Display peripheral bundle (I2C bus for display init).
|
||||||
type DisplayParts;
|
type DisplayParts;
|
||||||
|
|
||||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
/// and DisplayBrightness for the two-level dim control).
|
||||||
|
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||||
|
|
||||||
/// RGB LED driver. Boards without an LED use `()`.
|
/// RGB LED driver. Boards without an LED use `()`.
|
||||||
type LedDriver: RgbLed;
|
type LedDriver: RgbLed;
|
||||||
@@ -47,6 +50,21 @@ pub trait LoRaBoard: Sized {
|
|||||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||||
fn mac_address() -> [u8; 6];
|
fn mac_address() -> [u8; 6];
|
||||||
|
|
||||||
|
/// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262
|
||||||
|
/// (`0x0002`), matching every currently-supported board. Override for
|
||||||
|
/// boards that carry a different chip (LLCC68, SX1261, LR11xx, …).
|
||||||
|
fn radio_chip_id() -> u16 {
|
||||||
|
// SX1262 — see PROTOCOL.md §8.
|
||||||
|
0x0002
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning
|
||||||
|
/// window on this specific board. Reported in `GET_INFO`. Defaults
|
||||||
|
/// to the SX1262's nominal 150–960 MHz range.
|
||||||
|
fn freq_range_hz() -> (u32, u32) {
|
||||||
|
(150_000_000, 960_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decompose initialized board into peripheral bundles for each task.
|
/// Decompose initialized board into peripheral bundles for each task.
|
||||||
fn into_parts(
|
fn into_parts(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+62
-32
@@ -1,54 +1,84 @@
|
|||||||
//! Inter-task communication channels.
|
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
//! and tag-0 async RX/ERR events in one queue)
|
||||||
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
|
//! - [`DisplayCommandChannel`]: host_task → display_task (internal only —
|
||||||
|
//! `On` on connect, `Reset` on disconnect; no longer driven from the wire)
|
||||||
|
//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events)
|
||||||
|
|
||||||
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 crate::protocol::{Command, RadioConfig, Response};
|
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||||
|
|
||||||
/// Display commands routed from host_task to display_task.
|
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
|
||||||
pub enum DisplayCommand {
|
/// An event flowing from the host task into the radio task.
|
||||||
On,
|
///
|
||||||
Off,
|
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||||
#[allow(dead_code)] // only used by USB host task, not UART
|
/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal
|
||||||
|
/// from the host transport layer (DTR drop, UART teardown, or spec §3.4
|
||||||
|
/// inactivity timeout) telling the radio to park back to Unconfigured
|
||||||
|
/// and drain queues silently.
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InboundEvent {
|
||||||
|
Command { tag: u16, cmd: Command },
|
||||||
Reset,
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
/// Host-to-radio event channel (depth 8).
|
||||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||||
|
|
||||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
|
||||||
|
|
||||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
/// (OK, ERR in response, TX_DONE) carry the originating command's tag;
|
||||||
|
/// async events (RX, async ERR) carry `tag = 0x0000`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboundFrame {
|
||||||
|
pub tag: u16,
|
||||||
|
pub msg: DeviceMessage,
|
||||||
|
}
|
||||||
|
|
||||||
/// Radio → downstream typed events. The radio task emits one of these at
|
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
|
||||||
/// every mode transition and every RF packet. Mode events and data events
|
/// RX events + queued command responses without stalling the radio ISR).
|
||||||
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
|
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
|
||||||
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
|
|
||||||
/// happened* without implying a mode change.
|
// ── Internal: display commands (no longer wire-visible) ────────────
|
||||||
|
|
||||||
|
/// Internal display command routed from host_task to display_task. The
|
||||||
|
/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`);
|
||||||
|
/// these values drive the display statechart from local events only.
|
||||||
|
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||||
|
#[allow(dead_code)] // `Off` is reserved for future diagnostic paths.
|
||||||
|
pub enum DisplayCommand {
|
||||||
|
/// Host connected (DTR rose on USB, or UART came up). Wake display.
|
||||||
|
On,
|
||||||
|
/// Host disconnected. Park display.
|
||||||
|
Off,
|
||||||
|
/// Reset cumulative counters (e.g., on config change).
|
||||||
|
Reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-to-display command channel.
|
||||||
|
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||||
|
|
||||||
|
// ── Radio → Display: typed packet/mode events ──────────────────────
|
||||||
|
|
||||||
|
/// Radio → downstream typed events. These drive the display; they are
|
||||||
|
/// NOT the same thing as `OutboundFrame` (which goes out the host
|
||||||
|
/// transport). Mode events describe what the radio is doing now; packet
|
||||||
|
/// events describe what just happened.
|
||||||
#[derive(Debug, Clone, defmt::Format)]
|
#[derive(Debug, Clone, defmt::Format)]
|
||||||
pub enum RadioEvent {
|
pub enum RadioEvent {
|
||||||
/// Radio is now in RX mode.
|
|
||||||
EnteredRx,
|
EnteredRx,
|
||||||
/// Radio is now in TX mode.
|
|
||||||
EnteredTx,
|
EnteredTx,
|
||||||
/// Radio is now idle.
|
|
||||||
Idle,
|
Idle,
|
||||||
/// A packet was received with these link metrics.
|
|
||||||
PacketRx { rssi: i16, snr: Option<i16> },
|
PacketRx { rssi: i16, snr: Option<i16> },
|
||||||
/// A packet was transmitted successfully.
|
|
||||||
PacketTx,
|
PacketTx,
|
||||||
/// `SetConfig` applied a new validated config.
|
ConfigChanged(LoRaConfig),
|
||||||
ConfigChanged(RadioConfig),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Radio → Display event stream (depth 32: buffer RX bursts while the
|
|
||||||
/// display drains slowly).
|
|
||||||
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||||
+96
-25
@@ -37,8 +37,9 @@ 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::driver::DisplayBrightness;
|
||||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||||
use crate::protocol::RadioConfig;
|
use crate::protocol::LoRaConfig;
|
||||||
|
|
||||||
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ pub enum DisplayEvent {
|
|||||||
},
|
},
|
||||||
/// 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(LoRaConfig),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RadioEvent> for DisplayEvent {
|
impl From<RadioEvent> for DisplayEvent {
|
||||||
@@ -109,7 +110,7 @@ struct RunState {
|
|||||||
rssi_count: usize,
|
rssi_count: usize,
|
||||||
current_slot_rssi: i16,
|
current_slot_rssi: i16,
|
||||||
current_slot_tx: bool,
|
current_slot_tx: bool,
|
||||||
config: Option<RadioConfig>,
|
config: Option<LoRaConfig>,
|
||||||
rx_count: u32,
|
rx_count: u32,
|
||||||
tx_count: u32,
|
tx_count: u32,
|
||||||
last_rssi: Option<i16>,
|
last_rssi: Option<i16>,
|
||||||
@@ -220,6 +221,14 @@ impl DisplayContext {
|
|||||||
Timer::after_millis(50).await;
|
Timer::after_millis(50).await;
|
||||||
self.peripherals.led.set_rgb(0, 0, 0).await;
|
self.peripherals.led.set_rgb(0, 0, 0).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn oled_bright(&mut self) {
|
||||||
|
self.peripherals.display.set_bright().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn oled_dim(&mut self) {
|
||||||
|
self.peripherals.display.set_dim().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Statechart ─────────────────────────────────────────────────────
|
// ── Statechart ─────────────────────────────────────────────────────
|
||||||
@@ -243,16 +252,26 @@ statechart! {
|
|||||||
on(CmdOff) => Off;
|
on(CmdOff) => Off;
|
||||||
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
||||||
// makes either ordering land in the same place. Reset the run
|
// makes either ordering land in the same place. Reset the run
|
||||||
// state, turn off any blink-in-progress, and re-enter Splash —
|
// state, turn off any blink-in-progress, and land in LearnMore
|
||||||
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
|
// with a fresh 5 s timer. We target `LearnMore` (not `Splash`)
|
||||||
on(CmdReset) => reset_run_state, turn_led_off, Splash;
|
// because under hsmc 0.2 up-transition semantics, targeting a
|
||||||
|
// state that's already on the active path would only unwind
|
||||||
|
// below it — we'd park at `Splash` with no active child and
|
||||||
|
// the alternation timer would never restart.
|
||||||
|
on(CmdReset) => reset_run_state, turn_led_off, LearnMore;
|
||||||
// Config can arrive before the dashboard is up; cache it anywhere.
|
// Config can arrive before the dashboard is up; cache it anywhere.
|
||||||
on(ConfigChanged(cfg: RadioConfig)) => save_new_config;
|
on(ConfigChanged(cfg: LoRaConfig)) => save_new_config;
|
||||||
|
|
||||||
|
// Splash is bright for the first 10 s of (re-)entry, then the
|
||||||
|
// action-only `on(after 10s)` handler drops the OLED to dim.
|
||||||
|
// LearnMore ↔ Info keep alternating underneath — their own
|
||||||
|
// timers are independent of the 10 s window.
|
||||||
state Splash {
|
state Splash {
|
||||||
|
entry: set_bright;
|
||||||
default(LearnMore);
|
default(LearnMore);
|
||||||
on(EnteredRx) => Listening;
|
on(EnteredRx) => Listening;
|
||||||
on(EnteredTx) => Transmitting;
|
on(EnteredTx) => Transmitting;
|
||||||
|
on(after Duration::from_secs(10)) => set_dim;
|
||||||
|
|
||||||
state LearnMore {
|
state LearnMore {
|
||||||
entry: paint_learn_more;
|
entry: paint_learn_more;
|
||||||
@@ -265,24 +284,56 @@ statechart! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard brightness pattern: each mode-leaf holds a `Bright`
|
||||||
|
// substate. `entry: set_bright` owns the backlight going on;
|
||||||
|
// `set_dim` is fired **as an action** by the 3 s timeout
|
||||||
|
// handler, immediately before the up-transition that parks
|
||||||
|
// the machine at the leaf. The symmetric `exit: set_dim`
|
||||||
|
// would also fire on mode-change leaf-crossings (e.g.
|
||||||
|
// ListeningBright → TransmittingBright via EnteredTx), and
|
||||||
|
// the subsequent default descent would fire `set_bright`
|
||||||
|
// again — producing a visible dim/bright flash on every
|
||||||
|
// TX↔RX swap. Anchoring dim to the *timer* handler instead
|
||||||
|
// means leaf-crossings go bright→bright with no intermediate
|
||||||
|
// dim action. CmdOff/CmdReset exit paths don't fire set_dim
|
||||||
|
// either, which is fine — the display is blanked or walked
|
||||||
|
// back to Splash, which re-asserts its own brightness.
|
||||||
state Dashboard {
|
state Dashboard {
|
||||||
default(Listening);
|
default(Listening);
|
||||||
on(RadioIdle) => Splash;
|
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 {
|
state Listening {
|
||||||
entry: paint_listening;
|
entry: paint_listening;
|
||||||
|
default(ListeningBright);
|
||||||
on(EnteredTx) => Transmitting;
|
on(EnteredTx) => Transmitting;
|
||||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
|
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
|
||||||
|
// Split from the transition below because hsmc rejects
|
||||||
|
// payload bindings on transition-bearing handlers.
|
||||||
|
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||||
|
on(PacketRx) => ListeningBright;
|
||||||
|
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||||
|
on(PacketTx) => ListeningBright;
|
||||||
|
|
||||||
|
state ListeningBright {
|
||||||
|
entry: set_bright;
|
||||||
|
on(after Duration::from_secs(3)) => set_dim, Listening;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state Transmitting {
|
state Transmitting {
|
||||||
entry: paint_transmitting;
|
entry: paint_transmitting;
|
||||||
|
default(TransmittingBright);
|
||||||
on(EnteredRx) => Listening;
|
on(EnteredRx) => Listening;
|
||||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
|
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
|
||||||
|
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||||
|
on(PacketRx) => TransmittingBright;
|
||||||
|
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||||
|
on(PacketTx) => TransmittingBright;
|
||||||
|
|
||||||
|
state TransmittingBright {
|
||||||
|
entry: set_bright;
|
||||||
|
on(after Duration::from_secs(3)) => set_dim, Transmitting;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,13 +399,21 @@ impl DisplayActions for DisplayActionContext<'_> {
|
|||||||
self.led_blip(32, 0, 0).await;
|
self.led_blip(32, 0, 0).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_new_config(&mut self, cfg: RadioConfig) {
|
async fn save_new_config(&mut self, cfg: LoRaConfig) {
|
||||||
self.run.config = Some(cfg);
|
self.run.config = Some(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn advance_sparkline(&mut self) {
|
async fn advance_sparkline(&mut self) {
|
||||||
self.run.advance_slot();
|
self.run.advance_slot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
self.oled_bright().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
self.oled_dim().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Task entry point ───────────────────────────────────────────────
|
// ── Task entry point ───────────────────────────────────────────────
|
||||||
@@ -440,7 +499,7 @@ mod render {
|
|||||||
use heapless::String;
|
use heapless::String;
|
||||||
|
|
||||||
use super::Badge;
|
use super::Badge;
|
||||||
use crate::protocol::{Bandwidth, RadioConfig};
|
use crate::protocol::{LoRaBandwidth, LoRaConfig};
|
||||||
|
|
||||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||||
|
|
||||||
@@ -505,7 +564,7 @@ mod render {
|
|||||||
pub fn dashboard(
|
pub fn dashboard(
|
||||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||||
badge: Badge,
|
badge: Badge,
|
||||||
config: Option<RadioConfig>,
|
config: Option<LoRaConfig>,
|
||||||
rx_count: u32,
|
rx_count: u32,
|
||||||
tx_count: u32,
|
tx_count: u32,
|
||||||
last_rssi: Option<i16>,
|
last_rssi: Option<i16>,
|
||||||
@@ -588,21 +647,33 @@ mod render {
|
|||||||
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;
|
||||||
let bw_str = match cfg.bw {
|
let bw_str = match cfg.bw {
|
||||||
Bandwidth::Khz7 => "7.8",
|
LoRaBandwidth::Khz7 => "7.8",
|
||||||
Bandwidth::Khz10 => "10.4",
|
LoRaBandwidth::Khz10 => "10.4",
|
||||||
Bandwidth::Khz15 => "15.6",
|
LoRaBandwidth::Khz15 => "15.6",
|
||||||
Bandwidth::Khz20 => "20.8",
|
LoRaBandwidth::Khz20 => "20.8",
|
||||||
Bandwidth::Khz31 => "31.2",
|
LoRaBandwidth::Khz31 => "31.2",
|
||||||
Bandwidth::Khz41 => "41.7",
|
LoRaBandwidth::Khz41 => "41.7",
|
||||||
Bandwidth::Khz62 => "62.5",
|
LoRaBandwidth::Khz62 => "62.5",
|
||||||
Bandwidth::Khz125 => "125",
|
LoRaBandwidth::Khz125 => "125",
|
||||||
Bandwidth::Khz250 => "250",
|
LoRaBandwidth::Khz250 => "250",
|
||||||
Bandwidth::Khz500 => "500",
|
LoRaBandwidth::Khz500 => "500",
|
||||||
|
LoRaBandwidth::Khz200 => "200",
|
||||||
|
LoRaBandwidth::Khz400 => "400",
|
||||||
|
LoRaBandwidth::Khz800 => "800",
|
||||||
|
LoRaBandwidth::Khz1600 => "1600",
|
||||||
|
};
|
||||||
|
// Show only the CR denominator; the "4/" numerator is implied for
|
||||||
|
// every LoRa coding rate and just clutters the line.
|
||||||
|
let cr_str = match cfg.cr {
|
||||||
|
crate::protocol::LoRaCodingRate::Cr4_5 => "5",
|
||||||
|
crate::protocol::LoRaCodingRate::Cr4_6 => "6",
|
||||||
|
crate::protocol::LoRaCodingRate::Cr4_7 => "7",
|
||||||
|
crate::protocol::LoRaCodingRate::Cr4_8 => "8",
|
||||||
};
|
};
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
buf,
|
buf,
|
||||||
"{}.{:03}/{}/{}/{}",
|
"{}.{:03}/{}/{}/{}",
|
||||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
|
||||||
);
|
);
|
||||||
Text::with_alignment(
|
Text::with_alignment(
|
||||||
&buf,
|
&buf,
|
||||||
|
|||||||
@@ -10,3 +10,56 @@ pub mod sh1106;
|
|||||||
feature = "waveshare_rp2040_lora"
|
feature = "waveshare_rp2040_lora"
|
||||||
))]
|
))]
|
||||||
pub mod simple_led;
|
pub mod simple_led;
|
||||||
|
|
||||||
|
// ── Display brightness ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The display statechart dims the OLED a few seconds after the last
|
||||||
|
// activity. Our two driver families (`ssd1306` crate and our in-tree
|
||||||
|
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
|
||||||
|
// single async trait that the state-machine actions can call uniformly.
|
||||||
|
|
||||||
|
/// Two-level backlight control used by the display dashboard.
|
||||||
|
pub trait DisplayBrightness {
|
||||||
|
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast values for the two brightness levels, picked to be common across
|
||||||
|
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
|
||||||
|
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
|
||||||
|
const CONTRAST_BRIGHT: u8 = 0xFF;
|
||||||
|
const CONTRAST_DIM: u8 = 0x08;
|
||||||
|
|
||||||
|
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||||
|
where
|
||||||
|
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||||
|
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
// `Brightness::custom(precharge, contrast)`. Matches the shape of
|
||||||
|
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
// Low precharge pairs with low contrast — same combination the
|
||||||
|
// crate's DIMMEST preset uses.
|
||||||
|
let _ = self
|
||||||
|
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
|
||||||
|
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||||
|
where
|
||||||
|
I: embedded_hal_async::i2c::I2c,
|
||||||
|
{
|
||||||
|
async fn set_bright(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
|
||||||
|
}
|
||||||
|
async fn set_dim(&mut self) {
|
||||||
|
let _ = self.set_brightness(CONTRAST_DIM).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-40
@@ -1,53 +1,121 @@
|
|||||||
//! Shared COBS protocol helpers for USB and UART transports.
|
//! Shared framing helpers for USB and UART host transports.
|
||||||
//!
|
//!
|
||||||
//! Both `usb_task` and `uart_task` use these helpers for frame
|
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||||
//! accumulation, response encoding, and command routing.
|
//! → `Command::parse` → forwarded to radio task along with the tag. On
|
||||||
|
//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)`
|
||||||
|
//! with the received tag. On CRC/COBS failure we emit async
|
||||||
|
//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`).
|
||||||
//!
|
//!
|
||||||
//! The actual codec lives in `donglora-protocol` so host crates can
|
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||||
//! reuse it; this module re-exports the codec and owns the
|
//! → bytes ready for transport-level write.
|
||||||
//! firmware-only [`route_command`] (which touches embassy channels).
|
|
||||||
|
|
||||||
pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME};
|
use defmt::warn;
|
||||||
|
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||||
use crate::protocol::{Command, ErrorCode, Response};
|
use crate::protocol::{
|
||||||
|
encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError,
|
||||||
|
MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME,
|
||||||
|
};
|
||||||
|
|
||||||
/// Route a parsed command to the appropriate handler.
|
/// Dispatch a frame-level decode into the appropriate downstream queue.
|
||||||
///
|
///
|
||||||
/// Display and MAC commands are handled locally (response sent immediately).
|
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||||
/// All other commands are forwarded to the radio task (response sent later).
|
/// - Known commands are forwarded to the radio task via `commands`.
|
||||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||||
pub async fn route_command(
|
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||||
cmd: Command,
|
/// this; the spec says hosts MUST NOT send tag 0, and we classify such
|
||||||
|
/// frames as framing errors upstream.
|
||||||
|
pub async fn dispatch_frame(
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: &[u8],
|
||||||
commands: &CommandChannel,
|
commands: &CommandChannel,
|
||||||
responses: &ResponseChannel,
|
outbound: &OutboundChannel,
|
||||||
display_commands: &DisplayCommandChannel,
|
|
||||||
has_display: bool,
|
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
match cmd {
|
if tag == 0 {
|
||||||
Command::DisplayOn => {
|
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||||
if has_display {
|
// framing error; do not route to radio. (Spec §2.2)
|
||||||
display_commands.send(DisplayCommand::On).await;
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
responses.send(Response::Ok).await;
|
return;
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
match Command::parse(type_id, payload) {
|
||||||
|
Ok(cmd) => {
|
||||||
|
let envelope = InboundEvent::Command { tag, cmd };
|
||||||
|
if commands.try_send(envelope).is_err() {
|
||||||
|
emit_sync_err(outbound, tag, ErrorCode::EBusy).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::DisplayOff => {
|
Err(e) => {
|
||||||
if has_display {
|
let code = parse_err_to_wire_code(e);
|
||||||
display_commands.send(DisplayCommand::Off).await;
|
emit_sync_err(outbound, tag, code).await;
|
||||||
responses.send(Response::Ok).await;
|
}
|
||||||
} else {
|
}
|
||||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
}
|
||||||
}
|
|
||||||
}
|
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||||
Command::GetMac => {
|
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||||
responses.send(Response::MacAddress(mac)).await;
|
let frame = OutboundFrame {
|
||||||
}
|
tag: 0,
|
||||||
other => {
|
msg: DeviceMessage::Err(code),
|
||||||
commands.send(other).await;
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("async ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a synchronous error response echoing the originating tag.
|
||||||
|
async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) {
|
||||||
|
let frame = OutboundFrame {
|
||||||
|
tag,
|
||||||
|
msg: DeviceMessage::Err(code),
|
||||||
|
};
|
||||||
|
if outbound.try_send(frame).is_err() {
|
||||||
|
warn!("sync ERR dropped: outbound queue full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode {
|
||||||
|
match e {
|
||||||
|
CommandParseError::UnknownType => ErrorCode::EUnknownCmd,
|
||||||
|
CommandParseError::WrongLength => ErrorCode::ELength,
|
||||||
|
CommandParseError::InvalidField => ErrorCode::EParam,
|
||||||
|
CommandParseError::ReservedBitSet => ErrorCode::EParam,
|
||||||
|
CommandParseError::UnknownModulation => ErrorCode::EModulation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an outbound frame into a single wire-ready byte buffer.
|
||||||
|
///
|
||||||
|
/// Returns the number of bytes written to `out`, or `None` on buffer
|
||||||
|
/// overflow / encode failure (caller may log and drop).
|
||||||
|
pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option<usize> {
|
||||||
|
let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD];
|
||||||
|
let payload_len = match frame.msg.encode_payload(&mut payload_buf) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match encode_frame(
|
||||||
|
frame.msg.type_id(),
|
||||||
|
frame.tag,
|
||||||
|
&payload_buf[..payload_len],
|
||||||
|
out.as_mut_slice(),
|
||||||
|
) {
|
||||||
|
Ok(n) => Some(n),
|
||||||
|
Err(FrameEncodeError::BufferTooSmall) => {
|
||||||
|
warn!("encode_frame: output buffer too small");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(FrameEncodeError::PayloadTooLarge) => {
|
||||||
|
warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(FrameEncodeError::CobsEncode) => {
|
||||||
|
warn!("encode_frame: COBS encode refused");
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+107
-51
@@ -1,85 +1,141 @@
|
|||||||
//! UART host task: COBS-framed fixed-size LE command/response protocol.
|
//! UART host task for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3).
|
//! UART has no DTR line, so we assume a host is always present and
|
||||||
|
//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence
|
||||||
|
//! → Disconnected → drop radio to Unconfigured).
|
||||||
|
|
||||||
use defmt::warn;
|
use defmt::warn;
|
||||||
use embassy_executor::task;
|
use embassy_executor::task;
|
||||||
|
use embassy_time::{Duration, Instant};
|
||||||
use embedded_io_async::Write;
|
use embedded_io_async::Write;
|
||||||
|
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{
|
||||||
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
|
const READ_BUF: usize = 64;
|
||||||
|
const INACTIVITY_MS: u64 = 1000;
|
||||||
|
|
||||||
#[task]
|
#[task]
|
||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UartParts,
|
parts: crate::board::UartParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
_mac: [u8; 6],
|
||||||
) {
|
) {
|
||||||
let (mut rx, mut tx) = parts.driver.split();
|
let (mut rx, mut tx) = parts.driver.split();
|
||||||
|
|
||||||
// UART has no DTR — assume a host is always present.
|
// UART has no DTR — wake display immediately and assume a host.
|
||||||
if has_display {
|
if has_display {
|
||||||
display_commands.send(DisplayCommand::On).await;
|
display_commands.send(DisplayCommand::On).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut read_buf = [0u8; 64];
|
let mut read_buf = [0u8; READ_BUF];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut decoder = FrameDecoder::new();
|
||||||
let mut decoder = CobsDecoder::new();
|
let mut last_frame_at = Instant::now();
|
||||||
|
let mut connected = true;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
use embassy_futures::select::{select, Either};
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
match select(
|
match select3(
|
||||||
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
embedded_io_async::Read::read(&mut rx, &mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
|
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either::First(result) => {
|
Either3::First(result) => match result {
|
||||||
let n = match result {
|
Ok(0) => continue,
|
||||||
Ok(0) => continue,
|
Ok(n) => {
|
||||||
Ok(n) => n,
|
last_frame_at = Instant::now();
|
||||||
Err(_) => {
|
if !connected {
|
||||||
decoder.reset();
|
if has_display {
|
||||||
embassy_time::Timer::after_millis(100).await;
|
let _ = display_commands.try_send(DisplayCommand::On);
|
||||||
continue;
|
}
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
decoder.reset();
|
||||||
|
embassy_time::Timer::after_millis(100).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Either3::Second(frame) => {
|
||||||
|
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||||
|
if tx.write_all(&write_buf[..n]).await.is_err() {
|
||||||
|
warn!("UART write failed, frame dropped");
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
|
||||||
decoder.feed(&read_buf[..n], |cmd| {
|
|
||||||
let _ = cmds.push(cmd);
|
|
||||||
});
|
|
||||||
for cmd in cmds {
|
|
||||||
framing::route_command(
|
|
||||||
cmd,
|
|
||||||
commands,
|
|
||||||
responses,
|
|
||||||
display_commands,
|
|
||||||
has_display,
|
|
||||||
mac,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Either::Second(response) => {
|
Either3::Third(()) => {}
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
}
|
||||||
crate::debug_blink::set(13);
|
|
||||||
if let Some(frame) =
|
// Inactivity watchdog.
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||||
{
|
warn!("UART host inactivity timeout fired");
|
||||||
if tx.write_all(frame).await.is_err() {
|
let _ = commands.try_send(InboundEvent::Reset);
|
||||||
warn!("UART write failed, response dropped");
|
if has_display {
|
||||||
} else {
|
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
}
|
||||||
crate::debug_blink::set(14);
|
connected = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, MAX_PAYLOAD_FIELD> = heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, MAX_PAYLOAD_FIELD>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+126
-84
@@ -1,54 +1,53 @@
|
|||||||
//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol.
|
//! USB CDC-ACM host task for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! Host
|
//! Host
|
||||||
//! ├── Disconnected (default) no DTR
|
//! ├── Disconnected (default) no DTR, or inactivity timer fired
|
||||||
//! └── Connected host attached
|
//! └── Connected host attached, liveness OK
|
||||||
//! entry: wake_display
|
//! entry: wake_display, start_liveness_timer
|
||||||
//! exit: stop_radio, reset_display
|
//! exit: reset_radio, reset_display, stop_liveness_timer
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The statechart tracks exactly one thing: DTR. Entry/exit actions
|
//! The statechart tracks one state bit: "is a host present?". DTR drives
|
||||||
//! replace the old `was_connected` edge-detector and emit the display
|
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
|
||||||
//! wake / radio-stop / display-reset side-effects that used to be open
|
//! the disconnect edge when DTR is held high but no bytes arrive.
|
||||||
//! coded around the `select3` loop.
|
|
||||||
//!
|
//!
|
||||||
//! The data plane (COBS decoding, command routing, response encoding)
|
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||||
//! stays imperative in `protocol_loop` for the same reason the radio's
|
//! imperative in `protocol_loop` for the same reason the radio's
|
||||||
//! `rx_once` stayed out of its chart: `read_packet` blocks and can't
|
//! `rx_once` stayed out of its chart: `read_packet` blocks.
|
||||||
//! 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_time::{Duration, Instant};
|
||||||
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 hsmc::{statechart, Duration as HsmcDuration};
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
use crate::channel::{
|
||||||
use crate::protocol::Command;
|
CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel,
|
||||||
|
};
|
||||||
|
use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME};
|
||||||
|
|
||||||
const MAX_PACKET: usize = 64;
|
const MAX_USB_PACKET: usize = 64;
|
||||||
|
|
||||||
|
/// Spec §3.4: inactivity timer period. The device must see at least one
|
||||||
|
/// frame (good or bad) every 1000 ms or the session is considered lost.
|
||||||
|
const INACTIVITY_MS: u64 = 1000;
|
||||||
|
|
||||||
|
/// Poll interval for the DTR edge detector / inactivity check.
|
||||||
|
const POLL_INTERVAL_MS: u64 = 100;
|
||||||
|
|
||||||
/// Internal events driving `Host` state transitions.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HostInput {
|
pub enum HostInput {
|
||||||
/// DTR rose: host opened the serial port.
|
|
||||||
DtrRaised,
|
DtrRaised,
|
||||||
/// DTR fell: host closed or disconnected.
|
|
||||||
DtrDropped,
|
DtrDropped,
|
||||||
|
/// Host went silent for >1 s; kick the radio back to Unconfigured.
|
||||||
|
InactivityTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
pub struct HostContext {
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
@@ -68,9 +67,9 @@ impl HostContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop_radio_best_effort(&self) {
|
fn send_radio_reset(&self) {
|
||||||
if self.commands.try_send(Command::StopRx).is_err() {
|
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||||
warn!("host DTR-drop: StopRx dropped (command channel full)");
|
warn!("radio Reset inject: command queue full");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,8 +86,9 @@ statechart! {
|
|||||||
|
|
||||||
state Connected {
|
state Connected {
|
||||||
entry: wake_display;
|
entry: wake_display;
|
||||||
exit: stop_radio, reset_display;
|
exit: reset_radio, reset_display;
|
||||||
on(DtrDropped) => Disconnected;
|
on(DtrDropped) => Disconnected;
|
||||||
|
on(InactivityTimeout) => Disconnected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ impl HostActions for HostActionContext<'_> {
|
|||||||
self.wake_display_if_present().await;
|
self.wake_display_if_present().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_radio(&mut self) {
|
async fn reset_radio(&mut self) {
|
||||||
self.stop_radio_best_effort();
|
self.send_radio_reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reset_display(&mut self) {
|
async fn reset_display(&mut self) {
|
||||||
@@ -111,7 +111,7 @@ impl HostActions for HostActionContext<'_> {
|
|||||||
pub async fn host_task(
|
pub async fn host_task(
|
||||||
parts: crate::board::UsbParts,
|
parts: crate::board::UsbParts,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
mac: [u8; 6],
|
||||||
@@ -147,7 +147,7 @@ pub async fn host_task(
|
|||||||
|
|
||||||
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf);
|
||||||
|
|
||||||
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16);
|
let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16);
|
||||||
let mut usb_dev = builder.build();
|
let mut usb_dev = builder.build();
|
||||||
let (sender, receiver) = class.split();
|
let (sender, receiver) = class.split();
|
||||||
|
|
||||||
@@ -157,10 +157,9 @@ pub async fn host_task(
|
|||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
commands,
|
commands,
|
||||||
responses,
|
outbound,
|
||||||
display_commands,
|
display_commands,
|
||||||
has_display,
|
has_display,
|
||||||
mac,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -170,10 +169,9 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
mut sender: Sender<'d, D>,
|
mut sender: Sender<'d, D>,
|
||||||
mut receiver: Receiver<'d, D>,
|
mut receiver: Receiver<'d, D>,
|
||||||
commands: &'static CommandChannel,
|
commands: &'static CommandChannel,
|
||||||
responses: &'static ResponseChannel,
|
outbound: &'static OutboundChannel,
|
||||||
display_commands: &'static DisplayCommandChannel,
|
display_commands: &'static DisplayCommandChannel,
|
||||||
has_display: bool,
|
has_display: bool,
|
||||||
mac: [u8; 6],
|
|
||||||
) {
|
) {
|
||||||
use embassy_futures::select::{select3, Either3};
|
use embassy_futures::select::{select3, Either3};
|
||||||
|
|
||||||
@@ -182,55 +180,36 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
|||||||
display_commands,
|
display_commands,
|
||||||
has_display,
|
has_display,
|
||||||
});
|
});
|
||||||
// Enter the default: Disconnected.
|
machine.step(HsmcDuration::ZERO).await;
|
||||||
machine.step(Duration::ZERO).await;
|
|
||||||
|
|
||||||
let mut read_buf = [0u8; MAX_PACKET];
|
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||||
let mut write_buf = [0u8; MAX_FRAME];
|
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
let mut decoder = FrameDecoder::new();
|
||||||
let mut decoder = CobsDecoder::new();
|
|
||||||
|
let mut last_frame_at = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match select3(
|
match select3(
|
||||||
receiver.read_packet(&mut read_buf),
|
receiver.read_packet(&mut read_buf),
|
||||||
responses.receive(),
|
outbound.receive(),
|
||||||
embassy_time::Timer::after_millis(250),
|
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Either3::First(Ok(0)) => {}
|
Either3::First(Ok(0)) => {}
|
||||||
Either3::First(Ok(n)) => {
|
Either3::First(Ok(n)) => {
|
||||||
let mut cmds = heapless::Vec::<_, 4>::new();
|
last_frame_at = Instant::now();
|
||||||
decoder.feed(&read_buf[..n], |cmd| {
|
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||||
let _ = cmds.push(cmd);
|
|
||||||
});
|
|
||||||
for cmd in cmds {
|
|
||||||
framing::route_command(
|
|
||||||
cmd,
|
|
||||||
commands,
|
|
||||||
responses,
|
|
||||||
display_commands,
|
|
||||||
has_display,
|
|
||||||
mac,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Either3::First(Err(_)) => {
|
Either3::First(Err(_)) => {
|
||||||
// Read errored — usually a USB disconnect. Drop decoder
|
|
||||||
// scratch and back off briefly so we don't spin at I/O
|
|
||||||
// rate. Do NOT `continue`: the DTR check below must
|
|
||||||
// still run so the machine's exit action fires.
|
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
embassy_time::Timer::after_millis(100).await;
|
embassy_time::Timer::after_millis(100).await;
|
||||||
}
|
}
|
||||||
Either3::Second(response) => {
|
Either3::Second(frame) => {
|
||||||
if let Some(frame) =
|
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
|
||||||
{
|
|
||||||
for chunk in frame.chunks(MAX_PACKET) {
|
|
||||||
if sender.write_packet(chunk).await.is_err() {
|
if sender.write_packet(chunk).await.is_err() {
|
||||||
warn!("USB write failed, response dropped");
|
warn!("USB write failed, frame dropped");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,27 +218,90 @@ 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
|
// DTR edge detection — drives Connected / Disconnected transitions.
|
||||||
// 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();
|
||||||
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||||
match (was_connected, connected) {
|
match (was_connected, connected) {
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||||
|
last_frame_at = Instant::now();
|
||||||
}
|
}
|
||||||
(true, false) => {
|
(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();
|
decoder.reset();
|
||||||
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inactivity watchdog (spec §3.4): while Connected, if no frame
|
||||||
|
// arrived in the last INACTIVITY_MS, drive the session back to
|
||||||
|
// Disconnected.
|
||||||
|
if matches!(machine.current_state(), HostState::Connected)
|
||||||
|
&& last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS)
|
||||||
|
{
|
||||||
|
warn!("host inactivity timeout fired");
|
||||||
|
let _ = machine.dispatch(HostInput::InactivityTimeout).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed raw bytes into the frame decoder. For each complete frame:
|
||||||
|
/// - Ok → route into the command channel (or emit sync ERR on parse fail)
|
||||||
|
/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME)
|
||||||
|
///
|
||||||
|
/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the
|
||||||
|
/// payload slice is valid only for the duration of the callback.
|
||||||
|
async fn dispatch_bytes(
|
||||||
|
decoder: &mut FrameDecoder,
|
||||||
|
bytes: &[u8],
|
||||||
|
commands: &'static CommandChannel,
|
||||||
|
outbound: &'static OutboundChannel,
|
||||||
|
) {
|
||||||
|
// `FrameDecoder::feed` hands payloads by reference; we must synchronously
|
||||||
|
// copy any data we need before the callback returns. To avoid fighting
|
||||||
|
// lifetimes we accumulate per-frame work as an enum.
|
||||||
|
let mut work: heapless::Vec<FrameWork, 4> = heapless::Vec::new();
|
||||||
|
decoder.feed(bytes, |res| match res {
|
||||||
|
FrameResult::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
let mut p: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }> =
|
||||||
|
heapless::Vec::new();
|
||||||
|
let _ = p.extend_from_slice(payload);
|
||||||
|
let _ = work.push(FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload: p,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FrameResult::Err(_e) => {
|
||||||
|
let _ = work.push(FrameWork::Frame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for w in work {
|
||||||
|
match w {
|
||||||
|
FrameWork::Ok {
|
||||||
|
type_id,
|
||||||
|
tag,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
dispatch_frame(type_id, tag, &payload, commands, outbound).await;
|
||||||
|
}
|
||||||
|
FrameWork::Frame => {
|
||||||
|
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
enum FrameWork {
|
||||||
|
Ok {
|
||||||
|
type_id: u8,
|
||||||
|
tag: u16,
|
||||||
|
payload: heapless::Vec<u8, { crate::protocol::MAX_PAYLOAD_FIELD }>,
|
||||||
|
},
|
||||||
|
Frame,
|
||||||
|
}
|
||||||
+81
-4
@@ -39,7 +39,9 @@ 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, RadioEventChannel, ResponseChannel};
|
use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
@@ -55,11 +57,13 @@ cfg_if::cfg_if! {
|
|||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::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();
|
||||||
|
#[cfg(not(test))]
|
||||||
|
static mut INFO: Option<Info> = None;
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
@@ -81,8 +85,18 @@ async fn run(spawner: Spawner) {
|
|||||||
let board = <board::Board as LoRaBoard>::init();
|
let board = <board::Board as LoRaBoard>::init();
|
||||||
let parts = board.into_parts();
|
let parts = board.into_parts();
|
||||||
|
|
||||||
|
// Build the GET_INFO struct once at boot. This is the authoritative
|
||||||
|
// snapshot of device capabilities reported over the wire.
|
||||||
|
let info = build_info(parts.mac);
|
||||||
|
// SAFETY: touched only here at boot, before any task is spawned.
|
||||||
|
let info_ref: &'static Info = unsafe {
|
||||||
|
INFO = Some(info);
|
||||||
|
#[allow(static_mut_refs)]
|
||||||
|
INFO.as_ref().expect("INFO just initialized")
|
||||||
|
};
|
||||||
|
|
||||||
spawner.spawn(
|
spawner.spawn(
|
||||||
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &RADIO_EVENTS)
|
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
||||||
.expect("spawn radio_task"),
|
.expect("spawn radio_task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,7 +104,7 @@ async fn run(spawner: Spawner) {
|
|||||||
host::host_task(
|
host::host_task(
|
||||||
parts.host,
|
parts.host,
|
||||||
&COMMANDS,
|
&COMMANDS,
|
||||||
&RESPONSES,
|
&OUTBOUND,
|
||||||
&DISPLAY_COMMANDS,
|
&DISPLAY_COMMANDS,
|
||||||
parts.display.is_some(),
|
parts.display.is_some(),
|
||||||
parts.mac,
|
parts.mac,
|
||||||
@@ -98,6 +112,9 @@ async fn run(spawner: Spawner) {
|
|||||||
.expect("spawn host_task"),
|
.expect("spawn host_task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||||
|
let _ = info_ref;
|
||||||
|
|
||||||
if let Some(dp) = parts.display {
|
if let Some(dp) = parts.display {
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
{
|
{
|
||||||
@@ -117,3 +134,63 @@ async fn run(spawner: Spawner) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn build_info(mac: [u8; 6]) -> Info {
|
||||||
|
let (fmin, fmax) = <board::Board as LoRaBoard>::freq_range_hz();
|
||||||
|
let chip_id = <board::Board as LoRaBoard>::radio_chip_id();
|
||||||
|
let (tx_min, tx_max) = board::Board::TX_POWER_RANGE;
|
||||||
|
|
||||||
|
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
|
||||||
|
mcu_uid[..6].copy_from_slice(&mac);
|
||||||
|
|
||||||
|
Info {
|
||||||
|
proto_major: 1,
|
||||||
|
proto_minor: 0,
|
||||||
|
fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0),
|
||||||
|
fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0),
|
||||||
|
fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0),
|
||||||
|
radio_chip_id: chip_id,
|
||||||
|
capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX,
|
||||||
|
// SX126x supports SF5–SF12 (LLCC68 tops out at SF11 — that board
|
||||||
|
// can override this via a board-level trait method later).
|
||||||
|
supported_sf_bitmap: 0x1FE0,
|
||||||
|
// Sub-GHz BW enum values 0..9. SX128x would flip 10..13; add as
|
||||||
|
// boards come online.
|
||||||
|
supported_bw_bitmap: 0x03FF,
|
||||||
|
max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16,
|
||||||
|
// Real channel depths from channel.rs.
|
||||||
|
rx_queue_capacity: 32,
|
||||||
|
tx_queue_capacity: 1,
|
||||||
|
freq_min_hz: fmin,
|
||||||
|
freq_max_hz: fmax,
|
||||||
|
tx_power_min_dbm: tx_min,
|
||||||
|
tx_power_max_dbm: tx_max,
|
||||||
|
mcu_uid_len: 6,
|
||||||
|
mcu_uid,
|
||||||
|
radio_uid_len: 0,
|
||||||
|
radio_uid: [0u8; MAX_RADIO_UID_LEN],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const fn parse_u8_or(s: &str, default: u8) -> u8 {
|
||||||
|
// CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at
|
||||||
|
// compile time via a tiny constant function instead of pulling in a
|
||||||
|
// dependency.
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut out: u32 = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let b = bytes[i];
|
||||||
|
if b < b'0' || b > b'9' {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
out = out * 10 + (b - b'0') as u32;
|
||||||
|
if out > u8::MAX as u32 {
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out as u8
|
||||||
|
}
|
||||||
+11
-7
@@ -1,12 +1,16 @@
|
|||||||
//! Wire protocol types.
|
//! Wire protocol types for DongLoRa Protocol v2.
|
||||||
//!
|
//!
|
||||||
//! The definitions live in the `donglora-protocol` crate so host-side
|
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||||
//! Rust projects (client-rs, mux-rs, bridge) can share them with the
|
//! Rust projects can share them with the firmware without duplicating
|
||||||
//! firmware without duplicating wire layouts. This module re-exports
|
//! wire layouts. This module re-exports them unchanged so firmware
|
||||||
//! them unchanged for existing `crate::protocol::*` call sites.
|
//! modules can `use crate::protocol::*;`.
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use donglora_protocol::{
|
pub use donglora_protocol::{
|
||||||
Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT,
|
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||||
RADIO_CONFIG_SIZE, TX_POWER_MAX,
|
DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult,
|
||||||
|
FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode,
|
||||||
|
LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload,
|
||||||
|
SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN,
|
||||||
|
MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME,
|
||||||
};
|
};
|
||||||
+516
-453
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user