diff --git a/Cargo.lock b/Cargo.lock index 4098185..274b7fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,6 +1389,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" dependencies = [ + "defmt 1.0.1", "hash32", "stable_deref_trait", ] diff --git a/PROTOCOL.md b/PROTOCOL.md index 5a5f999..f22385c 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -1,291 +1,1923 @@ -# DongLoRa USB Protocol +# DongLoRa Protocol v2 Specification -DongLoRa communicates over USB CDC-ACM (virtual serial port). -No baud rate configuration needed. +**Version 1.0** -**USB identifiers:** VID `1209`, PID `5741` +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. -## Framing +--- -Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded -and terminated with a `0x00` sentinel byte: +## 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: ``` -[COBS-encoded bytes] [0x00] +┌──────┬──────┬──────────┬───────┐ +│ type │ tag │ payload │ crc16 │ +│ u8 │ u16 │ 0..P │ u16 │ +└──────┴──────┴──────────┴───────┘ ``` -COBS guarantees `0x00` never appears in the encoded data, so the -sentinel unambiguously marks frame boundaries. USB packets are -chunked at 64 bytes. +`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. -## Encoding - -All integers are **fixed-size little-endian**. No variable-length -encoding. Every field has a known size at a known offset. - -| Type | Size | Encoding | -|------|------|----------| -| `u8` | 1 | Raw byte | -| `i8` | 1 | Raw byte (two's complement) | -| `u16` | 2 | Little-endian | -| `i16` | 2 | Little-endian (two's complement) | -| `u32` | 4 | Little-endian | - -## RadioConfig (13 bytes) - -| Offset | Size | Field | Type | Valid range | -|--------|------|-------|------|-------------| -| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 | -| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) | -| 5 | 1 | `sf` | u8 | 5–12 | -| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) | -| 7 | 2 | `sync_word` | u16 LE | any | -| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max | -| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) | -| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled | - -Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum -TX power. The firmware resolves this to the actual hardware max. - -Set `preamble_len` to `0` to use the firmware default of 16 symbols. -Longer preambles improve reliability in mesh/noisy environments at -the cost of slightly longer air time. - -Set `cad` to `1` (or any non-zero value) to enable Channel Activity -Detection (listen-before-talk) before each transmission. The radio -checks for channel activity and waits for a clear channel before -transmitting. Set to `0` to disable. Default: enabled. - -## Commands (Host → Firmware) - -Each command is a tag byte followed by fixed-size fields. - -| Tag | Command | Payload | -|-----|---------|---------| -| 0 | Ping | — | -| 1 | GetConfig | — | -| 2 | SetConfig | RadioConfig (10 bytes) | -| 3 | StartRx | — | -| 4 | StopRx | — | -| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload | -| 6 | DisplayOn | — | -| 7 | DisplayOff | — | -| 8 | GetMac | — | - -## Responses (Firmware → Host) - -| Tag | Response | Payload | -|-----|----------|---------| -| 0 | Pong | — | -| 1 | Config | RadioConfig (10 bytes) | -| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload | -| 3 | TxDone | — | -| 4 | Ok | — | -| 5 | Error | code (u8) | -| 6 | MacAddress | 6 raw bytes (board's MAC/device address) | - -## Bandwidth - -| Value | Bandwidth | -|-------|-----------| -| 0 | 7.8 kHz | -| 1 | 10.4 kHz | -| 2 | 15.6 kHz | -| 3 | 20.8 kHz | -| 4 | 31.25 kHz | -| 5 | 41.7 kHz | -| 6 | 62.5 kHz | -| 7 | 125 kHz | -| 8 | 250 kHz | -| 9 | 500 kHz | - -## Error Codes - -| Value | Error | Meaning | -|-------|-------|---------| -| 0 | InvalidConfig | Radio config validation failed | -| 1 | RadioBusy | Radio busy (e.g. RX restart failed) | -| 2 | TxTimeout | Transmission timed out | -| 3 | CrcError | CRC error on received packet | -| 4 | NotConfigured | SetConfig required first | -| 5 | NoDisplay | No display attached | - -## Worked Example - -Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on: - -### 1. Build the bytes - -```python -import struct -config = struct.pack(" 32: - return INVALID # bad metadata, discard or flag - - # Step 2: check against demodulation floor - min_snr = -2.5 * (sf - 4) - margin = snr - min_snr # dB above the floor - - if margin < 0: - return UNRELIABLE # below floor, likely corrupt - if margin < 3: - return MARGINAL # near floor, may contain errors - return GOOD # comfortable margin +┌────────────────────────────────────┬──────┐ +│ COBS(type | tag | payload | crc16) │ 0x00 │ +└────────────────────────────────────┴──────┘ ``` -**What to do with each grade:** +All multi-byte integer fields are **little-endian**. -- **GOOD** — process normally. -- **MARGINAL** — process, but prefer application-layer integrity - checks (checksums, sequence numbers, known-format validation) - before acting on the payload. Log for diagnostics. -- **UNRELIABLE** — log for diagnostics but do not trust the payload - for routing decisions, command execution, or retransmission. - Applications may still display the data with a warning. -- **INVALID** — discard. The SNR metadata itself is corrupt. +### 2.2 Fields -### Application-layer defenses +**`type`** (u8): Message type identifier. See Section 4. -For safety-critical or command-and-control applications, do not rely -solely on the radio CRC. Add your own integrity checks: +**`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`. -- An application-layer CRC or hash over the payload -- Sequence numbers to detect duplicates and missing packets -- Known-format validation (magic bytes, expected lengths) -- Require acknowledgement before acting on commands +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. -### Radio config impact +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. -Wider bandwidth and lower spreading factor increase throughput but -reduce sensitivity, making low-SNR packets more common in marginal -links. If your application sees frequent MARGINAL/UNRELIABLE packets, -consider increasing SF or decreasing BW to improve link margin at the -cost of air time. +**`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. -## Host Implementation Checklist +**`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` -1. Open USB serial port (find by VID:PID `1209:5741`) -2. Implement COBS encode/decode (libraries exist for every language) -3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE -4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles) -5. Send `SetConfig`, read back `Ok` -6. Send `StartRx`, read back `Ok` -7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets) -8. To transmit: send `Transmit`, read back `TxDone` +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). -See `examples/` for working Python implementations. +### 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/src/board/traits.rs b/src/board/traits.rs index b707af2..bd3f0fc 100644 --- a/src/board/traits.rs +++ b/src/board/traits.rs @@ -47,6 +47,21 @@ pub trait LoRaBoard: Sized { /// Read the board's unique hardware address (MAC, device ID, etc.). fn mac_address() -> [u8; 6]; + /// Radio chip identifier reported in `GET_INFO`. Defaults to SX1262 + /// (`0x0002`), matching every currently-supported board. Override for + /// boards that carry a different chip (LLCC68, SX1261, LR11xx, …). + fn radio_chip_id() -> u16 { + // SX1262 — see PROTOCOL.md §8. + 0x0002 + } + + /// (freq_min_hz, freq_max_hz) — the RF front-end's effective tuning + /// window on this specific board. Reported in `GET_INFO`. Defaults + /// to the SX1262's nominal 150–960 MHz range. + fn freq_range_hz() -> (u32, u32) { + (150_000_000, 960_000_000) + } + /// Decompose initialized board into peripheral bundles for each task. fn into_parts( self, diff --git a/src/channel.rs b/src/channel.rs index a9f1e90..26662bb 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -1,54 +1,84 @@ -//! Inter-task communication channels. +//! Inter-task communication channels for DongLoRa Protocol v2. //! -//! - [`CommandChannel`]: USB → Radio (host commands) -//! - [`ResponseChannel`]: Radio → USB (firmware responses) -//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset) -//! - [`RadioEventChannel`]: Radio → Display (typed events from the radio) +//! - [`CommandChannel`]: host_task → radio_task (tag-correlated commands) +//! - [`OutboundChannel`]: radio_task → host_task (tag-correlated OK/ERR/TX_DONE +//! and tag-0 async RX/ERR events in one queue) +//! - [`DisplayCommandChannel`]: host_task → display_task (internal only — +//! `On` on connect, `Reset` on disconnect; no longer driven from the wire) +//! - [`RadioEventChannel`]: radio_task → display_task (typed mode/packet events) use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; -use crate::protocol::{Command, RadioConfig, Response}; +use crate::protocol::{Command, DeviceMessage, LoRaConfig}; -/// Display commands routed from host_task to display_task. -#[derive(Debug, Clone, Copy, defmt::Format)] -pub enum DisplayCommand { - On, - Off, - #[allow(dead_code)] // only used by USB host task, not UART +// ── Host → Radio: tag-correlated commands ─────────────────────────── + +/// An event flowing from the host task into the radio task. +/// +/// `Command` carries a non-zero correlation tag (the host framing layer +/// rejects tag=0 before it reaches here). `Reset` is a lifecycle signal +/// from the host transport layer (DTR drop, UART teardown, or spec §3.4 +/// inactivity timeout) telling the radio to park back to Unconfigured +/// and drain queues silently. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone)] +pub enum InboundEvent { + Command { tag: u16, cmd: Command }, Reset, } -/// USB-to-display command channel (depth 4: display commands are rare events). -pub type DisplayCommandChannel = Channel; +/// Host-to-radio event channel (depth 8). +pub type CommandChannel = Channel; -/// Host-to-radio command channel (depth 16: buffer bursty host command sequences). -pub type CommandChannel = Channel; +// ── Radio → Host: OK / ERR / TX_DONE / RX / async ERR ────────────── -/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes). -pub type ResponseChannel = Channel; +/// One outbound D→H frame carrying its tag. Tag-correlated messages +/// (OK, ERR in response, TX_DONE) carry the originating command's tag; +/// async events (RX, async ERR) carry `tag = 0x0000`. +#[derive(Debug, Clone)] +pub struct OutboundFrame { + pub tag: u16, + pub msg: DeviceMessage, +} -/// Radio → downstream typed events. The radio task emits one of these at -/// every mode transition and every RF packet. Mode events and data events -/// are separate: `EnteredRx` / `EnteredTx` / `Idle` describe what the radio -/// is *doing now*, while `PacketRx` / `PacketTx` describe *what just -/// happened* without implying a mode change. +/// Radio-to-host outbound channel (depth 32: enough to hold a burst of +/// RX events + queued command responses without stalling the radio ISR). +pub type OutboundChannel = Channel; + +// ── Internal: display commands (no longer wire-visible) ──────────── + +/// Internal display command routed from host_task to display_task. The +/// wire protocol no longer carries DisplayOn/Off (`PROTOCOL.md §15.2`); +/// these values drive the display statechart from local events only. +#[derive(Debug, Clone, Copy, defmt::Format)] +#[allow(dead_code)] // `Off` is reserved for future diagnostic paths. +pub enum DisplayCommand { + /// Host connected (DTR rose on USB, or UART came up). Wake display. + On, + /// Host disconnected. Park display. + Off, + /// Reset cumulative counters (e.g., on config change). + Reset, +} + +/// Host-to-display command channel. +pub type DisplayCommandChannel = Channel; + +// ── Radio → Display: typed packet/mode events ────────────────────── + +/// Radio → downstream typed events. These drive the display; they are +/// NOT the same thing as `OutboundFrame` (which goes out the host +/// transport). Mode events describe what the radio is doing now; packet +/// events describe what just happened. #[derive(Debug, Clone, defmt::Format)] pub enum RadioEvent { - /// Radio is now in RX mode. EnteredRx, - /// Radio is now in TX mode. EnteredTx, - /// Radio is now idle. Idle, - /// A packet was received with these link metrics. PacketRx { rssi: i16, snr: Option }, - /// A packet was transmitted successfully. PacketTx, - /// `SetConfig` applied a new validated config. - ConfigChanged(RadioConfig), + ConfigChanged(LoRaConfig), } -/// Radio → Display event stream (depth 32: buffer RX bursts while the -/// display drains slowly). pub type RadioEventChannel = Channel; diff --git a/src/display.rs b/src/display.rs index 7fe2e16..52d372c 100644 --- a/src/display.rs +++ b/src/display.rs @@ -38,7 +38,7 @@ use hsmc::statechart; use crate::board::{self, Board, DisplayDriver, DisplayParts, LedDriver, LoRaBoard, RgbLed}; use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioEvent, RadioEventChannel}; -use crate::protocol::RadioConfig; +use crate::protocol::LoRaConfig; use render::{BoardInfo, RSSI_HISTORY_LEN}; @@ -75,7 +75,7 @@ pub enum DisplayEvent { }, /// A packet was transmitted (data only — does not imply a mode change). PacketTx, - ConfigChanged(RadioConfig), + ConfigChanged(LoRaConfig), } impl From for DisplayEvent { @@ -109,7 +109,7 @@ struct RunState { rssi_count: usize, current_slot_rssi: i16, current_slot_tx: bool, - config: Option, + config: Option, rx_count: u32, tx_count: u32, last_rssi: Option, @@ -247,7 +247,7 @@ statechart! { // descend_defaults lands us in LearnMore with a fresh 5 s timer. on(CmdReset) => reset_run_state, turn_led_off, Splash; // Config can arrive before the dashboard is up; cache it anywhere. - on(ConfigChanged(cfg: RadioConfig)) => save_new_config; + on(ConfigChanged(cfg: LoRaConfig)) => save_new_config; state Splash { default(LearnMore); @@ -348,7 +348,7 @@ impl DisplayActions for DisplayActionContext<'_> { self.led_blip(32, 0, 0).await; } - async fn save_new_config(&mut self, cfg: RadioConfig) { + async fn save_new_config(&mut self, cfg: LoRaConfig) { self.run.config = Some(cfg); } @@ -440,7 +440,7 @@ mod render { use heapless::String; use super::Badge; - use crate::protocol::{Bandwidth, RadioConfig}; + use crate::protocol::{LoRaBandwidth, LoRaConfig}; pub const RSSI_HISTORY_LEN: usize = 128; @@ -505,7 +505,7 @@ mod render { pub fn dashboard( target: &mut impl DrawTarget, badge: Badge, - config: Option, + config: Option, rx_count: u32, tx_count: u32, last_rssi: Option, @@ -588,21 +588,33 @@ mod render { let freq_mhz = cfg.freq_hz / 1_000_000; let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; let bw_str = match cfg.bw { - Bandwidth::Khz7 => "7.8", - Bandwidth::Khz10 => "10.4", - Bandwidth::Khz15 => "15.6", - Bandwidth::Khz20 => "20.8", - Bandwidth::Khz31 => "31.2", - Bandwidth::Khz41 => "41.7", - Bandwidth::Khz62 => "62.5", - Bandwidth::Khz125 => "125", - Bandwidth::Khz250 => "250", - Bandwidth::Khz500 => "500", + LoRaBandwidth::Khz7 => "7.8", + LoRaBandwidth::Khz10 => "10.4", + LoRaBandwidth::Khz15 => "15.6", + LoRaBandwidth::Khz20 => "20.8", + LoRaBandwidth::Khz31 => "31.2", + LoRaBandwidth::Khz41 => "41.7", + LoRaBandwidth::Khz62 => "62.5", + LoRaBandwidth::Khz125 => "125", + LoRaBandwidth::Khz250 => "250", + LoRaBandwidth::Khz500 => "500", + LoRaBandwidth::Khz200 => "200", + LoRaBandwidth::Khz400 => "400", + LoRaBandwidth::Khz800 => "800", + LoRaBandwidth::Khz1600 => "1600", + }; + // Show only the CR denominator; the "4/" numerator is implied for + // every LoRa coding rate and just clutters the line. + let cr_str = match cfg.cr { + crate::protocol::LoRaCodingRate::Cr4_5 => "5", + crate::protocol::LoRaCodingRate::Cr4_6 => "6", + crate::protocol::LoRaCodingRate::Cr4_7 => "7", + crate::protocol::LoRaCodingRate::Cr4_8 => "8", }; let _ = write!( buf, "{}.{:03}/{}/{}/{}", - freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr + freq_mhz, freq_khz, bw_str, cfg.sf, cr_str ); Text::with_alignment( &buf, diff --git a/src/host/framing.rs b/src/host/framing.rs index f9a18b6..8abbb83 100644 --- a/src/host/framing.rs +++ b/src/host/framing.rs @@ -1,53 +1,121 @@ -//! Shared COBS protocol helpers for USB and UART transports. +//! Shared framing helpers for USB and UART host transports. //! -//! Both `usb_task` and `uart_task` use these helpers for frame -//! accumulation, response encoding, and command routing. +//! Ingress path: raw bytes → `FrameDecoder` → `(type_id, tag, payload)` +//! → `Command::parse` → forwarded to radio task along with the tag. On +//! parse failure we echo `ERR(ELENGTH/EPARAM/EMODULATION/EUNKNOWN_CMD)` +//! with the received tag. On CRC/COBS failure we emit async +//! `ERR(EFRAME)` with tag `0x0000` (`PROTOCOL.md §2.5`). //! -//! The actual codec lives in `donglora-protocol` so host crates can -//! reuse it; this module re-exports the codec and owns the -//! firmware-only [`route_command`] (which touches embassy channels). +//! Egress path: `OutboundFrame` (tag + DeviceMessage) → `encode_frame` +//! → bytes ready for transport-level write. -pub use donglora_protocol::framing::{cobs_encode_response, CobsDecoder, MAX_FRAME}; +use defmt::warn; -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; -use crate::protocol::{Command, ErrorCode, Response}; +use crate::channel::{CommandChannel, InboundEvent, OutboundChannel, OutboundFrame}; +use crate::protocol::{ + encode_frame, Command, CommandParseError, DeviceMessage, ErrorCode, FrameEncodeError, + MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME, +}; -/// Route a parsed command to the appropriate handler. +/// Dispatch a frame-level decode into the appropriate downstream queue. /// -/// Display and MAC commands are handled locally (response sent immediately). -/// All other commands are forwarded to the radio task (response sent later). -/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command -/// rule in PROTOCOL.md is required to keep solicited responses ordered. -pub async fn route_command( - cmd: Command, +/// - `type_id`, `tag`, and `payload` come from `FrameDecoder`. +/// - Known commands are forwarded to the radio task via `commands`. +/// - Parse failures produce a synchronous `ERR` with the echoed tag on +/// `outbound`, so the host sees a concrete answer instead of timing out. +/// - The caller is responsible for rejecting `tag == 0` before calling +/// this; the spec says hosts MUST NOT send tag 0, and we classify such +/// frames as framing errors upstream. +pub async fn dispatch_frame( + type_id: u8, + tag: u16, + payload: &[u8], commands: &CommandChannel, - responses: &ResponseChannel, - display_commands: &DisplayCommandChannel, - has_display: bool, - mac: [u8; 6], + outbound: &OutboundChannel, ) { - match cmd { - Command::DisplayOn => { - if has_display { - display_commands.send(DisplayCommand::On).await; - responses.send(Response::Ok).await; - } else { - responses.send(Response::Error(ErrorCode::NoDisplay)).await; + if tag == 0 { + // Hosts MUST NOT use tag 0 on outbound commands. Treat as + // framing error; do not route to radio. (Spec §2.2) + emit_async_err(outbound, ErrorCode::EFrame).await; + return; + } + match Command::parse(type_id, payload) { + Ok(cmd) => { + let envelope = InboundEvent::Command { tag, cmd }; + if commands.try_send(envelope).is_err() { + emit_sync_err(outbound, tag, ErrorCode::EBusy).await; } } - Command::DisplayOff => { - if has_display { - display_commands.send(DisplayCommand::Off).await; - responses.send(Response::Ok).await; - } else { - responses.send(Response::Error(ErrorCode::NoDisplay)).await; - } - } - Command::GetMac => { - responses.send(Response::MacAddress(mac)).await; - } - other => { - commands.send(other).await; + Err(e) => { + let code = parse_err_to_wire_code(e); + emit_sync_err(outbound, tag, code).await; + } + } +} + +/// Emit an async framing error (tag = 0x0000) per spec §2.5. +pub async fn emit_async_err(outbound: &OutboundChannel, code: ErrorCode) { + let frame = OutboundFrame { + tag: 0, + msg: DeviceMessage::Err(code), + }; + if outbound.try_send(frame).is_err() { + warn!("async ERR dropped: outbound queue full"); + } +} + +/// Emit a synchronous error response echoing the originating tag. +async fn emit_sync_err(outbound: &OutboundChannel, tag: u16, code: ErrorCode) { + let frame = OutboundFrame { + tag, + msg: DeviceMessage::Err(code), + }; + if outbound.try_send(frame).is_err() { + warn!("sync ERR dropped: outbound queue full"); + } +} + +fn parse_err_to_wire_code(e: CommandParseError) -> ErrorCode { + match e { + CommandParseError::UnknownType => ErrorCode::EUnknownCmd, + CommandParseError::WrongLength => ErrorCode::ELength, + CommandParseError::InvalidField => ErrorCode::EParam, + CommandParseError::ReservedBitSet => ErrorCode::EParam, + CommandParseError::UnknownModulation => ErrorCode::EModulation, + } +} + +/// Encode an outbound frame into a single wire-ready byte buffer. +/// +/// Returns the number of bytes written to `out`, or `None` on buffer +/// overflow / encode failure (caller may log and drop). +pub fn encode_outbound(frame: &OutboundFrame, out: &mut [u8; MAX_WIRE_FRAME]) -> Option { + let mut payload_buf = [0u8; MAX_PAYLOAD_FIELD]; + let payload_len = match frame.msg.encode_payload(&mut payload_buf) { + Ok(n) => n, + Err(e) => { + warn!("encode_payload failed: {:?}", defmt::Debug2Format(&e)); + return None; + } + }; + match encode_frame( + frame.msg.type_id(), + frame.tag, + &payload_buf[..payload_len], + out.as_mut_slice(), + ) { + Ok(n) => Some(n), + Err(FrameEncodeError::BufferTooSmall) => { + warn!("encode_frame: output buffer too small"); + None + } + Err(FrameEncodeError::PayloadTooLarge) => { + warn!("encode_frame: payload exceeds MAX_PAYLOAD_FIELD"); + None + } + Err(FrameEncodeError::CobsEncode) => { + warn!("encode_frame: COBS encode refused"); + None } } } diff --git a/src/host/uart.rs b/src/host/uart.rs index 6e100e3..361a4bd 100644 --- a/src/host/uart.rs +++ b/src/host/uart.rs @@ -1,85 +1,141 @@ -//! UART host task: COBS-framed fixed-size LE command/response protocol. +//! UART host task for DongLoRa Protocol v2. //! -//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3). +//! UART has no DTR line, so we assume a host is always present and +//! rely entirely on the spec §3.4 inactivity watchdog (1 s of silence +//! → Disconnected → drop radio to Unconfigured). use defmt::warn; use embassy_executor::task; +use embassy_time::{Duration, Instant}; use embedded_io_async::Write; -use super::framing::{self, CobsDecoder, MAX_FRAME}; -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; +use super::framing::{dispatch_frame, emit_async_err, encode_outbound}; +use crate::channel::{ + CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel, +}; +use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_PAYLOAD_FIELD, MAX_WIRE_FRAME}; + +const READ_BUF: usize = 64; +const INACTIVITY_MS: u64 = 1000; #[task] pub async fn host_task( parts: crate::board::UartParts, commands: &'static CommandChannel, - responses: &'static ResponseChannel, + outbound: &'static OutboundChannel, display_commands: &'static DisplayCommandChannel, has_display: bool, - mac: [u8; 6], + _mac: [u8; 6], ) { let (mut rx, mut tx) = parts.driver.split(); - // UART has no DTR — assume a host is always present. + // UART has no DTR — wake display immediately and assume a host. if has_display { display_commands.send(DisplayCommand::On).await; } - let mut read_buf = [0u8; 64]; - let mut write_buf = [0u8; MAX_FRAME]; - let mut cobs_encode_buf = [0u8; MAX_FRAME]; - let mut decoder = CobsDecoder::new(); + let mut read_buf = [0u8; READ_BUF]; + let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME]; + let mut decoder = FrameDecoder::new(); + let mut last_frame_at = Instant::now(); + let mut connected = true; loop { - use embassy_futures::select::{select, Either}; + use embassy_futures::select::{select3, Either3}; - match select( + match select3( embedded_io_async::Read::read(&mut rx, &mut read_buf), - responses.receive(), + outbound.receive(), + embassy_time::Timer::after(Duration::from_millis(100)), ) .await { - Either::First(result) => { - let n = match result { - Ok(0) => continue, - Ok(n) => n, - Err(_) => { - decoder.reset(); - embassy_time::Timer::after_millis(100).await; - continue; + Either3::First(result) => match result { + Ok(0) => continue, + Ok(n) => { + last_frame_at = Instant::now(); + if !connected { + if has_display { + let _ = display_commands.try_send(DisplayCommand::On); + } + connected = true; + } + dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await; + } + Err(_) => { + decoder.reset(); + embassy_time::Timer::after_millis(100).await; + } + }, + Either3::Second(frame) => { + if let Some(n) = encode_outbound(&frame, &mut write_buf) { + if tx.write_all(&write_buf[..n]).await.is_err() { + warn!("UART write failed, frame dropped"); } - }; - - let mut cmds = heapless::Vec::<_, 4>::new(); - decoder.feed(&read_buf[..n], |cmd| { - let _ = cmds.push(cmd); - }); - for cmd in cmds { - framing::route_command( - cmd, - commands, - responses, - display_commands, - has_display, - mac, - ) - .await; } } - Either::Second(response) => { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(13); - if let Some(frame) = - framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf) - { - if tx.write_all(frame).await.is_err() { - warn!("UART write failed, response dropped"); - } else { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(14); - } - } + Either3::Third(()) => {} + } + + // Inactivity watchdog. + if connected && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) { + warn!("UART host inactivity timeout fired"); + let _ = commands.try_send(InboundEvent::Reset); + if has_display { + let _ = display_commands.try_send(DisplayCommand::Reset); + } + connected = false; + } + } +} + +async fn dispatch_bytes( + decoder: &mut FrameDecoder, + bytes: &[u8], + commands: &'static CommandChannel, + outbound: &'static OutboundChannel, +) { + let mut work: heapless::Vec = heapless::Vec::new(); + decoder.feed(bytes, |res| match res { + FrameResult::Ok { + type_id, + tag, + payload, + } => { + let mut p: heapless::Vec = heapless::Vec::new(); + let _ = p.extend_from_slice(payload); + let _ = work.push(FrameWork::Ok { + type_id, + tag, + payload: p, + }); + } + FrameResult::Err(_) => { + let _ = work.push(FrameWork::Frame); + } + }); + for w in work { + match w { + FrameWork::Ok { + type_id, + tag, + payload, + } => { + dispatch_frame(type_id, tag, &payload, commands, outbound).await; + } + FrameWork::Frame => { + emit_async_err(outbound, ErrorCode::EFrame).await; } } } } + +#[allow(clippy::large_enum_variant)] +enum FrameWork { + Ok { + type_id: u8, + tag: u16, + payload: heapless::Vec, + }, + Frame, +} diff --git a/src/host/usb.rs b/src/host/usb.rs index 70df836..bdff9ce 100644 --- a/src/host/usb.rs +++ b/src/host/usb.rs @@ -1,54 +1,53 @@ -//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol. +//! USB CDC-ACM host task for DongLoRa Protocol v2. //! //! ```text //! Host -//! ├── Disconnected (default) no DTR -//! └── Connected host attached -//! entry: wake_display -//! exit: stop_radio, reset_display +//! ├── Disconnected (default) no DTR, or inactivity timer fired +//! └── Connected host attached, liveness OK +//! entry: wake_display, start_liveness_timer +//! exit: reset_radio, reset_display, stop_liveness_timer //! ``` //! -//! The statechart tracks exactly one thing: DTR. Entry/exit actions -//! replace the old `was_connected` edge-detector and emit the display -//! wake / radio-stop / display-reset side-effects that used to be open -//! coded around the `select3` loop. +//! The statechart tracks one state bit: "is a host present?". DTR drives +//! connect edges; the 1-second inactivity watchdog (spec §3.4) drives +//! the disconnect edge when DTR is held high but no bytes arrive. //! -//! The data plane (COBS decoding, command routing, response encoding) -//! stays imperative in `protocol_loop` for the same reason the radio's -//! `rx_once` stayed out of its chart: `read_packet` blocks and can't -//! live inside a statechart action. Host-contract lifecycle (one -//! outstanding command, unsolicited `RxPacket`, synchronous local -//! responses) is deliberately *not* modeled — any finer chart here -//! would desynchronise from the concurrent `select3` reality and lie -//! about what the firmware is actually doing. +//! The data plane (frame decode, command dispatch, frame encode) stays +//! imperative in `protocol_loop` for the same reason the radio's +//! `rx_once` stayed out of its chart: `read_packet` blocks. use defmt::warn; use embassy_executor::task; use embassy_futures::join::join; +use embassy_time::{Duration, Instant}; use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State}; use embassy_usb::Builder; -use hsmc::{statechart, Duration}; +use hsmc::{statechart, Duration as HsmcDuration}; use static_cell::StaticCell; -use super::framing::{self, CobsDecoder, MAX_FRAME}; -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; -use crate::protocol::Command; +use super::framing::{dispatch_frame, emit_async_err, encode_outbound}; +use crate::channel::{ + CommandChannel, DisplayCommand, DisplayCommandChannel, InboundEvent, OutboundChannel, +}; +use crate::protocol::{ErrorCode, FrameDecoder, FrameResult, MAX_WIRE_FRAME}; -const MAX_PACKET: usize = 64; +const MAX_USB_PACKET: usize = 64; + +/// Spec §3.4: inactivity timer period. The device must see at least one +/// frame (good or bad) every 1000 ms or the session is considered lost. +const INACTIVITY_MS: u64 = 1000; + +/// Poll interval for the DTR edge detector / inactivity check. +const POLL_INTERVAL_MS: u64 = 100; -/// Internal events driving `Host` state transitions. #[derive(Debug, Clone)] pub enum HostInput { - /// DTR rose: host opened the serial port. DtrRaised, - /// DTR fell: host closed or disconnected. DtrDropped, + /// Host went silent for >1 s; kick the radio back to Unconfigured. + InactivityTimeout, } -/// Machine context — channel handles the actions need to broadcast -/// DTR-edge side-effects. The COBS decoder, read/write scratch, and -/// USB endpoints stay local to `protocol_loop` because their async -/// operations can't live inside statechart actions. pub struct HostContext { commands: &'static CommandChannel, display_commands: &'static DisplayCommandChannel, @@ -68,9 +67,9 @@ impl HostContext { } } - fn stop_radio_best_effort(&self) { - if self.commands.try_send(Command::StopRx).is_err() { - warn!("host DTR-drop: StopRx dropped (command channel full)"); + fn send_radio_reset(&self) { + if self.commands.try_send(InboundEvent::Reset).is_err() { + warn!("radio Reset inject: command queue full"); } } } @@ -87,8 +86,9 @@ statechart! { state Connected { entry: wake_display; - exit: stop_radio, reset_display; + exit: reset_radio, reset_display; on(DtrDropped) => Disconnected; + on(InactivityTimeout) => Disconnected; } } } @@ -98,8 +98,8 @@ impl HostActions for HostActionContext<'_> { self.wake_display_if_present().await; } - async fn stop_radio(&mut self) { - self.stop_radio_best_effort(); + async fn reset_radio(&mut self) { + self.send_radio_reset(); } async fn reset_display(&mut self) { @@ -111,7 +111,7 @@ impl HostActions for HostActionContext<'_> { pub async fn host_task( parts: crate::board::UsbParts, commands: &'static CommandChannel, - responses: &'static ResponseChannel, + outbound: &'static OutboundChannel, display_commands: &'static DisplayCommandChannel, has_display: bool, mac: [u8; 6], @@ -147,7 +147,7 @@ pub async fn host_task( let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf); - let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16); + let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_USB_PACKET as u16); let mut usb_dev = builder.build(); let (sender, receiver) = class.split(); @@ -157,10 +157,9 @@ pub async fn host_task( sender, receiver, commands, - responses, + outbound, display_commands, has_display, - mac, ), ) .await; @@ -170,10 +169,9 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( mut sender: Sender<'d, D>, mut receiver: Receiver<'d, D>, commands: &'static CommandChannel, - responses: &'static ResponseChannel, + outbound: &'static OutboundChannel, display_commands: &'static DisplayCommandChannel, has_display: bool, - mac: [u8; 6], ) { use embassy_futures::select::{select3, Either3}; @@ -182,55 +180,36 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( display_commands, has_display, }); - // Enter the default: Disconnected. - machine.step(Duration::ZERO).await; + machine.step(HsmcDuration::ZERO).await; - let mut read_buf = [0u8; MAX_PACKET]; - let mut write_buf = [0u8; MAX_FRAME]; - let mut cobs_encode_buf = [0u8; MAX_FRAME]; - let mut decoder = CobsDecoder::new(); + let mut read_buf = [0u8; MAX_USB_PACKET]; + let mut write_buf: [u8; MAX_WIRE_FRAME] = [0u8; MAX_WIRE_FRAME]; + let mut decoder = FrameDecoder::new(); + + let mut last_frame_at = Instant::now(); loop { match select3( receiver.read_packet(&mut read_buf), - responses.receive(), - embassy_time::Timer::after_millis(250), + outbound.receive(), + embassy_time::Timer::after(Duration::from_millis(POLL_INTERVAL_MS)), ) .await { Either3::First(Ok(0)) => {} Either3::First(Ok(n)) => { - let mut cmds = heapless::Vec::<_, 4>::new(); - decoder.feed(&read_buf[..n], |cmd| { - let _ = cmds.push(cmd); - }); - for cmd in cmds { - framing::route_command( - cmd, - commands, - responses, - display_commands, - has_display, - mac, - ) - .await; - } + last_frame_at = Instant::now(); + dispatch_bytes(&mut decoder, &read_buf[..n], commands, outbound).await; } Either3::First(Err(_)) => { - // Read errored — usually a USB disconnect. Drop decoder - // scratch and back off briefly so we don't spin at I/O - // rate. Do NOT `continue`: the DTR check below must - // still run so the machine's exit action fires. decoder.reset(); embassy_time::Timer::after_millis(100).await; } - Either3::Second(response) => { - if let Some(frame) = - framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf) - { - for chunk in frame.chunks(MAX_PACKET) { + Either3::Second(frame) => { + if let Some(n) = encode_outbound(&frame, &mut write_buf) { + for chunk in write_buf[..n].chunks(MAX_USB_PACKET) { if sender.write_packet(chunk).await.is_err() { - warn!("USB write failed, response dropped"); + warn!("USB write failed, frame dropped"); break; } } @@ -239,27 +218,90 @@ async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( Either3::Third(()) => {} } - // Edge-detect DTR against the machine's current state. The - // machine's entry/exit actions own the side-effects. dispatch() - // pushes the event AND drains the internal queue, so - // current_state() reflects the post-dispatch state before we - // loop back around. + // DTR edge detection — drives Connected / Disconnected transitions. let connected = receiver.dtr(); let was_connected = matches!(machine.current_state(), HostState::Connected); match (was_connected, connected) { (false, true) => { let _ = machine.dispatch(HostInput::DtrRaised).await; + last_frame_at = Instant::now(); } (true, false) => { - // Belt-and-braces: reset decoder here too. The error - // arm above typically fires on disconnect and already - // resets it, but this makes intent explicit and - // survives embassy-usb behavior changes where a - // disconnect doesn't surface as a read error. decoder.reset(); let _ = machine.dispatch(HostInput::DtrDropped).await; } _ => {} } + + // Inactivity watchdog (spec §3.4): while Connected, if no frame + // arrived in the last INACTIVITY_MS, drive the session back to + // Disconnected. + if matches!(machine.current_state(), HostState::Connected) + && last_frame_at.elapsed() >= Duration::from_millis(INACTIVITY_MS) + { + warn!("host inactivity timeout fired"); + let _ = machine.dispatch(HostInput::InactivityTimeout).await; + } } } + +/// Feed raw bytes into the frame decoder. For each complete frame: +/// - Ok → route into the command channel (or emit sync ERR on parse fail) +/// - Err(Crc | Cobs | TooShort) → emit async ERR(EFRAME) +/// +/// Borrow-scoped per the `FrameDecoder::feed` callback contract: the +/// payload slice is valid only for the duration of the callback. +async fn dispatch_bytes( + decoder: &mut FrameDecoder, + bytes: &[u8], + commands: &'static CommandChannel, + outbound: &'static OutboundChannel, +) { + // `FrameDecoder::feed` hands payloads by reference; we must synchronously + // copy any data we need before the callback returns. To avoid fighting + // lifetimes we accumulate per-frame work as an enum. + let mut work: heapless::Vec = heapless::Vec::new(); + decoder.feed(bytes, |res| match res { + FrameResult::Ok { + type_id, + tag, + payload, + } => { + let mut p: heapless::Vec = + heapless::Vec::new(); + let _ = p.extend_from_slice(payload); + let _ = work.push(FrameWork::Ok { + type_id, + tag, + payload: p, + }); + } + FrameResult::Err(_e) => { + let _ = work.push(FrameWork::Frame); + } + }); + for w in work { + match w { + FrameWork::Ok { + type_id, + tag, + payload, + } => { + dispatch_frame(type_id, tag, &payload, commands, outbound).await; + } + FrameWork::Frame => { + emit_async_err(outbound, ErrorCode::EFrame).await; + } + } + } +} + +#[allow(clippy::large_enum_variant)] +enum FrameWork { + Ok { + type_id: u8, + tag: u16, + payload: heapless::Vec, + }, + Frame, +} diff --git a/src/main.rs b/src/main.rs index 5c66341..11e5d88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,7 +39,9 @@ use embassy_executor::Spawner; #[cfg(not(test))] use crate::board::LoRaBoard; #[cfg(not(test))] -use crate::channel::{CommandChannel, DisplayCommandChannel, RadioEventChannel, ResponseChannel}; +use crate::channel::{CommandChannel, DisplayCommandChannel, OutboundChannel, RadioEventChannel}; +#[cfg(not(test))] +use crate::protocol::{cap, Info, MAX_MCU_UID_LEN, MAX_RADIO_UID_LEN}; #[cfg(not(test))] cfg_if::cfg_if! { @@ -55,11 +57,13 @@ cfg_if::cfg_if! { #[cfg(not(test))] static COMMANDS: CommandChannel = CommandChannel::new(); #[cfg(not(test))] -static RESPONSES: ResponseChannel = ResponseChannel::new(); +static OUTBOUND: OutboundChannel = OutboundChannel::new(); #[cfg(not(test))] static RADIO_EVENTS: RadioEventChannel = RadioEventChannel::new(); #[cfg(not(test))] static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new(); +#[cfg(not(test))] +static mut INFO: Option = None; #[cfg(not(test))] cfg_if::cfg_if! { @@ -81,8 +85,18 @@ async fn run(spawner: Spawner) { let board = ::init(); let parts = board.into_parts(); + // Build the GET_INFO struct once at boot. This is the authoritative + // snapshot of device capabilities reported over the wire. + let info = build_info(parts.mac); + // SAFETY: touched only here at boot, before any task is spawned. + let info_ref: &'static Info = unsafe { + INFO = Some(info); + #[allow(static_mut_refs)] + INFO.as_ref().expect("INFO just initialized") + }; + spawner.spawn( - radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &RADIO_EVENTS) + radio::radio_task(parts.radio, &COMMANDS, &OUTBOUND, &RADIO_EVENTS, info_ref) .expect("spawn radio_task"), ); @@ -90,7 +104,7 @@ async fn run(spawner: Spawner) { host::host_task( parts.host, &COMMANDS, - &RESPONSES, + &OUTBOUND, &DISPLAY_COMMANDS, parts.display.is_some(), parts.mac, @@ -98,6 +112,9 @@ async fn run(spawner: Spawner) { .expect("spawn host_task"), ); + // Suppress "assigned but never read" warning on non-USB/UART path. + let _ = info_ref; + if let Some(dp) = parts.display { #[cfg(feature = "debug-checkpoint")] { @@ -117,3 +134,63 @@ async fn run(spawner: Spawner) { } } } + +#[cfg(not(test))] +fn build_info(mac: [u8; 6]) -> Info { + let (fmin, fmax) = ::freq_range_hz(); + let chip_id = ::radio_chip_id(); + let (tx_min, tx_max) = board::Board::TX_POWER_RANGE; + + let mut mcu_uid = [0u8; MAX_MCU_UID_LEN]; + mcu_uid[..6].copy_from_slice(&mac); + + Info { + proto_major: 1, + proto_minor: 0, + fw_major: parse_u8_or(env!("CARGO_PKG_VERSION_MAJOR"), 0), + fw_minor: parse_u8_or(env!("CARGO_PKG_VERSION_MINOR"), 0), + fw_patch: parse_u8_or(env!("CARGO_PKG_VERSION_PATCH"), 0), + radio_chip_id: chip_id, + capability_bitmap: cap::LORA | cap::CAD_BEFORE_TX, + // SX126x supports SF5–SF12 (LLCC68 tops out at SF11 — that board + // can override this via a board-level trait method later). + supported_sf_bitmap: 0x1FE0, + // Sub-GHz BW enum values 0..9. SX128x would flip 10..13; add as + // boards come online. + supported_bw_bitmap: 0x03FF, + max_payload_bytes: crate::protocol::MAX_OTA_PAYLOAD as u16, + // Real channel depths from channel.rs. + rx_queue_capacity: 32, + tx_queue_capacity: 1, + freq_min_hz: fmin, + freq_max_hz: fmax, + tx_power_min_dbm: tx_min, + tx_power_max_dbm: tx_max, + mcu_uid_len: 6, + mcu_uid, + radio_uid_len: 0, + radio_uid: [0u8; MAX_RADIO_UID_LEN], + } +} + +#[cfg(not(test))] +const fn parse_u8_or(s: &str, default: u8) -> u8 { + // CARGO_PKG_VERSION_* strings are short decimal ascii. Parse them at + // compile time via a tiny constant function instead of pulling in a + // dependency. + let bytes = s.as_bytes(); + let mut i = 0; + let mut out: u32 = 0; + while i < bytes.len() { + let b = bytes[i]; + if b < b'0' || b > b'9' { + return default; + } + out = out * 10 + (b - b'0') as u32; + if out > u8::MAX as u32 { + return default; + } + i += 1; + } + out as u8 +} diff --git a/src/protocol.rs b/src/protocol.rs index 45c160f..89a3419 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,12 +1,16 @@ -//! Wire protocol types. +//! Wire protocol types for DongLoRa Protocol v2. //! -//! The definitions live in the `donglora-protocol` crate so host-side -//! Rust projects (client-rs, mux-rs, bridge) can share them with the -//! firmware without duplicating wire layouts. This module re-exports -//! them unchanged for existing `crate::protocol::*` call sites. +//! Wire definitions live in the `donglora-protocol` crate so host-side +//! Rust projects can share them with the firmware without duplicating +//! wire layouts. This module re-exports them unchanged so firmware +//! modules can `use crate::protocol::*;`. #[allow(unused_imports)] pub use donglora_protocol::{ - Bandwidth, Command, ErrorCode, RadioConfig, Response, MAX_PAYLOAD, PREAMBLE_DEFAULT, - RADIO_CONFIG_SIZE, TX_POWER_MAX, + cap, commands, encode_frame, events, Command, CommandParseError, DeviceMessage, + DeviceMessageParseError, ErrorCode, FlrcConfig, FrameDecoder, FrameEncodeError, FrameResult, + FskConfig, Info, InfoParseError, LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode, + LrFhssConfig, Modulation, ModulationId, OkPayload, Owner, RadioChipId, RxOrigin, RxPayload, + SetConfigResult, SetConfigResultCode, TxDonePayload, TxFlags, TxResult, MAX_MCU_UID_LEN, + MAX_OK_PAYLOAD, MAX_OTA_PAYLOAD, MAX_PAYLOAD_FIELD, MAX_RADIO_UID_LEN, MAX_WIRE_FRAME, }; diff --git a/src/radio.rs b/src/radio.rs index c3f1c12..fac29a3 100644 --- a/src/radio.rs +++ b/src/radio.rs @@ -1,111 +1,135 @@ -//! LoRa radio task: SX1262 statechart driven by host commands. +//! LoRa radio task: SX1262 statechart driven by DongLoRa Protocol v2 host commands. //! //! ```text //! Radio -//! ├── Unconfigured (default) no config cached → no RF ops possible +//! ├── Unconfigured (default) no config cached → RF ops rejected //! └── Configured //! ├── Idle (default) ready, waiting -//! ├── StartingRx brief: HW init in response to host -//! │ CmdStartRx; sends Ok/Error then → -//! │ Receiving (Ok) or Idle (Err) -//! ├── Receiving continuous RX -//! ├── ResumingRx brief: post-TX HW re-arm; silently -//! │ → Receiving (Ok) or Idle (Err) +//! ├── StartingRx brief: HW init in response to host +//! │ RxStart; responds Ok/Err then → +//! │ Receiving (Ok) or Idle (Err) +//! ├── Receiving continuous RX +//! ├── ResumingRx brief: post-TX HW re-arm; silent → +//! │ Receiving (Ok) or Idle (Err) //! └── Transmitting -//! ├── FromIdle (default) TxDone/TxFailed → Idle -//! └── FromRx TxDone → ResumingRx, TxFailed → Idle +//! ├── FromIdle (default) TxDone → Idle +//! └── FromRx TxDone → ResumingRx //! ``` //! -//! The task body is a single `machine.run().await`. All I/O lives inside -//! state-scoped `during:` activities: +//! The task body is a single `machine.run().await`. Host commands flow in +//! via the `CommandChannel` as `InboundCommand { tag, cmd }` envelopes; +//! outbound D→H messages flow out via `OutboundChannel` as `OutboundFrame +//! { tag, msg }` envelopes (tag `0` for async RX / async ERR, echoed tag +//! for tag-correlated OK / ERR / TX_DONE). //! -//! * Root-level `during: next_command(commands)` continuously reads the -//! host CommandChannel and converts each command into a `RadioInput` -//! event. -//! * `Receiving.during: next_packet(lora, rx_buf)` runs the SX1262 RX -//! and yields `PacketReceived` events. -//! * `TransmittingFromIdle/FromRx.during: perform_tx(lora, pending_tx)` -//! drives `do_tx` and yields `TransmitDone`/`TransmitFailed`. +//! The `RadioInput` event variants intentionally do NOT carry the +//! command tag. Tags are routing metadata — the `next_command` during +//! activity stashes `incoming_tag` into context before emitting the +//! corresponding event, and action handlers read it back from context +//! when building responses. This keeps the hsmc DSL free of +//! bindings-with-transition combos, which hsmc's macro forbids. use defmt::{error, info, warn}; use embassy_executor::task; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; -use embassy_time::Delay; +use embassy_time::{Delay, Instant}; +use heapless::Vec as HVec; use hsmc::statechart; use lora_phy::mod_params::RadioError; use lora_phy::{LoRa, RxMode}; -use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts}; -use crate::channel::{CommandChannel, RadioEvent, RadioEventChannel, ResponseChannel}; -use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response}; +use crate::board::{RadioDriver, RadioParts}; +use crate::channel::{ + CommandChannel, InboundEvent, OutboundChannel, OutboundFrame, RadioEvent, RadioEventChannel, +}; +use crate::protocol::{ + Command, DeviceMessage, ErrorCode, Info, LoRaBandwidth, LoRaCodingRate, LoRaConfig, + LoRaHeaderMode, Modulation, OkPayload, Owner, RxOrigin, RxPayload, SetConfigResult, + SetConfigResultCode, TxDonePayload, TxResult, MAX_OTA_PAYLOAD, +}; -const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD; +const CAD_MAX_RETRIES: u8 = 10; type LoRaDriver = LoRa; -// ── Fixed LoRa radio parameters ───────────────────────────────────── -const IMPLICIT_HEADER: bool = false; -const CRC_ON: bool = true; -const IQ_INVERTED: bool = false; +// ── Pending TX staging ────────────────────────────────────────────── -/// A pending TX request staged by an incoming `Transmit` command, consumed -/// by the `perform_tx` during activity when the machine enters a -/// `Transmitting.*` state. `config` is `None` when the command asked to -/// reuse the cached config; the `stage_tx` handler resolves the effective -/// config. +/// A TX request accepted by the radio task but not yet on the air. +/// Carries the tag so the eventual `TX_DONE` echoes it. #[derive(Debug, Clone)] pub struct PendingTx { - pub config: Option, - pub payload: heapless::Vec, + pub tag: u16, + pub skip_cad: bool, + pub data: HVec, + pub config: LoRaConfig, } -/// Events driving the radio statechart. Host commands arrive as `Cmd*` -/// variants via the `next_command` during; low-level I/O results arrive -/// as internal variants via the `next_packet` / `perform_tx` durings. +// ── Events driving the statechart ─────────────────────────────────── +// +// Per-event tags are NOT carried on these variants (see module doc): +// `next_command` stashes `ctx.incoming_tag` before emitting. + +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum RadioInput { - // Commands flowing in from the host via CommandChannel: CmdPing, - CmdGetConfig, - CmdSetConfig(RadioConfig), - CmdStartRx, - CmdStopRx, - CmdTransmit(PendingTx), + CmdGetInfo, + CmdSetConfig(Modulation), + /// TX request. The stashed tag + flags + data sit on the context's + /// `pending_tx` slot (filled by `next_command`); this event merely + /// wakes the chart. + CmdTx, + CmdRxStart, + CmdRxStop, - // Internal events from the RX during activity: - PacketReceived { - rssi: i16, - snr: i16, - len: u8, - data: heapless::Vec, - }, - RxFailed(&'static str), + /// Host-transport disconnect / liveness timeout: park the radio. + Reset, - // Internal events from `try_start_rx_hw` (the entry action of - // StartingRx and ResumingRx). Two distinct states consume these, - // and their differing transitions are how the chart encodes whether - // the original trigger was a host CmdStartRx (must respond) or a - // post-TX RX re-arm (must NOT respond). + /// Internal: emitted by `apply_set_config` on a successful LoRa + /// configuration apply. Drives the Unconfigured → Configured + /// transition. No-op when dispatched in Configured. + ConfigApplied, + /// Internal: emitted by `apply_set_config` when the SPI write to the + /// radio failed mid-configuration (spec §6.3). Drives Configured → + /// Unconfigured; no-op in Unconfigured. + ConfigFailedRadio, + + PacketReceived(RxPayload), + RxFailed, StartRxOk, - StartRxFailed(&'static str), - - // Internal events from the TX during activity: - TransmitDone, - TransmitFailed(&'static str), + StartRxFailed, + TransmitDone { + airtime_us: u32, + }, + ChannelBusy, + TransmitFailed, } -/// Root context. Owns the LoRa driver, the RX scratch buffer, the cached -/// configuration, the outbound event + response channels, and a staging -/// slot for pending TX requests. +// ── Context ───────────────────────────────────────────────────────── + pub struct RadioContext { pub commands: &'static CommandChannel, - pub responses: &'static ResponseChannel, + pub outbound: &'static OutboundChannel, pub events: &'static RadioEventChannel, + pub info: &'static Info, pub lora: LoRaDriver, - pub rx_buf: [u8; MAX_PAYLOAD], - pub config: Option, + pub rx_buf: [u8; MAX_OTA_PAYLOAD], + /// Active LoRa config. `None` in `Unconfigured`; `Some(cfg)` in + /// `Configured` (LoRa is the only modulation the firmware drives + /// today; others are rejected upstream with `EMODULATION`). + pub config: Option, pub pending_tx: Option, + /// Packets dropped since the previous successfully-delivered RX event. + pub packets_dropped: u16, + /// Tag of the command currently being handled. Written by + /// `next_command` just before emitting the event; read by action + /// handlers to construct tag-echoed responses. + pub incoming_tag: u16, + /// Tag of the TX currently on the wire. Captured on entry to + /// `Transmitting`; consumed by `respond_tx_*` handlers. + pub last_tx_tag: Option, + /// Tag of a pending `RX_START`, pulled by `respond_ok_start_rx` / + /// `respond_err_start_rx` once the chip reports back. + pub pending_rx_start_tag: Option, } impl RadioContext { @@ -114,95 +138,78 @@ impl RadioContext { warn!("radio event dropped: downstream queue full"); } } + + async fn send(&self, tag: u16, msg: DeviceMessage) { + self.outbound.send(OutboundFrame { tag, msg }).await; + } } +// ── Statechart ───────────────────────────────────────────────────── + statechart! { Radio { context: RadioContext; events: RadioInput; default(Unconfigured); - // Commands handled the same way in every state. The actual command - // read — `during: next_command(commands)` — is placed only on states - // where commands are safe to interleave (NOT on Transmitting.*: - // canceling perform_tx mid-flight would abort a LoRa TX). + // Globals: PING always answers; GET_INFO always answers; Reset + // forces Unconfigured. on(CmdPing) => respond_pong; - on(CmdGetConfig) => respond_get_config; + on(CmdGetInfo) => respond_info; + on(Reset) => reset_to_unconfigured, Unconfigured; state Unconfigured { - during: next_command(commands); - on(CmdSetConfig(cfg: RadioConfig)) => cache_config; - on(CmdSetConfig) => Configured; - // Pre-configuration, only Ping/GetConfig make sense. - on(CmdStartRx) => respond_not_configured; - on(CmdStopRx) => respond_not_configured; - on(CmdTransmit) => respond_not_configured; + during: next_command(commands, incoming_tag, pending_tx); + on(CmdSetConfig(m: Modulation)) => apply_set_config; + on(ConfigApplied) => Configured; + on(CmdTx) => reject_tx_not_configured; + on(CmdRxStart) => reject_not_configured; + on(CmdRxStop) => reject_not_configured; } state Configured { - on(CmdSetConfig(cfg: RadioConfig)) => cache_config; + on(CmdSetConfig(m: Modulation)) => apply_set_config; + on(ConfigFailedRadio) => Unconfigured; + // Re-configure from any substate (Receiving, Transmitting, …) + // drops back to Idle. Spec §3.5: "Any continuous RX is + // stopped. RX ring is cleared." Only successful applies + // emit ConfigApplied, so validation failures keep state. + on(ConfigApplied) => Idle; default(Idle); state Idle { entry: announce_idle; - during: next_command(commands); - on(CmdStartRx) => StartingRx; - on(CmdTransmit(p: PendingTx)) => stage_tx; - on(CmdTransmit) => TransmittingFromIdle; - on(CmdStopRx) => respond_ok; + during: next_command(commands, incoming_tag, pending_tx); + on(CmdRxStart) => stage_rx_start; + on(CmdRxStart) => StartingRx; + on(CmdTx) => TransmittingFromIdle; + on(CmdRxStop) => respond_ok; } - // Brief intermediate state: a host `StartRx` is in flight, - // hardware is being configured. `try_start_rx_hw` (entry) - // emits `StartRxOk` or `StartRxFailed`; the transitions from - // here are what tell the host whether RX actually started. - // No `during: next_command` — this state is self-driven and - // we don't want to interleave other commands with the ack. state StartingRx { entry: try_start_rx_hw; - on(StartRxOk) => respond_ok, Receiving; - on(StartRxFailed(err: &'static str)) => respond_start_rx_error; - on(StartRxFailed) => Idle; + on(StartRxOk) => respond_ok_start_rx, Receiving; + on(StartRxFailed) => respond_err_start_rx, Idle; } state Receiving { entry: announce_entered_rx; - during: next_command(commands); + during: next_command(commands, incoming_tag, pending_tx); during: next_packet(lora, rx_buf, config); - // Idempotent ack: a redundant StartRx (same client re-issuing, - // or another client's request that mux still chose to forward) - // gets `Ok` without re-touching hardware. - on(CmdStartRx) => respond_ok; - on(PacketReceived { - rssi: i16, - snr: i16, - len: u8, - data: heapless::Vec, - }) => record_packet; - on(RxFailed(err: &'static str)) => announce_rx_failed; - on(RxFailed) => Idle; - on(CmdStopRx) => stop_rx_hw, respond_ok; - on(CmdStopRx) => Idle; - on(CmdTransmit(p: PendingTx)) => stage_tx; - on(CmdTransmit) => TransmittingFromRx; + on(CmdRxStart) => respond_ok; + on(PacketReceived(rx: RxPayload)) => record_packet; + on(RxFailed) => announce_rx_failed, Idle; + on(CmdRxStop) => stop_rx_hw, respond_ok; + on(CmdRxStop) => Idle; + on(CmdTx) => TransmittingFromRx; } - // Brief intermediate state: a TX from `Receiving` just - // finished and the radio needs to be put back into RX mode. - // Same `try_start_rx_hw` entry as `StartingRx`, but the - // transitions DO NOT send a response — `respond_tx_done` was - // already sent by the TransmittingFromRx → here transition. state ResumingRx { entry: try_start_rx_hw; on(StartRxOk) => Receiving; on(StartRxFailed) => Idle; } - // Transmitting.* deliberately has no `next_command` during: the - // TX operation inside `perform_tx` cannot be safely cancelled - // by an incoming host command. Commands arriving during TX - // queue on the CommandChannel and are processed by the next - // state that reads them (Idle or Receiving). state Transmitting { entry: announce_entered_tx; exit: announce_packet_tx; @@ -210,18 +217,18 @@ statechart! { state TransmittingFromIdle { during: perform_tx(lora, pending_tx); - on(TransmitDone) => respond_tx_done; + on(TransmitDone { airtime_us: u32 }) => respond_tx_done; on(TransmitDone) => Idle; - on(TransmitFailed(err: &'static str)) => respond_tx_failed; - on(TransmitFailed) => Idle; + on(ChannelBusy) => respond_tx_channel_busy, Idle; + on(TransmitFailed) => respond_tx_failed, Idle; } state TransmittingFromRx { during: perform_tx(lora, pending_tx); - on(TransmitDone) => respond_tx_done; + on(TransmitDone { airtime_us: u32 }) => respond_tx_done; on(TransmitDone) => ResumingRx; - on(TransmitFailed(err: &'static str)) => respond_tx_failed; - on(TransmitFailed) => Idle; + on(ChannelBusy) => respond_tx_channel_busy, ResumingRx; + on(TransmitFailed) => respond_tx_failed, Idle; } } } @@ -230,206 +237,213 @@ statechart! { // ── during: activities ────────────────────────────────────────────── -/// Bridges the host's `CommandChannel` (typed `Command`) into the -/// machine's event stream. Each awaited command becomes one `RadioInput`. -async fn next_command(commands: &mut &'static CommandChannel) -> RadioInput { - let cmd = commands.receive().await; - match cmd { - Command::Ping => RadioInput::CmdPing, - Command::GetConfig => RadioInput::CmdGetConfig, - Command::SetConfig(cfg) => RadioInput::CmdSetConfig(cfg), - Command::StartRx => RadioInput::CmdStartRx, - Command::StopRx => RadioInput::CmdStopRx, - Command::Transmit { config, payload } => { - // Mark the moment we learned about the Transmit command. - // This runs before the latch engages, so it's visible as a - // distinct LED value (18) when the firmware is sitting in - // Receiving (5) waiting for something to process — if LED - // stays on 5, the command never arrived at the firmware - // (mux / UART delay); if LED flashes through 18 then deeper, - // the command arrived normally. - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(18); - // The `stage_tx` handler resolves the effective config from - // either the inline `config` or the cached one. - RadioInput::CmdTransmit(PendingTx { config, payload }) - } - Command::DisplayOn | Command::DisplayOff | Command::GetMac => { - // These are dispatched elsewhere in the host; if they land - // here it's a routing bug. Map to Ping → Pong for minimum - // harm (better: surface an error; there's no "unknown - // command" variant today). - RadioInput::CmdPing +/// Read the next host command, stash its tag into context, and (for TX) +/// stage a placeholder pending-TX struct before emitting the corresponding +/// event. The `apply_set_config` / `try_start_rx_hw` / TX-staging actions +/// patch in the real config from ctx when they run. +async fn next_command( + commands: &mut &'static CommandChannel, + incoming_tag: &mut u16, + pending_tx: &mut Option, +) -> RadioInput { + match commands.receive().await { + InboundEvent::Reset => RadioInput::Reset, + InboundEvent::Command { tag, cmd } => { + *incoming_tag = tag; + match cmd { + Command::Ping => RadioInput::CmdPing, + Command::GetInfo => RadioInput::CmdGetInfo, + Command::SetConfig(m) => RadioInput::CmdSetConfig(m), + Command::Tx { flags, data } => { + // Placeholder config: the real `ctx.config` is patched + // in by `announce_entered_tx`. `reject_tx_not_configured` + // (Unconfigured state) consumes this slot without + // touching the config field. + *pending_tx = Some(PendingTx { + tag, + skip_cad: flags.skip_cad, + data, + config: dummy_lora_config(), + }); + RadioInput::CmdTx + } + Command::RxStart => RadioInput::CmdRxStart, + Command::RxStop => RadioInput::CmdRxStop, + } } } } -/// Runs one iteration of continuous RX. Produces a `PacketReceived` event -/// on success or `RxFailed` on error. The radio was configured for -/// continuous RX by `try_start_rx_hw` (entry action of `StartingRx` / -/// `ResumingRx`) before the chart entered `Receiving`; each iteration -/// of this during awaits the next packet. async fn next_packet( lora: &mut LoRaDriver, - rx_buf: &mut [u8; MAX_PAYLOAD], - config: &mut Option, + rx_buf: &mut [u8; MAX_OTA_PAYLOAD], + config: &mut Option, ) -> RadioInput { let Some(cfg) = config.as_ref() else { - return RadioInput::RxFailed("no cached config"); + return RadioInput::RxFailed; }; - let mdltn = match modulation_params(lora, cfg) { + let mdltn = match to_lora_mod_params(lora, cfg) { Ok(m) => m, - Err(_) => return RadioInput::RxFailed("mdltn"), + Err(_) => return RadioInput::RxFailed, }; let pkt = match lora.create_rx_packet_params( cfg.preamble_len, - IMPLICIT_HEADER, - MAX_PAYLOAD as u8, - CRC_ON, - IQ_INVERTED, + matches!(cfg.header_mode, LoRaHeaderMode::Implicit), + MAX_OTA_PAYLOAD as u8, + cfg.payload_crc, + cfg.iq_invert, &mdltn, ) { Ok(p) => p, - Err(_) => return RadioInput::RxFailed("pkt_params"), + Err(_) => return RadioInput::RxFailed, }; match lora.rx(&pkt, rx_buf).await { Ok((len, status)) => { - let copy_len = (len as usize).min(MAX_PAYLOAD); - let mut data: heapless::Vec = heapless::Vec::new(); + let ts_us = Instant::now().as_micros(); + let copy_len = (len as usize).min(MAX_OTA_PAYLOAD); + let mut data: HVec = HVec::new(); let _ = data.extend_from_slice(&rx_buf[..copy_len]); - RadioInput::PacketReceived { - rssi: status.rssi, - snr: status.snr, - len, + RadioInput::PacketReceived(RxPayload { + rssi_tenths_dbm: (status.rssi as i32 * 10) as i16, + snr_tenths_db: (status.snr as i32 * 10) as i16, + // lora-phy 3.0's RxStatus doesn't expose FreqErr; 0 for now. + freq_err_hz: 0, + timestamp_us: ts_us, + // SX126x silently drops bad-CRC packets at the chip; any + // packet arriving here passed CRC. + crc_valid: true, + packets_dropped: 0, // filled in by record_packet + origin: RxOrigin::Ota, data, - } + }) } - Err(_e) => RadioInput::RxFailed("rx"), + Err(_) => RadioInput::RxFailed, } } -/// Drives one TX operation using a staged `PendingTx`. Produces -/// `TransmitDone` on success or `TransmitFailed` on error. async fn perform_tx(lora: &mut LoRaDriver, pending: &mut Option) -> RadioInput { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(6); let Some(tx) = pending.take() else { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(15); - return RadioInput::TransmitFailed("no pending tx"); + return RadioInput::TransmitFailed; }; - let Some(cfg) = tx.config.as_ref() else { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(15); - return RadioInput::TransmitFailed("no effective config"); - }; - match do_tx(lora, cfg, &tx.payload).await { - Ok(()) => { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(10); - RadioInput::TransmitDone - } - Err(_) => { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(15); - RadioInput::TransmitFailed("tx") - } + let start = Instant::now(); + match do_tx(lora, &tx).await { + Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone { + airtime_us: Instant::now().duration_since(start).as_micros() as u32, + }, + Ok(TxOutcome::ChannelBusy) => RadioInput::ChannelBusy, + Err(_) => RadioInput::TransmitFailed, } } // ── Actions ───────────────────────────────────────────────────────── impl RadioActions for RadioActionContext<'_> { - // ── responses ──────────────────────────────────────────────────── + // ── PING / GET_INFO ─────────────────────────────────────────────── + async fn respond_pong(&mut self) { - self.responses.send(Response::Pong).await; + let tag = self.incoming_tag; + self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; } - async fn respond_get_config(&mut self) { - match self.config { - Some(cfg) => self.responses.send(Response::Config(cfg)).await, - None => { - self.responses - .send(Response::Error(ErrorCode::NotConfigured)) + async fn respond_info(&mut self) { + let tag = self.incoming_tag; + let info = *self.info; + self.send(tag, DeviceMessage::Ok(OkPayload::Info(info))) + .await; + } + + // ── SET_CONFIG ──────────────────────────────────────────────────── + + async fn apply_set_config(&mut self, m: Modulation) { + let tag = self.incoming_tag; + match m { + Modulation::LoRa(cfg) => { + if let Err(code) = validate_lora(&cfg, self.info) { + // Validation failure: radio + state unchanged (spec §3.5). + self.send(tag, DeviceMessage::Err(code)).await; + return; + } + // Cancel any queued-but-not-started TX per spec §3.5. + if let Some(cancelled) = self.pending_tx.take() { + let td = TxDonePayload { + result: TxResult::Cancelled, + airtime_us: 0, + }; + self.send(cancelled.tag, DeviceMessage::TxDone(td)).await; + } + if let Err(_e) = reconfigure_radio(&mut self.lora, &cfg).await { + // Hardware failure: drop to Unconfigured (spec §6.3). + self.config = None; + self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; + let _ = self.emit(RadioInput::ConfigFailedRadio); + return; + } + self.config = Some(cfg); + self.packets_dropped = 0; + self.announce(RadioEvent::ConfigChanged(cfg)); + let result = SetConfigResult { + result: SetConfigResultCode::Applied, + owner: Owner::Mine, + current: Modulation::LoRa(cfg), + }; + self.send(tag, DeviceMessage::Ok(OkPayload::SetConfig(result))) + .await; + // Drive the Unconfigured → Configured transition (no-op in + // Configured — there's no handler for this event there). + let _ = self.emit(RadioInput::ConfigApplied); + } + _ => { + self.send(tag, DeviceMessage::Err(ErrorCode::EModulation)) .await; } } } - async fn respond_ok(&mut self) { - // try_send: StopRx after RX with full response channel is - // tolerable — host is disconnected. - let _ = self.responses.try_send(Response::Ok); - } + // ── TX ──────────────────────────────────────────────────────────── - async fn respond_not_configured(&mut self) { - self.responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - } - - async fn respond_tx_done(&mut self) { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(11); - self.responses.send(Response::TxDone).await; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(12); - } - - async fn respond_tx_failed(&mut self, _err: &'static str) { - self.responses - .send(Response::Error(ErrorCode::TxTimeout)) - .await; - } - - // ── state bookkeeping ───────────────────────────────────────────── - async fn cache_config(&mut self, cfg: RadioConfig) { - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("SetConfig rejected: {}", reason); - self.responses - .send(Response::Error(ErrorCode::InvalidConfig)) + async fn reject_tx_not_configured(&mut self) { + if let Some(pt) = self.pending_tx.take() { + self.send(pt.tag, DeviceMessage::Err(ErrorCode::ENotConfigured)) .await; - return; } - let resolved = cfg.resolve(Board::TX_POWER_RANGE); - self.config = Some(resolved); - self.announce(RadioEvent::ConfigChanged(resolved)); - self.responses.send(Response::Ok).await; } - async fn stage_tx(&mut self, p: PendingTx) { - let effective = p.config.or(self.config); - let Some(cfg) = effective else { - self.responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - return; - }; - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("TX config rejected: {}", reason); - self.responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - return; + async fn respond_tx_done(&mut self, airtime_us: u32) { + if let Some(tag) = self.last_tx_tag.take() { + let td = TxDonePayload { + result: TxResult::Transmitted, + airtime_us, + }; + self.send(tag, DeviceMessage::TxDone(td)).await; } - let resolved = cfg.resolve(Board::TX_POWER_RANGE); - self.pending_tx = Some(PendingTx { - config: Some(resolved), - payload: p.payload, - }); } - // ── RX hardware ────────────────────────────────────────────────── + async fn respond_tx_channel_busy(&mut self) { + if let Some(tag) = self.last_tx_tag.take() { + let td = TxDonePayload { + result: TxResult::ChannelBusy, + airtime_us: 0, + }; + self.send(tag, DeviceMessage::TxDone(td)).await; + } + } + + async fn respond_tx_failed(&mut self) { + if let Some(tag) = self.last_tx_tag.take() { + self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; + } + } + + // ── RX_START / RX_STOP ──────────────────────────────────────────── + + async fn stage_rx_start(&mut self) { + self.pending_rx_start_tag = Some(self.incoming_tag); + } - /// Entry action for `StartingRx` and `ResumingRx`. Performs HW init - /// and emits `StartRxOk` on success or `StartRxFailed` on failure. - /// Whether the host receives a Response::Ok / Response::Error is - /// decided by the parent state (StartingRx responds; ResumingRx is - /// silent — `respond_tx_done` already went out). async fn try_start_rx_hw(&mut self) { let Some(cfg) = self.config else { error!("try_start_rx_hw: no config cached — state invariant broken"); - let _ = self.emit(RadioInput::StartRxFailed("no config")); + let _ = self.emit(RadioInput::StartRxFailed); return; }; match start_rx(&mut self.lora, &cfg).await { @@ -438,16 +452,31 @@ impl RadioActions for RadioActionContext<'_> { } Err(e) => { warn!("start_rx failed: {}", e); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(17); - let _ = self.emit(RadioInput::StartRxFailed("start_rx")); + let _ = self.emit(RadioInput::StartRxFailed); } } } - async fn respond_start_rx_error(&mut self, _err: &'static str) { - self.responses - .send(Response::Error(ErrorCode::InvalidConfig)) + async fn respond_ok(&mut self) { + let tag = self.incoming_tag; + self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; + } + + async fn respond_ok_start_rx(&mut self) { + if let Some(tag) = self.pending_rx_start_tag.take() { + self.send(tag, DeviceMessage::Ok(OkPayload::Empty)).await; + } + } + + async fn respond_err_start_rx(&mut self) { + if let Some(tag) = self.pending_rx_start_tag.take() { + self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await; + } + } + + async fn reject_not_configured(&mut self) { + let tag = self.incoming_tag; + self.send(tag, DeviceMessage::Err(ErrorCode::ENotConfigured)) .await; } @@ -455,164 +484,105 @@ impl RadioActions for RadioActionContext<'_> { let _ = self.lora.enter_standby().await; } - // ── announcements ──────────────────────────────────────────────── - async fn announce_idle(&mut self) { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(4); + // ── Reset (disconnect / inactivity timeout) ────────────────────── + + async fn reset_to_unconfigured(&mut self) { + self.pending_tx = None; + self.last_tx_tag = None; + self.pending_rx_start_tag = None; + self.config = None; + self.packets_dropped = 0; + let _ = self.lora.enter_standby().await; self.announce(RadioEvent::Idle); } + + // ── Announcements ───────────────────────────────────────────────── + + async fn announce_idle(&mut self) { + self.announce(RadioEvent::Idle); + } + async fn announce_entered_rx(&mut self) { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(5); self.announce(RadioEvent::EnteredRx); } + async fn announce_entered_tx(&mut self) { + // Patch the staged TX's config to whatever's active now. The + // tag and data stay exactly as the host sent them. + if let (Some(cfg), Some(pt)) = (self.config, self.pending_tx.as_mut()) { + pt.config = cfg; + } + if let Some(pt) = self.pending_tx.as_ref() { + self.last_tx_tag = Some(pt.tag); + } self.announce(RadioEvent::EnteredTx); } + async fn announce_packet_tx(&mut self) { self.announce(RadioEvent::PacketTx); } - async fn announce_rx_failed(&mut self, _err: &'static str) { + + async fn announce_rx_failed(&mut self) { self.announce(RadioEvent::Idle); } - // ── packet bookkeeping ──────────────────────────────────────────── - async fn record_packet( - &mut self, - rssi: i16, - snr: i16, - _len: u8, - data: heapless::Vec, - ) { + // ── Packet bookkeeping ──────────────────────────────────────────── + + async fn record_packet(&mut self, rx: RxPayload) { + let rssi_dbm = rx.rssi_tenths_dbm / 10; + let snr_db = Some(rx.snr_tenths_db / 10); self.announce(RadioEvent::PacketRx { - rssi, - snr: Some(snr), + rssi: rssi_dbm, + snr: snr_db, }); - let response = Response::RxPacket { - rssi, - snr, - payload: data, - }; - if self.responses.try_send(response).is_err() { - warn!("RX packet dropped: response channel full"); - } - } -} - -// ── Debug-checkpoint mapping (see `crate::debug_blink`) ───────────── -// -// Boot / steady states (latch is INACTIVE — values overwrite normally): -// 1 boot: radio_task entered -// 2 boot: LoRa::new ok (driver is live) -// 3 boot: machine constructed, about to enter run() -// 4 in Idle state (announce_idle ran) -// 5 in Receiving state (announce_entered_rx ran — HW is in RX) -// -// TX phases 6–15 (linear; latch ENGAGES only on `set(6)`; only deeper -// TX-phase writes can update; 14/15 are terminal and freeze the latch): -// 6 TX phase 1: entered perform_tx -// 7 TX phase 2: in CAD (prepare_for_cad / cad loop) -// 8 TX phase 3: in prepare_for_tx (SPI config write) -// 9 TX phase 4: awaiting tx() completion (on-air transmit) -// 10 TX phase 5: do_tx returned Ok (back in perform_tx) -// 11 TX phase 6: respond_tx_done handler entered -// 12 TX phase 7: responses.send(TxDone) completed (radio side done) -// 13 TX phase 8: host_task received the response from channel (set in host/uart.rs) -// 14 TX phase 9: host_task wrote response to UART — TERMINAL success -// 15 TX errored somewhere — TERMINAL error -// -// Boot-time errors (can only be seen before any TX engages the latch): -// 16 LoRa::new failed at boot (degraded_loop branch) -// 17 try_start_rx_hw failed (radio HW init returned Err) - -#[task] -pub async fn radio_task( - parts: RadioParts, - commands: &'static CommandChannel, - responses: &'static ResponseChannel, - events: &'static RadioEventChannel, -) { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(1); - - // Init the radio BEFORE the machine exists in any meaningful way — - // a failure here never touches the statechart; we just run a - // Ping-only loop forever. - let lora = match LoRa::new(parts.driver, false, parts.delay).await { - Ok(l) => { - info!("radio initialized"); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(2); - l - } - Err(e) => { - error!("radio init failed: {}", e); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(16); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - degraded_loop(commands, responses).await; - return; - } - }; - - let ctx = RadioContext { - commands, - responses, - events, - lora, - rx_buf: [0u8; MAX_PAYLOAD], - config: None, - pending_tx: None, - }; - - // `run()` under the embassy feature requires a bound channel even when - // no external task injects events. Radio inputs come from the root - // `during: next_command(commands)` activity and from internal durings; - // this static is kept solely to satisfy the constructor. - static RADIO_INTERNAL_CH: Channel = Channel::new(); - let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH); - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(3); - let _ = machine.run().await; -} - -/// Init-failure fallback. Answers Ping; everything else errors. -async fn degraded_loop(commands: &CommandChannel, responses: &ResponseChannel) { - loop { - let cmd = commands.receive().await; - if let Command::Ping = cmd { - responses.send(Response::Pong).await; + let mut rx = rx; + rx.packets_dropped = self.packets_dropped; + let delivered = self + .outbound + .try_send(OutboundFrame { + tag: 0, + msg: DeviceMessage::Rx(rx), + }) + .is_ok(); + if delivered { + self.packets_dropped = 0; } else { - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; + warn!("RX dropped: outbound queue full"); + self.packets_dropped = self.packets_dropped.saturating_add(1); } } } // ── LoRa helpers ──────────────────────────────────────────────────── -fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth { - use lora_phy::mod_params::Bandwidth::*; - match bw { - protocol::Bandwidth::Khz7 => _7KHz, - protocol::Bandwidth::Khz10 => _10KHz, - protocol::Bandwidth::Khz15 => _15KHz, - protocol::Bandwidth::Khz20 => _20KHz, - protocol::Bandwidth::Khz31 => _31KHz, - protocol::Bandwidth::Khz41 => _41KHz, - protocol::Bandwidth::Khz62 => _62KHz, - protocol::Bandwidth::Khz125 => _125KHz, - protocol::Bandwidth::Khz250 => _250KHz, - protocol::Bandwidth::Khz500 => _500KHz, - } +enum TxOutcome { + Transmitted, + ChannelBusy, } -fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor { +fn to_bw(bw: LoRaBandwidth) -> Option { + use lora_phy::mod_params::Bandwidth::*; + Some(match bw { + LoRaBandwidth::Khz7 => _7KHz, + LoRaBandwidth::Khz10 => _10KHz, + LoRaBandwidth::Khz15 => _15KHz, + LoRaBandwidth::Khz20 => _20KHz, + LoRaBandwidth::Khz31 => _31KHz, + LoRaBandwidth::Khz41 => _41KHz, + LoRaBandwidth::Khz62 => _62KHz, + LoRaBandwidth::Khz125 => _125KHz, + LoRaBandwidth::Khz250 => _250KHz, + LoRaBandwidth::Khz500 => _500KHz, + LoRaBandwidth::Khz200 + | LoRaBandwidth::Khz400 + | LoRaBandwidth::Khz800 + | LoRaBandwidth::Khz1600 => return None, + }) +} + +fn to_sf(sf: u8) -> Option { use lora_phy::mod_params::SpreadingFactor::*; - match sf { + Some(match sf { 5 => _5, 6 => _6, 7 => _7, @@ -621,77 +591,162 @@ fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor { 10 => _10, 11 => _11, 12 => _12, - _ => { - warn!("BUG: invalid SF {} passed validation", sf); - _7 - } - } + _ => return None, + }) } -fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate { +fn to_cr(cr: LoRaCodingRate) -> lora_phy::mod_params::CodingRate { use lora_phy::mod_params::CodingRate::*; match cr { - 5 => _4_5, - 6 => _4_6, - 7 => _4_7, - 8 => _4_8, - _ => { - warn!("BUG: invalid CR {} passed validation", cr); - _4_5 - } + LoRaCodingRate::Cr4_5 => _4_5, + LoRaCodingRate::Cr4_6 => _4_6, + LoRaCodingRate::Cr4_7 => _4_7, + LoRaCodingRate::Cr4_8 => _4_8, } } -fn modulation_params( +fn to_lora_mod_params( lora: &mut LoRaDriver, - cfg: &RadioConfig, + cfg: &LoRaConfig, ) -> Result { - lora.create_modulation_params(to_sf(cfg.sf), to_bw(cfg.bw), to_cr(cfg.cr), cfg.freq_hz) + let sf = to_sf(cfg.sf).ok_or(RadioError::UnavailableSpreadingFactor)?; + let bw = to_bw(cfg.bw).ok_or(RadioError::UnavailableBandwidth)?; + let cr = to_cr(cfg.cr); + lora.create_modulation_params(sf, bw, cr, cfg.freq_hz) } -async fn start_rx(lora: &mut LoRaDriver, cfg: &RadioConfig) -> Result<(), RadioError> { - let mdltn = modulation_params(lora, cfg)?; +async fn start_rx(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> { + let mdltn = to_lora_mod_params(lora, cfg)?; let pkt = lora.create_rx_packet_params( cfg.preamble_len, - IMPLICIT_HEADER, - MAX_PAYLOAD as u8, - CRC_ON, - IQ_INVERTED, + matches!(cfg.header_mode, LoRaHeaderMode::Implicit), + MAX_OTA_PAYLOAD as u8, + cfg.payload_crc, + cfg.iq_invert, &mdltn, )?; lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await } -const CAD_MAX_RETRIES: u8 = 10; +async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<(), RadioError> { + lora.enter_standby().await?; + // Calibrate image etc. via a throwaway create_modulation_params call + // — inside the SET_CONFIG OK window so first TX/RX is fast. + let _ = to_lora_mod_params(lora, cfg)?; + Ok(()) +} -async fn do_tx(lora: &mut LoRaDriver, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> { - let mdltn = modulation_params(lora, cfg)?; +async fn do_tx(lora: &mut LoRaDriver, tx: &PendingTx) -> Result { + let mdltn = to_lora_mod_params(lora, &tx.config)?; - if cfg.cad_enabled() { - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(7); + if !tx.skip_cad { lora.prepare_for_cad(&mdltn).await?; + let mut saw_activity = false; for _ in 0..CAD_MAX_RETRIES { if !lora.cad(&mdltn).await? { + saw_activity = false; break; } + saw_activity = true; embassy_time::Timer::after_millis(10).await; lora.prepare_for_cad(&mdltn).await?; } + if saw_activity { + return Ok(TxOutcome::ChannelBusy); + } } let mut tx_pkt = lora.create_tx_packet_params( - cfg.preamble_len, - IMPLICIT_HEADER, - CRC_ON, - IQ_INVERTED, + tx.config.preamble_len, + matches!(tx.config.header_mode, LoRaHeaderMode::Implicit), + tx.config.payload_crc, + tx.config.iq_invert, &mdltn, )?; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(8); - lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload) + lora.prepare_for_tx(&mdltn, &mut tx_pkt, tx.config.tx_power_dbm as i32, &tx.data) .await?; - #[cfg(feature = "debug-checkpoint")] - crate::debug_blink::set(9); - lora.tx().await + lora.tx().await?; + Ok(TxOutcome::Transmitted) +} + +fn validate_lora(cfg: &LoRaConfig, info: &Info) -> Result<(), ErrorCode> { + if cfg.freq_hz < info.freq_min_hz || cfg.freq_hz > info.freq_max_hz { + return Err(ErrorCode::EParam); + } + if !(5..=12).contains(&cfg.sf) { + return Err(ErrorCode::EParam); + } + if info.supported_sf_bitmap & (1u16 << cfg.sf) == 0 { + return Err(ErrorCode::EParam); + } + if info.supported_bw_bitmap & (1u16 << cfg.bw.as_u8()) == 0 { + return Err(ErrorCode::EParam); + } + if cfg.tx_power_dbm < info.tx_power_min_dbm || cfg.tx_power_dbm > info.tx_power_max_dbm { + return Err(ErrorCode::EParam); + } + if cfg.preamble_len < 6 { + return Err(ErrorCode::EParam); + } + Ok(()) +} + +fn dummy_lora_config() -> 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: 0, + header_mode: LoRaHeaderMode::Explicit, + payload_crc: true, + iq_invert: false, + } +} + +// ── Task entry ────────────────────────────────────────────────────── + +#[task] +pub async fn radio_task( + parts: RadioParts, + commands: &'static CommandChannel, + outbound: &'static OutboundChannel, + events: &'static RadioEventChannel, + info: &'static Info, +) { + let lora = match LoRa::new(parts.driver, false, parts.delay).await { + Ok(l) => { + info!("radio initialized"); + l + } + Err(e) => { + error!("radio init failed: {}", e); + return; + } + }; + + let ctx = RadioContext { + commands, + outbound, + events, + info, + lora, + rx_buf: [0u8; MAX_OTA_PAYLOAD], + config: None, + pending_tx: None, + packets_dropped: 0, + incoming_tag: 0, + last_tx_tag: None, + pending_rx_start_tag: None, + }; + + static RADIO_INTERNAL_CH: embassy_sync::channel::Channel< + embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, + RadioInput, + 8, + > = embassy_sync::channel::Channel::new(); + let mut machine = Radio::new(ctx, &RADIO_INTERNAL_CH); + let _ = machine.run().await; }