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
+7115
-2285
No files matched your search
+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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user