This document provides a comprehensive guide for communicating with MeshCore devices over Bluetooth Low Energy (BLE). It is platform-agnostic and can be used for Android, iOS, Python, JavaScript, or any other platform that supports BLE.
All secrets, hashes, and cryptographic values shown in this guide are EXAMPLE VALUES ONLY and are NOT real secrets.
- The secret
9b647d242d6e1c5883fde0c5cf5c4c5eused in examples is a made-up example value - All hex values, public keys, and hashes in examples are for demonstration purposes only
- Never use example secrets in production - always generate new cryptographically secure random secrets
- This guide is for protocol documentation only - implement proper security practices in your actual implementation
- Complete Command Reference
- Response Codes
- Push Codes
- Error Codes
- BLE Connection
- Protocol Overview
- Commands (Detailed)
- Channel Management
- Secret Generation and QR Codes
- Message Handling
- Response Parsing
- Example Implementation Flow
Source: MyMesh.cpp
| Code | Name | Description |
|---|---|---|
0x01 |
CMD_APP_START |
Must be first command. Initializes BLE communication. Send app name (9 bytes). Returns RESP_CODE_SELF_INFO with device public key, name, and settings. |
0x02 |
CMD_SEND_TXT_MSG |
Send a text message to a specific contact. Include recipient public key prefix (6 bytes) + message text. Returns RESP_CODE_SENT with expected ACK. |
0x03 |
CMD_SEND_CHANNEL_TXT_MSG |
Send a text message to a channel. Include channel index (1 byte) + timestamp (4 bytes) + message text. Broadcasts to all nodes on that channel. |
0x0A |
CMD_SYNC_NEXT_MESSAGE |
Retrieve the next queued incoming message. Returns RESP_CODE_CONTACT_MSG_RECV, RESP_CODE_CHANNEL_MSG_RECV, or RESP_CODE_NO_MORE_MESSAGES. Poll this regularly. |
0x19 |
CMD_SEND_RAW_DATA |
Send raw binary data to a contact. Used for non-text payloads like files or custom protocols. |
0x32 |
CMD_SEND_BINARY_REQ |
Send a binary request packet. For advanced binary communication protocols. |
| Code | Name | Description |
|---|---|---|
0x04 |
CMD_GET_CONTACTS |
Get the contact list. Optional 4-byte 'since' timestamp for incremental sync - only returns contacts modified after that time. |
0x09 |
CMD_ADD_UPDATE_CONTACT |
Add a new contact or update existing. Include public key (32 bytes), type, flags, path, name (32 bytes), and optional GPS coords. |
0x0F |
CMD_REMOVE_CONTACT |
Remove a contact by public key prefix (6 bytes). Deletes from device storage. |
0x10 |
CMD_SHARE_CONTACT |
Share a contact's info with another node. Broadcasts the contact's public key and name. |
0x11 |
CMD_EXPORT_CONTACT |
Export full contact data for backup. Returns RESP_CODE_EXPORT_CONTACT with serialized contact. |
0x12 |
CMD_IMPORT_CONTACT |
Import a previously exported contact. Restores contact to device. |
0x1E |
CMD_GET_CONTACT_BY_KEY |
Look up a contact by their public key (32 bytes). Returns contact info if found. |
| Code | Name | Description |
|---|---|---|
0x1F |
CMD_GET_CHANNEL |
Get channel configuration. Send channel index (0-7). Returns RESP_CODE_CHANNEL_INFO with name and secret (16 bytes). |
0x20 |
CMD_SET_CHANNEL |
Create or update a channel. 50 bytes total: index (1) + name (32) + secret (16). Use index 0 for public, 1-7 for private. |
| Code | Name | Description |
|---|---|---|
0x05 |
CMD_GET_DEVICE_TIME |
Get device's current Unix timestamp. Returns RESP_CODE_CURR_TIME with 4-byte timestamp. Useful for keepalive. |
0x06 |
CMD_SET_DEVICE_TIME |
Set device's time. Send 4-byte Unix timestamp. Syncs device clock with phone. |
0x14 |
CMD_GET_BATT_AND_STORAGE |
Get battery percentage (0-100) and storage usage (used KB / total KB). Good for status monitoring. |
0x16 |
CMD_DEVICE_QUERY |
Query device info. Returns firmware version, max contacts/channels, BLE PIN, model name, and version string. |
0x38 |
CMD_GET_STATS |
Get device statistics. Second byte specifies type: 0=core, 1=radio, 2=packets. (Firmware v8+) |
| Code | Name | Description |
|---|---|---|
0x0B |
CMD_SET_RADIO_PARAMS |
Set LoRa radio parameters: frequency (MHz), bandwidth (kHz), spreading factor (7-12), coding rate (5-8). |
0x0C |
CMD_SET_RADIO_TX_POWER |
Set radio transmit power in dBm. Higher = more range but more battery usage. Max depends on hardware. |
0x2B |
CMD_GET_TUNING_PARAMS |
Get current radio tuning parameters. Returns frequency, bandwidth, SF, CR, and power settings. |
0x15 |
CMD_SET_TUNING_PARAMS |
Set advanced tuning parameters for radio optimization. |
| Code | Name | Description |
|---|---|---|
0x07 |
CMD_SEND_SELF_ADVERT |
Broadcast your presence to nearby nodes. Sends your name, public key, and optional GPS. Other nodes can discover you. |
0x08 |
CMD_SET_ADVERT_NAME |
Set the name broadcasted in advertisements (max 32 chars). This is how others see you on the mesh. |
0x0E |
CMD_SET_ADVERT_LATLON |
Set GPS coordinates for advertisement. Lat/lon as 4-byte integers (divide by 1e6 for degrees). |
0x2A |
CMD_GET_ADVERT_PATH |
Get the routing path from a received advertisement. Shows hop count and intermediate nodes. |
0x34 |
CMD_SEND_PATH_DISCOVERY_REQ |
Actively discover routing path to a contact. Returns path info via PUSH_CODE_PATH_DISCOVERY_RESPONSE. |
| Code | Name | Description |
|---|---|---|
0x0D |
CMD_RESET_PATH |
Clear cached routing path to a contact. Forces re-discovery on next message. Use if routing seems broken. |
0x1A |
CMD_SEND_LOGIN |
Send login/connection request to a contact. Establishes a "session" for real-time messaging. |
0x1B |
CMD_SEND_STATUS_REQ |
Request online status from a contact. They reply with PUSH_CODE_STATUS_RESPONSE. |
0x1C |
CMD_HAS_CONNECTION |
Check if active connection exists to a contact. Returns boolean. |
0x1D |
CMD_LOGOUT |
Disconnect from a contact's session. Ends real-time messaging session. |
0x24 |
CMD_SEND_TRACE_PATH |
Trace the routing path to a contact. Returns detailed hop-by-hop path via PUSH_CODE_TRACE_DATA. |
0x36 |
CMD_SET_FLOOD_SCOPE |
Set flood broadcast scope/range. Controls how far flood messages propagate. (Firmware v8+) |
| Code | Name | Description |
|---|---|---|
0x17 |
CMD_EXPORT_PRIVATE_KEY |
Export device's ED25519 private key (64 bytes). DANGER: Backup only, keep secure! |
0x18 |
CMD_IMPORT_PRIVATE_KEY |
Import a private key. Replaces device identity. Used for restoring backups. |
0x21 |
CMD_SIGN_START |
Begin a signing operation. Prepares to sign up to 8KB of data. |
0x22 |
CMD_SIGN_DATA |
Send a chunk of data to sign. Can be called multiple times for large data. |
0x23 |
CMD_SIGN_FINISH |
Complete signing. Returns RESP_CODE_SIGNATURE with the ED25519 signature. |
0x25 |
CMD_SET_DEVICE_PIN |
Set the BLE pairing PIN (6 digits). Used for secure Bluetooth pairing. |
| Code | Name | Description |
|---|---|---|
0x13 |
CMD_REBOOT |
Reboot the device immediately. Connection will be lost. |
0x26 |
CMD_SET_OTHER_PARAMS |
Set miscellaneous device parameters. Content varies by firmware version. |
0x27 |
CMD_SEND_TELEMETRY_REQ |
Request telemetry data. Deprecated - use newer methods. |
0x28 |
CMD_GET_CUSTOM_VARS |
Get custom user-defined variables stored on device. |
0x29 |
CMD_SET_CUSTOM_VAR |
Set a custom variable. Key-value storage for app-specific data. |
0x33 |
CMD_FACTORY_RESET |
DANGER: Factory reset device. Erases all contacts, channels, and settings. |
0x37 |
CMD_SEND_CONTROL_DATA |
Send control/command data. For device control protocols. (Firmware v8+) |
| Code | Name | Description |
|---|---|---|
0x00 |
STATS_TYPE_CORE |
Core statistics |
0x01 |
STATS_TYPE_RADIO |
Radio statistics |
0x02 |
STATS_TYPE_PACKETS |
Packet statistics |
| Code | Name | Description |
|---|---|---|
0x00 |
RESP_CODE_OK |
Command succeeded. May include optional 4-byte value depending on command. |
0x01 |
RESP_CODE_ERR |
Command failed. Byte 1 contains error code (see Error Codes below). |
0x02 |
RESP_CODE_CONTACTS_START |
Start of contacts list. Followed by multiple RESP_CODE_CONTACT packets. |
0x03 |
RESP_CODE_CONTACT |
Single contact entry. Contains public key, name, type, flags, path, GPS, timestamps. |
0x04 |
RESP_CODE_END_OF_CONTACTS |
End of contacts list. Includes most recent lastmod timestamp for sync. |
0x05 |
RESP_CODE_SELF_INFO |
Device self-info. Contains public key (32 bytes), name, TX power, radio params, GPS. |
0x06 |
RESP_CODE_SENT |
Message queued for sending. Contains message type, expected ACK (4 bytes), timeout. |
0x07 |
RESP_CODE_CONTACT_MSG_RECV |
Incoming contact message (legacy). Contains sender key prefix, path, timestamp, text. |
0x08 |
RESP_CODE_CHANNEL_MSG_RECV |
Incoming channel message (legacy). Contains channel index, path, timestamp, text. |
0x09 |
RESP_CODE_CURR_TIME |
Device's current Unix timestamp (4 bytes, little-endian). |
0x0A |
RESP_CODE_NO_MORE_MESSAGES |
No more messages in queue. Stop polling until PUSH_CODE_MSG_WAITING. |
0x0B |
RESP_CODE_EXPORT_CONTACT |
Exported contact data for backup/sharing. Full serialized contact. |
0x0C |
RESP_CODE_BATT_AND_STORAGE |
Battery % (2 bytes) + used KB (4 bytes) + total KB (4 bytes). |
0x0D |
RESP_CODE_DEVICE_INFO |
Firmware version, max contacts/channels, BLE PIN, model, version string. |
0x0E |
RESP_CODE_PRIVATE_KEY |
Device's ED25519 private key (64 bytes). Handle with extreme care! |
0x0F |
RESP_CODE_DISABLED |
Requested feature is disabled on this device. |
0x10 |
RESP_CODE_CONTACT_MSG_RECV_V3 |
Contact message v3. Adds SNR (signal-to-noise ratio) for signal quality. |
0x11 |
RESP_CODE_CHANNEL_MSG_RECV_V3 |
Channel message v3. Adds SNR byte at offset 1 (divide by 4 for dB). |
0x12 |
RESP_CODE_CHANNEL_INFO |
Channel config: index (1) + name (32) + secret (16) = 50 bytes total. |
0x13 |
RESP_CODE_SIGN_START |
Signing operation started. Ready to receive data chunks. |
0x14 |
RESP_CODE_SIGNATURE |
ED25519 signature (64 bytes) of the signed data. |
0x15 |
RESP_CODE_CUSTOM_VARS |
Custom variables stored on device. Key-value pairs. |
0x16 |
RESP_CODE_ADVERT_PATH |
Routing path from advertisement. Shows hops to reach sender. |
0x17 |
RESP_CODE_TUNING_PARAMS |
Current radio tuning: frequency, bandwidth, SF, CR, power. |
0x18 |
RESP_CODE_STATS |
Device statistics. Type in byte 1: 0=core, 1=radio, 2=packets. (v8+) |
These are pushed from device to client at any time (asynchronous notifications). Your app must handle these even when not expecting them:
| Code | Name | Description |
|---|---|---|
0x80 |
PUSH_CODE_ADVERT |
Another node's advertisement received. Contains their public key, name, and signal info. |
0x81 |
PUSH_CODE_PATH_UPDATED |
Routing path to a contact has changed. May indicate better/worse route found. |
0x82 |
PUSH_CODE_SEND_CONFIRMED |
Your message was ACKed by recipient. Contains the ACK code (6 bytes) matching sent message. |
0x83 |
PUSH_CODE_MSG_WAITING |
Important! New messages are queued. Call CMD_SYNC_NEXT_MESSAGE to retrieve them. |
0x84 |
PUSH_CODE_RAW_DATA |
Raw binary data received from a contact. For custom protocols. |
0x85 |
PUSH_CODE_LOGIN_SUCCESS |
Login/connection to a contact succeeded. Session established for real-time chat. |
0x86 |
PUSH_CODE_LOGIN_FAIL |
Login/connection to a contact failed. Target may be offline or rejected. |
0x87 |
PUSH_CODE_STATUS_RESPONSE |
Contact replied to your status request. Contains their online/offline status. |
0x88 |
PUSH_CODE_LOG_RX_DATA |
Radio RX log data. For debugging - shows raw received packets. Can ignore. |
0x89 |
PUSH_CODE_TRACE_DATA |
Trace path results. Shows hop-by-hop route with signal quality at each hop. |
0x8A |
PUSH_CODE_NEW_ADVERT |
A new (previously unknown) node advertised. Different from 0x80 which includes known nodes. |
0x8B |
PUSH_CODE_TELEMETRY_RESPONSE |
Telemetry data from a contact. May include GPS, battery, sensor readings. |
0x8C |
PUSH_CODE_BINARY_RESPONSE |
Response to a binary request. Contains the binary payload. |
0x8D |
PUSH_CODE_PATH_DISCOVERY_RESPONSE |
Path discovery completed. Contains discovered route to target contact. |
0x8E |
PUSH_CODE_CONTROL_DATA |
Control/command data received. For device control protocols. (v8+) |
Returned in byte 1 of RESP_CODE_ERR (0x01):
| Code | Name | Description |
|---|---|---|
0x01 |
ERR_CODE_UNSUPPORTED_CMD |
Command not recognized or not supported by this firmware version. Check command code. |
0x02 |
ERR_CODE_NOT_FOUND |
Requested resource not found. Contact doesn't exist, channel not configured, etc. |
0x03 |
ERR_CODE_TABLE_FULL |
Storage table is full. Delete contacts/channels before adding more. Check max limits. |
0x04 |
ERR_CODE_BAD_STATE |
Command not valid in current state. E.g., signing without starting, or not connected. |
0x05 |
ERR_CODE_FILE_IO_ERROR |
File system error. Storage may be corrupted. Try factory reset as last resort. |
0x06 |
ERR_CODE_ILLEGAL_ARG |
Invalid argument. Wrong size, out of range, or malformed data. Check command format. |
From MeshCore firmware:
| Constant | Value | Description |
|---|---|---|
SEND_TIMEOUT_BASE_MILLIS |
500ms | Base send timeout |
FLOOD_SEND_TIMEOUT_FACTOR |
16.0 | Flood timeout multiplier |
DIRECT_SEND_PERHOP_FACTOR |
6.0 | Per-hop timeout factor |
DIRECT_SEND_PERHOP_EXTRA_MILLIS |
250ms | Extra per-hop delay |
LAZY_CONTACTS_WRITE_DELAY |
5000ms | Contact save delay |
MAX_SIGN_DATA_LEN |
8KB | Max signing data |
The default public channel PSK (Base64):
izOH6cXN6mrJ5e26oRXNcg==
This decodes to 16 bytes and is used for public/unencrypted channels.
MeshCore devices use the Nordic UART Service (NUS) with the following UUIDs:
- Service UUID:
6e400001-b5a3-f393-e0a9-e50e24dcca9e - TX Characteristic (Client → Device, Write):
6e400002-b5a3-f393-e0a9-e50e24dcca9e - RX Characteristic (Device → Client, Notify):
6e400003-b5a3-f393-e0a9-e50e24dcca9e
Note: TX/RX naming is from the device's perspective. You WRITE to TX (6e400002) and receive NOTIFICATIONS from RX (6e400003).
-
Scan for Devices
- Scan for BLE devices advertising the MeshCore service UUID
- Filter by device name (typically contains "MeshCore" or similar)
- 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
0000ff00-0000-1000-8000-00805f9b34fb - Discover RX characteristic (
0000ff01-...) for receiving data - Discover TX characteristic (
0000ff02-...) for sending commands
- Discover the service with UUID
-
Enable Notifications
- Subscribe to notifications on the RX characteristic
- Enable notifications/indications to receive data from the device
- On some platforms, you may need to write to a descriptor (e.g.,
0x2902) with value0x01or0x02
-
Send AppStart Command
- Send the app start command (see Commands) to initialize communication
- Wait for OK response before sending other commands
- Disconnected: No connection established
- Connecting: Connection attempt in progress
- Connected: GATT connection established, ready for commands
- Error: Connection failed or lost
Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff.
When writing commands to the TX 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, especially for critical commands like CMD_SET_CHANNEL.
The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like CMD_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:
-
Handle Chunking: If MTU is small, commands may be split automatically by the BLE stack
- Ensure all chunks are sent before waiting for response
- Responses may also arrive in chunks - buffer until complete
Critical: Commands must be sent in the correct sequence:
-
After Connection:
- Wait for GATT connection established
- Wait for services/characteristics discovered
- Wait for notifications enabled (descriptor write complete)
- Wait 200-1000ms for device to be ready (some devices need initialization time)
- Send
APP_STARTcommand - Wait for
PACKET_OKresponse before sending any other commands
-
Command-Response Matching:
- Send one command at a time
- Wait for response before sending next command
- Use timeout (typically 5 seconds)
- Match response to command by:
- Command type (e.g.,
CMD_GET_CHANNEL→PACKET_CHANNEL_INFO) - Sequence number (if implemented)
- First-in-first-out queue
- Command type (e.g.,
-
Timing Considerations:
- Minimum delay between commands: 50-100ms
- After
APP_START: Wait 200-500ms before next command - After
CMD_SET_CHANNEL: Wait 500-1000ms for channel to be created - After enabling notifications: Wait 200ms before sending commands
Example Flow:
# 1. Connect and discover
await connect_to_device(device)
await discover_services()
await enable_notifications()
await asyncio.sleep(0.2) # Wait for device ready
# 2. Send AppStart
send_command(build_app_start())
response = await wait_for_response(PACKET_OK, timeout=5.0)
if response.type != PACKET_OK:
raise Exception("AppStart failed")
# 3. Now safe to send other commands
await asyncio.sleep(0.1) # Small delay between commands
send_command(build_device_query())
response = await wait_for_response(PACKET_DEVICE_INFO, timeout=5.0)For reliable operation, implement a command queue:
-
Queue Structure:
- Maintain a queue of pending commands
- Track which command is currently waiting for response
- Only send next command after receiving response or timeout
-
Implementation:
class CommandQueue:
def __init__(self):
self.queue = []
self.waiting_for_response = False
self.current_command = None
async def send_command(self, command, expected_response_type, timeout=5.0):
if self.waiting_for_response:
# Queue the command
self.queue.append((command, expected_response_type, timeout))
return
self.waiting_for_response = True
self.current_command = (command, expected_response_type, timeout)
# Send command
await write_to_tx_characteristic(command)
# Wait for response
response = await wait_for_response(expected_response_type, timeout)
self.waiting_for_response = False
self.current_command = None
# Process next queued command
if self.queue:
next_cmd, next_type, next_timeout = self.queue.pop(0)
await self.send_command(next_cmd, next_type, next_timeout)
return response- Error Handling:
- On timeout: Clear current command, process next in queue
- On error: Log error, clear current command, process next
- Don't block queue on single command failure
The MeshCore protocol uses a binary format with the following structure:
- Commands: Sent from client to device via TX characteristic
- Responses: Received from device via RX characteristic (notifications)
- All multi-byte integers: Little-endian byte order
- 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).
Purpose: Initialize communication with the device. Must be sent first after connection.
Command Format:
Byte 0: 0x01
Byte 1: 0x03
Bytes 2-10: "mccli" (ASCII, null-padded to 9 bytes)
Example (hex):
01 03 6d 63 63 6c 69 00 00 00 00
Response: PACKET_OK (0x00)
Purpose: Query device information.
Command Format:
Byte 0: 0x16
Byte 1: 0x03
Example (hex):
16 03
Response: PACKET_DEVICE_INFO (0x0D) with device information
Purpose: Retrieve information about a specific channel.
Command Format:
Byte 0: 0x1F
Byte 1: Channel Index (0-7)
Example (get channel 1):
1F 01
Response: PACKET_CHANNEL_INFO (0x12) with channel details
Note: The device does not return channel secrets for security reasons. Store secrets locally when creating channels.
Purpose: Create or update a channel on the device.
CRITICAL from MyMesh.cpp lines 1416-1429:
- 66-byte commands are REJECTED with
ERR_CODE_UNSUPPORTED_CMD! - 50-byte commands are ACCEPTED.
Command Format (50 bytes total):
Byte 0: 0x20 (CMD_SET_CHANNEL)
Byte 1: Channel Index (0-7)
Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)
Bytes 34-49: Secret (16 bytes)
Total Length: 50 bytes (NOT 66!)
Channel Index:
- Index 0: Reserved for public channels (no secret)
- Indices 1-7: Available for private channels
Channel Name:
- UTF-8 encoded
- Maximum 32 bytes
- Padded with null bytes (0x00) if shorter
Secret Field (16 bytes):
- For private channels: 16-byte PSK (Pre-Shared Key)
- For public channels: All zeros (0x00)
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]
Response: RESP_CODE_OK (0x00) on success, RESP_CODE_ERR (0x01) on failure
Purpose: Send a text message to a channel.
Command Format:
Byte 0: 0x03
Byte 1: 0x00
Byte 2: Channel Index (0-7)
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
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_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.
Purpose: Query device battery level.
Command Format:
Byte 0: 0x14
Example (hex):
14
Response: PACKET_BATTERY (0x0C) with battery percentage
-
Public Channels (Index 0)
- No secret required
- Anyone with the channel name can join
- Use for open communication
-
Private Channels (Indices 1-7)
- Require a 16-byte secret (PSK)
- Secret field in CMD_SET_CHANNEL is exactly 16 bytes (NOT 32!)
- Only devices with the secret can access the channel
-
Create Channel:
- Choose an available index (1-7 for private channels)
- Generate or provide a 16-byte secret
- Send
CMD_SET_CHANNELcommand with name and secret - Store the secret locally (device does not return it)
-
Query Channel:
- Send
CMD_GET_CHANNELcommand with channel index - Parse
PACKET_CHANNEL_INFOresponse - Note: Secret will be null in response (security feature)
- Send
-
Delete Channel:
- Send
CMD_SET_CHANNELcommand with empty name and all-zero secret - Or overwrite with a new channel
- Send
- Index 0: Reserved for public channels
- Indices 1-7: Available for private channels
- If a channel exists at index 0 but should be private, migrate it to index 1-7
For private channels, generate a cryptographically secure 16-byte secret:
Pseudocode:
import secrets
# Generate 16 random bytes
secret_bytes = secrets.token_bytes(16)
# Convert to hex string for storage/sharing
secret_hex = secret_bytes.hex() # 32 hex charactersImportant: Use a cryptographically secure random number generator (CSPRNG). Do not use predictable values.
CRITICAL: From MyMesh.cpp lines 1418-1429, CMD_SET_CHANNEL only accepts 16-byte secrets:
- 66-byte commands (with 32-byte secret) are REJECTED with
ERR_CODE_UNSUPPORTED_CMD - 50-byte commands (with 16-byte secret) are ACCEPTED
Process:
- Generate or provide a 16-byte secret (PSK)
- Send as-is in the 16-byte secret field of
CMD_SET_CHANNEL
Pseudocode:
import secrets
secret_16_bytes = secrets.token_bytes(16) # 16 bytes, NOT 32!
# Use directly in CMD_SET_CHANNEL commandNote: NO padding needed! The firmware expects exactly 16 bytes.
This matches MeshCore's PUBLIC_GROUP_PSK = "izOH6cXN6mrJ5e26oRXNcg==" (Base64 = 16 bytes).
To remove a channel: Set name to empty string and secret to all zeros (16 zero bytes).
MeshCore uses a non-standard ED25519 key format for device identity:
- A 32-byte seed is fed into SHA-512 to generate 64 bytes
- MeshCore stores the 64-byte result as
(a, RH)- the private scalar and signing component - The original 32-byte seed is discarded and not recoverable
- This 64-byte key can sign messages and derive the 32-byte public key
Important: This format is NOT compatible with standard ED25519 libraries (like PyNaCl) which expect either the 32-byte seed or the 64-byte (seed + public key) format.
See: https://blog.mozilla.org/warner/2011/11/29/ed25519-keys/ for the key derivation diagram.
Channel PSKs are different - they use raw 16 bytes with no SHA-512 expansion.
QR codes for sharing channel secrets use the following format:
URL Scheme:
meshcore://channel/add?name=<ChannelName>&secret=<32HexChars>
Parameters:
name: Channel name (URL-encoded if needed)secret: 32-character hexadecimal representation of the 16-byte secret
Example (using example secret - NOT a real secret):
meshcore://channel/add?name=YourChannelName&secret=9b647d242d6e1c5883fde0c5cf5c4c5e
Alternative Formats (for backward compatibility):
- JSON Format:
{
"name": "YourChannelName",
"secret": "9b647d242d6e1c5883fde0c5cf5c4c5e"
}Note: The secret value above is an example only - generate your own secure random secret.
- Plain Hex (32 hex characters):
9b647d242d6e1c5883fde0c5cf5c4c5e
Note: This is an example hex value - always generate your own cryptographically secure random secret.
Steps:
- Generate or use existing 16-byte secret
- Convert to 32-character hex string (lowercase)
- URL-encode the channel name
- Construct the
meshcore://URL - Generate QR code from the URL string
Example (Python with qrcode library):
import qrcode
from urllib.parse import quote
import secrets
channel_name = "YourChannelName"
# Generate a real cryptographically secure secret (NOT the example value)
secret_bytes = secrets.token_bytes(16)
secret_hex = secret_bytes.hex() # This will be a different value each time
# Example value shown in documentation: "9b647d242d6e1c5883fde0c5cf5c4c5e"
# DO NOT use the example value - always generate your own!
url = f"meshcore://channel/add?name={quote(channel_name)}&secret={secret_hex}"
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save("channel_qr.png")When scanning a QR code:
-
Parse URL Format:
- Extract
nameandsecretquery parameters - Validate secret is 32 hex characters
- Extract
-
Parse JSON Format:
- Parse JSON object
- Extract
nameandsecretfields
-
Parse Plain Hex:
- Extract only hex characters (0-9, a-f, A-F)
- Validate length is 32 characters
- Convert to lowercase
-
Validate Secret:
- Must be exactly 32 hex characters (16 bytes)
- Convert hex string to bytes
-
Create Channel:
- Use extracted name and secret
- Send
CMD_SET_CHANNELcommand
Messages are received via the RX 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
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
}Standard Format (PACKET_CHANNEL_MSG_RECV, 0x08):
Byte 0: 0x08 (packet type)
Byte 1: Channel Index (0-7)
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-7)
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
}Use the SEND_CHANNEL_MESSAGE command (see Commands).
Important:
- Messages are limited to 133 characters per MeshCore specification
- Long messages should be split into chunks
- Include a chunk indicator (e.g., "[1/3] message text")
| Value | Name | Description |
|---|---|---|
| 0x00 | PACKET_OK | Command succeeded |
| 0x01 | PACKET_ERROR | Command failed |
| 0x02 | PACKET_CONTACT_START | Start of contact list |
| 0x03 | PACKET_CONTACT | Contact information |
| 0x04 | PACKET_CONTACT_END | End of contact list |
| 0x05 | PACKET_SELF_INFO | Device self-information |
| 0x06 | PACKET_MSG_SENT | Message sent confirmation |
| 0x07 | PACKET_CONTACT_MSG_RECV | Contact message (standard) |
| 0x08 | PACKET_CHANNEL_MSG_RECV | Channel message (standard) |
| 0x09 | PACKET_CURRENT_TIME | Current time response |
| 0x0A | PACKET_NO_MORE_MSGS | No more messages available |
| 0x0C | PACKET_BATTERY | Battery level |
| 0x0D | PACKET_DEVICE_INFO | Device information |
| 0x10 | PACKET_CONTACT_MSG_RECV_V3 | Contact message (V3 with SNR) |
| 0x11 | PACKET_CHANNEL_MSG_RECV_V3 | Channel message (V3 with SNR) |
| 0x12 | PACKET_CHANNEL_INFO | Channel information |
| 0x80 | PACKET_ADVERTISEMENT | Advertisement packet |
| 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
| 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) |
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)
RESP_CODE_CHANNEL_INFO (0x12):
Byte 0: 0x12 (RESP_CODE_CHANNEL_INFO)
Byte 1: Channel Index
Bytes 2-33: Channel Name (32 bytes, null-terminated)
Bytes 34-49: Secret (16 bytes)
Total Length: 50 bytes
Note from MyMesh.cpp lines 1405-1412: The device returns the full 50-byte packet including the 16-byte secret. This is different from some documentation that claims the secret is omitted.
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: BLE PIN (32-bit little-endian)
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)
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()
return infoPACKET_BATTERY (0x0C):
Byte 0: 0x0C
Bytes 1-2: Battery Level (16-bit little-endian, percentage 0-100)
Optional (if data size > 3):
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
level = int.from_bytes(data[1:3], 'little')
info = {'level': level}
if len(data) > 3:
used_kb = int.from_bytes(data[3:7], 'little')
total_kb = int.from_bytes(data[7:11], 'little')
info['used_kb'] = used_kb
info['total_kb'] = total_kb
return infoPACKET_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, null-terminated)
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 infoPACKET_MSG_SENT (0x06):
Byte 0: 0x06
Byte 1: Message Type
Bytes 2-5: Expected ACK (4 bytes, hex)
Bytes 6-9: Suggested Timeout (32-bit little-endian, seconds)
PACKET_ACK (0x82):
Byte 0: 0x82
Bytes 1-6: ACK Code (6 bytes, hex)
PACKET_ERROR (0x01) may include an error code in byte 1:
| Error Code | Description |
|---|---|
| 0x00 | Generic error (no specific code) |
| 0x01 | Invalid command |
| 0x02 | Invalid parameter |
| 0x03 | Channel not found |
| 0x04 | Channel already exists |
| 0x05 | Channel index out of range |
| 0x06 | Secret mismatch |
| 0x07 | Message too long |
| 0x08 | Device busy |
| 0x09 | Not enough storage |
Note: Error codes may vary by firmware version. Always check byte 1 of PACKET_ERROR response.
BLE notifications may arrive in chunks, especially for larger packets. Implement buffering:
Implementation:
class PacketBuffer:
def __init__(self):
self.buffer = bytearray()
self.expected_length = None
def add_data(self, data):
self.buffer.extend(data)
# Check if we have a complete packet
if len(self.buffer) >= 1:
packet_type = self.buffer[0]
# Determine expected length based on packet type
expected = self.get_expected_length(packet_type)
if expected is not None and len(self.buffer) >= expected:
# Complete packet
packet = bytes(self.buffer[:expected])
self.buffer = self.buffer[expected:]
return packet
elif expected is None:
# Variable length packet - try to parse what we have
# Some packets have minimum length requirements
if self.can_parse_partial(packet_type):
return self.try_parse_partial()
return None # Incomplete packet
def get_expected_length(self, packet_type):
# Fixed-length packets
fixed_lengths = {
0x00: 5, # PACKET_OK (minimum)
0x01: 2, # PACKET_ERROR (minimum)
0x0A: 1, # PACKET_NO_MORE_MSGS
0x14: 3, # PACKET_BATTERY (minimum)
}
return fixed_lengths.get(packet_type)
def can_parse_partial(self, packet_type):
# Some packets can be parsed partially
return packet_type in [0x12, 0x08, 0x11, 0x07, 0x10, 0x05, 0x0D]
def try_parse_partial(self):
# Try to parse with available data
# Return packet if successfully parsed, None otherwise
# This is packet-type specific
passUsage:
buffer = PacketBuffer()
def on_notification_received(data):
packet = buffer.add_data(data)
if packet:
parse_and_handle_packet(packet)-
Command-Response Pattern:
- Send command via TX characteristic
- Wait for response via RX characteristic (notification)
- Match response to command using sequence numbers or command type
- Handle timeout (typically 5 seconds)
- Use command queue to prevent concurrent commands
-
Asynchronous Messages:
- Device may send messages at any time via RX characteristic
- Handle
PACKET_MESSAGES_WAITING(0x83) by pollingGET_MESSAGEcommand - Parse incoming messages and route to appropriate handlers
- Buffer partial packets until complete
-
Response Matching:
- Match responses to commands by expected packet type:
APP_START→PACKET_OKDEVICE_QUERY→PACKET_DEVICE_INFOCMD_GET_CHANNEL→PACKET_CHANNEL_INFOCMD_SET_CHANNEL→PACKET_OKorPACKET_ERRORSEND_CHANNEL_MESSAGE→PACKET_MSG_SENTGET_MESSAGE→PACKET_CHANNEL_MSG_RECV,PACKET_CONTACT_MSG_RECV, orPACKET_NO_MORE_MSGSGET_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.,
CMD_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
# 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 (Nordic UART Service)
service = discover_service(gatt, "6e400001-b5a3-f393-e0a9-e50e24dcca9e")
tx_char = discover_characteristic(service, "6e400002-b5a3-f393-e0a9-e50e24dcca9e") # Write
rx_char = discover_characteristic(service, "6e400003-b5a3-f393-e0a9-e50e24dcca9e") # Notify
# 4. Enable notifications on RX characteristic
enable_notifications(rx_char, on_notification_received)
# 5. Send AppStart command
send_command(tx_char, build_app_start())
wait_for_response(PACKET_OK)# 1. Generate 16-byte secret (NOT 32!)
secret_16_bytes = generate_secret(16) # Use CSPRNG
secret_hex = secret_16_bytes.hex() # 32 hex chars for storage
# 2. Build CMD_SET_CHANNEL command (50 bytes total)
# IMPORTANT: Secret field is exactly 16 bytes, NOT 32!
channel_name = "YourChannelName"
channel_index = 1 # Use 1-7 for private channels
command = build_set_channel(channel_index, channel_name, secret_16_bytes)
# 3. Send command
send_command(tx_char, command)
response = wait_for_response(RESP_CODE_OK)
# 4. Store secret locally for later use
store_channel_secret(channel_index, secret_hex)# 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(tx_char, command)
response = wait_for_response(PACKET_MSG_SENT)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(tx_char, build_get_message())import secrets
from urllib.parse import quote
# 1. Generate QR code data
channel_name = "YourChannelName"
# Generate a real secret (NOT the example value from documentation)
secret_bytes = secrets.token_bytes(16)
secret_hex = secret_bytes.hex()
# Example value in documentation: "9b647d242d6e1c5883fde0c5cf5c4c5e"
# DO NOT use example values - always generate your own secure random secrets!
url = f"meshcore://channel/add?name={quote(channel_name)}&secret={secret_hex}"
# 2. Generate QR code image
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 3. Display or save QR code
img.save("channel_qr.png")-
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
- Device does not return secrets - you must store them locally
-
Message Handling:
- Poll
GET_MESSAGEperiodically or whenPACKET_MESSAGES_WAITINGis received - Handle message chunking for long messages (>133 characters)
- Implement message deduplication to avoid processing the same message twice
- Poll
-
Error Handling:
- Implement timeouts for all commands (typically 5 seconds)
- Handle
PACKET_ERRORresponses appropriately - Log errors for debugging but don't expose sensitive information
-
Channel Management:
- Avoid using channel index 0 for private channels
- Migrate channels from index 0 to 1-7 if needed
- Query channels after connection to discover existing channels
- Use
BluetoothGattAPI - Request
BLUETOOTH_CONNECTandBLUETOOTH_SCANpermissions (Android 12+) - Enable notifications by writing to descriptor
0x2902with value0x01or0x02
- Use
CoreBluetoothframework - Implement
CBPeripheralDelegatefor notifications - Request Bluetooth permissions in Info.plist
- Use
bleaklibrary for cross-platform BLE support - Handle async/await for BLE operations
- Use
asynciofor command-response patterns
- Use
nobleor@abandonware/noblefor BLE - Handle callbacks or promises for async operations
- Use
Bufferfor binary data manipulation
- 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
- No response: Verify notifications are enabled, check connection state
- Error responses: Verify command format, check channel index validity
- Timeout: Increase timeout value or check device responsiveness
- Messages not received: Poll
GET_MESSAGEcommand periodically - Duplicate messages: Implement message deduplication using timestamps/hashes
- Message truncation: Split long messages into chunks
- Secret not working: Verify secret field is exactly 16 bytes (NOT 32!)
- CMD_SET_CHANNEL rejected: Ensure command is 50 bytes total (66-byte commands are rejected!)
- Channel not found: Query channels after connection to discover existing channels
- Channel index 0: Migrate to index 1-7 for private channels
- MeshCore Python implementation:
meshcore_py-main/src/meshcore/ - BLE GATT Specification: Bluetooth SIG Core Specification
- ED25519 Key Expansion: RFC 8032
Last Updated: January 2026
Protocol Version: Based on MeshCore v1.36.0+