Files
HaloKeymind/docs/companion_protocol.md
T

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.

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
  2. Packet Structure
  3. Commands
  4. Channel Management
  5. Message Handling
  6. Response Parsing
  7. Example Implementation Flow
  8. Best Practices
  9. 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

  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 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).


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_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 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.

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) 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:

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 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.

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

  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

# 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

  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