# DongLoRa Protocol v2 Specification

**Version 1.0**

A minimal, reliable, transport-agnostic wire protocol for exposing a LoRa radio transceiver as a USB-serial (or BLE / WiFi / TCP) device. This document is the normative reference for implementers of both host software and device firmware.

---

## Table of Contents

1. Overview
2. Frame Format
3. State Machine
4. Message Types
5. Common Data Types
6. Message Reference
7. Error Codes
8. Radio Chip ID Enum
9. Capability Bitmap
10. Modulation Parameters
11. Ordering and Interleaving
12. Flow Control and Backpressure
13. Multi-Client Operation (Future)
14. Host Behavior Requirements
15. Device Behavior Requirements
16. Version Compatibility
17. Appendix A — COBS Reference
18. Appendix B — CRC Reference
19. Appendix C — Wire Examples
20. Appendix D — Glossary

---

## 1. Overview

DongLoRa Protocol is the wire protocol spoken between a host application and firmware on a microcontroller attached to a Semtech LoRa transceiver (SX126x, SX127x, SX128x, LR11xx, LR2021, or similar). It exposes the transceiver's native capabilities over a byte-stream transport with no editorialization: the host asks, the chip does.

### 1.1 Design Principles

1. **Minimal.** Ten message types. No sub-protocols, no modes, no sessions.
2. **Transport-agnostic.** Runs identically over USB CDC-ACM, USB-to-UART bridges, plain UART, BLE, TCP, or any other ordered byte stream.
3. **No persistent state.** The device boots unconfigured and forgets everything on host disconnect. There is no device-side memory between sessions.
4. **No policy.** The firmware does not enforce regulatory limits, retry on failure, reorder TX, or apply profiles. The host is authoritative for all policy.
5. **Binary.** No text, no JSON, no AT commands. Every bit is accounted for.
6. **Host drives.** The device never initiates a conversation. Every device-to-host frame is either a response to a command or a radio-sourced event.

### 1.2 Terminology

- **Device**: MCU + radio transceiver + firmware that speaks DongLoRa Protocol.
- **Host**: Any software endpoint speaking DongLoRa Protocol.
- **Client**: In the multi-client future, an individual host connection. A single-client USB deployment has one client, which is the host.
- **Frame**: A single DongLoRa Protocol message on the wire, COBS-encoded and delimited.
- **Tag**: 16-bit correlation identifier for matching responses to commands.
- **CONFIGURED / UNCONFIGURED**: The two states of the device's state machine (Section 3).

---

## 2. Frame Format

### 2.1 Layout

Every DongLoRa Protocol frame has this pre-encoding structure:

```
┌──────┬──────┬──────────┬───────┐
│ type │ tag  │ payload  │ crc16 │
│  u8  │ u16  │  0..P    │  u16  │
└──────┴──────┴──────────┴───────┘
```

`P` is the maximum payload size for the largest message type, computed as `max_payload_bytes + 20` bytes (to accommodate the `RX` event's 20-byte metadata prefix plus the `bytes` field). `max_payload_bytes` is reported by the device in `GET_INFO` (Section 6.2) and reflects the chip's maximum over-the-air packet length.

The pre-encoding structure is then COBS-encoded (Section 2.4) and terminated with a single `0x00` byte on the wire:

```
┌────────────────────────────────────┬──────┐
│ COBS(type | tag | payload | crc16) │ 0x00 │
└────────────────────────────────────┴──────┘
```

All multi-byte integer fields are **little-endian**.

### 2.2 Fields

**`type`** (u8): Message type identifier. See Section 4.

**`tag`** (u16, little-endian):
- On host→device commands: a host-chosen correlation ID. MUST NOT be `0x0000`.
- On device→host command responses (`OK`, `ERR`, `TX_DONE`): echoes the tag of the originating command.
- On device→host asynchronous events (`RX`, asynchronous `ERR`): always `0x0000`.

Tag uniqueness: a tag is **outstanding** from the moment the host transmits the command until the host has received the final response for that tag (`OK`, `ERR`, or for `TX` the `TX_DONE`). Hosts MUST NOT reuse a tag that is currently outstanding. Once a tag is closed it may be reused freely. A monotonic counter (starting at 1, wrapping at `0xFFFF` and skipping `0x0000`) is the recommended implementation.

Tag = 0x0000 on a host→device command is a protocol violation. The device MUST treat such a frame as a framing error and discard it (emitting asynchronous `ERR(EFRAME)` per Section 2.5), because a response with tag 0x0000 would be indistinguishable from an asynchronous event. Hosts MUST never send tag 0x0000.

**`payload`** (variable length, 0 to `max_payload_bytes + 20` bytes):
- Structure defined per message type (Section 6).
- Length is implicit from the frame boundary: the payload runs from the byte after the tag to two bytes before the end of the decoded frame.

**`crc16`** (u16, little-endian):
- CRC-16/CCITT-FALSE, computed over `type || tag || payload` (pre-COBS bytes, not including the CRC itself).
- Polynomial: `0x1021`
- Initial value: `0xFFFF`
- No reflection (input or output)
- XOR out: `0x0000`
- Check value for ASCII `"123456789"`: `0x29B1`

This variant is also known as CRC-16/AUTOSAR or CRC-16/IBM-3740. It is NOT the same as CRC-16/KERMIT, CRC-16/XMODEM, or CRC-16/CCITT (which use different initial values or reflect their input).

### 2.3 Frame Size Bounds

- Minimum pre-COBS frame: 5 bytes (type + tag + CRC, empty payload).
- Maximum pre-COBS frame: `5 + max_payload_bytes + 20` bytes. For a device reporting `max_payload_bytes = 255`, this is 280 bytes.
- COBS adds at most `ceil(pre_cobs_length / 254) + 1` bytes of overhead.
- Plus one `0x00` delimiter byte.

Implementations SHOULD use buffer sizes of at least `max_payload_bytes + 32` bytes to accommodate all frames comfortably.

### 2.4 COBS Encoding

Frames are encoded with Consistent Overhead Byte Stuffing (COBS), using `0x00` as the framing byte. This guarantees that `0x00` never appears within an encoded frame, so a single `0x00` reliably marks frame boundaries.

**Algorithm**: The standard COBS algorithm (Cheshire & Baker, 1999). Reference implementation in Appendix A.

**Decoder resynchronization**: A receiver that opens the stream mid-frame, or that has lost bytes, discards received bytes until the next `0x00` and begins decoding at the byte following that `0x00`. No other synchronization mechanism is used.

**Overhead**: At most `ceil(N / 254) + 1` bytes for a pre-encoded frame of N bytes, plus the trailing `0x00` delimiter. For typical frames (under 254 bytes), overhead is 2 bytes.

### 2.5 Receiver Parsing Algorithm

On receipt of a byte stream:

1. Accumulate bytes until a `0x00` byte is received. That `0x00` is the frame delimiter.
2. COBS-decode the bytes that preceded the delimiter.
3. If the decoded length is less than 5 bytes, discard. Device MAY emit asynchronous `ERR(EFRAME)`.
4. Split the decoded bytes: `type` (1) || `tag` (2) || `payload` (variable) || `crc16` (2).
5. Compute CRC-16/CCITT-FALSE over `type || tag || payload`. If it does not match `crc16`, discard. Device MAY emit asynchronous `ERR(EFRAME)`.
6. Dispatch by `type`. Unknown `type` byte on the device side: emit `ERR(EUNKNOWN_CMD)` with the received tag. Unknown `type` on the host side: silently discard.

A partial frame at stream close (bytes received without a terminating `0x00`) is always discarded.

---

## 3. State Machine

The device has exactly two states: **UNCONFIGURED** and **CONFIGURED**.

```
                ┌──────────────────┐
                │   UNCONFIGURED   │  ← initial state on boot
                └──────────────────┘
                 │                ▲
   SET_CONFIG    │                │  inactivity timeout (1 s)
   result=       │                │  OR client disconnect
   APPLIED       ▼                │
                ┌──────────────────┐
                │    CONFIGURED    │
                └──────────────────┘
```

### 3.1 UNCONFIGURED

- Entered on boot, after inactivity timeout, or on final client disconnect.
- Radio is in standby (TX disabled, RX disabled, minimal current).
- TX queue is empty. RX ring is empty.
- Accepted commands: `PING`, `GET_INFO`, `SET_CONFIG`.
- Other commands (`TX`, `RX_START`, `RX_STOP`) return `ERR(ENOTCONFIGURED)`.

### 3.2 CONFIGURED

- Entered when `SET_CONFIG` returns `result = APPLIED`.
- Radio parameters match the most recent successful `SET_CONFIG` payload.
- All commands accepted.
- Radio is idle (no RX, no TX) unless `RX_START` has been issued or a TX is in flight.

### 3.3 Transitions

**UNCONFIGURED → CONFIGURED**: successful `SET_CONFIG` with `result = APPLIED`.

**CONFIGURED → UNCONFIGURED**:
- Inactivity timer expires (no frames received for 1000 ms), OR
- Final client disconnects at the transport layer.

Note: `SET_CONFIG` results of `ALREADY_MATCHED` or `LOCKED_MISMATCH` do NOT transition state. They are responses observing the current configuration.

**Within CONFIGURED**: `SET_CONFIG` may be issued again to change params (subject to the lock in multi-client, Section 13). See Section 3.5 for operations spanning this transition.

### 3.4 Liveness Timer

- Each client maintains an inactivity timer with a fixed period of **1000 ms**.
- The timer is **inert** until the first frame from that client has been received. A freshly-booted device with no activity does not time out. The timer starts when the first frame arrives and resets on every subsequent frame.
- The timer is reset whenever any byte stream activity results in a complete frame arriving, whether that frame parses successfully or not. Even a frame that fails CRC or COBS resets the timer — the bytes arrived, meaning the host is present.
- If the timer expires:
  1. All queued-but-not-started TXs issued by that client are discarded silently. No `TX_DONE` is emitted.
  2. Any TX on the air is allowed to complete (it cannot be cleanly aborted). No `TX_DONE` is emitted.
  3. In single-client: the device transitions to UNCONFIGURED. RX ring is cleared. Any continuous RX is aborted. The timer returns to the inert state until the next frame arrives.
  4. In multi-client: only that client's session state is cleaned up. See Section 13.

A client whose inactivity timer has fired is considered disconnected. Transport-level disconnect events (USB detach, BLE disconnect, TCP close) have identical effect to timer expiration.

### 3.5 Operations Spanning Reconfiguration

**`SET_CONFIG` while a TX is on the air**: the in-progress TX is allowed to physically complete. Its `TX_DONE` is emitted normally. Any queued-but-not-yet-started TXs are cancelled and emit `TX_DONE(result=CANCELLED)`. The RX ring is cleared. If the radio was in continuous RX when `SET_CONFIG` was issued, RX is automatically re-armed with the new parameters once the in-flight TX finishes and the radio has completed reconfiguration; the host does **not** need to re-issue `RX_START`. 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)`.
- RX ring is cleared.
- Radio is reconfigured and calibrated if required. If the radio was in continuous RX when `SET_CONFIG` was issued, RX is automatically re-armed with the new parameters once reconfiguration completes; otherwise the radio is left in idle. Either way, the `OK` response is not emitted until this sequence finishes, so the host may issue TX / RX commands immediately after `OK`.

**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 <stdint.h>
#include <stddef.h>

// 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 <stdint.h>
#include <stddef.h>

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.
