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:
29 files changed
+7104
-2274
No files matched your search
+1
-1
@@ -32,7 +32,7 @@ defmt = { version = "1", optional = true }
|
||||
default = ["defmt"]
|
||||
# Enables `defmt::Format` derives on public types. On by default so firmware
|
||||
# (the primary no_std consumer) just works; host crates disable default-features.
|
||||
defmt = ["dep:defmt"]
|
||||
defmt = ["dep:defmt", "heapless/defmt"]
|
||||
# Enables `Debug` derives and other std-only niceties for host-side fuzz /
|
||||
# proptest builds. No-op on firmware.
|
||||
std = []
|
||||
|
||||
+1923
File diff suppressed because it is too large.
Load diff
+13
-27
@@ -22,6 +22,13 @@ features = ["std"]
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[[bin]]
|
||||
name = "decoder_feed"
|
||||
path = "fuzz_targets/decoder_feed.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "parse_command"
|
||||
path = "fuzz_targets/parse_command.rs"
|
||||
@@ -30,43 +37,22 @@ doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "parse_radio_config"
|
||||
path = "fuzz_targets/parse_radio_config.rs"
|
||||
name = "parse_device_message"
|
||||
path = "fuzz_targets/parse_device_message.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_decoder_feed"
|
||||
path = "fuzz_targets/cobs_decoder_feed.rs"
|
||||
name = "frame_roundtrip"
|
||||
path = "fuzz_targets/frame_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_roundtrip"
|
||||
path = "fuzz_targets/cobs_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "command_roundtrip"
|
||||
path = "fuzz_targets/command_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_decoder_multi_frame"
|
||||
path = "fuzz_targets/cobs_decoder_multi_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "cobs_chunk_invariant"
|
||||
path = "fuzz_targets/cobs_chunk_invariant.rs"
|
||||
name = "lora_config_roundtrip"
|
||||
path = "fuzz_targets/lora_config_roundtrip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,14 +1,20 @@
|
||||
//! `Command::from_bytes` must never panic on arbitrary input.
|
||||
//!
|
||||
//! Any accepted command must also respect the heapless payload bound.
|
||||
//! `Command::parse` must never panic on arbitrary `(type_id, payload)`
|
||||
//! input. Accepted commands must respect the OTA payload bound.
|
||||
|
||||
#![no_main]
|
||||
|
||||
use donglora_protocol::{Command, MAX_PAYLOAD};
|
||||
use arbitrary::Arbitrary;
|
||||
use donglora_protocol::{Command, MAX_OTA_PAYLOAD};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(data) {
|
||||
assert!(payload.len() <= MAX_PAYLOAD);
|
||||
#[derive(Debug, Arbitrary)]
|
||||
struct Input<'a> {
|
||||
type_id: u8,
|
||||
payload: &'a [u8],
|
||||
}
|
||||
|
||||
fuzz_target!(|input: Input| {
|
||||
if let Ok(Command::Tx { data, .. }) = Command::parse(input.type_id, input.payload) {
|
||||
assert!(data.len() <= MAX_OTA_PAYLOAD);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -40,13 +40,11 @@ test-deny:
|
||||
|
||||
# fuzz every target for 60s (requires nightly + cargo-fuzz)
|
||||
test-fuzz:
|
||||
cd fuzz && cargo +nightly fuzz run decoder_feed -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run parse_command -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run command_roundtrip -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_decoder_multi_frame -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run cobs_chunk_invariant -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run parse_device_message -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run frame_roundtrip -- -max_total_time=60
|
||||
cd fuzz && cargo +nightly fuzz run lora_config_roundtrip -- -max_total_time=60
|
||||
|
||||
# run tests under miri (catches UB, requires nightly + miri component)
|
||||
# The proptest suite is skipped — its file-persistence layer calls getcwd
|
||||
@@ -59,22 +57,16 @@ test-miri:
|
||||
#
|
||||
# Runs under both feature configurations so that feature-gated paths
|
||||
# (currently only the defmt::Format derives) are covered in both shapes.
|
||||
# Three mutants are excluded as provably equivalent — swapping the
|
||||
# operator does not change behaviour because a downstream check catches
|
||||
# the resulting case. See the regex strings for the specific sites.
|
||||
# No exclusions yet for the v1.0 codec — rerun mutation testing on a
|
||||
# fresh target and add `-E` lines here for any provably-equivalent
|
||||
# mutants that survive, with a comment explaining why.
|
||||
test-mutants: test-mutants-default test-mutants-no-default
|
||||
|
||||
test-mutants-default:
|
||||
cargo mutants --no-shuffle --test-tool=nextest \
|
||||
-E 'replace > with >= in Command::from_bytes' \
|
||||
-E 'replace > with >= in CobsDecoder::feed' \
|
||||
-E 'replace < with <= in cobs_encode_response'
|
||||
cargo mutants --no-shuffle --test-tool=nextest
|
||||
|
||||
test-mutants-no-default:
|
||||
cargo mutants --no-shuffle --test-tool=nextest --no-default-features \
|
||||
-E 'replace > with >= in Command::from_bytes' \
|
||||
-E 'replace > with >= in CobsDecoder::feed' \
|
||||
-E 'replace < with <= in cobs_encode_response'
|
||||
cargo mutants --no-shuffle --test-tool=nextest --no-default-features
|
||||
|
||||
# format code
|
||||
fmt:
|
||||
|
||||
+122
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,718 +1,251 @@
|
||||
//! Wire protocol types and fixed-size little-endian serialization.
|
||||
//! DongLoRa Protocol v2 — wire types, framing, and codecs.
|
||||
//!
|
||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
||||
//! This crate is the normative Rust implementation of the protocol
|
||||
//! defined in `PROTOCOL.md`. It is `no_std` and alloc-free: every
|
||||
//! container is fixed-capacity (`heapless::Vec`) and every fallible
|
||||
//! operation returns a typed error — no panics, no unwraps, no
|
||||
//! `unsafe`.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! - [`frame`] — wire-level COBS + CRC framing. `encode_frame` turns a
|
||||
//! `(type_id, tag, payload)` tuple into a complete wire-ready byte
|
||||
//! slice; [`FrameDecoder`] accumulates inbound bytes and emits
|
||||
//! [`FrameResult`] values as frames arrive.
|
||||
//! - [`commands`] — host-to-device command enum and `type_id` constants.
|
||||
//! - [`events`] — device-to-host message enum (`OK` / `ERR` / `RX` /
|
||||
//! `TX_DONE`) and its sub-shapes.
|
||||
//! - [`modulation`] — per-modulation parameter structs for `SET_CONFIG`
|
||||
//! (LoRa, FSK/GFSK, LR-FHSS, FLRC).
|
||||
//! - [`info`] — `GET_INFO` response payload, including capability bits.
|
||||
//! - [`errors`] — wire-level error codes (`ErrorCode`).
|
||||
//! - [`chip_id`] — `RadioChipId` enum.
|
||||
//! - [`crc`] — CRC-16/CCITT-FALSE implementation.
|
||||
//!
|
||||
//! # Typical encode
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use donglora_protocol::{encode_frame, Command, commands::TYPE_PING, MAX_WIRE_FRAME};
|
||||
//!
|
||||
//! let cmd = Command::Ping;
|
||||
//! let mut payload_buf = [0u8; 4];
|
||||
//! let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
|
||||
//!
|
||||
//! let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
//! let wire_len = encode_frame(cmd.type_id(), 1, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
//! // wire[..wire_len] is ready to transmit.
|
||||
//! # let _ = (TYPE_PING, wire_len);
|
||||
//! ```
|
||||
//!
|
||||
//! # Typical decode
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use donglora_protocol::{Command, FrameDecoder, FrameResult};
|
||||
//!
|
||||
//! let mut decoder = FrameDecoder::new();
|
||||
//! let incoming_bytes: &[u8] = &[];
|
||||
//! decoder.feed(incoming_bytes, |res| match res {
|
||||
//! FrameResult::Ok { type_id, tag, payload } => {
|
||||
//! match Command::parse(type_id, payload) {
|
||||
//! Ok(cmd) => { /* dispatch cmd with tag */ }
|
||||
//! Err(_e) => { /* respond ERR(EUNKNOWN_CMD) with echoed tag */ }
|
||||
//! }
|
||||
//! }
|
||||
//! FrameResult::Err(_e) => { /* emit async ERR(EFRAME) */ }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
#![no_std]
|
||||
|
||||
pub mod framing;
|
||||
pub mod chip_id;
|
||||
pub mod commands;
|
||||
pub mod crc;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod frame;
|
||||
pub mod info;
|
||||
pub mod modulation;
|
||||
|
||||
use heapless::Vec;
|
||||
// ── Public re-exports ───────────────────────────────────────────────
|
||||
|
||||
/// Maximum LoRa payload size in bytes.
|
||||
pub const MAX_PAYLOAD: usize = 256;
|
||||
pub use chip_id::RadioChipId;
|
||||
pub use commands::{Command, TxFlags};
|
||||
pub use errors::ErrorCode;
|
||||
pub use events::{
|
||||
DeviceMessage, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode,
|
||||
TxDonePayload, TxResult,
|
||||
};
|
||||
pub use frame::{FrameDecoder, FrameResult, encode_frame};
|
||||
pub use info::{Info, cap};
|
||||
pub use modulation::{
|
||||
FlrcBitrate, FlrcBt, FlrcCodingRate, FlrcConfig, FlrcPreambleLen, FskConfig, LoRaBandwidth,
|
||||
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, LrFhssBandwidth, LrFhssCodingRate, LrFhssConfig,
|
||||
LrFhssGrid, Modulation, ModulationId,
|
||||
};
|
||||
|
||||
/// RadioConfig wire size (fixed).
|
||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
||||
// ── Top-level size constants ────────────────────────────────────────
|
||||
|
||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
||||
/// Maximum over-the-air packet payload (bytes). The spec allows this to
|
||||
/// be chip-dependent via `GET_INFO.max_payload_bytes`; 255 is the
|
||||
/// ceiling for all currently-supported Semtech silicon.
|
||||
pub const MAX_OTA_PAYLOAD: usize = 255;
|
||||
|
||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
||||
/// Maximum payload-field size in any DongLoRa Protocol frame. This is the OTA limit
|
||||
/// plus the 20-byte `RX` metadata prefix: type + tag + this + crc gives
|
||||
/// the overall frame budget.
|
||||
pub const MAX_PAYLOAD_FIELD: usize = MAX_OTA_PAYLOAD + 20;
|
||||
|
||||
/// LoRa signal bandwidth.
|
||||
/// Byte count of the pre-CRC, pre-COBS frame header: `type(1) + tag(2)`.
|
||||
pub const FRAME_HEADER_SIZE: usize = 3;
|
||||
|
||||
/// Byte count of the frame trailer (CRC16).
|
||||
pub const FRAME_TRAILER_SIZE: usize = 2;
|
||||
|
||||
/// Maximum pre-COBS frame size: header + max payload + trailer.
|
||||
pub const MAX_PRE_COBS_FRAME: usize = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + FRAME_TRAILER_SIZE;
|
||||
|
||||
/// COBS encoding overhead upper bound: `ceil(n/254) + 1` bytes added
|
||||
/// around a run of n bytes. For `MAX_PRE_COBS_FRAME = 280` this is 3.
|
||||
pub const MAX_COBS_OVERHEAD: usize = 3;
|
||||
|
||||
/// Maximum on-wire frame size: pre-COBS size + COBS overhead + 1 for the
|
||||
/// trailing `0x00` sentinel. Buffer sizes of this value (or more)
|
||||
/// guarantee no encode-side overflow across any valid frame.
|
||||
pub const MAX_WIRE_FRAME: usize = MAX_PRE_COBS_FRAME + MAX_COBS_OVERHEAD + 1;
|
||||
|
||||
/// Maximum FSK/GFSK sync-word length (`PROTOCOL.md §10.2`).
|
||||
pub const MAX_SYNC_WORD_LEN: usize = 8;
|
||||
|
||||
/// Maximum `GET_INFO.mcu_uid` length (`PROTOCOL.md §6.2`).
|
||||
pub const MAX_MCU_UID_LEN: usize = 32;
|
||||
|
||||
/// Maximum `GET_INFO.radio_uid` length (`PROTOCOL.md §6.2`).
|
||||
pub const MAX_RADIO_UID_LEN: usize = 16;
|
||||
|
||||
/// Maximum `OK` payload size across all shapes. `Info` is the worst
|
||||
/// case: `GET_INFO`'s 37-byte fixed prefix plus both maximum UIDs
|
||||
/// (32 + 16).
|
||||
pub const MAX_OK_PAYLOAD: usize = 37 + MAX_MCU_UID_LEN + MAX_RADIO_UID_LEN;
|
||||
|
||||
/// Maximum `OK` payload size for `SET_CONFIG`: `result(1) + owner(1) +
|
||||
/// modulation_id(1) + max params(24 for FSK with 8-byte sync word)`.
|
||||
pub const MAX_SETCONFIG_OK_PAYLOAD: usize = 2 + 1 + 24;
|
||||
|
||||
// ── Error types ─────────────────────────────────────────────────────
|
||||
|
||||
/// Errors returned from `encode_frame`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum Bandwidth {
|
||||
Khz7 = 0,
|
||||
Khz10 = 1,
|
||||
Khz15 = 2,
|
||||
Khz20 = 3,
|
||||
Khz31 = 4,
|
||||
Khz41 = 5,
|
||||
Khz62 = 6,
|
||||
Khz125 = 7,
|
||||
Khz250 = 8,
|
||||
Khz500 = 9,
|
||||
pub enum FrameEncodeError {
|
||||
/// Payload exceeds `MAX_PAYLOAD_FIELD`.
|
||||
PayloadTooLarge,
|
||||
/// Output buffer too small for the encoded frame.
|
||||
BufferTooSmall,
|
||||
/// COBS encoder rejected the input (should be unreachable given
|
||||
/// size checks, but surfaced as its own variant for completeness).
|
||||
CobsEncode,
|
||||
}
|
||||
|
||||
impl Bandwidth {
|
||||
fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Khz7),
|
||||
1 => Some(Self::Khz10),
|
||||
2 => Some(Self::Khz15),
|
||||
3 => Some(Self::Khz20),
|
||||
4 => Some(Self::Khz31),
|
||||
5 => Some(Self::Khz41),
|
||||
6 => Some(Self::Khz62),
|
||||
7 => Some(Self::Khz125),
|
||||
8 => Some(Self::Khz250),
|
||||
9 => Some(Self::Khz500),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete LoRa radio configuration.
|
||||
///
|
||||
/// Wire layout (13 bytes, all little-endian):
|
||||
/// ```text
|
||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
||||
/// ```
|
||||
/// Errors returned from the frame decoder.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct RadioConfig {
|
||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
||||
pub freq_hz: u32,
|
||||
pub bw: Bandwidth,
|
||||
/// Spreading factor (5-12).
|
||||
pub sf: u8,
|
||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
||||
pub cr: u8,
|
||||
pub sync_word: u16,
|
||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
||||
/// board's maximum.
|
||||
pub tx_power_dbm: i8,
|
||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
||||
pub preamble_len: u16,
|
||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
||||
pub cad: u8,
|
||||
pub enum FrameDecodeError {
|
||||
/// COBS decode failed.
|
||||
Cobs,
|
||||
/// CRC check failed.
|
||||
Crc,
|
||||
/// Decoded frame shorter than the minimum `type + tag + crc`.
|
||||
TooShort,
|
||||
}
|
||||
|
||||
impl RadioConfig {
|
||||
/// Validate fields against hardware limits.
|
||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
||||
return Err("frequency out of range (150-960 MHz)");
|
||||
}
|
||||
if !(5..=12).contains(&self.sf) {
|
||||
return Err("spreading factor out of range (5-12)");
|
||||
}
|
||||
if !(5..=8).contains(&self.cr) {
|
||||
return Err("coding rate out of range (5-8)");
|
||||
}
|
||||
if self.tx_power_dbm != TX_POWER_MAX
|
||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
||||
{
|
||||
return Err("TX power out of range for this board");
|
||||
}
|
||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
||||
return Err("preamble length too short (min 6)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve sentinel values to concrete defaults.
|
||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
||||
if self.tx_power_dbm == TX_POWER_MAX {
|
||||
self.tx_power_dbm = power_range.1;
|
||||
}
|
||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
||||
self.preamble_len = 16;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether CAD (listen-before-talk) is enabled.
|
||||
pub fn cad_enabled(&self) -> bool {
|
||||
self.cad != 0
|
||||
}
|
||||
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bw as u8;
|
||||
buf[5] = self.sf;
|
||||
buf[6] = self.cr;
|
||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[9] = self.tx_power_dbm as u8;
|
||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[12] = self.cad;
|
||||
RADIO_CONFIG_SIZE
|
||||
}
|
||||
|
||||
/// Deserialize from fixed-size LE bytes.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
if buf.len() < RADIO_CONFIG_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
||||
bw: Bandwidth::from_u8(buf[4])?,
|
||||
sf: buf[5],
|
||||
cr: buf[6],
|
||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
||||
tx_power_dbm: buf[9] as i8,
|
||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
||||
cad: buf[12],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Host → firmware commands.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Ping,
|
||||
GetConfig,
|
||||
SetConfig(RadioConfig),
|
||||
StartRx,
|
||||
StopRx,
|
||||
Transmit {
|
||||
config: Option<RadioConfig>,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
DisplayOn,
|
||||
DisplayOff,
|
||||
GetMac,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Deserialize from a COBS-decoded frame.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
let tag = *buf.first()?;
|
||||
let rest = &buf[1..];
|
||||
match tag {
|
||||
0 => Some(Self::Ping),
|
||||
1 => Some(Self::GetConfig),
|
||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
||||
3 => Some(Self::StartRx),
|
||||
4 => Some(Self::StopRx),
|
||||
5 => {
|
||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (config, pos) = if rest[0] == 0 {
|
||||
(None, 1)
|
||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
||||
(
|
||||
Some(RadioConfig::from_bytes(&rest[1..])?),
|
||||
1 + RADIO_CONFIG_SIZE,
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.len() < pos + 2 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
||||
let data_start = pos + 2;
|
||||
if rest.len() < data_start + len {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
||||
Some(Self::Transmit { config, payload })
|
||||
}
|
||||
6 => Some(Self::DisplayOn),
|
||||
7 => Some(Self::DisplayOff),
|
||||
8 => Some(Self::GetMac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Firmware → host responses.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i16,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
TxDone,
|
||||
Ok,
|
||||
Error(ErrorCode),
|
||||
MacAddress([u8; 6]),
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
match self {
|
||||
Self::Pong => {
|
||||
buf[0] = 0;
|
||||
1
|
||||
}
|
||||
Self::Config(cfg) => {
|
||||
buf[0] = 1;
|
||||
1 + cfg.write_to(&mut buf[1..])
|
||||
}
|
||||
Self::RxPacket { rssi, snr, payload } => {
|
||||
buf[0] = 2;
|
||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
||||
7 + payload.len()
|
||||
}
|
||||
Self::TxDone => {
|
||||
buf[0] = 3;
|
||||
1
|
||||
}
|
||||
Self::Ok => {
|
||||
buf[0] = 4;
|
||||
1
|
||||
}
|
||||
Self::Error(code) => {
|
||||
buf[0] = 5;
|
||||
buf[1] = code as u8;
|
||||
2
|
||||
}
|
||||
Self::MacAddress(mac) => {
|
||||
buf[0] = 6;
|
||||
buf[1..7].copy_from_slice(&mac);
|
||||
7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error codes reported to the host.
|
||||
/// Errors from encoding a `Command` payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum ErrorCode {
|
||||
InvalidConfig = 0,
|
||||
RadioBusy = 1,
|
||||
TxTimeout = 2,
|
||||
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
|
||||
NotConfigured = 4,
|
||||
NoDisplay = 5,
|
||||
pub enum CommandEncodeError {
|
||||
/// Output buffer too small.
|
||||
BufferTooSmall,
|
||||
/// TX with zero-byte payload (spec rejects with `ERR(ELENGTH)`).
|
||||
EmptyTxPayload,
|
||||
/// TX payload exceeds `MAX_OTA_PAYLOAD`.
|
||||
PayloadTooLarge,
|
||||
/// FSK sync_word_len > `MAX_SYNC_WORD_LEN`.
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config() -> RadioConfig {
|
||||
RadioConfig {
|
||||
freq_hz: 915_000_000,
|
||||
bw: Bandwidth::Khz125,
|
||||
sf: 7,
|
||||
cr: 5,
|
||||
sync_word: 0x3444,
|
||||
tx_power_dbm: 22,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// ── RadioConfig roundtrip ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
let n = cfg.write_to(&mut buf);
|
||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_all_bandwidths() {
|
||||
for bw_val in 0u8..=9 {
|
||||
let bw = Bandwidth::from_u8(bw_val).unwrap();
|
||||
let cfg = RadioConfig {
|
||||
freq_hz: 433_000_000,
|
||||
bw,
|
||||
sf: 12,
|
||||
cr: 8,
|
||||
sync_word: 0x1234,
|
||||
tx_power_dbm: -9,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_negative_power() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_short_buffer() {
|
||||
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_empty_buffer() {
|
||||
assert!(RadioConfig::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_invalid_bandwidth() {
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
make_config().write_to(&mut buf);
|
||||
buf[4] = 255; // invalid bandwidth
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
// ── RadioConfig::validate ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_freq_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
let mut cfg = RadioConfig {
|
||||
freq_hz: 150_000_000,
|
||||
..base
|
||||
};
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 960_000_000;
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 149_999_999;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
|
||||
cfg.freq_hz = 960_000_001;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_sf_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for sf in 5..=12 {
|
||||
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(
|
||||
RadioConfig { sf: 13, ..base }
|
||||
.validate(power_range)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cr_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for cr in 5..=8 {
|
||||
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_out_of_range() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 23,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: -10,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_default_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
assert!(
|
||||
RadioConfig {
|
||||
preamble_len: 6,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
RadioConfig {
|
||||
preamble_len: 5,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── RadioConfig::resolve ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_power_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 10,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_default() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: 32,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
||||
}
|
||||
|
||||
// ── Command::from_bytes ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn command_ping() {
|
||||
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_get_config() {
|
||||
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_set_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
buf[0] = 2;
|
||||
cfg.write_to(&mut buf[1..]);
|
||||
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_start_stop_rx() {
|
||||
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
|
||||
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_no_config() {
|
||||
let payload = b"hello";
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[4..9].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..9]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert!(config.is_none());
|
||||
assert_eq!(p.as_slice(), b"hello");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_with_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 1; // has_config = true
|
||||
cfg.write_to(&mut buf[2..]);
|
||||
let payload = b"test";
|
||||
let pos = 2 + RADIO_CONFIG_SIZE;
|
||||
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[pos + 2..pos + 6].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert_eq!(config, Some(cfg));
|
||||
assert_eq!(p.as_slice(), b"test");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_empty_payload() {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||
|
||||
match Command::from_bytes(&buf).unwrap() {
|
||||
Command::Transmit { config, payload } => {
|
||||
assert!(config.is_none());
|
||||
assert!(payload.is_empty());
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_truncated() {
|
||||
// Tag only — missing has_config byte
|
||||
assert!(Command::from_bytes(&[5]).is_none());
|
||||
|
||||
// has_config=1 but no config bytes
|
||||
assert!(Command::from_bytes(&[5, 1]).is_none());
|
||||
|
||||
// has_config=0 but no length bytes
|
||||
assert!(Command::from_bytes(&[5, 0]).is_none());
|
||||
|
||||
// has_config=0, length says 5 but only 2 bytes of payload
|
||||
let mut buf = [0u8; 6];
|
||||
buf[0] = 5;
|
||||
buf[1] = 0;
|
||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
||||
buf[4] = 0xAA;
|
||||
buf[5] = 0xBB;
|
||||
assert!(Command::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_display_and_mac() {
|
||||
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
|
||||
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
|
||||
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_invalid_tag() {
|
||||
assert!(Command::from_bytes(&[9]).is_none());
|
||||
assert!(Command::from_bytes(&[255]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_empty_buffer() {
|
||||
assert!(Command::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
// ── Response::write_to ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn response_pong() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Pong.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
let n = Response::Config(cfg).write_to(&mut buf);
|
||||
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
|
||||
assert_eq!(buf[0], 1);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_rx_packet() {
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(b"data");
|
||||
let mut buf = [0u8; 64];
|
||||
let n = Response::RxPacket {
|
||||
rssi: -80,
|
||||
snr: 10,
|
||||
payload,
|
||||
}
|
||||
.write_to(&mut buf);
|
||||
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
|
||||
assert_eq!(buf[0], 2);
|
||||
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
|
||||
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
|
||||
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
|
||||
assert_eq!(&buf[7..11], b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tx_done() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_ok() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Ok.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_error_codes() {
|
||||
let mut buf = [0u8; 2];
|
||||
for (code, val) in [
|
||||
(ErrorCode::InvalidConfig, 0),
|
||||
(ErrorCode::RadioBusy, 1),
|
||||
(ErrorCode::TxTimeout, 2),
|
||||
(ErrorCode::NotConfigured, 4),
|
||||
(ErrorCode::NoDisplay, 5),
|
||||
] {
|
||||
let n = Response::Error(code).write_to(&mut buf);
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(buf[0], 5);
|
||||
assert_eq!(buf[1], val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_mac_address() {
|
||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
||||
let mut buf = [0u8; 7];
|
||||
let n = Response::MacAddress(mac).write_to(&mut buf);
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(buf[0], 6);
|
||||
assert_eq!(&buf[1..7], &mac);
|
||||
}
|
||||
/// Errors from parsing a `Command` payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum CommandParseError {
|
||||
/// `type_id` is not a defined command.
|
||||
UnknownType,
|
||||
/// Payload length is wrong for the command or modulation.
|
||||
WrongLength,
|
||||
/// An enum field carries a value not in the defined table.
|
||||
InvalidField,
|
||||
/// A reserved bit (`TX` flag bits 1–7) was set.
|
||||
ReservedBitSet,
|
||||
/// `SET_CONFIG`'s `modulation_id` is not assigned.
|
||||
UnknownModulation,
|
||||
}
|
||||
|
||||
/// Errors from encoding a device-message payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DeviceMessageEncodeError {
|
||||
BufferTooSmall,
|
||||
/// Payload exceeds an enum's fixed size budget.
|
||||
PayloadTooLarge,
|
||||
InvalidField,
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
/// Errors from parsing a device-message payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DeviceMessageParseError {
|
||||
UnknownType,
|
||||
/// Payload length wrong for the message shape.
|
||||
WrongLength,
|
||||
/// Payload shorter than the fixed prefix required by the message.
|
||||
TooShort,
|
||||
/// An enum field carries a value not in the defined table.
|
||||
InvalidField,
|
||||
/// Parser needs the originating command's `type_id` for an `OK`
|
||||
/// frame but none was supplied.
|
||||
MissingContext,
|
||||
/// `OK` frame for an unknown originating command type, or unknown
|
||||
/// `modulation_id` inside a `SET_CONFIG` result.
|
||||
UnknownContext,
|
||||
}
|
||||
|
||||
/// Errors from modulation param encoding.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ModulationEncodeError {
|
||||
BufferTooSmall,
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
/// Errors from modulation param parsing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ModulationParseError {
|
||||
/// Declared length did not match the expected total for this
|
||||
/// modulation shape.
|
||||
WrongLength { expected: usize, actual: usize },
|
||||
/// Payload shorter than the modulation's fixed prefix.
|
||||
TooShort,
|
||||
/// An enum field or reserved byte carried an invalid value.
|
||||
InvalidField,
|
||||
/// `modulation_id` byte does not match any defined modulation.
|
||||
UnknownModulation,
|
||||
}
|
||||
|
||||
/// Errors from `Info` encode/decode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum InfoParseError {
|
||||
TooShort,
|
||||
BufferTooSmall,
|
||||
InvalidField,
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+428
-252
@@ -1,275 +1,451 @@
|
||||
//! Canonical wire-byte vectors anchored to `PROTOCOL.md`.
|
||||
//! Appendix C wire vectors from `PROTOCOL.md`.
|
||||
//!
|
||||
//! These are hand-encoded byte sequences taken directly from the spec.
|
||||
//! They lock the firmware and host implementations to the *written*
|
||||
//! protocol, not just to each other — so a refactor that accidentally
|
||||
//! redefines a field layout fails here before it ships.
|
||||
//!
|
||||
//! Every vector is expressed twice: as a hex `&[u8]` decoded into a
|
||||
//! typed value, and as a typed value encoded back into bytes. Byte-for-
|
||||
//! byte equality in both directions is the invariant.
|
||||
//! Each test pins one or more complete wire-framed byte sequences from
|
||||
//! the spec. If a future refactor accidentally redefines a field layout
|
||||
//! or a CRC variant, the test fails with a concrete hex diff against
|
||||
//! the normative document — not just against the crate's own round-trip.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
||||
|
||||
use donglora_protocol::{
|
||||
Bandwidth, Command, ErrorCode, RADIO_CONFIG_SIZE, RadioConfig, Response, TX_POWER_MAX,
|
||||
Command, DeviceMessage, ErrorCode, FrameDecoder, FrameResult, Info, LoRaBandwidth,
|
||||
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_WIRE_FRAME, Modulation, OkPayload, Owner,
|
||||
RadioChipId, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags,
|
||||
TxResult, cap, commands, encode_frame,
|
||||
};
|
||||
use heapless::Vec as HVec;
|
||||
|
||||
// ── A well-known RadioConfig with hex-clean field values ──────────────
|
||||
//
|
||||
// Fields are picked so each multi-byte field has a visually obvious
|
||||
// little-endian byte pattern, and the config as a whole still validates
|
||||
// (freq in 150–960 MHz range, sf in 5–12, cr in 5–8).
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const KNOWN_FREQ_HZ: u32 = 0x1234_5678; // 305_419_896 Hz ≈ 305 MHz
|
||||
const KNOWN_SYNC: u16 = 0xABCD;
|
||||
const KNOWN_PREAMBLE: u16 = 0x00AB;
|
||||
|
||||
/// Matches the layout documented in `PROTOCOL.md §RadioConfig (13 bytes)`.
|
||||
const CONFIG_BYTES: [u8; RADIO_CONFIG_SIZE] = [
|
||||
0x78, 0x56, 0x34, 0x12, // freq_hz = 0x12345678 LE
|
||||
0x07, // bw = Khz125
|
||||
0x07, // sf = 7
|
||||
0x05, // cr = 5 (4/5)
|
||||
0xCD, 0xAB, // sync_word = 0xABCD LE
|
||||
0x16, // tx_power_dbm = 22
|
||||
0xAB, 0x00, // preamble_len = 0x00AB LE
|
||||
0x01, // cad = enabled
|
||||
];
|
||||
|
||||
fn known_config() -> RadioConfig {
|
||||
RadioConfig {
|
||||
freq_hz: KNOWN_FREQ_HZ,
|
||||
bw: Bandwidth::Khz125,
|
||||
sf: 7,
|
||||
cr: 5,
|
||||
sync_word: KNOWN_SYNC,
|
||||
tx_power_dbm: 22,
|
||||
preamble_len: KNOWN_PREAMBLE,
|
||||
cad: 1,
|
||||
}
|
||||
/// Encode a command as a complete wire-ready frame, tag-correlated.
|
||||
fn encode_cmd_frame(cmd: &Command, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
|
||||
let mut payload_buf = [0u8; 320];
|
||||
let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(cmd.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
let mut out = HVec::new();
|
||||
out.extend_from_slice(&wire[..n]).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
// ── RadioConfig ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn radio_config_canonical_bytes_decode() {
|
||||
assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config()));
|
||||
/// Encode a device message as a complete wire-ready frame, tag-correlated.
|
||||
fn encode_dev_frame(msg: &DeviceMessage, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
|
||||
let mut payload_buf = [0u8; 320];
|
||||
let payload_len = msg.encode_payload(&mut payload_buf).unwrap();
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(msg.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
let mut out = HVec::new();
|
||||
out.extend_from_slice(&wire[..n]).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_canonical_bytes_encode() {
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
let n = known_config().write_to(&mut buf);
|
||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
||||
assert_eq!(buf, CONFIG_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_tx_power_max_sentinel_is_minus_128() {
|
||||
// PROTOCOL.md: "Set tx_power_dbm to -128 (0x80) to use the board's maximum."
|
||||
assert_eq!(TX_POWER_MAX, -128);
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..known_config()
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(buf[9], 0x80, "TX_POWER_MAX must serialize as 0x80");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_preamble_default_sentinel_is_zero_le() {
|
||||
// PROTOCOL.md: "Set preamble_len to 0 to use the firmware default."
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: 0,
|
||||
..known_config()
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(&buf[10..12], &[0x00, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bandwidth_table_matches_protocol_md() {
|
||||
// PROTOCOL.md: 0..=9 enumerated in order 7.8…500 kHz.
|
||||
let expected = [
|
||||
(0u8, Bandwidth::Khz7),
|
||||
(1, Bandwidth::Khz10),
|
||||
(2, Bandwidth::Khz15),
|
||||
(3, Bandwidth::Khz20),
|
||||
(4, Bandwidth::Khz31),
|
||||
(5, Bandwidth::Khz41),
|
||||
(6, Bandwidth::Khz62),
|
||||
(7, Bandwidth::Khz125),
|
||||
(8, Bandwidth::Khz250),
|
||||
(9, Bandwidth::Khz500),
|
||||
];
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
for (wire, bw) in expected {
|
||||
RadioConfig {
|
||||
bw,
|
||||
..known_config()
|
||||
}
|
||||
.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,
|
||||
/// Decode a single frame out of `wire` and assert everything about it.
|
||||
fn decode_single_frame(wire: &[u8]) -> (u8, u16, HVec<u8, 320>) {
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut out: Option<(u8, u16, HVec<u8, 320>)> = None;
|
||||
decoder.feed(wire, |res| match res {
|
||||
FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
let mut p = HVec::new();
|
||||
p.extend_from_slice(payload).unwrap();
|
||||
out = Some((type_id, tag, p));
|
||||
}
|
||||
FrameResult::Err(e) => panic!("decode error: {:?}", e),
|
||||
});
|
||||
out.expect("expected one frame")
|
||||
}
|
||||
|
||||
// tag(2) + rssi(-80 = 0xFFB0 LE) + snr(10 LE) + len(4 LE) + "data"
|
||||
let expected: &[u8] = &[
|
||||
0x02, 0xB0, 0xFF, 0x0A, 0x00, 0x04, 0x00, b'd', b'a', b't', b'a',
|
||||
];
|
||||
assert_eq!(bytes.as_slice(), expected);
|
||||
// ── §C.2.1 PING / OK ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c21_ping_encodes_to_spec_bytes() {
|
||||
// H→D PING tag=0x0001 → wire: 03 01 01 03 9D C8 00
|
||||
let wire = encode_cmd_frame(&Command::Ping, 0x0001);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_error_canonical_bytes() {
|
||||
// PROTOCOL.md §Error codes: InvalidConfig=0, RadioBusy=1, TxTimeout=2,
|
||||
// NotConfigured=4, NoDisplay=5. Tag 3 is reserved (was CrcError).
|
||||
let pairs = [
|
||||
(ErrorCode::InvalidConfig, 0u8),
|
||||
(ErrorCode::RadioBusy, 1),
|
||||
(ErrorCode::TxTimeout, 2),
|
||||
(ErrorCode::NotConfigured, 4),
|
||||
(ErrorCode::NoDisplay, 5),
|
||||
];
|
||||
for (code, byte) in pairs {
|
||||
assert_eq!(
|
||||
encode_response_bytes(Response::Error(code)).as_slice(),
|
||||
&[0x05, byte],
|
||||
);
|
||||
fn c21_ok_encodes_to_spec_bytes() {
|
||||
// D→H OK tag=0x0001 → wire: 03 80 01 03 F7 C4 00
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0001);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c21_decode_roundtrip() {
|
||||
let (type_id, tag, payload) = decode_single_frame(&[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
|
||||
assert_eq!(type_id, commands::TYPE_PING);
|
||||
assert_eq!(tag, 0x0001);
|
||||
assert_eq!(payload.as_slice(), &[]);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), Command::Ping);
|
||||
}
|
||||
|
||||
// ── §C.2.3 SET_CONFIG LoRa EU868 ────────────────────────────────────
|
||||
|
||||
fn c23_lora() -> LoRaConfig {
|
||||
LoRaConfig {
|
||||
freq_hz: 868_100_000,
|
||||
sf: 7,
|
||||
bw: LoRaBandwidth::Khz125,
|
||||
cr: LoRaCodingRate::Cr4_5,
|
||||
preamble_len: 8,
|
||||
sync_word: 0x1424,
|
||||
tx_power_dbm: 14,
|
||||
header_mode: LoRaHeaderMode::Explicit,
|
||||
payload_crc: true,
|
||||
iq_invert: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_mac_address_canonical_bytes() {
|
||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
||||
assert_eq!(
|
||||
encode_response_bytes(Response::MacAddress(mac)).as_slice(),
|
||||
&[0x06, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF],
|
||||
);
|
||||
fn c23_set_config_encodes_to_spec_bytes() {
|
||||
// H→D SET_CONFIG tag=0x0003 → wire:
|
||||
// 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00
|
||||
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
|
||||
let wire = encode_cmd_frame(&cmd, 0x0003);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x03, 0x03, 0x08, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08, 0x04, 0x24,
|
||||
0x14, 0x0E, 0x02, 0x01, 0x03, 0xD9, 0x1F, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c23_set_config_ok_response() {
|
||||
// D→H OK tag=0x0003 result=APPLIED owner=MINE current=[same LoRa]
|
||||
let result = SetConfigResult {
|
||||
result: SetConfigResultCode::Applied,
|
||||
owner: Owner::Mine,
|
||||
current: Modulation::LoRa(c23_lora()),
|
||||
};
|
||||
let msg = DeviceMessage::Ok(OkPayload::SetConfig(result));
|
||||
let wire = encode_dev_frame(&msg, 0x0003);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x80, 0x03, 0x01, 0x09, 0x01, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08,
|
||||
0x04, 0x24, 0x14, 0x0E, 0x02, 0x01, 0x03, 0xC8, 0x91, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.4 TX with default CAD, packet "Hello" ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn c24_tx_with_cad() {
|
||||
// H→D TX tag=0x0004 flags=0x00 data="Hello"
|
||||
// wire: 03 04 04 01 08 48 65 6C 6C 6F 26 40 00
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"Hello").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags::default(),
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0004);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x04, 0x04, 0x01, 0x08, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x26, 0x40, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c24_tx_ok() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0004);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x04, 0x03, 0x02, 0x3B, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c24_tx_done() {
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 30_976,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0004);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0xC1, 0x04, 0x01, 0x01, 0x02, 0x79, 0x01, 0x03, 0xE3, 0xFA, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.5 TX with skip_cad ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c25_tx_skip_cad() {
|
||||
// H→D TX tag=0x0005 flags=0x01 data="URGENT"
|
||||
// wire: 03 04 05 0A 01 55 52 47 45 4E 54 DB 1C 00
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"URGENT").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags { skip_cad: true },
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0005);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x04, 0x05, 0x0A, 0x01, 0x55, 0x52, 0x47, 0x45, 0x4E, 0x54, 0xDB, 0x1C, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c25_tx_done() {
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 33_792,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0005);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0xC1, 0x05, 0x01, 0x01, 0x02, 0x84, 0x01, 0x03, 0x81, 0xE3, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.6 RX event ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c26_rx_start() {
|
||||
let wire = encode_cmd_frame(&Command::RxStart, 0x0006);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x05, 0x06, 0x03, 0xCA, 0x8D, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c26_rx_event() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -735,
|
||||
snr_tenths_db: 95,
|
||||
freq_err_hz: -125,
|
||||
timestamp_us: 42_000_000,
|
||||
crc_valid: true,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
|
||||
let expected: &[u8] = &[
|
||||
0x02, 0xC0, 0x01, 0x04, 0x21, 0xFD, 0x5F, 0x09, 0x83, 0xFF, 0xFF, 0xFF, 0x80, 0xDE, 0x80,
|
||||
0x02, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x07, 0x01, 0x02, 0x03, 0x04, 0xB9, 0x8E,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c26_rx_stop() {
|
||||
let wire = encode_cmd_frame(&Command::RxStop, 0x0008);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x06, 0x08, 0x03, 0x95, 0xF7, 0x00]);
|
||||
}
|
||||
|
||||
// ── §C.5.1 ERR(ENOTCONFIGURED) ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c51_err_notconfigured() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ENotConfigured), 0x0028);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x28, 0x02, 0x03, 0x03, 0x53, 0x7E, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.3 ERR(EPARAM) ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c53_err_eparam() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EParam), 0x002A);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x2A, 0x02, 0x01, 0x03, 0x59, 0xF5, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.4 ERR(EBUSY) ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c54_err_ebusy() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EBusy), 0x002B);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x2B, 0x02, 0x06, 0x03, 0x7A, 0x1A, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.6 Unknown command type byte ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c56_unknown_command_type_encodes_to_spec() {
|
||||
// The host sends type=0x10 with payload DE AD. We can't use
|
||||
// Command::encode_payload (no such variant), but the frame encoder
|
||||
// is type-agnostic so we can directly produce the wire bytes.
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x10, 0x003C, &[0xDE, 0xAD], &mut wire).unwrap();
|
||||
let expected: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
|
||||
assert_eq!(&wire[..n], expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c56_unknown_command_decoder_still_parses_frame() {
|
||||
// The decoder is payload-agnostic: it yields (type_id=0x10, tag=0x003C,
|
||||
// payload=DE AD). The command parser then rejects it cleanly with
|
||||
// UnknownType so the caller can respond with ERR(EUNKNOWN_CMD).
|
||||
let wire: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
|
||||
let (type_id, tag, payload) = decode_single_frame(wire);
|
||||
assert_eq!(type_id, 0x10);
|
||||
assert_eq!(tag, 0x003C);
|
||||
assert_eq!(payload.as_slice(), &[0xDE, 0xAD]);
|
||||
assert!(matches!(
|
||||
Command::parse(type_id, &payload),
|
||||
Err(donglora_protocol::CommandParseError::UnknownType)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c56_err_eunknown_cmd_reply() {
|
||||
// D→H ERR tag=0x003C code=0x0005
|
||||
// on wire: 03 81 3C 02 05 03 A3 05 00
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EUnknownCmd), 0x003C);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x3C, 0x02, 0x05, 0x03, 0xA3, 0x05, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.2 RX with crc_valid = 0 ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c62_rx_bad_crc_delivered_anyway() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x41, 0x42, 0xFF, 0x00, 0xCC])
|
||||
.unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -1050,
|
||||
snr_tenths_db: -80,
|
||||
freq_err_hz: 2200,
|
||||
timestamp_us: 120_000_000,
|
||||
crc_valid: false,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
|
||||
let expected: &[u8] = &[
|
||||
0x02, 0xC0, 0x01, 0x07, 0xE6, 0xFB, 0xB0, 0xFF, 0x98, 0x08, 0x01, 0x01, 0x04, 0x0E, 0x27,
|
||||
0x07, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0x41, 0x42, 0xFF, 0x04, 0xCC, 0x04,
|
||||
0x3C, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.4 Async ERR(EFRAME) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c64_async_err_eframe() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EFrame), 0x0000);
|
||||
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x02, 0x01, 0xCE, 0xEF, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.5 Async ERR(ERADIO) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c65_async_err_eradio() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ERadio), 0x0000);
|
||||
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x01, 0x01, 0x9D, 0xBA, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.8.1 COBS edge: no zeros in body ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c81_ping_no_zeros_in_body() {
|
||||
// H→D PING tag=0x0101 → no zero bytes in tag bytes 01 01.
|
||||
// wire: 06 01 01 01 BC D8 00
|
||||
let wire = encode_cmd_frame(&Command::Ping, 0x0101);
|
||||
assert_eq!(wire.as_slice(), &[0x06, 0x01, 0x01, 0x01, 0xBC, 0xD8, 0x00]);
|
||||
}
|
||||
|
||||
// ── §C.2.2 GET_INFO ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c22_get_info_request() {
|
||||
let wire = encode_cmd_frame(&Command::GetInfo, 0x0002);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x02, 0x02, 0x03, 0x9E, 0xC4, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c22_get_info_response() {
|
||||
// Full SX1262 GET_INFO response encoded as a complete wire frame.
|
||||
let mut mcu = [0u8; donglora_protocol::MAX_MCU_UID_LEN];
|
||||
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
|
||||
let info = Info {
|
||||
proto_major: 1,
|
||||
proto_minor: 0,
|
||||
fw_major: 0,
|
||||
fw_minor: 1,
|
||||
fw_patch: 0,
|
||||
radio_chip_id: RadioChipId::Sx1262.as_u16(),
|
||||
capability_bitmap: cap::LORA | cap::FSK | cap::CAD_BEFORE_TX,
|
||||
supported_sf_bitmap: 0x1FE0,
|
||||
supported_bw_bitmap: 0x03FF,
|
||||
max_payload_bytes: 255,
|
||||
rx_queue_capacity: 64,
|
||||
tx_queue_capacity: 16,
|
||||
freq_min_hz: 150_000_000,
|
||||
freq_max_hz: 960_000_000,
|
||||
tx_power_min_dbm: -9,
|
||||
tx_power_max_dbm: 22,
|
||||
mcu_uid_len: 8,
|
||||
mcu_uid: mcu,
|
||||
radio_uid_len: 0,
|
||||
radio_uid: [0u8; donglora_protocol::MAX_RADIO_UID_LEN],
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Info(info)), 0x0002);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x80, 0x02, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, 0x02, 0x01, 0x01,
|
||||
0x01, 0x01, 0x01, 0x06, 0xE0, 0x1F, 0xFF, 0x03, 0xFF, 0x02, 0x40, 0x02, 0x10, 0x05, 0x80,
|
||||
0xD1, 0xF0, 0x08, 0x0F, 0x70, 0x38, 0x39, 0xF7, 0x16, 0x08, 0xDE, 0xAD, 0xBE, 0xEF, 0x01,
|
||||
0x23, 0x45, 0x67, 0x03, 0xFA, 0xA4, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── Decode-side roundtrip for the key command shapes ───────────────
|
||||
|
||||
#[test]
|
||||
fn decode_tx_roundtrip() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"Hello").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags::default(),
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0004);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, commands::TYPE_TX);
|
||||
assert_eq!(tag, 0x0004);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_set_config_roundtrip() {
|
||||
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
|
||||
let wire = encode_cmd_frame(&cmd, 0x0003);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, commands::TYPE_SET_CONFIG);
|
||||
assert_eq!(tag, 0x0003);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rx_roundtrip() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -735,
|
||||
snr_tenths_db: 95,
|
||||
freq_err_hz: -125,
|
||||
timestamp_us: 42_000_000,
|
||||
crc_valid: true,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data: data.clone(),
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Rx(rx.clone()), 0x0000);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, 0xC0);
|
||||
assert_eq!(tag, 0x0000);
|
||||
let decoded = DeviceMessage::parse(type_id, &payload, None).unwrap();
|
||||
assert_eq!(decoded, DeviceMessage::Rx(rx));
|
||||
}
|
||||
Reference in new issue
Block a user