Files
meshcore-sar_android/MESSAGING_IMPLEMENTATION_GUIDE.md
Janez T 05b90d7f1f feat: Implement default destination selection for SAR Update Sheet
- Added logic to set the default destination to the first available room, or the first channel if no rooms exist.
- This enhancement improves user experience by automatically selecting a relevant destination upon initialization.
2025-10-14 22:58:33 +02:00

39 KiB

MeshCore Messaging System Implementation Guide

Complete reference for implementing message sending and receiving in the Flutter app

Date: 2025-01-14 Source: MeshCore C++ firmware (v1.9.1, firmware code 7) Files analyzed:

  • /Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp
  • /Users/dz0ny/meshcore-sar/MeshCore/examples/simple_room_server/MyMesh.cpp
  • /Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp
  • /Users/dz0ny/meshcore-sar/MeshCore/src/Packet.h

Table of Contents

  1. Message Types Overview
  2. Sending Messages
  3. Receiving Messages
  4. Room Login Protocol
  5. Message Confirmation Flow
  6. Binary Protocol Specifications
  7. Implementation Requirements
  8. Common Pitfalls
  9. Complete Example Flows

Message Types Overview

Three Message Types

  1. Direct Messages (DM)

    • Protocol: CMD_SEND_TXT_MSG (code 2) → PAYLOAD_TYPE_TXT_MSG (0x02)
    • Routing: Uses contact's out_path if available, otherwise flood mode
    • Confirmation: Receives ACK from recipient
    • Use case: Person-to-person messages, messages to rooms
    • Persistence: Only persisted if sent to a room (ADV_TYPE_ROOM)
  2. Channel Messages (Public Broadcast)

    • Protocol: CMD_SEND_CHANNEL_TXT_MSG (code 3) → PAYLOAD_TYPE_GRP_TXT (0x05)
    • Routing: Always flood mode (broadcast to all nodes)
    • Confirmation: No ACK (fire-and-forget)
    • Use case: Public announcements, SAR markers to all nodes
    • Persistence: EPHEMERAL - not stored anywhere!
  3. Room Messages (Persistent Storage)

    • Protocol: Same as Direct Messages but recipient is ADV_TYPE_ROOM contact
    • Routing: Uses room's out_path (after login establishes it)
    • Confirmation: Receives ACK from room server
    • Use case: Persistent SAR markers, logged communications
    • Persistence: IMMUTABLE storage in room's flash memory

CRITICAL: Channels vs Rooms

Channels (Ephemeral Broadcast)

Definition: Numeric identifiers (0 = "Public Channel") for over-the-air broadcasts

Characteristics:

  • Messages broadcast via radio, NOT stored anywhere
  • No login required
  • No persistence - if a node is offline, it misses the message
  • Flood routing only (no direct paths)
  • No ACK/confirmation
  • Channel 0 = "Public Channel" (default)
  • Channel 1+ = Reserved for future use

Protocol:

CMD_SEND_CHANNEL_TXT_MSG (code 3)
[0x03] - Command code
[1 byte] - Text type (TXT_TYPE_PLAIN = 0)
[1 byte] - Channel index (0 for public)
[4 bytes] - Sender timestamp (uint32 LE, epoch seconds)
[N bytes] - Text (remainder, max 160 - name_len - 2)

Rooms (Persistent Storage)

Definition: Actual contacts with public keys that provide database-backed storage

Characteristics:

  • Messages sent as direct messages to the room contact
  • Requires login with password
  • Persistent and immutable - stored in room's flash even when offline
  • Room pushes messages to clients automatically after login
  • Provides ACK confirmation
  • Supports admin/guest permissions

How to identify rooms:

  • contact.type == ADV_TYPE_ROOM (value 3)
  • Appears in Contacts tab
  • Has 32-byte public key
  • Requires password to access

Protocol to send to room:

Use CMD_SEND_TXT_MSG with room's public key (same as person-to-person message)

Sending Messages

1. Send Direct Message to Contact

File: lib/services/meshcore_ble_service.dart:1409-1427

Protocol (CMD_SEND_TXT_MSG, code 2):

[0x02] - Command code
[1 byte] - Text type (TXT_TYPE_PLAIN=0, TXT_TYPE_CLI_DATA=1, TXT_TYPE_SIGNED_PLAIN=2)
[1 byte] - Attempt number (0-3 for retries)
[4 bytes] - Sender timestamp (uint32 LE, epoch seconds UTC)
[6 bytes] - Recipient public key PREFIX (first 6 bytes only!)
[N bytes] - Message text (remainder, UTF-8, max 160 bytes)

Example:

Future<void> sendTextMessage({
  required Uint8List contactPublicKey,
  required String text,
  int textType = 0, // TXT_TYPE_PLAIN
  int attempt = 0,
}) async {
  if (text.length > 160) {
    throw ArgumentError('Text message exceeds 160 character limit');
  }

  final writer = BufferWriter();
  writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
  writer.writeByte(textType); // TXT_TYPE_*
  writer.writeByte(attempt); // 0-3
  writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds
  writer.writeBytes(contactPublicKey.sublist(0, 6)); // ONLY first 6 bytes!
  writer.writeString(text);
  await _writeData(writer.toBytes());
}

Response (RESP_CODE_SENT, code 6):

[0x06] - Response code
[1 byte] - Send type (1=flood, 0=direct)
[4 bytes] - Expected ACK code or TAG (for matching confirmation later)
[4 bytes] - Suggested timeout (uint32 LE, milliseconds)

Source:

  • Command composition: MyMesh.cpp:818-862 (Companion Radio)
  • Protocol implementation: BaseChatMesh.cpp:334-351 (Core library)
  • Message packet creation: BaseChatMesh.cpp:312-332 (composeMsgPacket)

2. Send Channel Message (Public Broadcast)

File: lib/services/meshcore_ble_service.dart:1439-1456

Protocol (CMD_SEND_CHANNEL_TXT_MSG, code 3):

[0x03] - Command code
[1 byte] - Text type (TXT_TYPE_PLAIN=0)
[1 byte] - Channel index (0 for 'public')
[4 bytes] - Sender timestamp (uint32 LE, epoch seconds)
[N bytes] - Message text (remainder, max ~140 chars depending on sender name)

Example:

Future<void> sendChannelMessage({
  required int channelIdx,
  required String text,
  int textType = 0, // TXT_TYPE_PLAIN
}) async {
  if (text.length > 160) {
    throw ArgumentError('Channel message too long');
  }

  final writer = BufferWriter();
  writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
  writer.writeByte(textType); // TXT_TYPE_*
  writer.writeByte(channelIdx); // 0 for 'public' channel
  writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
  writer.writeString(text);
  await _writeData(writer.toBytes());
}

Note: Channel messages do NOT receive ACK confirmations. They are fire-and-forget broadcasts.

Source:

  • Command handling: MyMesh.cpp:863-882 (Companion Radio)
  • Message composition: BaseChatMesh.cpp:379-398 (sendGroupMessage)

3. Send Message to Room (Persistent Storage)

CRITICAL: To send persistent messages to a room, use CMD_SEND_TXT_MSG (direct message) with the room's public key, NOT channel messages.

// ✅ CORRECT - Sends to room for persistent storage
await sendTextMessage(
  contactPublicKey: roomContact.publicKey, // Room's 32-byte public key
  text: 'S:🧑:46.0569,14.5058', // SAR marker
);

// ❌ WRONG - Ephemeral broadcast, NOT stored in room
await sendChannelMessage(
  channelIdx: 0,
  text: 'S:🧑:46.0569,14.5058',
);

Why?

  • Rooms are contacts with type == ADV_TYPE_ROOM (value 3)
  • Rooms receive direct messages and store them in flash memory
  • Channel messages are over-the-air only and never stored
  • SAR markers MUST be persistent for search coordination

Receiving Messages

Message Queue Architecture

The companion radio maintains an internal message queue in memory:

  • Messages received over the air are added to queue
  • Queue holds messages until app fetches them
  • Max queue size: OFFLINE_QUEUE_SIZE (typically 16 messages)
  • When full, oldest channel messages are deleted first

Message Receive Flow

1. Message received over radio
   ↓
2. Companion radio decrypts and validates
   ↓
3. Companion radio adds to internal queue
   ↓
4. Companion radio sends PUSH_CODE_MSG_WAITING (0x83) to app
   ↓
5. App's onMessageWaiting callback fires
   ↓
6. App calls CMD_SYNC_NEXT_MESSAGE (10)
   ↓
7. Companion radio sends RESP_CODE_CONTACT_MSG_RECV or RESP_CODE_CHANNEL_MSG_RECV
   ↓
8. App processes message and adds to UI
   ↓
9. Repeat steps 6-8 until RESP_CODE_NO_MORE_MESSAGES (10)

1. Handle Message Waiting Notification

Protocol (PUSH_CODE_MSG_WAITING, code 0x83):

[0x83] - Push code
(no additional data)

Implementation:

// Set up callback in connection initialization
_bleService.onMessageWaiting = () {
  print('📨 New message(s) waiting in companion radio queue');
  // Start fetching messages from queue
  _fetchAllPendingMessages();
};

Future<void> _fetchAllPendingMessages() async {
  bool hasMore = true;
  while (hasMore) {
    try {
      await _bleService.syncNextMessage();
      // Wait for response (RESP_CODE_CONTACT_MSG_RECV or RESP_CODE_NO_MORE_MESSAGES)
      // Response is handled by _onDataReceived()
      await Future.delayed(Duration(milliseconds: 100)); // Small delay between requests
    } catch (e) {
      print('Error fetching message: $e');
      hasMore = false;
    }
  }
}

Source: MyMesh.cpp:363-367, 428-436 (companion radio queue push notification)

2. Sync Next Message

Protocol (CMD_SYNC_NEXT_MESSAGE, code 10):

[0x0A] - Command code
(no additional data)

Implementation:

Future<void> syncNextMessage() async {
  final writer = BufferWriter();
  writer.writeByte(MeshCoreConstants.cmdSyncNextMessage); // 0x0A
  await _writeData(writer.toBytes());
}

Source: MyMesh.cpp:1056-1066 (companion radio command handler)

3. Parse Contact Message Response

Protocol (RESP_CODE_CONTACT_MSG_RECV, code 7):

[0x07] - Response code
[6 bytes] - Sender public key PREFIX (first 6 bytes)
[1 byte] - Path length (0xFF if direct, else hop count)
[1 byte] - Text type (TXT_TYPE_*)
[4 bytes] - Sender timestamp (uint32 LE, epoch seconds)
[N bytes] - Message text (remainder, null-terminated)

For signed messages (TXT_TYPE_SIGNED_PLAIN):

[0x07] - Response code
[6 bytes] - Sender public key PREFIX
[1 byte] - Path length
[1 byte] - Text type (TXT_TYPE_SIGNED_PLAIN = 2)
[4 bytes] - Sender timestamp
[4 bytes] - Author public key prefix (first 4 bytes of original author)
[N bytes] - Message text (remainder)

Implementation (file: lib/services/meshcore_ble_service.dart:512-578):

void _handleContactMessage(BufferReader reader) {
  try {
    final pubKeyPrefix = reader.readBytes(6);
    final pathLen = reader.readByte();
    final txtTypeByte = reader.readByte();
    final txtType = MessageTextType.fromValue(txtTypeByte);
    final senderTimestamp = reader.readUInt32LE();

    String text;
    Uint8List? authorPrefix;

    if (txtType == MessageTextType.signedPlain) {
      // Signed message: next 4 bytes are author prefix
      authorPrefix = reader.readBytes(4);
      text = reader.readString();
    } else {
      // Plain message
      text = reader.readString();
    }

    final message = Message(
      id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
      messageType: MessageType.contact,
      senderPublicKeyPrefix: pubKeyPrefix,
      pathLen: pathLen,
      textType: txtType,
      senderTimestamp: senderTimestamp,
      text: text,
      receivedAt: DateTime.now(),
      authorPublicKeyPrefix: authorPrefix, // For signed messages from rooms
    );

    onMessageReceived?.call(message);
  } catch (e) {
    print('Error parsing contact message: $e');
    onError?.call('Contact message parsing error: $e');
  }
}

Source: MyMesh.cpp:334-379 (companion radio message queueing)

4. Parse Channel Message Response

Protocol (RESP_CODE_CHANNEL_MSG_RECV, code 8):

[0x08] - Response code
[1 byte] - Channel index (0 for 'public')
[1 byte] - Path length (0xFF if direct, else hop count)
[1 byte] - Text type (TXT_TYPE_*)
[4 bytes] - Sender timestamp (uint32 LE, epoch seconds)
[N bytes] - Message text (remainder, null-terminated)

Implementation (file: lib/services/meshcore_ble_service.dart:581-647):

void _handleChannelMessage(BufferReader reader) {
  try {
    final channelIdx = reader.readInt8();
    final pathLen = reader.readByte();
    final txtTypeByte = reader.readByte();
    final txtType = MessageTextType.fromValue(txtTypeByte);
    final senderTimestamp = reader.readUInt32LE();
    final text = reader.readString();

    final message = Message(
      id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
      messageType: MessageType.channel,
      channelIdx: channelIdx,
      pathLen: pathLen,
      textType: txtType,
      senderTimestamp: senderTimestamp,
      text: text,
      receivedAt: DateTime.now(),
    );

    onMessageReceived?.call(message);
  } catch (e) {
    print('Error parsing channel message: $e');
    onError?.call('Channel message parsing error: $e');
  }
}

Source: MyMesh.cpp:401-446 (companion radio channel message queueing)

5. Handle No More Messages

Protocol (RESP_CODE_NO_MORE_MESSAGES, code 10):

[0x0A] - Response code
(no additional data)

Implementation:

case MeshCoreConstants.respNoMoreMessages:
  print('No more messages in queue');
  onNoMoreMessages?.call();
  break;

Source: MyMesh.cpp:1063-1065 (companion radio queue empty response)


Room Login Protocol

CRITICAL: Understanding Room Message Push

How room message sync works:

  1. Client sends login request with sync_since timestamp
  2. Room server stores client->extra.room.sync_since value
  3. Room server AUTOMATICALLY PUSHES messages where post_timestamp > sync_since
  4. Room server uses round-robin polling every 1200ms (SYNC_PUSH_INTERVAL)
  5. Room server waits for ACK before advancing to next message
  6. Client receives pushed messages via normal PUSH_CODE_MSG_WAITING flow

What the app MUST do:

  • Wait for PUSH_CODE_MSG_WAITING notifications
  • Call syncNextMessage() when notified
  • Continue until RESP_CODE_NO_MORE_MESSAGES

What the app MUST NOT do:

  • DON'T call syncNextMessage() immediately after PUSH_CODE_LOGIN_SUCCESS
  • DON'T try to "pull" messages manually
  • DON'T implement a timer to check for messages

Room Login Flow

1. App: CMD_SEND_LOGIN (26) with password and sync_since
   ↓
2. Radio: Sends PAYLOAD_TYPE_ANON_REQ to room
   ↓
3. Room: Validates password, stores sync_since
   ↓
4. Room: Sends login response back
   ↓
5. App: Receives PUSH_CODE_LOGIN_SUCCESS (0x85)
   ↓
6. Room: Delays 2000ms (PUSH_NOTIFY_DELAY_MILLIS)
   ↓
7. Room: Starts round-robin message push loop (every 1200ms)
   ↓
8. For each logged-in client:
     If post_timestamp > client.sync_since:
       - Room calls pushPostToClient()
       - Sends PAYLOAD_TYPE_TXT_MSG to client
       - Waits for ACK
       - Advances client.sync_since
       - Continues to next message
   ↓
9. Radio: Receives pushed message from room
   ↓
10. Radio: Adds to internal queue
    ↓
11. Radio: Sends PUSH_CODE_MSG_WAITING (0x83) to app
    ↓
12. App: onMessageWaiting callback fires
    ↓
13. App: Calls syncNextMessage() to fetch from queue
    ↓
14. App: Receives RESP_CODE_CONTACT_MSG_RECV with message
    ↓
15. Repeat steps 7-14 until all messages pushed

Login Request Protocol

Protocol (CMD_SEND_LOGIN, code 26):

[0x1A] - Command code (26)
[4 bytes] - Sender timestamp (uint32 LE, current epoch seconds)
[4 bytes] - sync_since timestamp (uint32 LE, epoch seconds - 0 for all messages)
[32 bytes] - Room public key
[N bytes] - Password (max 15 bytes, null-terminated)

Implementation (file: lib/services/meshcore_ble_service.dart:1616-1642):

Future<void> loginToRoom({
  required Uint8List roomPublicKey,
  required String password,
  int syncSince = 0, // 0 = get all messages
}) async {
  if (password.length > 15) {
    throw ArgumentError('Password exceeds 15 character limit');
  }

  final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // epoch seconds

  final writer = BufferWriter();
  writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
  writer.writeUInt32LE(now); // sender timestamp
  writer.writeUInt32LE(syncSince); // sync messages since this timestamp (0 = all)
  writer.writeBytes(roomPublicKey); // 32 bytes
  writer.writeString(password); // Max 15 bytes, null-terminated
  await _writeData(writer.toBytes());
}

Source:

  • Login request composition: BaseChatMesh.cpp:431-464
  • Companion radio command handler: MyMesh.cpp:1196-1217
  • Room server login processing: simple_room_server/MyMesh.cpp:282-363 (lines 286-324 critical)

Login Success Response

Protocol (PUSH_CODE_LOGIN_SUCCESS, code 0x85):

[0x85] - Push code
[1 byte] - Permissions (lowest bit = is_admin)
[6 bytes] - Room public key prefix (first 6 bytes)
[4 bytes] - Tag (int32 LE)
[1 byte] - (V7+) New permissions

Implementation (file: lib/services/meshcore_ble_service.dart:1175-1206):

void _handleLoginSuccess(BufferReader reader) {
  try {
    if (reader.remainingBytesCount >= 11) {
      final permissions = reader.readByte();
      final isAdmin = (permissions & 0x01) != 0;
      final publicKeyPrefix = reader.readBytes(6);
      final tag = reader.readInt32LE();

      // V7+ new permissions byte
      int? newPermissions;
      if (reader.hasRemaining) {
        newPermissions = reader.readByte();
      }

      print('✅ Successfully logged into room');
      onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);

      // DO NOT call syncAllMessages() here!
      // Wait for PUSH_CODE_MSG_WAITING instead
    }
  } catch (e) {
    print('Login success parsing error: $e');
    onError?.call('Login success parsing error: $e');
  }
}

Source:

  • Companion radio response parsing: MyMesh.cpp:496-525
  • Room server login success response: simple_room_server/MyMesh.cpp:335-346

Login Fail Response

Protocol (PUSH_CODE_LOGIN_FAIL, code 0x86):

[0x86] - Push code
[1 byte] - Reserved (zero)
[6 bytes] - Room public key prefix

Source: Room server validation: simple_room_server/MyMesh.cpp:303-314

Room Message Push Implementation

Room server C++ code (simple_room_server/MyMesh.cpp:777-820):

// Round-robin polling every SYNC_PUSH_INTERVAL (1200ms)
void MyMesh::loop() {
  mesh::Mesh::loop();

  if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) {
    // Check for ACK timeouts
    for (int i = 0; i < acl.getNumClients(); i++) {
      auto c = acl.getClientByIdx(i);
      if (c->extra.room.pending_ack && millisHasNowPassed(c->extra.room.ack_timeout)) {
        c->extra.room.push_failures++;
        c->extra.room.pending_ack = 0; // reset
      }
    }

    // Check next Round-Robin client, and sync next new post
    auto client = acl.getClientByIdx(next_client_idx);
    bool did_push = false;

    if (client->extra.room.pending_ack == 0 &&  // not waiting for ACK
        client->last_activity != 0 &&            // not evicted
        client->extra.room.push_failures < 3) {  // retries not maxed

      uint32_t now = getRTCClock()->getCurrentTime();
      for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) {
        auto p = &posts[idx];
        if (now >= p->post_timestamp + POST_SYNC_DELAY_SECS &&
            p->post_timestamp > client->extra.room.sync_since &&  // is new post?
            !p->author.matches(client->id)) {                      // don't push to author

          // Push this post to Client, then wait for ACK
          pushPostToClient(client, *p);
          did_push = true;
          break;
        }
        idx = (idx + 1) % MAX_UNSYNCED_POSTS; // wrap cyclic queue
      }
    }

    next_client_idx = (next_client_idx + 1) % acl.getNumClients(); // round robin

    if (did_push) {
      next_push = futureMillis(SYNC_PUSH_INTERVAL); // 1200ms
    } else {
      next_push = futureMillis(SYNC_PUSH_INTERVAL / 8); // faster when no pushes
    }
  }
  // ... rest of loop
}

Key constants:

  • PUSH_NOTIFY_DELAY_MILLIS = 2000ms (initial delay after login)
  • SYNC_PUSH_INTERVAL = 1200ms (time between push attempts)
  • POST_SYNC_DELAY_SECS = 6 (wait 6 seconds after post before pushing)

Message Confirmation Flow

ACK Protocol for Direct Messages

When you send a direct message, you receive an expected ACK code that you should match later.

1. Send Message

await sendTextMessage(
  contactPublicKey: contact.publicKey,
  text: 'Hello!',
);

2. Receive RESP_CODE_SENT

[0x06] - Response code
[1 byte] - Send type (1=flood, 0=direct)
[4 bytes] - Expected ACK code (store this!)
[4 bytes] - Suggested timeout (milliseconds)

3. Wait for PUSH_CODE_SEND_CONFIRMED

[0x82] - Push code
[4 bytes] - ACK code (match with expected ACK from step 2)
[4 bytes] - Round trip time (uint32 LE, milliseconds)

Implementation:

// Store expected ACKs
final Map<String, ExpectedAck> _expectedAcks = {};

// When sending
void _handleSentConfirmation(BufferReader reader) {
  final sendType = reader.readByte();
  final expectedAckOrTag = reader.readBytes(4);
  final suggestedTimeout = reader.readUInt32LE();

  // Store for matching later
  final ackKey = expectedAckOrTag.map((b) => b.toRadixString(16)).join();
  _expectedAcks[ackKey] = ExpectedAck(
    timestamp: DateTime.now(),
    timeout: Duration(milliseconds: suggestedTimeout),
  );

  // Set timeout
  Future.delayed(Duration(milliseconds: suggestedTimeout), () {
    if (_expectedAcks.containsKey(ackKey)) {
      _expectedAcks.remove(ackKey);
      print('⏱️ Message timeout - no ACK received');
      // Notify UI of timeout
    }
  });
}

// When confirmation arrives
void _handleSendConfirmed(BufferReader reader) {
  final ackCode = reader.readBytes(4);
  final roundTripTime = reader.readUInt32LE();

  final ackKey = ackCode.map((b) => b.toRadixString(16)).join();
  if (_expectedAcks.containsKey(ackKey)) {
    _expectedAcks.remove(ackKey);
    print('✅ Message confirmed! RTT: ${roundTripTime}ms');
    // Notify UI of successful delivery
  }
}

Source:

  • Expected ACK calculation: BaseChatMesh.cpp:323 (SHA256 hash of message)
  • ACK table management: MyMesh.cpp:316-332 (companion radio)
  • Confirmation push: MyMesh.cpp:320-324

No ACK for Channel Messages

Channel messages (public broadcasts) do NOT receive ACK confirmations. They are fire-and-forget.


Binary Protocol Specifications

All Integer Types are Little Endian!

CRITICAL: All multi-byte integers in MeshCore protocol use Little Endian byte order!

// ✅ CORRECT - Little Endian
writer.writeUInt32LE(timestamp);
writer.writeInt32LE(latitude);

// ❌ WRONG - Big Endian (will cause protocol errors!)
writer.writeUInt32BE(timestamp);

Text Type Enum

TXT_TYPE_PLAIN = 0        // Plain text message
TXT_TYPE_CLI_DATA = 1     // CLI command (admin only)
TXT_TYPE_SIGNED_PLAIN = 2 // Plain text, signed by original author

Source: TxtDataHelpers.h:6-8

Contact Types (ADV_TYPE)

ADV_TYPE_NONE = 0      // Unknown/invalid
ADV_TYPE_CHAT = 1      // Team member (person-to-person)
ADV_TYPE_REPEATER = 2  // Network repeater node
ADV_TYPE_ROOM = 3      // Room/server with persistent storage

Source: AdvertDataHelpers.h (inferred from protocol)

Message Length Limits

MAX_TEXT_LEN = 160 bytes  // For direct messages

Channel messages: 160 - len(advert_name) - 2 bytes
Typical: ~140 bytes if name is 18 chars

Source:

  • BaseChatMesh.h:8 (MAX_TEXT_LEN definition)
  • MyMesh.cpp:870 (channel message validation)

Public Key Handling

CRITICAL: Different commands use different public key lengths!

CMD_SEND_TXT_MSG:         6 bytes (prefix only!)
CMD_SEND_LOGIN:          32 bytes (full key)
CMD_ADD_UPDATE_CONTACT:  32 bytes (full key)
RESP_CODE_CONTACT:       32 bytes (full key)
RESP_CODE_CONTACT_MSG_RECV: 6 bytes (prefix)

Why? To save bandwidth, message sends use only 6-byte prefix for recipient identification. The companion radio looks up the full 32-byte key from its internal contact table.


Implementation Requirements

What the App MUST Implement

  1. Message Queue Handling

    • Respond to PUSH_CODE_MSG_WAITING by calling syncNextMessage()
    • Continue calling syncNextMessage() until RESP_CODE_NO_MORE_MESSAGES
    • Handle both RESP_CODE_CONTACT_MSG_RECV and RESP_CODE_CHANNEL_MSG_RECV
  2. Room Login Flow

    • Send CMD_SEND_LOGIN with password and sync_since
    • Wait for PUSH_CODE_LOGIN_SUCCESS or PUSH_CODE_LOGIN_FAIL
    • After login success, DO NOTHING - room will push messages automatically
    • Handle pushed messages via normal PUSH_CODE_MSG_WAITING flow
  3. Contact Management

    • Call CMD_GET_CONTACTS after connection to sync contacts
    • Use CMD_ADD_UPDATE_CONTACT to add rooms that haven't advertised
    • Store contacts locally for offline access
  4. Message Sending

    • Use CMD_SEND_TXT_MSG for direct messages and room messages
    • Use CMD_SEND_CHANNEL_TXT_MSG only for ephemeral public broadcasts
    • Store expected ACK codes from RESP_CODE_SENT
    • Match ACKs in PUSH_CODE_SEND_CONFIRMED to mark messages as delivered
  5. Signed Messages (from Rooms)

    • Parse TXT_TYPE_SIGNED_PLAIN messages correctly
    • Extract 4-byte author prefix after sender timestamp
    • Show original author in UI, not room's public key

What the App MUST NOT Do

  1. DON'T call syncNextMessage() immediately after PUSH_CODE_LOGIN_SUCCESS

    • The room server delays pushes by 2000ms
    • Calling sync immediately will get RESP_CODE_NO_MORE_MESSAGES
    • Wait for PUSH_CODE_MSG_WAITING instead!
  2. DON'T send SAR markers as channel messages

    • Channel messages are ephemeral (not stored)
    • SAR markers MUST be persistent
    • Use CMD_SEND_TXT_MSG to room contacts instead
  3. DON'T use Big Endian for integers

    • All multi-byte integers MUST be Little Endian
    • Check your BufferWriter implementation!
  4. DON'T send full 32-byte public key in CMD_SEND_TXT_MSG

    • Only send first 6 bytes (prefix)
    • Sending 32 bytes will cause protocol error
  5. DON'T retry login immediately on failure

    • Wait at least 5 seconds between retries
    • Excessive retries may get you blocked by room

Common Pitfalls

1. Incorrect Room Message Routing

Problem: Sending SAR markers to public channel instead of room

// ❌ WRONG - Ephemeral, not stored
await sendChannelMessage(
  channelIdx: 0,
  text: 'S:🧑:46.0569,14.5058',
);

// ✅ CORRECT - Persistent in room
await sendTextMessage(
  contactPublicKey: roomContact.publicKey,
  text: 'S:🧑:46.0569,14.5058',
);

2. Calling syncNextMessage() Too Early

Problem: Calling syncNextMessage() right after login success

// ❌ WRONG
onLoginSuccess = (prefix, perms, isAdmin, tag) async {
  await syncAllMessages(); // Too early! Room hasn't pushed yet
};

// ✅ CORRECT
onLoginSuccess = (prefix, perms, isAdmin, tag) {
  print('Login successful, waiting for message pushes...');
  // Don't call syncNextMessage() - wait for PUSH_CODE_MSG_WAITING
};

onMessageWaiting = () async {
  // Now fetch messages
  await fetchAllPendingMessages();
};

Why? Room server delays first push by 2000ms (PUSH_NOTIFY_DELAY_MILLIS). Calling sync immediately gets RESP_CODE_NO_MORE_MESSAGES.

3. Using Wrong Public Key Length

Problem: Sending 32-byte public key in CMD_SEND_TXT_MSG

// ❌ WRONG
writer.writeBytes(contactPublicKey); // 32 bytes

// ✅ CORRECT
writer.writeBytes(contactPublicKey.sublist(0, 6)); // Only first 6 bytes

4. Big Endian vs Little Endian

Problem: Using wrong byte order for integers

// ❌ WRONG - Big Endian
writer.writeUInt32BE(timestamp);

// ✅ CORRECT - Little Endian
writer.writeUInt32LE(timestamp);

All integers in MeshCore protocol are Little Endian!

5. Not Handling Signed Messages

Problem: Displaying room public key as sender instead of original author

// In room messages, sender is the room, but author is in message data

if (txtType == MessageTextType.signedPlain) {
  // Next 4 bytes are original author's public key prefix
  final authorPrefix = reader.readBytes(4);
  // Show authorPrefix as sender, not room's pubKeyPrefix
}

6. Room Contact Not in Companion Radio Table

Problem: Getting ERR_CODE_NOT_FOUND when trying to login

Solution: Add room contact to companion radio first:

// Before login, ensure room contact exists
await _bleService.addOrUpdateContact(roomContact);

// Wait a moment for contact to be added
await Future.delayed(Duration(milliseconds: 500));

// Now login
await _bleService.loginToRoom(
  roomPublicKey: roomContact.publicKey,
  password: 'mypassword',
);

7. Not Handling Message Queue Overflow

Problem: Offline queue fills up (16 messages max), oldest messages lost

Solution: Fetch messages promptly when PUSH_CODE_MSG_WAITING arrives. Don't delay message fetching.


Complete Example Flows

Example 1: Send SAR Marker to Room (Persistent)

// 1. Ensure we have room contact
final room = contacts.firstWhere(
  (c) => c.type == ContactType.room && c.advName == 'SAR Room',
  orElse: () => throw Exception('Room not found'),
);

// 2. Add room to companion radio if not already there
await _bleService.addOrUpdateContact(room);
await Future.delayed(Duration(milliseconds: 500));

// 3. Login to room
await _bleService.loginToRoom(
  roomPublicKey: room.publicKey,
  password: 'sarpassword',
  syncSince: 0, // Get all historical messages
);

// 4. Wait for login success
// (onLoginSuccess callback will fire)

// 5. Send SAR marker as direct message to room
await _bleService.sendTextMessage(
  contactPublicKey: room.publicKey,
  text: 'S:🧑:46.0569,14.5058',
);

// 6. Wait for RESP_CODE_SENT and store expected ACK
// (sendTextMessage will return immediately)

// 7. Wait for PUSH_CODE_SEND_CONFIRMED to confirm delivery
// (onSendConfirmed callback will fire when room ACKs)

Example 2: Receive Messages from Room

// Set up callbacks
_bleService.onLoginSuccess = (prefix, perms, isAdmin, tag) {
  print('✅ Logged into room successfully');
  print('   Waiting for automatic message push from room...');
  // DO NOT call syncNextMessage() here!
};

_bleService.onMessageWaiting = () async {
  print('📨 Message(s) waiting, fetching from queue...');
  await _fetchAllPendingMessages();
};

_bleService.onMessageReceived = (message) {
  if (message.textType == MessageTextType.signedPlain) {
    // This is a room message - show original author
    print('Room message from author: ${message.authorPublicKeyPrefix}');
  } else {
    // Normal direct message
    print('Direct message from: ${message.senderPublicKeyPrefix}');
  }

  // Add to UI
  setState(() {
    _messages.add(message);
  });
};

_bleService.onNoMoreMessages = () {
  print('✅ All messages fetched from queue');
};

// Fetch all pending messages from queue
Future<void> _fetchAllPendingMessages() async {
  while (true) {
    try {
      await _bleService.syncNextMessage();

      // Wait for response
      await Future.delayed(Duration(milliseconds: 100));

      // If RESP_CODE_NO_MORE_MESSAGES, onNoMoreMessages callback fires
      // and we can break (but it's safer to let it timeout naturally)
    } catch (e) {
      print('Error fetching message: $e');
      break;
    }
  }
}

Example 3: Send to Public Channel (Ephemeral Broadcast)

// This is for emergency broadcasts that ALL nodes should see immediately
// But it's NOT stored anywhere!

await _bleService.sendChannelMessage(
  channelIdx: 0, // 0 = "Public Channel"
  text: 'Emergency: Flash flood warning!',
);

// No ACK confirmation - fire and forget
// Offline nodes will never see this message

Example 4: Handle Login Failure and Retry

int _loginAttempts = 0;
const maxLoginAttempts = 3;

_bleService.onLoginFail = (prefix) async {
  print('❌ Login failed to room: $prefix');

  _loginAttempts++;
  if (_loginAttempts < maxLoginAttempts) {
    print('   Retrying in 5 seconds... (attempt ${_loginAttempts + 1}/$maxLoginAttempts)');

    await Future.delayed(Duration(seconds: 5));

    // Retry login
    await _bleService.loginToRoom(
      roomPublicKey: roomContact.publicKey,
      password: _password,
    );
  } else {
    print('   Max login attempts reached. Check password.');
    // Show error to user
  }
};

File References

All source code locations in MeshCore C++ firmware:

Companion Radio Implementation

  • Command handler: /Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp
    • Lines 818-862: CMD_SEND_TXT_MSG handler
    • Lines 863-882: CMD_SEND_CHANNEL_TXT_MSG handler
    • Lines 1056-1066: CMD_SYNC_NEXT_MESSAGE handler
    • Lines 1196-1217: CMD_SEND_LOGIN handler
    • Lines 334-379: Message queueing (queueMessage)
    • Lines 316-332: ACK processing (processAck)

Room Server Implementation

  • Room server: /Users/dz0ny/meshcore-sar/MeshCore/examples/simple_room_server/MyMesh.cpp
    • Lines 282-363: Login processing (onAnonDataRecv)
    • Lines 286-324: Password validation and client setup
    • Lines 335-346: Login success response
    • Lines 777-820: Message push loop (loop())
    • Lines 53-89: Push message to client (pushPostToClient)
    • Lines 91-100: Count unsynced messages (getUnsyncedCount)
    • Lines 102-113: ACK processing for pushed messages (processAck)

Core Library

  • Message sending: /Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp
    • Lines 334-351: Send direct message (sendMessage)
    • Lines 379-398: Send channel message (sendGroupMessage)
    • Lines 431-464: Send login request (sendLogin)
    • Lines 312-332: Compose message packet (composeMsgPacket)
    • Lines 143-233: Receive and process messages (onPeerDataRecv)

Protocol Definitions

  • Packet types: /Users/dz0ny/meshcore-sar/MeshCore/src/Packet.h
    • Lines 19-31: Payload type definitions
    • Lines 14-17: Route type definitions

Flutter App Implementation Status

Current Implementation (Correct)

BLE Service (lib/services/meshcore_ble_service.dart):

  • Lines 1409-1427: sendTextMessage() - correctly sends direct messages
  • Lines 1439-1456: sendChannelMessage() - correctly sends channel broadcasts
  • Lines 1616-1642: loginToRoom() - correctly sends login with sync_since
  • Lines 1479-1483: syncNextMessage() - correctly fetches from queue
  • Lines 1368-1398: addOrUpdateContact() - correctly adds room contacts
  • Lines 512-578: _handleContactMessage() - correctly parses contact messages
  • Lines 581-647: _handleChannelMessage() - correctly parses channel messages
  • Lines 1157-1166: _handleMsgWaiting() - correctly triggers onMessageWaiting callback
  • Lines 1175-1206: _handleLoginSuccess() - correctly parses login success

Issues to Fix

Room login state management (lib/models/room_login_state.dart):

  • This file exists but implementation details not reviewed yet
  • Ensure state machine doesn't call syncNextMessage() immediately after login success

Message routing decision (lib/screens/messages_tab.dart):

  • Need to check if SAR markers are being sent to rooms vs channels
  • Line 49 (_sendMessage): Verify routing logic

Connection provider (lib/providers/connection_provider.dart):

  • Lines 123, 600, 608, 643: Message waiting and sync handling
  • Verify syncNextMessage() is only called when onMessageWaiting fires
  • Check if there's any premature syncing after login
  1. Review connection_provider.dart message sync logic
  2. Review messages_tab.dart for SAR marker routing
  3. Add ACK tracking for message delivery confirmation
  4. Implement retry logic for failed logins
  5. Add UI indicators for message delivery status (sending/sent/confirmed/failed)

Testing Checklist

Room Login Testing

  • Login succeeds with correct password
  • Login fails with incorrect password
  • PUSH_CODE_MSG_WAITING arrives after 2+ seconds
  • Calling syncNextMessage() before push returns NO_MORE_MESSAGES
  • Room pushes all messages where post_timestamp > sync_since
  • Room pushes continue until all messages delivered
  • Room doesn't push messages to original author
  • Second login with higher sync_since only gets new messages

Message Sending Testing

  • Direct message to person succeeds
  • Direct message to room succeeds
  • Channel message broadcasts successfully
  • SAR marker sent to room is persistent
  • SAR marker sent to channel is NOT persistent (verify by rebooting)
  • Message length limit (160 bytes) enforced
  • Public key prefix (6 bytes) used in direct messages
  • Expected ACK code received in RESP_CODE_SENT
  • PUSH_CODE_SEND_CONFIRMED arrives after message delivery
  • Round-trip time is reasonable (<10 seconds typically)

Message Receiving Testing

  • Contact message received and displayed
  • Channel message received and displayed
  • Signed message shows original author, not room
  • PUSH_CODE_MSG_WAITING triggers message fetch
  • Multiple messages fetched from queue in sequence
  • RESP_CODE_NO_MORE_MESSAGES stops fetch loop
  • Offline messages queued (up to 16) and delivered later
  • Message timestamps are correct (UTC epoch seconds)

Error Handling Testing

  • ERR_CODE_NOT_FOUND when room contact missing
  • ERR_CODE_NOT_FOUND resolved by adding contact
  • Login timeout handled gracefully
  • Message send timeout detected
  • Queue overflow handled (oldest messages dropped)
  • Clock drift detected and corrected
  • Invalid message format doesn't crash app

Glossary

ACK (Acknowledgement): Confirmation packet sent by recipient to prove message delivery

Companion Radio: The MeshCore hardware device that handles LoRa radio communication

Contact: An entity in the mesh network (person, repeater, or room) with a 32-byte public key

Direct Message: Message sent point-to-point to a specific contact using their public key prefix

Channel Message: Broadcast message sent flood-mode to all nodes listening to a channel

Epoch Seconds: Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)

Flood Mode: Routing where message is rebroadcast by all nodes to cover entire network

Little Endian: Byte order where least significant byte comes first (used for all integers)

Public Key Prefix: First 6 bytes of a contact's 32-byte Ed25519 public key

Room: A server contact (ADV_TYPE_ROOM) that provides persistent message storage

sync_since: Timestamp used by rooms to determine which messages to push to client

TAG: Random unique identifier used to match requests with responses


Version History

  • v1.0 (2025-01-14): Initial documentation based on MeshCore firmware v1.9.1

Credits

This guide is based on analysis of the MeshCore firmware source code:

For questions or corrections, please refer to the source code comments.