From 05b90d7f1fee87ad395bcb111cec06756d23a51f Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 22:58:33 +0200 Subject: [PATCH] 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. --- MESSAGING_IMPLEMENTATION_GUIDE.md | 1276 +++++++++++++++++++++++++++++ lib/screens/messages_tab.dart | 23 + 2 files changed, 1299 insertions(+) create mode 100644 MESSAGING_IMPLEMENTATION_GUIDE.md diff --git a/MESSAGING_IMPLEMENTATION_GUIDE.md b/MESSAGING_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..af3343a --- /dev/null +++ b/MESSAGING_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,1276 @@ +# 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](#message-types-overview) +2. [Sending Messages](#sending-messages) +3. [Receiving Messages](#receiving-messages) +4. [Room Login Protocol](#room-login-protocol) +5. [Message Confirmation Flow](#message-confirmation-flow) +6. [Binary Protocol Specifications](#binary-protocol-specifications) +7. [Implementation Requirements](#implementation-requirements) +8. [Common Pitfalls](#common-pitfalls) +9. [Complete Example Flows](#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**: +```dart +Future 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**: +```dart +Future 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. + +```dart +// ✅ 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**: +```dart +// Set up callback in connection initialization +_bleService.onMessageWaiting = () { + print('📨 New message(s) waiting in companion radio queue'); + // Start fetching messages from queue + _fetchAllPendingMessages(); +}; + +Future _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**: +```dart +Future 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`): +```dart +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`): +```dart +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**: +```dart +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`): +```dart +Future 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`): +```dart +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`): +```cpp +// 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** +```dart +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**: +```dart +// Store expected ACKs +final Map _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! + +```dart +// ✅ 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 + +```dart +// ❌ 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 + +```dart +// ❌ 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` + +```dart +// ❌ 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 + +```dart +// ❌ 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 + +```dart +// 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: + +```dart +// 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) + +```dart +// 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 + +```dart +// 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 _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) + +```dart +// 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 + +```dart +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 + +### Recommended Next Steps + +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: +- MeshCore firmware: https://github.com/meshcore-dev/meshcore +- Firmware version: v1.9.1 (firmware code 7, build date: 2 Oct 2025) +- Protocol specification: Derived from C++ source code analysis + +For questions or corrections, please refer to the source code comments. diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index d5ea6a6..eebe612 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -627,6 +627,29 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> { void initState() { super.initState(); _getCurrentLocation(); + _setDefaultDestination(); + } + + void _setDefaultDestination() { + // Set default to first room, or first channel if no rooms exist + WidgetsBinding.instance.addPostFrameCallback((_) { + final contactsProvider = context.read(); + final destinations = contactsProvider.roomsAndChannels; + + if (destinations.isNotEmpty) { + // Prefer rooms over channels + final room = destinations.firstWhere( + (c) => c.isRoom, + orElse: () => destinations.first, + ); + + if (mounted) { + setState(() { + _selectedContact = room; + }); + } + } + }); } @override