Files
HaloKeymind/docs/companion_protocol.md
T

1311 lines
52 KiB
Markdown

# Companion Protocol
- **Last Updated**: 2026-08-26
- **Protocol Version**: 14 (`FIRMWARE_VER_CODE`)
> The command and response catalogs track
> `examples/companion_radio/MyMesh.cpp`. Applications should negotiate the
> protocol and validate lengths because older firmware exposes a subset.
This document is a practical guide to MeshCore's binary companion protocol.
The same protocol frames can be carried by the enabled BLE, USB serial, Wi-Fi,
or Ethernet companion interface; connection details differ by build.
On builds exposing more than one transport, delivery-required replies follow
the interface which supplied the command. The multi-frame contact-list response
holds that route until `END_OF_CONTACTS`; best-effort asynchronous observations
may still be broadcast to enabled clients. Treat the device as one Companion
session rather than as independent per-transport sessions.
The examples focus on BLE, but the packet formats are transport-independent.
## Official Libraries
Please see the following repos for existing MeshCore Companion Protocol libraries.
- JavaScript: [https://github.com/meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js)
- Python: [https://github.com/meshcore-dev/meshcore_py](https://github.com/meshcore-dev/meshcore_py)
## Important Security Note
All secrets, hashes, and cryptographic values shown in this guide are example values only.
- All hex values, public keys and hashes are for demonstration purposes only
- Never use example secrets in production
- Always generate new cryptographically secure random secrets
- Please implement proper security practices in your implementation
- This guide is for protocol documentation only
## Table of Contents
1. [BLE Connection](#ble-connection)
2. [Packet Structure](#packet-structure)
3. [Commands](#commands)
4. [Channel Management](#channel-management)
5. [Message Handling](#message-handling)
6. [Response Parsing](#response-parsing)
7. [Example Implementation Flow](#example-implementation-flow)
8. [Best Practices](#best-practices)
9. [Troubleshooting](#troubleshooting)
---
## BLE Connection
### Service and Characteristics
MeshCore Companion devices expose a BLE service with the following UUIDs:
- **Service UUID**: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
- **RX Characteristic** (App -> Firmware): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
- **TX Characteristic** (Firmware -> App): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
An nRF52 Full Companion also exposes a separate LoRa mOTA source service. It
does not replace or multiplex the normal Companion UART service:
- **mOTA Service**: `14518FC2-7E7A-4D84-8CAE-6664B0234CF2`
- **Device Request** (notify): `2BFAA1EE-7030-459A-B65A-E7CFD5B09735`
- **Host Response** (write with response): `ACF38A51-DD58-4DCE-917F-0B1135E41B1A`
All three mOTA attributes require an encrypted, MITM-authenticated connection
using the Companion's six-digit PIN. The source remains inactive until the
client subscribes to Device Request and explicitly starts it with command
`0x4B`. See [Bluetooth LoRa mOTA source](#bluetooth-lora-mota-source).
ESP32 and nRF52 Companion UART characteristics require the same PIN-protected,
MITM-authenticated link. ESP32 advertises DisplayOnly capability so a central
must enter the PIN shown by the Companion; a Just Works bond is insufficient.
### Connection Steps
1. **Scan for Devices**
- Scan for BLE devices advertising the MeshCore Service UUID
- Optionally filter by device name (typically contains "MeshCore" prefix)
- Note the device MAC address for reconnection
2. **Connect to GATT**
- Connect to the device using the discovered MAC address
- Wait for connection to be established
3. **Discover Services and Characteristics**
- Discover the service with UUID `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
- Discover the RX characteristic `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
- Your app writes to this, the firmware reads from this
- Discover the TX characteristic `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
- The firmware writes to this, your app reads from this
4. **Enable Notifications**
- Subscribe to notifications on the TX characteristic to receive data from the firmware
5. **Send Initial Commands**
- Send `CMD_APP_START` to identify your app to firmware and get radio settings
- Send `CMD_DEVICE_QUERY` to fetch device info and negotiate supported protocol versions
- Send `CMD_SET_DEVICE_TIME` to set the firmware clock
- Send `CMD_GET_CONTACTS` to fetch all contacts
- Send `CMD_GET_CHANNEL` multiple times to fetch all channel slots
- Send `CMD_SYNC_NEXT_MESSAGE` to fetch the next message stored in firmware
- Setup listeners for push codes, such as `PUSH_CODE_MSG_WAITING` or `PUSH_CODE_ADVERT`
- See [Commands](#commands) section for information on other commands
**Note**: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff.
### BLE Write Type
When writing commands to the RX characteristic, specify the write type:
- **Write with Response** (default): Waits for acknowledgment from device
- **Write without Response**: Faster but no acknowledgment
**Platform-specific**:
- **Android**: Use `BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT` or `WRITE_TYPE_NO_RESPONSE`
- **iOS**: Use `CBCharacteristicWriteType.withResponse` or `.withoutResponse`
- **Python (bleak)**: Use `write_gatt_char()` with `response=True` or `False`
**Recommendation**: Use write with response for reliability.
### MTU (Maximum Transmission Unit)
The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like `SET_CHANNEL` (50 bytes), you may need to:
1. **Request Larger MTU**: Request MTU of 512 bytes if supported
- Android: `gatt.requestMtu(512)`
- iOS: `peripheral.maximumWriteValueLength(for:)`
- Python (bleak): MTU is negotiated automatically
### Command Sequencing
**Critical**: Commands must be sent in the correct sequence:
1. **After Connection**:
- Wait for BLE connection to be established
- Wait for services/characteristics to be discovered
- Wait for notifications to be enabled
- Now you can safely send commands to the firmware
2. **Command-Response Matching**:
- Send one command at a time
- Wait for a response before sending another command
- Use a timeout (typically 5 seconds)
- Match response to command by type (e.g: `CMD_GET_CHANNEL` -> `RESP_CODE_CHANNEL_INFO`)
### Command Queue Management
For reliable operation, implement a command queue.
**Queue Structure**:
- Maintain a queue of pending commands
- Track which command is currently waiting for a response
- Only send next command after receiving response or timeout
**Error Handling**:
- On timeout, clear current command, process next in queue
- On error, log error, clear current command, process next
---
## Packet Structure
The MeshCore protocol uses a binary format with the following structure:
- **Commands**: Sent from app to firmware via RX characteristic
- **Responses**: Received from firmware via TX characteristic notifications
- **All multi-byte integers**: Little-endian byte order (except CayenneLPP which is Big-endian)
- **All strings**: UTF-8 encoding
Most packets follow this format:
```
[Packet Type (1 byte)] [Data (variable length)]
```
The first byte indicates the packet type (see [Response Parsing](#response-parsing)).
---
## Commands
The first byte selects the command. This is the current protocol-v14 command
catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
| Byte | Firmware name | Purpose |
|---|---|---|
| `0x01` | `CMD_APP_START` | Start an app session and request self information. |
| `0x02` | `CMD_SEND_TXT_MSG` | Send text to a contact. |
| `0x03` | `CMD_SEND_CHANNEL_TXT_MSG` | Send channel text. |
| `0x04` | `CMD_GET_CONTACTS` | Enumerate contacts, optionally modified since a timestamp. |
| `0x05` / `0x06` | `CMD_GET_DEVICE_TIME` / `CMD_SET_DEVICE_TIME` | Read or set the device clock. |
| `0x07` / `0x08` | `CMD_SEND_SELF_ADVERT` / `CMD_SET_ADVERT_NAME` | Advertise self or change the advertised name. |
| `0x09` | `CMD_ADD_UPDATE_CONTACT` | Add or update a contact. |
| `0x0A` | `CMD_SYNC_NEXT_MESSAGE` | Dequeue the next pending message. |
| `0x0B` / `0x0C` | `CMD_SET_RADIO_PARAMS` / `CMD_SET_RADIO_TX_POWER` | Set radio parameters or transmit power. |
| `0x0D` | `CMD_RESET_PATH` | Reset a contact's learned path. |
| `0x0E` | `CMD_SET_ADVERT_LATLON` | Set advertised coordinates. |
| `0x0F` | `CMD_REMOVE_CONTACT` | Remove a contact. |
| `0x10` / `0x11` / `0x12` | `CMD_SHARE_CONTACT` / `CMD_EXPORT_CONTACT` / `CMD_IMPORT_CONTACT` | Share, export, or import contact data. |
| `0x13` | `CMD_REBOOT` | Reboot after the required confirmation body. |
| `0x14` | `CMD_GET_BATT_AND_STORAGE` | Read battery and storage usage. |
| `0x15` | `CMD_SET_TUNING_PARAMS` | Set tuning parameters. |
| `0x16` | `CMD_DEVICE_QUERY` | Negotiate protocol support and read device information. |
| `0x17` / `0x18` | `CMD_EXPORT_PRIVATE_KEY` / `CMD_IMPORT_PRIVATE_KEY` | Export or import identity key material when enabled. |
| `0x19` | `CMD_SEND_RAW_DATA` | Send an application raw-data packet. |
| `0x1A`-`0x1D` | `CMD_SEND_LOGIN` through `CMD_LOGOUT` | Manage a server connection. |
| `0x1E` | `CMD_GET_CONTACT_BY_KEY` | Look up a contact by public-key prefix. |
| `0x1F` / `0x20` | `CMD_GET_CHANNEL` / `CMD_SET_CHANNEL` | Read or write a channel slot. |
| `0x21`-`0x23` | `CMD_SIGN_START` through `CMD_SIGN_FINISH` | Stream data for identity signing. |
| `0x24` | `CMD_SEND_TRACE_PATH` | Trace a direct route. |
| `0x25` | `CMD_SET_DEVICE_PIN` | Set or clear the device PIN. |
| `0x26` | `CMD_SET_OTHER_PARAMS` | Set telemetry, location, ACK, and related preferences. |
| `0x27` | `CMD_SEND_TELEMETRY_REQ` | Send the legacy telemetry request. |
| `0x28` / `0x29` | `CMD_GET_CUSTOM_VARS` / `CMD_SET_CUSTOM_VAR` | Read or set custom variables. |
| `0x2A` | `CMD_GET_ADVERT_PATH` | Read a cached advertisement path. |
| `0x2B` | `CMD_GET_TUNING_PARAMS` | Read tuning parameters. |
| `0x32` | `CMD_SEND_BINARY_REQ` | Send an application binary request. |
| `0x33` | `CMD_FACTORY_RESET` | Factory-reset after the required confirmation body. |
| `0x34` | `CMD_SEND_PATH_DISCOVERY_REQ` | Request path discovery. |
| `0x36` | `CMD_SET_FLOOD_SCOPE_KEY` | Select scoped or unscoped flood behavior. |
| `0x37` | `CMD_SEND_CONTROL_DATA` | Send zero-hop control data. |
| `0x38` | `CMD_GET_STATS` | Read core, radio, or packet statistics. |
| `0x39` | `CMD_SEND_ANON_REQ` | Send an anonymous request. |
| `0x3A` / `0x3B` | `CMD_SET_AUTOADD_CONFIG` / `CMD_GET_AUTOADD_CONFIG` | Write or read automatic-contact policy. |
| `0x3C` | `CMD_GET_ALLOWED_REPEAT_FREQ` | Read allowed client-repeat frequency ranges. |
| `0x3D` | `CMD_SET_PATH_HASH_MODE` | Set path-hash width mode. |
| `0x3E` | `CMD_SEND_CHANNEL_DATA` | Send a channel binary datagram. |
| `0x3F` / `0x40` | `CMD_SET_DEFAULT_FLOOD_SCOPE` / `CMD_GET_DEFAULT_FLOOD_SCOPE` | Write or read the default flood scope. |
| `0x41` | `CMD_SEND_RAW_PACKET` | Queue a fully encoded raw mesh packet. |
| `0x42` | `CMD_RUN_CLI_COMMAND` | Run a local CLI command (protocol v14+). |
| `0x4A` | `CMD_EXEC_LOCAL_OTA_CONTROL` | Run one bounded local TempRadio or OTA command when the Companion includes the OTA CLI. |
| `0x4B` | `CMD_BLE_MOTA_SOURCE` | Query, start, or stop an nRF52 Full Companion's Bluetooth-backed LoRa mOTA source. |
| `0x78`-`0x7F` | Deprecated hardware-setting aliases | Receive-only compatibility for clients shipped before command `0x42` became the canonical settings path. |
The sections below detail the most common frames. Refer to the source named
above for command bodies that are not expanded here.
`CMD_RUN_CLI_COMMAND` is followed by the local CLI text without a terminating
NUL. The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply
text. This is separate from sending a remote on-air CLI command with
`CMD_SEND_TXT_MSG` and `TXT_TYPE_CLI_COMMAND`. The body must contain at least
one byte and must not contain an embedded NUL. An unknown command is returned
as the normal CLI reply text `Unknown command`, not as an error frame. Clients
may prefix the CLI text with any two-character correlation tag and `|` (for
example, `A7|get radio.rxgain`); the reply preserves that prefix.
Direct USB, BLE, WiFi TCP, and Ethernet clients can also use this command for
`stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets`, `erase`,
and `get prv.key` (when private key export is enabled).
WiFi/MQTT builds accept `get wifi.pwd`, `get mqttN.password`, and
`get mqttN.token`. `get password` reports that Companion has no admin password;
infrastructure roles return their own admin password on a local CLI connection.
These privileges do not apply to CLI commands relayed over LoRa.
`set freq <MHz>` works through command `0x42` and authorized LoRa CLI, like
`set radio`; it saves the frequency and requires reboot to apply. See
[local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for role differences and the text-terminal equivalents.
Full Companion clients can send `version` through this command to receive the
untruncated build identity, for example `Companion 1.17.1.5-... (protocol 14,
build 31-Aug-2026)`. This deliberately supplements rather than changes the
20-byte legacy version field in `RESP_CODE_DEVICE_INFO`, so existing clients
keep the same frame layout.
Firmware from this fork predating the upstream `0x42` allocation used
`0x42`-`0x49` for these eight settings. This firmware accepts those values as
deprecated inbound aliases so existing clients continue to work. A one-byte
`0x42` frame is the legacy FEM-gain GET; `0x42` followed by text is the official
`CMD_RUN_CLI_COMMAND`. New clients should use `CMD_RUN_CLI_COMMAND` for all of
these settings, rather than allocating additional command bytes. For example,
send `0x42` followed by `get radio.rxgain` or `set radio.rxgain on`. The reply is
`RESP_CODE_CLI_REPLY` followed by the normal CLI reply text.
Two deprecated binary alias blocks remain receive-only for compatibility:
| Setting | Original alias | Later fork alias | GET body/reply | SET body/reply |
|---|---:|---:|---|---|
| FEM receive gain | `0x42` / `0x43` | `0x78` / `0x79` | No body; `OK, state` | One byte `0`/`1`; `OK` |
| Radio receive gain | `0x44` / `0x45` | `0x7A` / `0x7B` | No body; `OK, state` | One byte `0`/`1`; `OK` |
| WiFi power save | `0x46` / `0x47` | `0x7C` / `0x7D` | No body; `OK, mode` | One mode byte `0`-`2`; `OK` |
| Bluetooth name | `0x48` / `0x49` | `0x7E` / `0x7F` | No body; `OK, custom, name` | Zero to 31 UTF-8 bytes; `OK` |
Each pair lists GET then SET. Here `OK` is `RESP_CODE_OK`; the remaining reply
bytes have the same meanings as the CLI settings below. A bare `0x42` is the
old FEM GET, while `0x42` plus at least one text byte is
`CMD_RUN_CLI_COMMAND`. New clients must use the framed CLI form; these aliases
exist only so deployed clients do not break after a firmware update.
The equivalent framed CLI commands are:
| Setting | Commands |
|---|---|
| Radio receive gain | `get radio.rxgain`; `set radio.rxgain on|off` |
| FEM receive gain | `get radio.fem.rxgain`; `set radio.fem.rxgain on|off` |
| WiFi power save | `get wifi.powersave`; `set wifi.powersave none|min|max` |
| Bluetooth name | `get bluetooth.name`; `set bluetooth.name <name|default>` |
| Bluetooth address | `get bluetooth.mac`; `set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>` |
| Bluetooth stealth | `get bluetooth.stealth`; `set bluetooth.stealth on|off` |
The framed form works over the normal binary USB, BLE, or TCP transport and
does not need the USB terminal-start token. Unsupported settings return the
same explanatory text as the local CLI.
WiFi power-save modes are:
| Value | Mode |
|---:|---|
| `0` | `min` - minimum modem sleep |
| `1` | `none` - no modem sleep |
| `2` | `max` - maximum modem sleep |
A Full Companion that runs BLE and infrastructure WiFi simultaneously rejects
WiFi mode `none` because coexistence requires modem sleep. A Full Companion
using ESP-NOW as its primary mesh radio also rejects `max`, because maximum
modem sleep can miss broadcasts that the access point cannot buffer. If an
older image saved a conflicting value, the effective mode is capped to and
reported as `min`. Device power saving remains independent.
The SenseCAP Indicator Full profiles run exactly one secondary wireless
Companion transport per boot. Their active-mode constraints are:
| Indicator mode | Accepted `wifi.powersave` values |
|---|---|
| LoRa + infrastructure WiFi | `none`, `min`, `max` |
| LoRa + BLE | `min`, `max`; infrastructure WiFi is not started |
| ESP-NOW + infrastructure WiFi | `none`, `min`; `max` conflicts with primary ESP-NOW |
| ESP-NOW + BLE | `min`; infrastructure WiFi is not started and primary ESP-NOW remains active |
The Bluetooth name can be configured over USB, BLE, or TCP. Use
`set bluetooth.name default` to restore `MeshCore-<advert name>`; an empty CLI
value is rejected. (`clear` is also accepted as an alias for `default`.) A
custom name is limited to 31 valid UTF-8 bytes and takes effect after reboot.
Bluetooth Companion builds also accept `bluetooth.mac` (`ble.mac` is an
alias). A literal address must be a BLE random-static address; `random`
generates and saves one, `random-every-boot` rotates it at each startup, and
`random-after-connect` retains it through unused boots but rotates it on the
first boot after an authenticated connection. `default` or `clear` restores
the factory address. The change takes effect after reboot.
`bluetooth.stealth on|off` (`ble.stealth` is an alias) is a separate flag,
defaulting to off. It preserves the address policy and can be combined with
custom, saved random, rotating random, or factory addresses. With stealth on,
the node advertises normally until the first authenticated pairing, then
permits only that bonded peer to reconnect. Address changes clear the saved
peer and reopen pairing without disabling stealth. Rotating policies retain
their normal rotation triggers. Repeating `on` does not clear an existing bond.
Both flag changes require reboot. The old MAC value `stealth` is not accepted.
BLE still transmits directed or allowlisted advertisements; stealth is not
radio silence. These are application settings; an nRF52 OTAFIX bootloader
continues to advertise its separate DFU identity.
### Bluetooth LoRa mOTA source
Protocol v14 lets a phone control an install-capable Companion's own LoRa OTA
session. An nRF52 Full Companion can additionally act as the source for a
remote repeater update without a USB computer. The normal Companion service
still carries contacts, repeater login, CLI messages, and these two control
commands. The separate mOTA service carries only host-folder request/response
frames.
`CMD_EXEC_LOCAL_OTA_CONTROL` (`0x4A`) is followed by 1-174 printable ASCII
bytes. A Companion built with the OTA CLI accepts only these local command
families:
```text
tempradio <freq_kHz>,<bw_kHz>,<sf>,<cr>,<minutes>
normalradio
ota ...
```
`ota folder ...` is deliberately rejected because USB and Bluetooth source
ownership must not be changed through the wrong transport. Embedded NUL, CR,
LF, other control bytes, non-ASCII bytes, empty commands, and oversized frames
return `ERR_CODE_ILLEGAL_ARG`. A recognized command replies with
`RESP_CODE_OK`, one unsigned reply-length byte, and exactly that many printable
result bytes. Shell metacharacters are rejected as well; the text is dispatched
only to the in-firmware parser and is never passed to a host shell. Firmware
without the OTA CLI feature returns `ERR_CODE_UNSUPPORTED_CMD`.
`CMD_BLE_MOTA_SOURCE` (`0x4B`) has one action byte:
| Action | Meaning |
| ---: | --- |
| `0` | Read status without changing it. |
| `1` | Attach and enumerate the subscribed Bluetooth host's `.mota` catalog. |
| `2` | Detach the Bluetooth source. |
Current firmware returns eleven bytes (legacy protocol-v14 previews returned
the seven-byte prefix only):
```text
00 action flags offered_le16 advertised_le16 source_packets_sent_le32
```
Flag bit `0x01` means the encrypted GATT channel is connected and Device
Request notifications are enabled. Bit `0x02` means the Bluetooth catalog is
attached. Bit `0x04` means USB or another folder transport currently owns the
source slot. Start without a ready subscription, or while another source link
owns the slot, returns `ERR_CODE_BAD_STATE`. A non-nRF52 Full Companion returns
`ERR_CODE_UNSUPPORTED_CMD`. `source_packets_sent` is a per-attachment count of
OTA packets accepted by the Companion's LoRa transmit adapter, including
catalog/manifest traffic, data, proofs, and retries. It wraps as an unsigned
32-bit value. Clients should accept the legacy seven-byte response and display
the packet counter as unavailable.
After a successful start, the device sends the same bounded seeder frames used
by `motatool serve` on Device Request:
```text
device -> host: 'M' 'S' op args... xor(op || args)
host -> device: 'm' 's' op status payload... xor(all prior bytes)
```
Device requests are at most 11 bytes. A source response is at most 197 bytes.
The host may split one response across multiple write-with-response operations
when the negotiated ATT payload is smaller; it must preserve byte order and
must not interleave another response. Bad checksums, partial frames, overflow,
unsubscribe, loss of encryption, or disconnect fail closed. The firmware then
detaches the catalog and stops advertising its entries. USB and Bluetooth
folder sources are mutually exclusive.
A Linux reference controller and seeder is provided at
`tools/ble_mota/ble_mota_seeder.py`. It verifies every input with `motatool`
before offering it. A mobile implementation should apply the same complete
container verification before serving files.
### 1. App Start
**Purpose**: Initialize communication with the device. Must be sent first after connection.
**Command Format**:
```
Byte 0: 0x01
Bytes 1-7: Reserved (currently ignored by firmware)
Bytes 8+: Application name (UTF-8, optional)
```
**Example** (hex):
```
01 00 00 00 00 00 00 00 6d 63 63 6c 69
```
**Response**: `PACKET_SELF_INFO` (0x05)
---
### 2. Device Query
**Purpose**: Query device information.
**Command Format**:
```
Byte 0: 0x16
Byte 1: Highest companion protocol version understood by the app
```
**Example** (hex):
```
16 0E
```
**Response**: `PACKET_DEVICE_INFO` (0x0D) with device information
---
### 3. Get Channel Info
**Purpose**: Retrieve information about a specific channel.
**Command Format**:
```
Byte 0: 0x1F
Byte 1: Channel index (0 through max_channels - 1)
```
**Example** (get channel 1):
```
1F 01
```
**Response**: `PACKET_CHANNEL_INFO` (0x12) with channel details
---
### 4. Set Channel
**Purpose**: Create or update a channel on the device.
**Command Format**:
```
Byte 0: 0x20
Byte 1: Channel index (0 through max_channels - 1)
Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)
Bytes 34-49: Secret (16 bytes)
```
**Total Length**: 50 bytes
**Channel index**:
- Slot count is build-specific. Read `max_channels` from byte 3 of
`PACKET_DEVICE_INFO`; current profiles commonly expose 1, 8, or 40 slots.
- No slot number has an intrinsic public/private meaning.
**Channel Name**:
- UTF-8 encoded
- Maximum 32 bytes
- Padded with null bytes (0x00) if shorter
**Secret Field** (16 bytes):
- Supply the exact 16-byte channel key. A private channel normally uses a
cryptographically random key; known public and hashtag channels use their
defined or derived key.
- An all-zero key is not the public-channel key.
**Example** (create channel "SMS" at index 1 with secret):
```
20 01 53 4D 53 00 00 ... (name padded to 32 bytes)
[16 bytes of secret]
```
**Note**: The 32-byte secret variant is unsupported and returns `PACKET_ERROR`.
**Response**: `PACKET_OK` (0x00) on success, `PACKET_ERROR` (0x01) on failure
---
### 5. Send Channel Message
**Purpose**: Send a text message to a channel.
**Command Format**:
```
Byte 0: 0x03
Byte 1: 0x00
Byte 2: Channel index (0 through max_channels - 1)
Bytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)
Bytes 7+: Message Text (UTF-8, variable length)
```
**Timestamp**: Unix timestamp in seconds (32-bit unsigned integer, little-endian)
**Example** (send "Hello" to channel 1 at timestamp 1234567890):
```
03 00 01 D2 02 96 49 48 65 6C 6C 6F
```
**Response**: `PACKET_MSG_SENT` (0x06) on success
---
### 6. Send Channel Data Datagram
**Purpose**: Send a binary datagram to a channel. Unlike channel text messages, datagrams carry no built-in sender identity and no timestamp - applications needing either must encode them inside the binary payload.
**Command Format**:
```
Byte 0: 0x3E
Byte 1: Channel index (0 through max_channels - 1)
Byte 2: Encoded path descriptor (0xFF = flood)
Bytes 3+: Encoded path bytes (omitted for 0xFF)
Next 2 bytes (little-endian): Data Type (`data_type`, uint16)
Remaining bytes: Binary payload (variable length)
```
For a direct send, the descriptor's low six bits are the hash count and its
high two bits are the hash size minus one. Current mesh packets accept one-,
two-, or three-byte hashes; the four-byte code is reserved. The following path
therefore occupies `hash_count * hash_size` bytes; the descriptor itself is not
a raw byte count.
**Example** (flood, `DATA_TYPE_DEV`, payload `A1 B2 C3`, channel 1):
```
3E 01 FF FF FF A1 B2 C3
```
**Data Type / Transport Mapping**:
- `0x0000` (`DATA_TYPE_RESERVED`) is invalid and rejected with `PACKET_ERROR`.
- `0xFFFF` (`DATA_TYPE_DEV`) is the developer namespace for experimenting and developing apps.
- Registered application/community namespaces occupy `0x0100`-`0xFEFF`; the remaining nonzero ranges are reserved for internal or development use. See the [Registered data_type values](#registered-data_type-values) table below.
**Limits**:
- Maximum payload length is `MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167` bytes.
- Larger payloads are rejected with `PACKET_ERROR` (`ERR_CODE_ILLEGAL_ARG`).
**Response**: `PACKET_OK` (0x00) on success, or `PACKET_ERROR` (0x01) with one of:
- `ERR_CODE_NOT_FOUND` (2) - unknown `channel_idx`
- `ERR_CODE_ILLEGAL_ARG` (6) - invalid `path_len`, reserved `data_type` (`0x0000`), or payload larger than `MAX_CHANNEL_DATA_LENGTH`
- `ERR_CODE_TABLE_FULL` (3) - outbound send queue is full; retry later
**Inbound datagrams** are delivered to the host via `RESP_CODE_CHANNEL_DATA_RECV` (0x1B); see [Receive Channel Data Datagram](#receive-channel-data-datagram).
#### Registered `data_type` values
`data_type` is an **application identifier**, not a payload-format identifier. Each registered value identifies an application that owns its own internal payload schemas. The firmware does not inspect payload contents - `data_type` is transported opaquely.
| Value | Constant | Purpose |
|-----------------|----------------------|----------------------------------------------------------------------------------------|
| 0x0000 | `DATA_TYPE_RESERVED` | Reserved; invalid on send |
| 0x0001 - 0x00FF | - | Reserved for internal use |
| 0x0100 - 0xFEFF | - | Registered application namespaces (see [number_allocations.md](number_allocations.md)) |
| 0xFF00 - 0xFFFE | - | Testing/development; no registration required |
| 0xFFFF | `DATA_TYPE_DEV` | Developer/experimental namespace |
To register a new application, submit a PR adding a row to the table in [docs/number_allocations.md](number_allocations.md). Internal sub-formats within an allocated application ID are owned by that application and are not tracked in MeshCore firmware or this document.
---
### Receive Channel Data Datagram
Inbound group datagrams (radio-level `PAYLOAD_TYPE_GRP_DATA`, 0x06) are forwarded to the host as `RESP_CODE_CHANNEL_DATA_RECV` notifications.
**Frame Format** (`RESP_CODE_CHANNEL_DATA_RECV`, 0x1B):
```
Byte 0: 0x1B (packet type)
Byte 1: SNR (signed int8, scaled x4 - divide by 4.0 to recover dB)
Bytes 2-3: Reserved (clients MUST ignore)
Byte 4: Channel index (0 through max_channels - 1)
Byte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct)
Bytes 6-7: Data Type (uint16 little-endian)
Byte 8: Data Length
Bytes 9 .. 8+data_len: Payload
```
**Path bytes are not forwarded**: Only `path_len` is reported in the receive frame - the path itself is not copied to the host. There are no path bytes between byte 5 and the data_type field at bytes 6-7, regardless of `path_len`.
**Path Length semantics differ between send and receive**:
| Direction | `path_len = 0xFF` | `path_len != 0xFF` |
|-----------|---------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| Send | Flood the network | Direct route; the encoded path follows (low 6 bits = hash count, top 2 bits + 1 = hash size; on-wire byte count = `hash_count x hash_size`) |
| Receive | Packet arrived via direct route | Packet was flooded; this is the encoded `pkt->path_len` field as observed (no path bytes follow) |
In other words, the meaning of `0xFF` is inverted between the two directions, and on receive the field carries metadata only - never a routable path. `path_len` is an encoded byte (see `Packet::isValidPathLen` / `Packet::writePath` in `src/Packet.cpp`), not a raw byte count.
**Note**: The device may also emit `PACKET_MESSAGES_WAITING` (0x83) to notify the host that datagrams are queued; poll with `CMD_SYNC_NEXT_MESSAGE` (0x0A) to retrieve them.
**Parsing Pseudocode**:
```python
def parse_channel_data_recv(data):
if len(data) < 9:
return None
snr_byte = data[1]
snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0
channel_idx = data[4]
path_len = data[5]
data_type = int.from_bytes(data[6:8], 'little')
data_len = data[8]
if 9 + data_len > len(data):
return None
payload = data[9:9 + data_len]
return {
'snr': snr,
'channel_idx': channel_idx,
'path_len': path_len,
'data_type': data_type,
'payload': bytes(payload),
}
```
---
### 7. Get Message
**Purpose**: Request the next queued message from the device.
**Command Format**:
```
Byte 0: 0x0A
```
**Example** (hex):
```
0A
```
**Response**:
- `PACKET_CHANNEL_MSG_RECV` (0x08) or `PACKET_CHANNEL_MSG_RECV_V3` (0x11) for channel messages
- `PACKET_CONTACT_MSG_RECV` (0x07) or `PACKET_CONTACT_MSG_RECV_V3` (0x10) for contact messages
- `PACKET_CHANNEL_DATA_RECV` (0x1B) for channel data datagrams
- `PACKET_NO_MORE_MSGS` (0x0A) if no messages available
**Note**: Poll this command periodically to retrieve queued messages. The device may also send `PACKET_MESSAGES_WAITING` (0x83) as a notification when messages are available.
---
### 8. Get Battery and Storage
**Purpose**: Query device battery voltage and storage usage.
**Command Format**:
```
Byte 0: 0x14
```
**Example** (hex):
```
14
```
**Response**: `PACKET_BATTERY` (0x0C) with battery millivolts and storage information
---
## Channel Management
### Channel Types
1. **Public Channel**
- Uses a publicly known 16-byte key: `8b3387e9c5cdea6ac9e5edbaa115cd72`
- Anyone can join this channel, messages should be considered public
- Used as the default public group chat
2. **Hashtag Channels**
- Uses a secret key derived from the channel name
- It is the first 16 bytes of `sha256("#test")`
- For example hashtag channel `#test` has the key: `9cd8fcf22a47333b591d96a2b848b73f`
- Traffic is encrypted on air, but anyone who knows or guesses the channel
name can derive the key. Hashtag channels should not be treated as private.
- Used as a topic based public group chat, separate from the default public channel
3. **Private Channels**
- Uses a randomly generated 16-byte secret key
- Messages should be considered private between those that know the secret
- Users should keep the key secret, and only share with those you want to communicate with
- Used as a secure private group chat
### Channel Lifecycle
1. **Set Channel**:
- Read `max_channels` from device info, fetch those slots, and choose an
unused slot (normally an empty name and zeroed key)
- Generate or provide a 16-byte secret
- Send `CMD_SET_CHANNEL` with name and a 16-byte secret
2. **Get Channel**:
- Send `CMD_GET_CHANNEL` with channel index
- Parse `RESP_CODE_CHANNEL_INFO` response
3. **Delete Channel**:
- Send `CMD_SET_CHANNEL` with empty name and all-zero secret
- Or overwrite with a new channel
---
## Message Handling
### Receiving Messages
Messages are received via the TX characteristic (notifications). The device sends:
1. **Channel Messages**:
- `PACKET_CHANNEL_MSG_RECV` (0x08) - Standard format
- `PACKET_CHANNEL_MSG_RECV_V3` (0x11) - Version 3 with SNR
2. **Contact Messages**:
- `PACKET_CONTACT_MSG_RECV` (0x07) - Standard format
- `PACKET_CONTACT_MSG_RECV_V3` (0x10) - Version 3 with SNR
3. **Notifications**:
- `PACKET_MESSAGES_WAITING` (0x83) - Indicates messages are queued
### Contact Message Format
**Standard Format** (`PACKET_CONTACT_MSG_RECV`, 0x07):
```
Byte 0: 0x07 (packet type)
Bytes 1-6: Public Key Prefix (6 bytes, hex)
Byte 7: Path Length
Byte 8: Text Type
Bytes 9-12: Timestamp (32-bit little-endian)
Bytes 13-16: Signature (4 bytes, only if txt_type == 2)
Bytes 17+: Message Text (UTF-8)
```
**V3 Format** (`PACKET_CONTACT_MSG_RECV_V3`, 0x10):
```
Byte 0: 0x10 (packet type)
Byte 1: SNR (signed byte, multiplied by 4)
Bytes 2-3: Reserved
Bytes 4-9: Public Key Prefix (6 bytes, hex)
Byte 10: Path Length
Byte 11: Text Type
Bytes 12-15: Timestamp (32-bit little-endian)
Bytes 16-19: Signature (4 bytes, only if txt_type == 2)
Bytes 20+: Message Text (UTF-8)
```
**Parsing Pseudocode**:
```python
def parse_contact_message(data):
packet_type = data[0]
offset = 1
# Check for V3 format
if packet_type == 0x10: # V3
snr_byte = data[offset]
snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)
offset += 3 # Skip SNR + reserved
pubkey_prefix = data[offset:offset+6].hex()
offset += 6
path_len = data[offset]
txt_type = data[offset + 1]
offset += 2
timestamp = int.from_bytes(data[offset:offset+4], 'little')
offset += 4
# If txt_type == 2, skip 4-byte signature
if txt_type == 2:
offset += 4
message = data[offset:].decode('utf-8')
return {
'pubkey_prefix': pubkey_prefix,
'path_len': path_len,
'txt_type': txt_type,
'timestamp': timestamp,
'message': message,
'snr': snr if packet_type == 0x10 else None
}
```
### Channel Message Format
**Standard Format** (`PACKET_CHANNEL_MSG_RECV`, 0x08):
```
Byte 0: 0x08 (packet type)
Byte 1: Channel index (0 through max_channels - 1)
Byte 2: Path Length
Byte 3: Text Type
Bytes 4-7: Timestamp (32-bit little-endian)
Bytes 8+: Message Text (UTF-8)
```
**V3 Format** (`PACKET_CHANNEL_MSG_RECV_V3`, 0x11):
```
Byte 0: 0x11 (packet type)
Byte 1: SNR (signed byte, multiplied by 4)
Bytes 2-3: Reserved
Byte 4: Channel index (0 through max_channels - 1)
Byte 5: Path Length
Byte 6: Text Type
Bytes 7-10: Timestamp (32-bit little-endian)
Bytes 11+: Message Text (UTF-8)
```
**Parsing Pseudocode**:
```python
def parse_channel_message(data):
packet_type = data[0]
offset = 1
# Check for V3 format
if packet_type == 0x11: # V3
snr_byte = data[offset]
snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)
offset += 3 # Skip SNR + reserved
channel_idx = data[offset]
path_len = data[offset + 1]
txt_type = data[offset + 2]
timestamp = int.from_bytes(data[offset+3:offset+7], 'little')
message = data[offset+7:].decode('utf-8')
return {
'channel_idx': channel_idx,
'timestamp': timestamp,
'message': message,
'snr': snr if packet_type == 0x11 else None
}
```
### Sending Messages
Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
**Important**:
- The shared text envelope permits up to 160 UTF-8 bytes. For channel text,
firmware prepends `<sender name>: ` inside that envelope, so the available
message body is `160 - prefix_bytes` and varies with the configured name.
- Count encoded UTF-8 bytes, not Unicode characters. Split a longer message at
valid UTF-8 boundaries.
- Include a chunk indicator (e.g., "[1/3] message text")
---
## Response Parsing
### Terminology
This document uses a spec-level naming convention (`PACKET_*`) for bytes the firmware sends back to the host. In the firmware source these same values are split across two `#define` families by purpose:
- `RESP_CODE_*` - direct replies to a command (e.g. `RESP_CODE_CHANNEL_DATA_RECV` = `PACKET_CHANNEL_DATA_RECV` = 0x1B).
- `PUSH_CODE_*` - asynchronous notifications not tied to a specific command (e.g. `PUSH_CODE_MSG_WAITING` = `PACKET_MESSAGES_WAITING` = 0x83).
Byte values are authoritative; names are aliases. When reading firmware source, `RESP_CODE_X` / `PUSH_CODE_X` correspond to this doc's `PACKET_X` of the same numeric value.
### Response types
| Value | Firmware name | Description |
|---|---|---|
| `0x00` | `RESP_CODE_OK` | Command succeeded. |
| `0x01` | `RESP_CODE_ERR` | Command failed; byte 1 is the error code. |
| `0x02` | `RESP_CODE_CONTACTS_START` | Contact enumeration started. |
| `0x03` | `RESP_CODE_CONTACT` | One contact record. |
| `0x04` | `RESP_CODE_END_OF_CONTACTS` | Contact enumeration ended. |
| `0x05` | `RESP_CODE_SELF_INFO` | Device self-information. |
| `0x06` | `RESP_CODE_SENT` | Send accepted, with route/tag/timeout data. |
| `0x07` / `0x08` | `RESP_CODE_CONTACT_MSG_RECV` / `RESP_CODE_CHANNEL_MSG_RECV` | Queued legacy-format message. |
| `0x09` | `RESP_CODE_CURR_TIME` | Current device time. |
| `0x0A` | `RESP_CODE_NO_MORE_MESSAGES` | Offline queue is empty. |
| `0x0B` | `RESP_CODE_EXPORT_CONTACT` | Exported contact bytes. |
| `0x0C` | `RESP_CODE_BATT_AND_STORAGE` | Battery and storage values. |
| `0x0D` | `RESP_CODE_DEVICE_INFO` | Protocol and build information. |
| `0x0E` | `RESP_CODE_PRIVATE_KEY` | Exported identity key, when enabled. |
| `0x0F` | `RESP_CODE_DISABLED` | Requested sensitive feature is disabled. |
| `0x10` / `0x11` | `RESP_CODE_CONTACT_MSG_RECV_V3` / `RESP_CODE_CHANNEL_MSG_RECV_V3` | Queued message with SNR fields. |
| `0x12` | `RESP_CODE_CHANNEL_INFO` | Channel slot information. |
| `0x13` / `0x14` | `RESP_CODE_SIGN_START` / `RESP_CODE_SIGNATURE` | Signing capacity or completed signature. |
| `0x15` | `RESP_CODE_CUSTOM_VARS` | Custom-variable data. |
| `0x16` | `RESP_CODE_ADVERT_PATH` | Cached advertisement path. |
| `0x17` | `RESP_CODE_TUNING_PARAMS` | Tuning parameters. |
| `0x18` | `RESP_CODE_STATS` | Requested statistics subtype. |
| `0x19` | `RESP_CODE_AUTOADD_CONFIG` | Automatic-contact policy. |
| `0x1A` | `RESP_ALLOWED_REPEAT_FREQ` | Allowed repeat-frequency ranges. |
| `0x1B` | `RESP_CODE_CHANNEL_DATA_RECV` | Queued channel datagram. |
| `0x1C` | `RESP_CODE_DEFAULT_FLOOD_SCOPE` | Default flood-scope data. |
| `0x1D` | `RESP_CODE_CLI_REPLY` | Text returned by `CMD_RUN_CLI_COMMAND`. |
### Asynchronous push types
| Value | Firmware name | Description |
|---|---|---|
| `0x80` | `PUSH_CODE_ADVERT` | Advertisement received. |
| `0x81` | `PUSH_CODE_PATH_UPDATED` | A contact path changed. |
| `0x82` | `PUSH_CODE_SEND_CONFIRMED` | A sent message was acknowledged. |
| `0x83` | `PUSH_CODE_MSG_WAITING` | One or more offline frames are waiting. |
| `0x84` | `PUSH_CODE_RAW_DATA` | Raw application data received. |
| `0x85` / `0x86` | `PUSH_CODE_LOGIN_SUCCESS` / `PUSH_CODE_LOGIN_FAIL` | Server login result. |
| `0x87` | `PUSH_CODE_STATUS_RESPONSE` | Server status response. |
| `0x88` | `PUSH_CODE_LOG_RX_DATA` | Radio receive log data. |
| `0x89` | `PUSH_CODE_TRACE_DATA` | Completed trace data. |
| `0x8A` | `PUSH_CODE_NEW_ADVERT` | Newly stored contact advertisement. |
| `0x8B` | `PUSH_CODE_TELEMETRY_RESPONSE` | Telemetry response. |
| `0x8C` | `PUSH_CODE_BINARY_RESPONSE` | Binary request response. |
| `0x8D` | `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | Path-discovery response. |
| `0x8E` | `PUSH_CODE_CONTROL_DATA` | Control/discovery data. |
| `0x8F` | `PUSH_CODE_CONTACT_DELETED` | Oldest contact was deleted while making room. |
| `0x90` | `PUSH_CODE_CONTACTS_FULL` | Contact storage is full. |
### Parsing Responses
**PACKET_OK** (0x00):
```
Byte 0: 0x00
Bytes 1-4: Optional value (32-bit little-endian integer)
```
**PACKET_ERROR** (0x01):
```
Byte 0: 0x01
Byte 1: Error code (optional)
```
**PACKET_CHANNEL_INFO** (0x12):
```
Byte 0: 0x12
Byte 1: Channel Index
Bytes 2-33: Channel Name (32 bytes, null-terminated)
Bytes 34-49: Secret (16 bytes)
```
**Note**: The device returns the 16-byte channel secret in this response.
**PACKET_DEVICE_INFO** (0x0D):
```
Byte 0: 0x0D
Byte 1: Firmware Version (uint8)
Bytes 2+: Variable length based on firmware version
For firmware version >= 3:
Byte 2: Max Contacts Raw (uint8, actual = value * 2)
Byte 3: Max Channels (uint8)
Bytes 4-7: Active BLE PIN (32-bit little-endian; includes a generated session PIN)
Bytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)
Bytes 20-59: Model (40 bytes, UTF-8, null-padded)
Bytes 60-79: Version (20 bytes, UTF-8, null-padded)
Byte 80: Client repeat enabled/preferred (firmware v9+)
Byte 81: Path hash mode (firmware v10+)
```
**Parsing Pseudocode**:
```python
def parse_device_info(data):
if len(data) < 2:
return None
fw_ver = data[1]
info = {'fw_ver': fw_ver}
if fw_ver >= 3 and len(data) >= 80:
info['max_contacts'] = data[2] * 2
info['max_channels'] = data[3]
info['ble_pin'] = int.from_bytes(data[4:8], 'little')
info['fw_build'] = data[8:20].decode('utf-8').rstrip('\x00').strip()
info['model'] = data[20:60].decode('utf-8').rstrip('\x00').strip()
info['ver'] = data[60:80].decode('utf-8').rstrip('\x00').strip()
if fw_ver >= 9 and len(data) >= 81:
info['client_repeat'] = data[80] != 0
if fw_ver >= 10 and len(data) >= 82:
info['path_hash_mode'] = data[81]
return info
```
**PACKET_BATTERY** (0x0C):
```
Byte 0: 0x0C
Bytes 1-2: Battery Voltage (16-bit little-endian, millivolts)
Bytes 3-6: Used Storage (32-bit little-endian, KB)
Bytes 7-10: Total Storage (32-bit little-endian, KB)
```
**Parsing Pseudocode**:
```python
def parse_battery(data):
if len(data) < 3:
return None
mv = int.from_bytes(data[1:3], 'little')
info = {'battery_mv': mv}
if len(data) >= 11:
info['used_kb'] = int.from_bytes(data[3:7], 'little')
info['total_kb'] = int.from_bytes(data[7:11], 'little')
return info
```
**PACKET_SELF_INFO** (0x05):
```
Byte 0: 0x05
Byte 1: Advertisement Type
Byte 2: TX Power
Byte 3: Max TX Power
Bytes 4-35: Public Key (32 bytes, hex)
Bytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)
Bytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)
Byte 44: Multi ACKs
Byte 45: Advertisement Location Policy
Byte 46: Telemetry Mode (bitfield)
Byte 47: Manual Add Contacts (bool)
Bytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)
Bytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)
Byte 56: Radio Spreading Factor
Byte 57: Radio Coding Rate
Bytes 58+: Device Name (UTF-8, variable length, no null terminator required)
```
**Parsing Pseudocode**:
```python
def parse_self_info(data):
if len(data) < 36:
return None
offset = 1
info = {
'adv_type': data[offset],
'tx_power': data[offset + 1],
'max_tx_power': data[offset + 2],
'public_key': data[offset + 3:offset + 35].hex()
}
offset += 35
lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6
lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6
info['adv_lat'] = lat
info['adv_lon'] = lon
offset += 8
info['multi_acks'] = data[offset]
info['adv_loc_policy'] = data[offset + 1]
telemetry_mode = data[offset + 2]
info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11
info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11
info['telemetry_mode_base'] = telemetry_mode & 0b11
info['manual_add_contacts'] = data[offset + 3] > 0
offset += 4
freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0
bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0
info['radio_freq'] = freq
info['radio_bw'] = bw
info['radio_sf'] = data[offset + 8]
info['radio_cr'] = data[offset + 9]
offset += 10
if offset < len(data):
name_bytes = data[offset:]
info['name'] = name_bytes.decode('utf-8').rstrip('\x00').strip()
return info
```
**PACKET_MSG_SENT** (0x06):
```
Byte 0: 0x06
Byte 1: Route Flag (0 = direct, 1 = flood)
Bytes 2-5: Tag / Expected ACK (4 bytes, little-endian)
Bytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)
```
**PACKET_SEND_CONFIRMED** (0x82):
```
Byte 0: 0x82
Bytes 1-4: ACK code (32-bit little-endian)
Bytes 5-8: Round-trip time (32-bit little-endian, milliseconds)
```
### Error Codes
`PACKET_ERROR` (0x01) carries a single-byte error code in byte 1. Values match the `ERR_CODE_*` constants defined in `examples/companion_radio/MyMesh.cpp`:
| Code | Constant (firmware) | Description |
|------|----------------------------|------------------------------------------------------------------------------|
| 1 | `ERR_CODE_UNSUPPORTED_CMD` | Unknown or unsupported command byte / sub-command |
| 2 | `ERR_CODE_NOT_FOUND` | Target not found (channel, contact, message, etc.) |
| 3 | `ERR_CODE_TABLE_FULL` | Internal queue or table is full - retry later |
| 4 | `ERR_CODE_BAD_STATE` | Operation not valid in current device state (e.g. iterator already running) |
| 5 | `ERR_CODE_FILE_IO_ERROR` | Filesystem or storage I/O failure |
| 6 | `ERR_CODE_ILLEGAL_ARG` | Invalid argument (bad length, out-of-range value, reserved field, etc.) |
**Note**: Error codes may vary by firmware version. Always check byte 1 of `PACKET_ERROR` response, and treat unknown codes as generic errors.
### Frame Handling
BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.
- Apps should treat each characteristic write/notification as exactly one companion protocol frame
- Apps should still validate frame lengths before parsing
- Future transports or firmware revisions may differ, so avoid assuming fixed payload sizes for variable-length responses
### Response Handling
1. **Command-Response Pattern**:
- Send command via RX characteristic
- Wait for response via TX characteristic (notification)
- Match the response by the expected response type; frames do not carry a
general command sequence number
- Handle timeout (typically 5 seconds)
- Use command queue to prevent concurrent commands
2. **Asynchronous Messages**:
- Device may send messages at any time via TX characteristic
- Handle `PACKET_MESSAGES_WAITING` (0x83) by polling `GET_MESSAGE` command
- Parse incoming messages and route to appropriate handlers
- Validate frame length before decoding
3. **Response Matching**:
- Match responses to commands by expected packet type:
- `APP_START` -> `PACKET_SELF_INFO`
- `DEVICE_QUERY` -> `PACKET_DEVICE_INFO`
- `GET_CHANNEL` -> `PACKET_CHANNEL_INFO`
- `SET_CHANNEL` -> `PACKET_OK` or `PACKET_ERROR`
- `SEND_CHANNEL_MESSAGE` -> `PACKET_MSG_SENT`
- `GET_MESSAGE` -> `PACKET_CHANNEL_MSG_RECV`, `PACKET_CONTACT_MSG_RECV`, `PACKET_CHANNEL_DATA_RECV`, or `PACKET_NO_MORE_MSGS`
- `SEND_CHANNEL_DATA` -> `PACKET_OK` or `PACKET_ERROR`
- `GET_BATTERY` -> `PACKET_BATTERY`
4. **Timeout Handling**:
- Default timeout: 5 seconds per command
- On timeout: Log error, clear current command, proceed to next in queue
- Some commands may take longer (e.g., `SET_CHANNEL` may need 1-2 seconds)
- Consider longer timeout for channel operations
5. **Error Recovery**:
- On `PACKET_ERROR`: Log error code, clear current command
- On connection loss: Clear command queue, attempt reconnection
- On invalid response: Log warning, clear current command, proceed
---
## Example Implementation Flow
### Initialization
```python
# 1. Scan for MeshCore device
device = scan_for_device("MeshCore")
# 2. Connect to BLE GATT
gatt = connect_to_device(device)
# 3. Discover services and characteristics
service = discover_service(gatt, "6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
rx_char = discover_characteristic(service, "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
tx_char = discover_characteristic(service, "6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
# 4. Enable notifications on TX characteristic
enable_notifications(tx_char, on_notification_received)
# 5. Send AppStart command
send_command(rx_char, build_app_start())
wait_for_response(PACKET_SELF_INFO)
```
### Creating a Private Channel
```python
# 1. Generate 16-byte secret
secret_16_bytes = generate_secret(16) # Use CSPRNG
secret_hex = secret_16_bytes.hex()
# 2. Build SET_CHANNEL command
channel_name = "YourChannelName"
channel_index = choose_unused_slot(max_channels)
command = build_set_channel(channel_index, channel_name, secret_16_bytes)
# 3. Send command
send_command(rx_char, command)
response = wait_for_response(PACKET_OK)
# 4. Store secret locally
store_channel_secret(channel_index, secret_hex)
```
### Sending a Message
```python
# 1. Build channel message command
channel_index = 1
message = "Hello, MeshCore!"
timestamp = int(time.time())
command = build_channel_message(channel_index, message, timestamp)
# 2. Send command
send_command(rx_char, command)
response = wait_for_response(PACKET_MSG_SENT)
```
### Receiving Messages
```python
def on_notification_received(data):
packet_type = data[0]
if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:
message = parse_channel_message(data)
handle_channel_message(message)
elif packet_type == PACKET_MESSAGES_WAITING:
# Poll for messages
send_command(rx_char, build_get_message())
```
---
## Best Practices
1. **Connection Management**:
- Implement auto-reconnect with exponential backoff
- Handle disconnections gracefully
- Store last connected device address for quick reconnection
2. **Secret Management**:
- Always use cryptographically secure random number generators
- Store secrets securely (encrypted storage)
- Never log or transmit secrets in plain text
3. **Message Handling**:
- Send `CMD_SYNC_NEXT_MESSAGE` when `PUSH_CODE_MSG_WAITING` is received
- Implement message deduplication to avoid displaying the same message twice
4. **Channel Management**:
- Fetch all channel slots even if you encounter an empty slot
- Ideally save new channels into the first empty slot
5. **Error Handling**:
- Implement timeouts for all commands (typically 5 seconds)
- Handle `RESP_CODE_ERR` responses appropriately
---
## Troubleshooting
### Connection Issues
- **Device not found**: Ensure device is powered on and advertising
- **Connection timeout**: Check Bluetooth permissions and device proximity
- **GATT errors**: Ensure proper service/characteristic discovery
### Command Issues
- **No response**: Verify notifications are enabled, check connection state
- **Error responses**: Verify command format and check error code
- **Timeout**: Increase timeout value or try again
### Message Issues
- **Messages not received**: Poll `GET_MESSAGE` command periodically
- **Duplicate messages**: Implement message deduplication using timestamp/content as a unique id
- **Message truncation**: Send long messages as separate shorter messages