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
+428
-252
@@ -1,275 +1,451 @@
|
||||
//! Canonical wire-byte vectors anchored to `PROTOCOL.md`.
|
||||
//! Appendix C wire vectors from `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.
|
||||
//! Each test pins one or more complete wire-framed byte sequences from
|
||||
//! the spec. If a future refactor accidentally redefines a field layout
|
||||
//! or a CRC variant, the test fails with a concrete hex diff against
|
||||
//! the normative document — not just against the crate's own round-trip.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
||||
|
||||
use donglora_protocol::{
|
||||
Bandwidth, Command, ErrorCode, RADIO_CONFIG_SIZE, RadioConfig, Response, TX_POWER_MAX,
|
||||
Command, DeviceMessage, ErrorCode, FrameDecoder, FrameResult, Info, LoRaBandwidth,
|
||||
LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_WIRE_FRAME, Modulation, OkPayload, Owner,
|
||||
RadioChipId, RxOrigin, RxPayload, SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags,
|
||||
TxResult, cap, commands, encode_frame,
|
||||
};
|
||||
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).
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
}
|
||||
/// Encode a command as a complete wire-ready frame, tag-correlated.
|
||||
fn encode_cmd_frame(cmd: &Command, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
|
||||
let mut payload_buf = [0u8; 320];
|
||||
let payload_len = cmd.encode_payload(&mut payload_buf).unwrap();
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(cmd.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
let mut out = HVec::new();
|
||||
out.extend_from_slice(&wire[..n]).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
// ── RadioConfig ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn radio_config_canonical_bytes_decode() {
|
||||
assert_eq!(RadioConfig::from_bytes(&CONFIG_BYTES), Some(known_config()));
|
||||
/// Encode a device message as a complete wire-ready frame, tag-correlated.
|
||||
fn encode_dev_frame(msg: &DeviceMessage, tag: u16) -> HVec<u8, MAX_WIRE_FRAME> {
|
||||
let mut payload_buf = [0u8; 320];
|
||||
let payload_len = msg.encode_payload(&mut payload_buf).unwrap();
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(msg.type_id(), tag, &payload_buf[..payload_len], &mut wire).unwrap();
|
||||
let mut out = HVec::new();
|
||||
out.extend_from_slice(&wire[..n]).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[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()
|
||||
/// Decode a single frame out of `wire` and assert everything about it.
|
||||
fn decode_single_frame(wire: &[u8]) -> (u8, u16, HVec<u8, 320>) {
|
||||
let mut decoder = FrameDecoder::new();
|
||||
let mut out: Option<(u8, u16, HVec<u8, 320>)> = None;
|
||||
decoder.feed(wire, |res| match res {
|
||||
FrameResult::Ok {
|
||||
type_id,
|
||||
tag,
|
||||
payload,
|
||||
} => {
|
||||
let mut p = HVec::new();
|
||||
p.extend_from_slice(payload).unwrap();
|
||||
out = Some((type_id, tag, p));
|
||||
}
|
||||
.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,
|
||||
FrameResult::Err(e) => panic!("decode error: {:?}", e),
|
||||
});
|
||||
out.expect("expected one frame")
|
||||
}
|
||||
|
||||
// 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);
|
||||
// ── §C.2.1 PING / OK ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c21_ping_encodes_to_spec_bytes() {
|
||||
// H→D PING tag=0x0001 → wire: 03 01 01 03 9D C8 00
|
||||
let wire = encode_cmd_frame(&Command::Ping, 0x0001);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
|
||||
}
|
||||
|
||||
#[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],
|
||||
);
|
||||
fn c21_ok_encodes_to_spec_bytes() {
|
||||
// D→H OK tag=0x0001 → wire: 03 80 01 03 F7 C4 00
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0001);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x01, 0x03, 0xF7, 0xC4, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c21_decode_roundtrip() {
|
||||
let (type_id, tag, payload) = decode_single_frame(&[0x03, 0x01, 0x01, 0x03, 0x9D, 0xC8, 0x00]);
|
||||
assert_eq!(type_id, commands::TYPE_PING);
|
||||
assert_eq!(tag, 0x0001);
|
||||
assert_eq!(payload.as_slice(), &[]);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), Command::Ping);
|
||||
}
|
||||
|
||||
// ── §C.2.3 SET_CONFIG LoRa EU868 ────────────────────────────────────
|
||||
|
||||
fn c23_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 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],
|
||||
);
|
||||
fn c23_set_config_encodes_to_spec_bytes() {
|
||||
// H→D SET_CONFIG tag=0x0003 → wire:
|
||||
// 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00
|
||||
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
|
||||
let wire = encode_cmd_frame(&cmd, 0x0003);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x03, 0x03, 0x08, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08, 0x04, 0x24,
|
||||
0x14, 0x0E, 0x02, 0x01, 0x03, 0xD9, 0x1F, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c23_set_config_ok_response() {
|
||||
// D→H OK tag=0x0003 result=APPLIED owner=MINE current=[same LoRa]
|
||||
let result = SetConfigResult {
|
||||
result: SetConfigResultCode::Applied,
|
||||
owner: Owner::Mine,
|
||||
current: Modulation::LoRa(c23_lora()),
|
||||
};
|
||||
let msg = DeviceMessage::Ok(OkPayload::SetConfig(result));
|
||||
let wire = encode_dev_frame(&msg, 0x0003);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x80, 0x03, 0x01, 0x09, 0x01, 0x01, 0xA0, 0x27, 0xBE, 0x33, 0x07, 0x07, 0x02, 0x08,
|
||||
0x04, 0x24, 0x14, 0x0E, 0x02, 0x01, 0x03, 0xC8, 0x91, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.4 TX with default CAD, packet "Hello" ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn c24_tx_with_cad() {
|
||||
// H→D TX tag=0x0004 flags=0x00 data="Hello"
|
||||
// wire: 03 04 04 01 08 48 65 6C 6C 6F 26 40 00
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"Hello").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags::default(),
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0004);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x04, 0x04, 0x01, 0x08, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x26, 0x40, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c24_tx_ok() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Empty), 0x0004);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x80, 0x04, 0x03, 0x02, 0x3B, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c24_tx_done() {
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 30_976,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0004);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0xC1, 0x04, 0x01, 0x01, 0x02, 0x79, 0x01, 0x03, 0xE3, 0xFA, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.5 TX with skip_cad ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c25_tx_skip_cad() {
|
||||
// H→D TX tag=0x0005 flags=0x01 data="URGENT"
|
||||
// wire: 03 04 05 0A 01 55 52 47 45 4E 54 DB 1C 00
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"URGENT").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags { skip_cad: true },
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0005);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x04, 0x05, 0x0A, 0x01, 0x55, 0x52, 0x47, 0x45, 0x4E, 0x54, 0xDB, 0x1C, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c25_tx_done() {
|
||||
let td = TxDonePayload {
|
||||
result: TxResult::Transmitted,
|
||||
airtime_us: 33_792,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::TxDone(td), 0x0005);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0xC1, 0x05, 0x01, 0x01, 0x02, 0x84, 0x01, 0x03, 0x81, 0xE3, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.2.6 RX event ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c26_rx_start() {
|
||||
let wire = encode_cmd_frame(&Command::RxStart, 0x0006);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x05, 0x06, 0x03, 0xCA, 0x8D, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c26_rx_event() {
|
||||
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 wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
|
||||
let expected: &[u8] = &[
|
||||
0x02, 0xC0, 0x01, 0x04, 0x21, 0xFD, 0x5F, 0x09, 0x83, 0xFF, 0xFF, 0xFF, 0x80, 0xDE, 0x80,
|
||||
0x02, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x07, 0x01, 0x02, 0x03, 0x04, 0xB9, 0x8E,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c26_rx_stop() {
|
||||
let wire = encode_cmd_frame(&Command::RxStop, 0x0008);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x06, 0x08, 0x03, 0x95, 0xF7, 0x00]);
|
||||
}
|
||||
|
||||
// ── §C.5.1 ERR(ENOTCONFIGURED) ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c51_err_notconfigured() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ENotConfigured), 0x0028);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x28, 0x02, 0x03, 0x03, 0x53, 0x7E, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.3 ERR(EPARAM) ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c53_err_eparam() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EParam), 0x002A);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x2A, 0x02, 0x01, 0x03, 0x59, 0xF5, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.4 ERR(EBUSY) ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c54_err_ebusy() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EBusy), 0x002B);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x2B, 0x02, 0x06, 0x03, 0x7A, 0x1A, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.5.6 Unknown command type byte ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c56_unknown_command_type_encodes_to_spec() {
|
||||
// The host sends type=0x10 with payload DE AD. We can't use
|
||||
// Command::encode_payload (no such variant), but the frame encoder
|
||||
// is type-agnostic so we can directly produce the wire bytes.
|
||||
let mut wire = [0u8; MAX_WIRE_FRAME];
|
||||
let n = encode_frame(0x10, 0x003C, &[0xDE, 0xAD], &mut wire).unwrap();
|
||||
let expected: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
|
||||
assert_eq!(&wire[..n], expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c56_unknown_command_decoder_still_parses_frame() {
|
||||
// The decoder is payload-agnostic: it yields (type_id=0x10, tag=0x003C,
|
||||
// payload=DE AD). The command parser then rejects it cleanly with
|
||||
// UnknownType so the caller can respond with ERR(EUNKNOWN_CMD).
|
||||
let wire: &[u8] = &[0x03, 0x10, 0x3C, 0x05, 0xDE, 0xAD, 0xE2, 0x24, 0x00];
|
||||
let (type_id, tag, payload) = decode_single_frame(wire);
|
||||
assert_eq!(type_id, 0x10);
|
||||
assert_eq!(tag, 0x003C);
|
||||
assert_eq!(payload.as_slice(), &[0xDE, 0xAD]);
|
||||
assert!(matches!(
|
||||
Command::parse(type_id, &payload),
|
||||
Err(donglora_protocol::CommandParseError::UnknownType)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c56_err_eunknown_cmd_reply() {
|
||||
// D→H ERR tag=0x003C code=0x0005
|
||||
// on wire: 03 81 3C 02 05 03 A3 05 00
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EUnknownCmd), 0x003C);
|
||||
let expected: &[u8] = &[0x03, 0x81, 0x3C, 0x02, 0x05, 0x03, 0xA3, 0x05, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.2 RX with crc_valid = 0 ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c62_rx_bad_crc_delivered_anyway() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(&[0x41, 0x42, 0xFF, 0x00, 0xCC])
|
||||
.unwrap();
|
||||
let rx = RxPayload {
|
||||
rssi_tenths_dbm: -1050,
|
||||
snr_tenths_db: -80,
|
||||
freq_err_hz: 2200,
|
||||
timestamp_us: 120_000_000,
|
||||
crc_valid: false,
|
||||
packets_dropped: 0,
|
||||
origin: RxOrigin::Ota,
|
||||
data,
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Rx(rx), 0x0000);
|
||||
let expected: &[u8] = &[
|
||||
0x02, 0xC0, 0x01, 0x07, 0xE6, 0xFB, 0xB0, 0xFF, 0x98, 0x08, 0x01, 0x01, 0x04, 0x0E, 0x27,
|
||||
0x07, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x04, 0x41, 0x42, 0xFF, 0x04, 0xCC, 0x04,
|
||||
0x3C, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.4 Async ERR(EFRAME) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c64_async_err_eframe() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::EFrame), 0x0000);
|
||||
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x02, 0x01, 0xCE, 0xEF, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.6.5 Async ERR(ERADIO) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c65_async_err_eradio() {
|
||||
let wire = encode_dev_frame(&DeviceMessage::Err(ErrorCode::ERadio), 0x0000);
|
||||
let expected: &[u8] = &[0x02, 0x81, 0x01, 0x05, 0x01, 0x01, 0x9D, 0xBA, 0x00];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── §C.8.1 COBS edge: no zeros in body ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c81_ping_no_zeros_in_body() {
|
||||
// H→D PING tag=0x0101 → no zero bytes in tag bytes 01 01.
|
||||
// wire: 06 01 01 01 BC D8 00
|
||||
let wire = encode_cmd_frame(&Command::Ping, 0x0101);
|
||||
assert_eq!(wire.as_slice(), &[0x06, 0x01, 0x01, 0x01, 0xBC, 0xD8, 0x00]);
|
||||
}
|
||||
|
||||
// ── §C.2.2 GET_INFO ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn c22_get_info_request() {
|
||||
let wire = encode_cmd_frame(&Command::GetInfo, 0x0002);
|
||||
assert_eq!(wire.as_slice(), &[0x03, 0x02, 0x02, 0x03, 0x9E, 0xC4, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c22_get_info_response() {
|
||||
// Full SX1262 GET_INFO response encoded as a complete wire frame.
|
||||
let mut mcu = [0u8; donglora_protocol::MAX_MCU_UID_LEN];
|
||||
mcu[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67]);
|
||||
let info = 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,
|
||||
supported_sf_bitmap: 0x1FE0,
|
||||
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; donglora_protocol::MAX_RADIO_UID_LEN],
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Ok(OkPayload::Info(info)), 0x0002);
|
||||
let expected: &[u8] = &[
|
||||
0x03, 0x80, 0x02, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, 0x02, 0x01, 0x01,
|
||||
0x01, 0x01, 0x01, 0x06, 0xE0, 0x1F, 0xFF, 0x03, 0xFF, 0x02, 0x40, 0x02, 0x10, 0x05, 0x80,
|
||||
0xD1, 0xF0, 0x08, 0x0F, 0x70, 0x38, 0x39, 0xF7, 0x16, 0x08, 0xDE, 0xAD, 0xBE, 0xEF, 0x01,
|
||||
0x23, 0x45, 0x67, 0x03, 0xFA, 0xA4, 0x00,
|
||||
];
|
||||
assert_eq!(wire.as_slice(), expected);
|
||||
}
|
||||
|
||||
// ── Decode-side roundtrip for the key command shapes ───────────────
|
||||
|
||||
#[test]
|
||||
fn decode_tx_roundtrip() {
|
||||
let mut data = HVec::new();
|
||||
data.extend_from_slice(b"Hello").unwrap();
|
||||
let cmd = Command::Tx {
|
||||
flags: TxFlags::default(),
|
||||
data,
|
||||
};
|
||||
let wire = encode_cmd_frame(&cmd, 0x0004);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, commands::TYPE_TX);
|
||||
assert_eq!(tag, 0x0004);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_set_config_roundtrip() {
|
||||
let cmd = Command::SetConfig(Modulation::LoRa(c23_lora()));
|
||||
let wire = encode_cmd_frame(&cmd, 0x0003);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, commands::TYPE_SET_CONFIG);
|
||||
assert_eq!(tag, 0x0003);
|
||||
assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rx_roundtrip() {
|
||||
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: data.clone(),
|
||||
};
|
||||
let wire = encode_dev_frame(&DeviceMessage::Rx(rx.clone()), 0x0000);
|
||||
let (type_id, tag, payload) = decode_single_frame(&wire);
|
||||
assert_eq!(type_id, 0xC0);
|
||||
assert_eq!(tag, 0x0000);
|
||||
let decoded = DeviceMessage::parse(type_id, &payload, None).unwrap();
|
||||
assert_eq!(decoded, DeviceMessage::Rx(rx));
|
||||
}
|
||||
Reference in new issue
Block a user