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.
This commit is contained in:
swaits committed 2026-04-22 09:21:11 -06:00
1 parent 35866edac4
commit 4cccb65124
29 files changed
+7104 -2274

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ defmt = { version = "1", optional = true }
default = ["defmt"] default = ["defmt"]
# Enables `defmt::Format` derives on public types. On by default so firmware # Enables `defmt::Format` derives on public types. On by default so firmware
# (the primary no_std consumer) just works; host crates disable default-features. # (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 / # Enables `Debug` derives and other std-only niceties for host-side fuzz /
# proptest builds. No-op on firmware. # proptest builds. No-op on firmware.
std = [] std = []
+1923
View File
File diff suppressed because it is too large. Load diff
+13 -27
View File
@@ -22,6 +22,13 @@ features = ["std"]
[workspace] [workspace]
members = ["."] members = ["."]
[[bin]]
name = "decoder_feed"
path = "fuzz_targets/decoder_feed.rs"
test = false
doc = false
bench = false
[[bin]] [[bin]]
name = "parse_command" name = "parse_command"
path = "fuzz_targets/parse_command.rs" path = "fuzz_targets/parse_command.rs"
@@ -30,43 +37,22 @@ doc = false
bench = false bench = false
[[bin]] [[bin]]
name = "parse_radio_config" name = "parse_device_message"
path = "fuzz_targets/parse_radio_config.rs" path = "fuzz_targets/parse_device_message.rs"
test = false test = false
doc = false doc = false
bench = false bench = false
[[bin]] [[bin]]
name = "cobs_decoder_feed" name = "frame_roundtrip"
path = "fuzz_targets/cobs_decoder_feed.rs" path = "fuzz_targets/frame_roundtrip.rs"
test = false test = false
doc = false doc = false
bench = false bench = false
[[bin]] [[bin]]
name = "cobs_roundtrip" name = "lora_config_roundtrip"
path = "fuzz_targets/cobs_roundtrip.rs" path = "fuzz_targets/lora_config_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"
test = false test = false
doc = false doc = false
bench = 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. //! `Command::parse` must never panic on arbitrary `(type_id, payload)`
//! //! input. Accepted commands must respect the OTA payload bound.
//! Any accepted command must also respect the heapless payload bound.
#![no_main] #![no_main]
use donglora_protocol::{Command, MAX_PAYLOAD}; use arbitrary::Arbitrary;
use donglora_protocol::{Command, MAX_OTA_PAYLOAD};
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| { #[derive(Debug, Arbitrary)]
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(data) { struct Input<'a> {
assert!(payload.len() <= MAX_PAYLOAD); 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) # fuzz every target for 60s (requires nightly + cargo-fuzz)
test-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_command -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=60 cd fuzz && cargo +nightly fuzz run parse_device_message -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=60 cd fuzz && cargo +nightly fuzz run frame_roundtrip -- -max_total_time=60
cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=60 cd fuzz && cargo +nightly fuzz run lora_config_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
# run tests under miri (catches UB, requires nightly + miri component) # run tests under miri (catches UB, requires nightly + miri component)
# The proptest suite is skipped — its file-persistence layer calls getcwd # 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 # Runs under both feature configurations so that feature-gated paths
# (currently only the defmt::Format derives) are covered in both shapes. # (currently only the defmt::Format derives) are covered in both shapes.
# Three mutants are excluded as provably equivalent — swapping the # No exclusions yet for the v1.0 codec — rerun mutation testing on a
# operator does not change behaviour because a downstream check catches # fresh target and add `-E` lines here for any provably-equivalent
# the resulting case. See the regex strings for the specific sites. # mutants that survive, with a comment explaining why.
test-mutants: test-mutants-default test-mutants-no-default test-mutants: test-mutants-default test-mutants-no-default
test-mutants-default: test-mutants-default:
cargo mutants --no-shuffle --test-tool=nextest \ 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'
test-mutants-no-default: test-mutants-no-default:
cargo mutants --no-shuffle --test-tool=nextest --no-default-features \ 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'
# format code # format code
fmt: 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. //! This crate is the normative Rust implementation of the protocol
//! See `docs/PROTOCOL.md` for the complete specification. //! 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] #![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 use chip_id::RadioChipId;
pub const MAX_PAYLOAD: usize = 256; 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). // ── Top-level size constants ────────────────────────────────────────
pub const RADIO_CONFIG_SIZE: usize = 13;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power. /// Maximum over-the-air packet payload (bytes). The spec allows this to
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire /// 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). /// Maximum payload-field size in any DongLoRa Protocol frame. This is the OTA limit
pub const PREAMBLE_DEFAULT: u16 = 0; /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)] pub enum FrameEncodeError {
pub enum Bandwidth { /// Payload exceeds `MAX_PAYLOAD_FIELD`.
Khz7 = 0, PayloadTooLarge,
Khz10 = 1, /// Output buffer too small for the encoded frame.
Khz15 = 2, BufferTooSmall,
Khz20 = 3, /// COBS encoder rejected the input (should be unreachable given
Khz31 = 4, /// size checks, but surfaced as its own variant for completeness).
Khz41 = 5, CobsEncode,
Khz62 = 6,
Khz125 = 7,
Khz250 = 8,
Khz500 = 9,
} }
impl Bandwidth { /// Errors returned from the frame decoder.
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]
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct RadioConfig { pub enum FrameDecodeError {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262). /// COBS decode failed.
pub freq_hz: u32, Cobs,
pub bw: Bandwidth, /// CRC check failed.
/// Spreading factor (5-12). Crc,
pub sf: u8, /// Decoded frame shorter than the minimum `type + tag + crc`.
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8. TooShort,
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,
} }
impl RadioConfig { /// Errors from encoding a `Command` payload.
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)] pub enum CommandEncodeError {
pub enum ErrorCode { /// Output buffer too small.
InvalidConfig = 0, BufferTooSmall,
RadioBusy = 1, /// TX with zero-byte payload (spec rejects with `ERR(ELENGTH)`).
TxTimeout = 2, EmptyTxPayload,
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets) /// TX payload exceeds `MAX_OTA_PAYLOAD`.
NotConfigured = 4, PayloadTooLarge,
NoDisplay = 5, /// FSK sync_word_len > `MAX_SYNC_WORD_LEN`.
SyncWordTooLong,
} }
// ── Tests ─────────────────────────────────────────────────────────── /// Errors from parsing a `Command` payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(test)] #[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[allow(clippy::unwrap_used, clippy::panic)] pub enum CommandParseError {
mod tests { /// `type_id` is not a defined command.
use super::*; UnknownType,
/// Payload length is wrong for the command or modulation.
fn make_config() -> RadioConfig { WrongLength,
RadioConfig { /// An enum field carries a value not in the defined table.
freq_hz: 915_000_000, InvalidField,
bw: Bandwidth::Khz125, /// A reserved bit (`TX` flag bits 1–7) was set.
sf: 7, ReservedBitSet,
cr: 5, /// `SET_CONFIG`'s `modulation_id` is not assigned.
sync_word: 0x3444, UnknownModulation,
tx_power_dbm: 22, }
preamble_len: 16,
cad: 1, /// Errors from encoding a device-message payload.
} #[derive(Debug, Clone, Copy, PartialEq, Eq)]
} #[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum DeviceMessageEncodeError {
// ── RadioConfig roundtrip ─────────────────────────────────────── BufferTooSmall,
/// Payload exceeds an enum's fixed size budget.
#[test] PayloadTooLarge,
fn radio_config_roundtrip() { InvalidField,
let cfg = make_config(); SyncWordTooLong,
let mut buf = [0u8; RADIO_CONFIG_SIZE]; }
let n = cfg.write_to(&mut buf);
assert_eq!(n, RADIO_CONFIG_SIZE); /// Errors from parsing a device-message payload.
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); #[derive(Debug, Clone, Copy, PartialEq, Eq)]
} #[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum DeviceMessageParseError {
#[test] UnknownType,
fn radio_config_roundtrip_all_bandwidths() { /// Payload length wrong for the message shape.
for bw_val in 0u8..=9 { WrongLength,
let bw = Bandwidth::from_u8(bw_val).unwrap(); /// Payload shorter than the fixed prefix required by the message.
let cfg = RadioConfig { TooShort,
freq_hz: 433_000_000, /// An enum field carries a value not in the defined table.
bw, InvalidField,
sf: 12, /// Parser needs the originating command's `type_id` for an `OK`
cr: 8, /// frame but none was supplied.
sync_word: 0x1234, MissingContext,
tx_power_dbm: -9, /// `OK` frame for an unknown originating command type, or unknown
preamble_len: 16, /// `modulation_id` inside a `SET_CONFIG` result.
cad: 1, UnknownContext,
}; }
let mut buf = [0u8; RADIO_CONFIG_SIZE];
cfg.write_to(&mut buf); /// Errors from modulation param encoding.
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); #[derive(Debug, Clone, Copy, PartialEq, Eq)]
} #[cfg_attr(feature = "defmt", derive(defmt::Format))]
} pub enum ModulationEncodeError {
BufferTooSmall,
#[test] SyncWordTooLong,
fn radio_config_roundtrip_negative_power() { }
let cfg = RadioConfig {
tx_power_dbm: TX_POWER_MAX, /// Errors from modulation param parsing.
..make_config() #[derive(Debug, Clone, Copy, PartialEq, Eq)]
}; #[cfg_attr(feature = "defmt", derive(defmt::Format))]
let mut buf = [0u8; RADIO_CONFIG_SIZE]; pub enum ModulationParseError {
cfg.write_to(&mut buf); /// Declared length did not match the expected total for this
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); /// modulation shape.
} WrongLength { expected: usize, actual: usize },
/// Payload shorter than the modulation's fixed prefix.
#[test] TooShort,
fn radio_config_from_short_buffer() { /// An enum field or reserved byte carried an invalid value.
let buf = [0u8; RADIO_CONFIG_SIZE - 1]; InvalidField,
assert!(RadioConfig::from_bytes(&buf).is_none()); /// `modulation_id` byte does not match any defined modulation.
} UnknownModulation,
}
#[test]
fn radio_config_from_empty_buffer() { /// Errors from `Info` encode/decode.
assert!(RadioConfig::from_bytes(&[]).is_none()); #[derive(Debug, Clone, Copy, PartialEq, Eq)]
} #[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum InfoParseError {
#[test] TooShort,
fn radio_config_invalid_bandwidth() { BufferTooSmall,
let mut buf = [0u8; RADIO_CONFIG_SIZE]; InvalidField,
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);
}
} }
+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);
}
}
+959 -133
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
+359 -636
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. //! Each test pins one or more complete wire-framed byte sequences from
//! They lock the firmware and host implementations to the *written* //! the spec. If a future refactor accidentally redefines a field layout
//! protocol, not just to each other — so a refactor that accidentally //! or a CRC variant, the test fails with a concrete hex diff against
//! redefines a field layout fails here before it ships. //! the normative document — not just against the crate's own round-trip.
//!
//! 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.
#![allow(clippy::unwrap_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use donglora_protocol::{ 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; use heapless::Vec as HVec;
// ── A well-known RadioConfig with hex-clean field values ────────────── // ── Helpers ─────────────────────────────────────────────────────────
//
// 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).
const KNOWN_FREQ_HZ: u32 = 0x1234_5678; // 305_419_896 Hz ≈ 305 MHz /// Encode a command as a complete wire-ready frame, tag-correlated.
const KNOWN_SYNC: u16 = 0xABCD; fn encode_cmd_frame(cmd: &Command, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
const KNOWN_PREAMBLE: u16 = 0x00AB; let mut payload_buf = [0u8; 320];
let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
/// Matches the layout documented in `PROTOCOL.md §RadioConfig (13 bytes)`. let mut wire = [0u8; MAX_WIRE_FRAME];
const CONFIG_BYTES: [u8; RADIO_CONFIG_SIZE] = [ let n = encode_frame(cmd.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
0x78, 0x56, 0x34, 0x12, // freq_hz = 0x12345678 LE let mut out = HVec::new();
0x07, // bw = Khz125 out.extend_from_slice(&wire[..n]).unwrap();
0x07, // sf = 7 out
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,
}
} }
// ── RadioConfig ───────────────────────────────────────────────────── /// 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> {
#[test] let mut payload_buf = [0u8; 320];
fn radio_config_canonical_bytes_decode() { let payload_len = msg.encode_payload(&mut payload_buf).unwrap();
assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config())); 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] /// Decode a single frame out of `wire` and assert everything about it.
fn radio_config_canonical_bytes_encode() { fn decode_single_frame(wire: &[u8]) -> (u8, u16, HVec<u8, 320>) {
let mut buf = [0u8; RADIO_CONFIG_SIZE]; let mut decoder = FrameDecoder::new();
let n = known_config().write_to(&mut buf); let mut out: Option<(u8, u16, HVec<u8, 320>)> = None;
assert_eq!(n, RADIO_CONFIG_SIZE); decoder.feed(wire, |res| match res {
assert_eq!(buf, CONFIG_BYTES); FrameResult::Ok {
} type_id,
tag,
#[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()
}
.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, payload,
} => {
let mut p = HVec::new();
p.extend_from_slice(payload).unwrap();
out = Some((type_id, tag, p));
}
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" // ── §C.2.1 PING / OK ────────────────────────────────────────────────
let expected: &[u8] = &[
0x02, 0xB0, 0xFF, 0x0A, 0x00, 0x04, 0x00, b'd', b'a', b't', b'a', #[test]
]; fn c21_ping_encodes_to_spec_bytes() {
assert_eq!(bytes.as_slice(), expected); // 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] #[test]
fn response_error_canonical_bytes() { fn c21_ok_encodes_to_spec_bytes() {
// PROTOCOL.md §Error codes: InvalidConfig=0, RadioBusy=1, TxTimeout=2, // D→H OK tag=0x0001 → wire: 03 80 01 03 F7 C4 00
// NotConfigured=4, NoDisplay=5. Tag 3 is reserved (was CrcError). let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0001);
let pairs = [ assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
(ErrorCode::InvalidConfig, 0u8), }
(ErrorCode::RadioBusy, 1),
(ErrorCode::TxTimeout, 2), #[test]
(ErrorCode::NotConfigured, 4), fn c21_decode_roundtrip() {
(ErrorCode::NoDisplay, 5), let (type_id, tag, payload) = decode_single_frame(&[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
]; assert_eq!(type_id, commands::TYPE_PING);
for (code, byte) in pairs { assert_eq!(tag, 0x0001);
assert_eq!( assert_eq!(payload.as_slice(), &[]);
encode_response_bytes(Response::Error(code)).as_slice(), assert_eq!(Command::parse(type_id, &payload).unwrap(), Command::Ping);
&[0x05, byte], }
);
// ── §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] #[test]
fn response_mac_address_canonical_bytes() { fn c23_set_config_encodes_to_spec_bytes() {
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; // H→D SET_CONFIG tag=0x0003 → wire:
assert_eq!( // 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00
encode_response_bytes(Response::MacAddress(mac)).as_slice(), let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
&[0x06, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], 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));
} }