@ -23,7 +23,7 @@
< link rel = "icon" href = "../assets/images/favicon.png" >
< meta name = "generator" content = "mkdocs-1.6.1, mkdocs-material-9.7.4 " >
< meta name = "generator" content = "mkdocs-1.6.1, mkdocs-material-9.7.5 " >
@ -545,10 +545,10 @@
< / li >
< li class = "md-nav__item" >
< a href = "#7-get-battery" class = "md-nav__link" >
< a href = "#7-get-battery-and-storage " class = "md-nav__link" >
< span class = "md-ellipsis" >
7. Get Battery
7. Get Battery and Storage
< / span >
< / a >
@ -706,10 +706,10 @@
< / li >
< li class = "md-nav__item" >
< a href = "#partial-packet -handling" class = "md-nav__link" >
< a href = "#frame -handling" class = "md-nav__link" >
< span class = "md-ellipsis" >
Partial Packet Handling
Frame Handling
< / span >
< / a >
@ -1337,10 +1337,10 @@
< / li >
< li class = "md-nav__item" >
< a href = "#7-get-battery" class = "md-nav__link" >
< a href = "#7-get-battery-and-storage " class = "md-nav__link" >
< span class = "md-ellipsis" >
7. Get Battery
7. Get Battery and Storage
< / span >
< / a >
@ -1498,10 +1498,10 @@
< / li >
< li class = "md-nav__item" >
< a href = "#partial-packet -handling" class = "md-nav__link" >
< a href = "#frame -handling" class = "md-nav__link" >
< span class = "md-ellipsis" >
Partial Packet Handling
Frame Handling
< / span >
< / a >
@ -1673,7 +1673,7 @@
< h1 id = "companion-protocol" > Companion Protocol< / h1 >
< ul >
< li > < strong > Last Updated< / strong > : 2026-01-03 < / li >
< li > < strong > Last Updated< / strong > : 2026-03-08 < / li >
< li > < strong > Protocol Version< / strong > : Companion Firmware v1.12.0+< / li >
< / ul >
< blockquote >
@ -1783,7 +1783,7 @@
< / ul >
< p > < strong > Recommendation< / strong > : Use write with response for reliability.< / p >
< h3 id = "mtu-maximum-transmission-unit" > MTU (Maximum Transmission Unit)< / h3 >
< p > The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like < code > SET_CHANNEL< / code > (66 bytes), you may need to:< / p >
< p > The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like < code > SET_CHANNEL< / code > (50 bytes), you may need to:< / p >
< ol >
< li > < strong > Request Larger MTU< / strong > : Request MTU of 512 bytes if supported< ul >
< li > Android: < code > gatt.requestMtu(512)< / code > < / li >
@ -1846,13 +1846,13 @@
< p > < strong > Purpose< / strong > : Initialize communication with the device. Must be sent first after connection.< / p >
< p > < strong > Command Format< / strong > :< / p >
< pre > < code > Byte 0: 0x01
Byte 1: 0x03
Bytes 2-10: " mccli" (ASCII, null-padded to 9 bytes )
Bytes 1-7: Reserved (currently ignored by firmware)
Bytes 8+: Application name (UTF-8, optional )
< / code > < / pre >
< p > < strong > Example< / strong > (hex):< / p >
< pre > < code > 01 03 6d 63 63 6c 69 00 00 00 00
< pre > < code > 01 00 00 00 00 00 00 00 6d 63 63 6c 69
< / code > < / pre >
< p > < strong > Response< / strong > : < code > PACKET_OK < / code > (0x00 )< / p >
< p > < strong > Response< / strong > : < code > PACKET_SELF_INF O< / code > (0x05 )< / p >
< hr / >
< h3 id = "2-device-query" > 2. Device Query< / h3 >
< p > < strong > Purpose< / strong > : Query device information.< / p >
@ -1875,7 +1875,6 @@ Byte 1: Channel Index (0-7)
< pre > < code > 1F 01
< / code > < / pre >
< p > < strong > Response< / strong > : < code > PACKET_CHANNEL_INFO< / code > (0x12) with channel details< / p >
< p > < strong > Note< / strong > : The device does not return channel secrets for security reasons. Store secrets locally when creating channels.< / p >
< hr / >
< h3 id = "4-set-channel" > 4. Set Channel< / h3 >
< p > < strong > Purpose< / strong > : Create or update a channel on the device.< / p >
@ -1883,9 +1882,9 @@ Byte 1: Channel Index (0-7)
< pre > < code > 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)
Bytes 34-49: Secret (16 bytes)
< / code > < / pre >
< p > < strong > Total Length< / strong > : 66 bytes< / p >
< p > < strong > Total Length< / strong > : 50 bytes< / p >
< p > < strong > Channel Index< / strong > :
- Index 0: Reserved for public channels (no secret)
- Indices 1-7: Available for private channels< / p >
@ -1893,13 +1892,14 @@ Bytes 34-65: Secret (32 bytes)
- UTF-8 encoded
- Maximum 32 bytes
- Padded with null bytes (0x00) if shorter< / p >
< p > < strong > Secret Field< / strong > (32 bytes):
- For < strong > private channels< / strong > : 32 -byte secret
< p > < strong > Secret Field< / strong > (16 bytes):
- For < strong > private channels< / strong > : 16 -byte secret
- For < strong > public channels< / strong > : All zeros (0x00)< / p >
< p > < strong > Example< / strong > (create channel "YourChannelName" at index 1 with secret):< / p >
< pre > < code > 20 01 53 4D 53 00 00 ... (name padded to 32 bytes)
[32 bytes of secret]
[16 bytes of secret]
< / code > < / pre >
< p > < strong > Note< / strong > : The 32-byte secret variant is unsupported and returns < code > PACKET_ERROR< / code > .< / p >
< p > < strong > Response< / strong > : < code > PACKET_OK< / code > (0x00) on success, < code > PACKET_ERROR< / code > (0x01) on failure< / p >
< hr / >
< h3 id = "5-send-channel-message" > 5. Send Channel Message< / h3 >
@ -1931,15 +1931,15 @@ Bytes 7+: Message Text (UTF-8, variable length)
- < code > PACKET_NO_MORE_MSGS< / code > (0x0A) if no messages available< / p >
< p > < strong > Note< / strong > : Poll this command periodically to retrieve queued messages. The device may also send < code > PACKET_MESSAGES_WAITING< / code > (0x83) as a notification when messages are available.< / p >
< hr / >
< h3 id = "7-get-battery" > 7. Get Battery< / h3 >
< p > < strong > Purpose< / strong > : Query device battery level .< / p >
< h3 id = "7-get-battery-and-storage " > 7. Get Battery and Storage < / h3 >
< p > < strong > Purpose< / strong > : Query device battery voltage and storage usage .< / p >
< p > < strong > Command Format< / strong > :< / p >
< pre > < code > Byte 0: 0x14
< / code > < / pre >
< p > < strong > Example< / strong > (hex):< / p >
< pre > < code > 14
< / code > < / pre >
< p > < strong > Response< / strong > : < code > PACKET_BATTERY< / code > (0x0C) with battery percentage < / p >
< p > < strong > Response< / strong > : < code > PACKET_BATTERY< / code > (0x0C) with battery millivolts and storage information < / p >
< hr / >
< h2 id = "channel-management" > Channel Management< / h2 >
< h3 id = "channel-types" > Channel Types< / h3 >
@ -1970,7 +1970,7 @@ Bytes 7+: Message Text (UTF-8, variable length)
< li > < strong > Set Channel< / strong > :< ul >
< li > Fetch all channel slots, and find one with empty name and all-zero secret< / li >
< li > Generate or provide a 16-byte secret< / li >
< li > Send < code > CMD_SET_CHANNEL< / code > with name and secret< / li >
< li > Send < code > CMD_SET_CHANNEL< / code > with name and a 16-byte secret< / li >
< / ul >
< / li >
< li > < strong > Get Channel< / strong > :< ul >
@ -1987,7 +1987,7 @@ Bytes 7+: Message Text (UTF-8, variable length)
< hr / >
< h2 id = "message-handling" > Message Handling< / h2 >
< h3 id = "receiving-messages" > Receiving Messages< / h3 >
< p > Messages are received via the R X characteristic (notifications). The device sends:< / p >
< p > Messages are received via the T X characteristic (notifications). The device sends:< / p >
< ol >
< li > < strong > Channel Messages< / strong > :< / li >
< li > < code > PACKET_CHANNEL_MSG_RECV< / code > (0x08) - Standard format< / li >
@ -2239,9 +2239,9 @@ Byte 1: Error code (optional)
< pre > < code > 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 )
Bytes 34-49: Secret (16 bytes )
< / code > < / pre >
< p > < strong > Note< / strong > : The device may not return the full 66-byte packet. Parse what is available. The secret field is typically not returned for security reasons .< / p >
< p > < strong > Note< / strong > : The device returns the 16-byte channel secret in this response .< / p >
< p > < strong > PACKET_DEVICE_INFO< / strong > (0x0D):< / p >
< pre > < code > Byte 0: 0x0D
Byte 1: Firmware Version (uint8)
@ -2254,6 +2254,8 @@ 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)
Byte 80: Client repeat enabled/preferred (firmware v9+)
Byte 81: Path hash mode (firmware v10+)
< / code > < / pre >
< p > < strong > Parsing Pseudocode< / strong > :< / p >
< pre > < code class = "language-python" > def parse_device_info(data):
@ -2275,9 +2277,7 @@ Bytes 60-79: Version (20 bytes, UTF-8, null-padded)
< / code > < / pre >
< p > < strong > PACKET_BATTERY< / strong > (0x0C):< / p >
< pre > < code > Byte 0: 0x0C
Bytes 1-2: Battery Level (16-bit little-endian, percentage 0-100)
Optional (if data size > 3):
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)
< / code > < / pre >
@ -2286,14 +2286,12 @@ Bytes 7-10: Total Storage (32-bit little-endian, KB)
if len(data) < 3:
return None
level = int.from_bytes(data[1:3], 'little')
info = {'level': level }
mv = int.from_bytes(data[1:3], 'little')
info = {'battery_mv': mv }
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
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
< / code > < / pre >
@ -2313,7 +2311,7 @@ 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-terminat ed)
Bytes 58+: Device Name (UTF-8, variable length, no null terminator requir ed)
< / code > < / pre >
< p > < strong > Parsing Pseudocode< / strong > :< / p >
< pre > < code class = "language-python" > def parse_self_info(data):
@ -2360,9 +2358,9 @@ Bytes 58+: Device Name (UTF-8, variable length, null-terminated)
< / code > < / pre >
< p > < strong > PACKET_MSG_SENT< / strong > (0x06):< / p >
< pre > < code > 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)
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, milli seconds)
< / code > < / pre >
< p > < strong > PACKET_ACK< / strong > (0x82):< / p >
< pre > < code > Byte 0: 0x82
@ -2421,70 +2419,18 @@ Bytes 1-6: ACK Code (6 bytes, hex)
< / tbody >
< / table >
< p > < strong > Note< / strong > : Error codes may vary by firmware version. Always check byte 1 of < code > PACKET_ERROR< / code > response.< / p >
< h3 id = "partial-packet-handling" > Partial Packet Handling< / h3 >
< p > BLE notifications may arrive in chunks, especially for larger packets. Implement buffering:< / p >
< p > < strong > Implementation< / strong > :< / p >
< pre > < code class = "language-python" > 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
< / code > < / pre >
< p > < strong > Usage< / strong > :< / p >
< pre > < code class = "language-python" > buffer = PacketBuffer()
def on_notification_received(data):
packet = buffer.add_data(data)
if packet:
parse_and_handle_packet(packet)
< / code > < / pre >
< h3 id = "frame-handling" > Frame Handling< / h3 >
< p > BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.< / p >
< ul >
< li > Apps should treat each characteristic write/notification as exactly one companion protocol frame< / li >
< li > Apps should still validate frame lengths before parsing< / li >
< li > Future transports or firmware revisions may differ, so avoid assuming fixed payload sizes for variable-length responses< / li >
< / ul >
< h3 id = "response-handling" > Response Handling< / h3 >
< ol >
< li > < strong > Command-Response Pattern< / strong > :< / li >
< li > Send command via T X characteristic< / li >
< li > Wait for response via R X characteristic (notification)< / li >
< li > Send command via RX characteristic< / li >
< li > Wait for response via TX characteristic (notification)< / li >
< li > Match response to command using sequence numbers or command type< / li >
< li > Handle timeout (typically 5 seconds)< / li >
< li >
@ -2493,11 +2439,11 @@ def on_notification_received(data):
< li >
< p > < strong > Asynchronous Messages< / strong > :< / p >
< / li >
< li > Device may send messages at any time via R X characteristic< / li >
< li > Device may send messages at any time via T X characteristic< / li >
< li > Handle < code > PACKET_MESSAGES_WAITING< / code > (0x83) by polling < code > GET_MESSAGE< / code > command< / li >
< li > Parse incoming messages and route to appropriate handlers< / li >
< li >
< p > Buffer partial packets until complete < / p >
< p > Validate frame length before decoding < / p >
< / li >
< li >
< p > < strong > Response Matching< / strong > :< / p >
@ -2505,7 +2451,7 @@ def on_notification_received(data):
< li >
< p > Match responses to commands by expected packet type:< / p >
< ul >
< li > < code > APP_START< / code > → < code > PACKET_OK < / code > < / li >
< li > < code > APP_START< / code > → < code > PACKET_SELF_INF O< / code > < / li >
< li > < code > DEVICE_QUERY< / code > → < code > PACKET_DEVICE_INFO< / code > < / li >
< li > < code > GET_CHANNEL< / code > → < code > PACKET_CHANNEL_INFO< / code > < / li >
< li > < code > SET_CHANNEL< / code > → < code > PACKET_OK< / code > or < code > PACKET_ERROR< / code > < / li >
@ -2540,37 +2486,32 @@ device = scan_for_device("MeshCore")
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 " )
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 R X characteristic
enable_notifications(r x_char, on_notification_received)
# 4. Enable notifications on T X characteristic
enable_notifications(t x_char, on_notification_received)
# 5. Send AppStart command
send_command(t x_char, build_app_start())
wait_for_response(PACKET_OK )
send_command(r x_char, build_app_start())
wait_for_response(PACKET_SELF_INF O)
< / code > < / pre >
< h3 id = "creating-a-private-channel" > Creating a Private Channel< / h3 >
< pre > < code class = "language-python" > # 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
# 2. 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)
command = build_set_channel(channel_index, channel_name, secret_16_bytes)
# 4 . Send command
send_command(t x_char, command)
# 3. Send command
send_command(r x_char, command)
response = wait_for_response(PACKET_OK)
# 5. Store secret locally (device won't return it)
# 4. Store secret locally
store_channel_secret(channel_index, secret_hex)
< / code > < / pre >
< h3 id = "sending-a-message" > Sending a Message< / h3 >
@ -2581,7 +2522,7 @@ timestamp = int(time.time())
command = build_channel_message(channel_index, message, timestamp)
# 2. Send command
send_command(t x_char, command)
send_command(r x_char, command)
response = wait_for_response(PACKET_MSG_SENT)
< / code > < / pre >
< h3 id = "receiving-messages_1" > Receiving Messages< / h3 >
@ -2593,7 +2534,7 @@ response = wait_for_response(PACKET_MSG_SENT)
handle_channel_message(message)
elif packet_type == PACKET_MESSAGES_WAITING:
# Poll for messages
send_command(t x_char, build_get_message())
send_command(r x_char, build_get_message())
< / code > < / pre >
< hr / >
< h2 id = "best-practices" > Best Practices< / h2 >