meshcore-open/docs/BLE_PROTOCOL.md
2026-04-04 19:40:39 -04:00

35 KiB
Raw Permalink Blame History

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

  1. Scan for devices with known name prefixes (defined in MeshCoreUuids.deviceNamePrefixes):
    • MeshCore-
    • Whisper-
    • WisCore-
    • HT-
    • LowMesh_MC_
  2. Connect with 15-second timeout
  3. Request MTU of 185 bytes (falls back to default if unsupported)
  4. Discover services and locate NUS characteristics
  5. Enable notifications on TX characteristic (with 3 retry attempts)
  6. Initialize device by sending:
    • CMD_DEVICE_QUERY - Get device capabilities
    • CMD_APP_START - Register app with device
    • CMD_GET_BATT_AND_STORAGE - Request battery status
    • CMD_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 number
  • reserved (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 seconds
  • pub_key_prefix (6 bytes): First 6 bytes of recipient's public key
  • text (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 seconds
  • text (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 found
  • RESP_CODE_ERR (0x01) with ERR_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 key
  • type (1 byte): Advertisement type (1=chat, 2=repeater, 3=room, 4=sensor)
  • flags (1 byte): Contact flags
  • path_len (1 byte): Number of path bytes used
  • path (64 bytes): Custom routing path (padded with zeros)
  • name (32 bytes): Contact name, null-padded UTF-8
  • timestamp (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-8
  • psk (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 dBm
  • max_pwr (1 byte): Maximum TX power in dBm
  • pub_key (32 bytes): Device's public key
  • lat (4 bytes LE): Latitude × 1,000,000
  • lon (4 bytes LE): Longitude × 1,000,000
  • multi_acks (1 byte): Multi-ACK mode flag
  • adv_loc_policy (1 byte): Location advertisement policy
  • telemetry (1 byte): Telemetry mode flags
  • manual_add (1 byte): Manual contact addition mode
  • freq (4 bytes LE): Radio frequency in Hz
  • bw (4 bytes LE): Radio bandwidth in Hz
  • sf (1 byte): Spreading factor
  • cr (1 byte): Coding rate
  • name (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 key
  • type (1 byte): Contact type (1=chat, 2=repeater, 3=room, 4=sensor)
  • flags (1 byte): Contact flags
  • path_len (1 byte): Path length (0xFF = flood mode)
  • path (64 bytes): Routing path data
  • name (32 bytes): Contact name, null-terminated UTF-8
  • timestamp (4 bytes LE): Last seen timestamp
  • lat (4 bytes LE): Latitude × 1,000,000
  • lon (4 bytes LE): Longitude × 1,000,000
  • lastmod (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 ratio
  • res (2 bytes): Reserved
  • prefix (6 bytes): Sender's public key prefix
  • path_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 variants
  • text (variable): Message text, null-terminated

Text decoding:

  1. Try reading at base offset (timestamp + 4)
  2. If empty and room for extra bytes, try offset + 4
  3. Check for SMAZ compression prefix
  4. 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 ratio
  • res (2 bytes): Reserved
  • channel_idx (1 byte): Channel index
  • path_len (1 byte): Path length
  • txt_type (1 byte): Text type
  • timestamp (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 path
  • ack_hash (4 bytes): Hash for matching future ACK
  • timeout_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_SENT
  • trip_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 millivolts
  • storage_used_kb (4 bytes LE): Used storage in kilobytes
  • storage_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 version
  • max_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 Hz
  • bw (4 bytes LE): Bandwidth in Hz
  • sf (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:

  1. CMD_SIGN_START - Initialize signing session
  2. CMD_SIGN_DATA (multiple) - Add data chunks (max 8KB total)
  3. 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:

  1. Parse header to get route type
  2. Extract path and payload
  3. 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:

  1. Parse reaction from incoming message
  2. Find target message by messageId
  3. Increment emoji counter in target message's reactions map
  4. Don't display reaction as a separate message

Message Replies

Format: "@[node_name] [actual_message]"

Example: "@Alice Hello there!"

Processing:

  1. Parse reply mention from message text
  2. Find most recent message from mentioned sender
  3. Attach reply metadata to new message:
    • replyToMessageId
    • replyToSenderName
    • replyToText

Message Retry and ACK Tracking

The app implements automatic retry for failed messages:

Flow:

  1. Send message → Receive RESP_CODE_SENT with ack_hash and timeout_ms
  2. Start timeout timer
  3. On timeout: Retry with incremented attempt counter
  4. 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 Hz
  • cr: 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:

  1. PathHistoryService tracks success/failure per path
  2. On message send, select next path variant
  3. Record attempt and outcome
  4. Rotate to next path on retry
  5. 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:

  1. Compute channel hash: sha256(psk)[0]
  2. Encrypt payload with AES-128-ECB using first 16 bytes of PSK
  3. Compute HMAC-SHA256 of ciphertext using 32-byte padded PSK
  4. Prepend 2-byte MAC to ciphertext
  5. Prepend channel hash byte

Format:

[channel_hash][mac x2][ciphertext...]

Decryption (from PUSH_CODE_LOG_RX_DATA)

  1. Extract channel hash from payload
  2. Try each known channel's PSK
  3. Verify 2-byte HMAC prefix
  4. Decrypt with AES-128-ECB
  5. 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:

  1. Wait for RESP_CODE_SELF_INFO (device ready)
  2. Wait for RESP_CODE_END_OF_CONTACTS (contacts loaded)
  3. Send CMD_SYNC_NEXT_MESSAGE
  4. Process each message/response
  5. Send next CMD_SYNC_NEXT_MESSAGE
  6. Continue until RESP_CODE_NO_MORE_MESSAGES

Trigger: Also triggered by PUSH_CODE_MSG_WAITING notification.

Error Handling

Frame Validation

All frame handlers validate:

  1. Minimum frame length
  2. Expected data offsets
  3. Null-termination of strings
  4. 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:

  1. Cancel all subscriptions
  2. Clear device references (but preserve ID/name for reconnection)
  3. Clear in-memory contacts and conversations
  4. Reset sync state flags
  5. 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 cache
  • MessageStore: Per-contact message history
  • ChannelMessageStore: Per-channel message history
  • ContactSettingsStore: Per-contact settings (SMAZ, etc.)
  • ChannelSettingsStore: Per-channel settings
  • UnreadStore: 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_WAITING to trigger sync
  • Channel messages can be evicted if queue full (oldest first)
  • Contact messages are preserved over channel messages

Sync flow:

  1. App sends CMD_SYNC_NEXT_MESSAGE
  2. Firmware sends oldest queued frame
  3. App sends another CMD_SYNC_NEXT_MESSAGE
  4. 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:

  1. Computes expected_ack hash from message
  2. Stores in table with send timestamp
  3. On ACK receipt, computes trip time
  4. Sends PUSH_CODE_SEND_CONFIRMED to 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