From 4cccb65124fce541de2965c0010aeaf42226198a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 21 Apr 2026 16:05:25 -0600 Subject: [PATCH] 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. --- Cargo.toml | 2 +- PROTOCOL.md | 1923 +++++++++++++++++ fuzz/Cargo.toml | 40 +- fuzz/fuzz_targets/cobs_chunk_invariant.rs | 41 - fuzz/fuzz_targets/cobs_decoder_feed.rs | 36 - fuzz/fuzz_targets/cobs_decoder_multi_frame.rs | 42 - fuzz/fuzz_targets/cobs_roundtrip.rs | 126 -- fuzz/fuzz_targets/command_roundtrip.rs | 157 -- fuzz/fuzz_targets/decoder_feed.rs | 16 + fuzz/fuzz_targets/frame_roundtrip.rs | 44 + fuzz/fuzz_targets/lora_config_roundtrip.rs | 50 + fuzz/fuzz_targets/parse_command.rs | 20 +- fuzz/fuzz_targets/parse_device_message.rs | 18 + fuzz/fuzz_targets/parse_radio_config.rs | 10 - justfile | 26 +- src/chip_id.rs | 122 ++ src/commands.rs | 354 +++ src/crc.rs | 57 + src/errors.rs | 153 ++ src/events.rs | 657 ++++++ src/frame.rs | 369 ++++ src/framing.rs | 94 - src/info.rs | 323 +++ src/lib.rs | 923 ++------ src/modulation.rs | 999 +++++++++ tests/mutation_killers.rs | 1108 ++++++++-- tests/proptest.proptest-regressions | 9 + tests/proptest.rs | 1001 ++++----- tests/vectors.rs | 680 +++--- 29 files changed, 7115 insertions(+), 2285 deletions(-) create mode 100644 PROTOCOL.md delete mode 100644 fuzz/fuzz_targets/cobs_chunk_invariant.rs delete mode 100644 fuzz/fuzz_targets/cobs_decoder_feed.rs delete mode 100644 fuzz/fuzz_targets/cobs_decoder_multi_frame.rs delete mode 100644 fuzz/fuzz_targets/cobs_roundtrip.rs delete mode 100644 fuzz/fuzz_targets/command_roundtrip.rs create mode 100644 fuzz/fuzz_targets/decoder_feed.rs create mode 100644 fuzz/fuzz_targets/frame_roundtrip.rs create mode 100644 fuzz/fuzz_targets/lora_config_roundtrip.rs create mode 100644 fuzz/fuzz_targets/parse_device_message.rs delete mode 100644 fuzz/fuzz_targets/parse_radio_config.rs create mode 100644 src/chip_id.rs create mode 100644 src/commands.rs create mode 100644 src/crc.rs create mode 100644 src/errors.rs create mode 100644 src/events.rs create mode 100644 src/frame.rs delete mode 100644 src/framing.rs create mode 100644 src/info.rs create mode 100644 src/modulation.rs create mode 100644 tests/proptest.proptest-regressions diff --git a/Cargo.toml b/Cargo.toml index 5e588fa..6c8ac6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ defmt = { version = "1", optional = true } 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"] +defmt = ["dep:defmt", "heapless/defmt"] # Enables `Debug` derives and other std-only niceties for host-side fuzz / # proptest builds. No-op on firmware. std = [] diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..f22385c --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,1923 @@ +# DongLoRa Protocol v2 Specification + +**Version 1.0** + +A minimal, reliable, transport-agnostic wire protocol for exposing a LoRa radio transceiver as a USB-serial (or BLE / WiFi / TCP) device. This document is the normative reference for implementers of both host software and device firmware. + +--- + +## Table of Contents + +1. Overview +2. Frame Format +3. State Machine +4. Message Types +5. Common Data Types +6. Message Reference +7. Error Codes +8. Radio Chip ID Enum +9. Capability Bitmap +10. Modulation Parameters +11. Ordering and Interleaving +12. Flow Control and Backpressure +13. Multi-Client Operation (Future) +14. Host Behavior Requirements +15. Device Behavior Requirements +16. Version Compatibility +17. Appendix A — COBS Reference +18. Appendix B — CRC Reference +19. Appendix C — Wire Examples +20. Appendix D — Glossary + +--- + +## 1. Overview + +DongLoRa Protocol is the wire protocol spoken between a host application and firmware on a microcontroller attached to a Semtech LoRa transceiver (SX126x, SX127x, SX128x, LR11xx, LR2021, or similar). It exposes the transceiver's native capabilities over a byte-stream transport with no editorialization: the host asks, the chip does. + +### 1.1 Design Principles + +1. **Minimal.** Ten message types. No sub-protocols, no modes, no sessions. +2. **Transport-agnostic.** Runs identically over USB CDC-ACM, USB-to-UART bridges, plain UART, BLE, TCP, or any other ordered byte stream. +3. **No persistent state.** The device boots unconfigured and forgets everything on host disconnect. There is no device-side memory between sessions. +4. **No policy.** The firmware does not enforce regulatory limits, retry on failure, reorder TX, or apply profiles. The host is authoritative for all policy. +5. **Binary.** No text, no JSON, no AT commands. Every bit is accounted for. +6. **Host drives.** The device never initiates a conversation. Every device-to-host frame is either a response to a command or a radio-sourced event. + +### 1.2 Terminology + +- **Device**: MCU + radio transceiver + firmware that speaks DongLoRa Protocol. +- **Host**: Any software endpoint speaking DongLoRa Protocol. +- **Client**: In the multi-client future, an individual host connection. A single-client USB deployment has one client, which is the host. +- **Frame**: A single DongLoRa Protocol message on the wire, COBS-encoded and delimited. +- **Tag**: 16-bit correlation identifier for matching responses to commands. +- **CONFIGURED / UNCONFIGURED**: The two states of the device's state machine (Section 3). + +--- + +## 2. Frame Format + +### 2.1 Layout + +Every DongLoRa Protocol frame has this pre-encoding structure: + +``` +┌──────┬──────┬──────────┬───────┐ +│ type │ tag │ payload │ crc16 │ +│ u8 │ u16 │ 0..P │ u16 │ +└──────┴──────┴──────────┴───────┘ +``` + +`P` is the maximum payload size for the largest message type, computed as `max_payload_bytes + 20` bytes (to accommodate the `RX` event's 20-byte metadata prefix plus the `bytes` field). `max_payload_bytes` is reported by the device in `GET_INFO` (Section 6.2) and reflects the chip's maximum over-the-air packet length. + +The pre-encoding structure is then COBS-encoded (Section 2.4) and terminated with a single `0x00` byte on the wire: + +``` +┌────────────────────────────────────┬──────┐ +│ COBS(type | tag | payload | crc16) │ 0x00 │ +└────────────────────────────────────┴──────┘ +``` + +All multi-byte integer fields are **little-endian**. + +### 2.2 Fields + +**`type`** (u8): Message type identifier. See Section 4. + +**`tag`** (u16, little-endian): +- On host→device commands: a host-chosen correlation ID. MUST NOT be `0x0000`. +- On device→host command responses (`OK`, `ERR`, `TX_DONE`): echoes the tag of the originating command. +- On device→host asynchronous events (`RX`, asynchronous `ERR`): always `0x0000`. + +Tag uniqueness: a tag is **outstanding** from the moment the host transmits the command until the host has received the final response for that tag (`OK`, `ERR`, or for `TX` the `TX_DONE`). Hosts MUST NOT reuse a tag that is currently outstanding. Once a tag is closed it may be reused freely. A monotonic counter (starting at 1, wrapping at `0xFFFF` and skipping `0x0000`) is the recommended implementation. + +Tag = 0x0000 on a host→device command is a protocol violation. The device MUST treat such a frame as a framing error and discard it (emitting asynchronous `ERR(EFRAME)` per Section 2.5), because a response with tag 0x0000 would be indistinguishable from an asynchronous event. Hosts MUST never send tag 0x0000. + +**`payload`** (variable length, 0 to `max_payload_bytes + 20` bytes): +- Structure defined per message type (Section 6). +- Length is implicit from the frame boundary: the payload runs from the byte after the tag to two bytes before the end of the decoded frame. + +**`crc16`** (u16, little-endian): +- CRC-16/CCITT-FALSE, computed over `type || tag || payload` (pre-COBS bytes, not including the CRC itself). +- Polynomial: `0x1021` +- Initial value: `0xFFFF` +- No reflection (input or output) +- XOR out: `0x0000` +- Check value for ASCII `"123456789"`: `0x29B1` + +This variant is also known as CRC-16/AUTOSAR or CRC-16/IBM-3740. It is NOT the same as CRC-16/KERMIT, CRC-16/XMODEM, or CRC-16/CCITT (which use different initial values or reflect their input). + +### 2.3 Frame Size Bounds + +- Minimum pre-COBS frame: 5 bytes (type + tag + CRC, empty payload). +- Maximum pre-COBS frame: `5 + max_payload_bytes + 20` bytes. For a device reporting `max_payload_bytes = 255`, this is 280 bytes. +- COBS adds at most `ceil(pre_cobs_length / 254) + 1` bytes of overhead. +- Plus one `0x00` delimiter byte. + +Implementations SHOULD use buffer sizes of at least `max_payload_bytes + 32` bytes to accommodate all frames comfortably. + +### 2.4 COBS Encoding + +Frames are encoded with Consistent Overhead Byte Stuffing (COBS), using `0x00` as the framing byte. This guarantees that `0x00` never appears within an encoded frame, so a single `0x00` reliably marks frame boundaries. + +**Algorithm**: The standard COBS algorithm (Cheshire & Baker, 1999). Reference implementation in Appendix A. + +**Decoder resynchronization**: A receiver that opens the stream mid-frame, or that has lost bytes, discards received bytes until the next `0x00` and begins decoding at the byte following that `0x00`. No other synchronization mechanism is used. + +**Overhead**: At most `ceil(N / 254) + 1` bytes for a pre-encoded frame of N bytes, plus the trailing `0x00` delimiter. For typical frames (under 254 bytes), overhead is 2 bytes. + +### 2.5 Receiver Parsing Algorithm + +On receipt of a byte stream: + +1. Accumulate bytes until a `0x00` byte is received. That `0x00` is the frame delimiter. +2. COBS-decode the bytes that preceded the delimiter. +3. If the decoded length is less than 5 bytes, discard. Device MAY emit asynchronous `ERR(EFRAME)`. +4. Split the decoded bytes: `type` (1) || `tag` (2) || `payload` (variable) || `crc16` (2). +5. Compute CRC-16/CCITT-FALSE over `type || tag || payload`. If it does not match `crc16`, discard. Device MAY emit asynchronous `ERR(EFRAME)`. +6. Dispatch by `type`. Unknown `type` byte on the device side: emit `ERR(EUNKNOWN_CMD)` with the received tag. Unknown `type` on the host side: silently discard. + +A partial frame at stream close (bytes received without a terminating `0x00`) is always discarded. + +--- + +## 3. State Machine + +The device has exactly two states: **UNCONFIGURED** and **CONFIGURED**. + +``` + ┌──────────────────┐ + │ UNCONFIGURED │ ← initial state on boot + └──────────────────┘ + │ ▲ + SET_CONFIG │ │ inactivity timeout (1 s) + result= │ │ OR client disconnect + APPLIED ▼ │ + ┌──────────────────┐ + │ CONFIGURED │ + └──────────────────┘ +``` + +### 3.1 UNCONFIGURED + +- Entered on boot, after inactivity timeout, or on final client disconnect. +- Radio is in standby (TX disabled, RX disabled, minimal current). +- TX queue is empty. RX ring is empty. +- Accepted commands: `PING`, `GET_INFO`, `SET_CONFIG`. +- Other commands (`TX`, `RX_START`, `RX_STOP`) return `ERR(ENOTCONFIGURED)`. + +### 3.2 CONFIGURED + +- Entered when `SET_CONFIG` returns `result = APPLIED`. +- Radio parameters match the most recent successful `SET_CONFIG` payload. +- All commands accepted. +- Radio is idle (no RX, no TX) unless `RX_START` has been issued or a TX is in flight. + +### 3.3 Transitions + +**UNCONFIGURED → CONFIGURED**: successful `SET_CONFIG` with `result = APPLIED`. + +**CONFIGURED → UNCONFIGURED**: +- Inactivity timer expires (no frames received for 1000 ms), OR +- Final client disconnects at the transport layer. + +Note: `SET_CONFIG` results of `ALREADY_MATCHED` or `LOCKED_MISMATCH` do NOT transition state. They are responses observing the current configuration. + +**Within CONFIGURED**: `SET_CONFIG` may be issued again to change params (subject to the lock in multi-client, Section 13). See Section 3.5 for operations spanning this transition. + +### 3.4 Liveness Timer + +- Each client maintains an inactivity timer with a fixed period of **1000 ms**. +- The timer is **inert** until the first frame from that client has been received. A freshly-booted device with no activity does not time out. The timer starts when the first frame arrives and resets on every subsequent frame. +- The timer is reset whenever any byte stream activity results in a complete frame arriving, whether that frame parses successfully or not. Even a frame that fails CRC or COBS resets the timer — the bytes arrived, meaning the host is present. +- If the timer expires: + 1. All queued-but-not-started TXs issued by that client are discarded silently. No `TX_DONE` is emitted. + 2. Any TX on the air is allowed to complete (it cannot be cleanly aborted). No `TX_DONE` is emitted. + 3. In single-client: the device transitions to UNCONFIGURED. RX ring is cleared. Any continuous RX is aborted. The timer returns to the inert state until the next frame arrives. + 4. In multi-client: only that client's session state is cleaned up. See Section 13. + +A client whose inactivity timer has fired is considered disconnected. Transport-level disconnect events (USB detach, BLE disconnect, TCP close) have identical effect to timer expiration. + +### 3.5 Operations Spanning Reconfiguration + +**`SET_CONFIG` while a TX is on the air**: the in-progress TX is allowed to physically complete. Its `TX_DONE` is emitted normally. Any queued-but-not-yet-started TXs are cancelled and emit `TX_DONE(result=CANCELLED)`. Continuous RX, if active, is aborted. RX ring is cleared. New configuration takes effect after the in-flight TX finishes. + +**`SET_CONFIG` validation failure (`ERR(EPARAM)`, `ERR(ELENGTH)`, `ERR(EMODULATION)`)**: the chip is not touched. All parameters are validated before any SPI write to the radio. If validation fails, the radio and state machine are unchanged. + +**`SET_CONFIG` with no lock conflict, applied successfully**: the radio is driven through its configuration sequence (which includes any required image calibration, ~3–15 ms on SX126x). The `OK` response is not emitted until the radio has completed configuration and is ready to accept TX / RX commands. + +**Hardware failure during `SET_CONFIG`**: If the radio does not respond to SPI commands or returns unexpected status during configuration, the device returns `ERR(ERADIO)` synchronously with the command's tag. The radio is placed in the safest reachable state (typically standby) and the device transitions to UNCONFIGURED. No follow-up asynchronous `ERR` is emitted for the same fault. + +**Disconnect during `SET_CONFIG`**: If the client disconnects while the radio is being reconfigured (the ~15 ms calibration window), the configuration completes to the chip (it is not interrupted), the `OK` is generated and then silently dropped, and normal disconnect handling proceeds: the device transitions to UNCONFIGURED and re-idles the radio. No partial-configuration state is possible. + +--- + +## 4. Message Types + +| Hex | Direction | Name | Brief | +|------|-----------|--------------|-------------------------------------------| +| 0x01 | H→D | PING | Liveness | +| 0x02 | H→D | GET_INFO | Query capabilities and identity | +| 0x03 | H→D | SET_CONFIG | Configure the radio | +| 0x04 | H→D | TX | Transmit a packet | +| 0x05 | H→D | RX_START | Begin continuous reception | +| 0x06 | H→D | RX_STOP | End continuous reception | +| 0x80 | D→H | OK | Positive response | +| 0x81 | D→H | ERR | Negative response or asynchronous fault | +| 0xC0 | D→H | RX | Packet received | +| 0xC1 | D→H | TX_DONE | TX concluded (transmitted/busy/cancelled) | + +All other `type` byte values are reserved. Devices MUST reject unknown command types (bytes with MSB = 0) with `ERR(EUNKNOWN_CMD)` echoing the received tag. Hosts MUST silently discard frames with unknown device→host type bytes (MSB = 1). + +--- + +## 5. Common Data Types + +### 5.1 Scalar Encodings + +| Name | Width | Encoding | +|------|-------|------------------------------------------| +| u8 | 1 | Unsigned byte | +| u16 | 2 | Unsigned, little-endian | +| u32 | 4 | Unsigned, little-endian | +| u64 | 8 | Unsigned, little-endian | +| i8 | 1 | Signed byte, two's complement | +| i16 | 2 | Signed, little-endian, two's complement | +| i32 | 4 | Signed, little-endian, two's complement | + +### 5.2 Physical-Unit Encodings + +| Quantity | Encoding | +|------------------|----------------------------------------------------------| +| Frequency | `u32`, hertz | +| TX power | `i8`, dBm | +| RSSI | `i16`, tenths of dBm (e.g., −1234 = −123.4 dBm) | +| SNR | `i16`, tenths of dB (e.g., −45 = −4.5 dB) | +| Frequency error | `i32`, hertz | +| Time interval | `u32`, microseconds | +| Monotonic time | `u64`, microseconds since device boot (does not wrap) | + +### 5.3 Variable-Length Fields + +Fields whose length is not explicitly given in a message's payload definition extend from their stated start offset to the end of the frame's payload. Their length is implicit from the total frame length. + +Each message type has at most two variable-length fields, and they always appear last. When two appear together (e.g., the UIDs in `GET_INFO` response), explicit length prefixes disambiguate them. + +--- + +## 6. Message Reference + +### 6.1 PING (0x01, H→D) + +**Purpose**: Keepalive. Sent by the host when it has no other work to do, to prevent the device's inactivity timer from expiring. May also be used to measure round-trip latency. + +**Payload**: Empty (0 bytes). + +**Response**: `OK` with empty payload, tag echoed. + +**Valid in**: any state (UNCONFIGURED or CONFIGURED). + +**Errors**: none specific. + +--- + +### 6.2 GET_INFO (0x02, H→D) + +**Purpose**: Query the device's identity, firmware version, radio type, capabilities, and queue capacities. Optional — the host is not required to call it. Typically called once, after connecting. + +**Payload**: Empty. + +**Response**: `OK` with the following payload, tag echoed: + +``` +Offset Size Field Description + 0 1 proto_major u8, protocol major version (1 for this spec) + 1 1 proto_minor u8, protocol minor version (0 for this spec) + 2 1 fw_major u8, firmware major + 3 1 fw_minor u8, firmware minor + 4 1 fw_patch u8, firmware patch + 5 2 radio_chip_id u16, see Section 8 + 7 8 capability_bitmap u64, see Section 9 +15 2 supported_sf_bitmap u16, bit N = LoRa SF N is supported (N in 0..15) +17 2 supported_bw_bitmap u16, bit N = LoRa BW enum value N is supported (N in 0..15) +19 2 max_payload_bytes u16, max over-the-air payload +21 2 rx_queue_capacity u16, RX ring depth +23 2 tx_queue_capacity u16, TX queue depth +25 4 freq_min_hz u32, minimum carrier frequency the radio supports +29 4 freq_max_hz u32, maximum carrier frequency the radio supports +33 1 tx_power_min_dbm i8, minimum TX power (most negative) +34 1 tx_power_max_dbm i8, maximum TX power +35 1 mcu_uid_len u8, 0 to 32 +36 N mcu_uid N = mcu_uid_len +36+N 1 radio_uid_len u8, 0 to 16 +37+N M radio_uid M = radio_uid_len +``` + +Total payload size: `37 + mcu_uid_len + radio_uid_len` bytes. + +**Valid in**: any state. + +**Errors**: none specific. + +**Notes**: +- `radio_uid_len` MAY be 0 if the radio has no accessible unique identifier. +- `mcu_uid` and `radio_uid` are opaque byte strings; hosts SHOULD treat them as unique identifiers for the silicon and not interpret their internal structure. +- `capability_bitmap` tells the host which modulations and features this device supports before it attempts `SET_CONFIG`. +- `supported_sf_bitmap` and `supported_bw_bitmap` describe LoRa-specific capabilities. Each bit N corresponds to value N of the respective enum (SF index for SF, BW enum value for BW). Hosts SHOULD consult these before issuing `SET_CONFIG` to avoid trial-and-error. Rationale: some chips have gaps (e.g., LLCC68 supports SF5–SF11 but not SF12; SX127x supports SF6–SF12 but not SF5), which a min/max pair cannot express. +- Both bitmaps are meaningful only when `capability_bitmap` bit 0 (LoRa) is set. On non-LoRa-capable devices they MAY be zero. +- `rx_queue_capacity` and `tx_queue_capacity` help the host pipeline intelligently without overrunning the device. +- `freq_min_hz` / `freq_max_hz` bracket the RF front-end's supported range for this specific board. A chip's silicon may cover a wider range than the matching network permits, so these values reflect the board's effective tuning window. +- `tx_power_min_dbm` / `tx_power_max_dbm` bracket the chip's settable TX power register range. The host SHOULD validate its `tx_power_dbm` against these bounds before calling `SET_CONFIG`; values outside the range yield `ERR(EPARAM)`. +- All `GET_INFO` fields are stable for the lifetime of a session. The device MUST NOT change them after boot. Hosts MAY cache the response for the duration of the connection. +- If the firmware cannot identify its radio (e.g., SPI probe failed), `radio_chip_id` is `0x0000` and the host SHOULD abort — the device will not function. + +--- + +### 6.3 SET_CONFIG (0x03, H→D) + +**Purpose**: Configure the radio for a given modulation and parameter set. This is the only command that can transition the device from UNCONFIGURED to CONFIGURED. It may also be issued in CONFIGURED to change parameters. + +**Payload**: + +``` +Offset Size Field Description + 0 1 modulation_id u8, see Section 10 + 1 N params Modulation-specific struct (Section 10) +``` + +The length of `params` is determined entirely by `modulation_id` (Section 10). Sending a payload whose total length does not match `1 + expected_param_size` yields `ERR(ELENGTH)`. + +**Response (on success)**: `OK` with the following payload, tag echoed: + +``` +Offset Size Field Description + 0 1 result u8: 0=APPLIED, 1=ALREADY_MATCHED, 2=LOCKED_MISMATCH + 1 1 owner u8: 0=NONE, 1=MINE, 2=OTHER + 2 1 current_modulation u8, modulation currently in effect on the radio + 3 N current_params Parameters currently in effect +``` + +`current_modulation` and `current_params` always reflect what is actually programmed into the radio at the moment the response is constructed, regardless of whether this call caused a change. This gives the host a consistent ground truth in every case. + +**Result values**: +- **APPLIED (0)**: The request was accepted. The radio is now configured as requested. The calling client owns the lock (single-client: always `MINE`). +- **ALREADY_MATCHED (1)**: Another client holds the lock, but the active configuration byte-for-byte matches the requested one. No change was made. The calling client may proceed to use the radio cooperatively but does not own the lock. +- **LOCKED_MISMATCH (2)**: Another client holds the lock and the active configuration differs. No change was made. The calling client may inspect `current_params` to diff. + +**Owner values**: +- **NONE (0)**: No client currently holds the lock. +- **MINE (1)**: The calling client holds the lock. +- **OTHER (2)**: A different client holds the lock. + +**Valid in**: any state. + +**Errors** (all synchronous, with tag echoed): +- `ERR(EMODULATION)`: `modulation_id` is not supported on this chip. Check the capability bitmap. +- `ERR(ELENGTH)`: payload length does not match the expected size for `modulation_id`. +- `ERR(EPARAM)`: a parameter value is out of range (e.g., frequency outside chip's supported range, SF out of range, reserved field nonzero). + +**Atomicity**: All parameters are validated before any SPI write is issued to the radio. On validation failure the radio is untouched; the device returns `ERR` and remains in its prior state (UNCONFIGURED or CONFIGURED with prior params). Validation cannot leave the radio in an intermediate state. + +**Side effects on `APPLIED`**: +- Any in-progress TX is allowed to finish; its `TX_DONE` is emitted normally. +- All queued (not yet started) TXs are cancelled with `TX_DONE(result=CANCELLED)`. +- Any continuous RX is stopped. +- RX ring is cleared. +- Radio is reconfigured, calibrated if required, and left in idle. The `OK` response is not emitted until this sequence completes. + +**Notes**: +- **Auto-LDRO**: For LoRa, the device automatically enables Low Data Rate Optimization when `(2^SF) / BW_Hz > 16 ms` (the Semtech-recommended threshold). The host does not specify LDRO and is not informed of its state. +- Reconfiguring to identical parameters is a valid call. In single-client mode, the response is always `result = APPLIED, owner = MINE`. No short-circuit is performed; the radio is genuinely re-initialized. +- In multi-client mode, see Section 13.2 for lock behavior. + +--- + +### 6.4 TX (0x04, H→D) + +**Purpose**: Transmit a single packet. + +**Payload**: + +``` +Offset Size Field Description + 0 1 flags u8, see below + 1 N bytes Packet bytes, 1 ≤ N ≤ max_payload_bytes +``` + +**Flags** (bit 0 = LSB): +- **Bit 0 — `skip_cad`**: If 1, skip the default CAD-before-TX and transmit immediately. If 0, the device performs a CAD cycle first and transmits only if the channel is clear (emitting `TX_DONE(CHANNEL_BUSY)` otherwise). Default behavior (bit 0 = 0) is the recommended path. +- **Bits 1–7**: Reserved. MUST be 0. Device rejects frames with any reserved bit set via `ERR(EPARAM)`. + +**Response**: `OK` with empty payload, tag echoed. This response indicates the command was accepted and enqueued. It does NOT indicate the packet was transmitted. The transmission outcome is delivered later as a `TX_DONE` event with the same tag. + +**Asynchronous completion**: exactly one `TX_DONE` event with matching tag (Section 6.10), unless the command failed synchronously (received `ERR`, no `TX_DONE` follows) or the client disconnected before transmission (no `TX_DONE` emitted, since the client is gone). + +**Valid in**: CONFIGURED only. + +**Errors**: +- `ERR(ENOTCONFIGURED)`: device is UNCONFIGURED. +- `ERR(ELENGTH)`: N = 0 (zero-byte packets are not permitted), N > `max_payload_bytes`, or N exceeds the current modulation's per-mode maximum (which may be less than `max_payload_bytes`). +- `ERR(EPARAM)`: a reserved flag bit is set. +- `ERR(EBUSY)`: internal TX queue is full (already holds `tx_queue_capacity` pending TXs). Host should wait for an outstanding `TX_DONE` before retrying with a new tag. + +**Max payload note**: `max_payload_bytes` from `GET_INFO` is the chip-wide maximum. Individual modulations may have stricter limits (e.g., LR-FHSS payloads are ≤ ~100 bytes depending on configuration). A packet larger than the current modulation supports is rejected with `ERR(ELENGTH)`, even if it fits within `max_payload_bytes`. + +**CAD-default behavior on non-CAD modulations**: On modulations that do not support CAD (FSK, GFSK, FLRC, etc.), the `skip_cad` flag is silently ignored and the TX fires immediately. Host code does not change between LoRa and non-LoRa modes. + +**RX interruption by TX**: On half-duplex chips (all Semtech transceivers except when capability bit 22 is set), the radio briefly leaves RX to transmit and returns to RX afterward. A packet that was mid-reception during the transmission window is lost. On full-duplex chips, TX does not interrupt RX. + +**CAD timing**: For LoRa, the CAD duration depends on SF and BW. At SF7/BW125 it is ~4 ms; at SF12/BW125 it is ~130 ms. Hosts that need tight TX latency should either use a lower SF or set `skip_cad = 1`. + +--- + +### 6.5 RX_START (0x05, H→D) + +**Purpose**: Place the radio in continuous receive mode. Every received packet (including ones with bad CRC) produces an `RX` event. + +**Payload**: Empty. + +**Response**: `OK` with empty payload, tag echoed. + +**Valid in**: CONFIGURED only. + +**Errors**: +- `ERR(ENOTCONFIGURED)`: device is UNCONFIGURED. +- `ERR(EMODULATION)`: the currently-configured modulation does not support reception (e.g., LR-FHSS is transmit-only on all current Semtech silicon). + +**Notes**: +- Calling `RX_START` when already in continuous RX is a no-op. Response is `OK`. +- A `TX` interrupts RX, transmits, and the radio automatically returns to continuous RX afterward without a new `RX_START`. (On full-duplex chips, the interruption does not occur.) +- `SET_CONFIG` aborts continuous RX. Host must re-issue `RX_START` after reconfiguration if it still wants to receive. + +--- + +### 6.6 RX_STOP (0x06, H→D) + +**Purpose**: End continuous reception; radio returns to idle. + +**Payload**: Empty. + +**Response**: `OK` with empty payload, tag echoed. + +**Valid in**: CONFIGURED only. + +**Errors**: +- `ERR(ENOTCONFIGURED)`: device is UNCONFIGURED. + +**Notes**: +- Calling `RX_STOP` when not in continuous RX is a no-op. Response is `OK`. +- RX events already in the ring at the time of `RX_STOP` remain queued; the host continues to receive them as normal `RX` frames until the ring drains. + +--- + +### 6.7 OK (0x80, D→H) + +**Purpose**: Positive response to a host command. + +**Tag**: Echoes the originating command's tag. Never `0x0000`. + +**Payload**: Structure depends on originating command: +- `PING`, `TX`, `RX_START`, `RX_STOP`: empty. +- `GET_INFO`: info struct (Section 6.2). +- `SET_CONFIG`: result / owner / current-config struct (Section 6.3). + +**Notes**: To correctly interpret an `OK` payload, the host must know which command issued the echoed tag. Outstanding-tag bookkeeping is mandatory host-side. + +--- + +### 6.8 ERR (0x81, D→H) + +**Purpose**: Negative response to a command, OR asynchronous radio / protocol fault. + +**Tag**: +- Non-zero → synchronous error, tag echoes the originating command. The command was not carried out and no compensating event will follow. +- Zero → asynchronous error, not tied to any specific command. Fanned out to all connected clients in multi-client mode. + +**Payload**: + +``` +Offset Size Field Description + 0 2 code u16, error code (Section 7) +``` + +--- + +### 6.9 RX (0xC0, D→H) + +**Purpose**: Unsolicited event indicating a packet was received over the air (or, in multi-client mode, that another client transmitted and this is the loopback). + +**Tag**: Always `0x0000`. + +**Payload**: + +``` +Offset Size Field Description + 0 2 rssi i16, tenths of dBm + 2 2 snr i16, tenths of dB + 4 4 freq_err i32, hertz, estimated LO offset + 8 8 timestamp_us u64, µs since boot, captured at RxDone IRQ (packet end) +16 1 crc_valid u8: 0=CRC failed, 1=CRC passed or CRC not configured +17 2 packets_dropped u16, packets dropped since previous delivered RX +19 1 origin u8: 0=over-the-air, 1=device-local loopback +20 N bytes Packet payload, N determined by frame length +``` + +**Field semantics**: +- `timestamp_us` is captured the instant the radio asserts RxDone (the end-of-packet IRQ). +- `crc_valid = 0` packets are delivered to the host anyway, because they may still be of interest (debugging, partial capture). The bytes may be corrupt. +- When `crc_valid = 0`, `snr` and `freq_err` may be meaningless. +- `packets_dropped` is cleared to 0 on every delivered `RX` event. It represents losses since the last successful delivery. +- `origin = 1` is only possible in multi-client mode (Section 13). In single-client it is always 0. +- `bytes` has length `N ≥ 0` and always satisfies `N ≤ max_payload_bytes`. If the radio reports a length field larger than `max_payload_bytes` (possible on a corrupted PHY header), the device MUST truncate to `max_payload_bytes` and set `crc_valid = 0`. Hosts can rely on `N ≤ max_payload_bytes` unconditionally. + +--- + +### 6.10 TX_DONE (0xC1, D→H) + +**Purpose**: Asynchronous completion of a previously-issued `TX`. + +**Tag**: Echoes the tag of the originating `TX`. + +**Payload**: + +``` +Offset Size Field Description + 0 1 result u8: 0=TRANSMITTED, 1=CHANNEL_BUSY, 2=CANCELLED + 1 4 airtime_us u32, on-air duration in microseconds (0 for non-TRANSMITTED) +``` + +**Result values**: +- **TRANSMITTED (0)**: Packet went on the air. `airtime_us` is the measured or computed time-on-air. +- **CHANNEL_BUSY (1)**: CAD detected activity; TX was aborted before transmission. `airtime_us = 0` (CAD time itself is not counted). This result is only possible when `skip_cad = 0` was set in the originating TX **and** the current modulation supports CAD (LoRa only). Any TX with `skip_cad = 1`, or any TX on a non-CAD modulation, will never produce CHANNEL_BUSY. +- **CANCELLED (2)**: TX was discarded because `SET_CONFIG` or disconnect occurred before the packet reached the air. `airtime_us = 0`. + +**Notes**: +- Exactly one `TX_DONE` is emitted per `TX` that received an `OK`. A `TX` that received `ERR` produces no `TX_DONE`. +- On client disconnect, `TX_DONE` for cancelled TXs is not emitted — the client is gone. +- On `CHANNEL_BUSY`, hosts should apply randomized backoff and retry with a **new tag**. The prior tag's state is closed and the same tag cannot be reused while any earlier-issued outstanding tag is still open. + +--- + +## 7. Error Codes + +All error codes are `u16` little-endian. The tag on the enclosing `ERR` frame distinguishes context: non-zero tag = synchronous error tied to a specific command; tag 0x0000 = asynchronous error not tied to a command. The two tables below group codes by the context in which they are *typically* emitted, but the device MAY emit any code in either context when it fits semantically (e.g., `ERR(ERADIO)` is listed as asynchronous but is emitted synchronously when a hardware fault occurs during processing of a specific command, such as SPI failure during `SET_CONFIG`). + +### 7.1 Synchronous (tag ≠ 0), typical + +| Code | Name | Meaning | +|--------|-----------------|---------------------------------------------------------------| +| 0x0001 | EPARAM | A parameter value is out of range or invalid. | +| 0x0002 | ELENGTH | Payload length is wrong for the command or modulation. | +| 0x0003 | ENOTCONFIGURED | Command requires CONFIGURED; device is UNCONFIGURED. | +| 0x0004 | EMODULATION | Requested modulation is not supported by this chip. | +| 0x0005 | EUNKNOWN_CMD | Unknown command type byte. | +| 0x0006 | EBUSY | Transient: TX queue full. Host should wait and retry. | + +Codes 0x0000–0x00FF are reserved for synchronous errors. + +### 7.2 Asynchronous (tag = 0), typical + +| Code | Name | Meaning | +|--------|-----------|----------------------------------------------------------------------| +| 0x0101 | ERADIO | Radio SPI error or unexpected hardware state. | +| 0x0102 | EFRAME | Inbound frame had bad CRC, bad COBS, or wrong length. | +| 0x0103 | EINTERNAL | Firmware encountered an unexpected internal condition. | + +Codes 0x0100–0x01FF are reserved for asynchronous errors. Codes 0x0200 and above are reserved for future extensions. + +**Impact of asynchronous errors on pending commands**: An `EFRAME` on a frame that was *supposed* to be a response leaves the host's corresponding outstanding tag open indefinitely from the device's perspective. Hosts MUST implement their own command timeouts (Section 14.2) to avoid tag exhaustion or indefinite waiting. + +--- + +## 8. Radio Chip ID Enum + +| Value | Chip | Family | +|---------|--------|---------------------| +| 0x0000 | Unknown| — | +| 0x0001 | SX1261 | Gen 2 sub-GHz | +| 0x0002 | SX1262 | Gen 2 sub-GHz | +| 0x0003 | SX1268 | Gen 2 sub-GHz | +| 0x0004 | LLCC68 | Gen 2 sub-GHz | +| 0x0010 | SX1272 | Gen 1 sub-GHz | +| 0x0011 | SX1276 | Gen 1 sub-GHz | +| 0x0012 | SX1277 | Gen 1 sub-GHz | +| 0x0013 | SX1278 | Gen 1 sub-GHz | +| 0x0014 | SX1279 | Gen 1 sub-GHz | +| 0x0020 | SX1280 | Gen 2 2.4 GHz | +| 0x0021 | SX1281 | Gen 2 2.4 GHz | +| 0x0030 | LR1110 | Gen 3 multi-band | +| 0x0031 | LR1120 | Gen 3 multi-band | +| 0x0032 | LR1121 | Gen 3 multi-band | +| 0x0040 | LR2021 | Gen 4 multi-band | + +Values not listed here are reserved. Future chips will be assigned in the next available slot of their family's range. + +--- + +## 9. Capability Bitmap + +The `capability_bitmap` field in `GET_INFO` is a `u64`. Bits are numbered from LSB. Undefined bits MUST be 0 in v1.0. Hosts MUST ignore undefined bits. + +### 9.1 Modulations (bits 0–15) + +| Bit | Modulation | +|-----|------------------| +| 0 | LoRa | +| 1 | FSK | +| 2 | GFSK | +| 3 | LR-FHSS | +| 4 | FLRC | +| 5 | MSK | +| 6 | GMSK | +| 7 | BLE-compatible | +| 8–15| Reserved | + +A bit set here means the device's `SET_CONFIG` accepts the corresponding `modulation_id`. + +Multiple modulation bits being set does NOT imply the device can operate in multiple modulations simultaneously. The radio is configured for exactly one modulation at a time (whichever was most recently applied via `SET_CONFIG`). To switch modulations, issue another `SET_CONFIG`. + +### 9.2 Radio Features (bits 16–31) + +| Bit | Feature | +|-----|----------------------------------------------------------------------| +| 16 | CAD-before-TX available (firmware performs CAD in default TX path) | +| 17 | IQ inversion supported in LoRa mode | +| 18 | Ranging / time-of-flight distance measurement | +| 19 | GNSS scanning | +| 20 | WiFi MAC scanning | +| 21 | Spectral scan | +| 22 | Full-duplex (simultaneous TX + RX) | +| 23–31| Reserved | + +If bit 16 is 0, the device does not perform CAD before TX even for LoRa mode; the `skip_cad` flag has no effect. + +### 9.3 Protocol Features (bits 32–47) + +| Bit | Feature | +|-----|----------------------| +| 32 | Multi-client support | +| 33–47| Reserved | + +### 9.4 Reserved (bits 48–63) + +All reserved for future use. + +--- + +## 10. Modulation Parameters + +The `modulation_id` byte in `SET_CONFIG` selects one of the following parameter structs. Any value not listed here is reserved and yields `ERR(EMODULATION)` (if the chip conceptually supports the modulation but the ID is not yet defined) or `ERR(EMODULATION)` (if the chip does not support it at all). + +| modulation_id | Modulation | Fixed struct size | +|---------------|------------|------------------------| +| 0x01 | LoRa | 15 bytes | +| 0x02 | FSK / GFSK | 16 + `sync_word_len` | +| 0x03 | LR-FHSS | 10 bytes | +| 0x04 | FLRC | 13 bytes | + +### 10.1 LoRa (modulation_id = 0x01, 15 bytes) + +``` +Offset Size Field Range / notes + 0 4 freq_hz u32, chip-supported range (EPARAM if outside) + 4 1 sf u8, 5–12 (chip-dependent: SX127x is 6–12) + 5 1 bw u8, bandwidth enum (see below) + 6 1 cr u8, coding-rate enum (see below) + 7 2 preamble_len u16, in symbols (typical min 6) + 9 2 sync_word u16 (SX127x: uses low byte only; high byte MUST be 0) +11 1 tx_power_dbm i8, chip-dependent range +12 1 header_mode u8, 0=explicit, 1=implicit +13 1 payload_crc u8, 0=disabled, 1=enabled +14 1 iq_invert u8, 0=normal, 1=inverted +``` + +**Bandwidth enum** (the small integer on the wire, not the kHz value): + +| Value | Bandwidth | Applicable chip families | +|-------|------------|---------------------------------| +| 0 | 7.81 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 1 | 10.42 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 2 | 15.63 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 3 | 20.83 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 4 | 31.25 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 5 | 41.67 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 6 | 62.5 kHz | SX126x, SX127x, LR11xx, LR2021 | +| 7 | 125 kHz | all | +| 8 | 250 kHz | all | +| 9 | 500 kHz | all | +| 10 | 200 kHz | SX128x | +| 11 | 400 kHz | SX128x | +| 12 | 800 kHz | SX128x | +| 13 | 1600 kHz | SX128x | + +A value not supported by the current radio yields `ERR(EPARAM)`. Hosts SHOULD consult `radio_chip_id` and select from the applicable set. + +**Coding-rate enum**: + +| Value | Coding rate | +|-------|-------------| +| 0 | 4/5 | +| 1 | 4/6 | +| 2 | 4/7 | +| 3 | 4/8 | + +Any value outside these enumerations produces `ERR(EPARAM)`. + +### 10.2 FSK / GFSK (modulation_id = 0x02, variable size) + +``` +Offset Size Field Description + 0 4 freq_hz u32, chip-supported range + 4 4 bitrate_bps u32, bits per second + 8 4 freq_dev_hz u32, FSK frequency deviation +12 1 rx_bw u8, chip-specific RX bandwidth enum +13 2 preamble_len u16, in bits +15 1 sync_word_len u8, 0–8 +16 N sync_word N = sync_word_len bytes +``` + +Total struct size: `16 + sync_word_len` bytes. + +**Sync word byte order**: The byte at offset 16 of the payload is transmitted first on the air; subsequent bytes follow in order. Within each byte, the MSB is transmitted first (standard FSK/GFSK convention). + +GFSK is selected by the firmware when the chip supports Gaussian filtering at the requested bitrate; the wire protocol does not distinguish it from FSK beyond the capability bitmap. + +### 10.3 LR-FHSS (modulation_id = 0x03, 10 bytes) + +``` +Offset Size Field Description + 0 4 freq_hz u32, center frequency + 4 1 bw_enum u8, LR-FHSS occupied-bandwidth enum (see below) + 5 1 cr_enum u8, LR-FHSS coding-rate enum (see below) + 6 1 grid u8, 0=25.39 kHz grid, 1=3.9 kHz grid + 7 1 hopping u8, 0=disabled, 1=enabled + 8 1 tx_power_dbm i8 + 9 1 reserved u8, MUST be 0 (EPARAM otherwise) +``` + +**LR-FHSS bandwidth enum**: + +| Value | Occupied BW | +|-------|-------------| +| 0 | 39.06 kHz | +| 1 | 85.94 kHz | +| 2 | 136.72 kHz | +| 3 | 183.59 kHz | +| 4 | 335.94 kHz | +| 5 | 386.72 kHz | +| 6 | 722.66 kHz | +| 7 | 1523.44 kHz | + +**LR-FHSS coding-rate enum**: + +| Value | Coding rate | +|-------|-------------| +| 0 | 5/6 | +| 1 | 2/3 | +| 2 | 1/2 | +| 3 | 1/3 | + +### 10.4 FLRC (modulation_id = 0x04, 13 bytes) + +``` +Offset Size Field Description + 0 4 freq_hz u32, carrier frequency + 4 1 bitrate_enum u8, bitrate enum (see below) + 5 1 cr_enum u8, FLRC coding-rate (0=1/2, 1=3/4, 2=1/1) + 6 1 bt_enum u8, Gaussian BT product (0=off, 1=0.5, 2=1.0) + 7 1 preamble_len u8, preamble length enum (see below) + 8 4 sync_word u32, 32-bit sync word (transmitted MSB-first on air) +12 1 tx_power_dbm i8 +``` + +**FLRC bitrate enum**: + +| Value | Bitrate | +|-------|-------------| +| 0 | 2600 kbps | +| 1 | 2080 kbps | +| 2 | 1300 kbps | +| 3 | 1040 kbps | +| 4 | 650 kbps | +| 5 | 520 kbps | +| 6 | 325 kbps | +| 7 | 260 kbps | + +**FLRC preamble length enum**: + +| Value | Preamble bits | +|-------|---------------| +| 0 | 8 | +| 1 | 12 | +| 2 | 16 | +| 3 | 20 | +| 4 | 24 | +| 5 | 28 | +| 6 | 32 | + +Any enum value outside these tables yields `ERR(EPARAM)`. + +FLRC specifics vary between SX128x and LR2021; the firmware maps chip-specific register values to these byte values. Values that don't correspond to a supported setting on the current chip produce `ERR(EPARAM)`. + +--- + +## 11. Ordering and Interleaving + +These invariants define what hosts and devices may rely on. + +**Command responses are monotonic per session**: +- If the host sends command A (tag = T_A) before command B (tag = T_B), then the device's response to A (`OK` or `ERR` with tag T_A) is emitted before the response to B (`OK` or `ERR` with tag T_B). +- This does NOT apply across clients in multi-client mode — each client sees its own command responses in order, but responses across clients may interleave arbitrarily. + +**TX_DONE order matches TX order**: +- If TX with tag T_1 was accepted before TX with tag T_2 (same client), then `TX_DONE(T_1)` is emitted before `TX_DONE(T_2)`. +- This follows from the serial nature of the TX queue. + +**RX and async events are unordered relative to responses**: +- `RX` events and asynchronous `ERR` events (tag = 0) may appear at any point in the device→host stream, interleaved with command responses. +- A host reading the stream cannot assume that a response to a pending command will arrive before an `RX` event, even if the command was issued first. + +**Exactly-once semantics**: +- Every command that received `OK` or `ERR` is concluded (plus the additional `TX_DONE` for `TX` that got `OK`). +- No duplicated responses, no lost responses (absent transport-level failure). + +--- + +## 12. Flow Control and Backpressure + +### 12.1 Transport-Level Flow Control + +DongLoRa Protocol relies on the underlying byte-stream transport to provide flow control: +- **USB CDC-ACM / CDC-UART bridges**: bulk endpoints provide implicit flow control via NAKs when the receiver is not ready. +- **Plain UART**: no flow control (host SHOULD use a reasonable bitrate like 921600 bps and respect device processing time). +- **BLE**: L2CAP / GATT credit-based flow control. +- **TCP**: window-based flow control. + +If the device's internal receive buffer is full, it MAY stall the transport (stop ACKing / NAKing bytes) until buffer space is freed. + +### 12.2 Device-Side Queue Limits + +- **TX queue**: depth = `tx_queue_capacity` (from `GET_INFO`). Additional TX attempts return `ERR(EBUSY)`. Host should wait for an outstanding `TX_DONE` before retrying. +- **RX ring**: depth = `rx_queue_capacity`. On overflow, oldest-first drop policy applies; the drop count is delivered in the next `RX` event's `packets_dropped` field. + +### 12.3 Host-Side Expectations + +Hosts SHOULD: +- Size their RX decoder buffer to at least `max_payload_bytes + 32` bytes. +- Not pipeline more commands than `tx_queue_capacity` (for TX) without waiting for responses. +- Drain the device→host stream continuously. A host that stops reading will eventually stall the device. + +--- + +## 13. Multi-Client Operation (Future) + +This section describes behavior on devices advertising capability bit 32 (multi-client). Single-client USB deployments may skip this section but note that multi-client-aware fields (`result`, `owner`, `origin`) appear on both and are defined consistently. + +### 13.1 Per-Client Liveness + +Each client has an independent 1000 ms inactivity timer. A client whose timer expires is disconnected; other clients are unaffected. + +### 13.2 Configuration Lock + +- The first `SET_CONFIG` from any client, while no lock is held, acquires the lock. +- The locking client may issue further `SET_CONFIG` commands freely; they always apply and return `result=APPLIED, owner=MINE`. +- A non-locking client's `SET_CONFIG`: + - If requested params match active params byte-for-byte: `OK(result=ALREADY_MATCHED, owner=OTHER)`. No change. + - Otherwise: `OK(result=LOCKED_MISMATCH, owner=OTHER)`. No change. +- The lock is released when the locking client disconnects (transport-level OR liveness timeout). If other clients are still connected, the device remains in CONFIGURED with the current params until the next successful `SET_CONFIG` from any client (which becomes the new locker). If no clients remain, the device transitions to UNCONFIGURED. + +### 13.3 RX Distribution + +`RX` events and asynchronous `ERR` events (tag = 0) are fanned out to every connected client. + +### 13.4 TX Distribution and Cross-Client Loopback + +- Each client's TXs go into a single global FIFO. +- Each client's `TX_DONE` is delivered only to the originating client. +- Each successful on-air TX (`TX_DONE(result=TRANSMITTED)`) is additionally delivered to all **other** connected clients as an `RX` event with: + - `rssi`, `snr`, `freq_err` = 0 + - `timestamp_us` = captured at TxDone IRQ + - `crc_valid` = 1 + - `packets_dropped` = 0 + - `origin` = 1 + - `bytes` = the transmitted packet + +TXs that completed with `CHANNEL_BUSY` or `CANCELLED` did not go on the air and do NOT generate loopback events. + +### 13.5 Single-Client Compatibility + +In single-client mode: +- `owner` is always `NONE` (before `SET_CONFIG`) or `MINE` (after). +- `result` is always `APPLIED` for successful `SET_CONFIG`; `ALREADY_MATCHED` and `LOCKED_MISMATCH` never occur. +- `origin` on `RX` is always 0. + +### 13.6 External Multiplexer Pattern + +DongLoRa Protocol intentionally does not carry a client identifier on the wire. When multi-client behavior is needed without firmware-side support, an external software multiplexer can provide it: + +- The multiplexer holds one single-client DongLoRa Protocol connection to the dongle (the dongle's sole client). +- Upstream, the multiplexer accepts multiple connections from downstream applications. +- For each frame flowing upward from the dongle, the multiplexer dispatches to the owning upstream connection based on its own internal `device_tag → (upstream_connection, upstream_tag)` mapping; for each frame flowing downward, the multiplexer rewrites the upstream-chosen tag into a fresh dongle-side tag and records the mapping. +- If the multiplexer wants to expose multi-client semantics to its upstream clients (the `result` / `owner` / `origin` fields defined in this section), it implements the locking, echo, and fan-out logic itself — the dongle is unaware. + +This is identical to the work firmware would do in Section 13.1–13.4, just implemented one layer up. When firmware later gains native multi-client support (capability bit 32), upstream applications can migrate from talking to the multiplexer to talking to the firmware directly without changing their DongLoRa Protocol implementation: the wire format is unchanged, and the transport connection itself serves as the client identity. See Section 13.7. + +### 13.7 Client Identity is the Transport Connection + +DongLoRa Protocol does not encode a client identifier in any frame. Instead, each transport-level connection (one USB CDC-ACM session, one BLE GATT link, one TCP socket) IS the client identity from the firmware's perspective. Firmware with native multi-client support (capability bit 32) accepts multiple simultaneous transport connections and maintains per-connection state internally: which client owns the config lock, which client enqueued each pending TX, which connection to deliver each RX event on. + +Consequences: +- No HELO or handshake is needed. A client opens its transport and immediately begins sending DongLoRa Protocol frames. +- The wire format is identical between single-client and multi-client firmware. A host written against single-client DongLoRa Protocol works unchanged against multi-client firmware; fields whose values don't vary for single-client (`owner`, `result = ALREADY_MATCHED/LOCKED_MISMATCH`, `origin = 1`) simply don't occur for that host. +- Firmware implementing multi-client pays the complexity of per-connection bookkeeping; single-client firmware pays nothing. Neither is on the wire. + +--- + +## 14. Host Behavior Requirements + +### 14.1 MUST + +- Send at least one frame every 1000 ms while connected, to prevent the device's inactivity timer from expiring. `PING` exists for this purpose. +- Never use tag `0x0000` on outbound commands. +- Track outstanding tags; do not reuse a tag until its final response has been received (`OK`, `ERR`, or `TX_DONE` as applicable). +- Validate the CRC on every received frame; discard frames with mismatched CRC. +- Treat COBS decoding failures as framing errors; resynchronize on the next `0x00`. +- Set all reserved flag bits and reserved payload bytes to 0. + +### 14.2 SHOULD + +- Send keepalives at 500 ms intervals or better (2× margin on the 1000 ms device timeout). +- Use a monotonic tag counter starting at 1, wrapping at 0xFFFF and skipping 0x0000. +- Call `GET_INFO` once at connection to learn `max_payload_bytes`, `capability_bitmap`, queue capacities, frequency range, TX power range, and identity. +- Validate `freq_hz` and `tx_power_dbm` locally against `GET_INFO`-reported ranges before issuing `SET_CONFIG`. +- Apply a per-command timeout to detect hung or missing responses. A reasonable default: 2000 ms for commands that do not involve airtime; for `TX`, 2000 ms + the computed packet airtime + maximum expected CAD duration. +- On `TX_DONE(CHANNEL_BUSY)`, apply randomized exponential backoff before retrying with a new tag. +- On `RX(packets_dropped > 0)`, log or surface the loss. +- On receiving an unexpected `ERR(ENOTCONFIGURED)` in response to a command that was issued while the host believed the device to be CONFIGURED, treat this as a lost-session signal: the device has timed out or rebooted. Re-issue `SET_CONFIG`, then `RX_START` if continuous receive was previously active. Any TXs that were in-flight at the time are presumed not to have been transmitted. + +### 14.3 MUST NOT + +- Send commands with any reserved flag bit or reserved payload byte set. +- Assume any device-side state persists across disconnects. +- Assume response ordering across tags beyond what Section 11 guarantees. + +--- + +## 15. Device Behavior Requirements + +### 15.1 MUST + +- Boot into UNCONFIGURED state. +- Reject unknown command types with `ERR(EUNKNOWN_CMD)` echoing the tag. +- Validate every `SET_CONFIG` parameter before writing to the radio. On validation failure the chip is not touched and an `ERR` is returned. +- Auto-enable LDRO on LoRa when `(2^SF) / BW_Hz > 16 ms`. +- Echo the command's tag on every `OK`, `ERR`, and `TX_DONE` response. +- Use tag `0x0000` for `RX` events and asynchronous `ERR` events. +- Reset the inactivity timer on every received frame, including frames that fail CRC or COBS decoding (the bytes arrived — the host is present). +- Emit exactly one of `OK` / `ERR` per command, then exactly one `TX_DONE` additionally for `TX` commands that received `OK`. +- Block the `OK` response to `SET_CONFIG` until the radio is fully configured and ready for subsequent commands (including any required calibration). + +### 15.2 SHOULD + +- Emit asynchronous `ERR(EFRAME)` when an inbound frame fails CRC or COBS decoding, to aid host-side diagnosis. +- Preserve the RX ring across TX operations (TX briefly interrupts RX, but queued RX events are not cleared). +- Use a display (if present) to reflect state: splash when UNCONFIGURED, connected status when CONFIGURED. Display behavior is not part of the protocol. + +### 15.3 MUST NOT + +- Persist any configuration, session state, tag state, or queue contents across reboots or disconnects. +- Initiate communication with the host (never send frames when the host has sent none). +- Enforce regulatory limits (TX power caps, duty cycle, band restrictions). The device is a transparent passthrough; regulatory compliance is the host's responsibility. +- Silently drop or modify packet payloads on TX or RX. + +--- + +## 16. Version Compatibility + +### 16.1 Protocol Version + +`GET_INFO.proto_major` and `GET_INFO.proto_minor` identify the protocol version the device implements. This document specifies protocol version 1.0 (`proto_major = 1`, `proto_minor = 0`). + +### 16.2 Major Version Changes + +Different major versions are wire-incompatible. A host that does not recognize the device's reported `proto_major` MUST NOT attempt to use the device. + +### 16.3 Minor Version Changes + +Minor version increments are backward-compatible: they may add new message types, new capability bits, new modulations, new error codes, or new trailing fields on existing payloads. A host SHOULD continue to work with a device reporting a higher minor version, but will not have access to features added after its implementation. + +### 16.4 Rules for Minor Version Extensions + +Future minor versions MAY: +- Define new values in reserved ranges (message types, error codes, modulation IDs, chip IDs, capability bits). +- Append new fields to the end of existing payloads. Implementations use the frame length to determine the payload boundary; fields beyond what they understand are ignored. + +Future minor versions MUST NOT: +- Change the meaning, encoding, or position of any existing field. +- Remove or renumber existing message types, error codes, chip IDs, modulation IDs, or capability bits. +- Change the semantics of existing commands or events. + +--- + +## Appendix A — COBS Reference Implementation + +```c +#include +#include + +// Encode src[0..src_len-1] to dst. Returns bytes written to dst. +// dst must have room for src_len + ceil(src_len/254) + 1 bytes. +size_t cobs_encode(const uint8_t *src, size_t src_len, uint8_t *dst) { + size_t r = 0, w = 1; + size_t code_idx = 0; + uint8_t code = 1; + + while (r < src_len) { + if (src[r] == 0) { + dst[code_idx] = code; + code_idx = w++; + code = 1; + } else { + dst[w++] = src[r]; + code++; + if (code == 0xFF) { + dst[code_idx] = code; + code_idx = w++; + code = 1; + } + } + r++; + } + dst[code_idx] = code; + return w; +} + +// Decode src[0..src_len-1] to dst. Returns bytes written to dst, +// or 0 on decode error (corrupt input). +size_t cobs_decode(const uint8_t *src, size_t src_len, uint8_t *dst) { + size_t r = 0, w = 0; + while (r < src_len) { + uint8_t code = src[r++]; + if (code == 0 || r + code - 1 > src_len) return 0; + for (uint8_t i = 1; i < code; i++) dst[w++] = src[r++]; + if (code != 0xFF && r < src_len) dst[w++] = 0; + } + return w; +} +``` + +--- + +## Appendix B — CRC-16/CCITT-FALSE Reference Implementation + +```c +#include +#include + +uint16_t crc16_ccitt_false(const uint8_t *data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; i++) { + crc ^= ((uint16_t)data[i]) << 8; + for (int b = 0; b < 8; b++) { + if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; + else crc <<= 1; + } + } + return crc; +} +``` + +Test vector: `crc16_ccitt_false("123456789", 9)` = `0x29B1`. + +--- + +## Appendix C — Wire Examples + +### C.1 Reading these examples + +Every example below gives: + +- **Direction and type**: `H→D` or `D→H`, followed by the message name and the tag in hex. +- **Decoded field values** (for messages with structured payloads): what each field means, not just raw bytes. +- **Pre-COBS body**: the `type | tag | payload` bytes, then a `| crc=0x....` marker, then the full pre-encoding frame `type | tag | payload | crc_lo crc_hi`. +- **Wire bytes**: the COBS-encoded frame followed by the trailing `0x00` delimiter. This is what actually moves over the transport. + +All multi-byte integers are little-endian. All examples were generated by running the reference implementations in Appendices A and B against the specified input, and validated by round-trip. + +Conventions used throughout: + +- Tag values are chosen per section to keep examples distinct across the appendix. Real implementations use a monotonic counter from 1, skipping 0. +- Error cases assume the device is in the state described by the section intro (CONFIGURED unless noted otherwise). +- `...` in decoded fields means "same as previously shown, unchanged". Byte dumps are always complete. + +--- + +### C.2 Basic sequences (happy path) + +#### C.2.1 PING exchange + +The simplest possible DongLoRa Protocol transaction. Host sends PING, device responds with OK. + +``` +H→D PING tag=0x0001 + pre-COBS: 01 01 00 | crc=0xc89d → 01 01 00 9D C8 + on wire: 03 01 01 03 9D C8 00 + +D→H OK tag=0x0001 + pre-COBS: 80 01 00 | crc=0xc4f7 → 80 01 00 F7 C4 + on wire: 03 80 01 03 F7 C4 00 +``` + +#### C.2.2 GET_INFO exchange + +Host queries device capabilities. Response describes an SX1262-based board with LoRa, FSK, and CAD; a 64-slot RX ring; a 16-deep TX queue; 150–960 MHz tuning; and −9 to +22 dBm TX power. + +``` +H→D GET_INFO tag=0x0002 + pre-COBS: 02 02 00 | crc=0xc49e → 02 02 00 9E C4 + on wire: 03 02 02 03 9E C4 00 + +D→H OK tag=0x0002 + proto_major: 1 + proto_minor: 0 + fw_major: 0 + fw_minor: 1 + fw_patch: 0 + radio_chip_id: 0x0002 (SX1262) + capability_bitmap: 0x0000000000010003 (bits 0=LoRa, 1=FSK, 16=CAD) + supported_sf_bitmap: 0x1FE0 (bits 5..12 = SF5..SF12) + supported_bw_bitmap: 0x03FF (all sub-GHz BW enum values 0..9) + max_payload_bytes: 255 + rx_queue_capacity: 64 + tx_queue_capacity: 16 + freq_min_hz: 150000000 (150 MHz) + freq_max_hz: 960000000 (960 MHz) + tx_power_min_dbm: -9 + tx_power_max_dbm: 22 + mcu_uid_len: 8 + mcu_uid: DE AD BE EF 01 23 45 67 + radio_uid_len: 0 + radio_uid: (empty) + pre-COBS: 80 02 00 01 00 00 01 00 02 00 03 00 01 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 | crc=0xa4fa → 80 02 00 01 00 00 01 00 02 00 03 00 01 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 FA A4 + on wire: 03 80 02 02 01 01 02 01 02 02 02 03 02 01 01 01 01 01 06 E0 1F FF 03 FF 02 40 02 10 05 80 D1 F0 08 0F 70 38 39 F7 16 08 DE AD BE EF 01 23 45 67 03 FA A4 00 +``` + +#### C.2.3 SET_CONFIG for LoRa (EU868, SF7, BW125, CR 4/5) + +Standard LoRa configuration on EU868.1 MHz. Device applies and echoes the current params. + +``` +H→D SET_CONFIG tag=0x0003 + modulation_id: 0x01 (LoRa) + freq_hz: 868100000 (868.1 MHz) + sf: 7 + bw: 7 (125 kHz) + cr: 0 (4/5) + preamble_len: 8 + sync_word: 0x1424 + tx_power_dbm: 14 + header_mode: 0 (explicit) + payload_crc: 1 (on) + iq_invert: 0 (normal) + pre-COBS: 03 03 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x1fd9 → 03 03 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 D9 1F + on wire: 03 03 03 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 D9 1F 00 + +D→H OK tag=0x0003 + result: 0 (APPLIED) + owner: 1 (MINE) + current_modulation: 0x01 (LoRa) + current_params: [same 15 bytes echoed back] + pre-COBS: 80 03 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x91c8 → 80 03 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 C8 91 + on wire: 03 80 03 01 09 01 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 C8 91 00 +``` + +#### C.2.4 TX with default CAD, packet "Hello" + +Assumes C.2.3 has run. Host transmits `48 65 6C 6C 6F` ("Hello"). Device ACKs synchronously, performs CAD (~4 ms at SF7/BW125), transmits, and reports completion asynchronously. Airtime 30.976 ms = preamble (12.544 ms) + 18 payload symbols × 1.024 ms/symbol. + +``` +H→D TX tag=0x0004 + flags: 0x00 (CAD enabled) + data: 48 65 6C 6C 6F ("Hello") + pre-COBS: 04 04 00 00 48 65 6C 6C 6F | crc=0x4026 → 04 04 00 00 48 65 6C 6C 6F 26 40 + on wire: 03 04 04 01 08 48 65 6C 6C 6F 26 40 00 + +D→H OK tag=0x0004 + pre-COBS: 80 04 00 | crc=0x3b02 → 80 04 00 02 3B + on wire: 03 80 04 03 02 3B 00 + +D→H TX_DONE tag=0x0004 + result: 0 (TRANSMITTED) + airtime_us: 30976 (30.976 ms) + pre-COBS: C1 04 00 00 00 79 00 00 | crc=0xfae3 → C1 04 00 00 00 79 00 00 E3 FA + on wire: 03 C1 04 01 01 02 79 01 03 E3 FA 00 +``` + +#### C.2.5 TX with skip_cad, packet "URGENT" + +Host sets `skip_cad` to bypass CAD — typical for time-critical ACKs in protocols with their own MAC. No CAD time; straight to TX. + +``` +H→D TX tag=0x0005 + flags: 0x01 (skip_cad set) + data: 55 52 47 45 4E 54 ("URGENT") + pre-COBS: 04 05 00 01 55 52 47 45 4E 54 | crc=0x1cdb → 04 05 00 01 55 52 47 45 4E 54 DB 1C + on wire: 03 04 05 0A 01 55 52 47 45 4E 54 DB 1C 00 + +D→H OK tag=0x0005 + pre-COBS: 80 05 00 | crc=0x0833 → 80 05 00 33 08 + on wire: 03 80 05 03 33 08 00 + +D→H TX_DONE tag=0x0005 + result: 0 (TRANSMITTED) + airtime_us: 33792 (no CAD; straight to TX) + pre-COBS: C1 05 00 00 00 84 00 00 | crc=0xe381 → C1 05 00 00 00 84 00 00 81 E3 + on wire: 03 C1 05 01 01 02 84 01 03 81 E3 00 +``` + +#### C.2.6 Continuous RX loop + +Host enters continuous RX. Device emits RX events as packets arrive. Host sends a periodic PING to keep the session alive, then eventually stops RX. + +``` +H→D RX_START tag=0x0006 + pre-COBS: 05 06 00 | crc=0x8dca → 05 06 00 CA 8D + on wire: 03 05 06 03 CA 8D 00 + +D→H OK tag=0x0006 + pre-COBS: 80 06 00 | crc=0x5d60 → 80 06 00 60 5D + on wire: 03 80 06 03 60 5D 00 + +D→H RX tag=0x0000 + rssi: -735 (-73.5 dBm) + snr: 95 (+9.5 dB) + freq_err: -125 (-125 Hz) + timestamp_us: 42000000 (42.000 s since boot) + crc_valid: 1 (pass) + packets_dropped: 0 + origin: 0 (over-the-air) + data: 01 02 03 04 + pre-COBS: C0 00 00 21 FD 5F 00 83 FF FF FF 80 DE 80 02 00 00 00 00 01 00 00 00 01 02 03 04 | crc=0x8eb9 → C0 00 00 21 FD 5F 00 83 FF FF FF 80 DE 80 02 00 00 00 00 01 00 00 00 01 02 03 04 B9 8E + on wire: 02 C0 01 04 21 FD 5F 09 83 FF FF FF 80 DE 80 02 01 01 01 02 01 01 01 07 01 02 03 04 B9 8E 00 + +H→D PING tag=0x0007 + pre-COBS: 01 07 00 | crc=0x623b → 01 07 00 3B 62 + on wire: 03 01 07 03 3B 62 00 + +D→H OK tag=0x0007 + pre-COBS: 80 07 00 | crc=0x6e51 → 80 07 00 51 6E + on wire: 03 80 07 03 51 6E 00 + +H→D RX_STOP tag=0x0008 + pre-COBS: 06 08 00 | crc=0xf795 → 06 08 00 95 F7 + on wire: 03 06 08 03 95 F7 00 + +D→H OK tag=0x0008 + pre-COBS: 80 08 00 | crc=0x7e6f → 80 08 00 6F 7E + on wire: 03 80 08 03 6F 7E 00 +``` + +--- + +### C.3 Pipelining and batching + +#### C.3.1 Three pipelined TXs + +Host issues three TXs without waiting for intermediate responses. Each gets `OK` synchronously as it is enqueued; `TX_DONE` events arrive later in submission order. + +``` +H→D TX tag=0x0064 (data: 00 41 = flags + "A") + pre-COBS: 04 64 00 00 41 | crc=0x53cc → 04 64 00 00 41 CC 53 + on wire: 03 04 64 01 04 41 CC 53 00 + +H→D TX tag=0x0065 (data: 00 42 = flags + "B") + pre-COBS: 04 65 00 00 42 | crc=0x151b → 04 65 00 00 42 1B 15 + on wire: 03 04 65 01 04 42 1B 15 00 + +H→D TX tag=0x0066 (data: 00 43 = flags + "C") + pre-COBS: 04 66 00 00 43 | crc=0x9ee6 → 04 66 00 00 43 E6 9E + on wire: 03 04 66 01 04 43 E6 9E 00 + +D→H OK tag=0x0064 + pre-COBS: 80 64 00 | crc=0x3028 → 80 64 00 28 30 + on wire: 03 80 64 03 28 30 00 + +D→H OK tag=0x0065 + pre-COBS: 80 65 00 | crc=0x0319 → 80 65 00 19 03 + on wire: 03 80 65 03 19 03 00 + +D→H OK tag=0x0066 + pre-COBS: 80 66 00 | crc=0x564a → 80 66 00 4A 56 + on wire: 03 80 66 03 4A 56 00 + +D→H TX_DONE tag=0x0064 (result=0, airtime=25000 us) + pre-COBS: C1 64 00 00 A8 61 00 00 | crc=0xcc8e → C1 64 00 00 A8 61 00 00 8E CC + on wire: 03 C1 64 01 03 A8 61 01 03 8E CC 00 + +D→H TX_DONE tag=0x0065 + pre-COBS: C1 65 00 00 A8 61 00 00 | crc=0x74ef → C1 65 00 00 A8 61 00 00 EF 74 + on wire: 03 C1 65 01 03 A8 61 01 03 EF 74 00 + +D→H TX_DONE tag=0x0066 + pre-COBS: C1 66 00 00 A8 61 00 00 | crc=0xac6d → C1 66 00 00 A8 61 00 00 6D AC + on wire: 03 C1 66 01 03 A8 61 01 03 6D AC 00 +``` + +#### C.3.2 Batched frames in a single transport write (narrative) + +A host writing three small frames in quick succession may see them concatenated into a single USB bulk transfer, one BLE notification, or one TCP packet. The byte stream is: + +``` +[frame1 wire bytes] [frame2 wire bytes] [frame3 wire bytes] +``` + +For the C.3.1 example, the first three `TX` frames batched look like: + +``` +03 04 64 01 04 41 CC 53 00 03 04 65 01 04 42 1B 15 00 03 04 66 01 04 43 E6 9E 00 +``` + +That is, 27 bytes of raw transport data containing three DongLoRa Protocol frames. The receiver's COBS delimiter scanner handles this naturally: it reads bytes into a frame buffer until `0x00`, decodes that frame, flushes the buffer, and continues. No additional framing or length-prefix is involved. + +--- + +### C.4 State transitions + +#### C.4.1 Reconfigure while RX is active + +Host is in continuous RX, receives a packet, then reconfigures (changing SF from 7 to 9). Device aborts RX, clears the ring, reconfigures, and returns to standby. Host must re-issue `RX_START` to resume. + +``` +H→D RX_START tag=0x000A + pre-COBS: 05 0A 00 | crc=0xc8a7 → 05 0A 00 A7 C8 + on wire: 03 05 0A 03 A7 C8 00 + +D→H OK tag=0x000A + pre-COBS: 80 0A 00 | crc=0x180d → 80 0A 00 0D 18 + on wire: 03 80 0A 03 0D 18 00 + +D→H RX tag=0x0000 + rssi: -820, snr: 80, freq_err: 0, timestamp_us: 50000000, + crc_valid: 1, packets_dropped: 0, origin: 0, data: AA BB + pre-COBS: C0 00 00 CC FC 50 00 00 00 00 00 80 F0 FA 02 00 00 00 00 01 00 00 00 AA BB | crc=0xc054 → C0 00 00 CC FC 50 00 00 00 00 00 80 F0 FA 02 00 00 00 00 01 00 00 00 AA BB 54 C0 + on wire: 02 C0 01 04 CC FC 50 01 01 01 01 05 80 F0 FA 02 01 01 01 02 01 01 01 05 AA BB 54 C0 00 + +H→D SET_CONFIG tag=0x000B (LoRa, SF=9, other params same as C.2.3) + pre-COBS: 03 0B 00 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 | crc=0xbdcc → 03 0B 00 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 CC BD + on wire: 03 03 0B 08 01 A0 27 BE 33 09 07 02 08 04 24 14 0E 02 01 03 CC BD 00 + +D→H OK tag=0x000B + result: 0 (APPLIED), owner: 1 (MINE), current: LoRa SF=9 ... + pre-COBS: 80 0B 00 00 01 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 | crc=0x7f0b → 80 0B 00 00 01 01 A0 27 BE 33 09 07 00 08 00 24 14 0E 00 01 00 0B 7F + on wire: 03 80 0B 01 09 01 01 A0 27 BE 33 09 07 02 08 04 24 14 0E 02 01 03 0B 7F 00 + +(radio is now idle; host must re-start RX) + +H→D RX_START tag=0x000C + pre-COBS: 05 0C 00 | crc=0x6201 → 05 0C 00 01 62 + on wire: 03 05 0C 03 01 62 00 + +D→H OK tag=0x000C + pre-COBS: 80 0C 00 | crc=0xb2ab → 80 0C 00 AB B2 + on wire: 03 80 0C 03 AB B2 00 +``` + +#### C.4.2 Reconfigure with pending TXs — they are cancelled + +Host queues two TXs, then before they complete, sends `SET_CONFIG`. Both queued TXs emit `TX_DONE(CANCELLED)`. The `SET_CONFIG` then applies. + +``` +H→D TX tag=0x0014 (data = "first") + pre-COBS: 04 14 00 00 66 69 72 73 74 | crc=0x1dc9 → 04 14 00 00 66 69 72 73 74 C9 1D + on wire: 03 04 14 01 08 66 69 72 73 74 C9 1D 00 + +D→H OK tag=0x0014 + pre-COBS: 80 14 00 | crc=0x3871 → 80 14 00 71 38 + on wire: 03 80 14 03 71 38 00 + +H→D TX tag=0x0015 (data = "second") + pre-COBS: 04 15 00 00 73 65 63 6F 6E 64 | crc=0xa89a → 04 15 00 00 73 65 63 6F 6E 64 9A A8 + on wire: 03 04 15 01 09 73 65 63 6F 6E 64 9A A8 00 + +D→H OK tag=0x0015 + pre-COBS: 80 15 00 | crc=0x0b40 → 80 15 00 40 0B + on wire: 03 80 15 03 40 0B 00 + +H→D SET_CONFIG tag=0x0016 (LoRa SF=10 — different from current config) + pre-COBS: 03 16 00 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 | crc=0xc630 → 03 16 00 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 30 C6 + on wire: 03 03 16 08 01 A0 27 BE 33 0A 07 02 08 04 24 14 0E 02 01 03 30 C6 00 + +D→H TX_DONE tag=0x0014 + result: 2 (CANCELLED), airtime_us: 0 + pre-COBS: C1 14 00 02 00 00 00 00 | crc=0xcf82 → C1 14 00 02 00 00 00 00 82 CF + on wire: 03 C1 14 02 02 01 01 01 03 82 CF 00 + +D→H TX_DONE tag=0x0015 + result: 2 (CANCELLED), airtime_us: 0 + pre-COBS: C1 15 00 02 00 00 00 00 | crc=0x77e3 → C1 15 00 02 00 00 00 00 E3 77 + on wire: 03 C1 15 02 02 01 01 01 03 E3 77 00 + +D→H OK tag=0x0016 + result: 0 (APPLIED), owner: 1 (MINE), current: LoRa SF=10 ... + pre-COBS: 80 16 00 00 01 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 | crc=0x3264 → 80 16 00 00 01 01 A0 27 BE 33 0A 07 00 08 00 24 14 0E 00 01 00 64 32 + on wire: 03 80 16 01 09 01 01 A0 27 BE 33 0A 07 02 08 04 24 14 0E 02 01 03 64 32 00 +``` + +#### C.4.3 Inactivity timeout and recovery + +Host goes silent for more than 1000 ms. Device transitions to UNCONFIGURED. When host returns and tries to TX, it gets `ERR(ENOTCONFIGURED)`. Host re-issues `SET_CONFIG` to recover. + +``` +H→D TX tag=0x001E (data = "before") + pre-COBS: 04 1E 00 00 62 65 66 6F 72 65 | crc=0x197f → 04 1E 00 00 62 65 66 6F 72 65 7F 19 + on wire: 03 04 1E 01 09 62 65 66 6F 72 65 7F 19 00 + +D→H OK tag=0x001E + pre-COBS: 80 1E 00 | crc=0xd7ba → 80 1E 00 BA D7 + on wire: 03 80 1E 03 BA D7 00 + +D→H TX_DONE tag=0x001E (TRANSMITTED, airtime 30976 us) + pre-COBS: C1 1E 00 00 00 79 00 00 | crc=0x3ed6 → C1 1E 00 00 00 79 00 00 D6 3E + on wire: 03 C1 1E 01 01 02 79 01 03 D6 3E 00 + + [ host sleeps 1.5 s — no frames sent ] + [ device's inactivity timer expires → UNCONFIGURED ] + +H→D TX tag=0x001F (data = "after") + pre-COBS: 04 1F 00 00 61 66 74 65 72 | crc=0x03ef → 04 1F 00 00 61 66 74 65 72 EF 03 + on wire: 03 04 1F 01 08 61 66 74 65 72 EF 03 00 + +D→H ERR tag=0x001F + code: 0x0003 (ENOTCONFIGURED) + pre-COBS: 81 1F 00 03 00 | crc=0x0397 → 81 1F 00 03 00 97 03 + on wire: 03 81 1F 02 03 03 97 03 00 + + (host realises session is lost; re-configures) + +H→D SET_CONFIG tag=0x0020 (LoRa SF=7, as before) + pre-COBS: 03 20 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xea74 → 03 20 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 74 EA + on wire: 03 03 20 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 74 EA 00 + +D→H OK tag=0x0020 (APPLIED, MINE) + pre-COBS: 80 20 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x19bb → 80 20 00 00 01 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 BB 19 + on wire: 03 80 20 01 09 01 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 BB 19 00 + +H→D TX tag=0x0021 (retry) + pre-COBS: 04 21 00 00 61 66 74 65 72 | crc=0xdb22 → 04 21 00 00 61 66 74 65 72 22 DB + on wire: 03 04 21 01 08 61 66 74 65 72 22 DB 00 + +D→H OK tag=0x0021 + pre-COBS: 80 21 00 | crc=0xc211 → 80 21 00 11 C2 + on wire: 03 80 21 03 11 C2 00 + +D→H TX_DONE tag=0x0021 (TRANSMITTED, airtime 30976 us) + pre-COBS: C1 21 00 00 00 79 00 00 | crc=0xedb2 → C1 21 00 00 00 79 00 00 B2 ED + on wire: 03 C1 21 01 01 02 79 01 03 B2 ED 00 +``` + +--- + +### C.5 Error responses + +#### C.5.1 TX attempted in UNCONFIGURED state + +Host sends TX without first calling `SET_CONFIG`. Device returns `ERR(ENOTCONFIGURED)`. + +``` +H→D TX tag=0x0028 (data = "hi") + pre-COBS: 04 28 00 00 68 69 | crc=0x7d24 → 04 28 00 00 68 69 24 7D + on wire: 03 04 28 01 05 68 69 24 7D 00 + +D→H ERR tag=0x0028 + code: 0x0003 (ENOTCONFIGURED) + pre-COBS: 81 28 00 03 00 | crc=0x7e53 → 81 28 00 03 00 53 7E + on wire: 03 81 28 02 03 03 53 7E 00 +``` + +#### C.5.2 TX with empty payload data + +Host sends TX with only the flags byte and no data. Device returns `ERR(ELENGTH)` because data must be at least 1 byte. + +``` +H→D TX tag=0x0029 + flags: 0 + data: (empty — invalid) + pre-COBS: 04 29 00 00 | crc=0x5666 → 04 29 00 00 66 56 + on wire: 03 04 29 01 03 66 56 00 + +D→H ERR tag=0x0029 + code: 0x0002 (ELENGTH) + pre-COBS: 81 29 00 02 00 | crc=0x3bd6 → 81 29 00 02 00 D6 3B + on wire: 03 81 29 02 02 03 D6 3B 00 +``` + +#### C.5.3 TX with reserved flag bit set + +Host sets bit 1 of `flags` (reserved). Device returns `ERR(EPARAM)`. + +``` +H→D TX tag=0x002A + flags: 0x02 (bit 1 is reserved) + data: 68 69 + pre-COBS: 04 2A 00 02 68 69 | crc=0x57c7 → 04 2A 00 02 68 69 C7 57 + on wire: 03 04 2A 06 02 68 69 C7 57 00 + +D→H ERR tag=0x002A + code: 0x0001 (EPARAM) + pre-COBS: 81 2A 00 01 00 | crc=0xf559 → 81 2A 00 01 00 59 F5 + on wire: 03 81 2A 02 01 03 59 F5 00 +``` + +#### C.5.4 TX queue full (EBUSY) + +Host has pipelined `tx_queue_capacity` TXs and none have completed. The next TX returns `ERR(EBUSY)`. Host waits for a `TX_DONE` to free a slot, then retries with a **new** tag. + +``` +H→D TX tag=0x002B (queue already full) + pre-COBS: 04 2B 00 00 6F 76 65 72 66 6C 6F 77 | crc=0x4824 → 04 2B 00 00 6F 76 65 72 66 6C 6F 77 24 48 + on wire: 03 04 2B 01 0B 6F 76 65 72 66 6C 6F 77 24 48 00 + +D→H ERR tag=0x002B + code: 0x0006 (EBUSY) + pre-COBS: 81 2B 00 06 00 | crc=0x1a7a → 81 2B 00 06 00 7A 1A + on wire: 03 81 2B 02 06 03 7A 1A 00 +``` + +#### C.5.5 TX collision (CHANNEL_BUSY) and retry + +CAD detects another transmitter. TX is aborted before airtime. Host backs off (randomised 20–100 ms) and retries with a new tag. + +``` +H→D TX tag=0x0032 (data = "retry-me") + pre-COBS: 04 32 00 00 72 65 74 72 79 2D 6D 65 | crc=0x0d16 → 04 32 00 00 72 65 74 72 79 2D 6D 65 16 0D + on wire: 03 04 32 01 0B 72 65 74 72 79 2D 6D 65 16 0D 00 + +D→H OK tag=0x0032 + pre-COBS: 80 32 00 | crc=0x9431 → 80 32 00 31 94 + on wire: 03 80 32 03 31 94 00 + +D→H TX_DONE tag=0x0032 + result: 1 (CHANNEL_BUSY), airtime_us: 0 + pre-COBS: C1 32 00 01 00 00 00 00 | crc=0xee83 → C1 32 00 01 00 00 00 00 83 EE + on wire: 03 C1 32 02 01 01 01 01 03 83 EE 00 + + [ random backoff ] + +H→D TX tag=0x0033 (same data, NEW tag) + pre-COBS: 04 33 00 00 72 65 74 72 79 2D 6D 65 | crc=0xd55f → 04 33 00 00 72 65 74 72 79 2D 6D 65 5F D5 + on wire: 03 04 33 01 0B 72 65 74 72 79 2D 6D 65 5F D5 00 + +D→H OK tag=0x0033 + pre-COBS: 80 33 00 | crc=0xa700 → 80 33 00 00 A7 + on wire: 03 80 33 01 02 A7 00 + +D→H TX_DONE tag=0x0033 + result: 0 (TRANSMITTED), airtime_us: 30976 + pre-COBS: C1 33 00 00 00 79 00 00 | crc=0xba2a → C1 33 00 00 00 79 00 00 2A BA + on wire: 03 C1 33 01 01 02 79 01 03 2A BA 00 +``` + +Note: the `OK` response for tag=0x0033 shows a COBS encoding quirk: the CRC is `0xA700`, so the low byte is `0x00`. COBS encodes this by inserting a `0x02` code byte that splits the run around the zero. + +#### C.5.6 Unknown command type byte + +Host sends a frame with `type = 0x10` (in the reserved H→D range). Device returns `ERR(EUNKNOWN_CMD)` with the received tag echoed. + +``` +H→D type=0x10 tag=0x003C + type: 0x10 (reserved, not yet assigned) + payload: DE AD + pre-COBS: 10 3C 00 DE AD | crc=0x24e2 → 10 3C 00 DE AD E2 24 + on wire: 03 10 3C 05 DE AD E2 24 00 + +D→H ERR tag=0x003C + code: 0x0005 (EUNKNOWN_CMD) + pre-COBS: 81 3C 00 05 00 | crc=0x05a3 → 81 3C 00 05 00 A3 05 + on wire: 03 81 3C 02 05 03 A3 05 00 +``` + +#### C.5.7 SET_CONFIG with truncated LoRa params + +Host declares LoRa modulation but includes only 10 bytes of params instead of the required 15. Device returns `ERR(ELENGTH)`. + +``` +H→D SET_CONFIG tag=0x0046 + modulation_id: 0x01 (LoRa) + params: (10 bytes — should be 15) + pre-COBS: 03 46 00 01 00 00 00 00 00 00 00 00 00 00 | crc=0x293b → 03 46 00 01 00 00 00 00 00 00 00 00 00 00 3B 29 + on wire: 03 03 46 02 01 01 01 01 01 01 01 01 01 01 03 3B 29 00 + +D→H ERR tag=0x0046 + code: 0x0002 (ELENGTH) + pre-COBS: 81 46 00 02 00 | crc=0xb6ea → 81 46 00 02 00 EA B6 + on wire: 03 81 46 02 02 03 EA B6 00 +``` + +#### C.5.8 SET_CONFIG with frequency outside chip range + +Host requests 2.45 GHz on a sub-GHz-only SX1262 (advertised range 150–960 MHz). Device returns `ERR(EPARAM)`. + +``` +H→D SET_CONFIG tag=0x0047 + modulation_id: 0x01 (LoRa) + freq_hz: 2450000000 (2.45 GHz — outside 150 MHz–960 MHz) + (other params as normal) + pre-COBS: 03 47 00 01 80 08 08 92 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x4934 → 03 47 00 01 80 08 08 92 07 07 00 08 00 24 14 0E 00 01 00 34 49 + on wire: 03 03 47 08 01 80 08 08 92 07 07 02 08 04 24 14 0E 02 01 03 34 49 00 + +D→H ERR tag=0x0047 + code: 0x0001 (EPARAM) + pre-COBS: 81 47 00 01 00 | crc=0x950d → 81 47 00 01 00 0D 95 + on wire: 03 81 47 02 01 03 0D 95 00 +``` + +#### C.5.9 SET_CONFIG with unsupported modulation + +Host requests FLRC (2.4 GHz-only modulation) on a sub-GHz SX1262. Capability bitmap does not advertise FLRC. Device returns `ERR(EMODULATION)`. + +``` +H→D SET_CONFIG tag=0x0048 + modulation_id: 0x04 (FLRC — not supported on SX1262) + params: [13 bytes, ignored because modulation is unsupported] + pre-COBS: 03 48 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 | crc=0x96c2 → 03 48 00 04 00 00 00 00 00 00 00 00 00 00 00 00 00 C2 96 + on wire: 03 03 48 02 04 01 01 01 01 01 01 01 01 01 01 01 01 03 C2 96 00 + +D→H ERR tag=0x0048 + code: 0x0004 (EMODULATION) + pre-COBS: 81 48 00 04 00 | crc=0xbe16 → 81 48 00 04 00 16 BE + on wire: 03 81 48 02 04 03 16 BE 00 +``` + +#### C.5.10 Radio hardware fault during SET_CONFIG + +Radio SPI fails to respond during the configuration sequence. Device returns `ERR(ERADIO)` synchronously, transitions to UNCONFIGURED, and places the radio in its safest reachable state. + +``` +H→D SET_CONFIG tag=0x0049 (LoRa, standard params) + pre-COBS: 03 49 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xe56a → 03 49 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 6A E5 + on wire: 03 03 49 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 6A E5 00 + +D→H ERR tag=0x0049 + code: 0x0101 (ERADIO) + pre-COBS: 81 49 00 01 01 | crc=0x2776 → 81 49 00 01 01 76 27 + on wire: 03 81 49 05 01 01 76 27 00 +``` + +--- + +### C.6 Asynchronous events + +Async events carry `tag = 0x0000`. They may appear interleaved anywhere in the D→H stream. + +#### C.6.1 RX with good CRC + +See C.2.6 above for an example within the continuous-RX flow. + +#### C.6.2 RX with crc_valid=0 (corrupt packet delivered anyway) + +Radio reports a packet, but PHY CRC failed. Device delivers the bytes with `crc_valid = 0`; the host decides whether to discard. RSSI and SNR reported but may not be meaningful. + +``` +D→H RX tag=0x0000 + rssi: -1050 (-105.0 dBm — weak signal) + snr: -80 (-8.0 dB — negative SNR) + freq_err: 2200 (2.2 kHz offset) + timestamp_us: 120000000 (120.000 s) + crc_valid: 0 (FAIL — bytes may be corrupt) + packets_dropped: 0 + origin: 0 (over-the-air) + data: 41 42 FF 00 CC + pre-COBS: C0 00 00 E6 FB B0 FF 98 08 00 00 00 0E 27 07 00 00 00 00 00 00 00 00 41 42 FF 00 CC | crc=0x3c04 → C0 00 00 E6 FB B0 FF 98 08 00 00 00 0E 27 07 00 00 00 00 00 00 00 00 41 42 FF 00 CC 04 3C + on wire: 02 C0 01 07 E6 FB B0 FF 98 08 01 01 04 0E 27 07 01 01 01 01 01 01 01 04 41 42 FF 04 CC 04 3C 00 +``` + +#### C.6.3 RX following buffer overrun + +Host was slow draining. Three packets arrived and were dropped because the ring was full. The next successfully-delivered RX reports `packets_dropped = 3`. The counter resets to 0 after this delivery. + +``` +D→H RX tag=0x0000 + rssi: -730 (-73.0 dBm) + snr: 100 (+10.0 dB) + freq_err: 50 (50 Hz) + timestamp_us: 130500000 + crc_valid: 1 (pass) + packets_dropped: 3 (three lost since previous delivery) + origin: 0 + data: 01 02 + pre-COBS: C0 00 00 26 FD 64 00 32 00 00 00 A0 45 C7 07 00 00 00 00 01 03 00 00 01 02 | crc=0x86a7 → C0 00 00 26 FD 64 00 32 00 00 00 A0 45 C7 07 00 00 00 00 01 03 00 00 01 02 A7 86 + on wire: 02 C0 01 04 26 FD 64 02 32 01 01 05 A0 45 C7 07 01 01 01 03 01 03 01 05 01 02 A7 86 00 +``` + +#### C.6.4 Malformed incoming frame triggers async ERR(EFRAME) + +Host (or noise on the line) sends a frame whose CRC does not match the body. Device discards the frame and emits an async `ERR(EFRAME)` with `tag = 0`. The liveness timer is still reset — the bytes arrived, so the host is present. + +``` +D→H ERR tag=0x0000 + code: 0x0102 (EFRAME) + pre-COBS: 81 00 00 02 01 | crc=0xefce → 81 00 00 02 01 CE EF + on wire: 02 81 01 05 02 01 CE EF 00 +``` + +Note: the same frame type (`ERR`, `0x81`) is used here as in synchronous error responses; the tag (`0x0000`) is the only discriminator. Host code distinguishes async faults from command responses by the tag value. + +#### C.6.5 Asynchronous radio fault + +Radio chip asserts an unexpected IRQ (e.g., an RxTimeout from a stale `RX_START` that the firmware already reset). No originating command. Device emits `ERR(ERADIO)` with `tag = 0`. + +``` +D→H ERR tag=0x0000 + code: 0x0101 (ERADIO) + pre-COBS: 81 00 00 01 01 | crc=0xba9d → 81 00 00 01 01 9D BA + on wire: 02 81 01 05 01 01 9D BA 00 +``` + +--- + +### C.7 Multi-client scenarios (informative) + +These examples assume firmware with multi-client capability bit 32 set. Single-client deployments will never see these responses. + +#### C.7.1 Second client finds matching config (ALREADY_MATCHED) + +Client A has locked LoRa SF7/BW125. Client B connects and requests identical params. Device reports `ALREADY_MATCHED` with `owner = OTHER`; Client B may proceed without changing anything. + +``` +H→D SET_CONFIG tag=0x0050 (from Client B; identical to Client A's config) + pre-COBS: 03 50 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0x16cb → 03 50 00 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 CB 16 + on wire: 03 03 50 08 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 CB 16 00 + +D→H OK tag=0x0050 + result: 1 (ALREADY_MATCHED) + owner: 2 (OTHER — Client A holds the lock) + current_modulation: 0x01 (LoRa) + current_params: [SF=7, BW=125 kHz, ... same as request] + pre-COBS: 80 50 00 01 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xd834 → 80 50 00 01 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 34 D8 + on wire: 03 80 50 0A 01 02 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 34 D8 00 +``` + +#### C.7.2 Second client finds mismatched config (LOCKED_MISMATCH) + +Client A is locked on SF7. Client B requests SF9 with different TX power. Device rejects the change; returns the actual active params so Client B can diff and decide what to do. + +``` +H→D SET_CONFIG tag=0x0051 (from Client B; sf=9, tx_power_dbm=17) + pre-COBS: 03 51 00 01 A0 27 BE 33 09 07 00 08 00 24 14 11 00 01 00 | crc=0x7ac9 → 03 51 00 01 A0 27 BE 33 09 07 00 08 00 24 14 11 00 01 00 C9 7A + on wire: 03 03 51 08 01 A0 27 BE 33 09 07 02 08 04 24 14 11 02 01 03 C9 7A 00 + +D→H OK tag=0x0051 + result: 2 (LOCKED_MISMATCH) + owner: 2 (OTHER) + current_modulation: 0x01 (LoRa) + current_params: [SF=7, tx_power=14 — Client A's setting, unchanged] + pre-COBS: 80 51 00 02 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 | crc=0xedf5 → 80 51 00 02 02 01 A0 27 BE 33 07 07 00 08 00 24 14 0E 00 01 00 F5 ED + on wire: 03 80 51 0A 02 02 01 A0 27 BE 33 07 07 02 08 04 24 14 0E 02 01 03 F5 ED 00 +``` + +#### C.7.3 Cross-client TX loopback (origin=1) + +Client A transmits. Client B, which is also RX-active on the same device, sees the packet as an RX event with the `origin = 1` flag set, distinguishing it from over-the-air traffic. + +``` +H→D TX tag=0x005A (from Client A; data = "BROADCAST") + pre-COBS: 04 5A 00 00 42 52 4F 41 44 43 41 53 54 | crc=0x7914 → 04 5A 00 00 42 52 4F 41 44 43 41 53 54 14 79 + on wire: 03 04 5A 01 0C 42 52 4F 41 44 43 41 53 54 14 79 00 + +D→H OK tag=0x005A (to Client A only) + pre-COBS: 80 5A 00 | crc=0x16b2 → 80 5A 00 B2 16 + on wire: 03 80 5A 03 B2 16 00 + +D→H TX_DONE tag=0x005A (to Client A only; TRANSMITTED) + pre-COBS: C1 5A 00 00 00 D8 00 00 | crc=0xa850 → C1 5A 00 00 00 D8 00 00 50 A8 + on wire: 03 C1 5A 01 01 02 D8 01 03 50 A8 00 + +D→H RX tag=0x0000 (to Client B only; local loopback, not OTA) + rssi: 0 (n/a for local loopback) + snr: 0 + freq_err: 0 + timestamp_us: 180000000 + crc_valid: 1 (local TX, assumed valid) + packets_dropped: 0 + origin: 1 (local loopback — NOT over the air) + data: 42 52 4F 41 44 43 41 53 54 ("BROADCAST") + pre-COBS: C0 00 00 00 00 00 00 00 00 00 00 00 95 BA 0A 00 00 00 00 01 00 00 01 42 52 4F 41 44 43 41 53 54 | crc=0x00e0 → C0 00 00 00 00 00 00 00 00 00 00 00 95 BA 0A 00 00 00 00 01 00 00 01 42 52 4F 41 44 43 41 53 54 E0 00 + on wire: 02 C0 01 01 01 01 01 01 01 01 01 01 04 95 BA 0A 01 01 01 02 01 01 0C 01 42 52 4F 41 44 43 41 53 54 E0 01 00 +``` + +--- + +### C.8 COBS edge cases + +#### C.8.1 Frame body contains no 0x00 bytes + +When the unencoded body has no zero bytes, COBS simply prepends one code byte giving the run length + 1. No internal code bytes are inserted. + +``` +H→D PING tag=0x0101 + (tag bytes 0x01 0x01 — no zeros anywhere in body) + pre-COBS: 01 01 01 | crc=0xd8bc → 01 01 01 BC D8 + on wire: 06 01 01 01 BC D8 00 +``` + +The leading `06` means "next zero (or end of frame) is 6 bytes away". + +#### C.8.2 Frame body contains many 0x00 bytes + +RX event with all-zero metadata and a 3-byte all-zero payload. COBS must replace each zero with a run-length code byte. This is the opposite extreme from C.8.1: many short runs, many code bytes. + +``` +D→H RX tag=0x0000 + rssi: 0, snr: 0, freq_err: 0 + timestamp_us: 256 (0x100) + crc_valid: 1, packets_dropped: 0, origin: 0 + data: 00 00 00 + pre-COBS: C0 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 01 00 00 00 00 00 00 | crc=0x74b4 → C0 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 01 00 00 00 00 00 00 B4 74 + on wire: 02 C0 01 01 01 01 01 01 01 01 01 01 02 01 01 01 01 01 01 02 01 01 01 01 01 01 03 B4 74 00 +``` + +The frequent `01` bytes in the wire output are COBS code bytes pointing "next zero is 1 byte away" — each represents a single zero in the original body. + +#### C.8.3 254-byte non-zero run triggers a 0xFF code byte + +When the body contains a run of 254 or more non-zero bytes, COBS inserts an extra code byte. This is the only case where encoding adds more than a fixed constant — it adds `ceil(N / 254)` overhead bytes for an N-byte run. + +Example: a TX command with tag=0x0101, skip_cad flag, and 253 bytes of non-zero data. Total pre-COBS body is 259 bytes with no zeros (header, flags byte, all 253 data bytes, and both CRC bytes happen to be non-zero). + +``` + Body composition (259 bytes, all non-zero): + type: 04 (1 byte) + tag: 01 01 (2 bytes) + flags: 01 (1 byte) + data: 01 02 03 ... FD (253 bytes, values 0x01..0xFD) + crc: 53 46 (2 bytes; CRC = 0x4653) + + COBS output (261 bytes): + Position 0: FF ← code byte: "next zero (or end of frame) + would be 255 bytes away; bail and start + a fresh run here" + Positions 1..254: 04 01 01 01 01 02 03 ... FC (first 254 source bytes) + Position 255: 06 ← new code byte + Positions 256..260: FD 53 46 (remaining 5 source bytes: + the last data byte FD, + plus CRC 53 46) + (code says "6 bytes" = 5 data + the + virtual end-of-frame position) + + Wire: 261 encoded bytes + 1 trailing 0x00 delimiter = 262 bytes total. +``` + +**Buffer sizing consequence**: a host encoding an N-byte frame needs at most `N + ceil(N / 254) + 1` bytes of output buffer (plus the trailing delimiter). For any frame under 254 bytes, overhead is exactly 1 byte (the leading code byte) plus the trailing delimiter. At exactly 254 bytes or more, a second code byte is added. At 508+, a third. Real DongLoRa Protocol frames almost always fall in the single-overhead-byte range. + +--- + +### C.9 Parser resynchronization (narrative) + +#### C.9.1 Mid-stream connection + +A host process opens the transport while the device is mid-frame (for example, a previous host process was killed and a new one is starting up). The incoming byte stream looks like: + +``` +... 14 0E 02 01 03 0B 7F 00 03 05 0C 03 01 62 00 ... + |← tail of previous frame →| |← next complete frame →| +``` + +The new host discards incoming bytes until it sees a `0x00`. That `0x00` closes whatever partial frame was in flight when the host started reading (which is discarded). Reading resumes with the byte immediately following: `03 05 0C 03 01 62 00` is the next complete frame, which COBS-decodes cleanly and verifies CRC. Normal operation proceeds. + +No special handshake, no timeout — the self-synchronising property of COBS makes mid-stream recovery deterministic and instantaneous. + +#### C.9.2 Single-frame corruption + +The host receives three frames in rapid succession. The middle one has a bit error in transit (say, flipped by a noisy USB cable) that corrupts one of the data bytes. The host: + +1. Reads to the first `0x00`, decodes the first frame, verifies CRC → OK, processes it. +2. Reads to the second `0x00`, decodes the second frame, **CRC check fails** → discards the frame silently. Does not emit any response (it cannot determine the originating tag reliably from a corrupted frame). +3. Reads to the third `0x00`, decodes the third frame, verifies CRC → OK, processes it. + +The outstanding tag of the discarded frame remains open on the host side. The host's per-command timeout (Section 14.2) will eventually fire, at which point the host may re-issue the command with a new tag. In practice, transport-level bit errors on USB are very rare; this path is defensive rather than routine. + +The device, in the symmetrical case, emits an async `ERR(EFRAME)` with tag `0x0000` (§C.6.4) as a diagnostic hint but does not directly signal which command was lost — the corrupted bytes are, by definition, not reliably parseable. + +## Appendix D — Glossary + +**Airtime**: The duration a packet occupies the radio channel during transmission. + +**CAD**: Channel Activity Detection. A LoRa radio primitive that briefly listens for a LoRa preamble and reports busy/clear. + +**COBS**: Consistent Overhead Byte Stuffing. A framing technique that eliminates a chosen byte value (here, `0x00`) from an encoded byte stream. + +**CR**: Coding Rate. LoRa forward-error-correction ratio: 4/5, 4/6, 4/7, or 4/8. + +**FLRC**: Fast Long Range Communication. A higher-throughput modulation supported by SX128x and LR2021. + +**LDRO**: Low Data Rate Optimization. A LoRa setting required at high SF and low BW to tolerate clock drift during long symbol times. + +**LoRa**: Long Range. A chirp-spread-spectrum modulation technique from Semtech. + +**LR-FHSS**: Long Range Frequency-Hopping Spread Spectrum. A Semtech modulation for low-throughput satellite or very-long-range use. + +**Outstanding tag**: A tag for which the host has sent a command but not yet received its final response. + +**SF**: Spreading Factor. LoRa symbol-duration parameter (5–12), trading throughput for range. + +**Tag**: The 16-bit correlation ID in the frame header, used to match responses to commands. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1a10dc9..15808e4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -22,6 +22,13 @@ features = ["std"] [workspace] members = ["."] +[[bin]] +name = "decoder_feed" +path = "fuzz_targets/decoder_feed.rs" +test = false +doc = false +bench = false + [[bin]] name = "parse_command" path = "fuzz_targets/parse_command.rs" @@ -30,43 +37,22 @@ doc = false bench = false [[bin]] -name = "parse_radio_config" -path = "fuzz_targets/parse_radio_config.rs" +name = "parse_device_message" +path = "fuzz_targets/parse_device_message.rs" test = false doc = false bench = false [[bin]] -name = "cobs_decoder_feed" -path = "fuzz_targets/cobs_decoder_feed.rs" +name = "frame_roundtrip" +path = "fuzz_targets/frame_roundtrip.rs" test = false doc = false bench = false [[bin]] -name = "cobs_roundtrip" -path = "fuzz_targets/cobs_roundtrip.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "command_roundtrip" -path = "fuzz_targets/command_roundtrip.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "cobs_decoder_multi_frame" -path = "fuzz_targets/cobs_decoder_multi_frame.rs" -test = false -doc = false -bench = false - -[[bin]] -name = "cobs_chunk_invariant" -path = "fuzz_targets/cobs_chunk_invariant.rs" +name = "lora_config_roundtrip" +path = "fuzz_targets/lora_config_roundtrip.rs" test = false doc = false bench = false diff --git a/fuzz/fuzz_targets/cobs_chunk_invariant.rs b/fuzz/fuzz_targets/cobs_chunk_invariant.rs deleted file mode 100644 index 33a9047..0000000 --- a/fuzz/fuzz_targets/cobs_chunk_invariant.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Chunk-boundary invariance: the same input stream fed into two -//! decoders — one in arbitrary chunks, one all-at-once — must produce -//! the same command sequence. - -#![no_main] - -use donglora_protocol::Command; -use donglora_protocol::framing::CobsDecoder; -use libfuzzer_sys::fuzz_target; - -fn drain(stream: &[u8], chunk_sched: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - let mut decoder = CobsDecoder::new(); - let mut cursor = 0; - let mut idx = 0; - while cursor < stream.len() { - let split = if idx < chunk_sched.len() { - ((chunk_sched[idx] as usize) % 64) + 1 - } else { - 16 - }; - idx += 1; - let end = (cursor + split).min(stream.len()); - decoder.feed(&stream[cursor..end], |c| out.push(c)); - cursor = end; - } - out -} - -fuzz_target!(|data: &[u8]| { - let split = 32.min(data.len()); - let (sched, stream) = data.split_at(split); - - let whole: Vec = { - let mut v = Vec::new(); - CobsDecoder::new().feed(stream, |c| v.push(c)); - v - }; - let chunked = drain(stream, sched); - assert_eq!(whole, chunked); -}); diff --git a/fuzz/fuzz_targets/cobs_decoder_feed.rs b/fuzz/fuzz_targets/cobs_decoder_feed.rs deleted file mode 100644 index ccb30bb..0000000 --- a/fuzz/fuzz_targets/cobs_decoder_feed.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! `CobsDecoder::feed` must never panic or yield an oversized payload -//! when fed arbitrary bytes in arbitrary chunks. -//! -//! The input is split into chunks driven by a leading length-prefix -//! stream so the fuzzer can exercise boundary handling. - -#![no_main] - -use donglora_protocol::MAX_PAYLOAD; -use donglora_protocol::framing::CobsDecoder; -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - let mut decoder = CobsDecoder::new(); - let mut cursor = 0; - - // Use the first byte (if any) as a pseudo-chunk-schedule: low nibble - // picks a split size 1..=16, high nibble is skipped. Subsequent - // leading bytes drive subsequent splits. - let mut sched_idx = 0; - while cursor < data.len() { - let split = if sched_idx < data.len() { - ((data[sched_idx] & 0x0F) as usize) + 1 - } else { - 16 - }; - sched_idx = sched_idx.saturating_add(1); - let end = (cursor + split).min(data.len()); - decoder.feed(&data[cursor..end], |cmd| { - if let donglora_protocol::Command::Transmit { payload, .. } = cmd { - assert!(payload.len() <= MAX_PAYLOAD); - } - }); - cursor = end; - } -}); diff --git a/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs b/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs deleted file mode 100644 index eda1cb7..0000000 --- a/fuzz/fuzz_targets/cobs_decoder_multi_frame.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Feed arbitrary bytes to `CobsDecoder::feed` using an arbitrary -//! chunking strategy driven by the fuzz input, and confirm: -//! - the decoder never panics, -//! - every emitted command respects the bounded payload, -//! - the total number of commands never exceeds the number of 0x00 -//! sentinels in the stream. - -#![no_main] - -use donglora_protocol::framing::CobsDecoder; -use donglora_protocol::{Command, MAX_PAYLOAD}; -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // Split the input: first 32 bytes (if available) drive chunk sizes, - // the rest is the stream. - let split = 32.min(data.len()); - let (sched, stream) = data.split_at(split); - - let mut decoder = CobsDecoder::new(); - let mut cursor = 0; - let mut sched_idx = 0; - let mut count = 0; - while cursor < stream.len() { - let split_size = if sched_idx < sched.len() { - ((sched[sched_idx] as usize) % 64) + 1 - } else { - 16 - }; - sched_idx += 1; - let end = (cursor + split_size).min(stream.len()); - decoder.feed(&stream[cursor..end], |cmd| { - count += 1; - if let Command::Transmit { payload, .. } = cmd { - assert!(payload.len() <= MAX_PAYLOAD); - } - }); - cursor = end; - } - let sentinel_count = stream.iter().filter(|&&b| b == 0).count(); - assert!(count <= sentinel_count); -}); diff --git a/fuzz/fuzz_targets/cobs_roundtrip.rs b/fuzz/fuzz_targets/cobs_roundtrip.rs deleted file mode 100644 index c7acd70..0000000 --- a/fuzz/fuzz_targets/cobs_roundtrip.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Structure-aware COBS roundtrip: build an arbitrary `Response`, encode -//! it through `cobs_encode_response`, decode the framed bytes via -//! `ucobs::decode`, and confirm the decoded payload matches the raw -//! `write_to` output. - -#![no_main] - -use arbitrary::{Arbitrary, Unstructured}; -use donglora_protocol::framing::{MAX_FRAME, cobs_encode_response}; -use donglora_protocol::{Bandwidth, ErrorCode, RadioConfig, Response}; -use heapless::Vec as HVec; -use libfuzzer_sys::fuzz_target; - -#[derive(Debug, Arbitrary)] -enum ArbResponse { - Pong, - Config(ArbConfig), - RxPacket { - rssi: i16, - snr: i16, - payload: Vec, - }, - TxDone, - Ok, - Error(ArbError), - MacAddress([u8; 6]), -} - -#[derive(Debug, Arbitrary)] -struct ArbConfig { - freq_hz: u32, - bw: u8, - sf: u8, - cr: u8, - sync_word: u16, - tx_power_dbm: i8, - preamble_len: u16, - cad: u8, -} - -#[derive(Debug, Arbitrary, Clone, Copy)] -enum ArbError { - InvalidConfig, - RadioBusy, - TxTimeout, - NotConfigured, - NoDisplay, -} - -fn into_error(a: ArbError) -> ErrorCode { - match a { - ArbError::InvalidConfig => ErrorCode::InvalidConfig, - ArbError::RadioBusy => ErrorCode::RadioBusy, - ArbError::TxTimeout => ErrorCode::TxTimeout, - ArbError::NotConfigured => ErrorCode::NotConfigured, - ArbError::NoDisplay => ErrorCode::NoDisplay, - } -} - -fn into_config(c: &ArbConfig) -> RadioConfig { - let bw = match c.bw % 10 { - 0 => 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, - _ => Bandwidth::Khz500, - }; - RadioConfig { - freq_hz: c.freq_hz, - bw, - sf: c.sf, - cr: c.cr, - sync_word: c.sync_word, - tx_power_dbm: c.tx_power_dbm, - preamble_len: c.preamble_len, - cad: c.cad, - } -} - -fn into_response(a: ArbResponse) -> Response { - match a { - ArbResponse::Pong => Response::Pong, - ArbResponse::Config(c) => Response::Config(into_config(&c)), - ArbResponse::RxPacket { rssi, snr, payload } => { - let mut p = HVec::::new(); - let take = payload.len().min(donglora_protocol::MAX_PAYLOAD); - let _ = p.extend_from_slice(&payload[..take]); - Response::RxPacket { - rssi, - snr, - payload: p, - } - } - ArbResponse::TxDone => Response::TxDone, - ArbResponse::Ok => Response::Ok, - ArbResponse::Error(e) => Response::Error(into_error(e)), - ArbResponse::MacAddress(m) => Response::MacAddress(m), - } -} - -fuzz_target!(|data: &[u8]| { - let mut u = Unstructured::new(data); - let Ok(arb) = ArbResponse::arbitrary(&mut u) else { - return; - }; - let resp = into_response(arb); - - let mut raw = [0u8; MAX_FRAME]; - let raw_n = resp.clone().write_to(&mut raw); - - let mut write = [0u8; MAX_FRAME]; - let mut encode = [0u8; MAX_FRAME]; - let Some(framed) = cobs_encode_response(resp, &mut write, &mut encode) else { - return; - }; - - assert_eq!(*framed.last().unwrap(), 0x00); - let mut decoded = [0u8; MAX_FRAME]; - let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap(); - assert_eq!(&decoded[..n], &raw[..raw_n]); -}); diff --git a/fuzz/fuzz_targets/command_roundtrip.rs b/fuzz/fuzz_targets/command_roundtrip.rs deleted file mode 100644 index da08b07..0000000 --- a/fuzz/fuzz_targets/command_roundtrip.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Structure-aware Command roundtrip: construct an arbitrary Command, -//! hand-encode it using the same wire layout the library parses, and -//! confirm `Command::from_bytes` recovers the original value exactly. -//! -//! The library itself is asymmetric (firmware only decodes commands), -//! so this fuzz target carries a matching encoder. - -#![no_main] - -use arbitrary::{Arbitrary, Unstructured}; -use donglora_protocol::{Bandwidth, Command, MAX_PAYLOAD, RADIO_CONFIG_SIZE, RadioConfig}; -use heapless::Vec as HVec; -use libfuzzer_sys::fuzz_target; - -#[derive(Debug, Arbitrary)] -enum ArbCommand { - Ping, - GetConfig, - SetConfig(ArbConfig), - StartRx, - StopRx, - Transmit { - config: Option, - payload: Vec, - }, - DisplayOn, - DisplayOff, - GetMac, -} - -#[derive(Debug, Arbitrary)] -struct ArbConfig { - freq_hz: u32, - bw: u8, - sf: u8, - cr: u8, - sync_word: u16, - tx_power_dbm: i8, - preamble_len: u16, - cad: u8, -} - -fn into_config(c: &ArbConfig) -> RadioConfig { - let bw = match c.bw % 10 { - 0 => 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, - _ => Bandwidth::Khz500, - }; - RadioConfig { - freq_hz: c.freq_hz, - bw, - sf: c.sf, - cr: c.cr, - sync_word: c.sync_word, - tx_power_dbm: c.tx_power_dbm, - preamble_len: c.preamble_len, - cad: c.cad, - } -} - -fn into_command(a: &ArbCommand) -> Command { - match a { - ArbCommand::Ping => Command::Ping, - ArbCommand::GetConfig => Command::GetConfig, - ArbCommand::SetConfig(c) => Command::SetConfig(into_config(c)), - ArbCommand::StartRx => Command::StartRx, - ArbCommand::StopRx => Command::StopRx, - ArbCommand::Transmit { config, payload } => { - let cfg = config.as_ref().map(into_config); - let mut p: HVec = HVec::new(); - let take = payload.len().min(MAX_PAYLOAD); - let _ = p.extend_from_slice(&payload[..take]); - Command::Transmit { - config: cfg, - payload: p, - } - } - ArbCommand::DisplayOn => Command::DisplayOn, - ArbCommand::DisplayOff => Command::DisplayOff, - ArbCommand::GetMac => Command::GetMac, - } -} - -fn encode(cmd: &Command, out: &mut [u8]) -> usize { - match cmd { - Command::Ping => { - out[0] = 0; - 1 - } - Command::GetConfig => { - out[0] = 1; - 1 - } - Command::SetConfig(cfg) => { - out[0] = 2; - cfg.write_to(&mut out[1..]); - 1 + RADIO_CONFIG_SIZE - } - Command::StartRx => { - out[0] = 3; - 1 - } - Command::StopRx => { - out[0] = 4; - 1 - } - Command::Transmit { config, payload } => { - out[0] = 5; - let mut pos = 1; - if let Some(cfg) = config { - out[pos] = 1; - pos += 1; - cfg.write_to(&mut out[pos..]); - pos += RADIO_CONFIG_SIZE; - } else { - out[pos] = 0; - pos += 1; - } - out[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes()); - pos += 2; - out[pos..pos + payload.len()].copy_from_slice(payload); - pos + payload.len() - } - Command::DisplayOn => { - out[0] = 6; - 1 - } - Command::DisplayOff => { - out[0] = 7; - 1 - } - Command::GetMac => { - out[0] = 8; - 1 - } - } -} - -fuzz_target!(|data: &[u8]| { - let mut u = Unstructured::new(data); - let Ok(arb) = ArbCommand::arbitrary(&mut u) else { - return; - }; - let cmd = into_command(&arb); - - let mut buf = [0u8; 1024]; - let n = encode(&cmd, &mut buf); - let parsed = Command::from_bytes(&buf[..n]).expect("encoded command must parse"); - assert_eq!(parsed, cmd); -}); diff --git a/fuzz/fuzz_targets/decoder_feed.rs b/fuzz/fuzz_targets/decoder_feed.rs new file mode 100644 index 0000000..debcc6a --- /dev/null +++ b/fuzz/fuzz_targets/decoder_feed.rs @@ -0,0 +1,16 @@ +//! `FrameDecoder::feed` must never panic on arbitrary input, and any +//! yielded payload slice must fit the documented bounds. + +#![no_main] + +use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let mut decoder = FrameDecoder::new(); + decoder.feed(data, |res| { + if let FrameResult::Ok { payload, .. } = res { + assert!(payload.len() <= MAX_PAYLOAD_FIELD); + } + }); +}); diff --git a/fuzz/fuzz_targets/frame_roundtrip.rs b/fuzz/fuzz_targets/frame_roundtrip.rs new file mode 100644 index 0000000..ae5f3e6 --- /dev/null +++ b/fuzz/fuzz_targets/frame_roundtrip.rs @@ -0,0 +1,44 @@ +//! An arbitrary well-formed `(type_id, tag, payload)` must survive a +//! frame encode → decode round trip byte-for-byte. + +#![no_main] + +use arbitrary::Arbitrary; +use donglora_protocol::{FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, encode_frame}; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +struct Input<'a> { + type_id: u8, + tag: u16, + payload: &'a [u8], +} + +fuzz_target!(|input: Input| { + if input.payload.len() > MAX_PAYLOAD_FIELD { + return; + } + let mut wire = [0u8; MAX_WIRE_FRAME]; + let Ok(n) = encode_frame(input.type_id, input.tag, input.payload, &mut wire) else { + return; + }; + + let mut decoder = FrameDecoder::new(); + let mut matched = false; + decoder.feed(&wire[..n], |res| { + if let FrameResult::Ok { + type_id, + tag, + payload, + } = res + { + assert_eq!(type_id, input.type_id); + assert_eq!(tag, input.tag); + assert_eq!(payload, input.payload); + matched = true; + } else { + panic!("encode→decode produced a frame error: {:?}", res); + } + }); + assert!(matched, "encoded frame did not decode"); +}); diff --git a/fuzz/fuzz_targets/lora_config_roundtrip.rs b/fuzz/fuzz_targets/lora_config_roundtrip.rs new file mode 100644 index 0000000..fa1ae7a --- /dev/null +++ b/fuzz/fuzz_targets/lora_config_roundtrip.rs @@ -0,0 +1,50 @@ +//! `LoRaConfig` round-trips through its wire encoding for every +//! construction derived via `Arbitrary`. + +#![no_main] + +use arbitrary::Arbitrary; +use donglora_protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode}; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +struct Input { + freq_hz: u32, + sf: u8, + bw_raw: u8, + cr_raw: u8, + preamble_len: u16, + sync_word: u16, + tx_power_dbm: i8, + header_raw: u8, + payload_crc: bool, + iq_invert: bool, +} + +fuzz_target!(|input: Input| { + let Some(bw) = LoRaBandwidth::from_u8(input.bw_raw & 0x0F) else { + return; + }; + let Some(cr) = LoRaCodingRate::from_u8(input.cr_raw & 0x03) else { + return; + }; + let Some(header_mode) = LoRaHeaderMode::from_u8(input.header_raw & 0x01) else { + return; + }; + let cfg = LoRaConfig { + freq_hz: input.freq_hz, + sf: input.sf, + bw, + cr, + preamble_len: input.preamble_len, + sync_word: input.sync_word, + tx_power_dbm: input.tx_power_dbm, + header_mode, + payload_crc: input.payload_crc, + iq_invert: input.iq_invert, + }; + + let mut buf = [0u8; 32]; + let n = cfg.encode(&mut buf).unwrap(); + assert_eq!(LoRaConfig::decode(&buf[..n]).unwrap(), cfg); +}); diff --git a/fuzz/fuzz_targets/parse_command.rs b/fuzz/fuzz_targets/parse_command.rs index eee7e65..601d1e4 100644 --- a/fuzz/fuzz_targets/parse_command.rs +++ b/fuzz/fuzz_targets/parse_command.rs @@ -1,14 +1,20 @@ -//! `Command::from_bytes` must never panic on arbitrary input. -//! -//! Any accepted command must also respect the heapless payload bound. +//! `Command::parse` must never panic on arbitrary `(type_id, payload)` +//! input. Accepted commands must respect the OTA payload bound. #![no_main] -use donglora_protocol::{Command, MAX_PAYLOAD}; +use arbitrary::Arbitrary; +use donglora_protocol::{Command, MAX_OTA_PAYLOAD}; use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { - if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(data) { - assert!(payload.len() <= MAX_PAYLOAD); +#[derive(Debug, Arbitrary)] +struct Input<'a> { + type_id: u8, + payload: &'a [u8], +} + +fuzz_target!(|input: Input| { + if let Ok(Command::Tx { data, .. }) = Command::parse(input.type_id, input.payload) { + assert!(data.len() <= MAX_OTA_PAYLOAD); } }); diff --git a/fuzz/fuzz_targets/parse_device_message.rs b/fuzz/fuzz_targets/parse_device_message.rs new file mode 100644 index 0000000..be1151d --- /dev/null +++ b/fuzz/fuzz_targets/parse_device_message.rs @@ -0,0 +1,18 @@ +//! `DeviceMessage::parse` must never panic on arbitrary input. + +#![no_main] + +use arbitrary::Arbitrary; +use donglora_protocol::DeviceMessage; +use libfuzzer_sys::fuzz_target; + +#[derive(Debug, Arbitrary)] +struct Input<'a> { + type_id: u8, + payload: &'a [u8], + originating_cmd: Option, +} + +fuzz_target!(|input: Input| { + let _ = DeviceMessage::parse(input.type_id, input.payload, input.originating_cmd); +}); diff --git a/fuzz/fuzz_targets/parse_radio_config.rs b/fuzz/fuzz_targets/parse_radio_config.rs deleted file mode 100644 index a16ee23..0000000 --- a/fuzz/fuzz_targets/parse_radio_config.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! `RadioConfig::from_bytes` must never panic on arbitrary input. - -#![no_main] - -use donglora_protocol::RadioConfig; -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - let _ = RadioConfig::from_bytes(data); -}); diff --git a/justfile b/justfile index 792b2b0..9720ba0 100644 --- a/justfile +++ b/justfile @@ -40,13 +40,11 @@ test-deny: # fuzz every target for 60s (requires nightly + cargo-fuzz) test-fuzz: + cd fuzz && cargo +nightly fuzz run decoder_feed -- -max_total_time=60 cd fuzz && cargo +nightly fuzz run parse_command -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run parse_radio_config -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run cobs_decoder_feed -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run cobs_roundtrip -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run command_roundtrip -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run cobs_decoder_multi_frame -- -max_total_time=60 - cd fuzz && cargo +nightly fuzz run cobs_chunk_invariant -- -max_total_time=60 + cd fuzz && cargo +nightly fuzz run parse_device_message -- -max_total_time=60 + cd fuzz && cargo +nightly fuzz run frame_roundtrip -- -max_total_time=60 + cd fuzz && cargo +nightly fuzz run lora_config_roundtrip -- -max_total_time=60 # run tests under miri (catches UB, requires nightly + miri component) # The proptest suite is skipped — its file-persistence layer calls getcwd @@ -59,22 +57,16 @@ test-miri: # # Runs under both feature configurations so that feature-gated paths # (currently only the defmt::Format derives) are covered in both shapes. -# Three mutants are excluded as provably equivalent — swapping the -# operator does not change behaviour because a downstream check catches -# the resulting case. See the regex strings for the specific sites. +# No exclusions yet for the v1.0 codec — rerun mutation testing on a +# fresh target and add `-E` lines here for any provably-equivalent +# mutants that survive, with a comment explaining why. test-mutants: test-mutants-default test-mutants-no-default test-mutants-default: - cargo mutants --no-shuffle --test-tool=nextest \ - -E 'replace > with >= in Command::from_bytes' \ - -E 'replace > with >= in CobsDecoder::feed' \ - -E 'replace < with <= in cobs_encode_response' + cargo mutants --no-shuffle --test-tool=nextest test-mutants-no-default: - cargo mutants --no-shuffle --test-tool=nextest --no-default-features \ - -E 'replace > with >= in Command::from_bytes' \ - -E 'replace > with >= in CobsDecoder::feed' \ - -E 'replace < with <= in cobs_encode_response' + cargo mutants --no-shuffle --test-tool=nextest --no-default-features # format code fmt: diff --git a/src/chip_id.rs b/src/chip_id.rs new file mode 100644 index 0000000..68d9ae3 --- /dev/null +++ b/src/chip_id.rs @@ -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 { + 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); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..01cc1b6 --- /dev/null +++ b/src/commands.rs @@ -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 { + 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, + }, + 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 { + 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 { + 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 for CommandEncodeError { + fn from(e: ModulationEncodeError) -> Self { + match e { + ModulationEncodeError::BufferTooSmall => Self::BufferTooSmall, + ModulationEncodeError::SyncWordTooLong => Self::SyncWordTooLong, + } + } +} + +impl From 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) + )); + } +} diff --git a/src/crc.rs b/src/crc.rs new file mode 100644 index 0000000..53bedfd --- /dev/null +++ b/src/crc.rs @@ -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); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..21b1ed6 --- /dev/null +++ b/src/errors.rs @@ -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()); + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..5cf31d0 --- /dev/null +++ b/src/events.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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 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 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) + )); + } +} diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..71914cc --- /dev/null +++ b/src/frame.rs @@ -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 { + 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)>(&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)>(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), 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 = 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]); + } +} diff --git a/src/framing.rs b/src/framing.rs deleted file mode 100644 index d10efeb..0000000 --- a/src/framing.rs +++ /dev/null @@ -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 - } -} diff --git a/src/info.rs b/src/info.rs new file mode 100644 index 0000000..7ee9e73 --- /dev/null +++ b/src/info.rs @@ -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::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 { + 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 { + 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 80db2ff..75107a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { - 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 { - 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, - 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, Clone, 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. +/// 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, } diff --git a/src/modulation.rs b/src/modulation.rs new file mode 100644 index 0000000..1ae6481 --- /dev/null +++ b/src/modulation.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/tests/mutation_killers.rs b/tests/mutation_killers.rs index 3f5dba0..0b01515 100644 --- a/tests/mutation_killers.rs +++ b/tests/mutation_killers.rs @@ -1,155 +1,981 @@ -//! Targeted tests that kill mutants surfaced by `cargo mutants`. +//! Targeted tests that exercise specific branches of the codec so +//! surviving mutants surface quickly. //! -//! Each test below corresponds to a specific mutant that proptest and -//! the canonical vectors failed to catch. Keep them — deleting one -//! unlocks the corresponding mutant. +//! These complement `vectors.rs` (canonical wire bytes) and +//! `proptest.rs` (random round-trips) by pinning narrow edge cases that +//! random strategies tend to miss — e.g. off-by-one comparisons, CRC +//! byte order, COBS boundary cases, reserved-bit validation. -#![allow(clippy::unwrap_used, clippy::panic)] +#![allow(clippy::unwrap_used, clippy::panic, clippy::bool_assert_comparison)] -use donglora_protocol::framing::{CobsDecoder, MAX_FRAME, cobs_encode_response}; -use donglora_protocol::{Bandwidth, Command, RADIO_CONFIG_SIZE, RadioConfig, Response}; +use donglora_protocol::{ + Command, CommandEncodeError, CommandParseError, DeviceMessage, DeviceMessageParseError, + ErrorCode, FrameDecodeError, FrameDecoder, FrameResult, Info, InfoParseError, LoRaBandwidth, + LoRaCodingRate, LoRaConfig, LoRaHeaderMode, MAX_MCU_UID_LEN, MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, + MAX_PAYLOAD_FIELD, MAX_PRE_COBS_FRAME, MAX_RADIO_UID_LEN, MAX_SETCONFIG_OK_PAYLOAD, + MAX_WIRE_FRAME, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, + SetConfigResultCode, TxDonePayload, TxFlags, TxResult, cap, commands, crc::crc16, encode_frame, + events, +}; use heapless::Vec as HVec; -fn base_config() -> RadioConfig { - RadioConfig { - freq_hz: 915_000_000, - bw: Bandwidth::Khz125, - sf: 7, - cr: 5, - sync_word: 0x1234, - tx_power_dbm: 22, - preamble_len: 16, - cad: 1, - } -} - -// ── RadioConfig::cad_enabled ──────────────────────────────────────── +// ── CRC boundary vectors ─────────────────────────────────────────── #[test] -fn cad_enabled_is_true_for_non_zero() { - // Kills: replace cad_enabled with `true`/`false`, replace `!=` with `==`. - for cad in [1u8, 2, 127, 255] { - let cfg = RadioConfig { - cad, - ..base_config() - }; - assert!(cfg.cad_enabled(), "cad={cad} must report enabled"); +fn crc16_check_value_is_0x29b1() { + // Spec Appendix B — the canonical CRC-16/CCITT-FALSE check value. + // Any accidental variant switch (KERMIT, XMODEM, reflected, different + // init) breaks this before it reaches the wire layer. + assert_eq!(crc16(b"123456789"), 0x29B1); +} + +#[test] +fn crc16_single_null_byte_not_zero() { + // A naive "init = 0" implementation would yield 0 for a single null + // input. The correct CCITT-FALSE init 0xFFFF yields 0xE1F0. + assert_eq!(crc16(&[0x00]), 0xE1F0); +} + +#[test] +fn crc16_empty_input_is_initial_value() { + assert_eq!(crc16(&[]), 0xFFFF); +} + +// ── Frame CRC trailer positioning ────────────────────────────────── + +#[test] +fn encode_then_decode_flipped_crc_byte_fails() { + // Target: swapping CRC byte order or indexing off-by-one in the + // CRC trailer placement. Take a valid frame, flip one CRC byte, and + // confirm the decoder rejects. + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(commands::TYPE_PING, 0x0042, &[], &mut wire).unwrap(); + // Last byte is 0x00 sentinel; the byte before that is part of the + // COBS-encoded CRC. Flip it to inject CRC corruption. + wire[n - 2] ^= 0x01; + + let mut decoder = FrameDecoder::new(); + let mut saw_err = false; + decoder.feed(&wire[..n], |res| { + if matches!(res, FrameResult::Err(_)) { + saw_err = true; + } + }); + assert!(saw_err, "CRC-corrupted frame must produce FrameResult::Err"); +} + +#[test] +fn decoder_rejects_frame_exactly_4_bytes_post_cobs() { + // Min valid post-COBS frame is 5 bytes (type + tag + crc). A frame + // that decodes to exactly 4 bytes post-COBS must be flagged TooShort + // — catches off-by-one on the minimum-length check. + // COBS of [0xAA, 0xBB, 0xCC, 0xDD] is [0x05, 0xAA, 0xBB, 0xCC, 0xDD]. + let wire = [0x05, 0xAA, 0xBB, 0xCC, 0xDD, 0x00]; + let mut decoder = FrameDecoder::new(); + let mut saw_too_short = false; + decoder.feed(&wire, |res| { + if matches!(res, FrameResult::Err(FrameDecodeError::TooShort)) { + saw_too_short = true; + } + }); + assert!(saw_too_short); +} + +#[test] +fn decoder_accepts_frame_exactly_5_bytes_post_cobs() { + // The 5-byte boundary must succeed (empty payload: type + tag + crc). + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); + let mut decoder = FrameDecoder::new(); + let mut saw_ok = false; + decoder.feed(&wire[..n], |res| { + if matches!(res, FrameResult::Ok { .. }) { + saw_ok = true; + } + }); + assert!(saw_ok); +} + +// ── TX flag boundary ─────────────────────────────────────────────── + +#[test] +fn tx_flags_reserved_bits_each_rejected() { + // Target: `b & !0b0000_0001 != 0` mutated to `== 0` or flipped + // elsewhere. Verify every single reserved bit position triggers + // rejection. + for bit in 1..8 { + let byte = 1u8 << bit; + assert!( + matches!( + TxFlags::from_byte(byte), + Err(CommandParseError::ReservedBitSet) + ), + "bit {bit} must be rejected", + ); + } + // Bit 0 alone is allowed (that's the skip_cad flag). + assert_eq!(TxFlags::from_byte(0b0000_0001).unwrap().skip_cad, true); +} + +// ── Empty-TX rejection ───────────────────────────────────────────── + +#[test] +fn tx_parse_rejects_only_flags_byte() { + // Spec §6.4: TX with zero data bytes must be rejected. + assert!(matches!( + Command::parse(commands::TYPE_TX, &[0x00]), + Err(CommandParseError::WrongLength) + )); +} + +#[test] +fn tx_parse_accepts_one_data_byte() { + // Minimum valid TX: flags byte + exactly one data byte. + let got = Command::parse(commands::TYPE_TX, &[0x00, 0x42]).unwrap(); + match got { + Command::Tx { flags, data } => { + assert_eq!(flags.skip_cad, false); + assert_eq!(data.as_slice(), &[0x42]); + } + other => panic!("expected Tx, got {:?}", other), } } #[test] -fn cad_enabled_is_false_for_zero() { - let cfg = RadioConfig { - cad: 0, - ..base_config() - }; - assert!(!cfg.cad_enabled()); -} - -// ── Command::from_bytes Transmit with-config boundary ────────────── - -#[test] -fn transmit_with_config_requires_more_than_radio_config_size() { - // The parser checks `rest.len() > RADIO_CONFIG_SIZE` so there is at - // least one byte past the config for the `len` field (actually two, - // caught by a later check). Swapping `>` for `>=` or `&&` for `||` - // silently breaks the boundary — these inputs pin it. - - // has_config=1 with exactly RADIO_CONFIG_SIZE bytes of config and - // nothing else: must reject (no room for the len field). - let mut just_config = [0u8; 2 + RADIO_CONFIG_SIZE]; - just_config[0] = 5; // Transmit tag - just_config[1] = 1; // has_config = true - base_config().write_to(&mut just_config[2..]); - assert!( - Command::from_bytes(&just_config).is_none(), - "has_config=1 with no len bytes must reject", - ); - - // has_config=2 is a sentinel the parser must reject outright. - // Swapping `&&` for `||` would accept it (high byte count would make - // the second half true). - let mut bad_flag = [0u8; 32]; - bad_flag[0] = 5; - bad_flag[1] = 2; - assert!( - Command::from_bytes(&bad_flag).is_none(), - "has_config must be strictly 0 or 1", - ); -} - -// ── CobsDecoder::reset ───────────────────────────────────────────── - -#[test] -fn cobs_decoder_reset_discards_partial_frame() { - let mut decoder = CobsDecoder::new(); - - // Feed half a frame (no trailing 0x00 yet) then reset. The reset must - // discard the accumulator so the next (different) frame decodes correctly. - decoder.feed(&[0x02, 0x99], |_| panic!("no command yet")); - decoder.reset(); - - // Now feed a complete, properly-framed Ping (tag 0). - let mut raw = [0u8; 1]; - raw[0] = 0; - let mut framed = [0u8; 8]; - let n = ucobs::encode(&raw[..1], &mut framed).unwrap(); - framed[n] = 0x00; - - let mut got = None; - decoder.feed(&framed[..n + 1], |c| got = Some(c)); - assert_eq!(got, Some(Command::Ping)); -} - -// ── CobsDecoder::feed — empty-frame handling ─────────────────────── - -#[test] -fn cobs_decoder_treats_back_to_back_zeros_as_empty_frames() { - // Kills: `if self.len > 0` → `>=`. With `>=` we would attempt to - // decode an empty buffer on every stray 0x00 byte, which ucobs::decode - // rejects — so the behaviour would be a silent change in error-handling - // rather than a functional break. This test pins the current contract: - // feeding only sentinel bytes never yields a Command and never panics. - let mut decoder = CobsDecoder::new(); - let mut yielded = 0; - decoder.feed(&[0, 0, 0, 0, 0], |_| yielded += 1); - assert_eq!(yielded, 0); -} - -// ── cobs_encode_response — buffer-full boundary ──────────────────── - -#[test] -fn cobs_encode_response_rejects_overflow() { - // A maximum-payload RxPacket produces 263 raw bytes + 3 COBS overhead - // ≈ 266 bytes — well under MAX_FRAME (512). To exercise the overflow - // branch we fabricate the condition by encoding into a too-small - // buffer via the same function on a synthetic oversized response. - // - // This test uses the real function with real buffers. The overflow - // branch fires only when `encoded_len + 1 > encode_buf.len()`, which - // for MAX_FRAME = 512 cannot happen with any valid Response. So we - // rely on proptest / fuzz to exercise sub-threshold cases and pin the - // happy path here: the happy path must always produce a trailing 0x00 - // sentinel and leave the encoded-length byte in bounds. - let mut payload: HVec = HVec::new(); - for i in 0..256u16 { - payload.push((i & 0xFF) as u8).unwrap(); +fn tx_parse_rejects_payload_exceeding_max_ota() { + // Craft MAX_OTA_PAYLOAD + 1 bytes of data (plus the flags byte). + let mut bytes: HVec = HVec::new(); + bytes.push(0x00).unwrap(); // flags + for _ in 0..=MAX_OTA_PAYLOAD { + bytes.push(0x55).unwrap(); } - let mut write = [0u8; MAX_FRAME]; - let mut encode = [0u8; MAX_FRAME]; - let framed = cobs_encode_response( - Response::RxPacket { - rssi: -1, - snr: -2, + assert!(matches!( + Command::parse(commands::TYPE_TX, &bytes), + Err(CommandParseError::WrongLength) + )); +} + +// ── COBS run-length boundaries ───────────────────────────────────── + +#[test] +fn cobs_254_byte_nonzero_run() { + // A long run of nonzero payload bytes forces COBS to emit a 0xFF + // code byte and start a fresh run — the classic boundary case + // from `PROTOCOL.md §C.8.3`. We pick a run large enough that the + // type + tag + payload + CRC span exceeds 254 bytes without any + // interior zero to break it up. + let mut big: HVec = HVec::new(); + for i in 1..=253u8 { + big.push(i).unwrap(); + } + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x04, 0x0101, &big, &mut wire).unwrap(); + + let mut decoder = FrameDecoder::new(); + let mut seen: Option<(u8, u16, HVec)> = None; + decoder.feed(&wire[..n], |res| { + if let FrameResult::Ok { + type_id, + tag, payload, - }, - &mut write, - &mut encode, - ) - .unwrap(); - // Last byte is the sentinel. - assert_eq!(*framed.last().unwrap(), 0x00); - // Encoded length sits strictly inside the buffer; the `< buf.len()` - // check must be strict, not `<=`. - assert!(framed.len() <= MAX_FRAME); + } = res + { + let mut p = HVec::new(); + p.extend_from_slice(payload).unwrap(); + seen = Some((type_id, tag, p)); + } + }); + let (ty, tag, payload) = seen.unwrap(); + assert_eq!(ty, 0x04); + assert_eq!(tag, 0x0101); + assert_eq!(payload.len(), big.len()); + assert_eq!(payload.as_slice(), big.as_slice()); +} + +#[test] +fn decoder_handles_split_feed() { + // Feed one full frame across two `feed()` calls. Accumulator must + // preserve the partial state, not reset on each call. + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); + assert!(n >= 3); + + let mut decoder = FrameDecoder::new(); + let mut count = 0; + decoder.feed(&wire[..n / 2], |_| { + count += 1; + }); + assert_eq!(count, 0, "no 0x00 in first chunk → no frame yet"); + + decoder.feed(&wire[n / 2..n], |res| { + if matches!(res, FrameResult::Ok { .. }) { + count += 1; + } + }); + assert_eq!(count, 1, "second chunk contains the 0x00 sentinel"); +} + +// ── Tag little-endian byte order ─────────────────────────────────── + +#[test] +fn tag_byte_order_is_le() { + // tag=0x0102 → bytes 02 01 (LE). Any mutant swapping the tag + // bytes would fail here. + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x01, 0x0102, &[], &mut wire).unwrap(); + + let mut decoder = FrameDecoder::new(); + let mut seen_tag = None; + decoder.feed(&wire[..n], |res| { + if let FrameResult::Ok { tag, .. } = res { + seen_tag = Some(tag); + } + }); + assert_eq!(seen_tag, Some(0x0102)); +} + +// ── Size-constant pins (lib.rs) ───────────────────────────────────── +// +// The `+` in `MAX_PRE_COBS_FRAME = FRAME_HEADER_SIZE + MAX_PAYLOAD_FIELD + +// FRAME_TRAILER_SIZE` etc. is a prime mutation target. Buffers sized +// from the mutated constant still work (bigger ≠ failure, smaller +// masked by existing slack), so exact-value tests are the canonical +// kill. + +#[test] +fn max_pre_cobs_frame_pins_exactly_280() { + assert_eq!(MAX_PRE_COBS_FRAME, 280); +} + +#[test] +fn max_wire_frame_pins_exactly_284() { + // 280 pre-COBS + 3 worst-case COBS overhead + 1 sentinel. + assert_eq!(MAX_WIRE_FRAME, 284); +} + +#[test] +fn max_ok_payload_pins_exactly_85() { + // 37 fixed + 32 mcu_uid + 16 radio_uid. Any mutation that flips + // `+` to `*` or `-` crashes this. + assert_eq!(MAX_OK_PAYLOAD, 85); +} + +#[test] +fn max_setconfig_ok_payload_pins_exactly_27() { + // 2 (result + owner) + 1 (modulation_id) + 24 (FSK with 8B sync). + assert_eq!(MAX_SETCONFIG_OK_PAYLOAD, 27); +} + +// ── Command::encode_payload / parse boundaries (commands.rs) ──────── + +fn tx_cmd(data_len: usize) -> Command { + let mut d: HVec = HVec::new(); + for i in 0..data_len { + d.push(i as u8).unwrap(); + } + Command::Tx { + flags: TxFlags::default(), + data: d, + } +} + +#[test] +fn tx_encode_accepts_exactly_max_ota_payload() { + // Kills `> MAX_OTA_PAYLOAD` → `>=` (the boundary-inclusive mutant). + let cmd = tx_cmd(MAX_OTA_PAYLOAD); + let mut buf = [0u8; MAX_OTA_PAYLOAD + 8]; + assert!(cmd.encode_payload(&mut buf).is_ok()); +} + +#[test] +fn tx_encode_rejects_max_ota_payload_plus_one() { + // Kills `> MAX_OTA_PAYLOAD` → `==` — a heapless::Vec can't hold + // more than MAX_OTA_PAYLOAD, so we reach the limit via the parse + // path with an oversized slice. + let mut bytes: HVec = HVec::new(); + bytes.push(0x00).unwrap(); + for _ in 0..=MAX_OTA_PAYLOAD { + bytes.push(0x42).unwrap(); + } + assert!(matches!( + Command::parse(commands::TYPE_TX, &bytes), + Err(CommandParseError::WrongLength) + )); +} + +#[test] +fn tx_encode_rejects_buffer_exactly_one_byte_too_small() { + // Kills `buf.len() < total` → `==` and `<=`. + let cmd = tx_cmd(5); + // total = 1 (flags) + 5 (data) = 6 + let mut exact = [0u8; 6]; + assert!(cmd.encode_payload(&mut exact).is_ok()); + + let mut too_small = [0u8; 5]; + assert!(matches!( + cmd.encode_payload(&mut too_small), + Err(CommandEncodeError::BufferTooSmall) + )); +} + +#[test] +fn tx_parse_body_exactly_max_ota_payload() { + // Kills `body.len() > MAX_OTA_PAYLOAD` → `==`. + let mut bytes: HVec = HVec::new(); + bytes.push(0x00).unwrap(); + for _ in 0..MAX_OTA_PAYLOAD { + bytes.push(0x42).unwrap(); + } + let cmd = Command::parse(commands::TYPE_TX, &bytes).unwrap(); + match cmd { + Command::Tx { data, .. } => assert_eq!(data.len(), MAX_OTA_PAYLOAD), + _ => panic!("expected Tx"), + } +} + +// ── SetConfigResultCode / Owner enum coverage (events.rs) ─────────── + +#[test] +fn set_config_result_code_as_u8_exact() { + // Kills `SetConfigResultCode::as_u8 -> u8 with 0` by pinning each + // non-zero variant's discriminant. + assert_eq!(SetConfigResultCode::Applied.as_u8(), 0); + assert_eq!(SetConfigResultCode::AlreadyMatched.as_u8(), 1); + assert_eq!(SetConfigResultCode::LockedMismatch.as_u8(), 2); +} + +#[test] +fn set_config_result_code_from_u8_exact() { + // Kills match-arm-deletion mutants (1, 2) by asserting each maps. + assert_eq!( + SetConfigResultCode::from_u8(0), + Some(SetConfigResultCode::Applied), + ); + assert_eq!( + SetConfigResultCode::from_u8(1), + Some(SetConfigResultCode::AlreadyMatched), + ); + assert_eq!( + SetConfigResultCode::from_u8(2), + Some(SetConfigResultCode::LockedMismatch), + ); + assert_eq!(SetConfigResultCode::from_u8(3), None); +} + +#[test] +fn owner_as_u8_exact() { + // Kills `Owner::as_u8 -> u8 with 1`. + assert_eq!(Owner::None.as_u8(), 0); + assert_eq!(Owner::Mine.as_u8(), 1); + assert_eq!(Owner::Other.as_u8(), 2); +} + +#[test] +fn owner_from_u8_exact() { + // Kills match-arm-deletion mutants (0, 2). + assert_eq!(Owner::from_u8(0), Some(Owner::None)); + assert_eq!(Owner::from_u8(1), Some(Owner::Mine)); + assert_eq!(Owner::from_u8(2), Some(Owner::Other)); + assert_eq!(Owner::from_u8(3), None); +} + +// ── SetConfigResult encode/decode length boundaries ──────────────── + +fn sample_lora_for_scr() -> LoRaConfig { + LoRaConfig { + freq_hz: 915_000_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 set_config_result_encode_rejects_exactly_one_byte_buf() { + // Kills `buf.len() < 2` → `==` / `<=`. + let r = SetConfigResult { + result: SetConfigResultCode::Applied, + owner: Owner::Mine, + current: Modulation::LoRa(sample_lora_for_scr()), + }; + let mut one = [0u8; 1]; + assert!(r.encode(&mut one).is_err()); + let mut enough = [0u8; 32]; + assert!(r.encode(&mut enough).is_ok()); +} + +#[test] +fn set_config_result_decode_rejects_exactly_one_byte_buf() { + // Kills `buf.len() < 2` → `==` / `<=` on the decode side. + assert!(matches!( + SetConfigResult::decode(&[]), + Err(DeviceMessageParseError::TooShort) + )); + assert!(matches!( + SetConfigResult::decode(&[0x00]), + Err(DeviceMessageParseError::TooShort) + )); +} + +// ── OkPayload::parse_for dispatches each command cleanly ─────────── + +#[test] +fn ok_payload_parses_get_info() { + // Kills `delete match arm commands::TYPE_GET_INFO`. + let mut info_buf = [0u8; 128]; + let info = sample_info(); + let n = info.encode(&mut info_buf).unwrap(); + let parsed = OkPayload::parse_for(commands::TYPE_GET_INFO, &info_buf[..n]).unwrap(); + assert!(matches!(parsed, OkPayload::Info(_))); +} + +#[test] +fn ok_payload_parses_set_config() { + // Kills `delete match arm commands::TYPE_SET_CONFIG`. + let r = SetConfigResult { + result: SetConfigResultCode::Applied, + owner: Owner::Mine, + current: Modulation::LoRa(sample_lora_for_scr()), + }; + let mut buf = [0u8; 64]; + let n = r.encode(&mut buf).unwrap(); + let parsed = OkPayload::parse_for(commands::TYPE_SET_CONFIG, &buf[..n]).unwrap(); + assert!(matches!(parsed, OkPayload::SetConfig(_))); +} + +// ── DeviceMessage::parse covers each type_id ─────────────────────── + +#[test] +fn device_message_parses_err() { + // Kills `delete match arm TYPE_ERR` in DeviceMessage::parse. + let mut err_buf = [0u8; 2]; + events::encode_err_payload(ErrorCode::EBusy, &mut err_buf).unwrap(); + let parsed = DeviceMessage::parse(events::TYPE_ERR, &err_buf, None).unwrap(); + assert!(matches!(parsed, DeviceMessage::Err(ErrorCode::EBusy))); +} + +#[test] +fn device_message_parses_tx_done() { + // Kills `delete match arm TYPE_TX_DONE`. + let td = TxDonePayload { + result: TxResult::Transmitted, + airtime_us: 42, + }; + let mut buf = [0u8; 8]; + let n = td.encode(&mut buf).unwrap(); + let parsed = DeviceMessage::parse(events::TYPE_TX_DONE, &buf[..n], None).unwrap(); + assert!(matches!( + parsed, + DeviceMessage::TxDone(TxDonePayload { airtime_us: 42, .. }) + )); +} + +// ── RxPayload::decode length math (events.rs:255) ────────────────── + +#[test] +fn rx_payload_decode_subtracts_metadata_size() { + // `n = buf.len() - METADATA_SIZE` — kills `- -> /`. If `-` is + // replaced with `/`, the resulting n would be `buf.len() / 20` + // which for a 21-byte buffer yields 1 (matches the truth) — but + // for a 40-byte buffer yields 2 (wrong; truth is 20). Use a + // buffer length that makes the two calculations disagree. + let mut meta = [0u8; 20]; + meta[0..2].copy_from_slice(&(-100i16).to_le_bytes()); + meta[16] = 1; // crc_valid + meta[19] = 0; // origin Ota + let mut buf: HVec = HVec::new(); + buf.extend_from_slice(&meta).unwrap(); + buf.extend_from_slice(&[0x01u8; 20]).unwrap(); + let decoded = RxPayload::decode(&buf).unwrap(); + assert_eq!(decoded.data.len(), 20); +} + +// ── FrameDecoder reset actually clears state ────────────────────── + +#[test] +fn frame_decoder_reset_clears_accumulator() { + // Kills `replace FrameDecoder::reset with ()`. + let mut decoder = FrameDecoder::new(); + // Feed a half-frame: bytes without a 0x00 terminator. + decoder.feed(&[0xAA, 0xBB, 0xCC, 0xDD], |_| {}); + decoder.reset(); + // Now feed a complete, valid frame. The accumulator must not carry + // the stale bytes — if `reset` is a no-op, the decoder prepends + // them and the CRC / COBS decode fails. + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x01, 0x0001, &[], &mut wire).unwrap(); + let mut saw_ok = false; + decoder.feed(&wire[..n], |res| { + if matches!(res, FrameResult::Ok { .. }) { + saw_ok = true; + } + }); + assert!(saw_ok, "reset() must clear stale bytes"); +} + +// ── Frame decoder overflow || vs && branch (frame.rs:111) ────────── + +#[test] +fn frame_decoder_stray_sentinel_does_not_emit() { + // `self.overflowed || self.len == 0` mutated to `&&` would emit a + // spurious error for a stray 0x00 (both `overflowed` and `len == 0` + // must be true under `&&`, but on a naked `0x00` only `len == 0` + // holds — so the branch stops triggering and the decoder tries to + // COBS-decode an empty buffer). + let mut decoder = FrameDecoder::new(); + let mut saw_any = false; + decoder.feed(&[0x00], |_| { + saw_any = true; + }); + assert!( + !saw_any, + "stray 0x00 with empty buffer must not produce any frame" + ); +} + +// ── Capability bit values (info.rs) ──────────────────────────────── +// +// Kills the 14 `<< -> >>` mutants on the cap:: constants by pinning +// each bit's expected u64 value. + +#[test] +fn cap_bit_values_pinned() { + assert_eq!(cap::LORA, 1); + assert_eq!(cap::FSK, 2); + assert_eq!(cap::GFSK, 4); + assert_eq!(cap::LR_FHSS, 8); + assert_eq!(cap::FLRC, 16); + assert_eq!(cap::MSK, 32); + assert_eq!(cap::GMSK, 64); + assert_eq!(cap::BLE_COMPATIBLE, 128); + assert_eq!(cap::CAD_BEFORE_TX, 0x0001_0000); + assert_eq!(cap::IQ_INVERSION, 0x0002_0000); + assert_eq!(cap::RANGING, 0x0004_0000); + assert_eq!(cap::GNSS_SCAN, 0x0008_0000); + assert_eq!(cap::WIFI_MAC_SCAN, 0x0010_0000); + assert_eq!(cap::SPECTRAL_SCAN, 0x0020_0000); + assert_eq!(cap::FULL_DUPLEX, 0x0040_0000); + assert_eq!(cap::MULTI_CLIENT, 0x0000_0001_0000_0000); +} + +// ── Info::encode / decode boundaries ─────────────────────────────── + +fn sample_info() -> 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: 0x0002, + capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX, + supported_sf_bitmap: 0x1FE0, + supported_bw_bitmap: 0x03FF, + max_payload_bytes: 255, + rx_queue_capacity: 32, + tx_queue_capacity: 1, + 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 info_encode_rejects_buffer_exactly_one_byte_too_small() { + // Kills `buf.len() < total` → `==` / `<=` in Info::encode. + let i = sample_info(); + // 37 fixed + 8 mcu_uid + 0 radio_uid = 45 bytes required. + let mut exact = [0u8; 45]; + assert!(i.encode(&mut exact).is_ok()); + + let mut too_small = [0u8; 44]; + assert!(i.encode(&mut too_small).is_err()); +} + +#[test] +fn info_decode_rejects_mcu_uid_len_overrun() { + // Kills the `<` boundary checks and `+` arithmetic in Info::decode + // by constructing an `mcu_uid_len` that extends past the buffer. + let i = sample_info(); + let mut buf = [0u8; 128]; + let n = i.encode(&mut buf).unwrap(); + // Pretend mcu_uid_len is 30 so radio_len_idx is 66, beyond n=45. + buf[35] = 30; + assert!(matches!( + Info::decode(&buf[..n]), + Err(donglora_protocol::InfoParseError::TooShort) + )); +} + +#[test] +fn info_decode_offsets_are_correct() { + // Implicitly kills the `+ -> -` and `+ -> *` mutants in + // Info::decode's offset calculations by roundtripping a + // non-zero radio_uid_len AND non-zero mcu_uid_len — any arithmetic + // bug flips the data between the two UIDs. + let mut i = sample_info(); + let mut radio_uid = [0u8; MAX_RADIO_UID_LEN]; + radio_uid[..4].copy_from_slice(&[0xCA, 0xFE, 0xBA, 0xBE]); + i.radio_uid_len = 4; + i.radio_uid = radio_uid; + + let mut buf = [0u8; 128]; + let n = i.encode(&mut buf).unwrap(); + let decoded = Info::decode(&buf[..n]).unwrap(); + assert_eq!( + &decoded.mcu_uid[..8], + &[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x23, 0x45, 0x67] + ); + assert_eq!(&decoded.radio_uid[..4], &[0xCA, 0xFE, 0xBA, 0xBE]); +} + +#[test] +fn info_min_wire_size_pinned() { + // Kills mutants touching the fixed prefix size. + assert_eq!(Info::MIN_WIRE_SIZE, 37); +} + +// ── FskConfig::encode buffer-size boundary ───────────────────────── + +#[test] +fn fsk_encode_rejects_buffer_exactly_one_byte_too_small() { + // Kills `buf.len() < total` → `==` / `<=` in FskConfig::encode. + let mut cfg = donglora_protocol::FskConfig { + freq_hz: 0, + bitrate_bps: 0, + freq_dev_hz: 0, + rx_bw: 0, + preamble_len: 0, + sync_word_len: 4, + sync_word: [0u8; donglora_protocol::MAX_SYNC_WORD_LEN], + }; + cfg.sync_word[..4].copy_from_slice(&[1, 2, 3, 4]); + // total = 16 + 4 = 20 + let mut exact = [0u8; 20]; + assert!(cfg.encode(&mut exact).is_ok()); + + let mut too_small = [0u8; 19]; + assert!(cfg.encode(&mut too_small).is_err()); +} + +// ── Frame encode buffer boundary (frame.rs:68) ───────────────────── + +#[test] +fn encode_frame_rejects_output_buffer_exactly_one_byte_too_small() { + // Kills `out.len() < total` → `<=` in encode_frame. + // + // We need to know the exact encoded size for our chosen payload. + let mut generous = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0x01, 0x0001, b"test", &mut generous).unwrap(); + assert!(n >= 3); + let mut exact = [0u8; MAX_WIRE_FRAME]; + assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n]).is_ok()); + assert!(encode_frame(0x01, 0x0001, b"test", &mut exact[..n - 1]).is_err()); +} + +// ── frame.rs MAX_COBS_ENCODED arithmetic (frame.rs:38) ───────────── + +#[test] +fn max_wire_frame_minus_one_leaves_room_for_sentinel() { + // Indirectly kills `MAX_COBS_ENCODED = MAX_WIRE_FRAME - 1` mutants: + // encoding a maximum-payload frame must fit in exactly + // MAX_WIRE_FRAME bytes, and no fewer. + let big = [0x42u8; MAX_PAYLOAD_FIELD]; + let mut wire = [0u8; MAX_WIRE_FRAME]; + let n = encode_frame(0xC0, 0x0000, &big, &mut wire).unwrap(); + assert!(n <= MAX_WIRE_FRAME); + assert!(n > MAX_PAYLOAD_FIELD); +} + +// ── Unknown command type produces UnknownType, not WrongLength ───── + +#[test] +fn parse_unknown_type_with_empty_payload() { + // 0x10 is reserved (not a defined H→D command type). The parser + // must surface UnknownType so the caller can emit EUNKNOWN_CMD. + assert!(matches!( + Command::parse(0x10, &[]), + Err(CommandParseError::UnknownType) + )); +} + +// ── SetConfigResult::encode boundary (events.rs:103) ────────────── +// +// `buf.len() < 2` — the guard clamps writes to the result + owner +// bytes. A test that just flips the boundary to `is_err()` can't tell +// `<` from `<=` because the downstream `Modulation::encode` also fails. +// We distinguish by checking that buf[0] / buf[1] ARE written before +// the modulation encode fails: unmutated writes them; the `<=` mutant +// returns early and leaves the buffer untouched. + +#[test] +fn set_config_result_encode_writes_header_before_modulation_fails() { + let r = SetConfigResult { + result: SetConfigResultCode::LockedMismatch, + owner: Owner::Other, + current: Modulation::LoRa(sample_lora_for_scr()), + }; + let mut buf = [0xFFu8; 2]; + let res = r.encode(&mut buf); + assert!(res.is_err(), "modulation encode needs more than 2 bytes"); + assert_eq!( + buf[0], + SetConfigResultCode::LockedMismatch.as_u8(), + "result byte must be written before modulation encode fails", + ); + assert_eq!( + buf[1], + Owner::Other.as_u8(), + "owner byte must be written before modulation encode fails", + ); +} + +// ── SetConfigResult::decode boundary (events.rs:116) ─────────────── +// +// Unmutated: `buf.len() < 2` passes for len==2, then `Modulation::decode(&[])` +// returns `TooShort` → mapped to `WrongLength`. Mutated `<=`: guard trips, +// returns `TooShort`. Distinguish via the error variant. + +#[test] +fn set_config_result_decode_two_byte_buf_is_wrong_length_not_too_short() { + let res = SetConfigResult::decode(&[0x00, 0x01]); + assert!( + matches!(res, Err(DeviceMessageParseError::WrongLength)), + "decode of a 2-byte buffer must surface WrongLength from the inner \ + Modulation::decode call, not TooShort from the guard", + ); +} + +// ── RxPayload::decode uses `n` for the data slice (events.rs:255) ─ +// +// After the refactor, `n = buf.len() - METADATA_SIZE` is load-bearing: +// the data slice is `&buf[METADATA_SIZE..METADATA_SIZE + n]`. A mutant +// that swaps `-` for `/` yields a smaller n and a shorter data slice, +// which the round-trip observes. + +#[test] +fn rx_payload_encode_accepts_exactly_max_ota_payload() { + // Kills `data.len() > MAX_OTA_PAYLOAD` → `==` in RxPayload::encode. + // At exactly MAX_OTA_PAYLOAD, the `==` mutant trips + // PayloadTooLarge where the unmutated path encodes cleanly. + let mut data: HVec = HVec::new(); + for i in 0..MAX_OTA_PAYLOAD { + data.push(i as u8).unwrap(); + } + let rx = RxPayload { + rssi_tenths_dbm: -735, + snr_tenths_db: 95, + freq_err_hz: 0, + timestamp_us: 0, + crc_valid: true, + packets_dropped: 0, + origin: RxOrigin::Ota, + data, + }; + let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD]; + let n = rx.encode(&mut buf).unwrap(); + assert_eq!(n, 20 + MAX_OTA_PAYLOAD); +} + +#[test] +fn rx_payload_encode_accepts_buffer_exactly_equal_to_total() { + // Kills `buf.len() < total` → `==` in RxPayload::encode. With the + // `==` mutant, an exactly-sized buffer trips BufferTooSmall instead + // of succeeding. + let mut data: HVec = 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, + }; + // total = 20 metadata + 4 data = 24. Exact fit. + let mut buf = [0u8; 24]; + let n = rx.encode(&mut buf).unwrap(); + assert_eq!(n, 24); +} + +#[test] +fn rx_payload_decode_accepts_exactly_max_ota_payload() { + // Kills `n > MAX_OTA_PAYLOAD` → `==` in RxPayload::decode. A buffer + // with exactly 20 + MAX_OTA_PAYLOAD bytes means n == MAX_OTA_PAYLOAD; + // the `==` mutant trips and returns WrongLength, unmutated decodes + // cleanly with full-length data. + let mut buf = [0u8; 20 + MAX_OTA_PAYLOAD]; + buf[16] = 1; // crc_valid + buf[19] = 0; // origin Ota + for (i, b) in buf[20..].iter_mut().enumerate() { + *b = i as u8; + } + let decoded = RxPayload::decode(&buf).unwrap(); + assert_eq!(decoded.data.len(), MAX_OTA_PAYLOAD); + assert_eq!(decoded.data[0], 0); + assert_eq!( + decoded.data[MAX_OTA_PAYLOAD - 1], + (MAX_OTA_PAYLOAD - 1) as u8 + ); +} + +#[test] +fn rx_payload_decode_accepts_exactly_metadata_size_no_data() { + // Kills both `buf.len() < METADATA_SIZE` → `==` / `<=` mutants on + // `RxPayload::decode`. A 20-byte buffer (metadata only, zero data) + // must succeed: unmutated returns an RxEvent with empty `data`; + // either mutant trips the guard and returns TooShort. + let mut buf = [0u8; 20]; + buf[16] = 1; // crc_valid + buf[19] = 0; // origin Ota + let decoded = RxPayload::decode(&buf).unwrap(); + assert_eq!(decoded.data.len(), 0); +} + +#[test] +fn rx_payload_decode_rejects_one_byte_below_metadata_size() { + // Complements the above: a 19-byte buffer must fail regardless of + // how the `<` boundary is mutated, since the data slice + // `buf[METADATA_SIZE..]` would otherwise index out of bounds. + let buf = [0u8; 19]; + assert!(matches!( + RxPayload::decode(&buf), + Err(DeviceMessageParseError::TooShort), + )); +} + +#[test] +fn rx_payload_decode_n_is_used_for_data_slice() { + let mut buf = [0u8; 40]; + buf[16] = 1; // crc_valid + buf[19] = 0; // origin Ota + for (i, b) in buf[20..40].iter_mut().enumerate() { + *b = i as u8; + } + let decoded = RxPayload::decode(&buf).unwrap(); + // Unmutated: n = 40 - 20 = 20 → data slice is all 20 bytes. + // Mutated `-` → `/`: n = 40 / 20 = 2 → data slice is only 2 bytes. + assert_eq!(decoded.data.len(), 20); + assert_eq!(decoded.data[0], 0); + assert_eq!(decoded.data[19], 19); +} + +// ── Info sample for the tests below ──────────────────────────────── + +fn info_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: 0x0002, + capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX, + supported_sf_bitmap: 0x1FE0, + supported_bw_bitmap: 0x03FF, + max_payload_bytes: 255, + rx_queue_capacity: 32, + tx_queue_capacity: 1, + 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], + } +} + +// ── Info::decode min-size boundary (info.rs:132) ─────────────────── +// +// A zero-UID Info encodes to exactly MIN_WIRE_SIZE (37) bytes. Decode +// of a 37-byte buffer must succeed. `<=` mutant makes it return +// TooShort. + +#[test] +fn info_decode_accepts_exactly_min_wire_size() { + let mut i = info_sample(); + i.mcu_uid_len = 0; + i.radio_uid_len = 0; + let mut buf = [0u8; 64]; + let n = i.encode(&mut buf).unwrap(); + assert_eq!(n, 37); + let decoded = Info::decode(&buf[..n]).unwrap(); + assert_eq!(decoded.mcu_uid_len, 0); + assert_eq!(decoded.radio_uid_len, 0); +} + +// ── Info::decode `radio_len_idx + 1` guard (info.rs:141) ────────── +// +// The `+ 1` reserves the byte that holds `radio_uid_len`. A 44-byte +// buffer with mcu_uid_len=8 has radio_len_idx=44 — the check needs 45 +// bytes, so 44 must produce TooShort. Mutants `+ → -` or `+ → *` +// either relax the guard and then panic reading buf[44], or simply +// allow the read to succeed with garbage data. + +#[test] +fn info_decode_radio_len_idx_plus_one_guard_is_active() { + let mut buf = [0u8; 44]; + buf[35] = 8; // mcu_uid_len so radio_len_idx lands at 44 + assert!(matches!(Info::decode(&buf), Err(InfoParseError::TooShort),)); +} + +// ── Info::decode `expected_total` uses both + (info.rs:149) ─────── +// +// `Self::MIN_WIRE_SIZE + mcu_n + radio_n` — a mutation to `-` at +// either `+` produces a smaller expected_total that passes the guard +// at buf.len()==45 (which unmutated correctly rejects as TooShort). +// With the guard bypassed, decode panics reading radio_uid from a +// slice that extends past the buffer. + +#[test] +fn info_decode_expected_total_uses_both_plus_signs() { + let mut i = info_sample(); + i.radio_uid_len = 4; + let mut big = [0u8; 128]; + let n = i.encode(&mut big).unwrap(); + assert_eq!(n, 49); + assert!(matches!( + Info::decode(&big[..45]), + Err(InfoParseError::TooShort), + )); +} + +// ── Info::decode tolerates trailing bytes (info.rs:150) ─────────── +// +// Per spec §16.4, minor-version extensions may append fields. The +// decoder's final length guard is `buf.len() < expected_total`; a +// mutant to `>` trips when buf.len() exceeds expected_total, which +// breaks forward-compatibility. + +#[test] +fn info_decode_tolerates_trailing_bytes() { + let i = info_sample(); + let mut buf = [0u8; 64]; + let n = i.encode(&mut buf).unwrap(); + assert_eq!(n, 45); + // Simulate 10 bytes of a future-minor-version extension. + for b in &mut buf[45..55] { + *b = 0xAA; + } + let decoded = Info::decode(&buf[..55]).unwrap(); + assert_eq!(decoded, i); } diff --git a/tests/proptest.proptest-regressions b/tests/proptest.proptest-regressions new file mode 100644 index 0000000..04a3d02 --- /dev/null +++ b/tests/proptest.proptest-regressions @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 64af53fe0215619bcede1dcaa56542db91b76a0e54503b6972ff4e3c6cd08e2f # shrinks to cfg = FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] } +cc a06f54304af03f33b704275b09b6ff07210078a04104dc2eac284b8d7cecbab9 # shrinks to m = FskGfsk(FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] }), tag = 1 +cc bbb3809fa365465d27529942bd6d43b83af837bae0aa93b568818a65a3382bf1 # shrinks to cmd = SetConfig(FskGfsk(FskConfig { freq_hz: 0, bitrate_bps: 0, freq_dev_hz: 0, rx_bw: 0, preamble_len: 0, sync_word_len: 0, sync_word: [0, 0, 0, 0, 0, 0, 0, 1] })), tag = 1 diff --git a/tests/proptest.rs b/tests/proptest.rs index bb915d0..9c90e15 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -1,696 +1,419 @@ -//! Property tests for the wire protocol and COBS framing. +//! Property tests against the DongLoRa Protocol v2 wire codec. //! -//! These cover three flavours of invariant: -//! -//! 1. **Roundtrips** — serialize then parse equals identity on well-formed -//! values. -//! 2. **No-panic** — parsers never panic on arbitrary byte input. This is -//! the cheap version of the fuzz suite and runs on every `cargo test`. -//! 3. **Bounded capacity** — oversized inputs are discarded, never allocate, -//! never overflow the `heapless::Vec` backing store. +//! - **Roundtrip** — encoding then decoding yields the original value, +//! for frames, commands, LoRa/FSK/LR-FHSS/FLRC params, and `Info`. +//! - **No-panic** — `FrameDecoder::feed`, `Command::parse`, and the +//! various payload decoders never panic on arbitrary byte input. +//! - **Bounded capacity** — encoders refuse oversized inputs with a +//! typed error; no hidden allocation. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#![allow(clippy::unwrap_used, clippy::panic)] -use donglora_protocol::framing::{CobsDecoder, MAX_FRAME, cobs_encode_response}; use donglora_protocol::{ - Bandwidth, Command, ErrorCode, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE, RadioConfig, - Response, TX_POWER_MAX, + Command, DeviceMessage, ErrorCode, FlrcBitrate, FlrcBt, FlrcCodingRate, FlrcConfig, + FlrcPreambleLen, FrameDecoder, FrameResult, FskConfig, Info, LoRaBandwidth, LoRaCodingRate, + LoRaConfig, LoRaHeaderMode, LrFhssBandwidth, LrFhssCodingRate, LrFhssConfig, LrFhssGrid, + MAX_MCU_UID_LEN, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_SYNC_WORD_LEN, + MAX_WIRE_FRAME, Modulation, RadioChipId, RxOrigin, RxPayload, TxDonePayload, TxFlags, TxResult, + commands, encode_frame, events, }; use heapless::Vec as HVec; use proptest::prelude::*; // ── Strategies ────────────────────────────────────────────────────── -fn arb_bandwidth() -> impl Strategy { - prop_oneof![ - Just(Bandwidth::Khz7), - Just(Bandwidth::Khz10), - Just(Bandwidth::Khz15), - Just(Bandwidth::Khz20), - Just(Bandwidth::Khz31), - Just(Bandwidth::Khz41), - Just(Bandwidth::Khz62), - Just(Bandwidth::Khz125), - Just(Bandwidth::Khz250), - Just(Bandwidth::Khz500), - ] -} - -fn arb_radio_config() -> impl Strategy { +fn arb_lora() -> impl Strategy { ( - any::(), - arb_bandwidth(), - any::(), - any::(), + 150_000_000u32..=960_000_000u32, + 5u8..=12u8, + 0u8..=13u8, + 0u8..=3u8, + 0u16..=1024u16, any::(), - any::(), - any::(), - any::(), + -20i8..=22i8, + 0u8..=1u8, + any::(), + any::(), ) .prop_map( - |(freq_hz, bw, sf, cr, sync_word, tx_power_dbm, preamble_len, cad)| RadioConfig { + |( freq_hz, - bw, sf, - cr, + bw_raw, + cr_raw, + preamble_len, sync_word, tx_power_dbm, - preamble_len, - cad, + header_raw, + payload_crc, + iq_invert, + )| { + LoRaConfig { + freq_hz, + sf, + bw: LoRaBandwidth::from_u8(bw_raw).unwrap(), + cr: LoRaCodingRate::from_u8(cr_raw).unwrap(), + preamble_len, + sync_word, + tx_power_dbm, + header_mode: LoRaHeaderMode::from_u8(header_raw).unwrap(), + payload_crc, + iq_invert, + } }, ) } -/// Payload bytes, bounded by MAX_PAYLOAD so the heapless Vec never overflows. -fn arb_payload() -> impl Strategy> { - prop::collection::vec(any::(), 0..=MAX_PAYLOAD).prop_map(|v| { - let mut out = HVec::new(); - let _ = out.extend_from_slice(&v); - out +fn arb_fsk() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + any::(), + 0u8..=MAX_SYNC_WORD_LEN as u8, + prop::array::uniform8(any::()), + ) + .prop_map( + |(freq_hz, bitrate_bps, freq_dev_hz, rx_bw, preamble_len, sync_word_len, sync)| { + // Only the first `sync_word_len` bytes go on the wire; + // trailing bytes must be canonicalised to zero so that + // a wire round-trip is an identity. + let mut sync_word = [0u8; MAX_SYNC_WORD_LEN]; + sync_word[..sync_word_len as usize] + .copy_from_slice(&sync[..sync_word_len as usize]); + FskConfig { + freq_hz, + bitrate_bps, + freq_dev_hz, + rx_bw, + preamble_len, + sync_word_len, + sync_word, + } + }, + ) +} + +fn arb_lr_fhss() -> impl Strategy { + ( + any::(), + 0u8..=7u8, + 0u8..=3u8, + 0u8..=1u8, + any::(), + any::(), + ) + .prop_map( + |(freq_hz, bw, cr, grid, hopping, tx_power_dbm)| LrFhssConfig { + freq_hz, + bw: LrFhssBandwidth::from_u8(bw).unwrap(), + cr: LrFhssCodingRate::from_u8(cr).unwrap(), + grid: LrFhssGrid::from_u8(grid).unwrap(), + hopping, + tx_power_dbm, + }, + ) +} + +fn arb_flrc() -> impl Strategy { + ( + any::(), + 0u8..=7u8, + 0u8..=2u8, + 0u8..=2u8, + 0u8..=6u8, + any::(), + any::(), + ) + .prop_map( + |(freq_hz, br, cr, bt, pre, sync_word, tx_power_dbm)| FlrcConfig { + freq_hz, + bitrate: FlrcBitrate::from_u8(br).unwrap(), + cr: FlrcCodingRate::from_u8(cr).unwrap(), + bt: FlrcBt::from_u8(bt).unwrap(), + preamble_len: FlrcPreambleLen::from_u8(pre).unwrap(), + sync_word, + tx_power_dbm, + }, + ) +} + +fn arb_modulation() -> impl Strategy { + prop_oneof![ + arb_lora().prop_map(Modulation::LoRa), + arb_fsk().prop_map(Modulation::FskGfsk), + arb_lr_fhss().prop_map(Modulation::LrFhss), + arb_flrc().prop_map(Modulation::Flrc), + ] +} + +fn arb_ota_payload() -> impl Strategy> { + prop::collection::vec(any::(), 1..=MAX_OTA_PAYLOAD).prop_map(|v| { + let mut h = HVec::new(); + h.extend_from_slice(&v).unwrap(); + h }) } -fn arb_error_code() -> impl Strategy { - prop_oneof![ - Just(ErrorCode::InvalidConfig), - Just(ErrorCode::RadioBusy), - Just(ErrorCode::TxTimeout), - Just(ErrorCode::NotConfigured), - Just(ErrorCode::NoDisplay), - ] -} - -fn arb_response() -> impl Strategy { - prop_oneof![ - Just(Response::Pong), - arb_radio_config().prop_map(Response::Config), - (any::(), any::(), arb_payload()) - .prop_map(|(rssi, snr, payload)| Response::RxPacket { rssi, snr, payload }), - Just(Response::TxDone), - Just(Response::Ok), - arb_error_code().prop_map(Response::Error), - any::<[u8; 6]>().prop_map(Response::MacAddress), - ] -} - -/// Test-only Command encoder. The firmware only ever decodes commands, so -/// there is no `Command::write_to` in the library itself — we roll the wire -/// layout here to exercise the roundtrip. -fn encode_command(cmd: &Command, out: &mut [u8]) -> usize { - match cmd { - Command::Ping => { - out[0] = 0; - 1 - } - Command::GetConfig => { - out[0] = 1; - 1 - } - Command::SetConfig(cfg) => { - out[0] = 2; - cfg.write_to(&mut out[1..]); - 1 + RADIO_CONFIG_SIZE - } - Command::StartRx => { - out[0] = 3; - 1 - } - Command::StopRx => { - out[0] = 4; - 1 - } - Command::Transmit { config, payload } => { - out[0] = 5; - let mut pos = 1; - if let Some(cfg) = config { - out[pos] = 1; - pos += 1; - cfg.write_to(&mut out[pos..]); - pos += RADIO_CONFIG_SIZE; - } else { - out[pos] = 0; - pos += 1; - } - out[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes()); - pos += 2; - out[pos..pos + payload.len()].copy_from_slice(payload); - pos + payload.len() - } - Command::DisplayOn => { - out[0] = 6; - 1 - } - Command::DisplayOff => { - out[0] = 7; - 1 - } - Command::GetMac => { - out[0] = 8; - 1 - } - } -} - fn arb_command() -> impl Strategy { prop_oneof![ Just(Command::Ping), - Just(Command::GetConfig), - arb_radio_config().prop_map(Command::SetConfig), - Just(Command::StartRx), - Just(Command::StopRx), - (prop::option::of(arb_radio_config()), arb_payload()) - .prop_map(|(config, payload)| Command::Transmit { config, payload }), - Just(Command::DisplayOn), - Just(Command::DisplayOff), - Just(Command::GetMac), + Just(Command::GetInfo), + Just(Command::RxStart), + Just(Command::RxStop), + arb_modulation().prop_map(Command::SetConfig), + (any::(), arb_ota_payload()).prop_map(|(skip_cad, data)| Command::Tx { + flags: TxFlags { skip_cad }, + data, + }), ] } -/// Decode a raw (non-COBS) Response buffer back into a `Response`. The -/// library is intentionally asymmetric — firmware encodes, host decodes — -/// so this lives in the test crate only. -fn decode_response(buf: &[u8]) -> Option { - let tag = *buf.first()?; - let rest = &buf[1..]; - match tag { - 0 => Some(Response::Pong), - 1 => Some(Response::Config(RadioConfig::from_bytes(rest)?)), - 2 => { - if rest.len() < 6 { - return None; - } - let rssi = i16::from_le_bytes([rest[0], rest[1]]); - let snr = i16::from_le_bytes([rest[2], rest[3]]); - let len = u16::from_le_bytes([rest[4], rest[5]]) as usize; - if rest.len() < 6 + len { - return None; - } - let mut payload = HVec::new(); - let _ = payload.extend_from_slice(&rest[6..6 + len]); - Some(Response::RxPacket { rssi, snr, payload }) - } - 3 => Some(Response::TxDone), - 4 => Some(Response::Ok), - 5 => { - let code = *rest.first()?; - let err = match code { - 0 => ErrorCode::InvalidConfig, - 1 => ErrorCode::RadioBusy, - 2 => ErrorCode::TxTimeout, - 4 => ErrorCode::NotConfigured, - 5 => ErrorCode::NoDisplay, - _ => return None, - }; - Some(Response::Error(err)) - } - 6 => { - if rest.len() < 6 { - return None; - } - let mut mac = [0u8; 6]; - mac.copy_from_slice(&rest[..6]); - Some(Response::MacAddress(mac)) - } - _ => None, - } +fn arb_tag() -> impl Strategy { + // DongLoRa Protocol hosts must never send tag=0. The frame codec still handles it + // (tag=0 is the correct tag for async events), but for command + // round-trips we use the non-zero range hosts actually emit. + 1u16..=u16::MAX } -/// COBS-frame a raw byte slice with trailing 0x00 sentinel. -fn cobs_frame(raw: &[u8], out: &mut [u8]) -> usize { - let n = ucobs::encode(raw, out).unwrap(); - out[n] = 0x00; - n + 1 +fn arb_rx_payload() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + 0u8..=1u8, + prop::collection::vec(any::(), 0..=MAX_OTA_PAYLOAD), + ) + .prop_map( + |(rssi, snr, freq_err, ts, crc_valid, drops, origin, data)| { + let mut d = HVec::new(); + d.extend_from_slice(&data).unwrap(); + RxPayload { + rssi_tenths_dbm: rssi, + snr_tenths_db: snr, + freq_err_hz: freq_err, + timestamp_us: ts, + crc_valid, + packets_dropped: drops, + origin: RxOrigin::from_u8(origin).unwrap(), + data: d, + } + }, + ) } -// ── Properties — lightweight (100_000 cases) ──────────────────────── +fn arb_info() -> impl Strategy { + // Proptest tuples top out at 12 elements; Info needs 18 random + // inputs, so pair two sub-tuples. + let head = ( + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + any::(), + ); + let tail = ( + any::(), + any::(), + any::(), + any::(), + prop::collection::vec(any::(), 0..=MAX_MCU_UID_LEN), + prop::collection::vec(any::(), 0..=MAX_RADIO_UID_LEN), + ); + (head, tail).prop_map(|(h, t)| { + let (pma, pmi, fa, fi, fp, chip, capb, sf, bw, mpb, rxq, txq) = h; + let (fmin, fmax, pmin, pmax, mcu_bytes, radio_bytes) = t; + let mut mcu_uid = [0u8; MAX_MCU_UID_LEN]; + mcu_uid[..mcu_bytes.len()].copy_from_slice(&mcu_bytes); + let mut radio_uid = [0u8; MAX_RADIO_UID_LEN]; + radio_uid[..radio_bytes.len()].copy_from_slice(&radio_bytes); + Info { + proto_major: pma, + proto_minor: pmi, + fw_major: fa, + fw_minor: fi, + fw_patch: fp, + radio_chip_id: chip, + capability_bitmap: capb, + supported_sf_bitmap: sf, + supported_bw_bitmap: bw, + max_payload_bytes: mpb, + rx_queue_capacity: rxq, + tx_queue_capacity: txq, + freq_min_hz: fmin, + freq_max_hz: fmax, + tx_power_min_dbm: pmin, + tx_power_max_dbm: pmax, + mcu_uid_len: mcu_bytes.len() as u8, + mcu_uid, + radio_uid_len: radio_bytes.len() as u8, + radio_uid, + } + }) +} + +// ── Properties ────────────────────────────────────────────────────── proptest! { - // Cheap properties: tiny allocations, fast inner loops. 100k cases - // keeps the whole suite under a few seconds. - #![proptest_config(ProptestConfig { - cases: 100_000, - max_shrink_iters: 10_000, - .. ProptestConfig::default() - })] - - /// RadioConfig roundtrip: write_to ∘ from_bytes is identity for any - /// value produced by the struct's public fields. #[test] - fn radio_config_roundtrip(cfg in arb_radio_config()) { - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - let n = cfg.write_to(&mut buf); - prop_assert_eq!(n, RADIO_CONFIG_SIZE); - prop_assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); - } + fn command_roundtrip(cmd in arb_command(), tag in arb_tag()) { + 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(); - /// RadioConfig::validate is deterministic: the same config against the - /// same power range produces the same verdict across N invocations. - /// (Tautological but catches any accidental interior mutability.) - #[test] - fn validate_is_deterministic( - cfg in arb_radio_config(), - power_range in (-20i8..=0i8, 10i8..=30i8), - ) { - let v1 = cfg.validate(power_range); - let v2 = cfg.validate(power_range); - prop_assert_eq!(v1, v2); - } - - /// Resolve never turns sentinels into out-of-range values: after resolve, - /// `tx_power_dbm` sits inside the power range and `preamble_len` ≥ 6. - #[test] - fn resolve_clears_sentinels( - cfg in arb_radio_config(), - power_range in (-20i8..=0i8, 10i8..=30i8), - ) { - let r = cfg.resolve(power_range); - prop_assert_ne!(r.tx_power_dbm, TX_POWER_MAX); - prop_assert_ne!(r.preamble_len, PREAMBLE_DEFAULT); - } - - /// `Command::from_bytes` never panics on arbitrary input. Proptest - /// runs this on every `cargo test`; cargo-fuzz runs the same code - /// for hours in CI. - #[test] - fn command_from_bytes_no_panic(data in prop::collection::vec(any::(), 0..1024)) { - let _ = Command::from_bytes(&data); - } - - /// RadioConfig::from_bytes never panics on arbitrary input. - #[test] - fn radio_config_from_bytes_no_panic(data in prop::collection::vec(any::(), 0..64)) { - let _ = RadioConfig::from_bytes(&data); - } - - /// Bandwidth bijection: every accepted on-wire value round-trips. - /// Complements the enum-exhaustive vector test by sweeping the full - /// u8 space and confirming accept/reject follow the spec. - #[test] - fn bandwidth_u8_is_bijection(v in any::()) { - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - RadioConfig { - freq_hz: 915_000_000, - bw: Bandwidth::Khz125, - sf: 7, - cr: 5, - sync_word: 0, - tx_power_dbm: 0, - preamble_len: 16, - cad: 0, - } - .write_to(&mut buf); - buf[4] = v; - - let parsed = RadioConfig::from_bytes(&buf); - if v <= 9 { - let decoded = parsed.unwrap(); - let mut back = [0u8; RADIO_CONFIG_SIZE]; - decoded.write_to(&mut back); - prop_assert_eq!(back[4], v); - } else { - prop_assert!(parsed.is_none()); - } - } - - /// write_to touches exactly RADIO_CONFIG_SIZE bytes — no surrounding - /// memory is clobbered. - #[test] - fn radio_config_write_to_stays_in_bounds(cfg in arb_radio_config()) { - let mut buf = [0xAAu8; RADIO_CONFIG_SIZE + 16]; - let n = cfg.write_to(&mut buf[..RADIO_CONFIG_SIZE]); - prop_assert_eq!(n, RADIO_CONFIG_SIZE); - for (i, &b) in buf[RADIO_CONFIG_SIZE..].iter().enumerate() { - prop_assert_eq!(b, 0xAA, "byte at RADIO_CONFIG_SIZE+{} was clobbered", i); - } - } - - /// After `resolve(pr)` clears both sentinels, `validate(pr)` accepts - /// the remaining fields iff they are independently in range. - #[test] - fn resolve_then_validate_consistent( - cfg in arb_radio_config(), - power_range in (-20i8..=0i8, 10i8..=30i8), - ) { - let r = cfg.resolve(power_range); - if r.tx_power_dbm < power_range.0 || r.tx_power_dbm > power_range.1 { - prop_assert!(r.validate(power_range).is_err()); - } - if r.preamble_len < 6 { - prop_assert!(r.validate(power_range).is_err()); - } - } -} - -// ── Properties — heavyweight (10_000 cases) ──────────────────────── - -proptest! { - // Properties that touch MAX_FRAME-sized buffers or multi-frame - // streams; ~10k cases keeps total runtime reasonable. - #![proptest_config(ProptestConfig { - cases: 10_000, - max_shrink_iters: 10_000, - .. ProptestConfig::default() - })] - - /// Bounded-capacity invariant: a `Transmit` command parsed from bytes - /// never yields a payload longer than MAX_PAYLOAD. (The parser truncates - /// silently via `extend_from_slice`; this check guards that assumption.) - #[test] - fn transmit_payload_bounded( - has_config in any::(), - cfg in arb_radio_config(), - len_field in 0u16..=2000, // deliberately oversized - data in prop::collection::vec(any::(), 0..2000), - ) { - let mut buf = [0u8; 2100]; - buf[0] = 5; // Transmit tag - let mut pos = 1; - if has_config { - buf[pos] = 1; - pos += 1; - cfg.write_to(&mut buf[pos..]); - pos += RADIO_CONFIG_SIZE; - } else { - buf[pos] = 0; - pos += 1; - } - buf[pos..pos + 2].copy_from_slice(&len_field.to_le_bytes()); - pos += 2; - let copy_len = data.len().min(buf.len() - pos); - buf[pos..pos + copy_len].copy_from_slice(&data[..copy_len]); - let total = pos + copy_len; - - if let Some(Command::Transmit { payload, .. }) = Command::from_bytes(&buf[..total]) { - prop_assert!(payload.len() <= MAX_PAYLOAD); - } - } - - /// Command roundtrip via the test-only encoder. Catches any parser/encoder - /// drift if either side is edited without the other. - #[test] - fn command_roundtrip(cmd in arb_command()) { - let mut buf = [0u8; 1024]; - let n = encode_command(&cmd, &mut buf); - let parsed = Command::from_bytes(&buf[..n]).unwrap(); + let mut decoder = FrameDecoder::new(); + let mut got: Option<(u8, u16, HVec)> = None; + decoder.feed(&wire[..n], |res| match res { + FrameResult::Ok { type_id, tag, payload } => { + let mut p = HVec::new(); + p.extend_from_slice(payload).unwrap(); + got = Some((type_id, tag, p)); + } + FrameResult::Err(e) => panic!("decode error: {:?}", e), + }); + let (type_id, decoded_tag, payload) = got.unwrap(); + prop_assert_eq!(type_id, cmd.type_id()); + prop_assert_eq!(decoded_tag, tag); + let parsed = Command::parse(type_id, &payload).unwrap(); prop_assert_eq!(parsed, cmd); } - /// COBS reversibility: encode-then-decode a response yields the same - /// bytes that `write_to` produced. #[test] - fn cobs_response_roundtrip(resp in arb_response()) { - let mut write = [0u8; MAX_FRAME]; - let mut encode = [0u8; MAX_FRAME]; + fn lora_config_roundtrip(cfg in arb_lora()) { + let mut buf = [0u8; 16]; + let n = cfg.encode(&mut buf).unwrap(); + let decoded = LoRaConfig::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, cfg); + } - // Expected raw bytes before COBS. - let raw_len = { - let mut raw = [0u8; MAX_FRAME]; - let n = resp.clone().write_to(&mut raw); - let raw_vec: HVec = HVec::from_slice(&raw[..n]).unwrap(); - raw_vec + #[test] + fn fsk_config_roundtrip(cfg in arb_fsk()) { + let mut buf = [0u8; 32]; + let n = cfg.encode(&mut buf).unwrap(); + let decoded = FskConfig::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, cfg); + } + + #[test] + fn lr_fhss_roundtrip(cfg in arb_lr_fhss()) { + let mut buf = [0u8; 16]; + let n = cfg.encode(&mut buf).unwrap(); + let decoded = LrFhssConfig::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, cfg); + } + + #[test] + fn flrc_roundtrip(cfg in arb_flrc()) { + let mut buf = [0u8; 16]; + let n = cfg.encode(&mut buf).unwrap(); + let decoded = FlrcConfig::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, cfg); + } + + #[test] + fn rx_payload_roundtrip(rx in arb_rx_payload()) { + let mut buf = [0u8; MAX_PAYLOAD_FIELD]; + let n = rx.encode(&mut buf).unwrap(); + let decoded = RxPayload::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, rx); + } + + #[test] + fn tx_done_roundtrip(result_raw in 0u8..=2u8, airtime in any::()) { + let td = TxDonePayload { + result: TxResult::from_u8(result_raw).unwrap(), + airtime_us: airtime, }; - - let framed = cobs_encode_response(resp, &mut write, &mut encode) - .expect("non-overflowing response should encode"); - - // Last byte is the 0x00 sentinel; the preceding bytes are the COBS frame. - prop_assert_eq!(*framed.last().unwrap(), 0x00); - let mut decoded = [0u8; MAX_FRAME]; - let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap(); - prop_assert_eq!(&decoded[..n], raw_len.as_slice()); + let mut buf = [0u8; 8]; + let n = td.encode(&mut buf).unwrap(); + prop_assert_eq!(TxDonePayload::decode(&buf[..n]).unwrap(), td); } - /// CobsDecoder never panics and never yields a command longer than the - /// bounded payload for arbitrary byte streams delivered in arbitrary - /// chunk boundaries. #[test] - fn cobs_decoder_no_panic( - stream in prop::collection::vec(any::(), 0..4096), - chunk_splits in prop::collection::vec(1usize..=128, 0..32), + fn err_roundtrip(code_raw in any::()) { + let code = ErrorCode::from_u16(code_raw); + let mut buf = [0u8; 2]; + let n = events::encode_err_payload(code, &mut buf).unwrap(); + prop_assert_eq!(n, 2); + let decoded = events::decode_err_payload(&buf).unwrap(); + prop_assert_eq!(decoded.as_u16(), code_raw); + } + + #[test] + fn info_roundtrip(info in arb_info()) { + let mut buf = [0u8; 128]; + let n = info.encode(&mut buf).unwrap(); + let decoded = Info::decode(&buf[..n]).unwrap(); + prop_assert_eq!(decoded, info); + } + + #[test] + fn decoder_never_panics_on_arbitrary_bytes(bytes in prop::collection::vec(any::(), 0..1024)) { + let mut decoder = FrameDecoder::new(); + decoder.feed(&bytes, |_res| {}); + } + + #[test] + fn command_parse_never_panics(type_id in any::(), payload in prop::collection::vec(any::(), 0..320)) { + let _ = Command::parse(type_id, &payload); + } + + #[test] + fn device_message_parse_never_panics( + type_id in any::(), + payload in prop::collection::vec(any::(), 0..320), + cmd_type in prop::option::of(any::()), ) { - let mut decoder = CobsDecoder::new(); - let mut cursor = 0; - for &split in &chunk_splits { - if cursor >= stream.len() { - break; + let _ = DeviceMessage::parse(type_id, &payload, cmd_type); + } + + #[test] + fn tag_zero_still_encodes(payload in prop::collection::vec(any::(), 0..=MAX_PAYLOAD_FIELD)) { + // The crate itself doesn't reject tag=0 — tag=0 is the correct + // tag for async events. Host-level code enforces the "commands + // must not use tag=0" rule separately. + let mut wire = [0u8; MAX_WIRE_FRAME]; + let _ = encode_frame(0xC0, 0, &payload, &mut wire); + } + + #[test] + fn random_modulation_via_set_config_roundtrip(m in arb_modulation(), tag in arb_tag()) { + let cmd = Command::SetConfig(m); + 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 decoder = FrameDecoder::new(); + let mut got: Option<(u8, u16, HVec)> = None; + decoder.feed(&wire[..n], |res| { + if let FrameResult::Ok { type_id, tag, payload } = res { + let mut p = HVec::new(); + p.extend_from_slice(payload).unwrap(); + got = Some((type_id, tag, p)); } - let end = (cursor + split).min(stream.len()); - decoder.feed(&stream[cursor..end], |cmd| { - if let Command::Transmit { payload, .. } = cmd { - assert!(payload.len() <= MAX_PAYLOAD); - } - }); - cursor = end; - } - // Deliver any remainder as one chunk. - if cursor < stream.len() { - decoder.feed(&stream[cursor..], |_| {}); - } - } - - /// COBS round-trip through the streaming decoder: encoding a real - /// command, appending the 0x00 sentinel, feeding the result to - /// `CobsDecoder::feed` yields exactly that command. - #[test] - fn cobs_decoder_roundtrip(cmd in arb_command()) { - let mut raw = [0u8; 1024]; - let n = encode_command(&cmd, &mut raw); - - let mut framed = [0u8; 1100]; - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - framed[enc_n] = 0x00; - - let mut decoder = CobsDecoder::new(); - let mut got: Option = None; - decoder.feed(&framed[..enc_n + 1], |c| got = Some(c)); - prop_assert_eq!(got, Some(cmd)); - } - - /// Multi-frame streaming: N concatenated COBS frames decode to N - /// commands in the same order. - #[test] - fn cobs_decoder_multi_frame(cmds in prop::collection::vec(arb_command(), 1..8)) { - let mut stream: HVec = HVec::new(); - let mut raw = [0u8; 1024]; - let mut framed = [0u8; 1100]; - for cmd in &cmds { - let n = encode_command(cmd, &mut raw); - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); - } - - let mut decoder = CobsDecoder::new(); - let mut got: HVec = HVec::new(); - decoder.feed(&stream, |c| { - got.push(c).unwrap(); }); - prop_assert_eq!(got.as_slice(), cmds.as_slice()); + let (type_id, decoded_tag, payload) = got.unwrap(); + prop_assert_eq!(type_id, commands::TYPE_SET_CONFIG); + prop_assert_eq!(decoded_tag, tag); + prop_assert_eq!(Command::parse(type_id, &payload).unwrap(), cmd); } - /// Chunk-boundary invariance: the same byte stream fed in arbitrary - /// chunk splits yields the same command sequence as feeding it all - /// at once. #[test] - fn cobs_decoder_chunk_invariant( - cmds in prop::collection::vec(arb_command(), 0..6), - splits in prop::collection::vec(1usize..=64, 0..64), - ) { - let mut stream: HVec = HVec::new(); - let mut raw = [0u8; 1024]; - let mut framed = [0u8; 1100]; - for cmd in &cmds { - let n = encode_command(cmd, &mut raw); - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); + fn radio_chip_id_from_u16_matches_as_u16(v in any::()) { + if let Some(id) = RadioChipId::from_u16(v) { + prop_assert_eq!(id.as_u16(), v); } - - let mut all_at_once: HVec = HVec::new(); - CobsDecoder::new().feed(&stream, |c| { - all_at_once.push(c).unwrap(); - }); - - let mut chunked: HVec = HVec::new(); - let mut decoder = CobsDecoder::new(); - let mut cursor = 0; - for &split in &splits { - if cursor >= stream.len() { - break; - } - let end = (cursor + split).min(stream.len()); - decoder.feed(&stream[cursor..end], |c| { - chunked.push(c).unwrap(); - }); - cursor = end; - } - if cursor < stream.len() { - decoder.feed(&stream[cursor..], |c| { - chunked.push(c).unwrap(); - }); - } - - prop_assert_eq!(chunked, all_at_once); - } - - /// Garbage-then-valid: a leading oversized/garbage frame is discarded - /// but does not poison the decoder; the next valid frame decodes. - #[test] - fn cobs_decoder_recovers_after_garbage( - garbage in prop::collection::vec(1u8..=255, 1..2 * MAX_FRAME), - cmd in arb_command(), - ) { - let mut stream: HVec = HVec::new(); - // Feed garbage followed by a sentinel (no valid COBS structure). - stream.extend_from_slice(&garbage).unwrap(); - stream.push(0x00).unwrap(); - - // Then a legitimate framed command. - let mut raw = [0u8; 1024]; - let n = encode_command(&cmd, &mut raw); - let mut framed = [0u8; 1100]; - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); - - let mut decoder = CobsDecoder::new(); - let mut got: HVec = HVec::new(); - decoder.feed(&stream, |c| { - got.push(c).unwrap(); - }); - // The recovered command must be last. Garbage may or may not - // parse as Some command — we only assert that the real one did. - prop_assert!(got.contains(&cmd), "valid frame after garbage must decode"); - prop_assert_eq!(got.last(), Some(&cmd)); - } - - /// Sentinel padding: arbitrary runs of 0x00 between frames are - /// treated as empty frames (no command emitted) and do not disturb - /// subsequent decoding. - #[test] - fn cobs_decoder_ignores_sentinel_runs( - leading_zeros in 0usize..32, - cmd in arb_command(), - trailing_zeros in 0usize..32, - ) { - let mut stream: HVec = HVec::new(); - for _ in 0..leading_zeros { - stream.push(0x00).unwrap(); - } - let mut raw = [0u8; 1024]; - let n = encode_command(&cmd, &mut raw); - let mut framed = [0u8; 1100]; - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); - for _ in 0..trailing_zeros { - stream.push(0x00).unwrap(); - } - - let mut decoder = CobsDecoder::new(); - let mut got: HVec = HVec::new(); - decoder.feed(&stream, |c| { - got.push(c).unwrap(); - }); - prop_assert!(got.contains(&cmd)); - } - - /// Oversized frame (more than MAX_FRAME bytes before a sentinel) is - /// discarded; a subsequent valid frame decodes. - #[test] - fn cobs_decoder_oversized_discards( - overflow_bytes in (MAX_FRAME + 1)..(3 * MAX_FRAME), - cmd in arb_command(), - ) { - let mut stream: HVec = HVec::new(); - for _ in 0..overflow_bytes { - stream.push(0x01).unwrap(); // non-zero filler - } - stream.push(0x00).unwrap(); - - let mut raw = [0u8; 1024]; - let n = encode_command(&cmd, &mut raw); - let mut framed = [0u8; 1100]; - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); - - let mut decoder = CobsDecoder::new(); - let mut got: Option = None; - decoder.feed(&stream, |c| got = Some(c)); - prop_assert_eq!(got, Some(cmd)); - } - - /// Full Response roundtrip: write_to + COBS encode + COBS decode + - /// bytes-to-Response yields the original value. - #[test] - fn response_full_roundtrip(resp in arb_response()) { - let mut write = [0u8; MAX_FRAME]; - let mut encode = [0u8; MAX_FRAME]; - let framed = cobs_encode_response(resp.clone(), &mut write, &mut encode).unwrap(); - - let mut decoded = [0u8; MAX_FRAME]; - let n = ucobs::decode(&framed[..framed.len() - 1], &mut decoded).unwrap(); - prop_assert_eq!(decode_response(&decoded[..n]), Some(resp)); - } - - /// Encoding is deterministic: the same Response yields byte-identical - /// output across repeated invocations. - #[test] - fn response_encoding_deterministic(resp in arb_response()) { - let mut a_write = [0u8; MAX_FRAME]; - let mut a_enc = [0u8; MAX_FRAME]; - let mut b_write = [0u8; MAX_FRAME]; - let mut b_enc = [0u8; MAX_FRAME]; - let a_len = cobs_encode_response(resp.clone(), &mut a_write, &mut a_enc).unwrap().len(); - let b_len = cobs_encode_response(resp, &mut b_write, &mut b_enc).unwrap().len(); - prop_assert_eq!(a_len, b_len); - prop_assert_eq!(&a_enc[..a_len], &b_enc[..b_len]); - } - - /// `cobs_encode_response` output has exactly one 0x00 — at the end. - /// The COBS layer's entire contract is that no zero appears inside. - #[test] - fn cobs_encoded_has_single_trailing_zero(resp in arb_response()) { - let mut write = [0u8; MAX_FRAME]; - let mut encode = [0u8; MAX_FRAME]; - let framed = cobs_encode_response(resp, &mut write, &mut encode).unwrap(); - let zero_count = framed.iter().filter(|&&b| b == 0).count(); - prop_assert_eq!(zero_count, 1); - prop_assert_eq!(*framed.last().unwrap(), 0x00); - } - - /// COBS-framed stream: feed-byte-by-byte is equivalent to feed-all-at-once. - #[test] - fn cobs_decoder_byte_by_byte_equivalent(cmds in prop::collection::vec(arb_command(), 0..4)) { - let mut stream: HVec = HVec::new(); - let mut raw = [0u8; 1024]; - let mut framed = [0u8; 1100]; - for cmd in &cmds { - let n = encode_command(cmd, &mut raw); - let enc_n = ucobs::encode(&raw[..n], &mut framed).unwrap(); - stream.extend_from_slice(&framed[..enc_n]).unwrap(); - stream.push(0x00).unwrap(); - } - - let mut whole: HVec = HVec::new(); - CobsDecoder::new().feed(&stream, |c| whole.push(c).unwrap()); - - let mut per_byte: HVec = HVec::new(); - let mut decoder = CobsDecoder::new(); - for &b in &stream { - decoder.feed(&[b], |c| per_byte.push(c).unwrap()); - } - prop_assert_eq!(per_byte, whole); } } - -// Keep the framer helper exercised so unused-code lints on `cobs_frame` -// stay quiet even if a future edit removes its last caller. -#[test] -fn cobs_frame_helper_smoke() { - let mut out = [0u8; 16]; - let n = cobs_frame(&[1, 2, 3], &mut out); - assert!(n >= 4); - assert_eq!(out[n - 1], 0x00); -} diff --git a/tests/vectors.rs b/tests/vectors.rs index 60acd93..cce443e 100644 --- a/tests/vectors.rs +++ b/tests/vectors.rs @@ -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 { + 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 { + 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) { + let mut decoder = FrameDecoder::new(); + let mut out: Option<(u8, u16, HVec)> = 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 = 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, + 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)); }