35 KiB
MeshCore BLE Protocol Documentation
Overview
The MeshCore BLE protocol implements a binary frame-based communication system using Nordic UART Service (NUS) for low-level transport. The protocol supports mesh networking operations including contact management, text messaging, channel communication, and device configuration.
BLE Transport Layer
Nordic UART Service (NUS)
Service UUID: 6e400001-b5a3-f393-e0a9-e50e24dcca9e
Characteristics:
-
RX Characteristic (Write):
6e400002-b5a3-f393-e0a9-e50e24dcca9e- Used for sending commands/data TO the device
- Supports write with/without response
-
TX Characteristic (Notify):
6e400003-b5a3-f393-e0a9-e50e24dcca9e- Used for receiving responses/data FROM the device
- Notifications enabled during connection
Connection Flow
- Scan for devices with known name prefixes (defined in
MeshCoreUuids.deviceNamePrefixes):MeshCore-Whisper-WisCore-HT-LowMesh_MC_
- Connect with 15-second timeout
- Request MTU of 185 bytes (falls back to default if unsupported)
- Discover services and locate NUS characteristics
- Enable notifications on TX characteristic (with 3 retry attempts)
- Initialize device by sending:
CMD_DEVICE_QUERY- Get device capabilitiesCMD_APP_START- Register app with deviceCMD_GET_BATT_AND_STORAGE- Request battery statusCMD_GET_RADIO_SETTINGS- Get LoRa radio parameters
Frame Structure
Command Frames (App → Device)
All command frames start with a single-byte command code followed by command-specific data.
Format: [command_code][parameters...]
Maximum Frame Size: 172 bytes (maxFrameSize)
Response Frames (Device → App)
Response frames start with a response code, followed by response-specific data.
Format: [response_code][data...]
Push Frames (Device → App, Asynchronous)
Push frames are unsolicited notifications from the device, using codes ≥ 0x80.
Format: [push_code][data...]
Command Codes (0x01-0x39)
Commands sent from the app to the device:
| Code | Name | Description |
|---|---|---|
| 0x01 | CMD_APP_START |
Register application with device |
| 0x02 | CMD_SEND_TXT_MSG |
Send direct text message to contact |
| 0x03 | CMD_SEND_CHANNEL_TXT_MSG |
Send text message to channel |
| 0x04 | CMD_GET_CONTACTS |
Request contact list |
| 0x05 | CMD_GET_DEVICE_TIME |
Get device's current time |
| 0x06 | CMD_SET_DEVICE_TIME |
Sync device time |
| 0x07 | CMD_SEND_SELF_ADVERT |
Broadcast self advertisement |
| 0x08 | CMD_SET_ADVERT_NAME |
Set node display name |
| 0x09 | CMD_ADD_UPDATE_CONTACT |
Add/update contact with custom path |
| 0x0A | CMD_SYNC_NEXT_MESSAGE |
Request next queued message |
| 0x0B | CMD_SET_RADIO_PARAMS |
Configure LoRa radio settings |
| 0x0C | CMD_SET_RADIO_TX_POWER |
Set transmit power |
| 0x0D | CMD_RESET_PATH |
Clear contact's routing path |
| 0x0E | CMD_SET_ADVERT_LATLON |
Set node GPS coordinates |
| 0x0F | CMD_REMOVE_CONTACT |
Delete contact from device |
| 0x10 | CMD_SHARE_CONTACT |
Share contact via mesh |
| 0x11 | CMD_EXPORT_CONTACT |
Export contact data |
| 0x12 | CMD_IMPORT_CONTACT |
Import contact data |
| 0x13 | CMD_REBOOT |
Reboot device |
| 0x14 | CMD_GET_BATT_AND_STORAGE |
Request battery and storage info |
| 0x15 | CMD_SET_TUNING_PARAMS |
Set device tuning parameters |
| 0x16 | CMD_DEVICE_QUERY |
Query device capabilities |
| 0x17 | CMD_EXPORT_PRIVATE_KEY |
Export device private key (secure) |
| 0x18 | CMD_IMPORT_PRIVATE_KEY |
Import device private key (secure) |
| 0x19 | CMD_SEND_RAW_DATA |
Send raw data to contact |
| 0x1A | CMD_SEND_LOGIN |
Authenticate to repeater |
| 0x1B | CMD_SEND_STATUS_REQ |
Request status from repeater |
| 0x1C | CMD_HAS_CONNECTION |
Check if connection exists to contact |
| 0x1D | CMD_LOGOUT |
Disconnect from repeater |
| 0x1E | CMD_GET_CONTACT_BY_KEY |
Get specific contact by public key |
| 0x1F | CMD_GET_CHANNEL |
Get channel configuration |
| 0x20 | CMD_SET_CHANNEL |
Configure channel |
| 0x21 | CMD_SIGN_START |
Start signing operation |
| 0x22 | CMD_SIGN_DATA |
Add data to be signed |
| 0x23 | CMD_SIGN_FINISH |
Finish signing and get signature |
| 0x24 | CMD_SEND_TRACE_PATH |
Send path trace request |
| 0x25 | CMD_SET_DEVICE_PIN |
Set device PIN for pairing |
| 0x26 | CMD_SET_OTHER_PARAMS |
Set miscellaneous parameters |
| 0x27 | CMD_SEND_TELEMETRY_REQ |
Request telemetry data (deprecated) |
| 0x28 | CMD_GET_CUSTOM_VARS |
Get custom variables |
| 0x29 | CMD_SET_CUSTOM_VAR |
Set custom variable |
| 0x2A | CMD_GET_ADVERT_PATH |
Get advertisement path for contact |
| 0x2B | CMD_GET_TUNING_PARAMS |
Get device tuning parameters |
| 0x32 | CMD_SEND_BINARY_REQ |
Send binary request to contact |
| 0x33 | CMD_FACTORY_RESET |
Factory reset device |
| 0x34 | CMD_SEND_PATH_DISCOVERY_REQ |
Request path discovery |
| 0x36 | CMD_SET_FLOOD_SCOPE |
Set flood routing scope (v8+) |
| 0x37 | CMD_SEND_CONTROL_DATA |
Send control data (v8+) |
| 0x38 | CMD_GET_STATS |
Get statistics (v8+, sub-types: core/radio/packets) |
| 0x39 | CMD_GET_RADIO_SETTINGS |
Get current radio parameters |
Response Codes (0x00-0x19)
Responses from device to app:
| Code | Name | Description |
|---|---|---|
| 0x00 | RESP_CODE_OK |
Generic success |
| 0x01 | RESP_CODE_ERR |
Generic error |
| 0x02 | RESP_CODE_CONTACTS_START |
Beginning of contact list |
| 0x03 | RESP_CODE_CONTACT |
Contact entry |
| 0x04 | RESP_CODE_END_OF_CONTACTS |
End of contact list |
| 0x05 | RESP_CODE_SELF_INFO |
Device identity and settings |
| 0x06 | RESP_CODE_SENT |
Message sent (includes ACK hash) |
| 0x07 | RESP_CODE_CONTACT_MSG_RECV |
Received direct message (v1/v2) |
| 0x08 | RESP_CODE_CHANNEL_MSG_RECV |
Received channel message (v1/v2) |
| 0x09 | RESP_CODE_CURR_TIME |
Current device time |
| 0x0A | RESP_CODE_NO_MORE_MESSAGES |
Queue empty |
| 0x0B | RESP_CODE_EXPORT_CONTACT |
Exported contact data |
| 0x0C | RESP_CODE_BATT_AND_STORAGE |
Battery and storage status |
| 0x0D | RESP_CODE_DEVICE_INFO |
Device capabilities |
| 0x0E | RESP_CODE_PRIVATE_KEY |
Exported private key |
| 0x0F | RESP_CODE_DISABLED |
Feature disabled |
| 0x10 | RESP_CODE_CONTACT_MSG_RECV_V3 |
Received direct message (v3) |
| 0x11 | RESP_CODE_CHANNEL_MSG_RECV_V3 |
Received channel message (v3) |
| 0x12 | RESP_CODE_CHANNEL_INFO |
Channel configuration |
| 0x13 | RESP_CODE_SIGN_START |
Signing operation started |
| 0x14 | RESP_CODE_SIGNATURE |
Digital signature result |
| 0x15 | RESP_CODE_CUSTOM_VARS |
Custom variables data |
| 0x16 | RESP_CODE_ADVERT_PATH |
Advertisement path data |
| 0x17 | RESP_CODE_TUNING_PARAMS |
Tuning parameters |
| 0x18 | RESP_CODE_STATS |
Statistics data (v8+) |
| 0x19 | RESP_CODE_RADIO_SETTINGS |
Radio parameters |
Push Codes (0x80-0x8E)
Asynchronous notifications from device:
| Code | Name | Description |
|---|---|---|
| 0x80 | PUSH_CODE_ADVERT |
Advertisement received |
| 0x81 | PUSH_CODE_PATH_UPDATED |
Contact path changed |
| 0x82 | PUSH_CODE_SEND_CONFIRMED |
Message ACK received |
| 0x83 | PUSH_CODE_MSG_WAITING |
New messages in queue |
| 0x84 | PUSH_CODE_RAW_DATA |
Raw data received from contact |
| 0x85 | PUSH_CODE_LOGIN_SUCCESS |
Repeater login succeeded |
| 0x86 | PUSH_CODE_LOGIN_FAIL |
Repeater login failed |
| 0x87 | PUSH_CODE_STATUS_RESPONSE |
Repeater status response |
| 0x88 | PUSH_CODE_LOG_RX_DATA |
Raw LoRa packet log |
| 0x89 | PUSH_CODE_TRACE_DATA |
Path trace response |
| 0x8A | PUSH_CODE_NEW_ADVERT |
New contact advertisement |
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE |
Telemetry data response |
| 0x8C | PUSH_CODE_BINARY_RESPONSE |
Binary request response |
| 0x8D | PUSH_CODE_PATH_DISCOVERY_RESPONSE |
Path discovery response |
| 0x8E | PUSH_CODE_CONTROL_DATA |
Control data received (v8+) |
Key Frame Formats
CMD_APP_START (0x01)
Registers the application with the device.
Format:
[0x01][app_ver][reserved x6][app_name...]\0
Fields:
app_ver(1 byte): Application version numberreserved(6 bytes): Reserved for future use (zeros)app_name(variable): Null-terminated UTF-8 app name
Example:
buildAppStartFrame(appName: 'MeshCoreOpen', appVersion: 1)
// [0x01][0x01][0x00 x6]["MeshCoreOpen"][0x00]
CMD_SEND_TXT_MSG (0x02)
Sends a direct message to a contact.
Format:
[0x02][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0
Fields:
txt_type(1 byte): Message type (0=plain, 1=CLI data)attempt(1 byte): Retry attempt number (0-3)timestamp(4 bytes LE): Unix timestamp in secondspub_key_prefix(6 bytes): First 6 bytes of recipient's public keytext(variable): UTF-8 message text, null-terminated
Max text length: 160 bytes after overhead (matching firmware MAX_TEXT_LEN)
Example:
buildSendTextMsgFrame(recipientPubKey, "Hello mesh!", attempt: 0)
CMD_SEND_CHANNEL_TXT_MSG (0x03)
Sends a message to a channel (broadcast group).
Format:
[0x03][txt_type][channel_idx][timestamp x4][text...]\0
Fields:
txt_type(1 byte): Message type (0=plain)channel_idx(1 byte): Channel index (0-7 typically)timestamp(4 bytes LE): Unix timestamp in secondstext(variable): UTF-8 message text, null-terminated
Max text length: Depends on sender name prefix (see maxChannelMessageBytes())
CMD_GET_CONTACTS (0x04)
Requests contact list from device.
Format:
[0x04] # Get all contacts
[0x04][since x4] # Get contacts modified after timestamp
Fields:
since(4 bytes LE, optional): Unix timestamp filter
Response:
RESP_CODE_CONTACTS_START(0x02)- Multiple
RESP_CODE_CONTACT(0x03) frames RESP_CODE_END_OF_CONTACTS(0x04)
CMD_GET_CONTACT_BY_KEY (0x1E)
Fetches a specific contact by their public key.
Format:
[0x1E][pub_key x32]
Fields:
pub_key(32 bytes): Contact's Ed25519 public key
Response:
RESP_CODE_CONTACT(0x03) if foundRESP_CODE_ERR(0x01) withERR_CODE_NOT_FOUND(2) if not found
Use case: Efficiently check if a specific contact exists without fetching entire contact list.
Example:
// Fetch specific contact
final pubKey = hexToPubKey('a1b2c3d4...');
await connector.getContactByKey(pubKey);
// Response handled in _handleContact() as usual
CMD_SET_DEVICE_TIME (0x06)
Synchronizes device clock with app.
Format:
[0x06][timestamp x4]
Fields:
timestamp(4 bytes LE): Current Unix timestamp in seconds
CMD_SET_ADVERT_NAME (0x08)
Sets the device's display name for advertisements.
Format:
[0x08][name...]
Fields:
name(variable): UTF-8 name, max 31 bytes (truncated if longer)
CMD_SET_ADVERT_LATLON (0x0E)
Sets the device's GPS coordinates.
Format:
[0x0E][lat x4][lon x4]
Fields:
lat(4 bytes LE): Latitude × 1,000,000 (signed int32)lon(4 bytes LE): Longitude × 1,000,000 (signed int32)
Example:
// 37.7749° N, -122.4194° W (San Francisco)
buildSetAdvertLatLonFrame(37.7749, -122.4194)
// lat_int = 37774900, lon_int = -122419400
CMD_ADD_UPDATE_CONTACT (0x09)
Adds a new contact or updates an existing contact's routing path.
Format:
[0x09][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
Fields:
pub_key(32 bytes): Contact's public keytype(1 byte): Advertisement type (1=chat, 2=repeater, 3=room, 4=sensor)flags(1 byte): Contact flagspath_len(1 byte): Number of path bytes usedpath(64 bytes): Custom routing path (padded with zeros)name(32 bytes): Contact name, null-padded UTF-8timestamp(4 bytes LE): Unix timestamp
Total size: 136 bytes
CMD_RESET_PATH (0x0D)
Clears a contact's custom path, reverting to flood mode.
Format:
[0x0D][pub_key x32]
Fields:
pub_key(32 bytes): Contact's public key
CMD_SET_RADIO_PARAMS (0x0B)
Configures LoRa radio parameters.
Format:
[0x0B][freq x4][bw x4][sf][cr]
Fields:
freq(4 bytes LE): Frequency in Hz (300,000 - 2,500,000)bw(4 bytes LE): Bandwidth in Hz (7,000 - 500,000)sf(1 byte): Spreading factor (5-12)cr(1 byte): Coding rate (5-8)
Example:
// 915 MHz, 125 kHz BW, SF7, CR 4/5
buildSetRadioParamsFrame(915000000, 125000, 7, 5)
CMD_SET_CHANNEL (0x20)
Creates or updates a channel configuration.
Format:
[0x20][idx][name x32][psk x16]
Fields:
idx(1 byte): Channel index (0-7)name(32 bytes): Channel name, null-padded UTF-8psk(16 bytes): Pre-shared key for encryption
To delete a channel: Send empty name and zero PSK
RESP_CODE_SELF_INFO (0x05)
Device identity and current settings.
Format:
[0x05][adv_type][tx_pwr][max_pwr][pub_key x32][lat x4][lon x4][multi_acks]
[adv_loc_policy][telemetry][manual_add][freq x4][bw x4][sf][cr][name...]
Fields:
adv_type(1 byte): Advertisement type (1=chat, 2=repeater)tx_pwr(1 byte): Current TX power in dBmmax_pwr(1 byte): Maximum TX power in dBmpub_key(32 bytes): Device's public keylat(4 bytes LE): Latitude × 1,000,000lon(4 bytes LE): Longitude × 1,000,000multi_acks(1 byte): Multi-ACK mode flagadv_loc_policy(1 byte): Location advertisement policytelemetry(1 byte): Telemetry mode flagsmanual_add(1 byte): Manual contact addition modefreq(4 bytes LE): Radio frequency in Hzbw(4 bytes LE): Radio bandwidth in Hzsf(1 byte): Spreading factorcr(1 byte): Coding ratename(variable): Node name, null-terminated UTF-8
Minimum size: 58 bytes (without name)
RESP_CODE_CONTACT (0x03)
Contact entry from device.
Format:
[0x03][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
[lat x4][lon x4][lastmod x4]
Fields:
pub_key(32 bytes): Contact's public keytype(1 byte): Contact type (1=chat, 2=repeater, 3=room, 4=sensor)flags(1 byte): Contact flagspath_len(1 byte): Path length (0xFF = flood mode)path(64 bytes): Routing path dataname(32 bytes): Contact name, null-terminated UTF-8timestamp(4 bytes LE): Last seen timestamplat(4 bytes LE): Latitude × 1,000,000lon(4 bytes LE): Longitude × 1,000,000lastmod(4 bytes LE): Last modification timestamp
Total size: 148 bytes
Path length interpretation:
-1(0xFF): Flood mode (no direct path)≥0: Direct path with N hops
RESP_CODE_CONTACT_MSG_RECV_V3 (0x10)
Received direct message (protocol version 3).
Format:
[0x10][snr][res x2][prefix x6][path_len][txt_type][timestamp x4][extra? x4][text...]
Fields:
snr(1 byte): Signal-to-noise ratiores(2 bytes): Reservedprefix(6 bytes): Sender's public key prefixpath_len(1 byte): Path length (0xFF = direct)txt_type(1 byte): Text type (bits 7-2: type, bits 1-0: flags)- Type 0: Plain text
- Type 1: CLI data
timestamp(4 bytes LE): Message timestamp (Unix seconds)extra(4 bytes, optional): Extra data for signed/plain variantstext(variable): Message text, null-terminated
Text decoding:
- Try reading at base offset (timestamp + 4)
- If empty and room for extra bytes, try offset + 4
- Check for SMAZ compression prefix
- Decode as UTF-8
RESP_CODE_CHANNEL_MSG_RECV_V3 (0x11)
Received channel message (protocol version 3).
Format:
[0x11][snr][res x2][channel_idx][path_len][txt_type][timestamp x4][sender_name...]: [text...]
Fields:
snr(1 byte): Signal-to-noise ratiores(2 bytes): Reservedchannel_idx(1 byte): Channel indexpath_len(1 byte): Path lengthtxt_type(1 byte): Text typetimestamp(4 bytes LE): Message timestamp- Combined text format:
"[sender_name]: [message_text]"
RESP_CODE_SENT (0x06)
Confirmation that message was transmitted to LoRa radio.
Format:
[0x06][is_flood][ack_hash x4][timeout_ms x4]
Fields:
is_flood(1 byte): 1 if flood mode, 0 if direct pathack_hash(4 bytes): Hash for matching future ACKtimeout_ms(4 bytes LE): Expected ACK timeout in milliseconds
PUSH_CODE_SEND_CONFIRMED (0x82)
ACK received for a sent message.
Format:
[0x82][ack_hash x4][trip_time_ms x4]
Fields:
ack_hash(4 bytes): Hash matching RESP_CODE_SENTtrip_time_ms(4 bytes LE): Round-trip time in milliseconds
PUSH_CODE_PATH_UPDATED (0x81)
Notification that a contact's path has been updated by the device.
Format:
[0x81][pub_key x32]
Fields:
pub_key(32 bytes): Contact whose path changed
Handler action: Request updated contact list
RESP_CODE_BATT_AND_STORAGE (0x0C)
Battery and storage status.
Format:
[0x0C][battery_mv x2][storage_used_kb x4][storage_total_kb x4]
Fields:
battery_mv(2 bytes LE): Battery voltage in millivoltsstorage_used_kb(4 bytes LE): Used storage in kilobytesstorage_total_kb(4 bytes LE): Total storage in kilobytes
Battery percentage calculation:
// Chemistry-specific voltage ranges:
// LiFePO4: 2600-3650 mV
// LiPo/NMC: 3000-4200 mV
int percent = ((mv - minMv) * 100) / (maxMv - minMv);
RESP_CODE_DEVICE_INFO (0x0D)
Device capabilities and limits.
Format:
[0x0D][protocol_ver][max_contacts_div2][max_channels]
Fields:
protocol_ver(1 byte): Protocol versionmax_contacts_div2(1 byte): Max contacts ÷ 2 (actual = value × 2)max_channels(1 byte): Max supported channels
Example: [0x0D][0x03][0x10][0x08] = v3, 32 contacts, 8 channels
RESP_CODE_RADIO_SETTINGS (0x19)
Current LoRa radio parameters.
Format:
[0x19][freq x4][bw x4][sf][cr]
Fields:
freq(4 bytes LE): Frequency in Hzbw(4 bytes LE): Bandwidth in Hzsf(1 byte): Spreading factor (5-12)cr(1 byte): Coding rate (5-8, subtract 4 for actual CR)
Advanced Commands (Not Yet Implemented in Flutter)
The following commands are supported by the firmware but not yet implemented in the Flutter app:
CMD_GET_ADVERT_PATH (0x2A)
Get the recently heard advertisement path for a contact.
Purpose: Retrieves the inbound path that was used when the contact's advertisement was last received. Useful for discovering optimal paths.
Format:
[0x2A][pub_key_prefix x6]
Response: RESP_CODE_ADVERT_PATH (0x16) with path data
CMD_GET_STATS (0x38)
Get device statistics (protocol version 8+).
Format:
[0x38][stats_type]
Stats types:
0x00: Core stats (packet counts, air time)0x01: Radio stats (RSSI, SNR, noise floor)0x02: Packet stats (detailed packet analysis)
Response: RESP_CODE_STATS (0x18) with statistics data
CMD_SEND_TELEMETRY_REQ (0x27)
Request telemetry data from a contact (deprecated in favor of binary requests).
Purpose: Request sensor data, battery status, or environmental data from remote nodes.
Response: PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
CMD_SEND_PATH_DISCOVERY_REQ (0x34)
Request path discovery to a contact.
Purpose: Actively discover available paths to a contact by broadcasting a discovery request.
Response: PUSH_CODE_PATH_DISCOVERY_RESPONSE (0x8D) with discovered paths
CMD_SET_FLOOD_SCOPE (0x36)
Set flood routing scope (v8+).
Purpose: Configure geographic or logical boundaries for flood routing to reduce mesh congestion.
Format:
[0x36][scope_data...]
CMD_FACTORY_RESET (0x33)
Factory reset the device.
Purpose: Erase all stored data (contacts, keys, settings) and return to factory defaults.
Format:
[0x33]
Response: RESP_CODE_OK or RESP_CODE_ERR
WARNING: This erases the device's identity! Use with caution.
CMD_EXPORT_PRIVATE_KEY (0x17) / CMD_IMPORT_PRIVATE_KEY (0x18)
Export or import the device's Ed25519 private key.
Security: These commands should be protected by device PIN or other authentication.
Export format:
[0x17]
Import format:
[0x18][private_key x32]
Response: RESP_CODE_PRIVATE_KEY (0x0E) or RESP_CODE_OK
CMD_GET_CUSTOM_VARS (0x28) / CMD_SET_CUSTOM_VAR (0x29)
Get or set custom device variables for application-specific configuration.
Get format:
[0x28]
Set format:
[0x29][var_id][value...]
Response: RESP_CODE_CUSTOM_VARS (0x15)
CMD_SET_OTHER_PARAMS (0x26)
Set miscellaneous device parameters (battery chemistry, telemetry mode, etc.).
Format:
[0x26][param_data...]
CMD_SIGN_START/DATA/FINISH (0x21-0x23)
Multi-step digital signing operation.
Purpose: Sign arbitrary data using the device's private key.
Flow:
CMD_SIGN_START- Initialize signing sessionCMD_SIGN_DATA(multiple) - Add data chunks (max 8KB total)CMD_SIGN_FINISH- Get Ed25519 signature
Response: RESP_CODE_SIGNATURE (0x14) with 64-byte signature
PUSH_CODE_LOG_RX_DATA (0x88)
Raw LoRa packet for debugging/decryption.
Format:
[0x88][flags][snr][raw_packet...]
Used for: Decrypting channel messages when device doesn't have the channel key.
Raw packet structure:
[header][transport_id? x4][path_len][path...][payload...]
Channel message decryption flow:
- Parse header to get route type
- Extract path and payload
- For group text payload (type 0x05):
- First payload byte is channel hash
- Verify hash against known channels
- Decrypt payload using channel PSK
- Parse decrypted data as channel message
Message Features
Text Compression (SMAZ)
The protocol supports optional SMAZ compression for text messages:
Encoding:
// Only compress if it saves space
String outbound = Smaz.encodeIfSmaller(text);
Decoding:
// Detect and decode SMAZ prefix
String decoded = Smaz.tryDecodePrefixed(received) ?? received;
Enable per-contact/channel:
- Contact: Stored in
ContactSettingsStore - Channel: Stored in
ChannelSettingsStore
Exclusions: Structured payloads starting with g:, m:, or V1| are never compressed.
Message Reactions
Format: "m:[message_id]:[emoji]"
Example: "m:abc123:👍"
Processing:
- Parse reaction from incoming message
- Find target message by
messageId - Increment emoji counter in target message's
reactionsmap - Don't display reaction as a separate message
Message Replies
Format: "@[node_name] [actual_message]"
Example: "@Alice Hello there!"
Processing:
- Parse reply mention from message text
- Find most recent message from mentioned sender
- Attach reply metadata to new message:
replyToMessageIdreplyToSenderNamereplyToText
Message Retry and ACK Tracking
The app implements automatic retry for failed messages:
Flow:
- Send message → Receive
RESP_CODE_SENTwithack_hashandtimeout_ms - Start timeout timer
- On timeout: Retry with incremented attempt counter
- On
PUSH_CODE_SEND_CONFIRMED: Mark delivered, record trip time
Retry strategy: Exponential backoff with path rotation (if auto-rotation enabled).
LoRa Timing Calculations
Airtime Calculation
Based on Semtech SX127x datasheet formula:
double symbolDuration = (1 << sf) / (bw / 1000.0); // ms
double preambleTime = (preambleSymbols + 4.25) * symbolDuration;
int numerator = 8*payloadBytes - 4*sf + 28 + 16*crc - headerBytes;
int denominator = 4*(sf - 2*de);
int payloadSymbols = 8 + ceil(numerator/denominator) * (cr + 4);
double payloadTime = payloadSymbols * symbolDuration;
int airtime = ceil(preambleTime + payloadTime);
Variables:
sf: Spreading factor (5-12)bw: Bandwidth in Hzcr: Coding rate (5-8)de: Low data rate optimization (1 if sf≥11, else 0)crc: CRC enabled (always 1)
Message Timeout Calculation
Flood mode (path_len = -1):
timeout = 500 + (16 × airtime) ms
Direct path (path_len ≥ 0):
timeout = 500 + ((airtime×6 + 250) × (hops+1)) ms
Example (SF7, BW125, 100 bytes, 2 hops):
airtime ≈ 50 ms
timeout = 500 + ((50×6 + 250) × 3) = 500 + (550 × 3) = 2150 ms
Path Routing
Path Format
Paths are sequences of 1-byte public key prefixes:
Example path (3 hops):
[0xAB][0xCD][0xEF] // Route through nodes AB... → CD... → EF...
Max path size: 64 bytes = 64 hops maximum
Path Modes
Flood mode (path_len = -1):
- Message floods through all nodes
- Higher latency, more reliable
- Used when no direct path known
Direct mode (path_len ≥ 0):
- Message follows specific path
- Lower latency, less reliable
- Requires path discovery/maintenance
Auto Path Rotation
When enabled, the app cycles through known paths:
Implementation:
PathHistoryServicetracks success/failure per path- On message send, select next path variant
- Record attempt and outcome
- Rotate to next path on retry
- Update contact's path in-memory via
CMD_ADD_UPDATE_CONTACT
Channel Encryption
Channels use symmetric encryption with pre-shared keys (PSK).
Encryption Scheme
MAC: HMAC-SHA256 (first 2 bytes) Cipher: AES-128-ECB
Process:
- Compute channel hash:
sha256(psk)[0] - Encrypt payload with AES-128-ECB using first 16 bytes of PSK
- Compute HMAC-SHA256 of ciphertext using 32-byte padded PSK
- Prepend 2-byte MAC to ciphertext
- Prepend channel hash byte
Format:
[channel_hash][mac x2][ciphertext...]
Decryption (from PUSH_CODE_LOG_RX_DATA)
- Extract channel hash from payload
- Try each known channel's PSK
- Verify 2-byte HMAC prefix
- Decrypt with AES-128-ECB
- Parse decrypted payload as channel message
Data Types
Binary Encoding
Little-endian for all multi-byte integers:
// Read uint32
int val = data[offset] | (data[offset+1] << 8) |
(data[offset+2] << 16) | (data[offset+3] << 24);
// Write uint32
data[offset] = val & 0xFF;
data[offset+1] = (val >> 8) & 0xFF;
data[offset+2] = (val >> 16) & 0xFF;
data[offset+3] = (val >> 24) & 0xFF;
String Encoding
Format: Null-terminated UTF-8
// Read C-string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
}
Fallback: If UTF-8 decoding fails, use Latin-1 (byte-to-char mapping).
Public Keys
Format: 32-byte Ed25519 public keys
Hex representation:
String hex = pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
// Example: "a1b2c3d4e5f6..."
Prefix matching: First 6 bytes used for message routing.
Connection Management
Connection States
enum MeshCoreConnectionState {
disconnected, // Not connected
scanning, // BLE scan in progress
connecting, // Connection attempt in progress
connected, // Fully connected and initialized
disconnecting, // Disconnect in progress
}
Auto-Reconnection
When connection is lost (not manual disconnect):
Strategy: Exponential backoff
int delayMs = 1000 * (1 << attempt); // 1s, 2s, 4s, 8s, 16s, 32s
delayMs = min(delayMs, 30000); // Cap at 30 seconds
Attempts: Unlimited until manual disconnect or successful reconnect.
Message Queue Syncing
On connect, the app syncs queued messages:
Flow:
- Wait for
RESP_CODE_SELF_INFO(device ready) - Wait for
RESP_CODE_END_OF_CONTACTS(contacts loaded) - Send
CMD_SYNC_NEXT_MESSAGE - Process each message/response
- Send next
CMD_SYNC_NEXT_MESSAGE - Continue until
RESP_CODE_NO_MORE_MESSAGES
Trigger: Also triggered by PUSH_CODE_MSG_WAITING notification.
Error Handling
Frame Validation
All frame handlers validate:
- Minimum frame length
- Expected data offsets
- Null-termination of strings
- Public key prefix matching (for messages)
Invalid frames: Silently ignored, logged to debug.
Send Failures
Exceptions:
- Not connected:
throw Exception("Not connected to a MeshCore device") - No write support:
throw Exception("RX characteristic does not support write")
Retries: Write operations use platform-level retries (BLE stack).
Disconnection Recovery
Actions on disconnect:
- Cancel all subscriptions
- Clear device references (but preserve ID/name for reconnection)
- Clear in-memory contacts and conversations
- Reset sync state flags
- Schedule reconnection (if not manual)
CLI Commands
The device supports text-based CLI commands for advanced configuration:
Format:
[0x01][command_string...][0x00]
Examples:
sendCliCommand('set privacy on')
sendCliCommand('radio sf 7')
sendCliCommand('channel add "TestChannel"')
Note: CLI commands don't use the frame-based protocol and are sent as UTF-8 text with 0x01 prefix.
Protocol Constants
const int pubKeySize = 32; // Ed25519 public key
const int maxPathSize = 64; // Max routing path
const int pathHashSize = 1; // Path prefix size
const int maxNameSize = 32; // Max name length
const int maxFrameSize = 172; // BLE MTU constraint
const int maxTextPayloadBytes = 160; // Firmware limit (10 cipher blocks)
const int appProtocolVersion = 3; // Current protocol version
Implementation Notes
Thread Safety
MeshCoreConnector uses Flutter's ChangeNotifier:
- All state changes trigger
notifyListeners() - BLE callbacks run on main isolate
- No explicit locking required (single-threaded)
Storage
Persistent storage uses separate stores:
ContactStore: Contact list cacheMessageStore: Per-contact message historyChannelMessageStore: Per-channel message historyContactSettingsStore: Per-contact settings (SMAZ, etc.)ChannelSettingsStore: Per-channel settingsUnreadStore: Unread message tracking
Windowing: Only most recent 200 messages kept in memory per conversation.
Deduplication
Contact messages: Compare timestamp + text in last 10 messages.
Channel messages:
- Same text + timestamp within 5 seconds = duplicate
- Self-messages: Match sender name + path contains own public key prefix
Notifications
The app shows system notifications for:
- New advertisements (if enabled)
- New direct messages (if enabled)
- New channel messages (if enabled)
Filtering: No notifications for outgoing messages or CLI data.
Firmware Implementation Details
C++ Firmware Side (companion_radio)
The firmware implements the protocol through the MyMesh class which extends BaseChatMesh. Key implementation notes:
Frame Constants
#define MAX_FRAME_SIZE 172 // BLE MTU constraint
#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // 160 bytes
Timeout Calculations
Base constants:
#define SEND_TIMEOUT_BASE_MILLIS 500
#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
Flood timeout: 500 + (airtime × 16) ms
Direct timeout: 500 + ((airtime × 6 + 250) × (hops + 1)) ms
These match the Dart implementation.
Offline Message Queue
The firmware maintains an offline queue for when the BLE client is disconnected:
#define OFFLINE_QUEUE_SIZE 16
Frame offline_queue[OFFLINE_QUEUE_SIZE];
Features:
- Queues messages when app not connected
- On connection, sends
PUSH_CODE_MSG_WAITINGto trigger sync - Channel messages can be evicted if queue full (oldest first)
- Contact messages are preserved over channel messages
Sync flow:
- App sends
CMD_SYNC_NEXT_MESSAGE - Firmware sends oldest queued frame
- App sends another
CMD_SYNC_NEXT_MESSAGE - Repeat until firmware sends
RESP_CODE_NO_MORE_MESSAGES
Contact Storage
Lazy write strategy:
#define LAZY_CONTACTS_WRITE_DELAY 5000 // 5 seconds
Contact list changes trigger a delayed write (5s after last change) to reduce wear on flash storage.
Advertisement Path Cache
The firmware caches recently heard advertisement paths in volatile memory:
#define ADVERT_PATH_TABLE_SIZE 16
struct AdvertPath {
uint8_t pubkey_prefix[6];
char name[32];
uint32_t recv_timestamp;
uint8_t path_len;
uint8_t path[MAX_PATH_SIZE];
};
Purpose: Allows CMD_GET_ADVERT_PATH to retrieve inbound paths for discovered nodes.
Expected ACK Table
#define EXPECTED_ACK_TABLE_SIZE 8
struct {
uint32_t ack; // Expected ACK hash
uint32_t msg_sent; // Timestamp when sent
ContactInfo* contact; // Recipient contact
} expected_ack_table[EXPECTED_ACK_TABLE_SIZE];
When message is sent, firmware:
- Computes
expected_ackhash from message - Stores in table with send timestamp
- On ACK receipt, computes trip time
- Sends
PUSH_CODE_SEND_CONFIRMEDto app
Protocol Version Negotiation
uint8_t app_target_ver = 0; // Set by CMD_APP_START
The firmware adapts response formats based on app version:
- Version < 3: Uses
RESP_CODE_CONTACT_MSG_RECV(no SNR) - Version ≥ 3: Uses
RESP_CODE_CONTACT_MSG_RECV_V3(includes SNR + reserved bytes)
Error Codes
#define ERR_CODE_UNSUPPORTED_CMD 1
#define ERR_CODE_NOT_FOUND 2
#define ERR_CODE_TABLE_FULL 3
#define ERR_CODE_BAD_STATE 4
#define ERR_CODE_FILE_IO_ERROR 5
#define ERR_CODE_ILLEGAL_ARG 6
Returned in RESP_CODE_ERR frames: [0x01][err_code]
Flutter Implementation Details
Message Windowing
static const int _messageWindowSize = 200;
Only most recent 200 messages per contact/channel kept in memory. Older messages stored on disk but must be explicitly loaded via loadOlderMessages().
Frame Processing
All received frames processed in _handleFrame():
void _handleFrame(List<int> data) {
final frame = Uint8List.fromList(data);
final code = frame[0];
switch (code) {
case respCodeSelfInfo: _handleSelfInfo(frame);
case respCodeContact: _handleContact(frame);
// ... etc
}
}
Validations:
- Minimum frame length checks
- Null-termination validation for strings
- Public key prefix matching for messages
- Contact existence checks before processing messages
Auto-Reconnection
int _nextReconnectDelayMs() {
final attempt = _reconnectAttempts.clamp(0, 6);
final delayMs = 1000 * (1 << attempt); // Exponential backoff
return delayMs > 30000 ? 30000 : delayMs;
}
Strategy: 1s, 2s, 4s, 8s, 16s, 32s, then capped at 30s.
Self-Info Retry
Timer.periodic(const Duration(milliseconds: 3500), (timer) {
if (!_awaitingSelfInfo) timer.cancel();
sendFrame(buildAppStartFrame());
});
On connect, if RESP_CODE_SELF_INFO not received within 3s, retry CMD_APP_START every 3.5s until received.
Message Deduplication
Contact messages: Compare last 10 messages for same timestamp + text.
Channel messages:
- Same text within 5 seconds = duplicate
- Self-message detection: Match sender name with self name + path contains own public key prefix
Reaction Processing
final reactionInfo = Message.parseReaction(message.text);
if (reactionInfo != null) {
_processContactReaction(messages, reactionInfo);
return; // Don't add as visible message
}
Reactions are parsed, processed to update target message's reaction counts, but never displayed as standalone messages.
References
- Firmware Repository: https://github.com/nonik0/meshcore
- LoRa Airtime Calculator: Based on Semtech AN1200.22
- SMAZ Compression: https://github.com/antirez/smaz
- Ed25519: https://ed25519.cr.yp.to/
- AES-128-ECB: FIPS 197
- Nordic UART Service (NUS): Nordic Semiconductor BLE UART specification