11# Companion Protocol
22
3- - ** Last Updated** : 2026-01-03
3+ - ** Last Updated** : 2026-03-08
44- ** Protocol Version** : Companion Firmware v1.12.0+
55
66> NOTE: This document is still in development. Some information may be inaccurate.
@@ -100,7 +100,7 @@ When writing commands to the RX characteristic, specify the write type:
100100
101101### MTU (Maximum Transmission Unit)
102102
103- The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like ` SET_CHANNEL ` (66 bytes), you may need to:
103+ The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like ` SET_CHANNEL ` (50 bytes), you may need to:
104104
1051051 . ** Request Larger MTU** : Request MTU of 512 bytes if supported
106106 - Android: ` gatt.requestMtu(512) `
@@ -167,16 +167,16 @@ The first byte indicates the packet type (see [Response Parsing](#response-parsi
167167** Command Format** :
168168```
169169Byte 0: 0x01
170- Byte 1: 0x03
171- Bytes 2-10: "mccli" (ASCII, null-padded to 9 bytes )
170+ Bytes 1-7: Reserved (currently ignored by firmware)
171+ Bytes 8+: Application name (UTF-8, optional )
172172```
173173
174174** Example** (hex):
175175```
176- 01 03 6d 63 63 6c 69 00 00 00 00
176+ 01 00 00 00 00 00 00 00 6d 63 63 6c 69
177177```
178178
179- ** Response** : ` PACKET_OK ` (0x00 )
179+ ** Response** : ` PACKET_SELF_INFO ` (0x05 )
180180
181181---
182182
@@ -216,8 +216,6 @@ Byte 1: Channel Index (0-7)
216216
217217** Response** : ` PACKET_CHANNEL_INFO ` (0x12) with channel details
218218
219- ** Note** : The device does not return channel secrets for security reasons. Store secrets locally when creating channels.
220-
221219---
222220
223221### 4. Set Channel
@@ -229,10 +227,10 @@ Byte 1: Channel Index (0-7)
229227Byte 0: 0x20
230228Byte 1: Channel Index (0-7)
231229Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)
232- Bytes 34-65 : Secret (32 bytes)
230+ Bytes 34-49 : Secret (16 bytes)
233231```
234232
235- ** Total Length** : 66 bytes
233+ ** Total Length** : 50 bytes
236234
237235** Channel Index** :
238236- Index 0: Reserved for public channels (no secret)
@@ -243,16 +241,18 @@ Bytes 34-65: Secret (32 bytes)
243241- Maximum 32 bytes
244242- Padded with null bytes (0x00) if shorter
245243
246- ** Secret Field** (32 bytes):
247- - For ** private channels** : 32 -byte secret
244+ ** Secret Field** (16 bytes):
245+ - For ** private channels** : 16 -byte secret
248246- For ** public channels** : All zeros (0x00)
249247
250248** Example** (create channel "YourChannelName" at index 1 with secret):
251249```
25225020 01 53 4D 53 00 00 ... (name padded to 32 bytes)
253- [32 bytes of secret]
251+ [16 bytes of secret]
254252```
255253
254+ ** Note** : The 32-byte secret variant is unsupported and returns ` PACKET_ERROR ` .
255+
256256** Response** : ` PACKET_OK ` (0x00) on success, ` PACKET_ERROR ` (0x01) on failure
257257
258258---
@@ -304,9 +304,9 @@ Byte 0: 0x0A
304304
305305---
306306
307- ### 7. Get Battery
307+ ### 7. Get Battery and Storage
308308
309- ** Purpose** : Query device battery level .
309+ ** Purpose** : Query device battery voltage and storage usage .
310310
311311** Command Format** :
312312```
@@ -318,7 +318,7 @@ Byte 0: 0x14
31831814
319319```
320320
321- ** Response** : ` PACKET_BATTERY ` (0x0C) with battery percentage
321+ ** Response** : ` PACKET_BATTERY ` (0x0C) with battery millivolts and storage information
322322
323323---
324324
@@ -346,7 +346,7 @@ Byte 0: 0x14
3463461 . ** Set Channel** :
347347 - Fetch all channel slots, and find one with empty name and all-zero secret
348348 - Generate or provide a 16-byte secret
349- - Send ` CMD_SET_CHANNEL ` with name and secret
349+ - Send ` CMD_SET_CHANNEL ` with name and a 16-byte secret
3503502 . ** Get Channel** :
351351 - Send ` CMD_GET_CHANNEL ` with channel index
352352 - Parse ` RESP_CODE_CHANNEL_INFO ` response
@@ -360,7 +360,7 @@ Byte 0: 0x14
360360
361361### Receiving Messages
362362
363- Messages are received via the RX characteristic (notifications). The device sends:
363+ Messages are received via the TX characteristic (notifications). The device sends:
364364
3653651 . ** Channel Messages** :
366366 - ` PACKET_CHANNEL_MSG_RECV ` (0x08) - Standard format
@@ -544,10 +544,10 @@ Byte 1: Error code (optional)
544544Byte 0: 0x12
545545Byte 1: Channel Index
546546Bytes 2-33: Channel Name (32 bytes, null-terminated)
547- Bytes 34-65 : Secret (32 bytes, but device typically only returns 20 bytes total )
547+ Bytes 34-49 : Secret (16 bytes)
548548```
549549
550- ** 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 .
550+ ** Note** : The device returns the 16 -byte channel secret in this response .
551551
552552** PACKET_DEVICE_INFO** (0x0D):
553553```
@@ -562,6 +562,8 @@ Bytes 4-7: BLE PIN (32-bit little-endian)
562562Bytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)
563563Bytes 20-59: Model (40 bytes, UTF-8, null-padded)
564564Bytes 60-79: Version (20 bytes, UTF-8, null-padded)
565+ Byte 80: Client repeat enabled/preferred (firmware v9+)
566+ Byte 81: Path hash mode (firmware v10+)
565567```
566568
567569** Parsing Pseudocode** :
@@ -587,9 +589,7 @@ def parse_device_info(data):
587589** PACKET_BATTERY** (0x0C):
588590```
589591Byte 0: 0x0C
590- Bytes 1-2: Battery Level (16-bit little-endian, percentage 0-100)
591-
592- Optional (if data size > 3):
592+ Bytes 1-2: Battery Voltage (16-bit little-endian, millivolts)
593593Bytes 3-6: Used Storage (32-bit little-endian, KB)
594594Bytes 7-10: Total Storage (32-bit little-endian, KB)
595595```
@@ -600,14 +600,12 @@ def parse_battery(data):
600600 if len (data) < 3 :
601601 return None
602602
603- level = int .from_bytes(data[1 :3 ], ' little' )
604- info = {' level ' : level }
603+ mv = int .from_bytes(data[1 :3 ], ' little' )
604+ info = {' battery_mv ' : mv }
605605
606- if len (data) > 3 :
607- used_kb = int .from_bytes(data[3 :7 ], ' little' )
608- total_kb = int .from_bytes(data[7 :11 ], ' little' )
609- info[' used_kb' ] = used_kb
610- info[' total_kb' ] = total_kb
606+ if len (data) >= 11 :
607+ info[' used_kb' ] = int .from_bytes(data[3 :7 ], ' little' )
608+ info[' total_kb' ] = int .from_bytes(data[7 :11 ], ' little' )
611609
612610 return info
613611```
@@ -629,7 +627,7 @@ Bytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)
629627Bytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)
630628Byte 56: Radio Spreading Factor
631629Byte 57: Radio Coding Rate
632- Bytes 58+: Device Name (UTF-8, variable length, null-terminated )
630+ Bytes 58+: Device Name (UTF-8, variable length, no null terminator required )
633631```
634632
635633** Parsing Pseudocode** :
@@ -680,9 +678,9 @@ def parse_self_info(data):
680678** PACKET_MSG_SENT** (0x06):
681679```
682680Byte 0: 0x06
683- Byte 1: Message Type
684- Bytes 2-5: Expected ACK (4 bytes, hex )
685- Bytes 6-9: Suggested Timeout (32-bit little-endian, seconds )
681+ Byte 1: Route Flag (0 = direct, 1 = flood)
682+ Bytes 2-5: Tag / Expected ACK (4 bytes, little-endian )
683+ Bytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds )
686684```
687685
688686** PACKET_ACK** (0x82):
@@ -710,89 +708,32 @@ Bytes 1-6: ACK Code (6 bytes, hex)
710708
711709** Note** : Error codes may vary by firmware version. Always check byte 1 of ` PACKET_ERROR ` response.
712710
713- ### Partial Packet Handling
714-
715- BLE notifications may arrive in chunks, especially for larger packets. Implement buffering:
716-
717- ** Implementation** :
718- ``` python
719- class PacketBuffer :
720- def __init__ (self ):
721- self .buffer = bytearray ()
722- self .expected_length = None
723-
724- def add_data (self , data ):
725- self .buffer.extend(data)
726-
727- # Check if we have a complete packet
728- if len (self .buffer) >= 1 :
729- packet_type = self .buffer[0 ]
730-
731- # Determine expected length based on packet type
732- expected = self .get_expected_length(packet_type)
733-
734- if expected is not None and len (self .buffer) >= expected:
735- # Complete packet
736- packet = bytes (self .buffer[:expected])
737- self .buffer = self .buffer[expected:]
738- return packet
739- elif expected is None :
740- # Variable length packet - try to parse what we have
741- # Some packets have minimum length requirements
742- if self .can_parse_partial(packet_type):
743- return self .try_parse_partial()
744-
745- return None # Incomplete packet
746-
747- def get_expected_length (self , packet_type ):
748- # Fixed-length packets
749- fixed_lengths = {
750- 0x 00 : 5 , # PACKET_OK (minimum)
751- 0x 01 : 2 , # PACKET_ERROR (minimum)
752- 0x 0A : 1 , # PACKET_NO_MORE_MSGS
753- 0x 14 : 3 , # PACKET_BATTERY (minimum)
754- }
755- return fixed_lengths.get(packet_type)
756-
757- def can_parse_partial (self , packet_type ):
758- # Some packets can be parsed partially
759- return packet_type in [0x 12 , 0x 08 , 0x 11 , 0x 07 , 0x 10 , 0x 05 , 0x 0D ]
760-
761- def try_parse_partial (self ):
762- # Try to parse with available data
763- # Return packet if successfully parsed, None otherwise
764- # This is packet-type specific
765- pass
766- ```
711+ ### Frame Handling
767712
768- ** Usage** :
769- ``` python
770- buffer = PacketBuffer()
713+ BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.
771714
772- def on_notification_received (data ):
773- packet = buffer.add_data(data)
774- if packet:
775- parse_and_handle_packet(packet)
776- ```
715+ - Apps should treat each characteristic write/notification as exactly one companion protocol frame
716+ - Apps should still validate frame lengths before parsing
717+ - Future transports or firmware revisions may differ, so avoid assuming fixed payload sizes for variable-length responses
777718
778719### Response Handling
779720
7807211 . ** Command-Response Pattern** :
781- - Send command via TX characteristic
782- - Wait for response via RX characteristic (notification)
722+ - Send command via RX characteristic
723+ - Wait for response via TX characteristic (notification)
783724 - Match response to command using sequence numbers or command type
784725 - Handle timeout (typically 5 seconds)
785726 - Use command queue to prevent concurrent commands
786727
7877282 . ** Asynchronous Messages** :
788- - Device may send messages at any time via RX characteristic
729+ - Device may send messages at any time via TX characteristic
789730 - Handle ` PACKET_MESSAGES_WAITING ` (0x83) by polling ` GET_MESSAGE ` command
790731 - Parse incoming messages and route to appropriate handlers
791- - Buffer partial packets until complete
732+ - Validate frame length before decoding
792733
7937343 . ** Response Matching** :
794735 - Match responses to commands by expected packet type:
795- - ` APP_START ` → ` PACKET_OK `
736+ - ` APP_START ` → ` PACKET_SELF_INFO `
796737 - ` DEVICE_QUERY ` → ` PACKET_DEVICE_INFO `
797738 - ` GET_CHANNEL ` → ` PACKET_CHANNEL_INFO `
798739 - ` SET_CHANNEL ` → ` PACKET_OK ` or ` PACKET_ERROR `
@@ -825,16 +766,16 @@ device = scan_for_device("MeshCore")
825766gatt = connect_to_device(device)
826767
827768# 3. Discover services and characteristics
828- service = discover_service(gatt, " 0000ff00-0000-1000-8000-00805f9b34fb " )
829- rx_char = discover_characteristic(service, " 0000ff01-0000-1000-8000-00805f9b34fb " )
830- tx_char = discover_characteristic(service, " 0000ff02-0000-1000-8000-00805f9b34fb " )
769+ service = discover_service(gatt, " 6E400001-B5A3-F393-E0A9-E50E24DCCA9E " )
770+ rx_char = discover_characteristic(service, " 6E400002-B5A3-F393-E0A9-E50E24DCCA9E " )
771+ tx_char = discover_characteristic(service, " 6E400003-B5A3-F393-E0A9-E50E24DCCA9E " )
831772
832- # 4. Enable notifications on RX characteristic
833- enable_notifications(rx_char , on_notification_received)
773+ # 4. Enable notifications on TX characteristic
774+ enable_notifications(tx_char , on_notification_received)
834775
835776# 5. Send AppStart command
836- send_command(tx_char , build_app_start())
837- wait_for_response(PACKET_OK )
777+ send_command(rx_char , build_app_start())
778+ wait_for_response(PACKET_SELF_INFO )
838779```
839780
840781### Creating a Private Channel
@@ -844,21 +785,16 @@ wait_for_response(PACKET_OK)
844785secret_16_bytes = generate_secret(16 ) # Use CSPRNG
845786secret_hex = secret_16_bytes.hex()
846787
847- # 2. Expand secret to 32 bytes using SHA-512
848- import hashlib
849- sha512_hash = hashlib.sha512(secret_16_bytes).digest()
850- secret_32_bytes = sha512_hash[:32 ]
851-
852- # 3. Build SET_CHANNEL command
788+ # 2. Build SET_CHANNEL command
853789channel_name = " YourChannelName"
854790channel_index = 1 # Use 1-7 for private channels
855- command = build_set_channel(channel_index, channel_name, secret_32_bytes )
791+ command = build_set_channel(channel_index, channel_name, secret_16_bytes )
856792
857- # 4 . Send command
858- send_command(tx_char , command)
793+ # 3 . Send command
794+ send_command(rx_char , command)
859795response = wait_for_response(PACKET_OK )
860796
861- # 5 . Store secret locally (device won't return it)
797+ # 4 . Store secret locally
862798store_channel_secret(channel_index, secret_hex)
863799```
864800
@@ -872,7 +808,7 @@ timestamp = int(time.time())
872808command = build_channel_message(channel_index, message, timestamp)
873809
874810# 2. Send command
875- send_command(tx_char , command)
811+ send_command(rx_char , command)
876812response = wait_for_response(PACKET_MSG_SENT )
877813```
878814
@@ -887,7 +823,7 @@ def on_notification_received(data):
887823 handle_channel_message(message)
888824 elif packet_type == PACKET_MESSAGES_WAITING :
889825 # Poll for messages
890- send_command(tx_char , build_get_message())
826+ send_command(rx_char , build_get_message())
891827```
892828
893829---
0 commit comments