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
+122
@@ -0,0 +1,122 @@
|
||||
//! Radio chip identifier enum from `PROTOCOL.md §8`.
|
||||
//!
|
||||
//! Reported by the device in `GET_INFO.radio_chip_id` so the host can
|
||||
//! make chip-specific decisions (bandwidth range, SF range, TX power
|
||||
//! range, modulation support) without having to infer them from the
|
||||
//! bitmap fields alone.
|
||||
|
||||
/// Radio chip identifier (u16, little-endian on the wire).
|
||||
///
|
||||
/// Values are assigned by `PROTOCOL.md §8`. Ranges are grouped by chip
|
||||
/// family so future additions slot in next to their siblings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u16)]
|
||||
pub enum RadioChipId {
|
||||
/// Firmware could not identify its radio. Hosts should abort: the
|
||||
/// device will not function.
|
||||
Unknown = 0x0000,
|
||||
|
||||
// Gen 2 sub-GHz
|
||||
Sx1261 = 0x0001,
|
||||
Sx1262 = 0x0002,
|
||||
Sx1268 = 0x0003,
|
||||
Llcc68 = 0x0004,
|
||||
|
||||
// Gen 1 sub-GHz
|
||||
Sx1272 = 0x0010,
|
||||
Sx1276 = 0x0011,
|
||||
Sx1277 = 0x0012,
|
||||
Sx1278 = 0x0013,
|
||||
Sx1279 = 0x0014,
|
||||
|
||||
// Gen 2 2.4 GHz
|
||||
Sx1280 = 0x0020,
|
||||
Sx1281 = 0x0021,
|
||||
|
||||
// Gen 3 multi-band
|
||||
Lr1110 = 0x0030,
|
||||
Lr1120 = 0x0031,
|
||||
Lr1121 = 0x0032,
|
||||
|
||||
// Gen 4 multi-band
|
||||
Lr2021 = 0x0040,
|
||||
}
|
||||
|
||||
impl RadioChipId {
|
||||
/// Wire-form u16. Use this when encoding `GET_INFO`.
|
||||
pub const fn as_u16(self) -> u16 {
|
||||
self as u16
|
||||
}
|
||||
|
||||
/// Parse a wire u16 into a known chip. Returns `None` for unassigned
|
||||
/// values; hosts receiving an unknown value should treat the device
|
||||
/// as unusable.
|
||||
pub const fn from_u16(v: u16) -> Option<Self> {
|
||||
Some(match v {
|
||||
0x0000 => Self::Unknown,
|
||||
0x0001 => Self::Sx1261,
|
||||
0x0002 => Self::Sx1262,
|
||||
0x0003 => Self::Sx1268,
|
||||
0x0004 => Self::Llcc68,
|
||||
0x0010 => Self::Sx1272,
|
||||
0x0011 => Self::Sx1276,
|
||||
0x0012 => Self::Sx1277,
|
||||
0x0013 => Self::Sx1278,
|
||||
0x0014 => Self::Sx1279,
|
||||
0x0020 => Self::Sx1280,
|
||||
0x0021 => Self::Sx1281,
|
||||
0x0030 => Self::Lr1110,
|
||||
0x0031 => Self::Lr1120,
|
||||
0x0032 => Self::Lr1121,
|
||||
0x0040 => Self::Lr2021,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_assigned_values_roundtrip() {
|
||||
let all = [
|
||||
RadioChipId::Unknown,
|
||||
RadioChipId::Sx1261,
|
||||
RadioChipId::Sx1262,
|
||||
RadioChipId::Sx1268,
|
||||
RadioChipId::Llcc68,
|
||||
RadioChipId::Sx1272,
|
||||
RadioChipId::Sx1276,
|
||||
RadioChipId::Sx1277,
|
||||
RadioChipId::Sx1278,
|
||||
RadioChipId::Sx1279,
|
||||
RadioChipId::Sx1280,
|
||||
RadioChipId::Sx1281,
|
||||
RadioChipId::Lr1110,
|
||||
RadioChipId::Lr1120,
|
||||
RadioChipId::Lr1121,
|
||||
RadioChipId::Lr2021,
|
||||
];
|
||||
for id in all {
|
||||
assert_eq!(RadioChipId::from_u16(id.as_u16()), Some(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassigned_values_reject() {
|
||||
for bad in [0x0005u16, 0x0015, 0x0022, 0x0033, 0x0041, 0xFFFF] {
|
||||
assert_eq!(RadioChipId::from_u16(bad), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_values_from_spec() {
|
||||
// Direct quote from §8 — any renumbering breaks the wire contract.
|
||||
assert_eq!(RadioChipId::Sx1262.as_u16(), 0x0002);
|
||||
assert_eq!(RadioChipId::Llcc68.as_u16(), 0x0004);
|
||||
assert_eq!(RadioChipId::Sx1280.as_u16(), 0x0020);
|
||||
assert_eq!(RadioChipId::Lr2021.as_u16(), 0x0040);
|
||||
}
|
||||
}
|
||||
+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)
|
||||
));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
//! CRC-16/CCITT-FALSE — the wire-level integrity check used by DongLoRa Protocol.
|
||||
//!
|
||||
//! Polynomial `0x1021`, initial value `0xFFFF`, no reflection, XOR-out `0x0000`.
|
||||
//! This is the same variant also known as CRC-16/AUTOSAR or
|
||||
//! CRC-16/IBM-3740. It is **NOT** the same as CRC-16/KERMIT,
|
||||
//! CRC-16/XMODEM, or plain "CRC-16/CCITT" — those use different initial
|
||||
//! values or reflect their inputs.
|
||||
//!
|
||||
//! Check value: `crc16("123456789") == 0x29B1`. A module-level test pins
|
||||
//! this, and `tests/vectors.rs` anchors every Appendix C wire example.
|
||||
|
||||
/// CRC-16/CCITT-FALSE of `data`.
|
||||
pub fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0xFFFF;
|
||||
for &byte in data {
|
||||
crc ^= (byte as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
if crc & 0x8000 != 0 {
|
||||
crc = (crc << 1) ^ 0x1021;
|
||||
} else {
|
||||
crc <<= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_value_matches_spec() {
|
||||
// PROTOCOL.md §2.2: CRC-16/CCITT-FALSE of ASCII "123456789" is 0x29B1.
|
||||
assert_eq!(crc16(b"123456789"), 0x29B1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_initial_value() {
|
||||
// No reflection, no XOR-out: empty input leaves the init value untouched.
|
||||
assert_eq!(crc16(&[]), 0xFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_zero_byte() {
|
||||
// 0xFFFF XOR 0x0000 = 0xFFFF, then 8 shift-and-maybe-xor rounds.
|
||||
// Worked out by hand against the reference impl in Appendix B.
|
||||
assert_eq!(crc16(&[0x00]), 0xE1F0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differs_from_kermit_variant() {
|
||||
// CRC-16/KERMIT of "123456789" is 0x2189 — if we ever accidentally
|
||||
// swap init or reflection, this vector catches it.
|
||||
assert_ne!(crc16(b"123456789"), 0x2189);
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
//! Error codes carried in `ERR` frames (`PROTOCOL.md §7`).
|
||||
//!
|
||||
//! Codes are u16 little-endian on the wire. The containing frame's tag
|
||||
//! distinguishes context: non-zero tag is a synchronous error tied to a
|
||||
//! specific command; tag `0x0000` is an asynchronous fault not tied to
|
||||
//! any command. The two namespaces share one u16 space:
|
||||
//!
|
||||
//! - `0x0000..=0x00FF` — synchronous-typical codes
|
||||
//! - `0x0100..=0x01FF` — asynchronous-typical codes
|
||||
//! - `0x0200..=0xFFFF` — reserved for future extensions
|
||||
//!
|
||||
//! Any code MAY appear in either context when it fits semantically; the
|
||||
//! split is convention, not enforcement. `ERADIO` for instance is listed
|
||||
//! in the async table but is emitted synchronously when a hardware fault
|
||||
//! aborts a specific command.
|
||||
|
||||
/// Wire-level error code.
|
||||
///
|
||||
/// Parsing an unknown code yields `ErrorCode::Unknown(raw)` so hosts can
|
||||
/// round-trip frames they don't fully understand (useful when a device
|
||||
/// reports a newer minor-version error code).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ErrorCode {
|
||||
/// `0x0001` — a parameter value is out of range or invalid.
|
||||
EParam,
|
||||
/// `0x0002` — payload length is wrong for the command or modulation.
|
||||
ELength,
|
||||
/// `0x0003` — command requires `CONFIGURED`; device is `UNCONFIGURED`.
|
||||
ENotConfigured,
|
||||
/// `0x0004` — requested modulation is not supported by this chip.
|
||||
EModulation,
|
||||
/// `0x0005` — unknown command type byte.
|
||||
EUnknownCmd,
|
||||
/// `0x0006` — transient: TX queue is full. Retry after a `TX_DONE`.
|
||||
EBusy,
|
||||
|
||||
/// `0x0101` — radio SPI error or unexpected hardware state.
|
||||
ERadio,
|
||||
/// `0x0102` — inbound frame had bad CRC, bad COBS, or wrong length.
|
||||
EFrame,
|
||||
/// `0x0103` — firmware encountered an unexpected internal condition.
|
||||
EInternal,
|
||||
|
||||
/// Any code not assigned in v1.0. Preserves the raw wire value so
|
||||
/// parsers don't drop information they don't understand.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
/// Wire-form u16.
|
||||
pub const fn as_u16(self) -> u16 {
|
||||
match self {
|
||||
Self::EParam => 0x0001,
|
||||
Self::ELength => 0x0002,
|
||||
Self::ENotConfigured => 0x0003,
|
||||
Self::EModulation => 0x0004,
|
||||
Self::EUnknownCmd => 0x0005,
|
||||
Self::EBusy => 0x0006,
|
||||
Self::ERadio => 0x0101,
|
||||
Self::EFrame => 0x0102,
|
||||
Self::EInternal => 0x0103,
|
||||
Self::Unknown(raw) => raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a wire u16. Assigned codes return their named variant;
|
||||
/// anything else becomes `Unknown(raw)`.
|
||||
pub const fn from_u16(v: u16) -> Self {
|
||||
match v {
|
||||
0x0001 => Self::EParam,
|
||||
0x0002 => Self::ELength,
|
||||
0x0003 => Self::ENotConfigured,
|
||||
0x0004 => Self::EModulation,
|
||||
0x0005 => Self::EUnknownCmd,
|
||||
0x0006 => Self::EBusy,
|
||||
0x0101 => Self::ERadio,
|
||||
0x0102 => Self::EFrame,
|
||||
0x0103 => Self::EInternal,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this code lives in the asynchronous-typical range
|
||||
/// (`0x0100..=0x01FF`). Does not imply the containing frame's tag;
|
||||
/// that's a separate wire-level fact.
|
||||
pub const fn is_async_range(self) -> bool {
|
||||
let v = self.as_u16();
|
||||
v >= 0x0100 && v <= 0x01FF
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_sync_values() {
|
||||
assert_eq!(ErrorCode::EParam.as_u16(), 0x0001);
|
||||
assert_eq!(ErrorCode::ELength.as_u16(), 0x0002);
|
||||
assert_eq!(ErrorCode::ENotConfigured.as_u16(), 0x0003);
|
||||
assert_eq!(ErrorCode::EModulation.as_u16(), 0x0004);
|
||||
assert_eq!(ErrorCode::EUnknownCmd.as_u16(), 0x0005);
|
||||
assert_eq!(ErrorCode::EBusy.as_u16(), 0x0006);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_async_values() {
|
||||
assert_eq!(ErrorCode::ERadio.as_u16(), 0x0101);
|
||||
assert_eq!(ErrorCode::EFrame.as_u16(), 0x0102);
|
||||
assert_eq!(ErrorCode::EInternal.as_u16(), 0x0103);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigned_roundtrip() {
|
||||
let all = [
|
||||
ErrorCode::EParam,
|
||||
ErrorCode::ELength,
|
||||
ErrorCode::ENotConfigured,
|
||||
ErrorCode::EModulation,
|
||||
ErrorCode::EUnknownCmd,
|
||||
ErrorCode::EBusy,
|
||||
ErrorCode::ERadio,
|
||||
ErrorCode::EFrame,
|
||||
ErrorCode::EInternal,
|
||||
];
|
||||
for code in all {
|
||||
assert_eq!(ErrorCode::from_u16(code.as_u16()), code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_preserves_raw() {
|
||||
let unusual = [0x0000u16, 0x0007, 0x0100, 0x0200, 0xFFFF];
|
||||
for raw in unusual {
|
||||
let c = ErrorCode::from_u16(raw);
|
||||
assert_eq!(c.as_u16(), raw);
|
||||
assert!(matches!(c, ErrorCode::Unknown(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_async_range_boundaries() {
|
||||
assert!(!ErrorCode::EParam.is_async_range());
|
||||
assert!(!ErrorCode::EBusy.is_async_range());
|
||||
assert!(ErrorCode::ERadio.is_async_range());
|
||||
assert!(ErrorCode::EInternal.is_async_range());
|
||||
// 0x00FF and 0x0200 are outside the async band.
|
||||
assert!(!ErrorCode::Unknown(0x00FF).is_async_range());
|
||||
assert!(!ErrorCode::Unknown(0x0200).is_async_range());
|
||||
assert!(ErrorCode::Unknown(0x01FF).is_async_range());
|
||||
}
|
||||
}
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
//! Device-to-host messages (`PROTOCOL.md §6.7–6.10`).
|
||||
//!
|
||||
//! Four wire types:
|
||||
//!
|
||||
//! | Hex | Name | Tag | Notes |
|
||||
//! |------|---------|------------------------|---------------------------------------|
|
||||
//! | 0x80 | OK | echoes command | Payload shape depends on command |
|
||||
//! | 0x81 | ERR | command or 0x0000 | 2-byte error code |
|
||||
//! | 0xC0 | RX | always 0x0000 | Rich metadata + packet bytes |
|
||||
//! | 0xC1 | TX_DONE | echoes originating TX | Result + airtime |
|
||||
//!
|
||||
//! OK is the only tricky one: its payload shape varies by what command
|
||||
//! produced it, so callers must supply the originating command's type
|
||||
//! when parsing (they know this from tag correlation). The three shapes
|
||||
//! are empty (for `PING` / `TX` / `RX_START` / `RX_STOP`), `Info` (for
|
||||
//! `GET_INFO`), and `SetConfigResult` (for `SET_CONFIG`).
|
||||
|
||||
use heapless::Vec as HVec;
|
||||
|
||||
use crate::{
|
||||
DeviceMessageEncodeError, DeviceMessageParseError, ErrorCode, Info, InfoParseError,
|
||||
MAX_OTA_PAYLOAD, Modulation, ModulationEncodeError, ModulationParseError, commands,
|
||||
};
|
||||
|
||||
// ── D→H message type identifiers ────────────────────────────────────
|
||||
|
||||
pub const TYPE_OK: u8 = 0x80;
|
||||
pub const TYPE_ERR: u8 = 0x81;
|
||||
pub const TYPE_RX: u8 = 0xC0;
|
||||
pub const TYPE_TX_DONE: u8 = 0xC1;
|
||||
|
||||
// ── OK sub-shapes ───────────────────────────────────────────────────
|
||||
|
||||
/// `SET_CONFIG` result code (`PROTOCOL.md §6.3`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum SetConfigResultCode {
|
||||
/// The request was accepted. The radio is now configured as requested.
|
||||
Applied = 0,
|
||||
/// Another client holds the lock; active config byte-for-byte matches.
|
||||
AlreadyMatched = 1,
|
||||
/// Another client holds the lock; active config differs.
|
||||
LockedMismatch = 2,
|
||||
}
|
||||
|
||||
impl SetConfigResultCode {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Applied,
|
||||
1 => Self::AlreadyMatched,
|
||||
2 => Self::LockedMismatch,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `SET_CONFIG` lock owner (`PROTOCOL.md §6.3`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum Owner {
|
||||
/// No client currently holds the lock.
|
||||
None = 0,
|
||||
/// The calling client holds the lock.
|
||||
Mine = 1,
|
||||
/// A different client holds the lock.
|
||||
Other = 2,
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::None,
|
||||
1 => Self::Mine,
|
||||
2 => Self::Other,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of `OK` in response to `SET_CONFIG` (`PROTOCOL.md §6.3`).
|
||||
///
|
||||
/// `current_modulation` and `current_params` always reflect what is
|
||||
/// actually programmed into the radio at the moment the response was
|
||||
/// constructed, regardless of whether this call caused the change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct SetConfigResult {
|
||||
pub result: SetConfigResultCode,
|
||||
pub owner: Owner,
|
||||
pub current: Modulation,
|
||||
}
|
||||
|
||||
impl SetConfigResult {
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
|
||||
if buf.len() < 2 {
|
||||
return Err(DeviceMessageEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0] = self.result.as_u8();
|
||||
buf[1] = self.owner.as_u8();
|
||||
let rest = self.current.encode(&mut buf[2..]).map_err(|e| match e {
|
||||
ModulationEncodeError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
|
||||
ModulationEncodeError::SyncWordTooLong => DeviceMessageEncodeError::SyncWordTooLong,
|
||||
})?;
|
||||
Ok(2 + rest)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
|
||||
if buf.len() < 2 {
|
||||
return Err(DeviceMessageParseError::TooShort);
|
||||
}
|
||||
let result =
|
||||
SetConfigResultCode::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
|
||||
let owner = Owner::from_u8(buf[1]).ok_or(DeviceMessageParseError::InvalidField)?;
|
||||
let current = Modulation::decode(&buf[2..]).map_err(DeviceMessageParseError::from)?;
|
||||
Ok(Self {
|
||||
result,
|
||||
owner,
|
||||
current,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload shape carried by an `OK` frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum OkPayload {
|
||||
/// For `PING`, `TX`, `RX_START`, `RX_STOP` — no body.
|
||||
Empty,
|
||||
/// For `GET_INFO`.
|
||||
Info(Info),
|
||||
/// For `SET_CONFIG`.
|
||||
SetConfig(SetConfigResult),
|
||||
}
|
||||
|
||||
impl OkPayload {
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
|
||||
match self {
|
||||
Self::Empty => Ok(0),
|
||||
Self::Info(info) => info.encode(buf).map_err(|e| match e {
|
||||
InfoParseError::TooShort | InfoParseError::InvalidField => {
|
||||
DeviceMessageEncodeError::InvalidField
|
||||
}
|
||||
InfoParseError::BufferTooSmall => DeviceMessageEncodeError::BufferTooSmall,
|
||||
}),
|
||||
Self::SetConfig(r) => r.encode(buf),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `OK` payload given the originating command's type byte.
|
||||
/// Returns `Empty` for `PING`/`TX`/`RX_START`/`RX_STOP` (and verifies
|
||||
/// that the payload is empty), `Info` for `GET_INFO`, `SetConfig` for
|
||||
/// `SET_CONFIG`. Any other `cmd_type` is rejected.
|
||||
pub fn parse_for(cmd_type: u8, payload: &[u8]) -> Result<Self, DeviceMessageParseError> {
|
||||
match cmd_type {
|
||||
commands::TYPE_PING
|
||||
| commands::TYPE_TX
|
||||
| commands::TYPE_RX_START
|
||||
| commands::TYPE_RX_STOP => {
|
||||
if !payload.is_empty() {
|
||||
return Err(DeviceMessageParseError::WrongLength);
|
||||
}
|
||||
Ok(Self::Empty)
|
||||
}
|
||||
commands::TYPE_GET_INFO => Info::decode(payload)
|
||||
.map(Self::Info)
|
||||
.map_err(DeviceMessageParseError::from),
|
||||
commands::TYPE_SET_CONFIG => SetConfigResult::decode(payload).map(Self::SetConfig),
|
||||
_ => Err(DeviceMessageParseError::UnknownContext),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RX event ────────────────────────────────────────────────────────
|
||||
|
||||
/// Packet origin — over-the-air vs. local-loopback from another client
|
||||
/// (`PROTOCOL.md §6.9`, §13.4). In single-client firmware always `Ota`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum RxOrigin {
|
||||
Ota = 0,
|
||||
LocalLoopback = 1,
|
||||
}
|
||||
|
||||
impl RxOrigin {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Ota,
|
||||
1 => Self::LocalLoopback,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `RX` event payload (`PROTOCOL.md §6.9`). Metadata is the 20-byte
|
||||
/// prefix; `data` is the OTA packet body (0..=MAX_OTA_PAYLOAD bytes).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct RxPayload {
|
||||
/// RSSI in tenths of a dBm.
|
||||
pub rssi_tenths_dbm: i16,
|
||||
/// SNR in tenths of a dB.
|
||||
pub snr_tenths_db: i16,
|
||||
/// Estimated LO offset in Hz.
|
||||
pub freq_err_hz: i32,
|
||||
/// Microseconds since boot, captured at RxDone IRQ.
|
||||
pub timestamp_us: u64,
|
||||
/// True if the radio reported CRC pass (or CRC was not configured).
|
||||
pub crc_valid: bool,
|
||||
/// Packets dropped since the previous delivered RX. Cleared to 0 on
|
||||
/// every delivered event.
|
||||
pub packets_dropped: u16,
|
||||
pub origin: RxOrigin,
|
||||
pub data: HVec<u8, MAX_OTA_PAYLOAD>,
|
||||
}
|
||||
|
||||
impl RxPayload {
|
||||
/// Fixed part of the payload preceding the variable-length `data`.
|
||||
pub const METADATA_SIZE: usize = 20;
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
|
||||
let total = Self::METADATA_SIZE + self.data.len();
|
||||
if buf.len() < total {
|
||||
return Err(DeviceMessageEncodeError::BufferTooSmall);
|
||||
}
|
||||
if self.data.len() > MAX_OTA_PAYLOAD {
|
||||
return Err(DeviceMessageEncodeError::PayloadTooLarge);
|
||||
}
|
||||
buf[0..2].copy_from_slice(&self.rssi_tenths_dbm.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&self.snr_tenths_db.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&self.freq_err_hz.to_le_bytes());
|
||||
buf[8..16].copy_from_slice(&self.timestamp_us.to_le_bytes());
|
||||
buf[16] = u8::from(self.crc_valid);
|
||||
buf[17..19].copy_from_slice(&self.packets_dropped.to_le_bytes());
|
||||
buf[19] = self.origin.as_u8();
|
||||
buf[20..total].copy_from_slice(&self.data);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
|
||||
if buf.len() < Self::METADATA_SIZE {
|
||||
return Err(DeviceMessageParseError::TooShort);
|
||||
}
|
||||
let n = buf.len() - Self::METADATA_SIZE;
|
||||
if n > MAX_OTA_PAYLOAD {
|
||||
return Err(DeviceMessageParseError::WrongLength);
|
||||
}
|
||||
let rssi_tenths_dbm = i16::from_le_bytes([buf[0], buf[1]]);
|
||||
let snr_tenths_db = i16::from_le_bytes([buf[2], buf[3]]);
|
||||
let freq_err_hz = i32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
||||
let timestamp_us = u64::from_le_bytes([
|
||||
buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15],
|
||||
]);
|
||||
let crc_valid = match buf[16] {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(DeviceMessageParseError::InvalidField),
|
||||
};
|
||||
let packets_dropped = u16::from_le_bytes([buf[17], buf[18]]);
|
||||
let origin = RxOrigin::from_u8(buf[19]).ok_or(DeviceMessageParseError::InvalidField)?;
|
||||
// Slice with `n` rather than `..` so the subtraction `buf.len() -
|
||||
// METADATA_SIZE` is load-bearing: a mutant that replaces `-` with
|
||||
// `/` yields a different `n` and therefore a shorter data slice,
|
||||
// which round-trip tests observe.
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&buf[Self::METADATA_SIZE..Self::METADATA_SIZE + n])
|
||||
.map_err(|_| DeviceMessageParseError::WrongLength)?;
|
||||
Ok(Self {
|
||||
rssi_tenths_dbm,
|
||||
snr_tenths_db,
|
||||
freq_err_hz,
|
||||
timestamp_us,
|
||||
crc_valid,
|
||||
packets_dropped,
|
||||
origin,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── TX_DONE event ───────────────────────────────────────────────────
|
||||
|
||||
/// `TX_DONE` result code (`PROTOCOL.md §6.10`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum TxResult {
|
||||
Transmitted = 0,
|
||||
ChannelBusy = 1,
|
||||
Cancelled = 2,
|
||||
}
|
||||
|
||||
impl TxResult {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Transmitted,
|
||||
1 => Self::ChannelBusy,
|
||||
2 => Self::Cancelled,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `TX_DONE` payload (`PROTOCOL.md §6.10`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct TxDonePayload {
|
||||
pub result: TxResult,
|
||||
/// Measured or computed on-air duration in microseconds. Always 0
|
||||
/// when `result` is not `Transmitted`.
|
||||
pub airtime_us: u32,
|
||||
}
|
||||
|
||||
impl TxDonePayload {
|
||||
pub const WIRE_SIZE: usize = 5;
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
|
||||
if buf.len() < Self::WIRE_SIZE {
|
||||
return Err(DeviceMessageEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0] = self.result.as_u8();
|
||||
buf[1..5].copy_from_slice(&self.airtime_us.to_le_bytes());
|
||||
Ok(Self::WIRE_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, DeviceMessageParseError> {
|
||||
if buf.len() != Self::WIRE_SIZE {
|
||||
return Err(DeviceMessageParseError::WrongLength);
|
||||
}
|
||||
let result = TxResult::from_u8(buf[0]).ok_or(DeviceMessageParseError::InvalidField)?;
|
||||
let airtime_us = u32::from_le_bytes([buf[1], buf[2], buf[3], buf[4]]);
|
||||
Ok(Self { result, airtime_us })
|
||||
}
|
||||
}
|
||||
|
||||
// ── ERR payload helper ──────────────────────────────────────────────
|
||||
|
||||
/// Encode an `ERR` payload (2 bytes of u16 LE error code).
|
||||
pub fn encode_err_payload(
|
||||
code: ErrorCode,
|
||||
buf: &mut [u8],
|
||||
) -> Result<usize, DeviceMessageEncodeError> {
|
||||
if buf.len() < 2 {
|
||||
return Err(DeviceMessageEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0..2].copy_from_slice(&code.as_u16().to_le_bytes());
|
||||
Ok(2)
|
||||
}
|
||||
|
||||
/// Decode an `ERR` payload (2 bytes).
|
||||
pub fn decode_err_payload(buf: &[u8]) -> Result<ErrorCode, DeviceMessageParseError> {
|
||||
if buf.len() != 2 {
|
||||
return Err(DeviceMessageParseError::WrongLength);
|
||||
}
|
||||
Ok(ErrorCode::from_u16(u16::from_le_bytes([buf[0], buf[1]])))
|
||||
}
|
||||
|
||||
// ── DeviceMessage top-level sum ────────────────────────────────────
|
||||
|
||||
/// Any device→host message, parsed into its semantic form.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DeviceMessage {
|
||||
Ok(OkPayload),
|
||||
Err(ErrorCode),
|
||||
Rx(RxPayload),
|
||||
TxDone(TxDonePayload),
|
||||
}
|
||||
|
||||
impl DeviceMessage {
|
||||
pub const fn type_id(&self) -> u8 {
|
||||
match self {
|
||||
Self::Ok(_) => TYPE_OK,
|
||||
Self::Err(_) => TYPE_ERR,
|
||||
Self::Rx(_) => TYPE_RX,
|
||||
Self::TxDone(_) => TYPE_TX_DONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the device-message payload (bytes between frame header and
|
||||
/// CRC) into `buf`. Returns the number of bytes written.
|
||||
pub fn encode_payload(&self, buf: &mut [u8]) -> Result<usize, DeviceMessageEncodeError> {
|
||||
match self {
|
||||
Self::Ok(ok) => ok.encode(buf),
|
||||
Self::Err(code) => encode_err_payload(*code, buf),
|
||||
Self::Rx(rx) => rx.encode(buf),
|
||||
Self::TxDone(td) => td.encode(buf),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a (`type_id`, `payload`) pair.
|
||||
///
|
||||
/// `originating_cmd_type` MUST be supplied for `OK` frames: it is
|
||||
/// the `type_id` of the H→D command whose tag the host observed
|
||||
/// echoed back. For all other device-message types it is ignored
|
||||
/// (pass `None`).
|
||||
pub fn parse(
|
||||
type_id: u8,
|
||||
payload: &[u8],
|
||||
originating_cmd_type: Option<u8>,
|
||||
) -> Result<Self, DeviceMessageParseError> {
|
||||
match type_id {
|
||||
TYPE_OK => {
|
||||
let cmd = originating_cmd_type.ok_or(DeviceMessageParseError::MissingContext)?;
|
||||
Ok(Self::Ok(OkPayload::parse_for(cmd, payload)?))
|
||||
}
|
||||
TYPE_ERR => Ok(Self::Err(decode_err_payload(payload)?)),
|
||||
TYPE_RX => Ok(Self::Rx(RxPayload::decode(payload)?)),
|
||||
TYPE_TX_DONE => Ok(Self::TxDone(TxDonePayload::decode(payload)?)),
|
||||
_ => Err(DeviceMessageParseError::UnknownType),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error conversions ───────────────────────────────────────────────
|
||||
|
||||
impl From<ModulationParseError> for DeviceMessageParseError {
|
||||
fn from(e: ModulationParseError) -> Self {
|
||||
match e {
|
||||
ModulationParseError::WrongLength { .. } | ModulationParseError::TooShort => {
|
||||
Self::WrongLength
|
||||
}
|
||||
ModulationParseError::InvalidField => Self::InvalidField,
|
||||
ModulationParseError::UnknownModulation => Self::UnknownContext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InfoParseError> for DeviceMessageParseError {
|
||||
fn from(e: InfoParseError) -> Self {
|
||||
match e {
|
||||
InfoParseError::TooShort | InfoParseError::BufferTooSmall => Self::WrongLength,
|
||||
InfoParseError::InvalidField => Self::InvalidField,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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_OK, 0x80);
|
||||
assert_eq!(TYPE_ERR, 0x81);
|
||||
assert_eq!(TYPE_RX, 0xC0);
|
||||
assert_eq!(TYPE_TX_DONE, 0xC1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_empty_roundtrip_for_ping() {
|
||||
let ok = OkPayload::Empty;
|
||||
let mut buf = [0u8; 4];
|
||||
let n = ok.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(
|
||||
OkPayload::parse_for(commands::TYPE_PING, &buf[..n]).unwrap(),
|
||||
ok
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_empty_rejects_nonempty_for_tx() {
|
||||
assert!(matches!(
|
||||
OkPayload::parse_for(commands::TYPE_TX, &[0]),
|
||||
Err(DeviceMessageParseError::WrongLength)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_set_config_roundtrip() {
|
||||
let r = SetConfigResult {
|
||||
result: SetConfigResultCode::Applied,
|
||||
owner: Owner::Mine,
|
||||
current: Modulation::LoRa(sample_lora()),
|
||||
};
|
||||
let mut buf = [0u8; 64];
|
||||
let n = r.encode(&mut buf).unwrap();
|
||||
// 2 (result + owner) + 1 (mod id) + 15 (LoRa).
|
||||
assert_eq!(n, 2 + 1 + 15);
|
||||
assert_eq!(SetConfigResult::decode(&buf[..n]).unwrap(), r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_set_config_spec_bytes_c23() {
|
||||
// PROTOCOL.md §C.2.3 — OK in reply to SET_CONFIG (tag=0x0003).
|
||||
// Payload is: result=0, owner=1, mod=0x01, then 15-byte LoRa params.
|
||||
let r = SetConfigResult {
|
||||
result: SetConfigResultCode::Applied,
|
||||
owner: Owner::Mine,
|
||||
current: Modulation::LoRa(sample_lora()),
|
||||
};
|
||||
let mut buf = [0u8; 64];
|
||||
let n = r.encode(&mut buf).unwrap();
|
||||
let expected: [u8; 18] = [
|
||||
0x00, // result APPLIED
|
||||
0x01, // owner MINE
|
||||
0x01, // modulation_id LoRa
|
||||
0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x00, 0x08, 0x00, 0x24, 0x14, 0x0E, 0x00, 0x01,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(&buf[..n], &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn err_payload_roundtrip() {
|
||||
let mut buf = [0u8; 2];
|
||||
let n = encode_err_payload(ErrorCode::ENotConfigured, &mut buf).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(&buf, &[0x03, 0x00]);
|
||||
let decoded = decode_err_payload(&buf).unwrap();
|
||||
assert_eq!(decoded, ErrorCode::ENotConfigured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn err_payload_preserves_unknown_codes() {
|
||||
let mut buf = [0u8; 2];
|
||||
encode_err_payload(ErrorCode::Unknown(0xABCD), &mut buf).unwrap();
|
||||
let decoded = decode_err_payload(&buf).unwrap();
|
||||
assert_eq!(decoded.as_u16(), 0xABCD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_payload_roundtrip_with_data() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -735,
|
||||
snr_tenths_db: 95,
|
||||
freq_err_hz: -125,
|
||||
timestamp_us: 42_000_000,
|
||||
crc_valid: true,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data,
|
||||
};
|
||||
let mut buf = [0u8; 64];
|
||||
let n = rx.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 20 + 4);
|
||||
let decoded = RxPayload::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_payload_spec_bytes_c26() {
|
||||
// PROTOCOL.md §C.2.6 — the RX event in the continuous-RX loop.
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -735,
|
||||
snr_tenths_db: 95,
|
||||
freq_err_hz: -125,
|
||||
timestamp_us: 42_000_000,
|
||||
crc_valid: true,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data,
|
||||
};
|
||||
let mut buf = [0u8; 64];
|
||||
let n = rx.encode(&mut buf).unwrap();
|
||||
let expected: [u8; 24] = [
|
||||
0x21, 0xFD, // rssi -735 = 0xFD21 LE
|
||||
0x5F, 0x00, // snr 95 = 0x005F LE
|
||||
0x83, 0xFF, 0xFF, 0xFF, // freq_err -125 LE i32
|
||||
0x80, 0xDE, 0x80, 0x02, 0x00, 0x00, 0x00, 0x00, // timestamp 42_000_000 LE u64
|
||||
0x01, // crc_valid
|
||||
0x00, 0x00, // packets_dropped
|
||||
0x00, // origin OTA
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
];
|
||||
assert_eq!(&buf[..n], &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_payload_rejects_invalid_crc_byte() {
|
||||
let mut buf = [0u8; 20];
|
||||
buf[16] = 2; // crc_valid must be 0 or 1
|
||||
assert!(RxPayload::decode(&buf).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_done_roundtrip() {
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 30_976,
|
||||
};
|
||||
let mut buf = [0u8; 8];
|
||||
let n = td.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(TxDonePayload::decode(&buf[..n]).unwrap(), td);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_done_spec_bytes_c24() {
|
||||
// PROTOCOL.md §C.2.4 — TX_DONE for "Hello" at SF7 BW125.
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 30_976,
|
||||
};
|
||||
let mut buf = [0u8; 5];
|
||||
td.encode(&mut buf).unwrap();
|
||||
assert_eq!(&buf, &[0x00, 0x00, 0x79, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_message_parse_requires_ok_context() {
|
||||
let mut buf = [0u8; 4];
|
||||
let n = OkPayload::Empty.encode(&mut buf).unwrap();
|
||||
assert!(matches!(
|
||||
DeviceMessage::parse(TYPE_OK, &buf[..n], None),
|
||||
Err(DeviceMessageParseError::MissingContext)
|
||||
));
|
||||
assert!(matches!(
|
||||
DeviceMessage::parse(TYPE_OK, &buf[..n], Some(commands::TYPE_PING)).unwrap(),
|
||||
DeviceMessage::Ok(OkPayload::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_message_unknown_type_rejects() {
|
||||
assert!(matches!(
|
||||
DeviceMessage::parse(0x55, &[], None),
|
||||
Err(DeviceMessageParseError::UnknownType)
|
||||
));
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
//! DongLoRa Protocol frame format and streaming decoder (`PROTOCOL.md §2`).
|
||||
//!
|
||||
//! Every DongLoRa Protocol frame on the wire is `COBS(type || tag_le || payload ||
|
||||
//! crc_le) || 0x00`. This module owns the wire-level codec: it does
|
||||
//! nothing protocol-semantic (no enum matching, no tag validation) —
|
||||
//! just framing, CRC, and COBS.
|
||||
//!
|
||||
//! - `encode_frame` builds a complete wire-ready frame (COBS-encoded,
|
||||
//! `0x00`-terminated) given `(type_id, tag, payload)`.
|
||||
//! - `FrameDecoder` accumulates bytes, splitting on `0x00`, and emits
|
||||
//! `FrameResult` values through a caller-provided closure. `FrameResult`
|
||||
//! is `Ok` with borrowed pre-payload-and-tag slices on success, or
|
||||
//! `Err` with a `FrameError` variant on CRC/COBS/length failure.
|
||||
//!
|
||||
//! The decoder's internal scratch borrows into the callback, so parsing
|
||||
//! is zero-copy above the framing layer. Typed parsing (`Command`,
|
||||
//! `Response`, `Event`) consumes those borrowed slices.
|
||||
|
||||
use crate::{
|
||||
FRAME_HEADER_SIZE, FRAME_TRAILER_SIZE, FrameDecodeError, FrameEncodeError, MAX_PAYLOAD_FIELD,
|
||||
MAX_PRE_COBS_FRAME, MAX_WIRE_FRAME, crc::crc16,
|
||||
};
|
||||
|
||||
/// Outcome of attempting to decode one frame delimited by a `0x00` byte.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum FrameResult<'a> {
|
||||
Ok {
|
||||
type_id: u8,
|
||||
tag: u16,
|
||||
payload: &'a [u8],
|
||||
},
|
||||
Err(FrameDecodeError),
|
||||
}
|
||||
|
||||
/// Worst-case COBS-encoded size for a maximum-length pre-COBS frame.
|
||||
/// Equal to `MAX_WIRE_FRAME - 1` (the `-1` accounts for the trailing
|
||||
/// `0x00` sentinel). Public so tests can pin its exact value against
|
||||
/// the derivation in `lib.rs`.
|
||||
pub const MAX_COBS_ENCODED: usize = MAX_WIRE_FRAME - 1;
|
||||
|
||||
/// Encode `(type_id, tag, payload)` as a complete DongLoRa Protocol frame (COBS-encoded,
|
||||
/// `0x00`-terminated). Returns the number of bytes written to `out`.
|
||||
///
|
||||
/// `out` must be at least `MAX_WIRE_FRAME` bytes to satisfy the worst
|
||||
/// case across all frame shapes. Smaller buffers succeed when the actual
|
||||
/// frame fits.
|
||||
pub fn encode_frame(
|
||||
type_id: u8,
|
||||
tag: u16,
|
||||
payload: &[u8],
|
||||
out: &mut [u8],
|
||||
) -> Result<usize, FrameEncodeError> {
|
||||
if payload.len() > MAX_PAYLOAD_FIELD {
|
||||
return Err(FrameEncodeError::PayloadTooLarge);
|
||||
}
|
||||
let pre_cobs_len = FRAME_HEADER_SIZE + payload.len() + FRAME_TRAILER_SIZE;
|
||||
|
||||
let mut scratch = [0u8; MAX_PRE_COBS_FRAME];
|
||||
scratch[0] = type_id;
|
||||
scratch[1..3].copy_from_slice(&tag.to_le_bytes());
|
||||
scratch[3..3 + payload.len()].copy_from_slice(payload);
|
||||
let crc = crc16(&scratch[..FRAME_HEADER_SIZE + payload.len()]);
|
||||
scratch[FRAME_HEADER_SIZE + payload.len()..pre_cobs_len].copy_from_slice(&crc.to_le_bytes());
|
||||
|
||||
let mut cobs_scratch = [0u8; MAX_COBS_ENCODED];
|
||||
let encoded_len = ucobs::encode(&scratch[..pre_cobs_len], &mut cobs_scratch)
|
||||
.ok_or(FrameEncodeError::CobsEncode)?;
|
||||
let total = encoded_len + 1;
|
||||
if out.len() < total {
|
||||
return Err(FrameEncodeError::BufferTooSmall);
|
||||
}
|
||||
out[..encoded_len].copy_from_slice(&cobs_scratch[..encoded_len]);
|
||||
out[encoded_len] = 0x00;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Streaming accumulator for inbound bytes. Emits one `FrameResult` per
|
||||
/// `0x00`-delimited frame encountered in the feed.
|
||||
///
|
||||
/// Overflow policy: if accumulated bytes exceed the maximum COBS-encoded
|
||||
/// size without a delimiter, the buffer is reset and the next `0x00` is
|
||||
/// treated as the start of a fresh frame. A `FrameResult::Err` is NOT
|
||||
/// emitted for the dropped bytes — the bytes are simply never classified
|
||||
/// (the device or host will observe a dropped response and time out at
|
||||
/// the command level).
|
||||
pub struct FrameDecoder {
|
||||
buf: [u8; MAX_COBS_ENCODED],
|
||||
len: usize,
|
||||
overflowed: bool,
|
||||
}
|
||||
|
||||
impl FrameDecoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
buf: [0u8; MAX_COBS_ENCODED],
|
||||
len: 0,
|
||||
overflowed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard any partial frame currently buffered.
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
self.overflowed = false;
|
||||
}
|
||||
|
||||
/// Feed `data` into the accumulator. For every complete frame
|
||||
/// detected, call `on_frame` with the result.
|
||||
pub fn feed<F: FnMut(FrameResult<'_>)>(&mut self, data: &[u8], mut on_frame: F) {
|
||||
for &byte in data {
|
||||
if byte == 0x00 {
|
||||
if self.overflowed || self.len == 0 {
|
||||
// Overflow recovery or stray sentinel: drop silently.
|
||||
self.len = 0;
|
||||
self.overflowed = false;
|
||||
continue;
|
||||
}
|
||||
let mut decoded = [0u8; MAX_PRE_COBS_FRAME];
|
||||
match ucobs::decode(&self.buf[..self.len], &mut decoded) {
|
||||
Some(n) => {
|
||||
emit_decoded(&decoded[..n], &mut on_frame);
|
||||
}
|
||||
None => on_frame(FrameResult::Err(FrameDecodeError::Cobs)),
|
||||
}
|
||||
self.len = 0;
|
||||
continue;
|
||||
}
|
||||
if self.overflowed {
|
||||
// Still waiting for the next 0x00 to resync; drop this byte.
|
||||
continue;
|
||||
}
|
||||
if self.len < self.buf.len() {
|
||||
self.buf[self.len] = byte;
|
||||
self.len += 1;
|
||||
} else {
|
||||
self.overflowed = true;
|
||||
self.len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FrameDecoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_decoded<F: FnMut(FrameResult<'_>)>(decoded: &[u8], on_frame: &mut F) {
|
||||
if decoded.len() < FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE {
|
||||
on_frame(FrameResult::Err(FrameDecodeError::TooShort));
|
||||
return;
|
||||
}
|
||||
let crc_start = decoded.len() - FRAME_TRAILER_SIZE;
|
||||
let body = &decoded[..crc_start];
|
||||
let expected = crc16(body);
|
||||
let got = u16::from_le_bytes([decoded[crc_start], decoded[crc_start + 1]]);
|
||||
if expected != got {
|
||||
on_frame(FrameResult::Err(FrameDecodeError::Crc));
|
||||
return;
|
||||
}
|
||||
let type_id = body[0];
|
||||
let tag = u16::from_le_bytes([body[1], body[2]]);
|
||||
let payload = &body[FRAME_HEADER_SIZE..];
|
||||
on_frame(FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn max_cobs_encoded_is_wire_frame_minus_one() {
|
||||
// Kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` with `+1` / `/1`
|
||||
// mutants (285 and 284 respectively, both ≠ 283).
|
||||
assert_eq!(MAX_COBS_ENCODED, 283);
|
||||
assert_eq!(MAX_COBS_ENCODED, MAX_WIRE_FRAME - 1);
|
||||
}
|
||||
|
||||
fn roundtrip(type_id: u8, tag: u16, payload: &[u8]) {
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(type_id, tag, payload, &mut wire).unwrap();
|
||||
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut emitted: heapless::Vec<(u8, u16, heapless::Vec<u8, 300>), 4> = heapless::Vec::new();
|
||||
decoder.feed(&wire[..n], |res| match res {
|
||||
FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
let mut p = heapless::Vec::new();
|
||||
p.extend_from_slice(payload).unwrap();
|
||||
emitted.push((type_id, tag, p)).unwrap();
|
||||
}
|
||||
FrameResult::Err(e) => panic!("decode error: {:?}", e),
|
||||
});
|
||||
assert_eq!(emitted.len(), 1);
|
||||
assert_eq!(emitted[0].0, type_id);
|
||||
assert_eq!(emitted[0].1, tag);
|
||||
assert_eq!(emitted[0].2.as_slice(), payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload() {
|
||||
roundtrip(0x01, 0x0001, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_byte_payload() {
|
||||
roundtrip(0x04, 0x1234, &[0xAA]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_payload() {
|
||||
let big = [0x42u8; MAX_PAYLOAD_FIELD];
|
||||
roundtrip(0xC0, 0x0000, &big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_large_payload() {
|
||||
let big = [0u8; MAX_PAYLOAD_FIELD + 1];
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
assert!(matches!(
|
||||
encode_frame(0x01, 1, &big, &mut wire),
|
||||
Err(FrameEncodeError::PayloadTooLarge)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_output_buffer() {
|
||||
let mut tiny = [0u8; 4];
|
||||
assert!(matches!(
|
||||
encode_frame(0x01, 1, b"hello", &mut tiny),
|
||||
Err(FrameEncodeError::BufferTooSmall)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_crc_corruption() {
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x04, 0x0042, b"hello", &mut wire).unwrap();
|
||||
// Flip a non-sentinel byte mid-frame. This will corrupt the CRC.
|
||||
wire[4] ^= 0xFF;
|
||||
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut got_err = None;
|
||||
decoder.feed(&wire[..n], |res| {
|
||||
if let FrameResult::Err(e) = res {
|
||||
got_err = Some(e);
|
||||
}
|
||||
});
|
||||
assert!(matches!(
|
||||
got_err,
|
||||
Some(FrameDecodeError::Crc | FrameDecodeError::Cobs)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resync_after_garbage() {
|
||||
let mut decoder = FrameDecoder::new();
|
||||
|
||||
// Leading garbage with no 0x00 is accumulated silently.
|
||||
decoder.feed(&[0xAA, 0xBB, 0xCC], |res| {
|
||||
panic!("unexpected frame: {:?}", res);
|
||||
});
|
||||
|
||||
// A first 0x00 closes the bogus frame — expect a decode error.
|
||||
let mut errs = 0;
|
||||
let mut oks = 0;
|
||||
decoder.feed(&[0x00], |res| match res {
|
||||
FrameResult::Err(_) => errs += 1,
|
||||
FrameResult::Ok { .. } => oks += 1,
|
||||
});
|
||||
assert!(errs >= 1);
|
||||
assert_eq!(oks, 0);
|
||||
|
||||
// Now a clean frame decodes normally.
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x80, 0x0001, b"", &mut wire).unwrap();
|
||||
let mut got_type = None;
|
||||
decoder.feed(&wire[..n], |res| {
|
||||
if let FrameResult::Ok { type_id, .. } = res {
|
||||
got_type = Some(type_id);
|
||||
}
|
||||
});
|
||||
assert_eq!(got_type, Some(0x80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_resets_and_continues() {
|
||||
let mut decoder = FrameDecoder::new();
|
||||
// Overrun the buffer with a long non-zero run.
|
||||
let bomb = [0x55u8; MAX_COBS_ENCODED + 20];
|
||||
decoder.feed(&bomb, |res| panic!("unexpected frame: {:?}", res));
|
||||
|
||||
// The next 0x00 clears the overflow state without emitting a
|
||||
// spurious error (we never saw a sane frame candidate).
|
||||
decoder.feed(&[0x00], |res| panic!("unexpected frame: {:?}", res));
|
||||
|
||||
// A clean frame still decodes.
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x01, 0x0007, &[], &mut wire).unwrap();
|
||||
let mut got_type_tag = None;
|
||||
decoder.feed(&wire[..n], |res| {
|
||||
if let FrameResult::Ok { type_id, tag, .. } = res {
|
||||
got_type_tag = Some((type_id, tag));
|
||||
}
|
||||
});
|
||||
assert_eq!(got_type_tag, Some((0x01, 0x0007)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_frames_in_one_feed() {
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME * 3];
|
||||
let a = encode_frame(0x01, 1, &[], &mut wire).unwrap();
|
||||
let b = encode_frame(0x02, 2, &[], &mut wire[a..]).unwrap();
|
||||
let c = encode_frame(0x03, 3, &[], &mut wire[a + b..]).unwrap();
|
||||
let total = a + b + c;
|
||||
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut tags: heapless::Vec<u16, 4> = heapless::Vec::new();
|
||||
decoder.feed(&wire[..total], |res| {
|
||||
if let FrameResult::Ok { tag, .. } = res {
|
||||
tags.push(tag).unwrap();
|
||||
}
|
||||
});
|
||||
assert_eq!(tags.as_slice(), &[1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_short_decoded_frame() {
|
||||
// Craft a COBS-encoded 1-byte pre-COBS frame (below 5-byte min).
|
||||
// COBS of [0x42] is [0x02, 0x42].
|
||||
let wire = [0x02, 0x42, 0x00];
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut got_err = None;
|
||||
decoder.feed(&wire, |res| {
|
||||
if let FrameResult::Err(e) = res {
|
||||
got_err = Some(e);
|
||||
}
|
||||
});
|
||||
assert_eq!(got_err, Some(FrameDecodeError::TooShort));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_ping_example() {
|
||||
// PROTOCOL.md §C.2.1 — H→D PING, tag=0x0001.
|
||||
// Expected on-wire: 03 01 01 03 9D C8 00
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap();
|
||||
assert_eq!(&wire[..n], &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_ok_example() {
|
||||
// PROTOCOL.md §C.2.1 — D→H OK, tag=0x0001.
|
||||
// Expected on-wire: 03 80 01 03 F7 C4 00
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x80, 0x0001, &[], &mut wire).unwrap();
|
||||
assert_eq!(&wire[..n], &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//! COBS framing for the DongLoRa wire protocol.
|
||||
//!
|
||||
//! Every frame sent between host and firmware is a COBS-encoded
|
||||
//! [`Command`] or [`Response`] payload followed by a single `0x00`
|
||||
//! sentinel byte. [`CobsDecoder`] accumulates an inbound byte stream
|
||||
//! and yields parsed [`Command`]s; [`cobs_encode_response`] produces a
|
||||
//! wire-ready byte slice from a [`Response`].
|
||||
//!
|
||||
//! Kept tiny and alloc-free so both the firmware (no_std, bounded
|
||||
//! buffers) and host crates can share it.
|
||||
|
||||
use crate::{Command, Response};
|
||||
|
||||
/// Maximum COBS frame size (bytes).
|
||||
///
|
||||
/// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263
|
||||
/// raw bytes. COBS adds `ceil(263/254)+1 = 3` bytes. 266 < 512.
|
||||
pub const MAX_FRAME: usize = 512;
|
||||
|
||||
const _: () = assert!(
|
||||
MAX_FRAME >= crate::MAX_PAYLOAD + 64,
|
||||
"MAX_FRAME too small for max payload + COBS overhead"
|
||||
);
|
||||
|
||||
/// Decodes COBS-framed commands from a byte stream.
|
||||
pub struct CobsDecoder {
|
||||
buf: [u8; MAX_FRAME],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Default for CobsDecoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CobsDecoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
buf: [0u8; MAX_FRAME],
|
||||
len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the accumulator (discard partial frame).
|
||||
pub fn reset(&mut self) {
|
||||
self.len = 0;
|
||||
}
|
||||
|
||||
/// Feed a chunk of bytes, calling `on_command` for each decoded command.
|
||||
pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) {
|
||||
for &byte in data {
|
||||
if byte == 0x00 {
|
||||
// End of COBS frame — decode and dispatch
|
||||
if self.len > 0 {
|
||||
let mut decode_buf = [0u8; MAX_FRAME];
|
||||
if let Some(decoded_len) = ucobs::decode(&self.buf[..self.len], &mut decode_buf)
|
||||
{
|
||||
if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) {
|
||||
on_command(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.len = 0;
|
||||
} else if self.len < MAX_FRAME {
|
||||
self.buf[self.len] = byte;
|
||||
self.len += 1;
|
||||
} else {
|
||||
// Frame too large — discard
|
||||
self.len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel.
|
||||
///
|
||||
/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow.
|
||||
pub fn cobs_encode_response<'a>(
|
||||
response: Response,
|
||||
write_buf: &mut [u8; MAX_FRAME],
|
||||
encode_buf: &'a mut [u8; MAX_FRAME],
|
||||
) -> Option<&'a [u8]> {
|
||||
let raw_len = response.write_to(write_buf);
|
||||
let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0);
|
||||
if encoded_len < encode_buf.len() {
|
||||
encode_buf[encoded_len] = 0x00;
|
||||
Some(&encode_buf[..encoded_len + 1])
|
||||
} else {
|
||||
#[cfg(feature = "defmt")]
|
||||
defmt::warn!("COBS encode buffer overflow");
|
||||
None
|
||||
}
|
||||
}
|
||||
+323
@@ -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());
|
||||
}
|
||||
}
|
||||
+228
-695
@@ -1,718 +1,251 @@
|
||||
//! Wire protocol types and fixed-size little-endian serialization.
|
||||
//! DongLoRa Protocol v2 — wire types, framing, and codecs.
|
||||
//!
|
||||
//! Every integer is fixed-width LE. No varints, no zigzag.
|
||||
//! See `docs/PROTOCOL.md` for the complete specification.
|
||||
//! This crate is the normative Rust implementation of the protocol
|
||||
//! defined in `PROTOCOL.md`. It is `no_std` and alloc-free: every
|
||||
//! container is fixed-capacity (`heapless::Vec`) and every fallible
|
||||
//! operation returns a typed error — no panics, no unwraps, no
|
||||
//! `unsafe`.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! - [`frame`] — wire-level COBS + CRC framing. `encode_frame` turns a
|
||||
//! `(type_id, tag, payload)` tuple into a complete wire-ready byte
|
||||
//! slice; [`FrameDecoder`] accumulates inbound bytes and emits
|
||||
//! [`FrameResult`] values as frames arrive.
|
||||
//! - [`commands`] — host-to-device command enum and `type_id` constants.
|
||||
//! - [`events`] — device-to-host message enum (`OK` / `ERR` / `RX` /
|
||||
//! `TX_DONE`) and its sub-shapes.
|
||||
//! - [`modulation`] — per-modulation parameter structs for `SET_CONFIG`
|
||||
//! (LoRa, FSK/GFSK, LR-FHSS, FLRC).
|
||||
//! - [`info`] — `GET_INFO` response payload, including capability bits.
|
||||
//! - [`errors`] — wire-level error codes (`ErrorCode`).
|
||||
//! - [`chip_id`] — `RadioChipId` enum.
|
||||
//! - [`crc`] — CRC-16/CCITT-FALSE implementation.
|
||||
//!
|
||||
//! # Typical encode
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use donglora_protocol::{encode_frame, Command, commands::TYPE_PING, MAX_WIRE_FRAME};
|
||||
//!
|
||||
//! let cmd = Command::Ping;
|
||||
//! let mut payload_buf = [0u8; 4];
|
||||
//! let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
|
||||
//!
|
||||
//! let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
//! let wire_len = encode_frame(cmd.type_id(), 1, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
//! // wire[..wire_len] is ready to transmit.
|
||||
//! # let _ = (TYPE_PING, wire_len);
|
||||
//! ```
|
||||
//!
|
||||
//! # Typical decode
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use donglora_protocol::{Command, FrameDecoder, FrameResult};
|
||||
//!
|
||||
//! let mut decoder = FrameDecoder::new();
|
||||
//! let incoming_bytes: &[u8] = &[];
|
||||
//! decoder.feed(incoming_bytes, |res| match res {
|
||||
//! FrameResult::Ok { type_id, tag, payload } => {
|
||||
//! match Command::parse(type_id, payload) {
|
||||
//! Ok(cmd) => { /* dispatch cmd with tag */ }
|
||||
//! Err(_e) => { /* respond ERR(EUNKNOWN_CMD) with echoed tag */ }
|
||||
//! }
|
||||
//! }
|
||||
//! FrameResult::Err(_e) => { /* emit async ERR(EFRAME) */ }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
#![no_std]
|
||||
|
||||
pub mod framing;
|
||||
pub mod chip_id;
|
||||
pub mod commands;
|
||||
pub mod crc;
|
||||
pub mod errors;
|
||||
pub mod events;
|
||||
pub mod frame;
|
||||
pub mod info;
|
||||
pub mod modulation;
|
||||
|
||||
use heapless::Vec;
|
||||
// ── Public re-exports ───────────────────────────────────────────────
|
||||
|
||||
/// Maximum LoRa payload size in bytes.
|
||||
pub const MAX_PAYLOAD: usize = 256;
|
||||
pub use chip_id::RadioChipId;
|
||||
pub use commands::{Command, TxFlags};
|
||||
pub use errors::ErrorCode;
|
||||
pub use events::{
|
||||
DeviceMessage, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode,
|
||||
TxDonePayload, TxResult,
|
||||
};
|
||||
pub use frame::{FrameDecoder, FrameResult, encode_frame};
|
||||
pub use info::{Info, cap};
|
||||
pub use modulation::{
|
||||
FlrcBitrate, FlrcBt, FlrcCodingRate, FlrcConfig, FlrcPreambleLen, FskConfig, LoRaBandwidth,
|
||||
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, LrFhssBandwidth, LrFhssCodingRate, LrFhssConfig,
|
||||
LrFhssGrid, Modulation, ModulationId,
|
||||
};
|
||||
|
||||
/// RadioConfig wire size (fixed).
|
||||
pub const RADIO_CONFIG_SIZE: usize = 13;
|
||||
// ── Top-level size constants ────────────────────────────────────────
|
||||
|
||||
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
|
||||
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
|
||||
/// Maximum over-the-air packet payload (bytes). The spec allows this to
|
||||
/// be chip-dependent via `GET_INFO.max_payload_bytes`; 255 is the
|
||||
/// ceiling for all currently-supported Semtech silicon.
|
||||
pub const MAX_OTA_PAYLOAD: usize = 255;
|
||||
|
||||
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
|
||||
pub const PREAMBLE_DEFAULT: u16 = 0;
|
||||
/// Maximum payload-field size in any DongLoRa Protocol frame. This is the OTA limit
|
||||
/// plus the 20-byte `RX` metadata prefix: type + tag + this + crc gives
|
||||
/// the overall frame budget.
|
||||
pub const MAX_PAYLOAD_FIELD: usize = MAX_OTA_PAYLOAD + 20;
|
||||
|
||||
/// LoRa signal bandwidth.
|
||||
/// Byte count of the pre-CRC, pre-COBS frame header: `type(1) + tag(2)`.
|
||||
pub const FRAME_HEADER_SIZE: usize = 3;
|
||||
|
||||
/// Byte count of the frame trailer (CRC16).
|
||||
pub const FRAME_TRAILER_SIZE: usize = 2;
|
||||
|
||||
/// Maximum pre-COBS frame size: header + max payload + trailer.
|
||||
pub const MAX_PRE_COBS_FRAME: usize = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + FRAME_TRAILER_SIZE;
|
||||
|
||||
/// COBS encoding overhead upper bound: `ceil(n/254) + 1` bytes added
|
||||
/// around a run of n bytes. For `MAX_PRE_COBS_FRAME = 280` this is 3.
|
||||
pub const MAX_COBS_OVERHEAD: usize = 3;
|
||||
|
||||
/// Maximum on-wire frame size: pre-COBS size + COBS overhead + 1 for the
|
||||
/// trailing `0x00` sentinel. Buffer sizes of this value (or more)
|
||||
/// guarantee no encode-side overflow across any valid frame.
|
||||
pub const MAX_WIRE_FRAME: usize = MAX_PRE_COBS_FRAME + MAX_COBS_OVERHEAD + 1;
|
||||
|
||||
/// Maximum FSK/GFSK sync-word length (`PROTOCOL.md §10.2`).
|
||||
pub const MAX_SYNC_WORD_LEN: usize = 8;
|
||||
|
||||
/// Maximum `GET_INFO.mcu_uid` length (`PROTOCOL.md §6.2`).
|
||||
pub const MAX_MCU_UID_LEN: usize = 32;
|
||||
|
||||
/// Maximum `GET_INFO.radio_uid` length (`PROTOCOL.md §6.2`).
|
||||
pub const MAX_RADIO_UID_LEN: usize = 16;
|
||||
|
||||
/// Maximum `OK` payload size across all shapes. `Info` is the worst
|
||||
/// case: `GET_INFO`'s 37-byte fixed prefix plus both maximum UIDs
|
||||
/// (32 + 16).
|
||||
pub const MAX_OK_PAYLOAD: usize = 37 + MAX_MCU_UID_LEN + MAX_RADIO_UID_LEN;
|
||||
|
||||
/// Maximum `OK` payload size for `SET_CONFIG`: `result(1) + owner(1) +
|
||||
/// modulation_id(1) + max params(24 for FSK with 8-byte sync word)`.
|
||||
pub const MAX_SETCONFIG_OK_PAYLOAD: usize = 2 + 1 + 24;
|
||||
|
||||
// ── Error types ─────────────────────────────────────────────────────
|
||||
|
||||
/// Errors returned from `encode_frame`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum Bandwidth {
|
||||
Khz7 = 0,
|
||||
Khz10 = 1,
|
||||
Khz15 = 2,
|
||||
Khz20 = 3,
|
||||
Khz31 = 4,
|
||||
Khz41 = 5,
|
||||
Khz62 = 6,
|
||||
Khz125 = 7,
|
||||
Khz250 = 8,
|
||||
Khz500 = 9,
|
||||
pub enum FrameEncodeError {
|
||||
/// Payload exceeds `MAX_PAYLOAD_FIELD`.
|
||||
PayloadTooLarge,
|
||||
/// Output buffer too small for the encoded frame.
|
||||
BufferTooSmall,
|
||||
/// COBS encoder rejected the input (should be unreachable given
|
||||
/// size checks, but surfaced as its own variant for completeness).
|
||||
CobsEncode,
|
||||
}
|
||||
|
||||
impl Bandwidth {
|
||||
fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Khz7),
|
||||
1 => Some(Self::Khz10),
|
||||
2 => Some(Self::Khz15),
|
||||
3 => Some(Self::Khz20),
|
||||
4 => Some(Self::Khz31),
|
||||
5 => Some(Self::Khz41),
|
||||
6 => Some(Self::Khz62),
|
||||
7 => Some(Self::Khz125),
|
||||
8 => Some(Self::Khz250),
|
||||
9 => Some(Self::Khz500),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete LoRa radio configuration.
|
||||
///
|
||||
/// Wire layout (13 bytes, all little-endian):
|
||||
/// ```text
|
||||
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
|
||||
/// ```
|
||||
/// Errors returned from the frame decoder.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct RadioConfig {
|
||||
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
|
||||
pub freq_hz: u32,
|
||||
pub bw: Bandwidth,
|
||||
/// Spreading factor (5-12).
|
||||
pub sf: u8,
|
||||
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
|
||||
pub cr: u8,
|
||||
pub sync_word: u16,
|
||||
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
|
||||
/// board's maximum.
|
||||
pub tx_power_dbm: i8,
|
||||
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for
|
||||
/// the firmware default (16 symbols). Valid range: 6–65535.
|
||||
pub preamble_len: u16,
|
||||
/// Channel Activity Detection (listen-before-talk) before TX.
|
||||
/// 0 = disabled, non-zero = enabled. Default: 1 (enabled).
|
||||
pub cad: u8,
|
||||
pub enum FrameDecodeError {
|
||||
/// COBS decode failed.
|
||||
Cobs,
|
||||
/// CRC check failed.
|
||||
Crc,
|
||||
/// Decoded frame shorter than the minimum `type + tag + crc`.
|
||||
TooShort,
|
||||
}
|
||||
|
||||
impl RadioConfig {
|
||||
/// Validate fields against hardware limits.
|
||||
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
|
||||
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
|
||||
return Err("frequency out of range (150-960 MHz)");
|
||||
}
|
||||
if !(5..=12).contains(&self.sf) {
|
||||
return Err("spreading factor out of range (5-12)");
|
||||
}
|
||||
if !(5..=8).contains(&self.cr) {
|
||||
return Err("coding rate out of range (5-8)");
|
||||
}
|
||||
if self.tx_power_dbm != TX_POWER_MAX
|
||||
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
|
||||
{
|
||||
return Err("TX power out of range for this board");
|
||||
}
|
||||
if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 {
|
||||
return Err("preamble length too short (min 6)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve sentinel values to concrete defaults.
|
||||
pub fn resolve(mut self, power_range: (i8, i8)) -> Self {
|
||||
if self.tx_power_dbm == TX_POWER_MAX {
|
||||
self.tx_power_dbm = power_range.1;
|
||||
}
|
||||
if self.preamble_len == PREAMBLE_DEFAULT {
|
||||
self.preamble_len = 16;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether CAD (listen-before-talk) is enabled.
|
||||
pub fn cad_enabled(&self) -> bool {
|
||||
self.cad != 0
|
||||
}
|
||||
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13).
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bw as u8;
|
||||
buf[5] = self.sf;
|
||||
buf[6] = self.cr;
|
||||
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[9] = self.tx_power_dbm as u8;
|
||||
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[12] = self.cad;
|
||||
RADIO_CONFIG_SIZE
|
||||
}
|
||||
|
||||
/// Deserialize from fixed-size LE bytes.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
if buf.len() < RADIO_CONFIG_SIZE {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
|
||||
bw: Bandwidth::from_u8(buf[4])?,
|
||||
sf: buf[5],
|
||||
cr: buf[6],
|
||||
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
|
||||
tx_power_dbm: buf[9] as i8,
|
||||
preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?),
|
||||
cad: buf[12],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Host → firmware commands.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Ping,
|
||||
GetConfig,
|
||||
SetConfig(RadioConfig),
|
||||
StartRx,
|
||||
StopRx,
|
||||
Transmit {
|
||||
config: Option<RadioConfig>,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
DisplayOn,
|
||||
DisplayOff,
|
||||
GetMac,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Deserialize from a COBS-decoded frame.
|
||||
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
|
||||
let tag = *buf.first()?;
|
||||
let rest = &buf[1..];
|
||||
match tag {
|
||||
0 => Some(Self::Ping),
|
||||
1 => Some(Self::GetConfig),
|
||||
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
|
||||
3 => Some(Self::StartRx),
|
||||
4 => Some(Self::StopRx),
|
||||
5 => {
|
||||
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (config, pos) = if rest[0] == 0 {
|
||||
(None, 1)
|
||||
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
|
||||
(
|
||||
Some(RadioConfig::from_bytes(&rest[1..])?),
|
||||
1 + RADIO_CONFIG_SIZE,
|
||||
)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.len() < pos + 2 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
|
||||
let data_start = pos + 2;
|
||||
if rest.len() < data_start + len {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
|
||||
Some(Self::Transmit { config, payload })
|
||||
}
|
||||
6 => Some(Self::DisplayOn),
|
||||
7 => Some(Self::DisplayOff),
|
||||
8 => Some(Self::GetMac),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Firmware → host responses.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
Config(RadioConfig),
|
||||
RxPacket {
|
||||
rssi: i16,
|
||||
snr: i16,
|
||||
payload: Vec<u8, MAX_PAYLOAD>,
|
||||
},
|
||||
TxDone,
|
||||
Ok,
|
||||
Error(ErrorCode),
|
||||
MacAddress([u8; 6]),
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
|
||||
pub fn write_to(self, buf: &mut [u8]) -> usize {
|
||||
match self {
|
||||
Self::Pong => {
|
||||
buf[0] = 0;
|
||||
1
|
||||
}
|
||||
Self::Config(cfg) => {
|
||||
buf[0] = 1;
|
||||
1 + cfg.write_to(&mut buf[1..])
|
||||
}
|
||||
Self::RxPacket { rssi, snr, payload } => {
|
||||
buf[0] = 2;
|
||||
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
|
||||
buf[3..5].copy_from_slice(&snr.to_le_bytes());
|
||||
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[7..7 + payload.len()].copy_from_slice(&payload);
|
||||
7 + payload.len()
|
||||
}
|
||||
Self::TxDone => {
|
||||
buf[0] = 3;
|
||||
1
|
||||
}
|
||||
Self::Ok => {
|
||||
buf[0] = 4;
|
||||
1
|
||||
}
|
||||
Self::Error(code) => {
|
||||
buf[0] = 5;
|
||||
buf[1] = code as u8;
|
||||
2
|
||||
}
|
||||
Self::MacAddress(mac) => {
|
||||
buf[0] = 6;
|
||||
buf[1..7].copy_from_slice(&mac);
|
||||
7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error codes reported to the host.
|
||||
/// Errors from encoding a `Command` payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum ErrorCode {
|
||||
InvalidConfig = 0,
|
||||
RadioBusy = 1,
|
||||
TxTimeout = 2,
|
||||
// 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets)
|
||||
NotConfigured = 4,
|
||||
NoDisplay = 5,
|
||||
pub enum CommandEncodeError {
|
||||
/// Output buffer too small.
|
||||
BufferTooSmall,
|
||||
/// TX with zero-byte payload (spec rejects with `ERR(ELENGTH)`).
|
||||
EmptyTxPayload,
|
||||
/// TX payload exceeds `MAX_OTA_PAYLOAD`.
|
||||
PayloadTooLarge,
|
||||
/// FSK sync_word_len > `MAX_SYNC_WORD_LEN`.
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config() -> RadioConfig {
|
||||
RadioConfig {
|
||||
freq_hz: 915_000_000,
|
||||
bw: Bandwidth::Khz125,
|
||||
sf: 7,
|
||||
cr: 5,
|
||||
sync_word: 0x3444,
|
||||
tx_power_dbm: 22,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// ── RadioConfig roundtrip ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
let n = cfg.write_to(&mut buf);
|
||||
assert_eq!(n, RADIO_CONFIG_SIZE);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_all_bandwidths() {
|
||||
for bw_val in 0u8..=9 {
|
||||
let bw = Bandwidth::from_u8(bw_val).unwrap();
|
||||
let cfg = RadioConfig {
|
||||
freq_hz: 433_000_000,
|
||||
bw,
|
||||
sf: 12,
|
||||
cr: 8,
|
||||
sync_word: 0x1234,
|
||||
tx_power_dbm: -9,
|
||||
preamble_len: 16,
|
||||
cad: 1,
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_roundtrip_negative_power() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
cfg.write_to(&mut buf);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_short_buffer() {
|
||||
let buf = [0u8; RADIO_CONFIG_SIZE - 1];
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_from_empty_buffer() {
|
||||
assert!(RadioConfig::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_config_invalid_bandwidth() {
|
||||
let mut buf = [0u8; RADIO_CONFIG_SIZE];
|
||||
make_config().write_to(&mut buf);
|
||||
buf[4] = 255; // invalid bandwidth
|
||||
assert!(RadioConfig::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
// ── RadioConfig::validate ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_freq_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
let mut cfg = RadioConfig {
|
||||
freq_hz: 150_000_000,
|
||||
..base
|
||||
};
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 960_000_000;
|
||||
assert!(cfg.validate(power_range).is_ok());
|
||||
|
||||
cfg.freq_hz = 149_999_999;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
|
||||
cfg.freq_hz = 960_000_001;
|
||||
assert!(cfg.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_sf_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for sf in 5..=12 {
|
||||
assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(
|
||||
RadioConfig { sf: 13, ..base }
|
||||
.validate(power_range)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cr_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
for cr in 5..=8 {
|
||||
assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok());
|
||||
}
|
||||
assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err());
|
||||
assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tx_power_out_of_range() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 23,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: -10,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_default_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert!(cfg.validate((-9, 22)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_preamble_boundaries() {
|
||||
let power_range = (-9, 22);
|
||||
let base = make_config();
|
||||
|
||||
assert!(
|
||||
RadioConfig {
|
||||
preamble_len: 6,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
RadioConfig {
|
||||
preamble_len: 5,
|
||||
..base
|
||||
}
|
||||
.validate(power_range)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── RadioConfig::resolve ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_power_max_sentinel() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: TX_POWER_MAX,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_power_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
tx_power_dbm: 10,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_default() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: PREAMBLE_DEFAULT,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preamble_explicit_unchanged() {
|
||||
let cfg = RadioConfig {
|
||||
preamble_len: 32,
|
||||
..make_config()
|
||||
};
|
||||
assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32);
|
||||
}
|
||||
|
||||
// ── Command::from_bytes ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn command_ping() {
|
||||
assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_get_config() {
|
||||
assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_set_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
buf[0] = 2;
|
||||
cfg.write_to(&mut buf[1..]);
|
||||
assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_start_stop_rx() {
|
||||
assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx));
|
||||
assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_no_config() {
|
||||
let payload = b"hello";
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[4..9].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..9]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert!(config.is_none());
|
||||
assert_eq!(p.as_slice(), b"hello");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_with_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 1; // has_config = true
|
||||
cfg.write_to(&mut buf[2..]);
|
||||
let payload = b"test";
|
||||
let pos = 2 + RADIO_CONFIG_SIZE;
|
||||
buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
buf[pos + 2..pos + 6].copy_from_slice(payload);
|
||||
|
||||
match Command::from_bytes(&buf[..pos + 6]).unwrap() {
|
||||
Command::Transmit { config, payload: p } => {
|
||||
assert_eq!(config, Some(cfg));
|
||||
assert_eq!(p.as_slice(), b"test");
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_empty_payload() {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[0] = 5; // tag
|
||||
buf[1] = 0; // has_config = false
|
||||
buf[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||
|
||||
match Command::from_bytes(&buf).unwrap() {
|
||||
Command::Transmit { config, payload } => {
|
||||
assert!(config.is_none());
|
||||
assert!(payload.is_empty());
|
||||
}
|
||||
_ => panic!("expected Transmit"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_transmit_truncated() {
|
||||
// Tag only — missing has_config byte
|
||||
assert!(Command::from_bytes(&[5]).is_none());
|
||||
|
||||
// has_config=1 but no config bytes
|
||||
assert!(Command::from_bytes(&[5, 1]).is_none());
|
||||
|
||||
// has_config=0 but no length bytes
|
||||
assert!(Command::from_bytes(&[5, 0]).is_none());
|
||||
|
||||
// has_config=0, length says 5 but only 2 bytes of payload
|
||||
let mut buf = [0u8; 6];
|
||||
buf[0] = 5;
|
||||
buf[1] = 0;
|
||||
buf[2..4].copy_from_slice(&5u16.to_le_bytes());
|
||||
buf[4] = 0xAA;
|
||||
buf[5] = 0xBB;
|
||||
assert!(Command::from_bytes(&buf).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_display_and_mac() {
|
||||
assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn));
|
||||
assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff));
|
||||
assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_invalid_tag() {
|
||||
assert!(Command::from_bytes(&[9]).is_none());
|
||||
assert!(Command::from_bytes(&[255]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_empty_buffer() {
|
||||
assert!(Command::from_bytes(&[]).is_none());
|
||||
}
|
||||
|
||||
// ── Response::write_to ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn response_pong() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Pong.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_config() {
|
||||
let cfg = make_config();
|
||||
let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE];
|
||||
let n = Response::Config(cfg).write_to(&mut buf);
|
||||
assert_eq!(n, 1 + RADIO_CONFIG_SIZE);
|
||||
assert_eq!(buf[0], 1);
|
||||
assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_rx_packet() {
|
||||
let mut payload = Vec::new();
|
||||
let _ = payload.extend_from_slice(b"data");
|
||||
let mut buf = [0u8; 64];
|
||||
let n = Response::RxPacket {
|
||||
rssi: -80,
|
||||
snr: 10,
|
||||
payload,
|
||||
}
|
||||
.write_to(&mut buf);
|
||||
assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4)
|
||||
assert_eq!(buf[0], 2);
|
||||
assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80);
|
||||
assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10);
|
||||
assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4);
|
||||
assert_eq!(&buf[7..11], b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_tx_done() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::TxDone.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_ok() {
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(Response::Ok.write_to(&mut buf), 1);
|
||||
assert_eq!(buf[0], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_error_codes() {
|
||||
let mut buf = [0u8; 2];
|
||||
for (code, val) in [
|
||||
(ErrorCode::InvalidConfig, 0),
|
||||
(ErrorCode::RadioBusy, 1),
|
||||
(ErrorCode::TxTimeout, 2),
|
||||
(ErrorCode::NotConfigured, 4),
|
||||
(ErrorCode::NoDisplay, 5),
|
||||
] {
|
||||
let n = Response::Error(code).write_to(&mut buf);
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(buf[0], 5);
|
||||
assert_eq!(buf[1], val);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_mac_address() {
|
||||
let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
||||
let mut buf = [0u8; 7];
|
||||
let n = Response::MacAddress(mac).write_to(&mut buf);
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(buf[0], 6);
|
||||
assert_eq!(&buf[1..7], &mac);
|
||||
}
|
||||
/// Errors from parsing a `Command` payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum CommandParseError {
|
||||
/// `type_id` is not a defined command.
|
||||
UnknownType,
|
||||
/// Payload length is wrong for the command or modulation.
|
||||
WrongLength,
|
||||
/// An enum field carries a value not in the defined table.
|
||||
InvalidField,
|
||||
/// A reserved bit (`TX` flag bits 1–7) was set.
|
||||
ReservedBitSet,
|
||||
/// `SET_CONFIG`'s `modulation_id` is not assigned.
|
||||
UnknownModulation,
|
||||
}
|
||||
|
||||
/// Errors from encoding a device-message payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DeviceMessageEncodeError {
|
||||
BufferTooSmall,
|
||||
/// Payload exceeds an enum's fixed size budget.
|
||||
PayloadTooLarge,
|
||||
InvalidField,
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
/// Errors from parsing a device-message payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum DeviceMessageParseError {
|
||||
UnknownType,
|
||||
/// Payload length wrong for the message shape.
|
||||
WrongLength,
|
||||
/// Payload shorter than the fixed prefix required by the message.
|
||||
TooShort,
|
||||
/// An enum field carries a value not in the defined table.
|
||||
InvalidField,
|
||||
/// Parser needs the originating command's `type_id` for an `OK`
|
||||
/// frame but none was supplied.
|
||||
MissingContext,
|
||||
/// `OK` frame for an unknown originating command type, or unknown
|
||||
/// `modulation_id` inside a `SET_CONFIG` result.
|
||||
UnknownContext,
|
||||
}
|
||||
|
||||
/// Errors from modulation param encoding.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ModulationEncodeError {
|
||||
BufferTooSmall,
|
||||
SyncWordTooLong,
|
||||
}
|
||||
|
||||
/// Errors from modulation param parsing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum ModulationParseError {
|
||||
/// Declared length did not match the expected total for this
|
||||
/// modulation shape.
|
||||
WrongLength { expected: usize, actual: usize },
|
||||
/// Payload shorter than the modulation's fixed prefix.
|
||||
TooShort,
|
||||
/// An enum field or reserved byte carried an invalid value.
|
||||
InvalidField,
|
||||
/// `modulation_id` byte does not match any defined modulation.
|
||||
UnknownModulation,
|
||||
}
|
||||
|
||||
/// Errors from `Info` encode/decode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum InfoParseError {
|
||||
TooShort,
|
||||
BufferTooSmall,
|
||||
InvalidField,
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
//! Per-modulation parameter structs for `SET_CONFIG` (`PROTOCOL.md §10`).
|
||||
//!
|
||||
//! `SET_CONFIG`'s payload is a one-byte modulation identifier followed
|
||||
//! by a modulation-specific struct. Each struct is fixed-layout
|
||||
//! little-endian; FSK/GFSK alone carries a variable-length sync word at
|
||||
//! its tail.
|
||||
//!
|
||||
//! The crate models the whole universe of DongLoRa Protocol modulations even on
|
||||
//! devices that physically can't drive them. Firmware ultimately returns
|
||||
//! `EMODULATION` for anything the chip doesn't support, but the wire
|
||||
//! codec stays uniform across all 1.0-compliant implementations.
|
||||
|
||||
use crate::{MAX_SYNC_WORD_LEN, ModulationEncodeError, ModulationParseError};
|
||||
|
||||
// ── Modulation identifier ───────────────────────────────────────────
|
||||
|
||||
/// One-byte modulation selector (`PROTOCOL.md §10`). Prefixes every
|
||||
/// `SET_CONFIG` payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum ModulationId {
|
||||
LoRa = 0x01,
|
||||
FskGfsk = 0x02,
|
||||
LrFhss = 0x03,
|
||||
Flrc = 0x04,
|
||||
}
|
||||
|
||||
impl ModulationId {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0x01 => Self::LoRa,
|
||||
0x02 => Self::FskGfsk,
|
||||
0x03 => Self::LrFhss,
|
||||
0x04 => Self::Flrc,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── LoRa enums ──────────────────────────────────────────────────────
|
||||
|
||||
/// LoRa signal bandwidth enum (`PROTOCOL.md §10.1`). The small integer
|
||||
/// is what goes on the wire, NOT the kHz value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LoRaBandwidth {
|
||||
/// 7.81 kHz — sub-GHz chips only.
|
||||
Khz7 = 0,
|
||||
/// 10.42 kHz — sub-GHz chips only.
|
||||
Khz10 = 1,
|
||||
/// 15.63 kHz — sub-GHz chips only.
|
||||
Khz15 = 2,
|
||||
/// 20.83 kHz — sub-GHz chips only.
|
||||
Khz20 = 3,
|
||||
/// 31.25 kHz — sub-GHz chips only.
|
||||
Khz31 = 4,
|
||||
/// 41.67 kHz — sub-GHz chips only.
|
||||
Khz41 = 5,
|
||||
/// 62.5 kHz — sub-GHz chips only.
|
||||
Khz62 = 6,
|
||||
/// 125 kHz — all LoRa chips.
|
||||
Khz125 = 7,
|
||||
/// 250 kHz — all LoRa chips.
|
||||
Khz250 = 8,
|
||||
/// 500 kHz — all LoRa chips.
|
||||
Khz500 = 9,
|
||||
/// 200 kHz — SX128x (2.4 GHz) only.
|
||||
Khz200 = 10,
|
||||
/// 400 kHz — SX128x (2.4 GHz) only.
|
||||
Khz400 = 11,
|
||||
/// 800 kHz — SX128x (2.4 GHz) only.
|
||||
Khz800 = 12,
|
||||
/// 1600 kHz — SX128x (2.4 GHz) only.
|
||||
Khz1600 = 13,
|
||||
}
|
||||
|
||||
impl LoRaBandwidth {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Khz7,
|
||||
1 => Self::Khz10,
|
||||
2 => Self::Khz15,
|
||||
3 => Self::Khz20,
|
||||
4 => Self::Khz31,
|
||||
5 => Self::Khz41,
|
||||
6 => Self::Khz62,
|
||||
7 => Self::Khz125,
|
||||
8 => Self::Khz250,
|
||||
9 => Self::Khz500,
|
||||
10 => Self::Khz200,
|
||||
11 => Self::Khz400,
|
||||
12 => Self::Khz800,
|
||||
13 => Self::Khz1600,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Nominal bandwidth in Hz. Useful for the auto-LDRO rule
|
||||
/// `(2^SF)/BW_Hz > 16 ms` and for display.
|
||||
pub const fn as_hz(self) -> u32 {
|
||||
match self {
|
||||
Self::Khz7 => 7_810,
|
||||
Self::Khz10 => 10_420,
|
||||
Self::Khz15 => 15_630,
|
||||
Self::Khz20 => 20_830,
|
||||
Self::Khz31 => 31_250,
|
||||
Self::Khz41 => 41_670,
|
||||
Self::Khz62 => 62_500,
|
||||
Self::Khz125 => 125_000,
|
||||
Self::Khz250 => 250_000,
|
||||
Self::Khz500 => 500_000,
|
||||
Self::Khz200 => 200_000,
|
||||
Self::Khz400 => 400_000,
|
||||
Self::Khz800 => 800_000,
|
||||
Self::Khz1600 => 1_600_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LoRa coding rate enum (`PROTOCOL.md §10.1`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LoRaCodingRate {
|
||||
/// 4/5.
|
||||
Cr4_5 = 0,
|
||||
/// 4/6.
|
||||
Cr4_6 = 1,
|
||||
/// 4/7.
|
||||
Cr4_7 = 2,
|
||||
/// 4/8.
|
||||
Cr4_8 = 3,
|
||||
}
|
||||
|
||||
impl LoRaCodingRate {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Cr4_5,
|
||||
1 => Self::Cr4_6,
|
||||
2 => Self::Cr4_7,
|
||||
3 => Self::Cr4_8,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// LoRa header mode (`PROTOCOL.md §10.1`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LoRaHeaderMode {
|
||||
Explicit = 0,
|
||||
Implicit = 1,
|
||||
}
|
||||
|
||||
impl LoRaHeaderMode {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Explicit,
|
||||
1 => Self::Implicit,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// LoRa configuration payload (15 bytes after `modulation_id`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct LoRaConfig {
|
||||
pub freq_hz: u32,
|
||||
/// Spreading factor 5–12 (chip-dependent: SX127x is 6–12).
|
||||
pub sf: u8,
|
||||
pub bw: LoRaBandwidth,
|
||||
pub cr: LoRaCodingRate,
|
||||
pub preamble_len: u16,
|
||||
pub sync_word: u16,
|
||||
pub tx_power_dbm: i8,
|
||||
pub header_mode: LoRaHeaderMode,
|
||||
/// Payload CRC: `false` disabled, `true` enabled.
|
||||
pub payload_crc: bool,
|
||||
/// IQ inversion: `false` normal, `true` inverted.
|
||||
pub iq_invert: bool,
|
||||
}
|
||||
|
||||
impl LoRaConfig {
|
||||
/// Fixed wire size of the per-modulation params.
|
||||
pub const WIRE_SIZE: usize = 15;
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
|
||||
if buf.len() < Self::WIRE_SIZE {
|
||||
return Err(ModulationEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.sf;
|
||||
buf[5] = self.bw.as_u8();
|
||||
buf[6] = self.cr.as_u8();
|
||||
buf[7..9].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[9..11].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[11] = self.tx_power_dbm as u8;
|
||||
buf[12] = self.header_mode.as_u8();
|
||||
buf[13] = u8::from(self.payload_crc);
|
||||
buf[14] = u8::from(self.iq_invert);
|
||||
Ok(Self::WIRE_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
|
||||
if buf.len() != Self::WIRE_SIZE {
|
||||
return Err(ModulationParseError::WrongLength {
|
||||
expected: Self::WIRE_SIZE,
|
||||
actual: buf.len(),
|
||||
});
|
||||
}
|
||||
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
let sf = buf[4];
|
||||
let bw = LoRaBandwidth::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let cr = LoRaCodingRate::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let preamble_len = u16::from_le_bytes([buf[7], buf[8]]);
|
||||
let sync_word = u16::from_le_bytes([buf[9], buf[10]]);
|
||||
let tx_power_dbm = buf[11] as i8;
|
||||
let header_mode =
|
||||
LoRaHeaderMode::from_u8(buf[12]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let payload_crc = match buf[13] {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(ModulationParseError::InvalidField),
|
||||
};
|
||||
let iq_invert = match buf[14] {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(ModulationParseError::InvalidField),
|
||||
};
|
||||
Ok(Self {
|
||||
freq_hz,
|
||||
sf,
|
||||
bw,
|
||||
cr,
|
||||
preamble_len,
|
||||
sync_word,
|
||||
tx_power_dbm,
|
||||
header_mode,
|
||||
payload_crc,
|
||||
iq_invert,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FSK / GFSK ──────────────────────────────────────────────────────
|
||||
|
||||
/// FSK / GFSK configuration payload (`PROTOCOL.md §10.2`).
|
||||
///
|
||||
/// Wire size is `16 + sync_word_len` bytes. `sync_word_len` is 0–8 and
|
||||
/// indicates how many leading bytes of the `sync_word` array are
|
||||
/// transmitted (MSB-first, per FSK convention). Extra trailing bytes
|
||||
/// are ignored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct FskConfig {
|
||||
pub freq_hz: u32,
|
||||
pub bitrate_bps: u32,
|
||||
pub freq_dev_hz: u32,
|
||||
/// Chip-specific RX-filter bandwidth index.
|
||||
pub rx_bw: u8,
|
||||
pub preamble_len: u16,
|
||||
pub sync_word_len: u8,
|
||||
pub sync_word: [u8; MAX_SYNC_WORD_LEN],
|
||||
}
|
||||
|
||||
impl FskConfig {
|
||||
/// Fixed part of the payload preceding the variable-length sync word.
|
||||
pub const FIXED_WIRE_SIZE: usize = 16;
|
||||
|
||||
/// Total wire size for the given `sync_word_len`. `sync_word_len`
|
||||
/// larger than 8 is rejected by `encode`.
|
||||
pub const fn wire_size_for(sync_word_len: u8) -> usize {
|
||||
Self::FIXED_WIRE_SIZE + sync_word_len as usize
|
||||
}
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
|
||||
if self.sync_word_len as usize > MAX_SYNC_WORD_LEN {
|
||||
return Err(ModulationEncodeError::SyncWordTooLong);
|
||||
}
|
||||
let total = Self::wire_size_for(self.sync_word_len);
|
||||
if buf.len() < total {
|
||||
return Err(ModulationEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&self.bitrate_bps.to_le_bytes());
|
||||
buf[8..12].copy_from_slice(&self.freq_dev_hz.to_le_bytes());
|
||||
buf[12] = self.rx_bw;
|
||||
buf[13..15].copy_from_slice(&self.preamble_len.to_le_bytes());
|
||||
buf[15] = self.sync_word_len;
|
||||
let n = self.sync_word_len as usize;
|
||||
buf[16..16 + n].copy_from_slice(&self.sync_word[..n]);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
|
||||
if buf.len() < Self::FIXED_WIRE_SIZE {
|
||||
return Err(ModulationParseError::TooShort);
|
||||
}
|
||||
let sync_word_len = buf[15];
|
||||
if sync_word_len as usize > MAX_SYNC_WORD_LEN {
|
||||
return Err(ModulationParseError::InvalidField);
|
||||
}
|
||||
let expected = Self::wire_size_for(sync_word_len);
|
||||
if buf.len() != expected {
|
||||
return Err(ModulationParseError::WrongLength {
|
||||
expected,
|
||||
actual: buf.len(),
|
||||
});
|
||||
}
|
||||
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
let bitrate_bps = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
||||
let freq_dev_hz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let rx_bw = buf[12];
|
||||
let preamble_len = u16::from_le_bytes([buf[13], buf[14]]);
|
||||
let mut sync_word = [0u8; MAX_SYNC_WORD_LEN];
|
||||
let n = sync_word_len as usize;
|
||||
sync_word[..n].copy_from_slice(&buf[16..16 + n]);
|
||||
Ok(Self {
|
||||
freq_hz,
|
||||
bitrate_bps,
|
||||
freq_dev_hz,
|
||||
rx_bw,
|
||||
preamble_len,
|
||||
sync_word_len,
|
||||
sync_word,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── LR-FHSS ─────────────────────────────────────────────────────────
|
||||
|
||||
/// LR-FHSS occupied-bandwidth enum (`PROTOCOL.md §10.3`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LrFhssBandwidth {
|
||||
/// 39.06 kHz.
|
||||
Khz39 = 0,
|
||||
/// 85.94 kHz.
|
||||
Khz85 = 1,
|
||||
/// 136.72 kHz.
|
||||
Khz136 = 2,
|
||||
/// 183.59 kHz.
|
||||
Khz183 = 3,
|
||||
/// 335.94 kHz.
|
||||
Khz335 = 4,
|
||||
/// 386.72 kHz.
|
||||
Khz386 = 5,
|
||||
/// 722.66 kHz.
|
||||
Khz722 = 6,
|
||||
/// 1523.44 kHz.
|
||||
Khz1523 = 7,
|
||||
}
|
||||
|
||||
impl LrFhssBandwidth {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Khz39,
|
||||
1 => Self::Khz85,
|
||||
2 => Self::Khz136,
|
||||
3 => Self::Khz183,
|
||||
4 => Self::Khz335,
|
||||
5 => Self::Khz386,
|
||||
6 => Self::Khz722,
|
||||
7 => Self::Khz1523,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// LR-FHSS coding rate enum (`PROTOCOL.md §10.3`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LrFhssCodingRate {
|
||||
/// 5/6.
|
||||
Cr5_6 = 0,
|
||||
/// 2/3.
|
||||
Cr2_3 = 1,
|
||||
/// 1/2.
|
||||
Cr1_2 = 2,
|
||||
/// 1/3.
|
||||
Cr1_3 = 3,
|
||||
}
|
||||
|
||||
impl LrFhssCodingRate {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Cr5_6,
|
||||
1 => Self::Cr2_3,
|
||||
2 => Self::Cr1_2,
|
||||
3 => Self::Cr1_3,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// LR-FHSS grid selection (`PROTOCOL.md §10.3`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum LrFhssGrid {
|
||||
/// 25.39 kHz grid.
|
||||
Khz25 = 0,
|
||||
/// 3.9 kHz grid.
|
||||
Khz3_9 = 1,
|
||||
}
|
||||
|
||||
impl LrFhssGrid {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Khz25,
|
||||
1 => Self::Khz3_9,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// LR-FHSS configuration payload (10 bytes after `modulation_id`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct LrFhssConfig {
|
||||
pub freq_hz: u32,
|
||||
pub bw: LrFhssBandwidth,
|
||||
pub cr: LrFhssCodingRate,
|
||||
pub grid: LrFhssGrid,
|
||||
pub hopping: bool,
|
||||
pub tx_power_dbm: i8,
|
||||
}
|
||||
|
||||
impl LrFhssConfig {
|
||||
pub const WIRE_SIZE: usize = 10;
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
|
||||
if buf.len() < Self::WIRE_SIZE {
|
||||
return Err(ModulationEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bw.as_u8();
|
||||
buf[5] = self.cr.as_u8();
|
||||
buf[6] = self.grid.as_u8();
|
||||
buf[7] = u8::from(self.hopping);
|
||||
buf[8] = self.tx_power_dbm as u8;
|
||||
buf[9] = 0; // reserved
|
||||
Ok(Self::WIRE_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
|
||||
if buf.len() != Self::WIRE_SIZE {
|
||||
return Err(ModulationParseError::WrongLength {
|
||||
expected: Self::WIRE_SIZE,
|
||||
actual: buf.len(),
|
||||
});
|
||||
}
|
||||
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
let bw = LrFhssBandwidth::from_u8(buf[4]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let cr = LrFhssCodingRate::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let grid = LrFhssGrid::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let hopping = match buf[7] {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(ModulationParseError::InvalidField),
|
||||
};
|
||||
let tx_power_dbm = buf[8] as i8;
|
||||
// Reserved byte MUST be zero per §10.3.
|
||||
if buf[9] != 0 {
|
||||
return Err(ModulationParseError::InvalidField);
|
||||
}
|
||||
Ok(Self {
|
||||
freq_hz,
|
||||
bw,
|
||||
cr,
|
||||
grid,
|
||||
hopping,
|
||||
tx_power_dbm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FLRC ────────────────────────────────────────────────────────────
|
||||
|
||||
/// FLRC bitrate enum (`PROTOCOL.md §10.4`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum FlrcBitrate {
|
||||
/// 2600 kbps.
|
||||
Kbps2600 = 0,
|
||||
/// 2080 kbps.
|
||||
Kbps2080 = 1,
|
||||
/// 1300 kbps.
|
||||
Kbps1300 = 2,
|
||||
/// 1040 kbps.
|
||||
Kbps1040 = 3,
|
||||
/// 650 kbps.
|
||||
Kbps650 = 4,
|
||||
/// 520 kbps.
|
||||
Kbps520 = 5,
|
||||
/// 325 kbps.
|
||||
Kbps325 = 6,
|
||||
/// 260 kbps.
|
||||
Kbps260 = 7,
|
||||
}
|
||||
|
||||
impl FlrcBitrate {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Kbps2600,
|
||||
1 => Self::Kbps2080,
|
||||
2 => Self::Kbps1300,
|
||||
3 => Self::Kbps1040,
|
||||
4 => Self::Kbps650,
|
||||
5 => Self::Kbps520,
|
||||
6 => Self::Kbps325,
|
||||
7 => Self::Kbps260,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// FLRC coding rate enum (`PROTOCOL.md §10.4`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum FlrcCodingRate {
|
||||
/// 1/2.
|
||||
Cr1_2 = 0,
|
||||
/// 3/4.
|
||||
Cr3_4 = 1,
|
||||
/// 1/1 (no coding).
|
||||
Cr1_1 = 2,
|
||||
}
|
||||
|
||||
impl FlrcCodingRate {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Cr1_2,
|
||||
1 => Self::Cr3_4,
|
||||
2 => Self::Cr1_1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// FLRC Gaussian BT-product enum (`PROTOCOL.md §10.4`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum FlrcBt {
|
||||
/// Gaussian filtering off.
|
||||
Off = 0,
|
||||
/// BT = 0.5.
|
||||
Bt0_5 = 1,
|
||||
/// BT = 1.0.
|
||||
Bt1_0 = 2,
|
||||
}
|
||||
|
||||
impl FlrcBt {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Off,
|
||||
1 => Self::Bt0_5,
|
||||
2 => Self::Bt1_0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// FLRC preamble length enum (`PROTOCOL.md §10.4`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
#[repr(u8)]
|
||||
pub enum FlrcPreambleLen {
|
||||
Bits8 = 0,
|
||||
Bits12 = 1,
|
||||
Bits16 = 2,
|
||||
Bits20 = 3,
|
||||
Bits24 = 4,
|
||||
Bits28 = 5,
|
||||
Bits32 = 6,
|
||||
}
|
||||
|
||||
impl FlrcPreambleLen {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
pub const fn from_u8(v: u8) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => Self::Bits8,
|
||||
1 => Self::Bits12,
|
||||
2 => Self::Bits16,
|
||||
3 => Self::Bits20,
|
||||
4 => Self::Bits24,
|
||||
5 => Self::Bits28,
|
||||
6 => Self::Bits32,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// FLRC configuration payload (13 bytes after `modulation_id`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub struct FlrcConfig {
|
||||
pub freq_hz: u32,
|
||||
pub bitrate: FlrcBitrate,
|
||||
pub cr: FlrcCodingRate,
|
||||
pub bt: FlrcBt,
|
||||
pub preamble_len: FlrcPreambleLen,
|
||||
/// 32-bit sync word, transmitted MSB-first on air.
|
||||
pub sync_word: u32,
|
||||
pub tx_power_dbm: i8,
|
||||
}
|
||||
|
||||
impl FlrcConfig {
|
||||
pub const WIRE_SIZE: usize = 13;
|
||||
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
|
||||
if buf.len() < Self::WIRE_SIZE {
|
||||
return Err(ModulationEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
|
||||
buf[4] = self.bitrate.as_u8();
|
||||
buf[5] = self.cr.as_u8();
|
||||
buf[6] = self.bt.as_u8();
|
||||
buf[7] = self.preamble_len.as_u8();
|
||||
buf[8..12].copy_from_slice(&self.sync_word.to_le_bytes());
|
||||
buf[12] = self.tx_power_dbm as u8;
|
||||
Ok(Self::WIRE_SIZE)
|
||||
}
|
||||
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
|
||||
if buf.len() != Self::WIRE_SIZE {
|
||||
return Err(ModulationParseError::WrongLength {
|
||||
expected: Self::WIRE_SIZE,
|
||||
actual: buf.len(),
|
||||
});
|
||||
}
|
||||
let freq_hz = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
let bitrate = FlrcBitrate::from_u8(buf[4]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let cr = FlrcCodingRate::from_u8(buf[5]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let bt = FlrcBt::from_u8(buf[6]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let preamble_len =
|
||||
FlrcPreambleLen::from_u8(buf[7]).ok_or(ModulationParseError::InvalidField)?;
|
||||
let sync_word = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let tx_power_dbm = buf[12] as i8;
|
||||
Ok(Self {
|
||||
freq_hz,
|
||||
bitrate,
|
||||
cr,
|
||||
bt,
|
||||
preamble_len,
|
||||
sync_word,
|
||||
tx_power_dbm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modulation sum type ─────────────────────────────────────────────
|
||||
|
||||
/// Any of the four modulation configs, tagged by `ModulationId`.
|
||||
///
|
||||
/// This is the type carried inside `Command::SetConfig`. Encoding writes
|
||||
/// the `modulation_id` byte first, then delegates to the per-modulation
|
||||
/// `encode`. Decoding dispatches on the leading byte and calls the
|
||||
/// matching `decode`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
|
||||
pub enum Modulation {
|
||||
LoRa(LoRaConfig),
|
||||
FskGfsk(FskConfig),
|
||||
LrFhss(LrFhssConfig),
|
||||
Flrc(FlrcConfig),
|
||||
}
|
||||
|
||||
impl Modulation {
|
||||
pub const fn id(&self) -> ModulationId {
|
||||
match self {
|
||||
Self::LoRa(_) => ModulationId::LoRa,
|
||||
Self::FskGfsk(_) => ModulationId::FskGfsk,
|
||||
Self::LrFhss(_) => ModulationId::LrFhss,
|
||||
Self::Flrc(_) => ModulationId::Flrc,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode as `[modulation_id][params]` into `buf`. Returns the total
|
||||
/// number of bytes written.
|
||||
pub fn encode(&self, buf: &mut [u8]) -> Result<usize, ModulationEncodeError> {
|
||||
if buf.is_empty() {
|
||||
return Err(ModulationEncodeError::BufferTooSmall);
|
||||
}
|
||||
buf[0] = self.id().as_u8();
|
||||
let n = match self {
|
||||
Self::LoRa(c) => c.encode(&mut buf[1..])?,
|
||||
Self::FskGfsk(c) => c.encode(&mut buf[1..])?,
|
||||
Self::LrFhss(c) => c.encode(&mut buf[1..])?,
|
||||
Self::Flrc(c) => c.encode(&mut buf[1..])?,
|
||||
};
|
||||
Ok(1 + n)
|
||||
}
|
||||
|
||||
/// Decode from `[modulation_id][params]`. The full slice is the
|
||||
/// `SET_CONFIG` payload; length errors are propagated as
|
||||
/// `ModulationParseError::WrongLength`.
|
||||
pub fn decode(buf: &[u8]) -> Result<Self, ModulationParseError> {
|
||||
if buf.is_empty() {
|
||||
return Err(ModulationParseError::TooShort);
|
||||
}
|
||||
let id = ModulationId::from_u8(buf[0]).ok_or(ModulationParseError::UnknownModulation)?;
|
||||
let params = &buf[1..];
|
||||
Ok(match id {
|
||||
ModulationId::LoRa => Self::LoRa(LoRaConfig::decode(params)?),
|
||||
ModulationId::FskGfsk => Self::FskGfsk(FskConfig::decode(params)?),
|
||||
ModulationId::LrFhss => Self::LrFhss(LrFhssConfig::decode(params)?),
|
||||
ModulationId::Flrc => Self::Flrc(FlrcConfig::decode(params)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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 lora_wire_size() {
|
||||
assert_eq!(LoRaConfig::WIRE_SIZE, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_roundtrip() {
|
||||
let cfg = sample_lora();
|
||||
let mut buf = [0u8; 32];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 15);
|
||||
let decoded = LoRaConfig::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_appendix_c23_bytes() {
|
||||
// PROTOCOL.md §C.2.3 — EU868 SF7 BW125 CR4/5, preamble=8, sync=0x1424,
|
||||
// power=14 dBm, explicit, crc on, iq normal.
|
||||
let cfg = sample_lora();
|
||||
let mut buf = [0u8; 15];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 15);
|
||||
let expected: [u8; 15] = [
|
||||
0xA0, 0x27, 0xBE, 0x33, // freq_hz = 868_100_000 LE
|
||||
0x07, // sf
|
||||
0x07, // bw = Khz125
|
||||
0x00, // cr = 4/5
|
||||
0x08, 0x00, // preamble_len = 8
|
||||
0x24, 0x14, // sync_word = 0x1424
|
||||
0x0E, // tx_power_dbm = 14
|
||||
0x00, // header explicit
|
||||
0x01, // payload_crc on
|
||||
0x00, // iq normal
|
||||
];
|
||||
assert_eq!(buf, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_rejects_wrong_length() {
|
||||
assert!(matches!(
|
||||
LoRaConfig::decode(&[0u8; 14]),
|
||||
Err(ModulationParseError::WrongLength { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
LoRaConfig::decode(&[0u8; 16]),
|
||||
Err(ModulationParseError::WrongLength { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_rejects_bad_enum_values() {
|
||||
let mut buf = [0u8; 15];
|
||||
sample_lora().encode(&mut buf).unwrap();
|
||||
|
||||
let mut bad = buf;
|
||||
bad[5] = 14; // BW 14 is undefined
|
||||
assert!(LoRaConfig::decode(&bad).is_err());
|
||||
|
||||
let mut bad = buf;
|
||||
bad[6] = 4; // CR 4 is undefined
|
||||
assert!(LoRaConfig::decode(&bad).is_err());
|
||||
|
||||
let mut bad = buf;
|
||||
bad[12] = 2; // header_mode 2 undefined
|
||||
assert!(LoRaConfig::decode(&bad).is_err());
|
||||
|
||||
let mut bad = buf;
|
||||
bad[13] = 2; // payload_crc must be 0 or 1
|
||||
assert!(LoRaConfig::decode(&bad).is_err());
|
||||
|
||||
let mut bad = buf;
|
||||
bad[14] = 2; // iq_invert must be 0 or 1
|
||||
assert!(LoRaConfig::decode(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fsk_roundtrip_empty_sync() {
|
||||
let cfg = FskConfig {
|
||||
freq_hz: 868_000_000,
|
||||
bitrate_bps: 9_600,
|
||||
freq_dev_hz: 5_000,
|
||||
rx_bw: 0x0B,
|
||||
preamble_len: 16,
|
||||
sync_word_len: 0,
|
||||
sync_word: [0u8; MAX_SYNC_WORD_LEN],
|
||||
};
|
||||
let mut buf = [0u8; 32];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 16);
|
||||
let decoded = FskConfig::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fsk_roundtrip_with_sync() {
|
||||
let mut sync = [0u8; MAX_SYNC_WORD_LEN];
|
||||
sync[..4].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
|
||||
let cfg = FskConfig {
|
||||
freq_hz: 868_000_000,
|
||||
bitrate_bps: 50_000,
|
||||
freq_dev_hz: 25_000,
|
||||
rx_bw: 0x1A,
|
||||
preamble_len: 32,
|
||||
sync_word_len: 4,
|
||||
sync_word: sync,
|
||||
};
|
||||
let mut buf = [0u8; 32];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 20);
|
||||
let decoded = FskConfig::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fsk_rejects_oversized_sync() {
|
||||
let mut cfg = FskConfig {
|
||||
freq_hz: 0,
|
||||
bitrate_bps: 0,
|
||||
freq_dev_hz: 0,
|
||||
rx_bw: 0,
|
||||
preamble_len: 0,
|
||||
sync_word_len: 9,
|
||||
sync_word: [0u8; MAX_SYNC_WORD_LEN],
|
||||
};
|
||||
let mut buf = [0u8; 32];
|
||||
assert!(cfg.encode(&mut buf).is_err());
|
||||
|
||||
// Decoder rejects the same: sync_word_len > 8.
|
||||
cfg.sync_word_len = 0;
|
||||
cfg.encode(&mut buf).unwrap();
|
||||
let mut bad = [0u8; 16];
|
||||
bad.copy_from_slice(&buf[..16]);
|
||||
bad[15] = 9;
|
||||
assert!(FskConfig::decode(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lr_fhss_roundtrip() {
|
||||
let cfg = LrFhssConfig {
|
||||
freq_hz: 915_000_000,
|
||||
bw: LrFhssBandwidth::Khz136,
|
||||
cr: LrFhssCodingRate::Cr2_3,
|
||||
grid: LrFhssGrid::Khz25,
|
||||
hopping: true,
|
||||
tx_power_dbm: 14,
|
||||
};
|
||||
let mut buf = [0u8; 10];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 10);
|
||||
let decoded = LrFhssConfig::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, cfg);
|
||||
assert_eq!(buf[9], 0, "reserved byte must serialize as 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lr_fhss_rejects_nonzero_reserved() {
|
||||
let cfg = LrFhssConfig {
|
||||
freq_hz: 0,
|
||||
bw: LrFhssBandwidth::Khz39,
|
||||
cr: LrFhssCodingRate::Cr1_3,
|
||||
grid: LrFhssGrid::Khz25,
|
||||
hopping: false,
|
||||
tx_power_dbm: 0,
|
||||
};
|
||||
let mut buf = [0u8; 10];
|
||||
cfg.encode(&mut buf).unwrap();
|
||||
buf[9] = 1;
|
||||
assert!(LrFhssConfig::decode(&buf).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flrc_roundtrip() {
|
||||
let cfg = FlrcConfig {
|
||||
freq_hz: 2_400_000_000,
|
||||
bitrate: FlrcBitrate::Kbps1300,
|
||||
cr: FlrcCodingRate::Cr3_4,
|
||||
bt: FlrcBt::Bt0_5,
|
||||
preamble_len: FlrcPreambleLen::Bits24,
|
||||
sync_word: 0x1234_5678,
|
||||
tx_power_dbm: 10,
|
||||
};
|
||||
let mut buf = [0u8; 13];
|
||||
let n = cfg.encode(&mut buf).unwrap();
|
||||
assert_eq!(n, 13);
|
||||
let decoded = FlrcConfig::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modulation_sum_roundtrip() {
|
||||
let m = Modulation::LoRa(sample_lora());
|
||||
let mut buf = [0u8; 64];
|
||||
let n = m.encode(&mut buf).unwrap();
|
||||
// id byte + LoRa wire size
|
||||
assert_eq!(n, 1 + LoRaConfig::WIRE_SIZE);
|
||||
assert_eq!(buf[0], ModulationId::LoRa.as_u8());
|
||||
let decoded = Modulation::decode(&buf[..n]).unwrap();
|
||||
assert_eq!(decoded, m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modulation_id_rejects_unknown() {
|
||||
assert!(matches!(
|
||||
Modulation::decode(&[0x05, 0, 0, 0]),
|
||||
Err(ModulationParseError::UnknownModulation)
|
||||
));
|
||||
assert!(matches!(
|
||||
Modulation::decode(&[]),
|
||||
Err(ModulationParseError::TooShort)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lora_bandwidth_hz_table() {
|
||||
// Cross-check §10.1 table values.
|
||||
assert_eq!(LoRaBandwidth::Khz125.as_hz(), 125_000);
|
||||
assert_eq!(LoRaBandwidth::Khz500.as_hz(), 500_000);
|
||||
assert_eq!(LoRaBandwidth::Khz7.as_hz(), 7_810);
|
||||
assert_eq!(LoRaBandwidth::Khz1600.as_hz(), 1_600_000);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user