Skip to content

BLE protocol — full specification

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.

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

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 start with a response code, followed by response-specific data.

Format: [response_code][data...]

Push Frames (Device → App, Asynchronous)

Section titled “Push Frames (Device → App, Asynchronous)”

Push frames are unsolicited notifications from the device, using codes ≥ 0x80.

Format: [push_code][data...]

Commands sent from the app to the device:

CodeNameDescription
0x01CMD_APP_STARTRegister application with device
0x02CMD_SEND_TXT_MSGSend direct text message to contact
0x03CMD_SEND_CHANNEL_TXT_MSGSend text message to channel
0x04CMD_GET_CONTACTSRequest contact list
0x05CMD_GET_DEVICE_TIMEGet device’s current time
0x06CMD_SET_DEVICE_TIMESync device time
0x07CMD_SEND_SELF_ADVERTBroadcast self advertisement
0x08CMD_SET_ADVERT_NAMESet node display name
0x09CMD_ADD_UPDATE_CONTACTAdd/update contact with custom path
0x0ACMD_SYNC_NEXT_MESSAGERequest next queued message
0x0BCMD_SET_RADIO_PARAMSConfigure LoRa radio settings
0x0CCMD_SET_RADIO_TX_POWERSet transmit power
0x0DCMD_RESET_PATHClear contact’s routing path
0x0ECMD_SET_ADVERT_LATLONSet node GPS coordinates
0x0FCMD_REMOVE_CONTACTDelete contact from device
0x10CMD_SHARE_CONTACTShare contact via mesh
0x11CMD_EXPORT_CONTACTExport contact data
0x12CMD_IMPORT_CONTACTImport contact data
0x13CMD_REBOOTReboot device
0x14CMD_GET_BATT_AND_STORAGERequest battery and storage info
0x15CMD_SET_TUNING_PARAMSSet device tuning parameters
0x16CMD_DEVICE_QUERYQuery device capabilities
0x17CMD_EXPORT_PRIVATE_KEYExport device private key (secure)
0x18CMD_IMPORT_PRIVATE_KEYImport device private key (secure)
0x19CMD_SEND_RAW_DATASend raw data to contact
0x1ACMD_SEND_LOGINAuthenticate to repeater
0x1BCMD_SEND_STATUS_REQRequest status from repeater
0x1CCMD_HAS_CONNECTIONCheck if connection exists to contact
0x1DCMD_LOGOUTDisconnect from repeater
0x1ECMD_GET_CONTACT_BY_KEYGet specific contact by public key
0x1FCMD_GET_CHANNELGet channel configuration
0x20CMD_SET_CHANNELConfigure channel
0x21CMD_SIGN_STARTStart signing operation
0x22CMD_SIGN_DATAAdd data to be signed
0x23CMD_SIGN_FINISHFinish signing and get signature
0x24CMD_SEND_TRACE_PATHSend path trace request
0x25CMD_SET_DEVICE_PINSet device PIN for pairing
0x26CMD_SET_OTHER_PARAMSSet miscellaneous parameters
0x27CMD_SEND_TELEMETRY_REQRequest telemetry data (deprecated)
0x28CMD_GET_CUSTOM_VARSGet custom variables
0x29CMD_SET_CUSTOM_VARSet custom variable
0x2ACMD_GET_ADVERT_PATHGet advertisement path for contact
0x2BCMD_GET_TUNING_PARAMSGet device tuning parameters
0x32CMD_SEND_BINARY_REQSend binary request to contact
0x33CMD_FACTORY_RESETFactory reset device
0x34CMD_SEND_PATH_DISCOVERY_REQRequest path discovery
0x36CMD_SET_FLOOD_SCOPESet flood routing scope (v8+)
0x37CMD_SEND_CONTROL_DATASend control data (v8+)
0x38CMD_GET_STATSGet statistics (v8+, sub-types: core/radio/packets)
0x39CMD_GET_RADIO_SETTINGSGet current radio parameters

Responses from device to app:

CodeNameDescription
0x00RESP_CODE_OKGeneric success
0x01RESP_CODE_ERRGeneric error
0x02RESP_CODE_CONTACTS_STARTBeginning of contact list
0x03RESP_CODE_CONTACTContact entry
0x04RESP_CODE_END_OF_CONTACTSEnd of contact list
0x05RESP_CODE_SELF_INFODevice identity and settings
0x06RESP_CODE_SENTMessage sent (includes ACK hash)
0x07RESP_CODE_CONTACT_MSG_RECVReceived direct message (v1/v2)
0x08RESP_CODE_CHANNEL_MSG_RECVReceived channel message (v1/v2)
0x09RESP_CODE_CURR_TIMECurrent device time
0x0ARESP_CODE_NO_MORE_MESSAGESQueue empty
0x0BRESP_CODE_EXPORT_CONTACTExported contact data
0x0CRESP_CODE_BATT_AND_STORAGEBattery and storage status
0x0DRESP_CODE_DEVICE_INFODevice capabilities
0x0ERESP_CODE_PRIVATE_KEYExported private key
0x0FRESP_CODE_DISABLEDFeature disabled
0x10RESP_CODE_CONTACT_MSG_RECV_V3Received direct message (v3)
0x11RESP_CODE_CHANNEL_MSG_RECV_V3Received channel message (v3)
0x12RESP_CODE_CHANNEL_INFOChannel configuration
0x13RESP_CODE_SIGN_STARTSigning operation started
0x14RESP_CODE_SIGNATUREDigital signature result
0x15RESP_CODE_CUSTOM_VARSCustom variables data
0x16RESP_CODE_ADVERT_PATHAdvertisement path data
0x17RESP_CODE_TUNING_PARAMSTuning parameters
0x18RESP_CODE_STATSStatistics data (v8+)
0x19RESP_CODE_RADIO_SETTINGSRadio parameters

Asynchronous notifications from device:

CodeNameDescription
0x80PUSH_CODE_ADVERTAdvertisement received
0x81PUSH_CODE_PATH_UPDATEDContact path changed
0x82PUSH_CODE_SEND_CONFIRMEDMessage ACK received
0x83PUSH_CODE_MSG_WAITINGNew messages in queue
0x84PUSH_CODE_RAW_DATARaw data received from contact
0x85PUSH_CODE_LOGIN_SUCCESSRepeater login succeeded
0x86PUSH_CODE_LOGIN_FAILRepeater login failed
0x87PUSH_CODE_STATUS_RESPONSERepeater status response
0x88PUSH_CODE_LOG_RX_DATARaw LoRa packet log
0x89PUSH_CODE_TRACE_DATAPath trace response
0x8APUSH_CODE_NEW_ADVERTNew contact advertisement
0x8BPUSH_CODE_TELEMETRY_RESPONSETelemetry data response
0x8CPUSH_CODE_BINARY_RESPONSEBinary request response
0x8DPUSH_CODE_PATH_DISCOVERY_RESPONSEPath discovery response
0x8EPUSH_CODE_CONTROL_DATAControl data received (v8+)

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]

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)

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())

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)

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

Synchronizes device clock with app.

Format:

[0x06][timestamp x4]

Fields:

  • timestamp (4 bytes LE): Current Unix timestamp in seconds

Sets the device’s display name for advertisements.

Format:

[0x08][name...]

Fields:

  • name (variable): UTF-8 name, max 31 bytes (truncated if longer)

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

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

Clears a contact’s custom path, reverting to flood mode.

Format:

[0x0D][pub_key x32]

Fields:

  • pub_key (32 bytes): Contact’s public key

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)

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

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)

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

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

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]"

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

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

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

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);

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

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)

Section titled “Advanced Commands (Not Yet Implemented in Flutter)”

The following commands are supported by the firmware but not yet implemented in the Flutter app:

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

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

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)

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

Set flood routing scope (v8+).

Purpose: Configure geographic or logical boundaries for flood routing to reduce mesh congestion.

Format:

[0x36][scope_data...]

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)

Section titled “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)

Section titled “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)

Set miscellaneous device parameters (battery chemistry, telemetry mode, etc.).

Format:

[0x26][param_data...]

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

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

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.

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

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

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).

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)

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

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

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

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

Channels use symmetric encryption with pre-shared keys (PSK).

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...]
  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

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;

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).

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.

enum MeshCoreConnectionState {
disconnected, // Not connected
scanning, // BLE scan in progress
connecting, // Connection attempt in progress
connected, // Fully connected and initialized
disconnecting, // Disconnect in progress
}

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.

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.

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.

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).

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)

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.

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

MeshCoreConnector uses Flutter’s ChangeNotifier:

  • All state changes trigger notifyListeners()
  • BLE callbacks run on main isolate
  • No explicit locking required (single-threaded)

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.

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

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.

The firmware implements the protocol through the MyMesh class which extends BaseChatMesh. Key implementation notes:

#define MAX_FRAME_SIZE 172 // BLE MTU constraint
#define MAX_TEXT_LEN (10*CIPHER_BLOCK_SIZE) // 160 bytes

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.

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

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.

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.

#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
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)
#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]

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().

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
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.

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.

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
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.