MeshCore Device Communication Protocol Guide
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.
⚠️ Important Security Note
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
Table of Contents
- BLE Connection
- Protocol Overview
- Commands
- Channel Management
- Secret Generation and QR Codes
- Message Handling
- Response Parsing
- Example Implementation Flow
BLE Connection
Service and Characteristics
MeshCore devices expose a BLE service with the following UUIDs:
- Service UUID:
0000ff00-0000-1000-8000-00805f9b34fb - RX Characteristic (Device → Client):
0000ff01-0000-1000-8000-00805f9b34fb - TX Characteristic (Client → Device):
0000ff02-0000-1000-8000-00805f9b34fb
Connection Steps
- 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 -
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
Connection State Management
- 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.
BLE Write Type
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_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, especially for critical commands like SET_CHANNEL.
MTU (Maximum Transmission Unit)
The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like SET_CHANNEL (66 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
-
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
Command Sequencing and Timing
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.,
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
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)
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 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
Protocol Overview
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
Packet Structure
Most packets follow this format:
[Packet Type (1 byte)] [Data (variable length)]
The first byte indicates the packet type (see Response Parsing).
Commands
1. App Start
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)
2. Device Query
Purpose: Query device information.
Command Format:
Byte 0: 0x16
Byte 1: 0x03
Example (hex):
16 03
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-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.
4. Set Channel
Purpose: Create or update a channel on the device.
Command Format:
Byte 0: 0x20
Byte 1: Channel Index (0-7)
Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)
Bytes 34-65: Secret (32 bytes, see [Secret Generation](#secret-generation))
Total Length: 66 bytes
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 (32 bytes): - For private channels: 32-byte secret (see Secret Generation) - For public channels: All zeros (0x00)
Example (create channel "YourChannelName" at index 1 with secret):
20 01 53 4D 53 00 00 ... (name padded to 32 bytes)
[32 bytes of secret]
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-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
6. 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_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.
7. Get Battery
Purpose: Query device battery level.
Command Format:
Byte 0: 0x14
Example (hex):
14
Response: PACKET_BATTERY (0x0C) with battery percentage
Channel Management
Channel Types
- 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
- Secret is expanded to 32 bytes using SHA-512 (see Secret Generation)
- Only devices with the secret can access the channel
Channel Lifecycle
- Create Channel:
- Choose an available index (1-7 for private channels)
- Generate or provide a 16-byte secret
- Send
SET_CHANNELcommand with name and secret -
Store the secret locally (device does not return it)
-
Query Channel:
- Send
GET_CHANNELcommand with channel index - Parse
PACKET_CHANNEL_INFOresponse -
Note: Secret will be null in response (security feature)
-
Delete Channel:
- Send
SET_CHANNELcommand with empty name and all-zero secret - Or overwrite with a new channel
Channel Index Management
- 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
Secret Generation and QR Codes
Secret Generation
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 characters
Important: Use a cryptographically secure random number generator (CSPRNG). Do not use predictable values.
Secret Expansion
When sending the secret to the device via SET_CHANNEL, the 16-byte secret must be expanded to 32 bytes:
Process:
1. Take the 16-byte secret
2. Compute SHA-512 hash: hash = SHA-512(secret)
3. Use the first 32 bytes of the hash as the secret field in the command
Pseudocode:
import hashlib
secret_16_bytes = ... # Your 16-byte secret
sha512_hash = hashlib.sha512(secret_16_bytes).digest() # 64 bytes
secret_32_bytes = sha512_hash[:32] # First 32 bytes
This matches MeshCore's ED25519 key expansion method.
QR Code Format
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.
QR Code Generation
Steps:
1. Generate or use existing 16-byte secret
2. Convert to 32-character hex string (lowercase)
3. URL-encode the channel name
4. Construct the meshcore:// URL
5. 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")
QR Code Scanning
When scanning a QR code:
- Parse URL Format:
- Extract
nameandsecretquery parameters -
Validate secret is 32 hex characters
-
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
SET_CHANNELcommand
Message Handling
Receiving Messages
Messages are received via the RX characteristic (notifications). The device sends:
- Channel Messages:
PACKET_CHANNEL_MSG_RECV(0x08) - Standard format-
PACKET_CHANNEL_MSG_RECV_V3(0x11) - Version 3 with SNR -
Contact Messages:
PACKET_CONTACT_MSG_RECV(0x07) - Standard format-
PACKET_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-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
}
Sending Messages
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")
Response Parsing
Packet Types
| 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) |
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-65: Secret (32 bytes, but device typically only returns 20 bytes total)
Note: The device may not return the full 66-byte packet. Parse what is available. The secret field is typically not returned for security reasons.
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 info
PACKET_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 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, 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 info
PACKET_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)
Error Codes
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.
Partial Packet Handling
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
pass
Usage:
buffer = PacketBuffer()
def on_notification_received(data):
packet = buffer.add_data(data)
if packet:
parse_and_handle_packet(packet)
Response Handling
- 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_INFOGET_CHANNEL→PACKET_CHANNEL_INFOSET_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
-
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
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, "0000ff00-0000-1000-8000-00805f9b34fb")
rx_char = discover_characteristic(service, "0000ff01-0000-1000-8000-00805f9b34fb")
tx_char = discover_characteristic(service, "0000ff02-0000-1000-8000-00805f9b34fb")
# 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)
Creating a Private Channel
# 1. Generate 16-byte secret
secret_16_bytes = generate_secret(16) # Use CSPRNG
secret_hex = secret_16_bytes.hex()
# 2. Expand secret to 32 bytes using SHA-512
import hashlib
sha512_hash = hashlib.sha512(secret_16_bytes).digest()
secret_32_bytes = sha512_hash[:32]
# 3. Build SET_CHANNEL command
channel_name = "YourChannelName"
channel_index = 1 # Use 1-7 for private channels
command = build_set_channel(channel_index, channel_name, secret_32_bytes)
# 4. Send command
send_command(tx_char, command)
response = wait_for_response(PACKET_OK)
# 5. Store secret locally (device won't return it)
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(tx_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(tx_char, build_get_message())
QR Code Sharing
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")
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
-
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
-
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
Platform-Specific Notes
Android
- Use
BluetoothGattAPI - Request
BLUETOOTH_CONNECTandBLUETOOTH_SCANpermissions (Android 12+) - Enable notifications by writing to descriptor
0x2902with value0x01or0x02
iOS
- Use
CoreBluetoothframework - Implement
CBPeripheralDelegatefor notifications - Request Bluetooth permissions in Info.plist
Python
- Use
bleaklibrary for cross-platform BLE support - Handle async/await for BLE operations
- Use
asynciofor command-response patterns
JavaScript/Node.js
- Use
nobleor@abandonware/noblefor BLE - Handle callbacks or promises for async operations
- Use
Bufferfor binary data manipulation
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, check channel index validity
- Timeout: Increase timeout value or check device responsiveness
Message Issues
- Messages not received: Poll
GET_MESSAGEcommand periodically - Duplicate messages: Implement message deduplication using timestamps/hashes
- Message truncation: Split long messages into chunks
Secret/Channel Issues
- Secret not working: Verify secret expansion (SHA-512) is correct
- Channel not found: Query channels after connection to discover existing channels
- Channel index 0: Migrate to index 1-7 for private channels
References
- MeshCore Python implementation:
meshcore_py-main/src/meshcore/ - BLE GATT Specification: Bluetooth SIG Core Specification
- ED25519 Key Expansion: RFC 8032
Last Updated: 2025-01-01 Protocol Version: Based on MeshCore v1.36.0+