Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6867439044
|
||
|
|
bcfe736b30
|
||
|
|
0033bbb941
|
||
|
|
42a6b1142d
|
||
|
|
fa1eeceb7c
|
||
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
No files matched your search
+161
@@ -7,6 +7,167 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.1] - 2026-04-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Radio no longer drops to Idle mid-RX-session on transient failures.**
|
||||
Extends the v1.1.0 invariant ("`SET_CONFIG` during RX auto-re-arms")
|
||||
to all failure paths that can occur while a client has RX active:
|
||||
- `Receiving.on(RxFailed) => Idle` now transitions to `ResumingRx`
|
||||
instead of `Idle`. A transient `next_packet` error re-arms the chip
|
||||
rather than ending the RX session.
|
||||
- `TransmittingFromRx.on(TransmitFailed) => Idle` now transitions to
|
||||
`ResumingRx`. TX failure during an active RX session returns
|
||||
`TX_FAILED` to the caller and resumes RX, matching the shape of the
|
||||
adjacent `ChannelBusy` handler.
|
||||
- `ResumingRx.on(StartRxFailed) => Idle` is now a bounded retry
|
||||
(5 × 25 ms backoff via a new `bump_resume_retries` action and a
|
||||
`ResumeRxRetry` / `ResumeRxExhausted` pair of internal events).
|
||||
The chip only lands in `Idle` after exhausting the retry budget —
|
||||
which means it's genuinely wedged and needs reconfiguring.
|
||||
|
||||
Visible effect: the OLED dashboard no longer bounces to Splash when
|
||||
`ai-bot` (or any other client that TXes while RX is running) sends a
|
||||
packet. Previously any of the three transitions above would fire
|
||||
`RadioEvent::Idle`, which the display uses as its `Dashboard → Splash`
|
||||
trigger; the radio would land in `Idle` with no client aware enough
|
||||
to re-send `RX_START`, stranding the dashboard until the session was
|
||||
manually torn down and restarted.
|
||||
|
||||
- Removed `announce_rx_failed` helper action: its only purpose was
|
||||
emitting `RadioEvent::Idle` on the now-deleted `Receiving.on(RxFailed)
|
||||
=> Idle` edge. No replacement needed — `ResumingRx` handles recovery
|
||||
silently.
|
||||
|
||||
## [1.2.0] - 2026-04-22
|
||||
|
||||
### Added
|
||||
|
||||
- **OLED auto-dim after inactivity.** The display is bright on any
|
||||
activity — splash entry, mode change into a dashboard leaf, packet
|
||||
RX/TX — and dims after a short idle window. Splash holds bright for
|
||||
10 s, then drops to dim while the LearnMore / Info carousel keeps
|
||||
alternating. The dashboard stays bright for 3 s after the last
|
||||
packet or mode change, then dims in place without changing
|
||||
Listening/Transmitting state. Cross-leaf TX↔RX hops transition
|
||||
bright→bright with no intermediate dim — `set_dim` is fired as an
|
||||
action by the 3 s timer handler rather than as a sibling `exit:`
|
||||
action, so mode-change leaf-crossings never see a flash.
|
||||
- `DisplayBrightness` trait in `src/driver/mod.rs` unifies the
|
||||
two-level backlight API across the `ssd1306` crate's `Ssd1306Async`
|
||||
and the in-tree `Sh1106` driver. Contrast values centralized as
|
||||
`CONTRAST_BRIGHT` (`0xFF`) and `CONTRAST_DIM` (`0x08`) constants so
|
||||
the two levels can be tuned in one place.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/display.rs` statechart restructured:
|
||||
- `Splash` gains `entry: set_bright` plus an action-only
|
||||
`on(after 10s) => set_dim` handler so the splash bright window
|
||||
is time-scoped without disturbing the LM↔Info alternation.
|
||||
- `Dashboard` `Listening` and `Transmitting` each gain a nested
|
||||
`*Bright` substate owning the `entry: set_bright` action. Packet
|
||||
handlers moved into the leaves and target the leaf's `*Bright` —
|
||||
sibling-to-sibling within the leaf restarts the 3 s timer without
|
||||
re-entering the leaf itself (paint / 1 s sparkline timer
|
||||
preserved). The 3 s timeout handler fires `set_dim` as an action
|
||||
and up-transitions to the leaf, parking the machine at the leaf
|
||||
with no active child.
|
||||
- `On.on(CmdReset)` now targets `LearnMore` directly rather than
|
||||
`Splash`. Under hsmc 0.2 up-transition semantics, targeting
|
||||
`Splash` from within `Splash.LearnMore` (USB-disconnect race path)
|
||||
would only unwind below `Splash` and park with no active child,
|
||||
leaving the LM↔Info alternation timer dead and the QR frozen on
|
||||
screen.
|
||||
- `LoRaBoard::DisplayDriver` trait bound extended with
|
||||
`+ DisplayBrightness` so brightness is part of the board contract
|
||||
alongside `DrawTarget<Color = BinaryColor>`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Elecrow ThinkNode-M2 board doc corrected: the shipping revision
|
||||
carries a WCH CH340K USB-UART bridge (USB `1a86:7522`), not a
|
||||
Silicon Labs CP2102. `just flash elecrow_thinknode_m2` now matches
|
||||
the CH340K / CH340 VID:PIDs directly instead of falling through
|
||||
to espflash auto-detect.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `hsmc` bumped to `0.2` to pick up the up-transition fix (transition
|
||||
to an already-active ancestor unwinds only the subtree strictly
|
||||
below the target; no exit/entry on the target; no default descent).
|
||||
|
||||
## [1.1.0] - 2026-04-22
|
||||
|
||||
Small but high-value behavior change: `SET_CONFIG` issued while the
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+11
-4
@@ -486,7 +486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "0.5.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
@@ -527,7 +527,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora-protocol"
|
||||
version = "0.1.0"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bae9e9e5a8cebc6b4167f9ef696b61ce25b94c6d8283dbe1b2e97075a481b9d"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"heapless 0.9.2",
|
||||
@@ -1389,6 +1391,7 @@ version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
@@ -1401,7 +1404,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960effec6ded897c013d5cde8fb7f09bf0137e783efc4c2e0346092a2941c46e"
|
||||
dependencies = [
|
||||
"embassy-futures",
|
||||
"embassy-sync 0.8.0",
|
||||
@@ -1412,7 +1417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e1933eea1e95edb4fa03688f969f0a1ffabc4f16fc396b40ead6bedd5c4bdff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+5
-4
@@ -3,13 +3,14 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.5.0"
|
||||
version = "1.2.1"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
license = "MIT"
|
||||
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"]
|
||||
categories = ["embedded", "hardware-support", "no-std"]
|
||||
|
||||
@@ -29,8 +30,8 @@ embassy-usb = { version = "0.6", features = ["defmt"] }
|
||||
embassy-usb-driver = "0.2"
|
||||
ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
||||
heapless = "0.9"
|
||||
donglora-protocol = { path = "../protocol" }
|
||||
hsmc = { path = "../../hsmc/hsmc", default-features = false, features = ["embassy"] }
|
||||
donglora-protocol = "1"
|
||||
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
||||
lora-phy = "3.0"
|
||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||
static_cell = "2.1"
|
||||
|
||||
+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}})"; \
|
||||
case "$target" in \
|
||||
xtensa-*) \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
|
||||
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60" "1a86:7522" "1a86:7523"); \
|
||||
if [ -n "$port" ]; then \
|
||||
echo "Using $port"; \
|
||||
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! ELECROW ThinkNode-M2 — ESP32-S3 Meshtastic handset with SH1106 OLED.
|
||||
//!
|
||||
//! Tamagotchi-form-factor handheld: ESP32-S3-WROOM-1-N4 + SX1262 + 1.3" SH1106
|
||||
//! OLED + CP2102 UART bridge + 1000 mAh Li-Po. Sold pre-loaded with Meshtastic;
|
||||
//! this firmware turns it into a DongLoRa radio bridge.
|
||||
//! OLED + WCH CH340K UART bridge + 1000 mAh Li-Po. Sold pre-loaded with
|
||||
//! Meshtastic; this firmware turns it into a DongLoRa radio bridge.
|
||||
//!
|
||||
//! Pin map (verified from Meshtastic's variants/esp32s3/ELECROW-ThinkNode-M2
|
||||
//! and PR meshtastic/firmware#6354):
|
||||
@@ -11,7 +11,7 @@
|
||||
//! LoRa pwr: GPIO48 (must be HIGH before any SPI traffic)
|
||||
//! OLED: I2C0 SDA=16, SCL=15, SH1106 @ 0x3C
|
||||
//! VEXT: GPIO46 (active HIGH — gates OLED supply)
|
||||
//! UART: TX=43, RX=44 via CP2102 bridge
|
||||
//! UART: TX=43, RX=44 via CH340K bridge (USB 1a86:7522)
|
||||
//! LED: GPIO6 (active HIGH indicator)
|
||||
//!
|
||||
//! Notable differences from the Heltec ESP32-S3 boards:
|
||||
|
||||
+20
-2
@@ -9,6 +9,8 @@
|
||||
use embedded_graphics::draw_target::DrawTarget;
|
||||
use embedded_graphics::pixelcolor::BinaryColor;
|
||||
|
||||
use crate::driver::DisplayBrightness;
|
||||
|
||||
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
|
||||
pub trait RgbLed {
|
||||
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
|
||||
@@ -35,8 +37,9 @@ pub trait LoRaBoard: Sized {
|
||||
/// Display peripheral bundle (I2C bus for display init).
|
||||
type DisplayParts;
|
||||
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor>;
|
||||
/// Concrete display driver type (must implement DrawTarget for rendering
|
||||
/// and DisplayBrightness for the two-level dim control).
|
||||
type DisplayDriver: DrawTarget<Color = BinaryColor> + DisplayBrightness;
|
||||
|
||||
/// RGB LED driver. Boards without an LED use `()`.
|
||||
type LedDriver: RgbLed;
|
||||
@@ -47,6 +50,21 @@ pub trait LoRaBoard: Sized {
|
||||
/// Read the board's unique hardware address (MAC, device ID, etc.).
|
||||
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.
|
||||
fn into_parts(
|
||||
self,
|
||||
|
||||
+62
-32
@@ -1,54 +1,84 @@
|
||||
//! Inter-task communication channels.
|
||||
//! Inter-task communication channels for DongLoRa Protocol v2.
|
||||
//!
|
||||
//! - [`CommandChannel`]: USB → Radio (host commands)
|
||||
//! - [`ResponseChannel`]: Radio → USB (firmware responses)
|
||||
//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset)
|
||||
//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio)
|
||||
//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands)
|
||||
//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE
|
||||
//! and tag-0 async RX/ERR events in one queue)
|
||||
//! - [`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::channel::Channel;
|
||||
|
||||
use crate::protocol::{Command, RadioConfig, Response};
|
||||
use crate::protocol::{Command, DeviceMessage, LoRaConfig};
|
||||
|
||||
/// Display commands routed from host_task to display_task.
|
||||
#[derive(Debug, Clone, Copy, defmt::Format)]
|
||||
pub enum DisplayCommand {
|
||||
On,
|
||||
Off,
|
||||
#[allow(dead_code)] // only used by USB host task, not UART
|
||||
// ── Host → Radio: tag-correlated commands ───────────────────────────
|
||||
|
||||
/// An event flowing from the host task into the radio task.
|
||||
///
|
||||
/// `Command` carries a non-zero correlation tag (the host framing layer
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// USB-to-display command channel (depth 4: display commands are rare events).
|
||||
pub type DisplayCommandChannel = Channel<CriticalSectionRawMutex, DisplayCommand, 4>;
|
||||
/// Host-to-radio event channel (depth 8).
|
||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, InboundEvent, 8>;
|
||||
|
||||
/// Host-to-radio command channel (depth 16: buffer bursty host command sequences).
|
||||
pub type CommandChannel = Channel<CriticalSectionRawMutex, Command, 16>;
|
||||
// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ──────────────
|
||||
|
||||
/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes).
|
||||
pub type ResponseChannel = Channel<CriticalSectionRawMutex, Response, 32>;
|
||||
/// One outbound D→H frame carrying its tag. Tag-correlated messages
|
||||
/// (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
|
||||
/// every mode transition and every RF packet. Mode events and data events
|
||||
/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio
|
||||
/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just
|
||||
/// happened* without implying a mode change.
|
||||
/// Radio-to-host outbound channel (depth 32: enough to hold a burst of
|
||||
/// RX events + queued command responses without stalling the radio ISR).
|
||||
pub type OutboundChannel = Channel<CriticalSectionRawMutex, OutboundFrame, 32>;
|
||||
|
||||
// ── 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)]
|
||||
pub enum RadioEvent {
|
||||
/// Radio is now in RX mode.
|
||||
EnteredRx,
|
||||
/// Radio is now in TX mode.
|
||||
EnteredTx,
|
||||
/// Radio is now idle.
|
||||
Idle,
|
||||
/// A packet was received with these link metrics.
|
||||
PacketRx { rssi: i16, snr: Option<i16> },
|
||||
/// A packet was transmitted successfully.
|
||||
PacketTx,
|
||||
/// `SetConfig` applied a new validated config.
|
||||
ConfigChanged(RadioConfig),
|
||||
ConfigChanged(LoRaConfig),
|
||||
}
|
||||
|
||||
/// Radio → Display event stream (depth 32: buffer RX bursts while the
|
||||
/// display drains slowly).
|
||||
pub type RadioEventChannel = Channel<CriticalSectionRawMutex, RadioEvent, 32>;
|
||||
+96
-25
@@ -37,8 +37,9 @@ use embassy_time::Timer;
|
||||
use hsmc::statechart;
|
||||
|
||||
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||
use crate::driver::DisplayBrightness;
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||
use crate::protocol::RadioConfig;
|
||||
use crate::protocol::LoRaConfig;
|
||||
|
||||
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).
|
||||
PacketTx,
|
||||
ConfigChanged(RadioConfig),
|
||||
ConfigChanged(LoRaConfig),
|
||||
}
|
||||
|
||||
impl From<RadioEvent> for DisplayEvent {
|
||||
@@ -109,7 +110,7 @@ struct RunState {
|
||||
rssi_count: usize,
|
||||
current_slot_rssi: i16,
|
||||
current_slot_tx: bool,
|
||||
config: Option<RadioConfig>,
|
||||
config: Option<LoRaConfig>,
|
||||
rx_count: u32,
|
||||
tx_count: u32,
|
||||
last_rssi: Option<i16>,
|
||||
@@ -220,6 +221,14 @@ impl DisplayContext {
|
||||
Timer::after_millis(50).await;
|
||||
self.peripherals.led.set_rgb(0, 0, 0).await;
|
||||
}
|
||||
|
||||
async fn oled_bright(&mut self) {
|
||||
self.peripherals.display.set_bright().await;
|
||||
}
|
||||
|
||||
async fn oled_dim(&mut self) {
|
||||
self.peripherals.display.set_dim().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statechart ─────────────────────────────────────────────────────
|
||||
@@ -243,16 +252,26 @@ statechart! {
|
||||
on(CmdOff) => Off;
|
||||
// `CmdReset` + `RadioIdle` race on USB disconnect; this handler
|
||||
// makes either ordering land in the same place. Reset the run
|
||||
// state, turn off any blink-in-progress, and re-enter Splash —
|
||||
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
|
||||
on(CmdReset) => reset_run_state, turn_led_off, Splash;
|
||||
// state, turn off any blink-in-progress, and land in LearnMore
|
||||
// with a fresh 5 s timer. We target `LearnMore` (not `Splash`)
|
||||
// because under hsmc 0.2 up-transition semantics, targeting a
|
||||
// state that's already on the active path would only unwind
|
||||
// below it — we'd park at `Splash` with no active child and
|
||||
// the alternation timer would never restart.
|
||||
on(CmdReset) => reset_run_state, turn_led_off, LearnMore;
|
||||
// Config can arrive before the dashboard is up; cache it anywhere.
|
||||
on(ConfigChanged(cfg: 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 {
|
||||
entry: set_bright;
|
||||
default(LearnMore);
|
||||
on(EnteredRx) => Listening;
|
||||
on(EnteredTx) => Transmitting;
|
||||
on(after Duration::from_secs(10)) => set_dim;
|
||||
|
||||
state LearnMore {
|
||||
entry: paint_learn_more;
|
||||
@@ -265,24 +284,56 @@ statechart! {
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard brightness pattern: each mode-leaf holds a `Bright`
|
||||
// substate. `entry: set_bright` owns the backlight going on;
|
||||
// `set_dim` is fired **as an action** by the 3 s timeout
|
||||
// handler, immediately before the up-transition that parks
|
||||
// the machine at the leaf. The symmetric `exit: set_dim`
|
||||
// would also fire on mode-change leaf-crossings (e.g.
|
||||
// ListeningBright → TransmittingBright via EnteredTx), and
|
||||
// the subsequent default descent would fire `set_bright`
|
||||
// again — producing a visible dim/bright flash on every
|
||||
// TX↔RX swap. Anchoring dim to the *timer* handler instead
|
||||
// means leaf-crossings go bright→bright with no intermediate
|
||||
// dim action. CmdOff/CmdReset exit paths don't fire set_dim
|
||||
// either, which is fine — the display is blanked or walked
|
||||
// back to Splash, which re-asserts its own brightness.
|
||||
state Dashboard {
|
||||
default(Listening);
|
||||
on(RadioIdle) => Splash;
|
||||
|
||||
// Packet events are pure data + one-shot LED feedback.
|
||||
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||
|
||||
state Listening {
|
||||
entry: paint_listening;
|
||||
default(ListeningBright);
|
||||
on(EnteredTx) => Transmitting;
|
||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_listening;
|
||||
// Split from the transition below because hsmc rejects
|
||||
// payload bindings on transition-bearing handlers.
|
||||
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||
on(PacketRx) => ListeningBright;
|
||||
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||
on(PacketTx) => ListeningBright;
|
||||
|
||||
state ListeningBright {
|
||||
entry: set_bright;
|
||||
on(after Duration::from_secs(3)) => set_dim, Listening;
|
||||
}
|
||||
}
|
||||
|
||||
state Transmitting {
|
||||
entry: paint_transmitting;
|
||||
default(TransmittingBright);
|
||||
on(EnteredRx) => Listening;
|
||||
on(every Duration::from_millis(1000)) => advance_sparkline, paint_transmitting;
|
||||
on(PacketRx { rssi: i16, snr: Option<i16> }) => record_rx_packet, blink_rx_led;
|
||||
on(PacketRx) => TransmittingBright;
|
||||
on(PacketTx) => record_tx_packet, blink_tx_led;
|
||||
on(PacketTx) => TransmittingBright;
|
||||
|
||||
state TransmittingBright {
|
||||
entry: set_bright;
|
||||
on(after Duration::from_secs(3)) => set_dim, Transmitting;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,13 +399,21 @@ impl DisplayActions for DisplayActionContext<'_> {
|
||||
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);
|
||||
}
|
||||
|
||||
async fn advance_sparkline(&mut self) {
|
||||
self.run.advance_slot();
|
||||
}
|
||||
|
||||
async fn set_bright(&mut self) {
|
||||
self.oled_bright().await;
|
||||
}
|
||||
|
||||
async fn set_dim(&mut self) {
|
||||
self.oled_dim().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task entry point ───────────────────────────────────────────────
|
||||
@@ -440,7 +499,7 @@ mod render {
|
||||
use heapless::String;
|
||||
|
||||
use super::Badge;
|
||||
use crate::protocol::{Bandwidth, RadioConfig};
|
||||
use crate::protocol::{LoRaBandwidth, LoRaConfig};
|
||||
|
||||
pub const RSSI_HISTORY_LEN: usize = 128;
|
||||
|
||||
@@ -505,7 +564,7 @@ mod render {
|
||||
pub fn dashboard(
|
||||
target: &mut impl DrawTarget<Color = BinaryColor>,
|
||||
badge: Badge,
|
||||
config: Option<RadioConfig>,
|
||||
config: Option<LoRaConfig>,
|
||||
rx_count: u32,
|
||||
tx_count: u32,
|
||||
last_rssi: Option<i16>,
|
||||
@@ -588,21 +647,33 @@ mod render {
|
||||
let freq_mhz = cfg.freq_hz / 1_000_000;
|
||||
let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000;
|
||||
let bw_str = match cfg.bw {
|
||||
Bandwidth::Khz7 => "7.8",
|
||||
Bandwidth::Khz10 => "10.4",
|
||||
Bandwidth::Khz15 => "15.6",
|
||||
Bandwidth::Khz20 => "20.8",
|
||||
Bandwidth::Khz31 => "31.2",
|
||||
Bandwidth::Khz41 => "41.7",
|
||||
Bandwidth::Khz62 => "62.5",
|
||||
Bandwidth::Khz125 => "125",
|
||||
Bandwidth::Khz250 => "250",
|
||||
Bandwidth::Khz500 => "500",
|
||||
LoRaBandwidth::Khz7 => "7.8",
|
||||
LoRaBandwidth::Khz10 => "10.4",
|
||||
LoRaBandwidth::Khz15 => "15.6",
|
||||
LoRaBandwidth::Khz20 => "20.8",
|
||||
LoRaBandwidth::Khz31 => "31.2",
|
||||
LoRaBandwidth::Khz41 => "41.7",
|
||||
LoRaBandwidth::Khz62 => "62.5",
|
||||
LoRaBandwidth::Khz125 => "125",
|
||||
LoRaBandwidth::Khz250 => "250",
|
||||
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!(
|
||||
buf,
|
||||
"{}.{:03}/{}/{}/{}",
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr
|
||||
freq_mhz, freq_khz, bw_str, cfg.sf, cr_str
|
||||
);
|
||||
Text::with_alignment(
|
||||
&buf,
|
||||
|
||||
@@ -10,3 +10,56 @@ pub mod sh1106;
|
||||
feature = "waveshare_rp2040_lora"
|
||||
))]
|
||||
pub mod simple_led;
|
||||
|
||||
// ── Display brightness ────────────────────────────────────────────────
|
||||
//
|
||||
// The display statechart dims the OLED a few seconds after the last
|
||||
// activity. Our two driver families (`ssd1306` crate and our in-tree
|
||||
// `sh1106`) expose incompatible brightness APIs, so we wrap them in a
|
||||
// single async trait that the state-machine actions can call uniformly.
|
||||
|
||||
/// Two-level backlight control used by the display dashboard.
|
||||
pub trait DisplayBrightness {
|
||||
fn set_bright(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
fn set_dim(&mut self) -> impl core::future::Future<Output = ()>;
|
||||
}
|
||||
|
||||
// Contrast values for the two brightness levels, picked to be common across
|
||||
// both driver families. `0xFF` is the SSD1306/SH1106 maximum; `0x08` is ~3%
|
||||
// of max — visible in a dim room, barely noticeable in daylight. Tune here.
|
||||
const CONTRAST_BRIGHT: u8 = 0xFF;
|
||||
const CONTRAST_DIM: u8 = 0x08;
|
||||
|
||||
impl<DI, SIZE, MODE> DisplayBrightness for ssd1306::Ssd1306Async<DI, SIZE, MODE>
|
||||
where
|
||||
DI: display_interface::AsyncWriteOnlyDataCommand,
|
||||
SIZE: ssd1306::size::DisplaySizeAsync,
|
||||
{
|
||||
async fn set_bright(&mut self) {
|
||||
// `Brightness::custom(precharge, contrast)`. Matches the shape of
|
||||
// the crate's BRIGHTEST preset (precharge 0x2) at max contrast.
|
||||
let _ = self
|
||||
.set_brightness(ssd1306::prelude::Brightness::custom(0x2, CONTRAST_BRIGHT))
|
||||
.await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
// Low precharge pairs with low contrast — same combination the
|
||||
// crate's DIMMEST preset uses.
|
||||
let _ = self
|
||||
.set_brightness(ssd1306::prelude::Brightness::custom(0x1, CONTRAST_DIM))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "wio_tracker_l1", feature = "elecrow_thinknode_m2"))]
|
||||
impl<I> DisplayBrightness for sh1106::Sh1106<I>
|
||||
where
|
||||
I: embedded_hal_async::i2c::I2c,
|
||||
{
|
||||
async fn set_bright(&mut self) {
|
||||
let _ = self.set_brightness(CONTRAST_BRIGHT).await;
|
||||
}
|
||||
async fn set_dim(&mut self) {
|
||||
let _ = self.set_brightness(CONTRAST_DIM).await;
|
||||
}
|
||||
}
|
||||
+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
|
||||
//! accumulation, response encoding, and command routing.
|
||||
//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)`
|
||||
//! → `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
|
||||
//! reuse it; this module re-exports the codec and owns the
|
||||
//! firmware-only [`route_command`] (which touches embassy channels).
|
||||
//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame`
|
||||
//! → bytes ready for transport-level write.
|
||||
|
||||
pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME};
|
||||
use defmt::warn;
|
||||
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use crate::protocol::{Command, ErrorCode, Response};
|
||||
use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame};
|
||||
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).
|
||||
/// All other commands are forwarded to the radio task (response sent later).
|
||||
/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command
|
||||
/// rule in PROTOCOL.md is required to keep solicited responses ordered.
|
||||
pub async fn route_command(
|
||||
cmd: Command,
|
||||
/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`.
|
||||
/// - Known commands are forwarded to the radio task via `commands`.
|
||||
/// - Parse failures produce a synchronous `ERR` with the echoed tag on
|
||||
/// `outbound`, so the host sees a concrete answer instead of timing out.
|
||||
/// - The caller is responsible for rejecting `tag == 0` before calling
|
||||
/// 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,
|
||||
responses: &ResponseChannel,
|
||||
display_commands: &DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
outbound: &OutboundChannel,
|
||||
) {
|
||||
match cmd {
|
||||
Command::DisplayOn => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
if tag == 0 {
|
||||
// Hosts MUST NOT use tag 0 on outbound commands. Treat as
|
||||
// framing error; do not route to radio. (Spec §2.2)
|
||||
emit_async_err(outbound, ErrorCode::EFrame).await;
|
||||
return;
|
||||
}
|
||||
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 => {
|
||||
if has_display {
|
||||
display_commands.send(DisplayCommand::Off).await;
|
||||
responses.send(Response::Ok).await;
|
||||
} else {
|
||||
responses.send(Response::Error(ErrorCode::NoDisplay)).await;
|
||||
}
|
||||
}
|
||||
Command::GetMac => {
|
||||
responses.send(Response::MacAddress(mac)).await;
|
||||
}
|
||||
other => {
|
||||
commands.send(other).await;
|
||||
Err(e) => {
|
||||
let code = parse_err_to_wire_code(e);
|
||||
emit_sync_err(outbound, tag, code).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an async framing error (tag = 0x0000) per spec §2.5.
|
||||
pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) {
|
||||
let frame = OutboundFrame {
|
||||
tag: 0,
|
||||
msg: DeviceMessage::Err(code),
|
||||
};
|
||||
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 embassy_executor::task;
|
||||
use embassy_time::{Duration, Instant};
|
||||
use embedded_io_async::Write;
|
||||
|
||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||
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]
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UartParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
_mac: [u8; 6],
|
||||
) {
|
||||
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 {
|
||||
display_commands.send(DisplayCommand::On).await;
|
||||
}
|
||||
|
||||
let mut read_buf = [0u8; 64];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
let mut decoder = CobsDecoder::new();
|
||||
let mut read_buf = [0u8; READ_BUF];
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut last_frame_at = Instant::now();
|
||||
let mut connected = true;
|
||||
|
||||
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),
|
||||
responses.receive(),
|
||||
outbound.receive(),
|
||||
embassy_time::Timer::after(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::First(result) => {
|
||||
let n = match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
decoder.reset();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
continue;
|
||||
Either3::First(result) => match result {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => {
|
||||
last_frame_at = Instant::now();
|
||||
if !connected {
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::On);
|
||||
}
|
||||
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) => {
|
||||
#[cfg(feature = "debug-checkpoint")]
|
||||
crate::debug_blink::set(13);
|
||||
if let Some(frame) =
|
||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||
{
|
||||
if tx.write_all(frame).await.is_err() {
|
||||
warn!("UART write failed, response dropped");
|
||||
} else {
|
||||
#[cfg(feature = "debug-checkpoint")]
|
||||
crate::debug_blink::set(14);
|
||||
}
|
||||
}
|
||||
Either3::Third(()) => {}
|
||||
}
|
||||
|
||||
// Inactivity watchdog.
|
||||
if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) {
|
||||
warn!("UART host inactivity timeout fired");
|
||||
let _ = commands.try_send(InboundEvent::Reset);
|
||||
if has_display {
|
||||
let _ = display_commands.try_send(DisplayCommand::Reset);
|
||||
}
|
||||
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
|
||||
//! Host
|
||||
//! ├── Disconnected (default) no DTR
|
||||
//! └── Connected host attached
|
||||
//! entry: wake_display
|
||||
//! exit: stop_radio, reset_display
|
||||
//! ├── Disconnected (default) no DTR, or inactivity timer fired
|
||||
//! └── Connected host attached, liveness OK
|
||||
//! entry: wake_display, start_liveness_timer
|
||||
//! exit: reset_radio, reset_display, stop_liveness_timer
|
||||
//! ```
|
||||
//!
|
||||
//! The statechart tracks exactly one thing: DTR. Entry/exit actions
|
||||
//! replace the old `was_connected` edge-detector and emit the display
|
||||
//! wake / radio-stop / display-reset side-effects that used to be open
|
||||
//! coded around the `select3` loop.
|
||||
//! The statechart tracks one state bit: "is a host present?". DTR drives
|
||||
//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives
|
||||
//! the disconnect edge when DTR is held high but no bytes arrive.
|
||||
//!
|
||||
//! The data plane (COBS decoding, command routing, response encoding)
|
||||
//! stays imperative in `protocol_loop` for the same reason the radio's
|
||||
//! `rx_once` stayed out of its chart: `read_packet` blocks and can't
|
||||
//! live inside a statechart action. Host-contract lifecycle (one
|
||||
//! outstanding command, unsolicited `RxPacket`, synchronous local
|
||||
//! responses) is deliberately *not* modeled — any finer chart here
|
||||
//! would desynchronise from the concurrent `select3` reality and lie
|
||||
//! about what the firmware is actually doing.
|
||||
//! The data plane (frame decode, command dispatch, frame encode) stays
|
||||
//! imperative in `protocol_loop` for the same reason the radio's
|
||||
//! `rx_once` stayed out of its chart: `read_packet` blocks.
|
||||
|
||||
use defmt::warn;
|
||||
use embassy_executor::task;
|
||||
use embassy_futures::join::join;
|
||||
use embassy_time::{Duration, Instant};
|
||||
use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State};
|
||||
use embassy_usb::Builder;
|
||||
use hsmc::{statechart, Duration};
|
||||
use hsmc::{statechart, Duration as HsmcDuration};
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use super::framing::{self, CobsDecoder, MAX_FRAME};
|
||||
use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel};
|
||||
use crate::protocol::Command;
|
||||
use super::framing::{dispatch_frame, emit_async_err, encode_outbound};
|
||||
use crate::channel::{
|
||||
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)]
|
||||
pub enum HostInput {
|
||||
/// DTR rose: host opened the serial port.
|
||||
DtrRaised,
|
||||
/// DTR fell: host closed or disconnected.
|
||||
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 {
|
||||
commands: &'static CommandChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
@@ -68,9 +67,9 @@ impl HostContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_radio_best_effort(&self) {
|
||||
if self.commands.try_send(Command::StopRx).is_err() {
|
||||
warn!("host DTR-drop: StopRx dropped (command channel full)");
|
||||
fn send_radio_reset(&self) {
|
||||
if self.commands.try_send(InboundEvent::Reset).is_err() {
|
||||
warn!("radio Reset inject: command queue full");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +86,9 @@ statechart! {
|
||||
|
||||
state Connected {
|
||||
entry: wake_display;
|
||||
exit: stop_radio, reset_display;
|
||||
exit: reset_radio, reset_display;
|
||||
on(DtrDropped) => Disconnected;
|
||||
on(InactivityTimeout) => Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,8 @@ impl HostActions for HostActionContext<'_> {
|
||||
self.wake_display_if_present().await;
|
||||
}
|
||||
|
||||
async fn stop_radio(&mut self) {
|
||||
self.stop_radio_best_effort();
|
||||
async fn reset_radio(&mut self) {
|
||||
self.send_radio_reset();
|
||||
}
|
||||
|
||||
async fn reset_display(&mut self) {
|
||||
@@ -111,7 +111,7 @@ impl HostActions for HostActionContext<'_> {
|
||||
pub async fn host_task(
|
||||
parts: crate::board::UsbParts,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
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 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 (sender, receiver) = class.split();
|
||||
|
||||
@@ -157,10 +157,9 @@ pub async fn host_task(
|
||||
sender,
|
||||
receiver,
|
||||
commands,
|
||||
responses,
|
||||
outbound,
|
||||
display_commands,
|
||||
has_display,
|
||||
mac,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -170,10 +169,9 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
mut sender: Sender<'d, D>,
|
||||
mut receiver: Receiver<'d, D>,
|
||||
commands: &'static CommandChannel,
|
||||
responses: &'static ResponseChannel,
|
||||
outbound: &'static OutboundChannel,
|
||||
display_commands: &'static DisplayCommandChannel,
|
||||
has_display: bool,
|
||||
mac: [u8; 6],
|
||||
) {
|
||||
use embassy_futures::select::{select3, Either3};
|
||||
|
||||
@@ -182,55 +180,36 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
display_commands,
|
||||
has_display,
|
||||
});
|
||||
// Enter the default: Disconnected.
|
||||
machine.step(Duration::ZERO).await;
|
||||
machine.step(HsmcDuration::ZERO).await;
|
||||
|
||||
let mut read_buf = [0u8; MAX_PACKET];
|
||||
let mut write_buf = [0u8; MAX_FRAME];
|
||||
let mut cobs_encode_buf = [0u8; MAX_FRAME];
|
||||
let mut decoder = CobsDecoder::new();
|
||||
let mut read_buf = [0u8; MAX_USB_PACKET];
|
||||
let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
|
||||
let mut last_frame_at = Instant::now();
|
||||
|
||||
loop {
|
||||
match select3(
|
||||
receiver.read_packet(&mut read_buf),
|
||||
responses.receive(),
|
||||
embassy_time::Timer::after_millis(250),
|
||||
outbound.receive(),
|
||||
embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either3::First(Ok(0)) => {}
|
||||
Either3::First(Ok(n)) => {
|
||||
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;
|
||||
}
|
||||
last_frame_at = Instant::now();
|
||||
dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await;
|
||||
}
|
||||
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();
|
||||
embassy_time::Timer::after_millis(100).await;
|
||||
}
|
||||
Either3::Second(response) => {
|
||||
if let Some(frame) =
|
||||
framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf)
|
||||
{
|
||||
for chunk in frame.chunks(MAX_PACKET) {
|
||||
Either3::Second(frame) => {
|
||||
if let Some(n) = encode_outbound(&frame, &mut write_buf) {
|
||||
for chunk in write_buf[..n].chunks(MAX_USB_PACKET) {
|
||||
if sender.write_packet(chunk).await.is_err() {
|
||||
warn!("USB write failed, response dropped");
|
||||
warn!("USB write failed, frame dropped");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -239,27 +218,90 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>(
|
||||
Either3::Third(()) => {}
|
||||
}
|
||||
|
||||
// Edge-detect DTR against the machine's current state. The
|
||||
// machine's entry/exit actions own the side-effects. dispatch()
|
||||
// pushes the event AND drains the internal queue, so
|
||||
// current_state() reflects the post-dispatch state before we
|
||||
// loop back around.
|
||||
// DTR edge detection — drives Connected / Disconnected transitions.
|
||||
let connected = receiver.dtr();
|
||||
let was_connected = matches!(machine.current_state(), HostState::Connected);
|
||||
match (was_connected, connected) {
|
||||
(false, true) => {
|
||||
let _ = machine.dispatch(HostInput::DtrRaised).await;
|
||||
last_frame_at = Instant::now();
|
||||
}
|
||||
(true, false) => {
|
||||
// Belt-and-braces: reset decoder here too. The error
|
||||
// arm above typically fires on disconnect and already
|
||||
// resets it, but this makes intent explicit and
|
||||
// survives embassy-usb behavior changes where a
|
||||
// disconnect doesn't surface as a read error.
|
||||
decoder.reset();
|
||||
let _ = machine.dispatch(HostInput::DtrDropped).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 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))]
|
||||
use crate::board::LoRaBoard;
|
||||
#[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_if::cfg_if! {
|
||||
@@ -55,11 +57,13 @@ cfg_if::cfg_if! {
|
||||
#[cfg(not(test))]
|
||||
static COMMANDS: CommandChannel = CommandChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static RESPONSES: ResponseChannel = ResponseChannel::new();
|
||||
static OUTBOUND: OutboundChannel = OutboundChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new();
|
||||
#[cfg(not(test))]
|
||||
static mut INFO: Option<Info> = None;
|
||||
|
||||
#[cfg(not(test))]
|
||||
cfg_if::cfg_if! {
|
||||
@@ -81,8 +85,18 @@ async fn run(spawner: Spawner) {
|
||||
let board = <board::Board as LoRaBoard>::init();
|
||||
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(
|
||||
radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &RADIO_EVENTS)
|
||||
radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref)
|
||||
.expect("spawn radio_task"),
|
||||
);
|
||||
|
||||
@@ -90,7 +104,7 @@ async fn run(spawner: Spawner) {
|
||||
host::host_task(
|
||||
parts.host,
|
||||
&COMMANDS,
|
||||
&RESPONSES,
|
||||
&OUTBOUND,
|
||||
&DISPLAY_COMMANDS,
|
||||
parts.display.is_some(),
|
||||
parts.mac,
|
||||
@@ -98,6 +112,9 @@ async fn run(spawner: Spawner) {
|
||||
.expect("spawn host_task"),
|
||||
);
|
||||
|
||||
// Suppress "assigned but never read" warning on non-USB/UART path.
|
||||
let _ = info_ref;
|
||||
|
||||
if let Some(dp) = parts.display {
|
||||
#[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
|
||||
//! Rust projects (client-rs, mux-rs, bridge) can share them with the
|
||||
//! firmware without duplicating wire layouts. This module re-exports
|
||||
//! them unchanged for existing `crate::protocol::*` call sites.
|
||||
//! Wire definitions live in the `donglora-protocol` crate so host-side
|
||||
//! Rust projects can share them with the firmware without duplicating
|
||||
//! wire layouts. This module re-exports them unchanged so firmware
|
||||
//! modules can `use crate::protocol::*;`.
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use donglora_protocol::{
|
||||
Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT,
|
||||
RADIO_CONFIG_SIZE, TX_POWER_MAX,
|
||||
cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage,
|
||||
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,
|
||||
};
|
||||
+577
-456
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user