Files
protocol-rs/src/commands.rs
T
swaits 4cccb65124 feat!: protocol v2 wire format and module restructure
The wire protocol is re-specified as DongLoRa Protocol v2 (see PROTOCOL.md). Every type_id, tag-correlation rule, and field layout is incompatible with 0.1.0.

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

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

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

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

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

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

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

* Rewrite canonical-byte test vectors against the v2 spec.
2026-04-22 09:21:11 -06:00

355 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
));
}
}