Files
protocol-rs/src/info.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

324 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.
//! `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());
}
}