MeshCore Companion Protocol (BLE API)

Companion Protocol

NOTE: This document is still in development. Some information may be inaccurate.

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.

Official Libraries

Please see the following repos for existing MeshCore Companion Protocol libraries.

Important Security Note

All secrets, hashes, and cryptographic values shown in this guide are example values only.

Table of Contents

  1. BLE Connection
  2. Packet Structure
  3. Commands
  4. Channel Management
  5. Message Handling
  6. Response Parsing
  7. Example Implementation Flow
  8. Best Practices
  9. Troubleshooting

---

BLE Connection

Service and Characteristics

MeshCore Companion devices expose a BLE service with the following UUIDs:

Connection Steps

  1. Scan for Devices
  1. Connect to GATT
  1. Discover Services and Characteristics
  1. Enable Notifications
  1. Send Initial Commands

Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff.

BLE Write Type

When writing commands to the RX characteristic, specify the write type:

Platform-specific:

Recommendation: Use write with response for reliability.

MTU (Maximum Transmission Unit)

The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like SET_CHANNEL (50 bytes), you may need to:

  1. Request Larger MTU: Request MTU of 512 bytes if supported

Command Sequencing

Critical: Commands must be sent in the correct sequence:

  1. After Connection:
  1. Command-Response Matching:

Command Queue Management

For reliable operation, implement a command queue.

Queue Structure:

Error Handling:

---

Packet Structure

The MeshCore protocol uses a binary format with the following 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
Bytes 1-7: Reserved (currently ignored by firmware)
Bytes 8+: Application name (UTF-8, optional)

Example (hex):

01 00 00 00 00 00 00 00 6d 63 63 6c 69

Response: PACKET_SELF_INFO (0x05)

---

2. Device Query

Purpose: Query device information.

Command Format:

Byte 0: 0x16
Byte 1: 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

---

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-49: Secret (16 bytes)

Total Length: 50 bytes

Channel Index:

Channel Name:

Secret Field (16 bytes):

Example (create channel "YourChannelName" at index 1 with secret):

20 01 53 4D 53 00 00 ... (name padded to 32 bytes)
 [16 bytes of secret]

Note: The 32-byte secret variant is unsupported and returns PACKET_ERROR.

Response: PACKET_OK (0x00) on success, PACKET_ERROR (0x01) on failure

---

5. Send Channel Message

Purpose: Send a text message to a channel.

Command Format:

Byte 0: 0x03
Byte 1: 0x00
Byte 2: Channel Index (0-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. Send Channel Data Datagram

Purpose: Send binary datagram data to a channel.

Command Format:

Byte 0: 0x3E
Bytes 1-2: Data Type (`data_type`, 16-bit little-endian)
Byte 3: Channel Index (0-7)
Bytes 4+: Binary payload bytes (variable length)

Data Type / Transport Mapping:

Note: Applications that need a timestamp should encode it inside the binary payload.

Limits:

Response: PACKET_OK (0x00) on success

---

6. Get Message

Purpose: Request the next queued message from the device.

Command Format:

Byte 0: 0x0A

Example (hex):

0A

Response:

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 and Storage

Purpose: Query device battery voltage and storage usage.

Command Format:

Byte 0: 0x14

Example (hex):

14

Response: PACKET_BATTERY (0x0C) with battery millivolts and storage information

---

Channel Management

Channel Types

  1. Public Channel
  1. Hashtag Channels
  1. Private Channels

Channel Lifecycle

  1. Set Channel:
  1. Get Channel:
  1. Delete Channel:

---

Message Handling

Receiving Messages

Messages are received via the TX characteristic (notifications). The device sends:

  1. Channel Messages:
  1. Contact Messages:
  1. Notifications:

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:

---

Response Parsing

Packet Types

ValueNameDescription
0x00PACKET_OKCommand succeeded
0x01PACKET_ERRORCommand failed
0x02PACKET_CONTACT_STARTStart of contact list
0x03PACKET_CONTACTContact information
0x04PACKET_CONTACT_ENDEnd of contact list
0x05PACKET_SELF_INFODevice self-information
0x06PACKET_MSG_SENTMessage sent confirmation
0x07PACKET_CONTACT_MSG_RECVContact message (standard)
0x08PACKET_CHANNEL_MSG_RECVChannel message (standard)
0x09PACKET_CURRENT_TIMECurrent time response
0x0APACKET_NO_MORE_MSGSNo more messages available
0x0CPACKET_BATTERYBattery level
0x0DPACKET_DEVICE_INFODevice information
0x10PACKET_CONTACT_MSG_RECV_V3Contact message (V3 with SNR)
0x11PACKET_CHANNEL_MSG_RECV_V3Channel message (V3 with SNR)
0x12PACKET_CHANNEL_INFOChannel information
0x80PACKET_ADVERTISEMENTAdvertisement packet
0x82PACKET_ACKAcknowledgment
0x83PACKET_MESSAGES_WAITINGMessages waiting notification
0x88PACKET_LOG_DATARF 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-49: Secret (16 bytes)

Note: The device returns the 16-byte channel secret in this response.

PACKET_DEVICE_INFO (0x0D):

Byte 0: 0x0D
Byte 1: Firmware Version (uint8)
Bytes 2+: Variable length based on firmware version

For firmware version >= 3:
Byte 2: Max Contacts Raw (uint8, actual = value * 2)
Byte 3: Max Channels (uint8)
Bytes 4-7: 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+)

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 Voltage (16-bit little-endian, millivolts)
Bytes 3-6: Used Storage (32-bit little-endian, KB)
Bytes 7-10: Total Storage (32-bit little-endian, KB)

Parsing Pseudocode:

def parse_battery(data):
 if len(data) < 3:
 return None
 
 mv = int.from_bytes(data[1:3], 'little')
 info = {'battery_mv': mv}
 
 if len(data) >= 11:
 info['used_kb'] = int.from_bytes(data[3:7], 'little')
 info['total_kb'] = int.from_bytes(data[7:11], 'little')
 
 return info

PACKET_SELF_INFO (0x05):

Byte 0: 0x05
Byte 1: Advertisement Type
Byte 2: TX Power
Byte 3: Max TX Power
Bytes 4-35: Public Key (32 bytes, hex)
Bytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)
Bytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)
Byte 44: Multi ACKs
Byte 45: Advertisement Location Policy
Byte 46: Telemetry Mode (bitfield)
Byte 47: Manual Add Contacts (bool)
Bytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)
Bytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)
Byte 56: Radio Spreading Factor
Byte 57: Radio Coding Rate
Bytes 58+: Device Name (UTF-8, variable length, no null terminator required)

Parsing Pseudocode:

def parse_self_info(data):
 if len(data) < 36:
 return None
 
 offset = 1
 info = {
 'adv_type': data[offset],
 'tx_power': data[offset + 1],
 'max_tx_power': data[offset + 2],
 'public_key': data[offset + 3:offset + 35].hex()
 }
 offset += 35
 
 lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6
 lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6
 info['adv_lat'] = lat
 info['adv_lon'] = lon
 offset += 8
 
 info['multi_acks'] = data[offset]
 info['adv_loc_policy'] = data[offset + 1]
 telemetry_mode = data[offset + 2]
 info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11
 info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11
 info['telemetry_mode_base'] = telemetry_mode & 0b11
 info['manual_add_contacts'] = data[offset + 3] > 0
 offset += 4
 
 freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0
 bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0
 info['radio_freq'] = freq
 info['radio_bw'] = bw
 info['radio_sf'] = data[offset + 8]
 info['radio_cr'] = data[offset + 9]
 offset += 10
 
 if offset < len(data):
 name_bytes = data[offset:]
 info['name'] = name_bytes.decode('utf-8').rstrip('\x00').strip()
 
 return info

PACKET_MSG_SENT (0x06):

Byte 0: 0x06
Byte 1: Route Flag (0 = direct, 1 = flood)
Bytes 2-5: Tag / Expected ACK (4 bytes, little-endian)
Bytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)

PACKET_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 CodeDescription
0x00Generic error (no specific code)
0x01Invalid command
0x02Invalid parameter
0x03Channel not found
0x04Channel already exists
0x05Channel index out of range
0x06Secret mismatch
0x07Message too long
0x08Device busy
0x09Not enough storage

Note: Error codes may vary by firmware version. Always check byte 1 of PACKET_ERROR response.

Frame Handling

BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer.

Response Handling

  1. Command-Response Pattern:
  1. Asynchronous Messages:
  1. Response Matching:
  1. Timeout Handling:
  1. Error Recovery:

---

Example Implementation Flow

Initialization

# 1. Scan for MeshCore device
device = scan_for_device("MeshCore")

# 2. Connect to BLE GATT
gatt = connect_to_device(device)

# 3. Discover services and characteristics
service = discover_service(gatt, "6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
rx_char = discover_characteristic(service, "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
tx_char = discover_characteristic(service, "6E400003-B5A3-F393-E0A9-E50E24DCCA9E")

# 4. Enable notifications on TX characteristic
enable_notifications(tx_char, on_notification_received)

# 5. Send AppStart command
send_command(rx_char, build_app_start())
wait_for_response(PACKET_SELF_INFO)

Creating a Private Channel

# 1. Generate 16-byte secret
secret_16_bytes = generate_secret(16) # Use CSPRNG
secret_hex = secret_16_bytes.hex()

# 2. Build SET_CHANNEL command
channel_name = "YourChannelName"
channel_index = 1 # Use 1-7 for private channels
command = build_set_channel(channel_index, channel_name, secret_16_bytes)

# 3. Send command
send_command(rx_char, command)
response = wait_for_response(PACKET_OK)

# 4. Store secret locally
store_channel_secret(channel_index, secret_hex)

Sending a Message

# 1. Build channel message command
channel_index = 1
message = "Hello, MeshCore!"
timestamp = int(time.time())
command = build_channel_message(channel_index, message, timestamp)

# 2. Send command
send_command(rx_char, command)
response = wait_for_response(PACKET_MSG_SENT)

Receiving Messages

def on_notification_received(data):
 packet_type = data[0]
 
 if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:
 message = parse_channel_message(data)
 handle_channel_message(message)
 elif packet_type == PACKET_MESSAGES_WAITING:
 # Poll for messages
 send_command(rx_char, build_get_message())

---

Best Practices

  1. Connection Management:
  1. Secret Management:
  1. Message Handling:
  1. Channel Management:
  1. Error Handling:

---

Troubleshooting

Connection Issues

Command Issues

Message Issues


Revision #2
Created 2026-05-03 05:05:15 UTC by Mesh America Admin
Updated 2026-05-03 12:58:41 UTC by Mesh America Admin