From a9ab81a27f250fb274776cf1ffcab762a9070562 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 13 Apr 2026 08:59:05 -0600 Subject: [PATCH] feat: import wire protocol types from firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copied src/protocol.rs from firmware verbatim, added crate-level #![no_std], migrated the defmt::Format derive gate from cfg_attr(not(test), ...) to cfg_attr(feature = "defmt", ...) so host-side (no-default-features) builds compile cleanly. Tests unchanged; all 35 pass. Firmware still owns the file for now — the re-export swap lands in a follow-up firmware commit to keep each change standalone. --- .gitignore | 5 + Cargo.toml | 35 +++ LICENSE | 21 ++ README.md | 25 ++ src/lib.rs | 716 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 802 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20b8bac --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +/fuzz/target +/fuzz/corpus +/fuzz/artifacts +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..95348b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "donglora-protocol" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["Stephen Waits "] +license = "MIT" +description = "DongLoRa wire protocol types and COBS framing — shared between firmware and host crates" +repository = "https://github.com/donglora/protocol" +homepage = "https://github.com/donglora/protocol" +keywords = ["lora", "radio", "protocol", "no-std"] +categories = ["embedded", "no-std", "encoding"] +exclude = ["fuzz", ".jj"] + +[dependencies] +heapless = "0.9" +ucobs = "0.3" +defmt = { version = "1", optional = true } + +[features] +default = ["defmt"] +# Enables `defmt::Format` derives on public types. On by default so firmware +# (the primary no_std consumer) just works; host crates disable default-features. +defmt = ["dep:defmt"] +# Enables `Debug` derives and other std-only niceties for host-side fuzz / +# proptest builds. No-op on firmware. +std = [] + +[dev-dependencies] +proptest = "1" + +[lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4ad6f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf7f505 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# donglora-protocol + +Wire protocol types and COBS framing for DongLoRa, shared between firmware +and host crates (`client-rs`, `mux-rs`, `bridge`). + +`no_std` by default. `defmt::Format` derives are opt-in via the default +`defmt` feature. Host consumers disable default features: + +```toml +donglora-protocol = { version = "0.1", default-features = false } +``` + +## Status + +Pre-1.0. Currently consumed by path dep; will publish to crates.io once +the surface area settles. + +## Layout + +- `src/lib.rs` — `Command`, `Response`, `RadioConfig`, `ErrorCode`, etc. +- `src/framing.rs` — `CobsDecoder` + `cobs_encode_response` for USB/UART + transports. +- `tests/` — proptest suite (roundtrips, bounded-capacity invariants, + regional-regs, duty-cycle math). +- `fuzz/` — cargo-fuzz targets on the parser and framer. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2113fe7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,716 @@ +//! Wire protocol types and fixed-size little-endian serialization. +//! +//! Every integer is fixed-width LE. No varints, no zigzag. +//! See `docs/PROTOCOL.md` for the complete specification. + +#![no_std] + +use heapless::Vec; + +/// Maximum LoRa payload size in bytes. +pub const MAX_PAYLOAD: usize = 256; + +/// RadioConfig wire size (fixed). +pub const RADIO_CONFIG_SIZE: usize = 13; + +/// 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 + +/// Sentinel value for `preamble_len`: use the firmware default (16 symbols). +pub const PREAMBLE_DEFAULT: u16 = 0; + +/// LoRa signal bandwidth. +#[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, +} + +impl Bandwidth { + fn from_u8(v: u8) -> Option { + 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] +/// ``` +#[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, +} + +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 { + 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, PartialEq)] +pub enum Command { + Ping, + GetConfig, + SetConfig(RadioConfig), + StartRx, + StopRx, + Transmit { + config: Option, + payload: Vec, + }, + DisplayOn, + DisplayOff, + GetMac, +} + +impl Command { + /// Deserialize from a COBS-decoded frame. + pub fn from_bytes(buf: &[u8]) -> Option { + 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, PartialEq)] +pub enum Response { + Pong, + Config(RadioConfig), + RxPacket { + rssi: i16, + snr: i16, + payload: Vec, + }, + 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. +#[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, +} + +// ── 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); + } +}