43 KiB
Companion Protocol
- Last Updated: 2026-08-18
- Protocol Version: 13 (
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
- Python: 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
- BLE Connection
- Packet Structure
- Commands
- Channel Management
- Message Handling
- Response Parsing
- Example Implementation Flow
- Best Practices
- 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
Connection Steps
-
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
-
Connect to GATT
- Connect to the device using the discovered MAC address
- Wait for connection to be established
-
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
- Discover the service with UUID
-
Enable Notifications
- Subscribe to notifications on the TX characteristic to receive data from the firmware
-
Send Initial Commands
- Send
CMD_APP_STARTto identify your app to firmware and get radio settings - Send
CMD_DEVICE_QUERYto fetch device info and negotiate supported protocol versions - Send
CMD_SET_DEVICE_TIMEto set the firmware clock - Send
CMD_GET_CONTACTSto fetch all contacts - Send
CMD_GET_CHANNELmultiple times to fetch all channel slots - Send
CMD_SYNC_NEXT_MESSAGEto fetch the next message stored in firmware - Setup listeners for push codes, such as
PUSH_CODE_MSG_WAITINGorPUSH_CODE_ADVERT - See Commands section for information on other commands
- Send
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_DEFAULTorWRITE_TYPE_NO_RESPONSE - iOS: Use
CBCharacteristicWriteType.withResponseor.withoutResponse - Python (bleak): Use
write_gatt_char()withresponse=TrueorFalse
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:
- Request Larger MTU: Request MTU of 512 bytes if supported
- Android:
gatt.requestMtu(512) - iOS:
peripheral.maximumWriteValueLength(for:) - Python (bleak): MTU is negotiated automatically
- Android:
Command Sequencing
Critical: Commands must be sent in the correct sequence:
-
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
-
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).
Commands
The first byte selects the command. This is the current protocol-v13 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 / 0x43 |
CMD_GET_RADIO_FEM_RXGAIN / CMD_SET_RADIO_FEM_RXGAIN |
Read or set FEM receive gain. |
0x44 / 0x45 |
CMD_GET_RADIO_RXGAIN / CMD_SET_RADIO_RXGAIN |
Read or set the radio chip's boosted receive-gain mode. |
0x46 / 0x47 |
CMD_GET_WIFI_POWER_SAVE / CMD_SET_WIFI_POWER_SAVE |
Read or set ESP32 Companion WiFi modem sleep. |
The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here.
The additive hardware-setting commands 0x42-0x47 do not change any existing
version-13 frame layout. Clients should probe the command they need and treat
ERR_CODE_UNSUPPORTED_CMD as feature absence.
Both gain-command pairs can be used over the normal binary Companion
connection; USB does not need to enter terminal mode. Inside the transport's
normal Companion frame, send a GET command as its single command byte. Send a
SET command followed by one byte (0 for off, 1 for on). A GET reply is
RESP_CODE_OK followed by the saved state; a successful SET reply is
RESP_CODE_OK. Invalid states return ERR_CODE_ILLEGAL_ARG. A temporarily
busy radio returns ERR_CODE_BAD_STATE; unsupported boosted gain returns
ERR_CODE_UNSUPPORTED_CMD. The FEM pair also returns
ERR_CODE_UNSUPPORTED_CMD when the board cannot control its external LNA. Raw
text such as set radio.rxgain off still requires the USB terminal start token.
The WiFi power-save pair is available on ESP32 WiFi Companion builds over the
normal binary USB, BLE, or TCP port 5000 transport. CMD_GET_WIFI_POWER_SAVE
has no body and replies with RESP_CODE_OK followed by one mode byte.
CMD_SET_WIFI_POWER_SAVE is followed by that mode byte and replies with
RESP_CODE_OK after saving it:
| Value | Mode |
|---|---|
0 |
min - minimum modem sleep |
1 |
none - no modem sleep |
2 |
max - maximum modem sleep |
An out-of-range value returns ERR_CODE_ILLEGAL_ARG. A non-WiFi Companion
returns ERR_CODE_UNSUPPORTED_CMD. Full Companion has Bluetooth enabled and
therefore returns ERR_CODE_BAD_STATE for none, because ESP32 WiFi/Bluetooth
coexistence requires modem sleep. A storage failure also returns
ERR_CODE_BAD_STATE. If an already-saved mode cannot be applied to the active
WiFi driver, SET still returns RESP_CODE_OK and the mode is applied on the
next connection. Device powersaving does not overwrite this setting.
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 0D
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_channelsfrom byte 3 ofPACKET_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 withPACKET_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 table below.
Limits:
- Maximum payload length is
MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167bytes. - 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) - unknownchannel_idxERR_CODE_ILLEGAL_ARG(6) - invalidpath_len, reserveddata_type(0x0000), or payload larger thanMAX_CHANNEL_DATA_LENGTHERR_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.
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) |
| 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. 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:
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) orPACKET_CHANNEL_MSG_RECV_V3(0x11) for channel messagesPACKET_CONTACT_MSG_RECV(0x07) orPACKET_CONTACT_MSG_RECV_V3(0x10) for contact messagesPACKET_CHANNEL_DATA_RECV(0x1B) for channel data datagramsPACKET_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
- 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
- Uses a publicly known 16-byte key:
- Hashtag Channels
- Uses a secret key derived from the channel name
- It is the first 16 bytes of
sha256("#test") - For example hashtag channel
#testhas 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
- 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
- Set Channel:
- Read
max_channelsfrom 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_CHANNELwith name and a 16-byte secret
- Read
- Get Channel:
- Send
CMD_GET_CHANNELwith channel index - Parse
RESP_CODE_CHANNEL_INFOresponse
- Send
- Delete Channel:
- Send
CMD_SET_CHANNELwith empty name and all-zero secret - Or overwrite with a new channel
- Send
Message Handling
Receiving Messages
Messages are received via the TX characteristic (notifications). The device sends:
-
Channel Messages:
PACKET_CHANNEL_MSG_RECV(0x08) - Standard formatPACKET_CHANNEL_MSG_RECV_V3(0x11) - Version 3 with SNR
-
Contact Messages:
PACKET_CONTACT_MSG_RECV(0x07) - Standard formatPACKET_CONTACT_MSG_RECV_V3(0x10) - Version 3 with SNR
-
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:
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:
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).
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 is160 - prefix_bytesand 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. |
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:
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:
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:
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
-
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
-
Asynchronous Messages:
- Device may send messages at any time via TX characteristic
- Handle
PACKET_MESSAGES_WAITING(0x83) by pollingGET_MESSAGEcommand - Parse incoming messages and route to appropriate handlers
- Validate frame length before decoding
-
Response Matching:
- Match responses to commands by expected packet type:
APP_START->PACKET_SELF_INFODEVICE_QUERY->PACKET_DEVICE_INFOGET_CHANNEL->PACKET_CHANNEL_INFOSET_CHANNEL->PACKET_OKorPACKET_ERRORSEND_CHANNEL_MESSAGE->PACKET_MSG_SENTGET_MESSAGE->PACKET_CHANNEL_MSG_RECV,PACKET_CONTACT_MSG_RECV,PACKET_CHANNEL_DATA_RECV, orPACKET_NO_MORE_MSGSSEND_CHANNEL_DATA->PACKET_OKorPACKET_ERRORGET_BATTERY->PACKET_BATTERY
- Match responses to commands by expected packet type:
-
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_CHANNELmay need 1-2 seconds) - Consider longer timeout for channel operations
-
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
- On
Example Implementation Flow
Initialization
# 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
# 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
# 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
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
-
Connection Management:
- Implement auto-reconnect with exponential backoff
- Handle disconnections gracefully
- Store last connected device address for quick reconnection
-
Secret Management:
- Always use cryptographically secure random number generators
- Store secrets securely (encrypted storage)
- Never log or transmit secrets in plain text
-
Message Handling:
- Send
CMD_SYNC_NEXT_MESSAGEwhenPUSH_CODE_MSG_WAITINGis received - Implement message deduplication to avoid displaying the same message twice
- Send
-
Channel Management:
- Fetch all channel slots even if you encounter an empty slot
- Ideally save new channels into the first empty slot
-
Error Handling:
- Implement timeouts for all commands (typically 5 seconds)
- Handle
RESP_CODE_ERRresponses 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_MESSAGEcommand periodically - Duplicate messages: Implement message deduplication using timestamp/content as a unique id
- Message truncation: Send long messages as separate shorter messages