test: canonical wire-byte vectors anchored to PROTOCOL.md
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.
This commit is contained in:
1 file changed
+275
@@ -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<u8, 64> = 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<u8, 128> {
|
||||
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<u8, 256> = 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],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user