10 Commits
Author SHA1 Message Date
swaits e2ac01f0cb chore: repoint URLs at git.swaits.com and bump for release
CI / Test (all-features) (push) Canceled after 0s
CI / Test (default) (push) Canceled after 0s
CI / Test (no-default-features) (push) Canceled after 0s
CI / Clippy (all-features) (push) Canceled after 0s
CI / Clippy (default) (push) Canceled after 0s
CI / Clippy (no-default-features) (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
CI / Miri (push) Canceled after 0s
CI / Fuzz (60s per target) (push) Canceled after 0s
CI / Mutants (default) (push) Canceled after 0s
CI / Mutants (no-default-features) (push) Canceled after 0s
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:37 -06:00
swaits bb82fa279b chore: point repository at git.swaits.com
CI / Test (all-features) (push) Canceled after 0s
CI / Test (default) (push) Canceled after 0s
CI / Test (no-default-features) (push) Canceled after 0s
CI / Clippy (all-features) (push) Canceled after 0s
CI / Clippy (default) (push) Canceled after 0s
CI / Clippy (no-default-features) (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
CI / Miri (push) Canceled after 0s
CI / Fuzz (60s per target) (push) Canceled after 0s
CI / Mutants (default) (push) Canceled after 0s
CI / Mutants (no-default-features) (push) Canceled after 0s
2026-08-31 17:19:35 -06:00
swaits dba6f264cd chore: release 1.1.0
CI / Test (all-features) (push) Canceled after 0s
CI / Test (default) (push) Canceled after 0s
CI / Test (no-default-features) (push) Canceled after 0s
CI / Clippy (all-features) (push) Canceled after 0s
CI / Clippy (default) (push) Canceled after 0s
CI / Clippy (no-default-features) (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
CI / Miri (push) Canceled after 0s
CI / Fuzz (60s per target) (push) Canceled after 0s
CI / Mutants (default) (push) Canceled after 0s
CI / Mutants (no-default-features) (push) Canceled after 0s
2026-04-22 11:45:21 -06:00
swaits acf715b3f0 feat: auto-resume RX across SET_CONFIG (no more re-issue of RX_START)
Spec §3.5 and §6.3 (SET_CONFIG side effects) previously required the host to re-issue RX_START after any successful SET_CONFIG, because the radio was unconditionally left in idle. That chore is hostile to scanner, time-division, and dynamic-modulation use cases and violates the principle of least surprise.


Update the normative text so that SET_CONFIG applied while the radio is in continuous RX auto-re-arms RX with the new parameters once reconfiguration completes. RX ring is still cleared (stale packets from the old modulation are not semantically meaningful). The OK response is still held until the radio is ready, so hosts may TX / RX immediately after it.


Behavior when RX was not active on SET_CONFIG is unchanged: radio lands in idle. No wire-format change. Old hosts that re-issue RX_START after SET_CONFIG keep working (the redundant call is a no-op OK on a radio already in Receiving).
2026-04-22 11:44:53 -06:00
swaits 66a2484aeb ci: update fuzz target list to the v2 names
The Fuzz workflow step was still iterating the old v0.1 target names (parse_radio_config, cobs_decoder_feed, cobs_roundtrip, command_roundtrip, cobs_decoder_multi_frame, cobs_chunk_invariant). After the Protocol v2 rewrite only five targets exist: decoder_feed, frame_roundtrip, lora_config_roundtrip, parse_command, parse_device_message. Update the workflow to match.
2026-04-22 10:33:06 -06:00
swaits 9ec45e32b7 chore: update repository URL after GitHub rename
GitHub renamed the repo from donglora/protocol to donglora/protocol-rs (the Rust implementation is now distinguished from a forthcoming protocol-py sibling). Update Cargo.toml repository/homepage and README badge / link URLs so the published 1.0.0 crate points at the new location.
2026-04-22 09:24:01 -06:00
swaits 85420dc673 chore: release 1.0.0 2026-04-22 09:21:12 -06:00
swaits 4cccb65124 feat!: protocol v2 wire format and module restructure
The wire protocol is re-specified as DongLoRa Protocol v2 (see PROTOCOL.md). Every type_id, tag-correlation rule, and field layout is incompatible with 0.1.0.

* Add PROTOCOL.md as the normative spec for the v2 wire format.

* Replace framing.rs with a streaming FrameDecoder that emits (type_id, tag, payload) triples on each COBS-delimited, CRC-checked frame.

* Split the single-file lib.rs into dedicated modules: frame, commands, events, errors, modulation, info, crc, chip_id. Re-exports preserve convenience imports at the crate root.

* Replace RadioConfig / Bandwidth with a Modulation enum covering LoRa, FSK/GFSK, LR-FHSS, and FLRC, each with its own typed parameter struct.

* Add TxFlags, RxOrigin, SetConfigResult, OkPayload to model v2 response shapes, and RadioChipId for GET_INFO.

* Rewrite fuzz corpus: replace the 4 cobs_* / command_roundtrip targets with decoder_feed, frame_roundtrip, lora_config_roundtrip, and parse_device_message; drop parse_radio_config.

* Expand proptest and mutation-killer coverage for the new wire shape.

* Rewrite canonical-byte test vectors against the v2 spec.
2026-04-22 09:21:11 -06:00
swaits 35866edac4 ci: also run workflow on pushes to dev 2026-04-13 14:10:16 -06:00
swaits da9249446c ci: pin fuzz jobs to x86_64-unknown-linux-gnu
cargo-fuzz defaults to x86_64-unknown-linux-musl on Linux so ASAN can
statically link, but dtolnay/rust-toolchain@nightly doesn't ship a
musl std and GH runners don't have it preinstalled — build failed with
'can't find crate for core' on byteorder. Pinning both fuzz build and
fuzz run to the gnu target uses the std that comes with the toolchain
and works out of the box.
2026-04-13 14:07:24 -06:00
32 changed files with 7235 additions and 2303 deletions

No files matched your search

+7 -4
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [main, dev]
pull_request:
env:
@@ -120,13 +120,16 @@ jobs:
tool: cargo-fuzz
- name: Build fuzz targets
working-directory: fuzz
run: cargo +nightly fuzz build
# Pin to the gnu target — cargo-fuzz defaults to musl for ASAN
# static linking, but the nightly toolchain we install doesn't
# ship a musl std.
run: cargo +nightly fuzz build --target x86_64-unknown-linux-gnu
- name: Smoke-run every target
working-directory: fuzz
run: |
for target in parse_command parse_radio_config cobs_decoder_feed cobs_roundtrip command_roundtrip cobs_decoder_multi_frame cobs_chunk_invariant; do
for target in decoder_feed frame_roundtrip lora_config_roundtrip parse_command parse_device_message; do
echo "── $target ──"
cargo +nightly fuzz run "$target" -- -max_total_time=60
cargo +nightly fuzz run "$target" --target x86_64-unknown-linux-gnu -- -max_total_time=60
done
mutants:
+89
View File
@@ -4,6 +4,95 @@ All notable changes to this crate are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).
## 1.1.0 — 2026-04-22
Spec-only release. No code change in the crate; `PROTOCOL.md`
normative behavior is amended so `SET_CONFIG` no longer disrupts
continuous RX.
### Changed
- `PROTOCOL.md` §3.5 ("Operations Spanning Reconfiguration") and
§6.3 ("Side effects on `APPLIED`"): a `SET_CONFIG` issued while
the radio is in continuous RX now **auto-re-arms RX** with the
new parameters once reconfiguration completes. The RX ring is
still cleared. The host does **not** need to re-issue `RX_START`
after `SET_CONFIG`.
Rationale: the previous "RX is stopped, re-issue RX_START
yourself" rule was hostile to scanner / time-division /
dynamic-modulation workflows and violated the principle of least
surprise. Scope of the change is strictly additive: hosts that
still issue a redundant `RX_START` after `SET_CONFIG` continue
to work (firmware replies `OK` with the radio already in
Receiving).
- No wire-format, field-layout, or `type_id` changes. The CRC, COBS
framing, tag semantics, and every message shape are byte-for-byte
identical to 1.0.0.
### Compatibility
- Wire-compatible with 1.0.0 firmware and hosts.
- Behavior-compatible with 1.0.0 hosts (old-style "always re-issue
RX_START" code paths are now no-ops but still correct).
- New-style hosts that rely on auto-resume **require** firmware
`donglora 1.1.0` or later, which implements the new semantics.
## 1.0.0 — 2026-04-22
The 1.0 wire-format rewrite. Every message shape, tag-correlation
rule, and field layout is re-specified in the new normative
`PROTOCOL.md` and is incompatible with the 0.1.x line.
### Breaking
- **DongLoRa Protocol v2 wire format.** Every `type_id`, tag-correlation
rule, and field layout is redefined in `PROTOCOL.md` §2 through §16.
No byte compatibility with 0.1.x.
- **Module split.** The single-file `lib.rs` is replaced with dedicated
modules: `frame`, `commands`, `events`, `errors`, `modulation`,
`info`, `crc`, `chip_id`. Re-exports at the crate root preserve the
short-form imports (`Command`, `ErrorCode`, etc.), but full paths to
the old `framing` module and `Response` / `RadioConfig` / `Bandwidth`
types no longer resolve.
- **Framing replaced.** `src/framing.rs` is gone. The new
`frame::FrameDecoder` streams inbound bytes and yields
`FrameResult::Ok { type_id, tag, payload }` on each COBS-delimited,
CRC-verified frame. Encoding moves to `encode_frame(type_id, tag,
payload, out)`.
- **`Modulation` replaces `RadioConfig` + `Bandwidth`.** New enum
covering LoRa, FSK/GFSK, LR-FHSS, and FLRC, each with its own typed
parameter struct.
- **New response shapes.** `OkPayload`, `SetConfigResult`,
`TxDonePayload`, `RxPayload`, `RxOrigin`, and `TxFlags` model the
richer response surface required by v2.
### Added
- `PROTOCOL.md`: the normative specification of DongLoRa Protocol v2,
including wire examples, error taxonomy, state machine, and
multi-client semantics (§13).
- `RadioChipId` enum for `GET_INFO` chip identification.
- CRC-16/CCITT-FALSE helper (`crc`) exposed for integrity checks on
arbitrary payload slices.
- `cap` bitflag helpers on the `Info` struct for decoding capability
bitmaps.
### Changed
- Fuzz corpus rewritten: `decoder_feed`, `frame_roundtrip`,
`lora_config_roundtrip`, `parse_command`, `parse_device_message`
replace the 0.1 `cobs_*` and `command_roundtrip` targets.
- Proptest suite and mutation-killer set expanded to cover the new
wire shape.
- Canonical-byte vectors rewritten against `PROTOCOL.md` §C.
### CI
- Workflow now runs on pushes to `dev` in addition to `main`.
- Fuzz jobs pinned to `x86_64-unknown-linux-gnu` for reproducibility.
## 0.1.0 — 2026-04-13
Initial release. The DongLoRa wire-protocol types and COBS framing,
+4 -4
View File
@@ -1,13 +1,13 @@
[package]
name = "donglora-protocol"
version = "0.1.0"
version = "1.2.1"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "DongLoRa wire protocol types and COBS framing — shared between firmware and host crates"
repository = "https://github.com/donglora/protocol"
homepage = "https://github.com/donglora/protocol"
repository = "https://git.swaits.com/donglora/protocol-rs"
homepage = "https://git.swaits.com/donglora/protocol-rs"
documentation = "https://docs.rs/donglora-protocol"
readme = "README.md"
keywords = ["lora", "radio", "protocol", "no-std"]
@@ -32,7 +32,7 @@ defmt = { version = "1", optional = true }
default = ["defmt"]
# Enables `defmt::Format` derives on public types. On by default so firmware
# (the primary no_std consumer) just works; host crates disable default-features.
defmt = ["dep:defmt"]
defmt = ["dep:defmt", "heapless/defmt"]
# Enables `Debug` derives and other std-only niceties for host-side fuzz /
# proptest builds. No-op on firmware.
std = []
+1922
View File
File diff suppressed because it is too large. Load diff
+22 -11
View File
@@ -1,32 +1,43 @@
# donglora-protocol
[![CI](https://github.com/donglora/protocol/actions/workflows/ci.yml/badge.svg)](https://github.com/donglora/protocol/actions/workflows/ci.yml)
[![CI](https://git.swaits.com/donglora/protocol-rs/actions/workflows/ci.yml/badge.svg)](https://git.swaits.com/donglora/protocol-rs/actions?workflow=ci.yml)
[![Crates.io](https://img.shields.io/crates/v/donglora-protocol.svg)](https://crates.io/crates/donglora-protocol)
[![Docs.rs](https://docs.rs/donglora-protocol/badge.svg)](https://docs.rs/donglora-protocol)
Wire protocol types and COBS framing for [DongLoRa], shared between the
firmware and host crates (`client-rs`, `mux-rs`, `bridge`). `no_std`,
zero-alloc, fixed-width little-endian — see the firmware repo's
[`PROTOCOL.md`][protocol-md] for the full spec.
zero-alloc, fixed-width little-endian. See [`PROTOCOL.md`][protocol-md]
in this repo for the normative DongLoRa Protocol v2 specification.
[DongLoRa]: https://github.com/donglora
[protocol-md]: https://github.com/donglora/firmware/blob/main/PROTOCOL.md
[DongLoRa]: https://git.swaits.com/donglora
[protocol-md]: https://git.swaits.com/donglora/protocol-rs/src/branch/main/PROTOCOL.md
## Install
```toml
# Embedded / firmware (defmt::Format derives on by default):
donglora-protocol = "0.1"
donglora-protocol = "1"
# Host (no defmt):
donglora-protocol = { version = "0.1", default-features = false }
donglora-protocol = { version = "1", default-features = false }
```
## Layout
- `src/lib.rs` — `Command`, `Response`, `RadioConfig`, `Bandwidth`,
`ErrorCode` plus constants and validation/resolve helpers.
- `src/framing.rs` — `CobsDecoder` (streaming) and `cobs_encode_response`.
- `src/frame.rs`: `FrameDecoder` (streaming COBS + CRC decoder) and
`encode_frame(type_id, tag, payload, out)`.
- `src/commands.rs`: host-to-device `Command` enum, `type_id`
constants, and `TxFlags`.
- `src/events.rs`: device-to-host `DeviceMessage` (OK / ERR / RX /
TX_DONE), with the `OkPayload`, `SetConfigResult`, `RxPayload`,
`RxOrigin`, and `TxDonePayload` sub-shapes.
- `src/modulation.rs`: `Modulation` enum (LoRa, FSK/GFSK, LR-FHSS,
FLRC) and their typed parameter structs.
- `src/info.rs`: `GET_INFO` response payload + capability bitmap
helpers.
- `src/errors.rs`: wire-level `ErrorCode` enum.
- `src/chip_id.rs`: `RadioChipId` enum.
- `src/crc.rs`: CRC-16/CCITT-FALSE helper.
## Features
@@ -46,7 +57,7 @@ The crate ships its own quality gate via `just test`:
| `test-fmt` | `cargo fmt -- --check` |
| `test-doc` | `cargo doc --no-deps --all-features` |
| `test-deny` | `cargo deny check` |
| `test-fuzz` | 60 s on each of 7 cargo-fuzz targets (nightly) |
| `test-fuzz` | 60 s on each of 5 cargo-fuzz targets (nightly) |
| `test-miri` | miri on lib + vector tests, both feature shapes (nightly) |
| `test-mutants` | cargo-mutants under both feature configurations |
+13 -27
View File
@@ -22,6 +22,13 @@ features = ["std"]
[workspace]
members = ["."]
[[bin]]
name = "decoder_feed"
path = "fuzz_targets/decoder_feed.rs"
test = false
doc = false
bench = false
[[bin]]
name = "parse_command"
path = "fuzz_targets/parse_command.rs"
@@ -30,43 +37,22 @@ doc = false
bench = false
[[bin]]
name = "parse_radio_config"
path = "fuzz_targets/parse_radio_config.rs"
name = "parse_device_message"
path = "fuzz_targets/parse_device_message.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_decoder_feed"
path = "fuzz_targets/cobs_decoder_feed.rs"
name = "frame_roundtrip"
path = "fuzz_targets/frame_roundtrip.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_roundtrip"
path = "fuzz_targets/cobs_roundtrip.rs"
test = false
doc = false
bench = false
[[bin]]
name = "command_roundtrip"
path = "fuzz_targets/command_roundtrip.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_decoder_multi_frame"
path = "fuzz_targets/cobs_decoder_multi_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "cobs_chunk_invariant"
path = "fuzz_targets/cobs_chunk_invariant.rs"
name = "lora_config_roundtrip"
path = "fuzz_targets/lora_config_roundtrip.rs"
test = false
doc = false
bench = false
-41
View File
@@ -1,41 +0,0 @@
//! Chunk-boundary invariance: the same input stream fed into two
//! decoders — one in arbitrary chunks, one all-at-once — must produce
//! the same command sequence.
#![no_main]
use donglora_protocol::Command;
use donglora_protocol::framing::CobsDecoder;
use libfuzzer_sys::fuzz_target;
fn drain(stream: &[u8], chunk_sched: &[u8]) -> Vec<Command> {
let mut out: Vec<Command> = Vec::new();
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
let mut idx = 0;
while cursor < stream.len() {
let split = if idx < chunk_sched.len() {
((chunk_sched[idx] as usize) % 64) + 1
} else {
16
};
idx += 1;
let end = (cursor + split).min(stream.len());
decoder.feed(&stream[cursor..end], |c| out.push(c));
cursor = end;
}
out
}
fuzz_target!(|data: &[u8]| {
let split = 32.min(data.len());
let (sched, stream) = data.split_at(split);
let whole: Vec<Command> = {
let mut v = Vec::new();
CobsDecoder::new().feed(stream, |c| v.push(c));
v
};
let chunked = drain(stream, sched);
assert_eq!(whole, chunked);
});
-36
View File
@@ -1,36 +0,0 @@
//! `CobsDecoder::feed` must never panic or yield an oversized payload
//! when fed arbitrary bytes in arbitrary chunks.
//!
//! The input is split into chunks driven by a leading length-prefix
//! stream so the fuzzer can exercise boundary handling.
#![no_main]
use donglora_protocol::MAX_PAYLOAD;
use donglora_protocol::framing::CobsDecoder;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
// Use the first byte (if any) as a pseudo-chunk-schedule: low nibble
// picks a split size 1..=16, high nibble is skipped. Subsequent
// leading bytes drive subsequent splits.
let mut sched_idx = 0;
while cursor < data.len() {
let split = if sched_idx < data.len() {
((data[sched_idx] & 0x0F) as usize) + 1
} else {
16
};
sched_idx = sched_idx.saturating_add(1);
let end = (cursor + split).min(data.len());
decoder.feed(&data[cursor..end], |cmd| {
if let donglora_protocol::Command::Transmit { payload, .. } = cmd {
assert!(payload.len() <= MAX_PAYLOAD);
}
});
cursor = end;
}
});
@@ -1,42 +0,0 @@
//! Feed arbitrary bytes to `CobsDecoder::feed` using an arbitrary
//! chunking strategy driven by the fuzz input, and confirm:
//! - the decoder never panics,
//! - every emitted command respects the bounded payload,
//! - the total number of commands never exceeds the number of 0x00
//! sentinels in the stream.
#![no_main]
use donglora_protocol::framing::CobsDecoder;
use donglora_protocol::{Command, MAX_PAYLOAD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Split the input: first 32 bytes (if available) drive chunk sizes,
// the rest is the stream.
let split = 32.min(data.len());
let (sched, stream) = data.split_at(split);
let mut decoder = CobsDecoder::new();
let mut cursor = 0;
let mut sched_idx = 0;
let mut count = 0;
while cursor < stream.len() {
let split_size = if sched_idx < sched.len() {
((sched[sched_idx] as usize) % 64) + 1
} else {
16
};
sched_idx += 1;
let end = (cursor + split_size).min(stream.len());
decoder.feed(&stream[cursor..end], |cmd| {
count += 1;
if let Command::Transmit { payload, .. } = cmd {
assert!(payload.len() <= MAX_PAYLOAD);
}
});
cursor = end;
}
let sentinel_count = stream.iter().filter(|&&b| b == 0).count();
assert!(count <= sentinel_count);
});
-126
View File
@@ -1,126 +0,0 @@
//! Structure-aware COBS roundtrip: build an arbitrary `Response`, encode
//! it through `cobs_encode_response`, decode the framed bytes via
//! `ucobs::decode`, and confirm the decoded payload matches the raw
//! `write_to` output.
#![no_main]
use arbitrary::{Arbitrary, Unstructured};
use donglora_protocol::framing::{MAX_FRAME, cobs_encode_response};
use donglora_protocol::{Bandwidth, ErrorCode, RadioConfig, Response};
use heapless::Vec as HVec;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
enum ArbResponse {
Pong,
Config(ArbConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8>,
},
TxDone,
Ok,
Error(ArbError),
MacAddress([u8; 6]),
}
#[derive(Debug, Arbitrary)]
struct ArbConfig {
freq_hz: u32,
bw: u8,
sf: u8,
cr: u8,
sync_word: u16,
tx_power_dbm: i8,
preamble_len: u16,
cad: u8,
}
#[derive(Debug, Arbitrary, Clone, Copy)]
enum ArbError {
InvalidConfig,
RadioBusy,
TxTimeout,
NotConfigured,
NoDisplay,
}
fn into_error(a: ArbError) -> ErrorCode {
match a {
ArbError::InvalidConfig => ErrorCode::InvalidConfig,
ArbError::RadioBusy => ErrorCode::RadioBusy,
ArbError::TxTimeout => ErrorCode::TxTimeout,
ArbError::NotConfigured => ErrorCode::NotConfigured,
ArbError::NoDisplay => ErrorCode::NoDisplay,
}
}
fn into_config(c: &ArbConfig) -> RadioConfig {
let bw = match c.bw % 10 {
0 => Bandwidth::Khz7,
1 => Bandwidth::Khz10,
2 => Bandwidth::Khz15,
3 => Bandwidth::Khz20,
4 => Bandwidth::Khz31,
5 => Bandwidth::Khz41,
6 => Bandwidth::Khz62,
7 => Bandwidth::Khz125,
8 => Bandwidth::Khz250,
_ => Bandwidth::Khz500,
};
RadioConfig {
freq_hz: c.freq_hz,
bw,
sf: c.sf,
cr: c.cr,
sync_word: c.sync_word,
tx_power_dbm: c.tx_power_dbm,
preamble_len: c.preamble_len,
cad: c.cad,
}
}
fn into_response(a: ArbResponse) -> Response {
match a {
ArbResponse::Pong => Response::Pong,
ArbResponse::Config(c) => Response::Config(into_config(&c)),
ArbResponse::RxPacket { rssi, snr, payload } => {
let mut p = HVec::<u8, { donglora_protocol::MAX_PAYLOAD }>::new();
let take = payload.len().min(donglora_protocol::MAX_PAYLOAD);
let _ = p.extend_from_slice(&payload[..take]);
Response::RxPacket {
rssi,
snr,
payload: p,
}
}
ArbResponse::TxDone => Response::TxDone,
ArbResponse::Ok => Response::Ok,
ArbResponse::Error(e) => Response::Error(into_error(e)),
ArbResponse::MacAddress(m) => Response::MacAddress(m),
}
}
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let Ok(arb) = ArbResponse::arbitrary(&mut u) else {
return;
};
let resp = into_response(arb);
let mut raw = [0u8; MAX_FRAME];
let raw_n = resp.clone().write_to(&mut raw);
let mut write = [0u8; MAX_FRAME];
let mut encode = [0u8; MAX_FRAME];
let Some(framed) = cobs_encode_response(resp, &mut write, &mut encode) else {
return;
};
assert_eq!(*framed.last().unwrap(), 0x00);
let mut decoded = [0u8; MAX_FRAME];
let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap();
assert_eq!(&decoded[..n], &raw[..raw_n]);
});
-157
View File
@@ -1,157 +0,0 @@
//! Structure-aware Command roundtrip: construct an arbitrary Command,
//! hand-encode it using the same wire layout the library parses, and
//! confirm `Command::from_bytes` recovers the original value exactly.
//!
//! The library itself is asymmetric (firmware only decodes commands),
//! so this fuzz target carries a matching encoder.
#![no_main]
use arbitrary::{Arbitrary, Unstructured};
use donglora_protocol::{Bandwidth, Command, MAX_PAYLOAD, RADIO_CONFIG_SIZE, RadioConfig};
use heapless::Vec as HVec;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
enum ArbCommand {
Ping,
GetConfig,
SetConfig(ArbConfig),
StartRx,
StopRx,
Transmit {
config: Option<ArbConfig>,
payload: Vec<u8>,
},
DisplayOn,
DisplayOff,
GetMac,
}
#[derive(Debug, Arbitrary)]
struct ArbConfig {
freq_hz: u32,
bw: u8,
sf: u8,
cr: u8,
sync_word: u16,
tx_power_dbm: i8,
preamble_len: u16,
cad: u8,
}
fn into_config(c: &ArbConfig) -> RadioConfig {
let bw = match c.bw % 10 {
0 => Bandwidth::Khz7,
1 => Bandwidth::Khz10,
2 => Bandwidth::Khz15,
3 => Bandwidth::Khz20,
4 => Bandwidth::Khz31,
5 => Bandwidth::Khz41,
6 => Bandwidth::Khz62,
7 => Bandwidth::Khz125,
8 => Bandwidth::Khz250,
_ => Bandwidth::Khz500,
};
RadioConfig {
freq_hz: c.freq_hz,
bw,
sf: c.sf,
cr: c.cr,
sync_word: c.sync_word,
tx_power_dbm: c.tx_power_dbm,
preamble_len: c.preamble_len,
cad: c.cad,
}
}
fn into_command(a: &ArbCommand) -> Command {
match a {
ArbCommand::Ping => Command::Ping,
ArbCommand::GetConfig => Command::GetConfig,
ArbCommand::SetConfig(c) => Command::SetConfig(into_config(c)),
ArbCommand::StartRx => Command::StartRx,
ArbCommand::StopRx => Command::StopRx,
ArbCommand::Transmit { config, payload } => {
let cfg = config.as_ref().map(into_config);
let mut p: HVec<u8, MAX_PAYLOAD> = HVec::new();
let take = payload.len().min(MAX_PAYLOAD);
let _ = p.extend_from_slice(&payload[..take]);
Command::Transmit {
config: cfg,
payload: p,
}
}
ArbCommand::DisplayOn => Command::DisplayOn,
ArbCommand::DisplayOff => Command::DisplayOff,
ArbCommand::GetMac => Command::GetMac,
}
}
fn encode(cmd: &Command, out: &mut [u8]) -> usize {
match cmd {
Command::Ping => {
out[0] = 0;
1
}
Command::GetConfig => {
out[0] = 1;
1
}
Command::SetConfig(cfg) => {
out[0] = 2;
cfg.write_to(&mut out[1..]);
1 + RADIO_CONFIG_SIZE
}
Command::StartRx => {
out[0] = 3;
1
}
Command::StopRx => {
out[0] = 4;
1
}
Command::Transmit { config, payload } => {
out[0] = 5;
let mut pos = 1;
if let Some(cfg) = config {
out[pos] = 1;
pos += 1;
cfg.write_to(&mut out[pos..]);
pos += RADIO_CONFIG_SIZE;
} else {
out[pos] = 0;
pos += 1;
}
out[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
pos += 2;
out[pos..pos + payload.len()].copy_from_slice(payload);
pos + payload.len()
}
Command::DisplayOn => {
out[0] = 6;
1
}
Command::DisplayOff => {
out[0] = 7;
1
}
Command::GetMac => {
out[0] = 8;
1
}
}
}
fuzz_target!(|data: &[u8]| {
let mut u = Unstructured::new(data);
let Ok(arb) = ArbCommand::arbitrary(&mut u) else {
return;
};
let cmd = into_command(&arb);
let mut buf = [0u8; 1024];
let n = encode(&cmd, &mut buf);
let parsed = Command::from_bytes(&buf[..n]).expect("encoded command must parse");
assert_eq!(parsed, cmd);
});
+16
View File
@@ -0,0 +1,16 @@
//! `FrameDecoder::feed` must never panic on arbitrary input, and any
//! yielded payload slice must fit the documented bounds.
#![no_main]
use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = FrameDecoder::new();
decoder.feed(data, |res| {
if let FrameResult::Ok { payload, .. } = res {
assert!(payload.len() <= MAX_PAYLOAD_FIELD);
}
});
});
+44
View File
@@ -0,0 +1,44 @@
//! An arbitrary well-formed `(type_id, tag, payload)` must survive a
//! frame encode → decode round trip byte-for-byte.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, encode_frame};
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
tag: u16,
payload: &'a [u8],
}
fuzz_target!(|input: Input| {
if input.payload.len() > MAX_PAYLOAD_FIELD {
return;
}
let mut wire = [0u8; MAX_WIRE_FRAME];
let Ok(n) = encode_frame(input.type_id, input.tag, input.payload, &mut wire) else {
return;
};
let mut decoder = FrameDecoder::new();
let mut matched = false;
decoder.feed(&wire[..n], |res| {
if let FrameResult::Ok {
type_id,
tag,
payload,
} = res
{
assert_eq!(type_id, input.type_id);
assert_eq!(tag, input.tag);
assert_eq!(payload, input.payload);
matched = true;
} else {
panic!("encode→decode produced a frame error: {:?}", res);
}
});
assert!(matched, "encoded frame did not decode");
});
@@ -0,0 +1,50 @@
//! `LoRaConfig` round-trips through its wire encoding for every
//! construction derived via `Arbitrary`.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input {
freq_hz: u32,
sf: u8,
bw_raw: u8,
cr_raw: u8,
preamble_len: u16,
sync_word: u16,
tx_power_dbm: i8,
header_raw: u8,
payload_crc: bool,
iq_invert: bool,
}
fuzz_target!(|input: Input| {
let Some(bw) = LoRaBandwidth::from_u8(input.bw_raw & 0x0F) else {
return;
};
let Some(cr) = LoRaCodingRate::from_u8(input.cr_raw & 0x03) else {
return;
};
let Some(header_mode) = LoRaHeaderMode::from_u8(input.header_raw & 0x01) else {
return;
};
let cfg = LoRaConfig {
freq_hz: input.freq_hz,
sf: input.sf,
bw,
cr,
preamble_len: input.preamble_len,
sync_word: input.sync_word,
tx_power_dbm: input.tx_power_dbm,
header_mode,
payload_crc: input.payload_crc,
iq_invert: input.iq_invert,
};
let mut buf = [0u8; 32];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(LoRaConfig::decode(&buf[..n]).unwrap(), cfg);
});
+13 -7
View File
@@ -1,14 +1,20 @@
//! `Command::from_bytes` must never panic on arbitrary input.
//!
//! Any accepted command must also respect the heapless payload bound.
//! `Command::parse` must never panic on arbitrary `(type_id, payload)`
//! input. Accepted commands must respect the OTA payload bound.
#![no_main]
use donglora_protocol::{Command, MAX_PAYLOAD};
use arbitrary::Arbitrary;
use donglora_protocol::{Command, MAX_OTA_PAYLOAD};
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(data) {
assert!(payload.len() <= MAX_PAYLOAD);
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
payload: &'a [u8],
}
fuzz_target!(|input: Input| {
if let Ok(Command::Tx { data, .. }) = Command::parse(input.type_id, input.payload) {
assert!(data.len() <= MAX_OTA_PAYLOAD);
}
});
+18
View File
@@ -0,0 +1,18 @@
//! `DeviceMessage::parse` must never panic on arbitrary input.
#![no_main]
use arbitrary::Arbitrary;
use donglora_protocol::DeviceMessage;
use libfuzzer_sys::fuzz_target;
#[derive(Debug, Arbitrary)]
struct Input<'a> {
type_id: u8,
payload: &'a [u8],
originating_cmd: Option<u8>,
}
fuzz_target!(|input: Input| {
let _ = DeviceMessage::parse(input.type_id, input.payload, input.originating_cmd);
});
-10
View File
@@ -1,10 +0,0 @@
//! `RadioConfig::from_bytes` must never panic on arbitrary input.
#![no_main]
use donglora_protocol::RadioConfig;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = RadioConfig::from_bytes(data);
});
+9 -17
View File
@@ -40,13 +40,11 @@ test-deny:
# fuzz every target for 60s (requires nightly + cargo-fuzz)
test-fuzz:
cd fuzz && cargo +nightly fuzz run decoder_feed -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run parse_command -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run command_roundtrip -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_decoder_multi_frame -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_chunk_invariant -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run parse_device_message -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run frame_roundtrip -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run lora_config_roundtrip -- -max_total_time=60
# run tests under miri (catches UB, requires nightly + miri component)
# The proptest suite is skipped — its file-persistence layer calls getcwd
@@ -59,22 +57,16 @@ test-miri:
#
# Runs under both feature configurations so that feature-gated paths
# (currently only the defmt::Format derives) are covered in both shapes.
# Three mutants are excluded as provably equivalent — swapping the
# operator does not change behaviour because a downstream check catches
# the resulting case. See the regex strings for the specific sites.
# No exclusions yet for the v1.0 codec — rerun mutation testing on a
# fresh target and add `-E` lines here for any provably-equivalent
# mutants that survive, with a comment explaining why.
test-mutants: test-mutants-default test-mutants-no-default
test-mutants-default:
cargo mutants --no-shuffle --test-tool=nextest \
-E 'replace > with >= in Command::from_bytes' \
-E 'replace > with >= in CobsDecoder::feed' \
-E 'replace < with <= in cobs_encode_response'
cargo mutants --no-shuffle --test-tool=nextest
test-mutants-no-default:
cargo mutants --no-shuffle --test-tool=nextest --no-default-features \
-E 'replace > with >= in Command::from_bytes' \
-E 'replace > with >= in CobsDecoder::feed' \
-E 'replace < with <= in cobs_encode_response'
cargo mutants --no-shuffle --test-tool=nextest --no-default-features
# format code
fmt:
+122
View File
@@ -0,0 +1,122 @@
//! Radio chip identifier enum from `PROTOCOL.md §8`.
//!
//! Reported by the device in `GET_INFO.radio_chip_id` so the host can
//! make chip-specific decisions (bandwidth range, SF range, TX power
//! range, modulation support) without having to infer them from the
//! bitmap fields alone.
/// Radio chip identifier (u16, little-endian on the wire).
///
/// Values are assigned by `PROTOCOL.md §8`. Ranges are grouped by chip
/// family so future additions slot in next to their siblings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u16)]
pub enum RadioChipId {
/// Firmware could not identify its radio. Hosts should abort: the
/// device will not function.
Unknown = 0x0000,
// Gen 2 sub-GHz
Sx1261 = 0x0001,
Sx1262 = 0x0002,
Sx1268 = 0x0003,
Llcc68 = 0x0004,
// Gen 1 sub-GHz
Sx1272 = 0x0010,
Sx1276 = 0x0011,
Sx1277 = 0x0012,
Sx1278 = 0x0013,
Sx1279 = 0x0014,
// Gen 2 2.4 GHz
Sx1280 = 0x0020,
Sx1281 = 0x0021,
// Gen 3 multi-band
Lr1110 = 0x0030,
Lr1120 = 0x0031,
Lr1121 = 0x0032,
// Gen 4 multi-band
Lr2021 = 0x0040,
}
impl RadioChipId {
/// Wire-form u16. Use this when encoding `GET_INFO`.
pub const fn as_u16(self) -> u16 {
self as u16
}
/// Parse a wire u16 into a known chip. Returns `None` for unassigned
/// values; hosts receiving an unknown value should treat the device
/// as unusable.
pub const fn from_u16(v: u16) -> Option<Self> {
Some(match v {
0x0000 => Self::Unknown,
0x0001 => Self::Sx1261,
0x0002 => Self::Sx1262,
0x0003 => Self::Sx1268,
0x0004 => Self::Llcc68,
0x0010 => Self::Sx1272,
0x0011 => Self::Sx1276,
0x0012 => Self::Sx1277,
0x0013 => Self::Sx1278,
0x0014 => Self::Sx1279,
0x0020 => Self::Sx1280,
0x0021 => Self::Sx1281,
0x0030 => Self::Lr1110,
0x0031 => Self::Lr1120,
0x0032 => Self::Lr1121,
0x0040 => Self::Lr2021,
_ => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_assigned_values_roundtrip() {
let all = [
RadioChipId::Unknown,
RadioChipId::Sx1261,
RadioChipId::Sx1262,
RadioChipId::Sx1268,
RadioChipId::Llcc68,
RadioChipId::Sx1272,
RadioChipId::Sx1276,
RadioChipId::Sx1277,
RadioChipId::Sx1278,
RadioChipId::Sx1279,
RadioChipId::Sx1280,
RadioChipId::Sx1281,
RadioChipId::Lr1110,
RadioChipId::Lr1120,
RadioChipId::Lr1121,
RadioChipId::Lr2021,
];
for id in all {
assert_eq!(RadioChipId::from_u16(id.as_u16()), Some(id));
}
}
#[test]
fn unassigned_values_reject() {
for bad in [0x0005u16, 0x0015, 0x0022, 0x0033, 0x0041, 0xFFFF] {
assert_eq!(RadioChipId::from_u16(bad), None);
}
}
#[test]
fn canonical_values_from_spec() {
// Direct quote from §8 — any renumbering breaks the wire contract.
assert_eq!(RadioChipId::Sx1262.as_u16(), 0x0002);
assert_eq!(RadioChipId::Llcc68.as_u16(), 0x0004);
assert_eq!(RadioChipId::Sx1280.as_u16(), 0x0020);
assert_eq!(RadioChipId::Lr2021.as_u16(), 0x0040);
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Host-to-device commands (`PROTOCOL.md §6`).
//!
//! A `Command` is the semantic content of an H→D frame — it does not
//! carry the tag (the tag is a frame-level concern, chosen freshly by
//! the host for each outbound command and echoed by the device on the
//! matching response).
//!
//! Encoding turns a `Command` into the payload bytes that go between a
//! frame's header and CRC; callers then wrap the (type_id, tag, payload)
//! tuple with `frame::encode_frame`.
//!
//! Parsing reverses: given a frame's `type_id` and `payload`, reconstruct
//! the `Command`. Length mismatches and reserved-bit violations are
//! returned as `CommandParseError` so the caller can decide whether to
//! emit `ERR(ELENGTH)` or `ERR(EPARAM)`.
use heapless::Vec as HVec;
use crate::{
CommandEncodeError, CommandParseError, MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError,
ModulationParseError,
};
// ── Message type identifiers ────────────────────────────────────────
/// `PING` (H→D).
pub const TYPE_PING: u8 = 0x01;
/// `GET_INFO` (H→D).
pub const TYPE_GET_INFO: u8 = 0x02;
/// `SET_CONFIG` (H→D).
pub const TYPE_SET_CONFIG: u8 = 0x03;
/// `TX` (H→D).
pub const TYPE_TX: u8 = 0x04;
/// `RX_START` (H→D).
pub const TYPE_RX_START: u8 = 0x05;
/// `RX_STOP` (H→D).
pub const TYPE_RX_STOP: u8 = 0x06;
// ── TX flags ────────────────────────────────────────────────────────
/// `TX` flag byte (`PROTOCOL.md §6.4`).
///
/// Bit 0 is `skip_cad`. Bits 1–7 are reserved and MUST be zero on the
/// wire; parsing rejects any frame with reserved bits set so the caller
/// can emit `ERR(EPARAM)` cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct TxFlags {
/// If true, skip the default CAD-before-TX and transmit immediately.
pub skip_cad: bool,
}
impl TxFlags {
pub const fn as_byte(self) -> u8 {
if self.skip_cad { 0b0000_0001 } else { 0 }
}
/// Parse the wire byte. Returns `Err(CommandParseError::ReservedBitSet)`
/// if any bit besides bit 0 is nonzero.
pub const fn from_byte(b: u8) -> Result<Self, CommandParseError> {
if b & !0b0000_0001 != 0 {
return Err(CommandParseError::ReservedBitSet);
}
Ok(Self {
skip_cad: b & 0b0000_0001 != 0,
})
}
}
// ── Command enum ────────────────────────────────────────────────────
/// Host-to-device command.
///
/// `Tx.data` is owned so a parsed `Command` outlives the frame buffer it
/// came from. This is a single memcpy over the radio packet — trivial
/// relative to LoRa airtime, and it simplifies the firmware's statechart
/// (which needs to carry the pending TX across state transitions).
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum Command {
Ping,
GetInfo,
SetConfig(Modulation),
Tx {
flags: TxFlags,
data: HVec<u8, MAX_OTA_PAYLOAD>,
},
RxStart,
RxStop,
}
impl Command {
/// Wire-level `type_id` for this command.
pub const fn type_id(&self) -> u8 {
match self {
Self::Ping => TYPE_PING,
Self::GetInfo => TYPE_GET_INFO,
Self::SetConfig(_) => TYPE_SET_CONFIG,
Self::Tx { .. } => TYPE_TX,
Self::RxStart => TYPE_RX_START,
Self::RxStop => TYPE_RX_STOP,
}
}
/// Encode the command's payload (the bytes between the frame header
/// and the CRC) into `buf`. Returns the number of bytes written.
pub fn encode_payload(&self, buf: &mut [u8]) -> Result<usize, CommandEncodeError> {
match self {
Self::Ping | Self::GetInfo | Self::RxStart | Self::RxStop => Ok(0),
Self::SetConfig(m) => m.encode(buf).map_err(CommandEncodeError::from),
Self::Tx { flags, data } => {
if data.is_empty() {
return Err(CommandEncodeError::EmptyTxPayload);
}
if data.len() > MAX_OTA_PAYLOAD {
return Err(CommandEncodeError::PayloadTooLarge);
}
let total = 1 + data.len();
if buf.len() < total {
return Err(CommandEncodeError::BufferTooSmall);
}
buf[0] = flags.as_byte();
buf[1..total].copy_from_slice(data);
Ok(total)
}
}
}
/// Parse a (`type_id`, `payload`) pair into a `Command`. The tag is
/// handled at the frame level and is not part of a command's
/// semantic content.
pub fn parse(type_id: u8, payload: &[u8]) -> Result<Self, CommandParseError> {
match type_id {
TYPE_PING => {
if !payload.is_empty() {
return Err(CommandParseError::WrongLength);
}
Ok(Self::Ping)
}
TYPE_GET_INFO => {
if !payload.is_empty() {
return Err(CommandParseError::WrongLength);
}
Ok(Self::GetInfo)
}
TYPE_SET_CONFIG => Modulation::decode(payload)
.map(Self::SetConfig)
.map_err(CommandParseError::from),
TYPE_TX => {
if payload.is_empty() {
return Err(CommandParseError::WrongLength);
}
let flags = TxFlags::from_byte(payload[0])?;
let body = &payload[1..];
if body.is_empty() {
return Err(CommandParseError::WrongLength);
}
if body.len() > MAX_OTA_PAYLOAD {
return Err(CommandParseError::WrongLength);
}
let mut data = HVec::new();
data.extend_from_slice(body)
.map_err(|_| CommandParseError::WrongLength)?;
Ok(Self::Tx { flags, data })
}
TYPE_RX_START => {
if !payload.is_empty() {
return Err(CommandParseError::WrongLength);
}
Ok(Self::RxStart)
}
TYPE_RX_STOP => {
if !payload.is_empty() {
return Err(CommandParseError::WrongLength);
}
Ok(Self::RxStop)
}
_ => Err(CommandParseError::UnknownType),
}
}
}
impl From<ModulationEncodeError> for CommandEncodeError {
fn from(e: ModulationEncodeError) -> Self {
match e {
ModulationEncodeError::BufferTooSmall => Self::BufferTooSmall,
ModulationEncodeError::SyncWordTooLong => Self::SyncWordTooLong,
}
}
}
impl From<ModulationParseError> for CommandParseError {
fn from(e: ModulationParseError) -> Self {
match e {
ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => {
Self::WrongLength
}
ModulationParseError::InvalidField => Self::InvalidField,
ModulationParseError::UnknownModulation => Self::UnknownModulation,
}
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
fn sample_lora() -> LoRaConfig {
LoRaConfig {
freq_hz: 868_100_000,
sf: 7,
bw: LoRaBandwidth::Khz125,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 8,
sync_word: 0x1424,
tx_power_dbm: 14,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
}
}
#[test]
fn type_ids_match_spec() {
assert_eq!(TYPE_PING, 0x01);
assert_eq!(TYPE_GET_INFO, 0x02);
assert_eq!(TYPE_SET_CONFIG, 0x03);
assert_eq!(TYPE_TX, 0x04);
assert_eq!(TYPE_RX_START, 0x05);
assert_eq!(TYPE_RX_STOP, 0x06);
}
#[test]
fn tx_flags_roundtrip() {
assert_eq!(TxFlags::default().as_byte(), 0);
assert_eq!(TxFlags { skip_cad: true }.as_byte(), 1);
assert_eq!(TxFlags::from_byte(0).unwrap(), TxFlags::default());
assert_eq!(TxFlags::from_byte(1).unwrap(), TxFlags { skip_cad: true });
}
#[test]
fn tx_flags_reject_reserved_bits() {
assert!(matches!(
TxFlags::from_byte(0x02),
Err(CommandParseError::ReservedBitSet)
));
assert!(matches!(
TxFlags::from_byte(0x80),
Err(CommandParseError::ReservedBitSet)
));
}
#[test]
fn roundtrip_empty_commands() {
for cmd in [
Command::Ping,
Command::GetInfo,
Command::RxStart,
Command::RxStop,
] {
let mut buf = [0u8; 4];
let n = cmd.encode_payload(&mut buf).unwrap();
assert_eq!(n, 0);
assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
}
}
#[test]
fn roundtrip_set_config_lora() {
let cmd = Command::SetConfig(Modulation::LoRa(sample_lora()));
let mut buf = [0u8; 32];
let n = cmd.encode_payload(&mut buf).unwrap();
// 1 (modulation_id) + 15 (LoRa wire size).
assert_eq!(n, 16);
assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
}
#[test]
fn roundtrip_tx_with_cad() {
let mut data = HVec::new();
data.extend_from_slice(b"Hello").unwrap();
let cmd = Command::Tx {
flags: TxFlags { skip_cad: false },
data,
};
let mut buf = [0u8; 8];
let n = cmd.encode_payload(&mut buf).unwrap();
assert_eq!(n, 6); // flags + 5 data bytes
assert_eq!(buf[0], 0x00);
assert_eq!(&buf[1..n], b"Hello");
assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
}
#[test]
fn roundtrip_tx_skip_cad() {
let mut data = HVec::new();
data.extend_from_slice(b"URGENT").unwrap();
let cmd = Command::Tx {
flags: TxFlags { skip_cad: true },
data,
};
let mut buf = [0u8; 8];
let n = cmd.encode_payload(&mut buf).unwrap();
assert_eq!(n, 7);
assert_eq!(buf[0], 0x01);
assert_eq!(&buf[1..n], b"URGENT");
assert_eq!(Command::parse(cmd.type_id(), &buf[..n]).unwrap(), cmd);
}
#[test]
fn tx_rejects_empty_payload() {
let cmd = Command::Tx {
flags: TxFlags::default(),
data: HVec::new(),
};
let mut buf = [0u8; 2];
assert!(matches!(
cmd.encode_payload(&mut buf),
Err(CommandEncodeError::EmptyTxPayload)
));
}
#[test]
fn tx_parse_rejects_empty_payload() {
// Just the flags byte, no data.
assert!(matches!(
Command::parse(TYPE_TX, &[0x00]),
Err(CommandParseError::WrongLength)
));
}
#[test]
fn ping_rejects_nonempty_payload() {
assert!(matches!(
Command::parse(TYPE_PING, &[0x00]),
Err(CommandParseError::WrongLength)
));
}
#[test]
fn unknown_type_rejects() {
assert!(matches!(
Command::parse(0x10, &[]),
Err(CommandParseError::UnknownType)
));
assert!(matches!(
Command::parse(0xFF, &[0xDE, 0xAD]),
Err(CommandParseError::UnknownType)
));
}
}
+57
View File
@@ -0,0 +1,57 @@
//! CRC-16/CCITT-FALSE — the wire-level integrity check used by DongLoRa Protocol.
//!
//! Polynomial `0x1021`, initial value `0xFFFF`, no reflection, XOR-out `0x0000`.
//! This is the same variant also known as CRC-16/AUTOSAR or
//! CRC-16/IBM-3740. It is **NOT** the same as CRC-16/KERMIT,
//! CRC-16/XMODEM, or plain "CRC-16/CCITT" — those use different initial
//! values or reflect their inputs.
//!
//! Check value: `crc16("123456789") == 0x29B1`. A module-level test pins
//! this, and `tests/vectors.rs` anchors every Appendix C wire example.
/// CRC-16/CCITT-FALSE of `data`.
pub fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0xFFFF;
for &byte in data {
crc ^= (byte as u16) << 8;
for _ in 0..8 {
if crc & 0x8000 != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
crc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_value_matches_spec() {
// PROTOCOL.md §2.2: CRC-16/CCITT-FALSE of ASCII "123456789" is 0x29B1.
assert_eq!(crc16(b"123456789"), 0x29B1);
}
#[test]
fn empty_input_is_initial_value() {
// No reflection, no XOR-out: empty input leaves the init value untouched.
assert_eq!(crc16(&[]), 0xFFFF);
}
#[test]
fn single_zero_byte() {
// 0xFFFF XOR 0x0000 = 0xFFFF, then 8 shift-and-maybe-xor rounds.
// Worked out by hand against the reference impl in Appendix B.
assert_eq!(crc16(&[0x00]), 0xE1F0);
}
#[test]
fn differs_from_kermit_variant() {
// CRC-16/KERMIT of "123456789" is 0x2189 — if we ever accidentally
// swap init or reflection, this vector catches it.
assert_ne!(crc16(b"123456789"), 0x2189);
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Error codes carried in `ERR` frames (`PROTOCOL.md §7`).
//!
//! Codes are u16 little-endian on the wire. The containing frame's tag
//! distinguishes context: non-zero tag is a synchronous error tied to a
//! specific command; tag `0x0000` is an asynchronous fault not tied to
//! any command. The two namespaces share one u16 space:
//!
//! - `0x0000..=0x00FF` — synchronous-typical codes
//! - `0x0100..=0x01FF` — asynchronous-typical codes
//! - `0x0200..=0xFFFF` — reserved for future extensions
//!
//! Any code MAY appear in either context when it fits semantically; the
//! split is convention, not enforcement. `ERADIO` for instance is listed
//! in the async table but is emitted synchronously when a hardware fault
//! aborts a specific command.
/// Wire-level error code.
///
/// Parsing an unknown code yields `ErrorCode::Unknown(raw)` so hosts can
/// round-trip frames they don't fully understand (useful when a device
/// reports a newer minor-version error code).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum ErrorCode {
/// `0x0001` — a parameter value is out of range or invalid.
EParam,
/// `0x0002` — payload length is wrong for the command or modulation.
ELength,
/// `0x0003` — command requires `CONFIGURED`; device is `UNCONFIGURED`.
ENotConfigured,
/// `0x0004` — requested modulation is not supported by this chip.
EModulation,
/// `0x0005` — unknown command type byte.
EUnknownCmd,
/// `0x0006` — transient: TX queue is full. Retry after a `TX_DONE`.
EBusy,
/// `0x0101` — radio SPI error or unexpected hardware state.
ERadio,
/// `0x0102` — inbound frame had bad CRC, bad COBS, or wrong length.
EFrame,
/// `0x0103` — firmware encountered an unexpected internal condition.
EInternal,
/// Any code not assigned in v1.0. Preserves the raw wire value so
/// parsers don't drop information they don't understand.
Unknown(u16),
}
impl ErrorCode {
/// Wire-form u16.
pub const fn as_u16(self) -> u16 {
match self {
Self::EParam => 0x0001,
Self::ELength => 0x0002,
Self::ENotConfigured => 0x0003,
Self::EModulation => 0x0004,
Self::EUnknownCmd => 0x0005,
Self::EBusy => 0x0006,
Self::ERadio => 0x0101,
Self::EFrame => 0x0102,
Self::EInternal => 0x0103,
Self::Unknown(raw) => raw,
}
}
/// Parse a wire u16. Assigned codes return their named variant;
/// anything else becomes `Unknown(raw)`.
pub const fn from_u16(v: u16) -> Self {
match v {
0x0001 => Self::EParam,
0x0002 => Self::ELength,
0x0003 => Self::ENotConfigured,
0x0004 => Self::EModulation,
0x0005 => Self::EUnknownCmd,
0x0006 => Self::EBusy,
0x0101 => Self::ERadio,
0x0102 => Self::EFrame,
0x0103 => Self::EInternal,
other => Self::Unknown(other),
}
}
/// Whether this code lives in the asynchronous-typical range
/// (`0x0100..=0x01FF`). Does not imply the containing frame's tag;
/// that's a separate wire-level fact.
pub const fn is_async_range(self) -> bool {
let v = self.as_u16();
v >= 0x0100 && v <= 0x01FF
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_sync_values() {
assert_eq!(ErrorCode::EParam.as_u16(), 0x0001);
assert_eq!(ErrorCode::ELength.as_u16(), 0x0002);
assert_eq!(ErrorCode::ENotConfigured.as_u16(), 0x0003);
assert_eq!(ErrorCode::EModulation.as_u16(), 0x0004);
assert_eq!(ErrorCode::EUnknownCmd.as_u16(), 0x0005);
assert_eq!(ErrorCode::EBusy.as_u16(), 0x0006);
}
#[test]
fn canonical_async_values() {
assert_eq!(ErrorCode::ERadio.as_u16(), 0x0101);
assert_eq!(ErrorCode::EFrame.as_u16(), 0x0102);
assert_eq!(ErrorCode::EInternal.as_u16(), 0x0103);
}
#[test]
fn assigned_roundtrip() {
let all = [
ErrorCode::EParam,
ErrorCode::ELength,
ErrorCode::ENotConfigured,
ErrorCode::EModulation,
ErrorCode::EUnknownCmd,
ErrorCode::EBusy,
ErrorCode::ERadio,
ErrorCode::EFrame,
ErrorCode::EInternal,
];
for code in all {
assert_eq!(ErrorCode::from_u16(code.as_u16()), code);
}
}
#[test]
fn unknown_preserves_raw() {
let unusual = [0x0000u16, 0x0007, 0x0100, 0x0200, 0xFFFF];
for raw in unusual {
let c = ErrorCode::from_u16(raw);
assert_eq!(c.as_u16(), raw);
assert!(matches!(c, ErrorCode::Unknown(_)));
}
}
#[test]
fn is_async_range_boundaries() {
assert!(!ErrorCode::EParam.is_async_range());
assert!(!ErrorCode::EBusy.is_async_range());
assert!(ErrorCode::ERadio.is_async_range());
assert!(ErrorCode::EInternal.is_async_range());
// 0x00FF and 0x0200 are outside the async band.
assert!(!ErrorCode::Unknown(0x00FF).is_async_range());
assert!(!ErrorCode::Unknown(0x0200).is_async_range());
assert!(ErrorCode::Unknown(0x01FF).is_async_range());
}
}
+657
View File
@@ -0,0 +1,657 @@
//! Device-to-host messages (`PROTOCOL.md §6.7–6.10`).
//!
//! Four wire types:
//!
//! | Hex | Name | Tag | Notes |
//! |------|---------|------------------------|---------------------------------------|
//! | 0x80 | OK | echoes command | Payload shape depends on command |
//! | 0x81 | ERR | command or 0x0000 | 2-byte error code |
//! | 0xC0 | RX | always 0x0000 | Rich metadata + packet bytes |
//! | 0xC1 | TX_DONE | echoes originating TX | Result + airtime |
//!
//! OK is the only tricky one: its payload shape varies by what command
//! produced it, so callers must supply the originating command's type
//! when parsing (they know this from tag correlation). The three shapes
//! are empty (for `PING` / `TX` / `RX_START` / `RX_STOP`), `Info` (for
//! `GET_INFO`), and `SetConfigResult` (for `SET_CONFIG`).
use heapless::Vec as HVec;
use crate::{
DeviceMessageEncodeError, DeviceMessageParseError, ErrorCode, Info, InfoParseError,
MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError, ModulationParseError, commands,
};
// ── D→H message type identifiers ────────────────────────────────────
pub const TYPE_OK: u8 = 0x80;
pub const TYPE_ERR: u8 = 0x81;
pub const TYPE_RX: u8 = 0xC0;
pub const TYPE_TX_DONE: u8 = 0xC1;
// ── OK sub-shapes ───────────────────────────────────────────────────
/// `SET_CONFIG` result code (`PROTOCOL.md §6.3`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum SetConfigResultCode {
/// The request was accepted. The radio is now configured as requested.
Applied = 0,
/// Another client holds the lock; active config byte-for-byte matches.
AlreadyMatched = 1,
/// Another client holds the lock; active config differs.
LockedMismatch = 2,
}
impl SetConfigResultCode {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Applied,
1 => Self::AlreadyMatched,
2 => Self::LockedMismatch,
_ => return None,
})
}
}
/// `SET_CONFIG` lock owner (`PROTOCOL.md §6.3`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum Owner {
/// No client currently holds the lock.
None = 0,
/// The calling client holds the lock.
Mine = 1,
/// A different client holds the lock.
Other = 2,
}
impl Owner {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::None,
1 => Self::Mine,
2 => Self::Other,
_ => return None,
})
}
}
/// Payload of `OK` in response to `SET_CONFIG` (`PROTOCOL.md §6.3`).
///
/// `current_modulation` and `current_params` always reflect what is
/// actually programmed into the radio at the moment the response was
/// constructed, regardless of whether this call caused the change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct SetConfigResult {
pub result: SetConfigResultCode,
pub owner: Owner,
pub current: Modulation,
}
impl SetConfigResult {
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
if buf.len() < 2 {
return Err(DeviceMessageEncodeError::BufferTooSmall);
}
buf[0] = self.result.as_u8();
buf[1] = self.owner.as_u8();
let rest = self.current.encode(&mut buf[2..]).map_err(|e| match e {
ModulationEncodeError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
ModulationEncodeError::SyncWordTooLong => DeviceMessageEncodeError::SyncWordTooLong,
})?;
Ok(2 + rest)
}
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
if buf.len() < 2 {
return Err(DeviceMessageParseError::TooShort);
}
let result =
SetConfigResultCode::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
let owner = Owner::from_u8(buf[1]).ok_or(DeviceMessageParseError::InvalidField)?;
let current = Modulation::decode(&buf[2..]).map_err(DeviceMessageParseError::from)?;
Ok(Self {
result,
owner,
current,
})
}
}
/// Payload shape carried by an `OK` frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum OkPayload {
/// For `PING`, `TX`, `RX_START`, `RX_STOP` — no body.
Empty,
/// For `GET_INFO`.
Info(Info),
/// For `SET_CONFIG`.
SetConfig(SetConfigResult),
}
impl OkPayload {
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
match self {
Self::Empty => Ok(0),
Self::Info(info) => info.encode(buf).map_err(|e| match e {
InfoParseError::TooShort | InfoParseError::InvalidField => {
DeviceMessageEncodeError::InvalidField
}
InfoParseError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
}),
Self::SetConfig(r) => r.encode(buf),
}
}
/// Parse the `OK` payload given the originating command's type byte.
/// Returns `Empty` for `PING`/`TX`/`RX_START`/`RX_STOP` (and verifies
/// that the payload is empty), `Info` for `GET_INFO`, `SetConfig` for
/// `SET_CONFIG`. Any other `cmd_type` is rejected.
pub fn parse_for(cmd_type: u8, payload: &[u8]) -> Result<Self, DeviceMessageParseError> {
match cmd_type {
commands::TYPE_PING
| commands::TYPE_TX
| commands::TYPE_RX_START
| commands::TYPE_RX_STOP => {
if !payload.is_empty() {
return Err(DeviceMessageParseError::WrongLength);
}
Ok(Self::Empty)
}
commands::TYPE_GET_INFO => Info::decode(payload)
.map(Self::Info)
.map_err(DeviceMessageParseError::from),
commands::TYPE_SET_CONFIG => SetConfigResult::decode(payload).map(Self::SetConfig),
_ => Err(DeviceMessageParseError::UnknownContext),
}
}
}
// ── RX event ────────────────────────────────────────────────────────
/// Packet origin — over-the-air vs. local-loopback from another client
/// (`PROTOCOL.md §6.9`, §13.4). In single-client firmware always `Ota`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum RxOrigin {
Ota = 0,
LocalLoopback = 1,
}
impl RxOrigin {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Ota,
1 => Self::LocalLoopback,
_ => return None,
})
}
}
/// `RX` event payload (`PROTOCOL.md §6.9`). Metadata is the 20-byte
/// prefix; `data` is the OTA packet body (0..=MAX_OTA_PAYLOAD bytes).
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct RxPayload {
/// RSSI in tenths of a dBm.
pub rssi_tenths_dbm: i16,
/// SNR in tenths of a dB.
pub snr_tenths_db: i16,
/// Estimated LO offset in Hz.
pub freq_err_hz: i32,
/// Microseconds since boot, captured at RxDone IRQ.
pub timestamp_us: u64,
/// True if the radio reported CRC pass (or CRC was not configured).
pub crc_valid: bool,
/// Packets dropped since the previous delivered RX. Cleared to 0 on
/// every delivered event.
pub packets_dropped: u16,
pub origin: RxOrigin,
pub data: HVec<u8, MAX_OTA_PAYLOAD>,
}
impl RxPayload {
/// Fixed part of the payload preceding the variable-length `data`.
pub const METADATA_SIZE: usize = 20;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
let total = Self::METADATA_SIZE + self.data.len();
if buf.len() < total {
return Err(DeviceMessageEncodeError::BufferTooSmall);
}
if self.data.len() > MAX_OTA_PAYLOAD {
return Err(DeviceMessageEncodeError::PayloadTooLarge);
}
buf[0..2].copy_from_slice(&self.rssi_tenths_dbm.to_le_bytes());
buf[2..4].copy_from_slice(&self.snr_tenths_db.to_le_bytes());
buf[4..8].copy_from_slice(&self.freq_err_hz.to_le_bytes());
buf[8..16].copy_from_slice(&self.timestamp_us.to_le_bytes());
buf[16] = u8::from(self.crc_valid);
buf[17..19].copy_from_slice(&self.packets_dropped.to_le_bytes());
buf[19] = self.origin.as_u8();
buf[20..total].copy_from_slice(&self.data);
Ok(total)
}
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
if buf.len() < Self::METADATA_SIZE {
return Err(DeviceMessageParseError::TooShort);
}
let n = buf.len() - Self::METADATA_SIZE;
if n > MAX_OTA_PAYLOAD {
return Err(DeviceMessageParseError::WrongLength);
}
let rssi_tenths_dbm = i16::from_le_bytes([buf[0], buf[1]]);
let snr_tenths_db = i16::from_le_bytes([buf[2], buf[3]]);
let freq_err_hz = i32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
let timestamp_us = u64::from_le_bytes([
buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
]);
let crc_valid = match buf[16] {
0 => false,
1 => true,
_ => return Err(DeviceMessageParseError::InvalidField),
};
let packets_dropped = u16::from_le_bytes([buf[17], buf[18]]);
let origin = RxOrigin::from_u8(buf[19]).ok_or(DeviceMessageParseError::InvalidField)?;
// Slice with `n` rather than `..` so the subtraction `buf.len() -
// METADATA_SIZE` is load-bearing: a mutant that replaces `-` with
// `/` yields a different `n` and therefore a shorter data slice,
// which round-trip tests observe.
let mut data = HVec::new();
data.extend_from_slice(&buf[Self::METADATA_SIZE..Self::METADATA_SIZE + n])
.map_err(|_| DeviceMessageParseError::WrongLength)?;
Ok(Self {
rssi_tenths_dbm,
snr_tenths_db,
freq_err_hz,
timestamp_us,
crc_valid,
packets_dropped,
origin,
data,
})
}
}
// ── TX_DONE event ───────────────────────────────────────────────────
/// `TX_DONE` result code (`PROTOCOL.md §6.10`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum TxResult {
Transmitted = 0,
ChannelBusy = 1,
Cancelled = 2,
}
impl TxResult {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Transmitted,
1 => Self::ChannelBusy,
2 => Self::Cancelled,
_ => return None,
})
}
}
/// `TX_DONE` payload (`PROTOCOL.md §6.10`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct TxDonePayload {
pub result: TxResult,
/// Measured or computed on-air duration in microseconds. Always 0
/// when `result` is not `Transmitted`.
pub airtime_us: u32,
}
impl TxDonePayload {
pub const WIRE_SIZE: usize = 5;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
if buf.len() < Self::WIRE_SIZE {
return Err(DeviceMessageEncodeError::BufferTooSmall);
}
buf[0] = self.result.as_u8();
buf[1..5].copy_from_slice(&self.airtime_us.to_le_bytes());
Ok(Self::WIRE_SIZE)
}
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
if buf.len() != Self::WIRE_SIZE {
return Err(DeviceMessageParseError::WrongLength);
}
let result = TxResult::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
let airtime_us = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]);
Ok(Self { result, airtime_us })
}
}
// ── ERR payload helper ──────────────────────────────────────────────
/// Encode an `ERR` payload (2 bytes of u16 LE error code).
pub fn encode_err_payload(
code: ErrorCode,
buf: &mut [u8],
) -> Result<usize, DeviceMessageEncodeError> {
if buf.len() < 2 {
return Err(DeviceMessageEncodeError::BufferTooSmall);
}
buf[0..2].copy_from_slice(&code.as_u16().to_le_bytes());
Ok(2)
}
/// Decode an `ERR` payload (2 bytes).
pub fn decode_err_payload(buf: &[u8]) -> Result<ErrorCode, DeviceMessageParseError> {
if buf.len() != 2 {
return Err(DeviceMessageParseError::WrongLength);
}
Ok(ErrorCode::from_u16(u16::from_le_bytes([buf[0], buf[1]])))
}
// ── DeviceMessage top-level sum ────────────────────────────────────
/// Any device→host message, parsed into its semantic form.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum DeviceMessage {
Ok(OkPayload),
Err(ErrorCode),
Rx(RxPayload),
TxDone(TxDonePayload),
}
impl DeviceMessage {
pub const fn type_id(&self) -> u8 {
match self {
Self::Ok(_) => TYPE_OK,
Self::Err(_) => TYPE_ERR,
Self::Rx(_) => TYPE_RX,
Self::TxDone(_) => TYPE_TX_DONE,
}
}
/// Encode the device-message payload (bytes between frame header and
/// CRC) into `buf`. Returns the number of bytes written.
pub fn encode_payload(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
match self {
Self::Ok(ok) => ok.encode(buf),
Self::Err(code) => encode_err_payload(*code, buf),
Self::Rx(rx) => rx.encode(buf),
Self::TxDone(td) => td.encode(buf),
}
}
/// Parse a (`type_id`, `payload`) pair.
///
/// `originating_cmd_type` MUST be supplied for `OK` frames: it is
/// the `type_id` of the H→D command whose tag the host observed
/// echoed back. For all other device-message types it is ignored
/// (pass `None`).
pub fn parse(
type_id: u8,
payload: &[u8],
originating_cmd_type: Option<u8>,
) -> Result<Self, DeviceMessageParseError> {
match type_id {
TYPE_OK => {
let cmd = originating_cmd_type.ok_or(DeviceMessageParseError::MissingContext)?;
Ok(Self::Ok(OkPayload::parse_for(cmd, payload)?))
}
TYPE_ERR => Ok(Self::Err(decode_err_payload(payload)?)),
TYPE_RX => Ok(Self::Rx(RxPayload::decode(payload)?)),
TYPE_TX_DONE => Ok(Self::TxDone(TxDonePayload::decode(payload)?)),
_ => Err(DeviceMessageParseError::UnknownType),
}
}
}
// ── Error conversions ───────────────────────────────────────────────
impl From<ModulationParseError> for DeviceMessageParseError {
fn from(e: ModulationParseError) -> Self {
match e {
ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => {
Self::WrongLength
}
ModulationParseError::InvalidField => Self::InvalidField,
ModulationParseError::UnknownModulation => Self::UnknownContext,
}
}
}
impl From<InfoParseError> for DeviceMessageParseError {
fn from(e: InfoParseError) -> Self {
match e {
InfoParseError::TooShort | InfoParseError::BufferTooSmall => Self::WrongLength,
InfoParseError::InvalidField => Self::InvalidField,
}
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
fn sample_lora() -> LoRaConfig {
LoRaConfig {
freq_hz: 868_100_000,
sf: 7,
bw: LoRaBandwidth::Khz125,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 8,
sync_word: 0x1424,
tx_power_dbm: 14,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
}
}
#[test]
fn type_ids_match_spec() {
assert_eq!(TYPE_OK, 0x80);
assert_eq!(TYPE_ERR, 0x81);
assert_eq!(TYPE_RX, 0xC0);
assert_eq!(TYPE_TX_DONE, 0xC1);
}
#[test]
fn ok_empty_roundtrip_for_ping() {
let ok = OkPayload::Empty;
let mut buf = [0u8; 4];
let n = ok.encode(&mut buf).unwrap();
assert_eq!(n, 0);
assert_eq!(
OkPayload::parse_for(commands::TYPE_PING, &buf[..n]).unwrap(),
ok
);
}
#[test]
fn ok_empty_rejects_nonempty_for_tx() {
assert!(matches!(
OkPayload::parse_for(commands::TYPE_TX, &[0]),
Err(DeviceMessageParseError::WrongLength)
));
}
#[test]
fn ok_set_config_roundtrip() {
let r = SetConfigResult {
result: SetConfigResultCode::Applied,
owner: Owner::Mine,
current: Modulation::LoRa(sample_lora()),
};
let mut buf = [0u8; 64];
let n = r.encode(&mut buf).unwrap();
// 2 (result + owner) + 1 (mod id) + 15 (LoRa).
assert_eq!(n, 2 + 1 + 15);
assert_eq!(SetConfigResult::decode(&buf[..n]).unwrap(), r);
}
#[test]
fn ok_set_config_spec_bytes_c23() {
// PROTOCOL.md §C.2.3 — OK in reply to SET_CONFIG (tag=0x0003).
// Payload is: result=0, owner=1, mod=0x01, then 15-byte LoRa params.
let r = SetConfigResult {
result: SetConfigResultCode::Applied,
owner: Owner::Mine,
current: Modulation::LoRa(sample_lora()),
};
let mut buf = [0u8; 64];
let n = r.encode(&mut buf).unwrap();
let expected: [u8; 18] = [
0x00, // result APPLIED
0x01, // owner MINE
0x01, // modulation_id LoRa
0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x00, 0x08, 0x00, 0x24, 0x14, 0x0E, 0x00, 0x01,
0x00,
];
assert_eq!(&buf[..n], &expected);
}
#[test]
fn err_payload_roundtrip() {
let mut buf = [0u8; 2];
let n = encode_err_payload(ErrorCode::ENotConfigured, &mut buf).unwrap();
assert_eq!(n, 2);
assert_eq!(&buf, &[0x03, 0x00]);
let decoded = decode_err_payload(&buf).unwrap();
assert_eq!(decoded, ErrorCode::ENotConfigured);
}
#[test]
fn err_payload_preserves_unknown_codes() {
let mut buf = [0u8; 2];
encode_err_payload(ErrorCode::Unknown(0xABCD), &mut buf).unwrap();
let decoded = decode_err_payload(&buf).unwrap();
assert_eq!(decoded.as_u16(), 0xABCD);
}
#[test]
fn rx_payload_roundtrip_with_data() {
let mut data = HVec::new();
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -735,
snr_tenths_db: 95,
freq_err_hz: -125,
timestamp_us: 42_000_000,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
};
let mut buf = [0u8; 64];
let n = rx.encode(&mut buf).unwrap();
assert_eq!(n, 20 + 4);
let decoded = RxPayload::decode(&buf[..n]).unwrap();
assert_eq!(decoded, rx);
}
#[test]
fn rx_payload_spec_bytes_c26() {
// PROTOCOL.md §C.2.6 — the RX event in the continuous-RX loop.
let mut data = HVec::new();
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -735,
snr_tenths_db: 95,
freq_err_hz: -125,
timestamp_us: 42_000_000,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
};
let mut buf = [0u8; 64];
let n = rx.encode(&mut buf).unwrap();
let expected: [u8; 24] = [
0x21, 0xFD, // rssi -735 = 0xFD21 LE
0x5F, 0x00, // snr 95 = 0x005F LE
0x83, 0xFF, 0xFF, 0xFF, // freq_err -125 LE i32
0x80, 0xDE, 0x80, 0x02, 0x00, 0x00, 0x00, 0x00, // timestamp 42_000_000 LE u64
0x01, // crc_valid
0x00, 0x00, // packets_dropped
0x00, // origin OTA
0x01, 0x02, 0x03, 0x04,
];
assert_eq!(&buf[..n], &expected);
}
#[test]
fn rx_payload_rejects_invalid_crc_byte() {
let mut buf = [0u8; 20];
buf[16] = 2; // crc_valid must be 0 or 1
assert!(RxPayload::decode(&buf).is_err());
}
#[test]
fn tx_done_roundtrip() {
let td = TxDonePayload {
result: TxResult::Transmitted,
airtime_us: 30_976,
};
let mut buf = [0u8; 8];
let n = td.encode(&mut buf).unwrap();
assert_eq!(n, 5);
assert_eq!(TxDonePayload::decode(&buf[..n]).unwrap(), td);
}
#[test]
fn tx_done_spec_bytes_c24() {
// PROTOCOL.md §C.2.4 — TX_DONE for "Hello" at SF7 BW125.
let td = TxDonePayload {
result: TxResult::Transmitted,
airtime_us: 30_976,
};
let mut buf = [0u8; 5];
td.encode(&mut buf).unwrap();
assert_eq!(&buf, &[0x00, 0x00, 0x79, 0x00, 0x00]);
}
#[test]
fn device_message_parse_requires_ok_context() {
let mut buf = [0u8; 4];
let n = OkPayload::Empty.encode(&mut buf).unwrap();
assert!(matches!(
DeviceMessage::parse(TYPE_OK, &buf[..n], None),
Err(DeviceMessageParseError::MissingContext)
));
assert!(matches!(
DeviceMessage::parse(TYPE_OK, &buf[..n], Some(commands::TYPE_PING)).unwrap(),
DeviceMessage::Ok(OkPayload::Empty)
));
}
#[test]
fn device_message_unknown_type_rejects() {
assert!(matches!(
DeviceMessage::parse(0x55, &[], None),
Err(DeviceMessageParseError::UnknownType)
));
}
}
+369
View File
@@ -0,0 +1,369 @@
//! DongLoRa Protocol frame format and streaming decoder (`PROTOCOL.md §2`).
//!
//! Every DongLoRa Protocol frame on the wire is `COBS(type || tag_le || payload ||
//! crc_le) || 0x00`. This module owns the wire-level codec: it does
//! nothing protocol-semantic (no enum matching, no tag validation) —
//! just framing, CRC, and COBS.
//!
//! - `encode_frame` builds a complete wire-ready frame (COBS-encoded,
//! `0x00`-terminated) given `(type_id, tag, payload)`.
//! - `FrameDecoder` accumulates bytes, splitting on `0x00`, and emits
//! `FrameResult` values through a caller-provided closure. `FrameResult`
//! is `Ok` with borrowed pre-payload-and-tag slices on success, or
//! `Err` with a `FrameError` variant on CRC/COBS/length failure.
//!
//! The decoder's internal scratch borrows into the callback, so parsing
//! is zero-copy above the framing layer. Typed parsing (`Command`,
//! `Response`, `Event`) consumes those borrowed slices.
use crate::{
FRAME_HEADER_SIZE, FRAME_TRAILER_SIZE, FrameDecodeError, FrameEncodeError, MAX_PAYLOAD_FIELD,
MAX_PRE_COBS_FRAME, MAX_WIRE_FRAME, crc::crc16,
};
/// Outcome of attempting to decode one frame delimited by a `0x00` byte.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum FrameResult<'a> {
Ok {
type_id: u8,
tag: u16,
payload: &'a [u8],
},
Err(FrameDecodeError),
}
/// Worst-case COBS-encoded size for a maximum-length pre-COBS frame.
/// Equal to `MAX_WIRE_FRAME - 1` (the `-1` accounts for the trailing
/// `0x00` sentinel). Public so tests can pin its exact value against
/// the derivation in `lib.rs`.
pub const MAX_COBS_ENCODED: usize = MAX_WIRE_FRAME - 1;
/// Encode `(type_id, tag, payload)` as a complete DongLoRa Protocol frame (COBS-encoded,
/// `0x00`-terminated). Returns the number of bytes written to `out`.
///
/// `out` must be at least `MAX_WIRE_FRAME` bytes to satisfy the worst
/// case across all frame shapes. Smaller buffers succeed when the actual
/// frame fits.
pub fn encode_frame(
type_id: u8,
tag: u16,
payload: &[u8],
out: &mut [u8],
) -> Result<usize, FrameEncodeError> {
if payload.len() > MAX_PAYLOAD_FIELD {
return Err(FrameEncodeError::PayloadTooLarge);
}
let pre_cobs_len = FRAME_HEADER_SIZE + payload.len() + FRAME_TRAILER_SIZE;
let mut scratch = [0u8; MAX_PRE_COBS_FRAME];
scratch[0] = type_id;
scratch[1..3].copy_from_slice(&tag.to_le_bytes());
scratch[3..3 + payload.len()].copy_from_slice(payload);
let crc = crc16(&scratch[..FRAME_HEADER_SIZE + payload.len()]);
scratch[FRAME_HEADER_SIZE + payload.len()..pre_cobs_len].copy_from_slice(&crc.to_le_bytes());
let mut cobs_scratch = [0u8; MAX_COBS_ENCODED];
let encoded_len = ucobs::encode(&scratch[..pre_cobs_len], &mut cobs_scratch)
.ok_or(FrameEncodeError::CobsEncode)?;
let total = encoded_len + 1;
if out.len() < total {
return Err(FrameEncodeError::BufferTooSmall);
}
out[..encoded_len].copy_from_slice(&cobs_scratch[..encoded_len]);
out[encoded_len] = 0x00;
Ok(total)
}
/// Streaming accumulator for inbound bytes. Emits one `FrameResult` per
/// `0x00`-delimited frame encountered in the feed.
///
/// Overflow policy: if accumulated bytes exceed the maximum COBS-encoded
/// size without a delimiter, the buffer is reset and the next `0x00` is
/// treated as the start of a fresh frame. A `FrameResult::Err` is NOT
/// emitted for the dropped bytes — the bytes are simply never classified
/// (the device or host will observe a dropped response and time out at
/// the command level).
pub struct FrameDecoder {
buf: [u8; MAX_COBS_ENCODED],
len: usize,
overflowed: bool,
}
impl FrameDecoder {
pub const fn new() -> Self {
Self {
buf: [0u8; MAX_COBS_ENCODED],
len: 0,
overflowed: false,
}
}
/// Discard any partial frame currently buffered.
pub fn reset(&mut self) {
self.len = 0;
self.overflowed = false;
}
/// Feed `data` into the accumulator. For every complete frame
/// detected, call `on_frame` with the result.
pub fn feed<F: FnMut(FrameResult<'_>)>(&mut self, data: &[u8], mut on_frame: F) {
for &byte in data {
if byte == 0x00 {
if self.overflowed || self.len == 0 {
// Overflow recovery or stray sentinel: drop silently.
self.len = 0;
self.overflowed = false;
continue;
}
let mut decoded = [0u8; MAX_PRE_COBS_FRAME];
match ucobs::decode(&self.buf[..self.len], &mut decoded) {
Some(n) => {
emit_decoded(&decoded[..n], &mut on_frame);
}
None => on_frame(FrameResult::Err(FrameDecodeError::Cobs)),
}
self.len = 0;
continue;
}
if self.overflowed {
// Still waiting for the next 0x00 to resync; drop this byte.
continue;
}
if self.len < self.buf.len() {
self.buf[self.len] = byte;
self.len += 1;
} else {
self.overflowed = true;
self.len = 0;
}
}
}
}
impl Default for FrameDecoder {
fn default() -> Self {
Self::new()
}
}
fn emit_decoded<F: FnMut(FrameResult<'_>)>(decoded: &[u8], on_frame: &mut F) {
if decoded.len() < FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE {
on_frame(FrameResult::Err(FrameDecodeError::TooShort));
return;
}
let crc_start = decoded.len() - FRAME_TRAILER_SIZE;
let body = &decoded[..crc_start];
let expected = crc16(body);
let got = u16::from_le_bytes([decoded[crc_start], decoded[crc_start + 1]]);
if expected != got {
on_frame(FrameResult::Err(FrameDecodeError::Crc));
return;
}
let type_id = body[0];
let tag = u16::from_le_bytes([body[1], body[2]]);
let payload = &body[FRAME_HEADER_SIZE..];
on_frame(FrameResult::Ok {
type_id,
tag,
payload,
});
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn max_cobs_encoded_is_wire_frame_minus_one() {
// Kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` with `+1` / `/1`
// mutants (285 and 284 respectively, both ≠ 283).
assert_eq!(MAX_COBS_ENCODED, 283);
assert_eq!(MAX_COBS_ENCODED, MAX_WIRE_FRAME - 1);
}
fn roundtrip(type_id: u8, tag: u16, payload: &[u8]) {
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(type_id, tag, payload, &mut wire).unwrap();
let mut decoder = FrameDecoder::new();
let mut emitted: heapless::Vec<(u8, u16, heapless::Vec<u8, 300>), 4> = heapless::Vec::new();
decoder.feed(&wire[..n], |res| match res {
FrameResult::Ok {
type_id,
tag,
payload,
} => {
let mut p = heapless::Vec::new();
p.extend_from_slice(payload).unwrap();
emitted.push((type_id, tag, p)).unwrap();
}
FrameResult::Err(e) => panic!("decode error: {:?}", e),
});
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].0, type_id);
assert_eq!(emitted[0].1, tag);
assert_eq!(emitted[0].2.as_slice(), payload);
}
#[test]
fn empty_payload() {
roundtrip(0x01, 0x0001, &[]);
}
#[test]
fn one_byte_payload() {
roundtrip(0x04, 0x1234, &[0xAA]);
}
#[test]
fn max_payload() {
let big = [0x42u8; MAX_PAYLOAD_FIELD];
roundtrip(0xC0, 0x0000, &big);
}
#[test]
fn rejects_too_large_payload() {
let big = [0u8; MAX_PAYLOAD_FIELD + 1];
let mut wire = [0u8; MAX_WIRE_FRAME];
assert!(matches!(
encode_frame(0x01, 1, &big, &mut wire),
Err(FrameEncodeError::PayloadTooLarge)
));
}
#[test]
fn rejects_small_output_buffer() {
let mut tiny = [0u8; 4];
assert!(matches!(
encode_frame(0x01, 1, b"hello", &mut tiny),
Err(FrameEncodeError::BufferTooSmall)
));
}
#[test]
fn detects_crc_corruption() {
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x04, 0x0042, b"hello", &mut wire).unwrap();
// Flip a non-sentinel byte mid-frame. This will corrupt the CRC.
wire[4] ^= 0xFF;
let mut decoder = FrameDecoder::new();
let mut got_err = None;
decoder.feed(&wire[..n], |res| {
if let FrameResult::Err(e) = res {
got_err = Some(e);
}
});
assert!(matches!(
got_err,
Some(FrameDecodeError::Crc | FrameDecodeError::Cobs)
));
}
#[test]
fn resync_after_garbage() {
let mut decoder = FrameDecoder::new();
// Leading garbage with no 0x00 is accumulated silently.
decoder.feed(&[0xAA, 0xBB, 0xCC], |res| {
panic!("unexpected frame: {:?}", res);
});
// A first 0x00 closes the bogus frame — expect a decode error.
let mut errs = 0;
let mut oks = 0;
decoder.feed(&[0x00], |res| match res {
FrameResult::Err(_) => errs += 1,
FrameResult::Ok { .. } => oks += 1,
});
assert!(errs >= 1);
assert_eq!(oks, 0);
// Now a clean frame decodes normally.
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x80, 0x0001, b"", &mut wire).unwrap();
let mut got_type = None;
decoder.feed(&wire[..n], |res| {
if let FrameResult::Ok { type_id, .. } = res {
got_type = Some(type_id);
}
});
assert_eq!(got_type, Some(0x80));
}
#[test]
fn overflow_resets_and_continues() {
let mut decoder = FrameDecoder::new();
// Overrun the buffer with a long non-zero run.
let bomb = [0x55u8; MAX_COBS_ENCODED + 20];
decoder.feed(&bomb, |res| panic!("unexpected frame: {:?}", res));
// The next 0x00 clears the overflow state without emitting a
// spurious error (we never saw a sane frame candidate).
decoder.feed(&[0x00], |res| panic!("unexpected frame: {:?}", res));
// A clean frame still decodes.
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x01, 0x0007, &[], &mut wire).unwrap();
let mut got_type_tag = None;
decoder.feed(&wire[..n], |res| {
if let FrameResult::Ok { type_id, tag, .. } = res {
got_type_tag = Some((type_id, tag));
}
});
assert_eq!(got_type_tag, Some((0x01, 0x0007)));
}
#[test]
fn multiple_frames_in_one_feed() {
let mut wire = [0u8; MAX_WIRE_FRAME * 3];
let a = encode_frame(0x01, 1, &[], &mut wire).unwrap();
let b = encode_frame(0x02, 2, &[], &mut wire[a..]).unwrap();
let c = encode_frame(0x03, 3, &[], &mut wire[a + b..]).unwrap();
let total = a + b + c;
let mut decoder = FrameDecoder::new();
let mut tags: heapless::Vec<u16, 4> = heapless::Vec::new();
decoder.feed(&wire[..total], |res| {
if let FrameResult::Ok { tag, .. } = res {
tags.push(tag).unwrap();
}
});
assert_eq!(tags.as_slice(), &[1, 2, 3]);
}
#[test]
fn detects_short_decoded_frame() {
// Craft a COBS-encoded 1-byte pre-COBS frame (below 5-byte min).
// COBS of [0x42] is [0x02, 0x42].
let wire = [0x02, 0x42, 0x00];
let mut decoder = FrameDecoder::new();
let mut got_err = None;
decoder.feed(&wire, |res| {
if let FrameResult::Err(e) = res {
got_err = Some(e);
}
});
assert_eq!(got_err, Some(FrameDecodeError::TooShort));
}
#[test]
fn spec_ping_example() {
// PROTOCOL.md §C.2.1 — H→D PING, tag=0x0001.
// Expected on-wire: 03 01 01 03 9D C8 00
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
assert_eq!(&wire[..n], &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
}
#[test]
fn spec_ok_example() {
// PROTOCOL.md §C.2.1 — D→H OK, tag=0x0001.
// Expected on-wire: 03 80 01 03 F7 C4 00
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x80, 0x0001, &[], &mut wire).unwrap();
assert_eq!(&wire[..n], &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
}
}
-94
View File
@@ -1,94 +0,0 @@
//! COBS framing for the DongLoRa wire protocol.
//!
//! Every frame sent between host and firmware is a COBS-encoded
//! [`Command`] or [`Response`] payload followed by a single `0x00`
//! sentinel byte. [`CobsDecoder`] accumulates an inbound byte stream
//! and yields parsed [`Command`]s; [`cobs_encode_response`] produces a
//! wire-ready byte slice from a [`Response`].
//!
//! Kept tiny and alloc-free so both the firmware (no_std, bounded
//! buffers) and host crates can share it.
use crate::{Command, Response};
/// Maximum COBS frame size (bytes).
///
/// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263
/// raw bytes. COBS adds `ceil(263/254)+1 = 3` bytes. 266 < 512.
pub const MAX_FRAME: usize = 512;
const _: () = assert!(
MAX_FRAME >= crate::MAX_PAYLOAD + 64,
"MAX_FRAME too small for max payload + COBS overhead"
);
/// Decodes COBS-framed commands from a byte stream.
pub struct CobsDecoder {
buf: [u8; MAX_FRAME],
len: usize,
}
impl Default for CobsDecoder {
fn default() -> Self {
Self::new()
}
}
impl CobsDecoder {
pub const fn new() -> Self {
Self {
buf: [0u8; MAX_FRAME],
len: 0,
}
}
/// Reset the accumulator (discard partial frame).
pub fn reset(&mut self) {
self.len = 0;
}
/// Feed a chunk of bytes, calling `on_command` for each decoded command.
pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) {
for &byte in data {
if byte == 0x00 {
// End of COBS frame — decode and dispatch
if self.len > 0 {
let mut decode_buf = [0u8; MAX_FRAME];
if let Some(decoded_len) = ucobs::decode(&self.buf[..self.len], &mut decode_buf)
{
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
on_command(cmd);
}
}
}
self.len = 0;
} else if self.len < MAX_FRAME {
self.buf[self.len] = byte;
self.len += 1;
} else {
// Frame too large — discard
self.len = 0;
}
}
}
}
/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel.
///
/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow.
pub fn cobs_encode_response<'a>(
response: Response,
write_buf: &mut [u8; MAX_FRAME],
encode_buf: &'a mut [u8; MAX_FRAME],
) -> Option<&'a [u8]> {
let raw_len = response.write_to(write_buf);
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
if encoded_len < encode_buf.len() {
encode_buf[encoded_len] = 0x00;
Some(&encode_buf[..encoded_len + 1])
} else {
#[cfg(feature = "defmt")]
defmt::warn!("COBS encode buffer overflow");
None
}
}
+323
View File
@@ -0,0 +1,323 @@
//! `GET_INFO` response payload (`PROTOCOL.md §6.2`).
//!
//! Carries the device's identity, firmware version, radio type,
//! capability bitmaps, queue depths, frequency range, TX-power range,
//! and optional MCU / radio unique identifiers. All fields are stable
//! for the lifetime of the session; hosts MAY cache the whole struct.
use crate::{InfoParseError, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
/// Capability bitmap bit positions (`PROTOCOL.md §9`).
///
/// Hosts should consult these before attempting `SET_CONFIG` with a
/// particular modulation. Undefined bits MUST be zero in v1.0 and MUST
/// be ignored by hosts that don't understand them.
pub mod cap {
// Modulations (bits 0–15)
//
// Bit 0 is written as a bare `1` (not `1 << 0`) because `1 << 0` and
// `1 >> 0` both evaluate to 1 — the two-operator form produces an
// equivalent mutation target that no test can distinguish. Every
// other bit keeps the `1 << N` form for clarity.
pub const LORA: u64 = 1;
pub const FSK: u64 = 1 << 1;
pub const GFSK: u64 = 1 << 2;
pub const LR_FHSS: u64 = 1 << 3;
pub const FLRC: u64 = 1 << 4;
pub const MSK: u64 = 1 << 5;
pub const GMSK: u64 = 1 << 6;
pub const BLE_COMPATIBLE: u64 = 1 << 7;
// Radio features (bits 16–31)
pub const CAD_BEFORE_TX: u64 = 1 << 16;
pub const IQ_INVERSION: u64 = 1 << 17;
pub const RANGING: u64 = 1 << 18;
pub const GNSS_SCAN: u64 = 1 << 19;
pub const WIFI_MAC_SCAN: u64 = 1 << 20;
pub const SPECTRAL_SCAN: u64 = 1 << 21;
pub const FULL_DUPLEX: u64 = 1 << 22;
// Protocol features (bits 32–47)
pub const MULTI_CLIENT: u64 = 1 << 32;
}
/// `GET_INFO` response payload.
///
/// `mcu_uid` and `radio_uid` are fixed-capacity arrays (`MAX_MCU_UID_LEN`
/// and `MAX_RADIO_UID_LEN` respectively). Only the first `*_len` bytes
/// are meaningful on the wire; the rest are zero-padded and ignored.
///
/// `radio_chip_id` is stored as the raw u16. Use `chip_id()` to project
/// it into the `RadioChipId` enum when convenient; unassigned values
/// stay representable so a newer device advertising a chip this codec
/// hasn't heard of still round-trips cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct Info {
pub proto_major: u8,
pub proto_minor: u8,
pub fw_major: u8,
pub fw_minor: u8,
pub fw_patch: u8,
pub radio_chip_id: u16,
pub capability_bitmap: u64,
pub supported_sf_bitmap: u16,
pub supported_bw_bitmap: u16,
pub max_payload_bytes: u16,
pub rx_queue_capacity: u16,
pub tx_queue_capacity: u16,
pub freq_min_hz: u32,
pub freq_max_hz: u32,
pub tx_power_min_dbm: i8,
pub tx_power_max_dbm: i8,
pub mcu_uid_len: u8,
pub mcu_uid: [u8; MAX_MCU_UID_LEN],
pub radio_uid_len: u8,
pub radio_uid: [u8; MAX_RADIO_UID_LEN],
}
impl Info {
/// Size of the fixed-layout prefix (through `radio_uid_len`'s byte).
/// The spec locates `radio_uid_len` at offset `36 + mcu_uid_len`; the
/// 37 bytes of fixed fields before `mcu_uid` plus 1 for the
/// `radio_uid_len` byte gives the absolute minimum wire size (with
/// `mcu_uid_len = 0` and `radio_uid_len = 0`).
pub const MIN_WIRE_SIZE: usize = 37;
/// Project `radio_chip_id` into the enum. Returns `None` for
/// unassigned values so callers can decide how to handle them.
pub fn chip_id(&self) -> Option<crate::RadioChipId> {
crate::RadioChipId::from_u16(self.radio_chip_id)
}
/// True if the device advertises a given capability bit.
pub fn supports(&self, mask: u64) -> bool {
self.capability_bitmap & mask != 0
}
/// Encode into `buf`. Returns the number of bytes written
/// (`37 + mcu_uid_len + radio_uid_len`).
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, InfoParseError> {
let mcu_n = self.mcu_uid_len as usize;
let radio_n = self.radio_uid_len as usize;
if mcu_n > MAX_MCU_UID_LEN || radio_n > MAX_RADIO_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
if buf.len() < total {
return Err(InfoParseError::BufferTooSmall);
}
buf[0] = self.proto_major;
buf[1] = self.proto_minor;
buf[2] = self.fw_major;
buf[3] = self.fw_minor;
buf[4] = self.fw_patch;
buf[5..7].copy_from_slice(&self.radio_chip_id.to_le_bytes());
buf[7..15].copy_from_slice(&self.capability_bitmap.to_le_bytes());
buf[15..17].copy_from_slice(&self.supported_sf_bitmap.to_le_bytes());
buf[17..19].copy_from_slice(&self.supported_bw_bitmap.to_le_bytes());
buf[19..21].copy_from_slice(&self.max_payload_bytes.to_le_bytes());
buf[21..23].copy_from_slice(&self.rx_queue_capacity.to_le_bytes());
buf[23..25].copy_from_slice(&self.tx_queue_capacity.to_le_bytes());
buf[25..29].copy_from_slice(&self.freq_min_hz.to_le_bytes());
buf[29..33].copy_from_slice(&self.freq_max_hz.to_le_bytes());
buf[33] = self.tx_power_min_dbm as u8;
buf[34] = self.tx_power_max_dbm as u8;
buf[35] = self.mcu_uid_len;
buf[36..36 + mcu_n].copy_from_slice(&self.mcu_uid[..mcu_n]);
let radio_len_idx = 36 + mcu_n;
buf[radio_len_idx] = self.radio_uid_len;
let radio_start = radio_len_idx + 1;
buf[radio_start..radio_start + radio_n].copy_from_slice(&self.radio_uid[..radio_n]);
Ok(total)
}
/// Decode from `buf`. The full slice must be the `GET_INFO` payload.
pub fn decode(buf: &[u8]) -> Result<Self, InfoParseError> {
if buf.len() < Self::MIN_WIRE_SIZE {
return Err(InfoParseError::TooShort);
}
let mcu_uid_len = buf[35];
if mcu_uid_len as usize > MAX_MCU_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let mcu_n = mcu_uid_len as usize;
let radio_len_idx = 36 + mcu_n;
if buf.len() < radio_len_idx + 1 {
return Err(InfoParseError::TooShort);
}
let radio_uid_len = buf[radio_len_idx];
if radio_uid_len as usize > MAX_RADIO_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let radio_n = radio_uid_len as usize;
let expected_total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
if buf.len() < expected_total {
return Err(InfoParseError::TooShort);
}
// Minor-version extensions may append fields; trailing bytes are
// allowed. But we at least require the declared UIDs to fit.
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
mcu_uid[..mcu_n].copy_from_slice(&buf[36..36 + mcu_n]);
let radio_start = radio_len_idx + 1;
let mut radio_uid = [0u8; MAX_RADIO_UID_LEN];
radio_uid[..radio_n].copy_from_slice(&buf[radio_start..radio_start + radio_n]);
Ok(Self {
proto_major: buf[0],
proto_minor: buf[1],
fw_major: buf[2],
fw_minor: buf[3],
fw_patch: buf[4],
radio_chip_id: u16::from_le_bytes([buf[5], buf[6]]),
capability_bitmap: u64::from_le_bytes([
buf[7], buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14],
]),
supported_sf_bitmap: u16::from_le_bytes([buf[15], buf[16]]),
supported_bw_bitmap: u16::from_le_bytes([buf[17], buf[18]]),
max_payload_bytes: u16::from_le_bytes([buf[19], buf[20]]),
rx_queue_capacity: u16::from_le_bytes([buf[21], buf[22]]),
tx_queue_capacity: u16::from_le_bytes([buf[23], buf[24]]),
freq_min_hz: u32::from_le_bytes([buf[25], buf[26], buf[27], buf[28]]),
freq_max_hz: u32::from_le_bytes([buf[29], buf[30], buf[31], buf[32]]),
tx_power_min_dbm: buf[33] as i8,
tx_power_max_dbm: buf[34] as i8,
mcu_uid_len,
mcu_uid,
radio_uid_len,
radio_uid,
})
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::RadioChipId;
fn sample() -> Info {
let mut mcu = [0u8; MAX_MCU_UID_LEN];
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
Info {
proto_major: 1,
proto_minor: 0,
fw_major: 0,
fw_minor: 1,
fw_patch: 0,
radio_chip_id: RadioChipId::Sx1262.as_u16(),
capability_bitmap: cap::LORA | cap::FSK | cap::CAD_BEFORE_TX,
// Bits 5..12 = SF5..SF12
supported_sf_bitmap: 0x1FE0,
// Bits 0..9 = all sub-GHz BW enum values
supported_bw_bitmap: 0x03FF,
max_payload_bytes: 255,
rx_queue_capacity: 64,
tx_queue_capacity: 16,
freq_min_hz: 150_000_000,
freq_max_hz: 960_000_000,
tx_power_min_dbm: -9,
tx_power_max_dbm: 22,
mcu_uid_len: 8,
mcu_uid: mcu,
radio_uid_len: 0,
radio_uid: [0u8; MAX_RADIO_UID_LEN],
}
}
#[test]
fn appendix_c22_encode_matches_spec() {
// PROTOCOL.md §C.2.2 — SX1262 board description. The spec's
// pre-COBS payload (after stripping the 3-byte response header:
// type 0x80, tag 0x0002) is:
//
// 01 00 00 01 00 02 00 03 00 01 00 00 00 00 00 00
// E0 1F FF 03 FF 00 40 00 10 00 80 D1 F0 08 00 70
// 38 39 F7 16 08 DE AD BE EF 01 23 45 67 00
//
// 45 bytes: 37 fixed + 8 mcu_uid + 0 radio_uid.
let mut buf = [0u8; 128];
let n = sample().encode(&mut buf).unwrap();
assert_eq!(n, 45);
let expected: [u8; 45] = [
0x01, 0x00, 0x00, 0x01, 0x00, // proto + fw
0x02, 0x00, // radio_chip_id = 0x0002 (SX1262)
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // capability_bitmap
0xE0, 0x1F, // supported_sf_bitmap = 0x1FE0
0xFF, 0x03, // supported_bw_bitmap = 0x03FF
0xFF, 0x00, // max_payload_bytes = 255
0x40, 0x00, // rx_queue_capacity = 64
0x10, 0x00, // tx_queue_capacity = 16
0x80, 0xD1, 0xF0, 0x08, // freq_min_hz = 150_000_000
0x00, 0x70, 0x38, 0x39, // freq_max_hz = 960_000_000
0xF7, // tx_power_min_dbm = -9
0x16, // tx_power_max_dbm = 22
0x08, // mcu_uid_len = 8
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, // mcu_uid
0x00, // radio_uid_len = 0
];
assert_eq!(&buf[..n], &expected);
}
#[test]
fn roundtrip() {
let info = sample();
let mut buf = [0u8; 128];
let n = info.encode(&mut buf).unwrap();
let decoded = Info::decode(&buf[..n]).unwrap();
assert_eq!(decoded, info);
}
#[test]
fn chip_id_projection() {
let info = sample();
assert_eq!(info.chip_id(), Some(RadioChipId::Sx1262));
let unknown = Info {
radio_chip_id: 0xFFFF,
..info
};
assert_eq!(unknown.chip_id(), None);
}
#[test]
fn supports_bits() {
let info = sample();
assert!(info.supports(cap::LORA));
assert!(info.supports(cap::FSK));
assert!(info.supports(cap::CAD_BEFORE_TX));
assert!(!info.supports(cap::MULTI_CLIENT));
assert!(!info.supports(cap::LR_FHSS));
}
#[test]
fn rejects_oversized_uids() {
let mut info = sample();
info.mcu_uid_len = (MAX_MCU_UID_LEN + 1) as u8;
let mut buf = [0u8; 128];
assert!(info.encode(&mut buf).is_err());
info.mcu_uid_len = 0;
info.radio_uid_len = (MAX_RADIO_UID_LEN + 1) as u8;
assert!(info.encode(&mut buf).is_err());
}
#[test]
fn rejects_short_buffer_on_decode() {
assert!(matches!(
Info::decode(&[0u8; 10]),
Err(InfoParseError::TooShort)
));
}
#[test]
fn rejects_declared_uid_overrun() {
let mut buf = [0u8; 128];
let mut info = sample();
info.mcu_uid_len = 16;
let n = info.encode(&mut buf).unwrap();
// Shorten the wire beyond what mcu_uid_len claims.
let truncated = n - 4;
assert!(Info::decode(&buf[..truncated]).is_err());
}
}
+228 -695
View File
@@ -1,718 +1,251 @@
//! Wire protocol types and fixed-size little-endian serialization.
//! DongLoRa Protocol v2 — wire types, framing, and codecs.
//!
//! Every integer is fixed-width LE. No varints, no zigzag.
//! See `docs/PROTOCOL.md` for the complete specification.
//! This crate is the normative Rust implementation of the protocol
//! defined in `PROTOCOL.md`. It is `no_std` and alloc-free: every
//! container is fixed-capacity (`heapless::Vec`) and every fallible
//! operation returns a typed error — no panics, no unwraps, no
//! `unsafe`.
//!
//! # Layout
//!
//! - [`frame`] — wire-level COBS + CRC framing. `encode_frame` turns a
//! `(type_id, tag, payload)` tuple into a complete wire-ready byte
//! slice; [`FrameDecoder`] accumulates inbound bytes and emits
//! [`FrameResult`] values as frames arrive.
//! - [`commands`] — host-to-device command enum and `type_id` constants.
//! - [`events`] — device-to-host message enum (`OK` / `ERR` / `RX` /
//! `TX_DONE`) and its sub-shapes.
//! - [`modulation`] — per-modulation parameter structs for `SET_CONFIG`
//! (LoRa, FSK/GFSK, LR-FHSS, FLRC).
//! - [`info`] — `GET_INFO` response payload, including capability bits.
//! - [`errors`] — wire-level error codes (`ErrorCode`).
//! - [`chip_id`] — `RadioChipId` enum.
//! - [`crc`] — CRC-16/CCITT-FALSE implementation.
//!
//! # Typical encode
//!
//! ```no_run
//! use donglora_protocol::{encode_frame, Command, commands::TYPE_PING, MAX_WIRE_FRAME};
//!
//! let cmd = Command::Ping;
//! let mut payload_buf = [0u8; 4];
//! let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
//!
//! let mut wire = [0u8; MAX_WIRE_FRAME];
//! let wire_len = encode_frame(cmd.type_id(), 1, &payload_buf[..payload_len], &mut wire).unwrap();
//! // wire[..wire_len] is ready to transmit.
//! # let _ = (TYPE_PING, wire_len);
//! ```
//!
//! # Typical decode
//!
//! ```no_run
//! use donglora_protocol::{Command, FrameDecoder, FrameResult};
//!
//! let mut decoder = FrameDecoder::new();
//! let incoming_bytes: &[u8] = &[];
//! decoder.feed(incoming_bytes, |res| match res {
//! FrameResult::Ok { type_id, tag, payload } => {
//! match Command::parse(type_id, payload) {
//! Ok(cmd) => { /* dispatch cmd with tag */ }
//! Err(_e) => { /* respond ERR(EUNKNOWN_CMD) with echoed tag */ }
//! }
//! }
//! FrameResult::Err(_e) => { /* emit async ERR(EFRAME) */ }
//! });
//! ```
#![no_std]
pub mod framing;
pub mod chip_id;
pub mod commands;
pub mod crc;
pub mod errors;
pub mod events;
pub mod frame;
pub mod info;
pub mod modulation;
use heapless::Vec;
// ── Public re-exports ───────────────────────────────────────────────
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
pub use chip_id::RadioChipId;
pub use commands::{Command, TxFlags};
pub use errors::ErrorCode;
pub use events::{
DeviceMessage, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode,
TxDonePayload, TxResult,
};
pub use frame::{FrameDecoder, FrameResult, encode_frame};
pub use info::{Info, cap};
pub use modulation::{
FlrcBitrate, FlrcBt, FlrcCodingRate, FlrcConfig, FlrcPreambleLen, FskConfig, LoRaBandwidth,
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, LrFhssBandwidth, LrFhssCodingRate, LrFhssConfig,
LrFhssGrid, Modulation, ModulationId,
};
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 13;
// ── Top-level size constants ────────────────────────────────────────
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Maximum over-the-air packet payload (bytes). The spec allows this to
/// be chip-dependent via `GET_INFO.max_payload_bytes`; 255 is the
/// ceiling for all currently-supported Semtech silicon.
pub const MAX_OTA_PAYLOAD: usize = 255;
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
pub const PREAMBLE_DEFAULT: u16 = 0;
/// Maximum payload-field size in any DongLoRa Protocol frame. This is the OTA limit
/// plus the 20-byte `RX` metadata prefix: type + tag + this + crc gives
/// the overall frame budget.
pub const MAX_PAYLOAD_FIELD: usize = MAX_OTA_PAYLOAD + 20;
/// LoRa signal bandwidth.
/// Byte count of the pre-CRC, pre-COBS frame header: `type(1) + tag(2)`.
pub const FRAME_HEADER_SIZE: usize = 3;
/// Byte count of the frame trailer (CRC16).
pub const FRAME_TRAILER_SIZE: usize = 2;
/// Maximum pre-COBS frame size: header + max payload + trailer.
pub const MAX_PRE_COBS_FRAME: usize = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + FRAME_TRAILER_SIZE;
/// COBS encoding overhead upper bound: `ceil(n/254) + 1` bytes added
/// around a run of n bytes. For `MAX_PRE_COBS_FRAME = 280` this is 3.
pub const MAX_COBS_OVERHEAD: usize = 3;
/// Maximum on-wire frame size: pre-COBS size + COBS overhead + 1 for the
/// trailing `0x00` sentinel. Buffer sizes of this value (or more)
/// guarantee no encode-side overflow across any valid frame.
pub const MAX_WIRE_FRAME: usize = MAX_PRE_COBS_FRAME + MAX_COBS_OVERHEAD + 1;
/// Maximum FSK/GFSK sync-word length (`PROTOCOL.md §10.2`).
pub const MAX_SYNC_WORD_LEN: usize = 8;
/// Maximum `GET_INFO.mcu_uid` length (`PROTOCOL.md §6.2`).
pub const MAX_MCU_UID_LEN: usize = 32;
/// Maximum `GET_INFO.radio_uid` length (`PROTOCOL.md §6.2`).
pub const MAX_RADIO_UID_LEN: usize = 16;
/// Maximum `OK` payload size across all shapes. `Info` is the worst
/// case: `GET_INFO`'s 37-byte fixed prefix plus both maximum UIDs
/// (32 + 16).
pub const MAX_OK_PAYLOAD: usize = 37 + MAX_MCU_UID_LEN + MAX_RADIO_UID_LEN;
/// Maximum `OK` payload size for `SET_CONFIG`: `result(1) + owner(1) +
/// modulation_id(1) + max params(24 for FSK with 8-byte sync word)`.
pub const MAX_SETCONFIG_OK_PAYLOAD: usize = 2 + 1 + 24;
// ── Error types ─────────────────────────────────────────────────────
/// Errors returned from `encode_frame`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum Bandwidth {
Khz7 = 0,
Khz10 = 1,
Khz15 = 2,
Khz20 = 3,
Khz31 = 4,
Khz41 = 5,
Khz62 = 6,
Khz125 = 7,
Khz250 = 8,
Khz500 = 9,
pub enum FrameEncodeError {
/// Payload exceeds `MAX_PAYLOAD_FIELD`.
PayloadTooLarge,
/// Output buffer too small for the encoded frame.
BufferTooSmall,
/// COBS encoder rejected the input (should be unreachable given
/// size checks, but surfaced as its own variant for completeness).
CobsEncode,
}
impl Bandwidth {
fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::Khz7),
1 => Some(Self::Khz10),
2 => Some(Self::Khz15),
3 => Some(Self::Khz20),
4 => Some(Self::Khz31),
5 => Some(Self::Khz41),
6 => Some(Self::Khz62),
7 => Some(Self::Khz125),
8 => Some(Self::Khz250),
9 => Some(Self::Khz500),
_ => None,
}
}
}
/// Complete LoRa radio configuration.
///
/// Wire layout (13 bytes, all little-endian):
/// ```text
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
/// ```
/// Errors returned from the frame decoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct RadioConfig {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
pub freq_hz: u32,
pub bw: Bandwidth,
/// Spreading factor (5-12).
pub sf: u8,
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
/// board's maximum.
pub tx_power_dbm: i8,
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
/// the firmware default (16 symbols). Valid range: 6–65535.
pub preamble_len: u16,
/// Channel Activity Detection (listen-before-talk) before TX.
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
pub cad: u8,
pub enum FrameDecodeError {
/// COBS decode failed.
Cobs,
/// CRC check failed.
Crc,
/// Decoded frame shorter than the minimum `type + tag + crc`.
TooShort,
}
impl RadioConfig {
/// Validate fields against hardware limits.
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
return Err("frequency out of range (150-960 MHz)");
}
if !(5..=12).contains(&self.sf) {
return Err("spreading factor out of range (5-12)");
}
if !(5..=8).contains(&self.cr) {
return Err("coding rate out of range (5-8)");
}
if self.tx_power_dbm != TX_POWER_MAX
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
{
return Err("TX power out of range for this board");
}
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
return Err("preamble length too short (min 6)");
}
Ok(())
}
/// Resolve sentinel values to concrete defaults.
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
if self.tx_power_dbm == TX_POWER_MAX {
self.tx_power_dbm = power_range.1;
}
if self.preamble_len == PREAMBLE_DEFAULT {
self.preamble_len = 16;
}
self
}
/// Whether CAD (listen-before-talk) is enabled.
pub fn cad_enabled(&self) -> bool {
self.cad != 0
}
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
pub fn write_to(self, buf: &mut [u8]) -> usize {
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw as u8;
buf[5] = self.sf;
buf[6] = self.cr;
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
buf[9] = self.tx_power_dbm as u8;
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[12] = self.cad;
RADIO_CONFIG_SIZE
}
/// Deserialize from fixed-size LE bytes.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < RADIO_CONFIG_SIZE {
return None;
}
Some(Self {
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
bw: Bandwidth::from_u8(buf[4])?,
sf: buf[5],
cr: buf[6],
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
tx_power_dbm: buf[9] as i8,
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
cad: buf[12],
})
}
}
/// Host → firmware commands.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Ping,
GetConfig,
SetConfig(RadioConfig),
StartRx,
StopRx,
Transmit {
config: Option<RadioConfig>,
payload: Vec<u8, MAX_PAYLOAD>,
},
DisplayOn,
DisplayOff,
GetMac,
}
impl Command {
/// Deserialize from a COBS-decoded frame.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Ping),
1 => Some(Self::GetConfig),
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
3 => Some(Self::StartRx),
4 => Some(Self::StopRx),
5 => {
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
if rest.is_empty() {
return None;
}
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
(
Some(RadioConfig::from_bytes(&rest[1..])?),
1 + RADIO_CONFIG_SIZE,
)
} else {
return None;
};
if rest.len() < pos + 2 {
return None;
}
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
let data_start = pos + 2;
if rest.len() < data_start + len {
return None;
}
let mut payload = Vec::new();
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
Some(Self::Transmit { config, payload })
}
6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff),
8 => Some(Self::GetMac),
_ => None,
}
}
}
/// Firmware → host responses.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum Response {
Pong,
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8, MAX_PAYLOAD>,
},
TxDone,
Ok,
Error(ErrorCode),
MacAddress([u8; 6]),
}
impl Response {
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
pub fn write_to(self, buf: &mut [u8]) -> usize {
match self {
Self::Pong => {
buf[0] = 0;
1
}
Self::Config(cfg) => {
buf[0] = 1;
1 + cfg.write_to(&mut buf[1..])
}
Self::RxPacket { rssi, snr, payload } => {
buf[0] = 2;
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
buf[3..5].copy_from_slice(&snr.to_le_bytes());
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[7..7 + payload.len()].copy_from_slice(&payload);
7 + payload.len()
}
Self::TxDone => {
buf[0] = 3;
1
}
Self::Ok => {
buf[0] = 4;
1
}
Self::Error(code) => {
buf[0] = 5;
buf[1] = code as u8;
2
}
Self::MacAddress(mac) => {
buf[0] = 6;
buf[1..7].copy_from_slice(&mac);
7
}
}
}
}
/// Error codes reported to the host.
/// Errors from encoding a `Command` payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 0,
RadioBusy = 1,
TxTimeout = 2,
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
NotConfigured = 4,
NoDisplay = 5,
pub enum CommandEncodeError {
/// Output buffer too small.
BufferTooSmall,
/// TX with zero-byte payload (spec rejects with `ERR(ELENGTH)`).
EmptyTxPayload,
/// TX payload exceeds `MAX_OTA_PAYLOAD`.
PayloadTooLarge,
/// FSK sync_word_len > `MAX_SYNC_WORD_LEN`.
SyncWordTooLong,
}
// ── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn make_config() -> RadioConfig {
RadioConfig {
freq_hz: 915_000_000,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: 0x3444,
tx_power_dbm: 22,
preamble_len: 16,
cad: 1,
}
}
// ── RadioConfig roundtrip ───────────────────────────────────────
#[test]
fn radio_config_roundtrip() {
let cfg = make_config();
let mut buf = [0u8; RADIO_CONFIG_SIZE];
let n = cfg.write_to(&mut buf);
assert_eq!(n, RADIO_CONFIG_SIZE);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
#[test]
fn radio_config_roundtrip_all_bandwidths() {
for bw_val in 0u8..=9 {
let bw = Bandwidth::from_u8(bw_val).unwrap();
let cfg = RadioConfig {
freq_hz: 433_000_000,
bw,
sf: 12,
cr: 8,
sync_word: 0x1234,
tx_power_dbm: -9,
preamble_len: 16,
cad: 1,
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
}
#[test]
fn radio_config_roundtrip_negative_power() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
}
#[test]
fn radio_config_from_short_buffer() {
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
assert!(RadioConfig::from_bytes(&buf).is_none());
}
#[test]
fn radio_config_from_empty_buffer() {
assert!(RadioConfig::from_bytes(&[]).is_none());
}
#[test]
fn radio_config_invalid_bandwidth() {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
make_config().write_to(&mut buf);
buf[4] = 255; // invalid bandwidth
assert!(RadioConfig::from_bytes(&buf).is_none());
}
// ── RadioConfig::validate ───────────────────────────────────────
#[test]
fn validate_freq_boundaries() {
let power_range = (-9, 22);
let base = make_config();
let mut cfg = RadioConfig {
freq_hz: 150_000_000,
..base
};
assert!(cfg.validate(power_range).is_ok());
cfg.freq_hz = 960_000_000;
assert!(cfg.validate(power_range).is_ok());
cfg.freq_hz = 149_999_999;
assert!(cfg.validate(power_range).is_err());
cfg.freq_hz = 960_000_001;
assert!(cfg.validate(power_range).is_err());
}
#[test]
fn validate_sf_boundaries() {
let power_range = (-9, 22);
let base = make_config();
for sf in 5..=12 {
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
}
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
assert!(
RadioConfig { sf: 13, ..base }
.validate(power_range)
.is_err()
);
}
#[test]
fn validate_cr_boundaries() {
let power_range = (-9, 22);
let base = make_config();
for cr in 5..=8 {
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
}
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
}
#[test]
fn validate_tx_power_max_sentinel() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
#[test]
fn validate_tx_power_out_of_range() {
let cfg = RadioConfig {
tx_power_dbm: 23,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
let cfg = RadioConfig {
tx_power_dbm: -10,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_err());
}
#[test]
fn validate_preamble_default_sentinel() {
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert!(cfg.validate((-9, 22)).is_ok());
}
#[test]
fn validate_preamble_boundaries() {
let power_range = (-9, 22);
let base = make_config();
assert!(
RadioConfig {
preamble_len: 6,
..base
}
.validate(power_range)
.is_ok()
);
assert!(
RadioConfig {
preamble_len: 5,
..base
}
.validate(power_range)
.is_err()
);
}
// ── RadioConfig::resolve ──────────────────────────────────────
#[test]
fn resolve_power_max_sentinel() {
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
}
#[test]
fn resolve_power_explicit_unchanged() {
let cfg = RadioConfig {
tx_power_dbm: 10,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
}
#[test]
fn resolve_preamble_default() {
let cfg = RadioConfig {
preamble_len: PREAMBLE_DEFAULT,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
}
#[test]
fn resolve_preamble_explicit_unchanged() {
let cfg = RadioConfig {
preamble_len: 32,
..make_config()
};
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
}
// ── Command::from_bytes ─────────────────────────────────────────
#[test]
fn command_ping() {
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
}
#[test]
fn command_get_config() {
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
}
#[test]
fn command_set_config() {
let cfg = make_config();
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
buf[0] = 2;
cfg.write_to(&mut buf[1..]);
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
}
#[test]
fn command_start_stop_rx() {
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
}
#[test]
fn command_transmit_no_config() {
let payload = b"hello";
let mut buf = [0u8; 64];
buf[0] = 5; // tag
buf[1] = 0; // has_config = false
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[4..9].copy_from_slice(payload);
match Command::from_bytes(&buf[..9]).unwrap() {
Command::Transmit { config, payload: p } => {
assert!(config.is_none());
assert_eq!(p.as_slice(), b"hello");
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_with_config() {
let cfg = make_config();
let mut buf = [0u8; 64];
buf[0] = 5; // tag
buf[1] = 1; // has_config = true
cfg.write_to(&mut buf[2..]);
let payload = b"test";
let pos = 2 + RADIO_CONFIG_SIZE;
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[pos + 2..pos + 6].copy_from_slice(payload);
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
Command::Transmit { config, payload: p } => {
assert_eq!(config, Some(cfg));
assert_eq!(p.as_slice(), b"test");
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_empty_payload() {
let mut buf = [0u8; 4];
buf[0] = 5; // tag
buf[1] = 0; // has_config = false
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
match Command::from_bytes(&buf).unwrap() {
Command::Transmit { config, payload } => {
assert!(config.is_none());
assert!(payload.is_empty());
}
_ => panic!("expected Transmit"),
}
}
#[test]
fn command_transmit_truncated() {
// Tag only — missing has_config byte
assert!(Command::from_bytes(&[5]).is_none());
// has_config=1 but no config bytes
assert!(Command::from_bytes(&[5, 1]).is_none());
// has_config=0 but no length bytes
assert!(Command::from_bytes(&[5, 0]).is_none());
// has_config=0, length says 5 but only 2 bytes of payload
let mut buf = [0u8; 6];
buf[0] = 5;
buf[1] = 0;
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
buf[4] = 0xAA;
buf[5] = 0xBB;
assert!(Command::from_bytes(&buf).is_none());
}
#[test]
fn command_display_and_mac() {
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
}
#[test]
fn command_invalid_tag() {
assert!(Command::from_bytes(&[9]).is_none());
assert!(Command::from_bytes(&[255]).is_none());
}
#[test]
fn command_empty_buffer() {
assert!(Command::from_bytes(&[]).is_none());
}
// ── Response::write_to ──────────────────────────────────────────
#[test]
fn response_pong() {
let mut buf = [0u8; 1];
assert_eq!(Response::Pong.write_to(&mut buf), 1);
assert_eq!(buf[0], 0);
}
#[test]
fn response_config() {
let cfg = make_config();
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
let n = Response::Config(cfg).write_to(&mut buf);
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
assert_eq!(buf[0], 1);
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
}
#[test]
fn response_rx_packet() {
let mut payload = Vec::new();
let _ = payload.extend_from_slice(b"data");
let mut buf = [0u8; 64];
let n = Response::RxPacket {
rssi: -80,
snr: 10,
payload,
}
.write_to(&mut buf);
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
assert_eq!(buf[0], 2);
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
assert_eq!(&buf[7..11], b"data");
}
#[test]
fn response_tx_done() {
let mut buf = [0u8; 1];
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
assert_eq!(buf[0], 3);
}
#[test]
fn response_ok() {
let mut buf = [0u8; 1];
assert_eq!(Response::Ok.write_to(&mut buf), 1);
assert_eq!(buf[0], 4);
}
#[test]
fn response_error_codes() {
let mut buf = [0u8; 2];
for (code, val) in [
(ErrorCode::InvalidConfig, 0),
(ErrorCode::RadioBusy, 1),
(ErrorCode::TxTimeout, 2),
(ErrorCode::NotConfigured, 4),
(ErrorCode::NoDisplay, 5),
] {
let n = Response::Error(code).write_to(&mut buf);
assert_eq!(n, 2);
assert_eq!(buf[0], 5);
assert_eq!(buf[1], val);
}
}
#[test]
fn response_mac_address() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
let mut buf = [0u8; 7];
let n = Response::MacAddress(mac).write_to(&mut buf);
assert_eq!(n, 7);
assert_eq!(buf[0], 6);
assert_eq!(&buf[1..7], &mac);
}
/// Errors from parsing a `Command` payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum CommandParseError {
/// `type_id` is not a defined command.
UnknownType,
/// Payload length is wrong for the command or modulation.
WrongLength,
/// An enum field carries a value not in the defined table.
InvalidField,
/// A reserved bit (`TX` flag bits 1–7) was set.
ReservedBitSet,
/// `SET_CONFIG`'s `modulation_id` is not assigned.
UnknownModulation,
}
/// Errors from encoding a device-message payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum DeviceMessageEncodeError {
BufferTooSmall,
/// Payload exceeds an enum's fixed size budget.
PayloadTooLarge,
InvalidField,
SyncWordTooLong,
}
/// Errors from parsing a device-message payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum DeviceMessageParseError {
UnknownType,
/// Payload length wrong for the message shape.
WrongLength,
/// Payload shorter than the fixed prefix required by the message.
TooShort,
/// An enum field carries a value not in the defined table.
InvalidField,
/// Parser needs the originating command's `type_id` for an `OK`
/// frame but none was supplied.
MissingContext,
/// `OK` frame for an unknown originating command type, or unknown
/// `modulation_id` inside a `SET_CONFIG` result.
UnknownContext,
}
/// Errors from modulation param encoding.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum ModulationEncodeError {
BufferTooSmall,
SyncWordTooLong,
}
/// Errors from modulation param parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum ModulationParseError {
/// Declared length did not match the expected total for this
/// modulation shape.
WrongLength { expected: usize, actual: usize },
/// Payload shorter than the modulation's fixed prefix.
TooShort,
/// An enum field or reserved byte carried an invalid value.
InvalidField,
/// `modulation_id` byte does not match any defined modulation.
UnknownModulation,
}
/// Errors from `Info` encode/decode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum InfoParseError {
TooShort,
BufferTooSmall,
InvalidField,
}
+999
View File
@@ -0,0 +1,999 @@
//! Per-modulation parameter structs for `SET_CONFIG` (`PROTOCOL.md §10`).
//!
//! `SET_CONFIG`'s payload is a one-byte modulation identifier followed
//! by a modulation-specific struct. Each struct is fixed-layout
//! little-endian; FSK/GFSK alone carries a variable-length sync word at
//! its tail.
//!
//! The crate models the whole universe of DongLoRa Protocol modulations even on
//! devices that physically can't drive them. Firmware ultimately returns
//! `EMODULATION` for anything the chip doesn't support, but the wire
//! codec stays uniform across all 1.0-compliant implementations.
use crate::{MAX_SYNC_WORD_LEN, ModulationEncodeError, ModulationParseError};
// ── Modulation identifier ───────────────────────────────────────────
/// One-byte modulation selector (`PROTOCOL.md §10`). Prefixes every
/// `SET_CONFIG` payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum ModulationId {
LoRa = 0x01,
FskGfsk = 0x02,
LrFhss = 0x03,
Flrc = 0x04,
}
impl ModulationId {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0x01 => Self::LoRa,
0x02 => Self::FskGfsk,
0x03 => Self::LrFhss,
0x04 => Self::Flrc,
_ => return None,
})
}
}
// ── LoRa enums ──────────────────────────────────────────────────────
/// LoRa signal bandwidth enum (`PROTOCOL.md §10.1`). The small integer
/// is what goes on the wire, NOT the kHz value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LoRaBandwidth {
/// 7.81 kHz — sub-GHz chips only.
Khz7 = 0,
/// 10.42 kHz — sub-GHz chips only.
Khz10 = 1,
/// 15.63 kHz — sub-GHz chips only.
Khz15 = 2,
/// 20.83 kHz — sub-GHz chips only.
Khz20 = 3,
/// 31.25 kHz — sub-GHz chips only.
Khz31 = 4,
/// 41.67 kHz — sub-GHz chips only.
Khz41 = 5,
/// 62.5 kHz — sub-GHz chips only.
Khz62 = 6,
/// 125 kHz — all LoRa chips.
Khz125 = 7,
/// 250 kHz — all LoRa chips.
Khz250 = 8,
/// 500 kHz — all LoRa chips.
Khz500 = 9,
/// 200 kHz — SX128x (2.4 GHz) only.
Khz200 = 10,
/// 400 kHz — SX128x (2.4 GHz) only.
Khz400 = 11,
/// 800 kHz — SX128x (2.4 GHz) only.
Khz800 = 12,
/// 1600 kHz — SX128x (2.4 GHz) only.
Khz1600 = 13,
}
impl LoRaBandwidth {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Khz7,
1 => Self::Khz10,
2 => Self::Khz15,
3 => Self::Khz20,
4 => Self::Khz31,
5 => Self::Khz41,
6 => Self::Khz62,
7 => Self::Khz125,
8 => Self::Khz250,
9 => Self::Khz500,
10 => Self::Khz200,
11 => Self::Khz400,
12 => Self::Khz800,
13 => Self::Khz1600,
_ => return None,
})
}
/// Nominal bandwidth in Hz. Useful for the auto-LDRO rule
/// `(2^SF)/BW_Hz > 16 ms` and for display.
pub const fn as_hz(self) -> u32 {
match self {
Self::Khz7 => 7_810,
Self::Khz10 => 10_420,
Self::Khz15 => 15_630,
Self::Khz20 => 20_830,
Self::Khz31 => 31_250,
Self::Khz41 => 41_670,
Self::Khz62 => 62_500,
Self::Khz125 => 125_000,
Self::Khz250 => 250_000,
Self::Khz500 => 500_000,
Self::Khz200 => 200_000,
Self::Khz400 => 400_000,
Self::Khz800 => 800_000,
Self::Khz1600 => 1_600_000,
}
}
}
/// LoRa coding rate enum (`PROTOCOL.md §10.1`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LoRaCodingRate {
/// 4/5.
Cr4_5 = 0,
/// 4/6.
Cr4_6 = 1,
/// 4/7.
Cr4_7 = 2,
/// 4/8.
Cr4_8 = 3,
}
impl LoRaCodingRate {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Cr4_5,
1 => Self::Cr4_6,
2 => Self::Cr4_7,
3 => Self::Cr4_8,
_ => return None,
})
}
}
/// LoRa header mode (`PROTOCOL.md §10.1`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LoRaHeaderMode {
Explicit = 0,
Implicit = 1,
}
impl LoRaHeaderMode {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Explicit,
1 => Self::Implicit,
_ => return None,
})
}
}
/// LoRa configuration payload (15 bytes after `modulation_id`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct LoRaConfig {
pub freq_hz: u32,
/// Spreading factor 5–12 (chip-dependent: SX127x is 6–12).
pub sf: u8,
pub bw: LoRaBandwidth,
pub cr: LoRaCodingRate,
pub preamble_len: u16,
pub sync_word: u16,
pub tx_power_dbm: i8,
pub header_mode: LoRaHeaderMode,
/// Payload CRC: `false` disabled, `true` enabled.
pub payload_crc: bool,
/// IQ inversion: `false` normal, `true` inverted.
pub iq_invert: bool,
}
impl LoRaConfig {
/// Fixed wire size of the per-modulation params.
pub const WIRE_SIZE: usize = 15;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
if buf.len() < Self::WIRE_SIZE {
return Err(ModulationEncodeError::BufferTooSmall);
}
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.sf;
buf[5] = self.bw.as_u8();
buf[6] = self.cr.as_u8();
buf[7..9].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[9..11].copy_from_slice(&self.sync_word.to_le_bytes());
buf[11] = self.tx_power_dbm as u8;
buf[12] = self.header_mode.as_u8();
buf[13] = u8::from(self.payload_crc);
buf[14] = u8::from(self.iq_invert);
Ok(Self::WIRE_SIZE)
}
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
if buf.len() != Self::WIRE_SIZE {
return Err(ModulationParseError::WrongLength {
expected: Self::WIRE_SIZE,
actual: buf.len(),
});
}
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
let sf = buf[4];
let bw = LoRaBandwidth::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
let cr = LoRaCodingRate::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
let preamble_len = u16::from_le_bytes([buf[7], buf[8]]);
let sync_word = u16::from_le_bytes([buf[9], buf[10]]);
let tx_power_dbm = buf[11] as i8;
let header_mode =
LoRaHeaderMode::from_u8(buf[12]).ok_or(ModulationParseError::InvalidField)?;
let payload_crc = match buf[13] {
0 => false,
1 => true,
_ => return Err(ModulationParseError::InvalidField),
};
let iq_invert = match buf[14] {
0 => false,
1 => true,
_ => return Err(ModulationParseError::InvalidField),
};
Ok(Self {
freq_hz,
sf,
bw,
cr,
preamble_len,
sync_word,
tx_power_dbm,
header_mode,
payload_crc,
iq_invert,
})
}
}
// ── FSK / GFSK ──────────────────────────────────────────────────────
/// FSK / GFSK configuration payload (`PROTOCOL.md §10.2`).
///
/// Wire size is `16 + sync_word_len` bytes. `sync_word_len` is 0–8 and
/// indicates how many leading bytes of the `sync_word` array are
/// transmitted (MSB-first, per FSK convention). Extra trailing bytes
/// are ignored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct FskConfig {
pub freq_hz: u32,
pub bitrate_bps: u32,
pub freq_dev_hz: u32,
/// Chip-specific RX-filter bandwidth index.
pub rx_bw: u8,
pub preamble_len: u16,
pub sync_word_len: u8,
pub sync_word: [u8; MAX_SYNC_WORD_LEN],
}
impl FskConfig {
/// Fixed part of the payload preceding the variable-length sync word.
pub const FIXED_WIRE_SIZE: usize = 16;
/// Total wire size for the given `sync_word_len`. `sync_word_len`
/// larger than 8 is rejected by `encode`.
pub const fn wire_size_for(sync_word_len: u8) -> usize {
Self::FIXED_WIRE_SIZE + sync_word_len as usize
}
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
if self.sync_word_len as usize > MAX_SYNC_WORD_LEN {
return Err(ModulationEncodeError::SyncWordTooLong);
}
let total = Self::wire_size_for(self.sync_word_len);
if buf.len() < total {
return Err(ModulationEncodeError::BufferTooSmall);
}
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4..8].copy_from_slice(&self.bitrate_bps.to_le_bytes());
buf[8..12].copy_from_slice(&self.freq_dev_hz.to_le_bytes());
buf[12] = self.rx_bw;
buf[13..15].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[15] = self.sync_word_len;
let n = self.sync_word_len as usize;
buf[16..16 + n].copy_from_slice(&self.sync_word[..n]);
Ok(total)
}
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
if buf.len() < Self::FIXED_WIRE_SIZE {
return Err(ModulationParseError::TooShort);
}
let sync_word_len = buf[15];
if sync_word_len as usize > MAX_SYNC_WORD_LEN {
return Err(ModulationParseError::InvalidField);
}
let expected = Self::wire_size_for(sync_word_len);
if buf.len() != expected {
return Err(ModulationParseError::WrongLength {
expected,
actual: buf.len(),
});
}
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
let bitrate_bps = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
let freq_dev_hz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rx_bw = buf[12];
let preamble_len = u16::from_le_bytes([buf[13], buf[14]]);
let mut sync_word = [0u8; MAX_SYNC_WORD_LEN];
let n = sync_word_len as usize;
sync_word[..n].copy_from_slice(&buf[16..16 + n]);
Ok(Self {
freq_hz,
bitrate_bps,
freq_dev_hz,
rx_bw,
preamble_len,
sync_word_len,
sync_word,
})
}
}
// ── LR-FHSS ─────────────────────────────────────────────────────────
/// LR-FHSS occupied-bandwidth enum (`PROTOCOL.md §10.3`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LrFhssBandwidth {
/// 39.06 kHz.
Khz39 = 0,
/// 85.94 kHz.
Khz85 = 1,
/// 136.72 kHz.
Khz136 = 2,
/// 183.59 kHz.
Khz183 = 3,
/// 335.94 kHz.
Khz335 = 4,
/// 386.72 kHz.
Khz386 = 5,
/// 722.66 kHz.
Khz722 = 6,
/// 1523.44 kHz.
Khz1523 = 7,
}
impl LrFhssBandwidth {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Khz39,
1 => Self::Khz85,
2 => Self::Khz136,
3 => Self::Khz183,
4 => Self::Khz335,
5 => Self::Khz386,
6 => Self::Khz722,
7 => Self::Khz1523,
_ => return None,
})
}
}
/// LR-FHSS coding rate enum (`PROTOCOL.md §10.3`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LrFhssCodingRate {
/// 5/6.
Cr5_6 = 0,
/// 2/3.
Cr2_3 = 1,
/// 1/2.
Cr1_2 = 2,
/// 1/3.
Cr1_3 = 3,
}
impl LrFhssCodingRate {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Cr5_6,
1 => Self::Cr2_3,
2 => Self::Cr1_2,
3 => Self::Cr1_3,
_ => return None,
})
}
}
/// LR-FHSS grid selection (`PROTOCOL.md §10.3`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum LrFhssGrid {
/// 25.39 kHz grid.
Khz25 = 0,
/// 3.9 kHz grid.
Khz3_9 = 1,
}
impl LrFhssGrid {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Khz25,
1 => Self::Khz3_9,
_ => return None,
})
}
}
/// LR-FHSS configuration payload (10 bytes after `modulation_id`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct LrFhssConfig {
pub freq_hz: u32,
pub bw: LrFhssBandwidth,
pub cr: LrFhssCodingRate,
pub grid: LrFhssGrid,
pub hopping: bool,
pub tx_power_dbm: i8,
}
impl LrFhssConfig {
pub const WIRE_SIZE: usize = 10;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
if buf.len() < Self::WIRE_SIZE {
return Err(ModulationEncodeError::BufferTooSmall);
}
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw.as_u8();
buf[5] = self.cr.as_u8();
buf[6] = self.grid.as_u8();
buf[7] = u8::from(self.hopping);
buf[8] = self.tx_power_dbm as u8;
buf[9] = 0; // reserved
Ok(Self::WIRE_SIZE)
}
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
if buf.len() != Self::WIRE_SIZE {
return Err(ModulationParseError::WrongLength {
expected: Self::WIRE_SIZE,
actual: buf.len(),
});
}
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
let bw = LrFhssBandwidth::from_u8(buf[4]).ok_or(ModulationParseError::InvalidField)?;
let cr = LrFhssCodingRate::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
let grid = LrFhssGrid::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
let hopping = match buf[7] {
0 => false,
1 => true,
_ => return Err(ModulationParseError::InvalidField),
};
let tx_power_dbm = buf[8] as i8;
// Reserved byte MUST be zero per §10.3.
if buf[9] != 0 {
return Err(ModulationParseError::InvalidField);
}
Ok(Self {
freq_hz,
bw,
cr,
grid,
hopping,
tx_power_dbm,
})
}
}
// ── FLRC ────────────────────────────────────────────────────────────
/// FLRC bitrate enum (`PROTOCOL.md §10.4`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum FlrcBitrate {
/// 2600 kbps.
Kbps2600 = 0,
/// 2080 kbps.
Kbps2080 = 1,
/// 1300 kbps.
Kbps1300 = 2,
/// 1040 kbps.
Kbps1040 = 3,
/// 650 kbps.
Kbps650 = 4,
/// 520 kbps.
Kbps520 = 5,
/// 325 kbps.
Kbps325 = 6,
/// 260 kbps.
Kbps260 = 7,
}
impl FlrcBitrate {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Kbps2600,
1 => Self::Kbps2080,
2 => Self::Kbps1300,
3 => Self::Kbps1040,
4 => Self::Kbps650,
5 => Self::Kbps520,
6 => Self::Kbps325,
7 => Self::Kbps260,
_ => return None,
})
}
}
/// FLRC coding rate enum (`PROTOCOL.md §10.4`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum FlrcCodingRate {
/// 1/2.
Cr1_2 = 0,
/// 3/4.
Cr3_4 = 1,
/// 1/1 (no coding).
Cr1_1 = 2,
}
impl FlrcCodingRate {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Cr1_2,
1 => Self::Cr3_4,
2 => Self::Cr1_1,
_ => return None,
})
}
}
/// FLRC Gaussian BT-product enum (`PROTOCOL.md §10.4`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum FlrcBt {
/// Gaussian filtering off.
Off = 0,
/// BT = 0.5.
Bt0_5 = 1,
/// BT = 1.0.
Bt1_0 = 2,
}
impl FlrcBt {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Off,
1 => Self::Bt0_5,
2 => Self::Bt1_0,
_ => return None,
})
}
}
/// FLRC preamble length enum (`PROTOCOL.md §10.4`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum FlrcPreambleLen {
Bits8 = 0,
Bits12 = 1,
Bits16 = 2,
Bits20 = 3,
Bits24 = 4,
Bits28 = 5,
Bits32 = 6,
}
impl FlrcPreambleLen {
pub const fn as_u8(self) -> u8 {
self as u8
}
pub const fn from_u8(v: u8) -> Option<Self> {
Some(match v {
0 => Self::Bits8,
1 => Self::Bits12,
2 => Self::Bits16,
3 => Self::Bits20,
4 => Self::Bits24,
5 => Self::Bits28,
6 => Self::Bits32,
_ => return None,
})
}
}
/// FLRC configuration payload (13 bytes after `modulation_id`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct FlrcConfig {
pub freq_hz: u32,
pub bitrate: FlrcBitrate,
pub cr: FlrcCodingRate,
pub bt: FlrcBt,
pub preamble_len: FlrcPreambleLen,
/// 32-bit sync word, transmitted MSB-first on air.
pub sync_word: u32,
pub tx_power_dbm: i8,
}
impl FlrcConfig {
pub const WIRE_SIZE: usize = 13;
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
if buf.len() < Self::WIRE_SIZE {
return Err(ModulationEncodeError::BufferTooSmall);
}
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bitrate.as_u8();
buf[5] = self.cr.as_u8();
buf[6] = self.bt.as_u8();
buf[7] = self.preamble_len.as_u8();
buf[8..12].copy_from_slice(&self.sync_word.to_le_bytes());
buf[12] = self.tx_power_dbm as u8;
Ok(Self::WIRE_SIZE)
}
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
if buf.len() != Self::WIRE_SIZE {
return Err(ModulationParseError::WrongLength {
expected: Self::WIRE_SIZE,
actual: buf.len(),
});
}
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
let bitrate = FlrcBitrate::from_u8(buf[4]).ok_or(ModulationParseError::InvalidField)?;
let cr = FlrcCodingRate::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
let bt = FlrcBt::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
let preamble_len =
FlrcPreambleLen::from_u8(buf[7]).ok_or(ModulationParseError::InvalidField)?;
let sync_word = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let tx_power_dbm = buf[12] as i8;
Ok(Self {
freq_hz,
bitrate,
cr,
bt,
preamble_len,
sync_word,
tx_power_dbm,
})
}
}
// ── Modulation sum type ─────────────────────────────────────────────
/// Any of the four modulation configs, tagged by `ModulationId`.
///
/// This is the type carried inside `Command::SetConfig`. Encoding writes
/// the `modulation_id` byte first, then delegates to the per-modulation
/// `encode`. Decoding dispatches on the leading byte and calls the
/// matching `decode`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum Modulation {
LoRa(LoRaConfig),
FskGfsk(FskConfig),
LrFhss(LrFhssConfig),
Flrc(FlrcConfig),
}
impl Modulation {
pub const fn id(&self) -> ModulationId {
match self {
Self::LoRa(_) => ModulationId::LoRa,
Self::FskGfsk(_) => ModulationId::FskGfsk,
Self::LrFhss(_) => ModulationId::LrFhss,
Self::Flrc(_) => ModulationId::Flrc,
}
}
/// Encode as `[modulation_id][params]` into `buf`. Returns the total
/// number of bytes written.
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
if buf.is_empty() {
return Err(ModulationEncodeError::BufferTooSmall);
}
buf[0] = self.id().as_u8();
let n = match self {
Self::LoRa(c) => c.encode(&mut buf[1..])?,
Self::FskGfsk(c) => c.encode(&mut buf[1..])?,
Self::LrFhss(c) => c.encode(&mut buf[1..])?,
Self::Flrc(c) => c.encode(&mut buf[1..])?,
};
Ok(1 + n)
}
/// Decode from `[modulation_id][params]`. The full slice is the
/// `SET_CONFIG` payload; length errors are propagated as
/// `ModulationParseError::WrongLength`.
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
if buf.is_empty() {
return Err(ModulationParseError::TooShort);
}
let id = ModulationId::from_u8(buf[0]).ok_or(ModulationParseError::UnknownModulation)?;
let params = &buf[1..];
Ok(match id {
ModulationId::LoRa => Self::LoRa(LoRaConfig::decode(params)?),
ModulationId::FskGfsk => Self::FskGfsk(FskConfig::decode(params)?),
ModulationId::LrFhss => Self::LrFhss(LrFhssConfig::decode(params)?),
ModulationId::Flrc => Self::Flrc(FlrcConfig::decode(params)?),
})
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_lora() -> LoRaConfig {
LoRaConfig {
freq_hz: 868_100_000,
sf: 7,
bw: LoRaBandwidth::Khz125,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 8,
sync_word: 0x1424,
tx_power_dbm: 14,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
}
}
#[test]
fn lora_wire_size() {
assert_eq!(LoRaConfig::WIRE_SIZE, 15);
}
#[test]
fn lora_roundtrip() {
let cfg = sample_lora();
let mut buf = [0u8; 32];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 15);
let decoded = LoRaConfig::decode(&buf[..n]).unwrap();
assert_eq!(decoded, cfg);
}
#[test]
fn lora_appendix_c23_bytes() {
// PROTOCOL.md §C.2.3 — EU868 SF7 BW125 CR4/5, preamble=8, sync=0x1424,
// power=14 dBm, explicit, crc on, iq normal.
let cfg = sample_lora();
let mut buf = [0u8; 15];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 15);
let expected: [u8; 15] = [
0xA0, 0x27, 0xBE, 0x33, // freq_hz = 868_100_000 LE
0x07, // sf
0x07, // bw = Khz125
0x00, // cr = 4/5
0x08, 0x00, // preamble_len = 8
0x24, 0x14, // sync_word = 0x1424
0x0E, // tx_power_dbm = 14
0x00, // header explicit
0x01, // payload_crc on
0x00, // iq normal
];
assert_eq!(buf, expected);
}
#[test]
fn lora_rejects_wrong_length() {
assert!(matches!(
LoRaConfig::decode(&[0u8; 14]),
Err(ModulationParseError::WrongLength { .. })
));
assert!(matches!(
LoRaConfig::decode(&[0u8; 16]),
Err(ModulationParseError::WrongLength { .. })
));
}
#[test]
fn lora_rejects_bad_enum_values() {
let mut buf = [0u8; 15];
sample_lora().encode(&mut buf).unwrap();
let mut bad = buf;
bad[5] = 14; // BW 14 is undefined
assert!(LoRaConfig::decode(&bad).is_err());
let mut bad = buf;
bad[6] = 4; // CR 4 is undefined
assert!(LoRaConfig::decode(&bad).is_err());
let mut bad = buf;
bad[12] = 2; // header_mode 2 undefined
assert!(LoRaConfig::decode(&bad).is_err());
let mut bad = buf;
bad[13] = 2; // payload_crc must be 0 or 1
assert!(LoRaConfig::decode(&bad).is_err());
let mut bad = buf;
bad[14] = 2; // iq_invert must be 0 or 1
assert!(LoRaConfig::decode(&bad).is_err());
}
#[test]
fn fsk_roundtrip_empty_sync() {
let cfg = FskConfig {
freq_hz: 868_000_000,
bitrate_bps: 9_600,
freq_dev_hz: 5_000,
rx_bw: 0x0B,
preamble_len: 16,
sync_word_len: 0,
sync_word: [0u8; MAX_SYNC_WORD_LEN],
};
let mut buf = [0u8; 32];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 16);
let decoded = FskConfig::decode(&buf[..n]).unwrap();
assert_eq!(decoded, cfg);
}
#[test]
fn fsk_roundtrip_with_sync() {
let mut sync = [0u8; MAX_SYNC_WORD_LEN];
sync[..4].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
let cfg = FskConfig {
freq_hz: 868_000_000,
bitrate_bps: 50_000,
freq_dev_hz: 25_000,
rx_bw: 0x1A,
preamble_len: 32,
sync_word_len: 4,
sync_word: sync,
};
let mut buf = [0u8; 32];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 20);
let decoded = FskConfig::decode(&buf[..n]).unwrap();
assert_eq!(decoded, cfg);
}
#[test]
fn fsk_rejects_oversized_sync() {
let mut cfg = FskConfig {
freq_hz: 0,
bitrate_bps: 0,
freq_dev_hz: 0,
rx_bw: 0,
preamble_len: 0,
sync_word_len: 9,
sync_word: [0u8; MAX_SYNC_WORD_LEN],
};
let mut buf = [0u8; 32];
assert!(cfg.encode(&mut buf).is_err());
// Decoder rejects the same: sync_word_len > 8.
cfg.sync_word_len = 0;
cfg.encode(&mut buf).unwrap();
let mut bad = [0u8; 16];
bad.copy_from_slice(&buf[..16]);
bad[15] = 9;
assert!(FskConfig::decode(&bad).is_err());
}
#[test]
fn lr_fhss_roundtrip() {
let cfg = LrFhssConfig {
freq_hz: 915_000_000,
bw: LrFhssBandwidth::Khz136,
cr: LrFhssCodingRate::Cr2_3,
grid: LrFhssGrid::Khz25,
hopping: true,
tx_power_dbm: 14,
};
let mut buf = [0u8; 10];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 10);
let decoded = LrFhssConfig::decode(&buf[..n]).unwrap();
assert_eq!(decoded, cfg);
assert_eq!(buf[9], 0, "reserved byte must serialize as 0");
}
#[test]
fn lr_fhss_rejects_nonzero_reserved() {
let cfg = LrFhssConfig {
freq_hz: 0,
bw: LrFhssBandwidth::Khz39,
cr: LrFhssCodingRate::Cr1_3,
grid: LrFhssGrid::Khz25,
hopping: false,
tx_power_dbm: 0,
};
let mut buf = [0u8; 10];
cfg.encode(&mut buf).unwrap();
buf[9] = 1;
assert!(LrFhssConfig::decode(&buf).is_err());
}
#[test]
fn flrc_roundtrip() {
let cfg = FlrcConfig {
freq_hz: 2_400_000_000,
bitrate: FlrcBitrate::Kbps1300,
cr: FlrcCodingRate::Cr3_4,
bt: FlrcBt::Bt0_5,
preamble_len: FlrcPreambleLen::Bits24,
sync_word: 0x1234_5678,
tx_power_dbm: 10,
};
let mut buf = [0u8; 13];
let n = cfg.encode(&mut buf).unwrap();
assert_eq!(n, 13);
let decoded = FlrcConfig::decode(&buf[..n]).unwrap();
assert_eq!(decoded, cfg);
}
#[test]
fn modulation_sum_roundtrip() {
let m = Modulation::LoRa(sample_lora());
let mut buf = [0u8; 64];
let n = m.encode(&mut buf).unwrap();
// id byte + LoRa wire size
assert_eq!(n, 1 + LoRaConfig::WIRE_SIZE);
assert_eq!(buf[0], ModulationId::LoRa.as_u8());
let decoded = Modulation::decode(&buf[..n]).unwrap();
assert_eq!(decoded, m);
}
#[test]
fn modulation_id_rejects_unknown() {
assert!(matches!(
Modulation::decode(&[0x05, 0, 0, 0]),
Err(ModulationParseError::UnknownModulation)
));
assert!(matches!(
Modulation::decode(&[]),
Err(ModulationParseError::TooShort)
));
}
#[test]
fn lora_bandwidth_hz_table() {
// Cross-check §10.1 table values.
assert_eq!(LoRaBandwidth::Khz125.as_hz(), 125_000);
assert_eq!(LoRaBandwidth::Khz500.as_hz(), 500_000);
assert_eq!(LoRaBandwidth::Khz7.as_hz(), 7_810);
assert_eq!(LoRaBandwidth::Khz1600.as_hz(), 1_600_000);
}
}
+967 -141
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 64af53fe0215619bcede1dcaa56542db91b76a0e54503b6972ff4e3c6cd08e2f # shrinks to cfg = FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] }
cc a06f54304af03f33b704275b09b6ff07210078a04104dc2eac284b8d7cecbab9 # shrinks to m = FskGfsk(FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] }), tag = 1
cc bbb3809fa365465d27529942bd6d43b83af837bae0aa93b568818a65a3382bf1 # shrinks to cmd = SetConfig(FskGfsk(FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] })), tag = 1
+362 -639
View File
File diff suppressed because it is too large. Load diff
+428 -252
View File
@@ -1,275 +1,451 @@
//! Canonical wire-byte vectors anchored to `PROTOCOL.md`.
//! Appendix C wire vectors from `PROTOCOL.md`.
//!
//! These are hand-encoded byte sequences taken directly from the spec.
//! They lock the firmware and host implementations to the *written*
//! protocol, not just to each other — so a refactor that accidentally
//! redefines a field layout fails here before it ships.
//!
//! Every vector is expressed twice: as a hex `&[u8]` decoded into a
//! typed value, and as a typed value encoded back into bytes. Byte-for-
//! byte equality in both directions is the invariant.
//! Each test pins one or more complete wire-framed byte sequences from
//! the spec. If a future refactor accidentally redefines a field layout
//! or a CRC variant, the test fails with a concrete hex diff against
//! the normative document — not just against the crate's own round-trip.
#![allow(clippy::unwrap_used, clippy::panic)]
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use donglora_protocol::{
Bandwidth, Command, ErrorCode, RADIO_CONFIG_SIZE, RadioConfig, Response, TX_POWER_MAX,
Command, DeviceMessage, ErrorCode, FrameDecoder, FrameResult, Info, LoRaBandwidth,
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_WIRE_FRAME, Modulation, OkPayload, Owner,
RadioChipId, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags,
TxResult, cap, commands, encode_frame,
};
use heapless::Vec as HVec;
// ── A well-known RadioConfig with hex-clean field values ──────────────
//
// Fields are picked so each multi-byte field has a visually obvious
// little-endian byte pattern, and the config as a whole still validates
// (freq in 150–960 MHz range, sf in 5–12, cr in 5–8).
// ── Helpers ─────────────────────────────────────────────────────────
const KNOWN_FREQ_HZ: u32 = 0x1234_5678; // 305_419_896 Hz ≈ 305 MHz
const KNOWN_SYNC: u16 = 0xABCD;
const KNOWN_PREAMBLE: u16 = 0x00AB;
/// Matches the layout documented in `PROTOCOL.md §RadioConfig (13 bytes)`.
const CONFIG_BYTES: [u8; RADIO_CONFIG_SIZE] = [
0x78, 0x56, 0x34, 0x12, // freq_hz = 0x12345678 LE
0x07, // bw = Khz125
0x07, // sf = 7
0x05, // cr = 5 (4/5)
0xCD, 0xAB, // sync_word = 0xABCD LE
0x16, // tx_power_dbm = 22
0xAB, 0x00, // preamble_len = 0x00AB LE
0x01, // cad = enabled
];
fn known_config() -> RadioConfig {
RadioConfig {
freq_hz: KNOWN_FREQ_HZ,
bw: Bandwidth::Khz125,
sf: 7,
cr: 5,
sync_word: KNOWN_SYNC,
tx_power_dbm: 22,
preamble_len: KNOWN_PREAMBLE,
cad: 1,
}
/// Encode a command as a complete wire-ready frame, tag-correlated.
fn encode_cmd_frame(cmd: &Command, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
let mut payload_buf = [0u8; 320];
let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(cmd.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
let mut out = HVec::new();
out.extend_from_slice(&wire[..n]).unwrap();
out
}
// ── RadioConfig ─────────────────────────────────────────────────────
#[test]
fn radio_config_canonical_bytes_decode() {
assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config()));
/// Encode a device message as a complete wire-ready frame, tag-correlated.
fn encode_dev_frame(msg: &DeviceMessage, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
let mut payload_buf = [0u8; 320];
let payload_len = msg.encode_payload(&mut payload_buf).unwrap();
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(msg.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
let mut out = HVec::new();
out.extend_from_slice(&wire[..n]).unwrap();
out
}
#[test]
fn radio_config_canonical_bytes_encode() {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
let n = known_config().write_to(&mut buf);
assert_eq!(n, RADIO_CONFIG_SIZE);
assert_eq!(buf, CONFIG_BYTES);
}
#[test]
fn radio_config_tx_power_max_sentinel_is_minus_128() {
// PROTOCOL.md: "Set tx_power_dbm to -128 (0x80) to use the board's maximum."
assert_eq!(TX_POWER_MAX, -128);
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX,
..known_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(buf[9], 0x80, "TX_POWER_MAX must serialize as 0x80");
}
#[test]
fn radio_config_preamble_default_sentinel_is_zero_le() {
// PROTOCOL.md: "Set preamble_len to 0 to use the firmware default."
let cfg = RadioConfig {
preamble_len: 0,
..known_config()
};
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf);
assert_eq!(&buf[10..12], &[0x00, 0x00]);
}
#[test]
fn bandwidth_table_matches_protocol_md() {
// PROTOCOL.md: 0..=9 enumerated in order 7.8…500 kHz.
let expected = [
(0u8, Bandwidth::Khz7),
(1, Bandwidth::Khz10),
(2, Bandwidth::Khz15),
(3, Bandwidth::Khz20),
(4, Bandwidth::Khz31),
(5, Bandwidth::Khz41),
(6, Bandwidth::Khz62),
(7, Bandwidth::Khz125),
(8, Bandwidth::Khz250),
(9, Bandwidth::Khz500),
];
let mut buf = [0u8; RADIO_CONFIG_SIZE];
for (wire, bw) in expected {
RadioConfig {
bw,
..known_config()
/// Decode a single frame out of `wire` and assert everything about it.
fn decode_single_frame(wire: &[u8]) -> (u8, u16, HVec<u8, 320>) {
let mut decoder = FrameDecoder::new();
let mut out: Option<(u8, u16, HVec<u8, 320>)> = None;
decoder.feed(wire, |res| match res {
FrameResult::Ok {
type_id,
tag,
payload,
} => {
let mut p = HVec::new();
p.extend_from_slice(payload).unwrap();
out = Some((type_id, tag, p));
}
.write_to(&mut buf);
assert_eq!(buf[4], wire, "bandwidth {:?} must wire as {}", bw, wire);
let decoded = RadioConfig::from_bytes(&buf).unwrap();
assert_eq!(decoded.bw, bw);
}
}
#[test]
fn bandwidth_invalid_values_reject() {
// PROTOCOL.md restricts bw to 0..=9.
let mut buf = CONFIG_BYTES;
for bad in [10u8, 11, 100, 255] {
buf[4] = bad;
assert!(
RadioConfig::from_bytes(&buf).is_none(),
"bandwidth {bad} must not decode",
);
}
}
// ── Commands ────────────────────────────────────────────────────────
#[test]
fn command_tags_match_protocol_md() {
// Tag table from PROTOCOL.md §Commands. Unit-variant parse-only.
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
}
#[test]
fn command_set_config_canonical_bytes() {
// tag(2) + CONFIG_BYTES
let mut bytes = [0u8; 1 + RADIO_CONFIG_SIZE];
bytes[0] = 2;
bytes[1..].copy_from_slice(&CONFIG_BYTES);
assert_eq!(
Command::from_bytes(&bytes),
Some(Command::SetConfig(known_config()))
);
}
#[test]
fn command_transmit_no_config_canonical_bytes() {
// PROTOCOL.md §Commands tag 5: has_config(1) + [RadioConfig if 1] + len(u16 LE) + payload
let bytes = [
0x05, // Transmit
0x00, // has_config = false
0x05, 0x00, // len = 5 LE
b'h', b'e', b'l', b'l', b'o',
];
match Command::from_bytes(&bytes).unwrap() {
Command::Transmit { config, payload } => {
assert!(config.is_none());
assert_eq!(payload.as_slice(), b"hello");
}
other => panic!("expected Transmit, got {:?}", other),
}
}
#[test]
fn command_transmit_with_config_canonical_bytes() {
let mut bytes: HVec<u8, 64> = HVec::new();
bytes.push(0x05).unwrap(); // Transmit
bytes.push(0x01).unwrap(); // has_config = true
bytes.extend_from_slice(&CONFIG_BYTES).unwrap();
bytes.extend_from_slice(&[0x02, 0x00]).unwrap(); // len = 2
bytes.extend_from_slice(b"OK").unwrap();
match Command::from_bytes(&bytes).unwrap() {
Command::Transmit { config, payload } => {
assert_eq!(config, Some(known_config()));
assert_eq!(payload.as_slice(), b"OK");
}
other => panic!("expected Transmit, got {:?}", other),
}
}
#[test]
fn command_unknown_tags_reject() {
// Defined tags: 0..=8. Everything else must reject.
for bad in [9u8, 10, 100, 255] {
assert!(Command::from_bytes(&[bad]).is_none());
}
}
// ── Responses ───────────────────────────────────────────────────────
fn encode_response_bytes(r: Response) -> HVec<u8, 128> {
let mut buf = [0u8; 128];
let n = r.write_to(&mut buf);
HVec::from_slice(&buf[..n]).unwrap()
}
#[test]
fn response_canonical_unit_tags() {
assert_eq!(encode_response_bytes(Response::Pong).as_slice(), &[0x00]);
assert_eq!(encode_response_bytes(Response::TxDone).as_slice(), &[0x03]);
assert_eq!(encode_response_bytes(Response::Ok).as_slice(), &[0x04]);
}
#[test]
fn response_config_canonical_bytes() {
let mut expected = [0u8; 1 + RADIO_CONFIG_SIZE];
expected[0] = 0x01; // Config tag
expected[1..].copy_from_slice(&CONFIG_BYTES);
assert_eq!(
encode_response_bytes(Response::Config(known_config())).as_slice(),
&expected
);
}
#[test]
fn response_rx_packet_canonical_bytes() {
let mut payload: HVec<u8, 256> = HVec::new();
payload.extend_from_slice(b"data").unwrap();
let bytes = encode_response_bytes(Response::RxPacket {
rssi: -80,
snr: 10,
payload,
FrameResult::Err(e) => panic!("decode error: {:?}", e),
});
out.expect("expected one frame")
}
// tag(2) + rssi(-80 = 0xFFB0 LE) + snr(10 LE) + len(4 LE) + "data"
let expected: &[u8] = &[
0x02, 0xB0, 0xFF, 0x0A, 0x00, 0x04, 0x00, b'd', b'a', b't', b'a',
];
assert_eq!(bytes.as_slice(), expected);
// ── §C.2.1 PING / OK ────────────────────────────────────────────────
#[test]
fn c21_ping_encodes_to_spec_bytes() {
// H→D PING tag=0x0001 → wire: 03 01 01 03 9D C8 00
let wire = encode_cmd_frame(&Command::Ping, 0x0001);
assert_eq!(wire.as_slice(), &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
}
#[test]
fn response_error_canonical_bytes() {
// PROTOCOL.md §Error codes: InvalidConfig=0, RadioBusy=1, TxTimeout=2,
// NotConfigured=4, NoDisplay=5. Tag 3 is reserved (was CrcError).
let pairs = [
(ErrorCode::InvalidConfig, 0u8),
(ErrorCode::RadioBusy, 1),
(ErrorCode::TxTimeout, 2),
(ErrorCode::NotConfigured, 4),
(ErrorCode::NoDisplay, 5),
];
for (code, byte) in pairs {
assert_eq!(
encode_response_bytes(Response::Error(code)).as_slice(),
&[0x05, byte],
);
fn c21_ok_encodes_to_spec_bytes() {
// D→H OK tag=0x0001 → wire: 03 80 01 03 F7 C4 00
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0001);
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
}
#[test]
fn c21_decode_roundtrip() {
let (type_id, tag, payload) = decode_single_frame(&[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
assert_eq!(type_id, commands::TYPE_PING);
assert_eq!(tag, 0x0001);
assert_eq!(payload.as_slice(), &[]);
assert_eq!(Command::parse(type_id, &payload).unwrap(), Command::Ping);
}
// ── §C.2.3 SET_CONFIG LoRa EU868 ────────────────────────────────────
fn c23_lora() -> LoRaConfig {
LoRaConfig {
freq_hz: 868_100_000,
sf: 7,
bw: LoRaBandwidth::Khz125,
cr: LoRaCodingRate::Cr4_5,
preamble_len: 8,
sync_word: 0x1424,
tx_power_dbm: 14,
header_mode: LoRaHeaderMode::Explicit,
payload_crc: true,
iq_invert: false,
}
}
#[test]
fn response_mac_address_canonical_bytes() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
assert_eq!(
encode_response_bytes(Response::MacAddress(mac)).as_slice(),
&[0x06, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF],
);
fn c23_set_config_encodes_to_spec_bytes() {
// H→D SET_CONFIG tag=0x0003 → wire:
// 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
let wire = encode_cmd_frame(&cmd, 0x0003);
let expected: &[u8] = &[
0x03, 0x03, 0x03, 0x08, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08, 0x04, 0x24,
0x14, 0x0E, 0x02, 0x01, 0x03, 0xD9, 0x1F, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
#[test]
fn c23_set_config_ok_response() {
// D→H OK tag=0x0003 result=APPLIED owner=MINE current=[same LoRa]
let result = SetConfigResult {
result: SetConfigResultCode::Applied,
owner: Owner::Mine,
current: Modulation::LoRa(c23_lora()),
};
let msg = DeviceMessage::Ok(OkPayload::SetConfig(result));
let wire = encode_dev_frame(&msg, 0x0003);
let expected: &[u8] = &[
0x03, 0x80, 0x03, 0x01, 0x09, 0x01, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08,
0x04, 0x24, 0x14, 0x0E, 0x02, 0x01, 0x03, 0xC8, 0x91, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.2.4 TX with default CAD, packet "Hello" ──────────────────────
#[test]
fn c24_tx_with_cad() {
// H→D TX tag=0x0004 flags=0x00 data="Hello"
// wire: 03 04 04 01 08 48 65 6C 6C 6F 26 40 00
let mut data = HVec::new();
data.extend_from_slice(b"Hello").unwrap();
let cmd = Command::Tx {
flags: TxFlags::default(),
data,
};
let wire = encode_cmd_frame(&cmd, 0x0004);
let expected: &[u8] = &[
0x03, 0x04, 0x04, 0x01, 0x08, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x26, 0x40, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
#[test]
fn c24_tx_ok() {
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0004);
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x04, 0x03, 0x02, 0x3B, 0x00]);
}
#[test]
fn c24_tx_done() {
let td = TxDonePayload {
result: TxResult::Transmitted,
airtime_us: 30_976,
};
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0004);
let expected: &[u8] = &[
0x03, 0xC1, 0x04, 0x01, 0x01, 0x02, 0x79, 0x01, 0x03, 0xE3, 0xFA, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.2.5 TX with skip_cad ─────────────────────────────────────────
#[test]
fn c25_tx_skip_cad() {
// H→D TX tag=0x0005 flags=0x01 data="URGENT"
// wire: 03 04 05 0A 01 55 52 47 45 4E 54 DB 1C 00
let mut data = HVec::new();
data.extend_from_slice(b"URGENT").unwrap();
let cmd = Command::Tx {
flags: TxFlags { skip_cad: true },
data,
};
let wire = encode_cmd_frame(&cmd, 0x0005);
let expected: &[u8] = &[
0x03, 0x04, 0x05, 0x0A, 0x01, 0x55, 0x52, 0x47, 0x45, 0x4E, 0x54, 0xDB, 0x1C, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
#[test]
fn c25_tx_done() {
let td = TxDonePayload {
result: TxResult::Transmitted,
airtime_us: 33_792,
};
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0005);
let expected: &[u8] = &[
0x03, 0xC1, 0x05, 0x01, 0x01, 0x02, 0x84, 0x01, 0x03, 0x81, 0xE3, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.2.6 RX event ─────────────────────────────────────────────────
#[test]
fn c26_rx_start() {
let wire = encode_cmd_frame(&Command::RxStart, 0x0006);
assert_eq!(wire.as_slice(), &[0x03, 0x05, 0x06, 0x03, 0xCA, 0x8D, 0x00]);
}
#[test]
fn c26_rx_event() {
let mut data = HVec::new();
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -735,
snr_tenths_db: 95,
freq_err_hz: -125,
timestamp_us: 42_000_000,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
};
let wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
let expected: &[u8] = &[
0x02, 0xC0, 0x01, 0x04, 0x21, 0xFD, 0x5F, 0x09, 0x83, 0xFF, 0xFF, 0xFF, 0x80, 0xDE, 0x80,
0x02, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x07, 0x01, 0x02, 0x03, 0x04, 0xB9, 0x8E,
0x00,
];
assert_eq!(wire.as_slice(), expected);
}
#[test]
fn c26_rx_stop() {
let wire = encode_cmd_frame(&Command::RxStop, 0x0008);
assert_eq!(wire.as_slice(), &[0x03, 0x06, 0x08, 0x03, 0x95, 0xF7, 0x00]);
}
// ── §C.5.1 ERR(ENOTCONFIGURED) ──────────────────────────────────────
#[test]
fn c51_err_notconfigured() {
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ENotConfigured), 0x0028);
let expected: &[u8] = &[0x03, 0x81, 0x28, 0x02, 0x03, 0x03, 0x53, 0x7E, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.5.3 ERR(EPARAM) ──────────────────────────────────────────────
#[test]
fn c53_err_eparam() {
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EParam), 0x002A);
let expected: &[u8] = &[0x03, 0x81, 0x2A, 0x02, 0x01, 0x03, 0x59, 0xF5, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.5.4 ERR(EBUSY) ───────────────────────────────────────────────
#[test]
fn c54_err_ebusy() {
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EBusy), 0x002B);
let expected: &[u8] = &[0x03, 0x81, 0x2B, 0x02, 0x06, 0x03, 0x7A, 0x1A, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.5.6 Unknown command type byte ────────────────────────────────
#[test]
fn c56_unknown_command_type_encodes_to_spec() {
// The host sends type=0x10 with payload DE AD. We can't use
// Command::encode_payload (no such variant), but the frame encoder
// is type-agnostic so we can directly produce the wire bytes.
let mut wire = [0u8; MAX_WIRE_FRAME];
let n = encode_frame(0x10, 0x003C, &[0xDE, 0xAD], &mut wire).unwrap();
let expected: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
assert_eq!(&wire[..n], expected);
}
#[test]
fn c56_unknown_command_decoder_still_parses_frame() {
// The decoder is payload-agnostic: it yields (type_id=0x10, tag=0x003C,
// payload=DE AD). The command parser then rejects it cleanly with
// UnknownType so the caller can respond with ERR(EUNKNOWN_CMD).
let wire: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
let (type_id, tag, payload) = decode_single_frame(wire);
assert_eq!(type_id, 0x10);
assert_eq!(tag, 0x003C);
assert_eq!(payload.as_slice(), &[0xDE, 0xAD]);
assert!(matches!(
Command::parse(type_id, &payload),
Err(donglora_protocol::CommandParseError::UnknownType)
));
}
#[test]
fn c56_err_eunknown_cmd_reply() {
// D→H ERR tag=0x003C code=0x0005
// on wire: 03 81 3C 02 05 03 A3 05 00
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EUnknownCmd), 0x003C);
let expected: &[u8] = &[0x03, 0x81, 0x3C, 0x02, 0x05, 0x03, 0xA3, 0x05, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.6.2 RX with crc_valid = 0 ────────────────────────────────────
#[test]
fn c62_rx_bad_crc_delivered_anyway() {
let mut data = HVec::new();
data.extend_from_slice(&[0x41, 0x42, 0xFF, 0x00, 0xCC])
.unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -1050,
snr_tenths_db: -80,
freq_err_hz: 2200,
timestamp_us: 120_000_000,
crc_valid: false,
packets_dropped: 0,
origin: RxOrigin::Ota,
data,
};
let wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
let expected: &[u8] = &[
0x02, 0xC0, 0x01, 0x07, 0xE6, 0xFB, 0xB0, 0xFF, 0x98, 0x08, 0x01, 0x01, 0x04, 0x0E, 0x27,
0x07, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0x41, 0x42, 0xFF, 0x04, 0xCC, 0x04,
0x3C, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.6.4 Async ERR(EFRAME) ────────────────────────────────────────
#[test]
fn c64_async_err_eframe() {
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EFrame), 0x0000);
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x02, 0x01, 0xCE, 0xEF, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.6.5 Async ERR(ERADIO) ────────────────────────────────────────
#[test]
fn c65_async_err_eradio() {
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ERadio), 0x0000);
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x01, 0x01, 0x9D, 0xBA, 0x00];
assert_eq!(wire.as_slice(), expected);
}
// ── §C.8.1 COBS edge: no zeros in body ──────────────────────────────
#[test]
fn c81_ping_no_zeros_in_body() {
// H→D PING tag=0x0101 → no zero bytes in tag bytes 01 01.
// wire: 06 01 01 01 BC D8 00
let wire = encode_cmd_frame(&Command::Ping, 0x0101);
assert_eq!(wire.as_slice(), &[0x06, 0x01, 0x01, 0x01, 0xBC, 0xD8, 0x00]);
}
// ── §C.2.2 GET_INFO ─────────────────────────────────────────────────
#[test]
fn c22_get_info_request() {
let wire = encode_cmd_frame(&Command::GetInfo, 0x0002);
assert_eq!(wire.as_slice(), &[0x03, 0x02, 0x02, 0x03, 0x9E, 0xC4, 0x00]);
}
#[test]
fn c22_get_info_response() {
// Full SX1262 GET_INFO response encoded as a complete wire frame.
let mut mcu = [0u8; donglora_protocol::MAX_MCU_UID_LEN];
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
let info = Info {
proto_major: 1,
proto_minor: 0,
fw_major: 0,
fw_minor: 1,
fw_patch: 0,
radio_chip_id: RadioChipId::Sx1262.as_u16(),
capability_bitmap: cap::LORA | cap::FSK | cap::CAD_BEFORE_TX,
supported_sf_bitmap: 0x1FE0,
supported_bw_bitmap: 0x03FF,
max_payload_bytes: 255,
rx_queue_capacity: 64,
tx_queue_capacity: 16,
freq_min_hz: 150_000_000,
freq_max_hz: 960_000_000,
tx_power_min_dbm: -9,
tx_power_max_dbm: 22,
mcu_uid_len: 8,
mcu_uid: mcu,
radio_uid_len: 0,
radio_uid: [0u8; donglora_protocol::MAX_RADIO_UID_LEN],
};
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Info(info)), 0x0002);
let expected: &[u8] = &[
0x03, 0x80, 0x02, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, 0x02, 0x01, 0x01,
0x01, 0x01, 0x01, 0x06, 0xE0, 0x1F, 0xFF, 0x03, 0xFF, 0x02, 0x40, 0x02, 0x10, 0x05, 0x80,
0xD1, 0xF0, 0x08, 0x0F, 0x70, 0x38, 0x39, 0xF7, 0x16, 0x08, 0xDE, 0xAD, 0xBE, 0xEF, 0x01,
0x23, 0x45, 0x67, 0x03, 0xFA, 0xA4, 0x00,
];
assert_eq!(wire.as_slice(), expected);
}
// ── Decode-side roundtrip for the key command shapes ───────────────
#[test]
fn decode_tx_roundtrip() {
let mut data = HVec::new();
data.extend_from_slice(b"Hello").unwrap();
let cmd = Command::Tx {
flags: TxFlags::default(),
data,
};
let wire = encode_cmd_frame(&cmd, 0x0004);
let (type_id, tag, payload) = decode_single_frame(&wire);
assert_eq!(type_id, commands::TYPE_TX);
assert_eq!(tag, 0x0004);
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
}
#[test]
fn decode_set_config_roundtrip() {
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
let wire = encode_cmd_frame(&cmd, 0x0003);
let (type_id, tag, payload) = decode_single_frame(&wire);
assert_eq!(type_id, commands::TYPE_SET_CONFIG);
assert_eq!(tag, 0x0003);
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
}
#[test]
fn decode_rx_roundtrip() {
let mut data = HVec::new();
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
let rx = RxPayload {
rssi_tenths_dbm: -735,
snr_tenths_db: 95,
freq_err_hz: -125,
timestamp_us: 42_000_000,
crc_valid: true,
packets_dropped: 0,
origin: RxOrigin::Ota,
data: data.clone(),
};
let wire = encode_dev_frame(&DeviceMessage::Rx(rx.clone()), 0x0000);
let (type_id, tag, payload) = decode_single_frame(&wire);
assert_eq!(type_id, 0xC0);
assert_eq!(tag, 0x0000);
let decoded = DeviceMessage::parse(type_id, &payload, None).unwrap();
assert_eq!(decoded, DeviceMessage::Rx(rx));
}