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