From 6acc12cde8e6268376664b1ac345670412b43007 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 13 Apr 2026 09:48:09 -0600 Subject: [PATCH] test: canonical wire-byte vectors anchored to PROTOCOL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-encoded byte sequences for RadioConfig, every Command tag, and every Response tag, taken directly from the spec. Fields use hex-clean values (freq_hz = 0x12345678, sync_word = 0xABCD, etc.) so each little-endian byte is visually verifiable. Locks firmware and host to the written spec — a refactor that quietly changes a field layout fails here, not in the field. --- tests/vectors.rs | 275 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/vectors.rs diff --git a/tests/vectors.rs b/tests/vectors.rs new file mode 100644 index 0000000..60acd93 --- /dev/null +++ b/tests/vectors.rs @@ -0,0 +1,275 @@ +//! Canonical wire-byte vectors anchored to `PROTOCOL.md`. +//! +//! These are hand-encoded byte sequences taken directly from the spec. +//! They lock the firmware and host implementations to the *written* +//! protocol, not just to each other — so a refactor that accidentally +//! redefines a field layout fails here before it ships. +//! +//! Every vector is expressed twice: as a hex `&[u8]` decoded into a +//! typed value, and as a typed value encoded back into bytes. Byte-for- +//! byte equality in both directions is the invariant. + +#![allow(clippy::unwrap_used, clippy::panic)] + +use donglora_protocol::{ + Bandwidth, Command, ErrorCode, RADIO_CONFIG_SIZE, RadioConfig, Response, TX_POWER_MAX, +}; +use heapless::Vec as HVec; + +// ── A well-known RadioConfig with hex-clean field values ────────────── +// +// Fields are picked so each multi-byte field has a visually obvious +// little-endian byte pattern, and the config as a whole still validates +// (freq in 150–960 MHz range, sf in 5–12, cr in 5–8). + +const KNOWN_FREQ_HZ: u32 = 0x1234_5678; // 305_419_896 Hz ≈ 305 MHz +const KNOWN_SYNC: u16 = 0xABCD; +const KNOWN_PREAMBLE: u16 = 0x00AB; + +/// Matches the layout documented in `PROTOCOL.md §RadioConfig (13 bytes)`. +const CONFIG_BYTES: [u8; RADIO_CONFIG_SIZE] = [ + 0x78, 0x56, 0x34, 0x12, // freq_hz = 0x12345678 LE + 0x07, // bw = Khz125 + 0x07, // sf = 7 + 0x05, // cr = 5 (4/5) + 0xCD, 0xAB, // sync_word = 0xABCD LE + 0x16, // tx_power_dbm = 22 + 0xAB, 0x00, // preamble_len = 0x00AB LE + 0x01, // cad = enabled +]; + +fn known_config() -> RadioConfig { + RadioConfig { + freq_hz: KNOWN_FREQ_HZ, + bw: Bandwidth::Khz125, + sf: 7, + cr: 5, + sync_word: KNOWN_SYNC, + tx_power_dbm: 22, + preamble_len: KNOWN_PREAMBLE, + cad: 1, + } +} + +// ── RadioConfig ───────────────────────────────────────────────────── + +#[test] +fn radio_config_canonical_bytes_decode() { + assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config())); +} + +#[test] +fn radio_config_canonical_bytes_encode() { + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + let n = known_config().write_to(&mut buf); + assert_eq!(n, RADIO_CONFIG_SIZE); + assert_eq!(buf, CONFIG_BYTES); +} + +#[test] +fn radio_config_tx_power_max_sentinel_is_minus_128() { + // PROTOCOL.md: "Set tx_power_dbm to -128 (0x80) to use the board's maximum." + assert_eq!(TX_POWER_MAX, -128); + let cfg = RadioConfig { + tx_power_dbm: TX_POWER_MAX, + ..known_config() + }; + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + cfg.write_to(&mut buf); + assert_eq!(buf[9], 0x80, "TX_POWER_MAX must serialize as 0x80"); +} + +#[test] +fn radio_config_preamble_default_sentinel_is_zero_le() { + // PROTOCOL.md: "Set preamble_len to 0 to use the firmware default." + let cfg = RadioConfig { + preamble_len: 0, + ..known_config() + }; + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + cfg.write_to(&mut buf); + assert_eq!(&buf[10..12], &[0x00, 0x00]); +} + +#[test] +fn bandwidth_table_matches_protocol_md() { + // PROTOCOL.md: 0..=9 enumerated in order 7.8…500 kHz. + let expected = [ + (0u8, Bandwidth::Khz7), + (1, Bandwidth::Khz10), + (2, Bandwidth::Khz15), + (3, Bandwidth::Khz20), + (4, Bandwidth::Khz31), + (5, Bandwidth::Khz41), + (6, Bandwidth::Khz62), + (7, Bandwidth::Khz125), + (8, Bandwidth::Khz250), + (9, Bandwidth::Khz500), + ]; + let mut buf = [0u8; RADIO_CONFIG_SIZE]; + for (wire, bw) in expected { + RadioConfig { + bw, + ..known_config() + } + .write_to(&mut buf); + assert_eq!(buf[4], wire, "bandwidth {:?} must wire as {}", bw, wire); + + let decoded = RadioConfig::from_bytes(&buf).unwrap(); + assert_eq!(decoded.bw, bw); + } +} + +#[test] +fn bandwidth_invalid_values_reject() { + // PROTOCOL.md restricts bw to 0..=9. + let mut buf = CONFIG_BYTES; + for bad in [10u8, 11, 100, 255] { + buf[4] = bad; + assert!( + RadioConfig::from_bytes(&buf).is_none(), + "bandwidth {bad} must not decode", + ); + } +} + +// ── Commands ──────────────────────────────────────────────────────── + +#[test] +fn command_tags_match_protocol_md() { + // Tag table from PROTOCOL.md §Commands. Unit-variant parse-only. + assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping)); + assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig)); + assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx)); + assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx)); + 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_set_config_canonical_bytes() { + // tag(2) + CONFIG_BYTES + let mut bytes = [0u8; 1 + RADIO_CONFIG_SIZE]; + bytes[0] = 2; + bytes[1..].copy_from_slice(&CONFIG_BYTES); + assert_eq!( + Command::from_bytes(&bytes), + Some(Command::SetConfig(known_config())) + ); +} + +#[test] +fn command_transmit_no_config_canonical_bytes() { + // PROTOCOL.md §Commands tag 5: has_config(1) + [RadioConfig if 1] + len(u16 LE) + payload + let bytes = [ + 0x05, // Transmit + 0x00, // has_config = false + 0x05, 0x00, // len = 5 LE + b'h', b'e', b'l', b'l', b'o', + ]; + match Command::from_bytes(&bytes).unwrap() { + Command::Transmit { config, payload } => { + assert!(config.is_none()); + assert_eq!(payload.as_slice(), b"hello"); + } + other => panic!("expected Transmit, got {:?}", other), + } +} + +#[test] +fn command_transmit_with_config_canonical_bytes() { + let mut bytes: HVec = HVec::new(); + bytes.push(0x05).unwrap(); // Transmit + bytes.push(0x01).unwrap(); // has_config = true + bytes.extend_from_slice(&CONFIG_BYTES).unwrap(); + bytes.extend_from_slice(&[0x02, 0x00]).unwrap(); // len = 2 + bytes.extend_from_slice(b"OK").unwrap(); + + match Command::from_bytes(&bytes).unwrap() { + Command::Transmit { config, payload } => { + assert_eq!(config, Some(known_config())); + assert_eq!(payload.as_slice(), b"OK"); + } + other => panic!("expected Transmit, got {:?}", other), + } +} + +#[test] +fn command_unknown_tags_reject() { + // Defined tags: 0..=8. Everything else must reject. + for bad in [9u8, 10, 100, 255] { + assert!(Command::from_bytes(&[bad]).is_none()); + } +} + +// ── Responses ─────────────────────────────────────────────────────── + +fn encode_response_bytes(r: Response) -> HVec { + let mut buf = [0u8; 128]; + let n = r.write_to(&mut buf); + HVec::from_slice(&buf[..n]).unwrap() +} + +#[test] +fn response_canonical_unit_tags() { + assert_eq!(encode_response_bytes(Response::Pong).as_slice(), &[0x00]); + assert_eq!(encode_response_bytes(Response::TxDone).as_slice(), &[0x03]); + assert_eq!(encode_response_bytes(Response::Ok).as_slice(), &[0x04]); +} + +#[test] +fn response_config_canonical_bytes() { + let mut expected = [0u8; 1 + RADIO_CONFIG_SIZE]; + expected[0] = 0x01; // Config tag + expected[1..].copy_from_slice(&CONFIG_BYTES); + assert_eq!( + encode_response_bytes(Response::Config(known_config())).as_slice(), + &expected + ); +} + +#[test] +fn response_rx_packet_canonical_bytes() { + let mut payload: HVec = HVec::new(); + payload.extend_from_slice(b"data").unwrap(); + let bytes = encode_response_bytes(Response::RxPacket { + rssi: -80, + snr: 10, + payload, + }); + + // tag(2) + rssi(-80 = 0xFFB0 LE) + snr(10 LE) + len(4 LE) + "data" + let expected: &[u8] = &[ + 0x02, 0xB0, 0xFF, 0x0A, 0x00, 0x04, 0x00, b'd', b'a', b't', b'a', + ]; + assert_eq!(bytes.as_slice(), expected); +} + +#[test] +fn response_error_canonical_bytes() { + // PROTOCOL.md §Error codes: InvalidConfig=0, RadioBusy=1, TxTimeout=2, + // NotConfigured=4, NoDisplay=5. Tag 3 is reserved (was CrcError). + let pairs = [ + (ErrorCode::InvalidConfig, 0u8), + (ErrorCode::RadioBusy, 1), + (ErrorCode::TxTimeout, 2), + (ErrorCode::NotConfigured, 4), + (ErrorCode::NoDisplay, 5), + ]; + for (code, byte) in pairs { + assert_eq!( + encode_response_bytes(Response::Error(code)).as_slice(), + &[0x05, byte], + ); + } +} + +#[test] +fn response_mac_address_canonical_bytes() { + let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + assert_eq!( + encode_response_bytes(Response::MacAddress(mac)).as_slice(), + &[0x06, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF], + ); +}