feat!: protocol v2 wire format and module restructure

The wire protocol is re-specified as DongLoRa Protocol v2 (see PROTOCOL.md). Every type_id, tag-correlation rule, and field layout is incompatible with 0.1.0.

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

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

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

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

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

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

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

* Rewrite canonical-byte test vectors against the v2 spec.
This commit is contained in:
swaits committed 2026-04-22 09:21:11 -06:00
1 parent 35866edac4
commit 4cccb65124
29 files changed
+7115 -2285

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
//! `GET_INFO` response payload (`PROTOCOL.md §6.2`).
//!
//! Carries the device's identity, firmware version, radio type,
//! capability bitmaps, queue depths, frequency range, TX-power range,
//! and optional MCU / radio unique identifiers. All fields are stable
//! for the lifetime of the session; hosts MAY cache the whole struct.
use crate::{InfoParseError, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN};
/// Capability bitmap bit positions (`PROTOCOL.md §9`).
///
/// Hosts should consult these before attempting `SET_CONFIG` with a
/// particular modulation. Undefined bits MUST be zero in v1.0 and MUST
/// be ignored by hosts that don't understand them.
pub mod cap {
// Modulations (bits 0–15)
//
// Bit 0 is written as a bare `1` (not `1 << 0`) because `1 << 0` and
// `1 >> 0` both evaluate to 1 — the two-operator form produces an
// equivalent mutation target that no test can distinguish. Every
// other bit keeps the `1 << N` form for clarity.
pub const LORA: u64 = 1;
pub const FSK: u64 = 1 << 1;
pub const GFSK: u64 = 1 << 2;
pub const LR_FHSS: u64 = 1 << 3;
pub const FLRC: u64 = 1 << 4;
pub const MSK: u64 = 1 << 5;
pub const GMSK: u64 = 1 << 6;
pub const BLE_COMPATIBLE: u64 = 1 << 7;
// Radio features (bits 16–31)
pub const CAD_BEFORE_TX: u64 = 1 << 16;
pub const IQ_INVERSION: u64 = 1 << 17;
pub const RANGING: u64 = 1 << 18;
pub const GNSS_SCAN: u64 = 1 << 19;
pub const WIFI_MAC_SCAN: u64 = 1 << 20;
pub const SPECTRAL_SCAN: u64 = 1 << 21;
pub const FULL_DUPLEX: u64 = 1 << 22;
// Protocol features (bits 32–47)
pub const MULTI_CLIENT: u64 = 1 << 32;
}
/// `GET_INFO` response payload.
///
/// `mcu_uid` and `radio_uid` are fixed-capacity arrays (`MAX_MCU_UID_LEN`
/// and `MAX_RADIO_UID_LEN` respectively). Only the first `*_len` bytes
/// are meaningful on the wire; the rest are zero-padded and ignored.
///
/// `radio_chip_id` is stored as the raw u16. Use `chip_id()` to project
/// it into the `RadioChipId` enum when convenient; unassigned values
/// stay representable so a newer device advertising a chip this codec
/// hasn't heard of still round-trips cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct Info {
pub proto_major: u8,
pub proto_minor: u8,
pub fw_major: u8,
pub fw_minor: u8,
pub fw_patch: u8,
pub radio_chip_id: u16,
pub capability_bitmap: u64,
pub supported_sf_bitmap: u16,
pub supported_bw_bitmap: u16,
pub max_payload_bytes: u16,
pub rx_queue_capacity: u16,
pub tx_queue_capacity: u16,
pub freq_min_hz: u32,
pub freq_max_hz: u32,
pub tx_power_min_dbm: i8,
pub tx_power_max_dbm: i8,
pub mcu_uid_len: u8,
pub mcu_uid: [u8; MAX_MCU_UID_LEN],
pub radio_uid_len: u8,
pub radio_uid: [u8; MAX_RADIO_UID_LEN],
}
impl Info {
/// Size of the fixed-layout prefix (through `radio_uid_len`'s byte).
/// The spec locates `radio_uid_len` at offset `36 + mcu_uid_len`; the
/// 37 bytes of fixed fields before `mcu_uid` plus 1 for the
/// `radio_uid_len` byte gives the absolute minimum wire size (with
/// `mcu_uid_len = 0` and `radio_uid_len = 0`).
pub const MIN_WIRE_SIZE: usize = 37;
/// Project `radio_chip_id` into the enum. Returns `None` for
/// unassigned values so callers can decide how to handle them.
pub fn chip_id(&self) -> Option<crate::RadioChipId> {
crate::RadioChipId::from_u16(self.radio_chip_id)
}
/// True if the device advertises a given capability bit.
pub fn supports(&self, mask: u64) -> bool {
self.capability_bitmap & mask != 0
}
/// Encode into `buf`. Returns the number of bytes written
/// (`37 + mcu_uid_len + radio_uid_len`).
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, InfoParseError> {
let mcu_n = self.mcu_uid_len as usize;
let radio_n = self.radio_uid_len as usize;
if mcu_n > MAX_MCU_UID_LEN || radio_n > MAX_RADIO_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
if buf.len() < total {
return Err(InfoParseError::BufferTooSmall);
}
buf[0] = self.proto_major;
buf[1] = self.proto_minor;
buf[2] = self.fw_major;
buf[3] = self.fw_minor;
buf[4] = self.fw_patch;
buf[5..7].copy_from_slice(&self.radio_chip_id.to_le_bytes());
buf[7..15].copy_from_slice(&self.capability_bitmap.to_le_bytes());
buf[15..17].copy_from_slice(&self.supported_sf_bitmap.to_le_bytes());
buf[17..19].copy_from_slice(&self.supported_bw_bitmap.to_le_bytes());
buf[19..21].copy_from_slice(&self.max_payload_bytes.to_le_bytes());
buf[21..23].copy_from_slice(&self.rx_queue_capacity.to_le_bytes());
buf[23..25].copy_from_slice(&self.tx_queue_capacity.to_le_bytes());
buf[25..29].copy_from_slice(&self.freq_min_hz.to_le_bytes());
buf[29..33].copy_from_slice(&self.freq_max_hz.to_le_bytes());
buf[33] = self.tx_power_min_dbm as u8;
buf[34] = self.tx_power_max_dbm as u8;
buf[35] = self.mcu_uid_len;
buf[36..36 + mcu_n].copy_from_slice(&self.mcu_uid[..mcu_n]);
let radio_len_idx = 36 + mcu_n;
buf[radio_len_idx] = self.radio_uid_len;
let radio_start = radio_len_idx + 1;
buf[radio_start..radio_start + radio_n].copy_from_slice(&self.radio_uid[..radio_n]);
Ok(total)
}
/// Decode from `buf`. The full slice must be the `GET_INFO` payload.
pub fn decode(buf: &[u8]) -> Result<Self, InfoParseError> {
if buf.len() < Self::MIN_WIRE_SIZE {
return Err(InfoParseError::TooShort);
}
let mcu_uid_len = buf[35];
if mcu_uid_len as usize > MAX_MCU_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let mcu_n = mcu_uid_len as usize;
let radio_len_idx = 36 + mcu_n;
if buf.len() < radio_len_idx + 1 {
return Err(InfoParseError::TooShort);
}
let radio_uid_len = buf[radio_len_idx];
if radio_uid_len as usize > MAX_RADIO_UID_LEN {
return Err(InfoParseError::InvalidField);
}
let radio_n = radio_uid_len as usize;
let expected_total = Self::MIN_WIRE_SIZE + mcu_n + radio_n;
if buf.len() < expected_total {
return Err(InfoParseError::TooShort);
}
// Minor-version extensions may append fields; trailing bytes are
// allowed. But we at least require the declared UIDs to fit.
let mut mcu_uid = [0u8; MAX_MCU_UID_LEN];
mcu_uid[..mcu_n].copy_from_slice(&buf[36..36 + mcu_n]);
let radio_start = radio_len_idx + 1;
let mut radio_uid = [0u8; MAX_RADIO_UID_LEN];
radio_uid[..radio_n].copy_from_slice(&buf[radio_start..radio_start + radio_n]);
Ok(Self {
proto_major: buf[0],
proto_minor: buf[1],
fw_major: buf[2],
fw_minor: buf[3],
fw_patch: buf[4],
radio_chip_id: u16::from_le_bytes([buf[5], buf[6]]),
capability_bitmap: u64::from_le_bytes([
buf[7], buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14],
]),
supported_sf_bitmap: u16::from_le_bytes([buf[15], buf[16]]),
supported_bw_bitmap: u16::from_le_bytes([buf[17], buf[18]]),
max_payload_bytes: u16::from_le_bytes([buf[19], buf[20]]),
rx_queue_capacity: u16::from_le_bytes([buf[21], buf[22]]),
tx_queue_capacity: u16::from_le_bytes([buf[23], buf[24]]),
freq_min_hz: u32::from_le_bytes([buf[25], buf[26], buf[27], buf[28]]),
freq_max_hz: u32::from_le_bytes([buf[29], buf[30], buf[31], buf[32]]),
tx_power_min_dbm: buf[33] as i8,
tx_power_max_dbm: buf[34] as i8,
mcu_uid_len,
mcu_uid,
radio_uid_len,
radio_uid,
})
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::RadioChipId;
fn sample() -> Info {
let mut mcu = [0u8; MAX_MCU_UID_LEN];
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
Info {
proto_major: 1,
proto_minor: 0,
fw_major: 0,
fw_minor: 1,
fw_patch: 0,
radio_chip_id: RadioChipId::Sx1262.as_u16(),
capability_bitmap: cap::LORA | cap::FSK | cap::CAD_BEFORE_TX,
// Bits 5..12 = SF5..SF12
supported_sf_bitmap: 0x1FE0,
// Bits 0..9 = all sub-GHz BW enum values
supported_bw_bitmap: 0x03FF,
max_payload_bytes: 255,
rx_queue_capacity: 64,
tx_queue_capacity: 16,
freq_min_hz: 150_000_000,
freq_max_hz: 960_000_000,
tx_power_min_dbm: -9,
tx_power_max_dbm: 22,
mcu_uid_len: 8,
mcu_uid: mcu,
radio_uid_len: 0,
radio_uid: [0u8; MAX_RADIO_UID_LEN],
}
}
#[test]
fn appendix_c22_encode_matches_spec() {
// PROTOCOL.md §C.2.2 — SX1262 board description. The spec's
// pre-COBS payload (after stripping the 3-byte response header:
// type 0x80, tag 0x0002) is:
//
// 01 00 00 01 00 02 00 03 00 01 00 00 00 00 00 00
// E0 1F FF 03 FF 00 40 00 10 00 80 D1 F0 08 00 70
// 38 39 F7 16 08 DE AD BE EF 01 23 45 67 00
//
// 45 bytes: 37 fixed + 8 mcu_uid + 0 radio_uid.
let mut buf = [0u8; 128];
let n = sample().encode(&mut buf).unwrap();
assert_eq!(n, 45);
let expected: [u8; 45] = [
0x01, 0x00, 0x00, 0x01, 0x00, // proto + fw
0x02, 0x00, // radio_chip_id = 0x0002 (SX1262)
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // capability_bitmap
0xE0, 0x1F, // supported_sf_bitmap = 0x1FE0
0xFF, 0x03, // supported_bw_bitmap = 0x03FF
0xFF, 0x00, // max_payload_bytes = 255
0x40, 0x00, // rx_queue_capacity = 64
0x10, 0x00, // tx_queue_capacity = 16
0x80, 0xD1, 0xF0, 0x08, // freq_min_hz = 150_000_000
0x00, 0x70, 0x38, 0x39, // freq_max_hz = 960_000_000
0xF7, // tx_power_min_dbm = -9
0x16, // tx_power_max_dbm = 22
0x08, // mcu_uid_len = 8
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67, // mcu_uid
0x00, // radio_uid_len = 0
];
assert_eq!(&buf[..n], &expected);
}
#[test]
fn roundtrip() {
let info = sample();
let mut buf = [0u8; 128];
let n = info.encode(&mut buf).unwrap();
let decoded = Info::decode(&buf[..n]).unwrap();
assert_eq!(decoded, info);
}
#[test]
fn chip_id_projection() {
let info = sample();
assert_eq!(info.chip_id(), Some(RadioChipId::Sx1262));
let unknown = Info {
radio_chip_id: 0xFFFF,
..info
};
assert_eq!(unknown.chip_id(), None);
}
#[test]
fn supports_bits() {
let info = sample();
assert!(info.supports(cap::LORA));
assert!(info.supports(cap::FSK));
assert!(info.supports(cap::CAD_BEFORE_TX));
assert!(!info.supports(cap::MULTI_CLIENT));
assert!(!info.supports(cap::LR_FHSS));
}
#[test]
fn rejects_oversized_uids() {
let mut info = sample();
info.mcu_uid_len = (MAX_MCU_UID_LEN + 1) as u8;
let mut buf = [0u8; 128];
assert!(info.encode(&mut buf).is_err());
info.mcu_uid_len = 0;
info.radio_uid_len = (MAX_RADIO_UID_LEN + 1) as u8;
assert!(info.encode(&mut buf).is_err());
}
#[test]
fn rejects_short_buffer_on_decode() {
assert!(matches!(
Info::decode(&[0u8; 10]),
Err(InfoParseError::TooShort)
));
}
#[test]
fn rejects_declared_uid_overrun() {
let mut buf = [0u8; 128];
let mut info = sample();
info.mcu_uid_len = 16;
let n = info.encode(&mut buf).unwrap();
// Shorten the wire beyond what mcu_uid_len claims.
let truncated = n - 4;
assert!(Info::decode(&buf[..truncated]).is_err());
}
}