Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027ac5eb04
|
||
|
|
b7d9b37bb0
|
||
|
|
b0ce0a7aca
|
||
|
|
2a3c2be060
|
No files matched your search
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
+9
-2
@@ -486,7 +486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donglora"
|
||||
version = "0.5.0"
|
||||
version = "1.1.0"
|
||||
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",
|
||||
]
|
||||
@@ -1402,6 +1405,8 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d8cb8820e7ff5fa4fa4b5dbb78b9f59d26ccc8e3bcf69d8f4c4ce29ee01b93a"
|
||||
dependencies = [
|
||||
"embassy-futures",
|
||||
"embassy-sync 0.8.0",
|
||||
@@ -1413,6 +1418,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4fa4dad2c05309fae4f6d15b74d9b37c1fd7325a96701972fdad89637635160"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+5
-4
@@ -3,13 +3,14 @@ members = ["."]
|
||||
|
||||
[package]
|
||||
name = "donglora"
|
||||
version = "0.5.0"
|
||||
version = "1.1.0"
|
||||
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.1.0"
|
||||
hsmc = { version = "0.1.0", 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
@@ -47,6 +47,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>;
|
||||
+30
-18
@@ -38,7 +38,7 @@ use hsmc::statechart;
|
||||
|
||||
use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed};
|
||||
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel};
|
||||
use crate::protocol::RadioConfig;
|
||||
use crate::protocol::LoRaConfig;
|
||||
|
||||
use render::{BoardInfo, RSSI_HISTORY_LEN};
|
||||
|
||||
@@ -75,7 +75,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 +109,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>,
|
||||
@@ -247,7 +247,7 @@ statechart! {
|
||||
// descend_defaults lands us in LearnMore with a fresh 5 s timer.
|
||||
on(CmdReset) => reset_run_state, turn_led_off, Splash;
|
||||
// Config can arrive before the dashboard is up; cache it anywhere.
|
||||
on(ConfigChanged(cfg: RadioConfig)) => save_new_config;
|
||||
on(ConfigChanged(cfg: LoRaConfig)) => save_new_config;
|
||||
|
||||
state Splash {
|
||||
default(LearnMore);
|
||||
@@ -348,7 +348,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -440,7 +440,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 +505,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 +588,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,
|
||||
|
||||
+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,
|
||||
};
|
||||
+516
-453
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user