From 59de6272894922a9fa270b964773af37751202ea Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 14 Oct 2025 15:27:18 +0200 Subject: [PATCH] feat: Add Packet Log Screen for BLE packet logging and exporting - Implemented PacketLogScreen to display and filter BLE packet logs. - Added functionality to export logs as CSV and text files. - Introduced clipboard copy feature for hex data. - Implemented clear logs functionality with confirmation dialog. - Enhanced MeshCoreBleService to log TX and RX packets with descriptions. - Added BufferReader methods for reading unsigned and signed 16-bit integers (big-endian). - Updated CayenneLppParser to read values as big-endian. - Created MessageStorageService for persisting messages to local storage. - Enhanced map markers to display telemetry data including voltage, humidity, and pressure. --- .claude/settings.local.json | 4 +- MESHCORE_BLE_PROTOCOL.md | 1224 +++++++++++++++++++++ MESHCORE_PROTOCOL.md | 1163 ++++++++++++++++++++ MESHCORE_QUICK_REFERENCE.md | 216 ++++ lib/main.dart | 29 +- lib/models/ble_packet_log.dart | 52 + lib/providers/app_provider.dart | 33 + lib/providers/connection_provider.dart | 73 +- lib/providers/messages_provider.dart | 58 + lib/screens/contacts_tab.dart | 63 +- lib/screens/home_screen.dart | 60 +- lib/screens/map_tab.dart | 10 +- lib/screens/message_history_screen.dart | 490 +++++++++ lib/screens/messages_tab.dart | 659 +++++++++-- lib/screens/packet_log_screen.dart | 568 ++++++++++ lib/services/buffer_reader.dart | 16 + lib/services/cayenne_lpp_parser.dart | 24 +- lib/services/meshcore_ble_service.dart | 195 +++- lib/services/message_storage_service.dart | 164 +++ lib/widgets/map_markers.dart | 12 +- 20 files changed, 4960 insertions(+), 153 deletions(-) create mode 100644 MESHCORE_BLE_PROTOCOL.md create mode 100644 MESHCORE_PROTOCOL.md create mode 100644 MESHCORE_QUICK_REFERENCE.md create mode 100644 lib/models/ble_packet_log.dart create mode 100644 lib/screens/message_history_screen.dart create mode 100644 lib/screens/packet_log_screen.dart create mode 100644 lib/services/message_storage_service.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f34e154..c29312b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,9 @@ "Bash(flutter pub get:*)", "Bash(flutter run:*)", "Bash(nc:*)", - "Bash(pkill:*)" + "Bash(pkill:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)" ], "deny": [], "ask": [] diff --git a/MESHCORE_BLE_PROTOCOL.md b/MESHCORE_BLE_PROTOCOL.md new file mode 100644 index 0000000..65455f2 --- /dev/null +++ b/MESHCORE_BLE_PROTOCOL.md @@ -0,0 +1,1224 @@ +# MeshCore BLE Protocol Specification + +Complete BLE command/response protocol extracted from [meshcore.js Connection class](https://github.com/meshcore-dev/meshcore.js). + +## Overview + +The MeshCore BLE protocol provides a command/response interface for smartphone applications to interact with MeshCore devices over Bluetooth Low Energy. This is a **separate protocol** from the mesh packet protocol used for LoRa radio communication. + +**Protocol Characteristics:** +- Uses Nordic UART Service (NUS) profile +- Simple command/response model +- App acts as BLE central, device acts as peripheral +- Commands sent over RX characteristic +- Responses/events received over TX characteristic +- Supports asynchronous push notifications + +--- + +## Service Specification + +**Service UUID:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` + +| Characteristic | UUID | Properties | Direction | Description | +|----------------|------|------------|-----------|-------------| +| RX | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` | Write, Write Without Response | App → Device | Commands | +| TX | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` | Notify | Device → App | Responses & Events | + +--- + +## Protocol Structure + +### Command Frame Format +``` +[Command Code: 1B] [Parameters...] +``` + +### Response Frame Format +``` +[Response Code: 1B] [Data...] +``` + +### Push Notification Format +``` +[Push Code: 1B] [Data...] +``` + +--- + +## Command Codes + +Commands sent from app to device over RX characteristic: + +| Code | Name | Description | +|------|------|-------------| +| - | `AppStart` | Initialize connection, get device info | +| - | `SendTxtMsg` | Send text message to contact | +| - | `SendChannelTxtMsg` | Send text message to channel | +| - | `GetContacts` | Request list of contacts | +| - | `GetDeviceTime` | Get device's current time | +| - | `SetDeviceTime` | Set device's current time | +| - | `SendSelfAdvert` | Broadcast advertisement | +| - | `SetAdvertName` | Set device's advertised name | +| - | `AddUpdateContact` | Add or update contact details | +| - | `SyncNextMessage` | Retrieve next queued message | +| - | `SetRadioParams` | Configure LoRa radio parameters | +| - | `SetTxPower` | Set transmit power | +| - | `ResetPath` | Reset routing path for contact | +| - | `SetAdvertLatLon` | Set device's GPS coordinates | +| - | `RemoveContact` | Delete contact from device | +| - | `ShareContact` | Send contact to mesh network | +| - | `ExportContact` | Export contact as packet bytes | +| - | `ImportContact` | Import contact from packet bytes | +| - | `Reboot` | Reboot device | +| - | `GetBatteryVoltage` | Read battery voltage | +| - | `DeviceQuery` | Query device firmware info | +| - | `ExportPrivateKey` | Export device's private key | +| - | `ImportPrivateKey` | Import private key to device | +| - | `SendRawData` | Send raw mesh packet | +| - | `SendLogin` | Login to repeater/room | +| - | `SendStatusReq` | Request repeater status | +| - | `SendTelemetryReq` | Request telemetry from contact | +| - | `SendBinaryReq` | Send binary request | +| - | `GetChannel` | Get channel configuration | +| - | `SetChannel` | Set channel configuration | +| - | `SignStart` | Start signing data | +| - | `SignData` | Send data chunk to sign | +| - | `SignFinish` | Finish signing, get signature | +| - | `SendTracePath` | Trace network path | +| - | `SetOtherParams` | Set miscellaneous parameters | + +--- + +## Response Codes + +Responses sent from device to app over TX characteristic: + +| Code | Name | Description | +|------|------|-------------| +| - | `Ok` | Command succeeded | +| - | `Err` | Command failed | +| - | `SelfInfo` | Device information | +| - | `CurrTime` | Current device time | +| - | `NoMoreMessages` | Message queue empty | +| - | `ContactMsgRecv` | Contact message received | +| - | `ChannelMsgRecv` | Channel message received | +| - | `ContactsStart` | Start of contacts list | +| - | `Contact` | Contact entry | +| - | `EndOfContacts` | End of contacts list | +| - | `Sent` | Message sent to mesh | +| - | `ExportContact` | Exported contact data | +| - | `BatteryVoltage` | Battery voltage reading | +| - | `DeviceInfo` | Firmware version info | +| - | `PrivateKey` | Exported private key | +| - | `Disabled` | Feature disabled | +| - | `ChannelInfo` | Channel configuration | +| - | `SignStart` | Signing session started | +| - | `Signature` | Ed25519 signature | + +--- + +## Push Codes + +Asynchronous events pushed from device to app: + +| Code | Name | Description | +|------|------|-------------| +| - | `Advert` | Advertisement broadcast | +| - | `PathUpdated` | Routing path updated | +| - | `SendConfirmed` | Message ACK received | +| - | `MsgWaiting` | Messages queued | +| - | `RawData` | Raw packet received | +| - | `LoginSuccess` | Login succeeded | +| - | `StatusResponse` | Status data received | +| - | `LogRxData` | Raw RX data (debug) | +| - | `TelemetryResponse` | Telemetry data received | +| - | `TraceData` | Path trace completed | +| - | `NewAdvert` | New contact advertised | +| - | `BinaryResponse` | Binary request response | + +--- + +## Command Details + +### AppStart +Initialize BLE connection and retrieve device information. + +**Format:** +``` +[AppStart] [1B: appVer] [6B: reserved] [string: appName] +``` + +**Parameters:** +- `appVer` (uint8): App protocol version (e.g., 1) +- `reserved` (6 bytes): Reserved for future use +- `appName` (string): Application name (null-terminated) + +**Response:** `SelfInfo` + +**Example:** +```javascript +await connection.sendCommandAppStart(); +// Sends: [CMD][0x01][00 00 00 00 00 00]["test\0"] +``` + +--- + +### SendTxtMsg +Send a text message to a contact. + +**Format:** +``` +[SendTxtMsg] [1B: txtType] [1B: attempt] [4B: timestamp] [6B: pubKeyPrefix] [string: text] +``` + +**Parameters:** +- `txtType` (uint8): Message type (0=Plain, 1=Emergency, etc.) +- `attempt` (uint8): Retry attempt number (usually 0) +- `timestamp` (uint32 LE): Unix timestamp +- `pubKeyPrefix` (6 bytes): First 6 bytes of recipient's public key +- `text` (string): UTF-8 message text (null-terminated) + +**Response:** `Sent` + +**Example:** +```javascript +const txtType = 0; // Plain +const attempt = 0; +const timestamp = Math.floor(Date.now() / 1000); +await connection.sendCommandSendTxtMsg(txtType, attempt, timestamp, contactPublicKey, "Hello!"); +``` + +--- + +### SendChannelTxtMsg +Send a text message to a group channel. + +**Format:** +``` +[SendChannelTxtMsg] [1B: txtType] [1B: channelIdx] [4B: timestamp] [string: text] +``` + +**Parameters:** +- `txtType` (uint8): Message type +- `channelIdx` (uint8): Channel index (0-255) +- `timestamp` (uint32 LE): Unix timestamp +- `text` (string): UTF-8 message text + +**Response:** `Ok` or `Err` + +--- + +### GetContacts +Request list of all contacts from device. + +**Format:** +``` +[GetContacts] [4B: since]? +``` + +**Parameters:** +- `since` (uint32 LE, optional): Only return contacts modified after this timestamp + +**Response Sequence:** +1. `ContactsStart` (with count) +2. Multiple `Contact` responses +3. `EndOfContacts` + +**Example:** +```javascript +const contacts = await connection.getContacts(); +// Returns array of contact objects +``` + +--- + +### GetDeviceTime +Get current time from device. + +**Format:** +``` +[GetDeviceTime] +``` + +**Response:** `CurrTime` + +**Response Format:** +``` +[CurrTime] [4B: epochSecs] +``` + +--- + +### SetDeviceTime +Set device's current time. + +**Format:** +``` +[SetDeviceTime] [4B: epochSecs] +``` + +**Parameters:** +- `epochSecs` (uint32 LE): Unix timestamp + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +await connection.syncDeviceTime(); // Sets to current system time +``` + +--- + +### SendSelfAdvert +Broadcast advertisement to mesh network. + +**Format:** +``` +[SendSelfAdvert] [1B: type] +``` + +**Parameters:** +- `type` (uint8): Advertisement type + - `Flood`: Broadcast to entire network + - `ZeroHop`: Only adjacent nodes + +**Response:** `Ok` or `Err` + +**Push:** `Advert` (when broadcast begins) + +**Example:** +```javascript +await connection.sendFloodAdvert(); // Network-wide +await connection.sendZeroHopAdvert(); // Adjacent only +``` + +--- + +### SetAdvertName +Set device's advertised name. + +**Format:** +``` +[SetAdvertName] [string: name] +``` + +**Parameters:** +- `name` (string): Display name (null-terminated, max 31 chars) + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +await connection.setAdvertName("Alice"); +``` + +--- + +### AddUpdateContact +Add new contact or update existing contact. + +**Format:** +``` +[AddUpdateContact] [32B: publicKey] [1B: type] [1B: flags] [1B: outPathLen] + [64B: outPath] [32B: advName] [4B: lastAdvert] + [4B: advLat] [4B: advLon] +``` + +**Parameters:** +- `publicKey` (32 bytes): Ed25519 public key +- `type` (uint8): Contact type (0=none, 1=chat, 2=repeater, 3=room) +- `flags` (uint8): Contact flags +- `outPathLen` (int8): Length of routing path +- `outPath` (64 bytes): Routing path (padded) +- `advName` (32 bytes): Name as C-string +- `lastAdvert` (uint32 LE): Last advertisement timestamp +- `advLat` (uint32 LE): GPS latitude (×10000) +- `advLon` (uint32 LE): GPS longitude (×10000) + +**Response:** `Ok` or `Err` + +--- + +### SyncNextMessage +Retrieve next message from device's queue. + +**Format:** +``` +[SyncNextMessage] +``` + +**Response:** One of: +- `ContactMsgRecv` - Message from contact +- `ChannelMsgRecv` - Message from channel +- `NoMoreMessages` - Queue empty + +**Example:** +```javascript +while (true) { + const msg = await connection.syncNextMessage(); + if (!msg) break; // No more messages + console.log(msg); +} +``` + +--- + +### SetRadioParams +Configure LoRa radio parameters. + +**Format:** +``` +[SetRadioParams] [4B: radioFreq] [4B: radioBw] [1B: radioSf] [1B: radioCr] +``` + +**Parameters:** +- `radioFreq` (uint32 LE): Frequency in Hz (e.g., 915000000) +- `radioBw` (uint32 LE): Bandwidth in Hz (e.g., 125000) +- `radioSf` (uint8): Spreading factor (7-12) +- `radioCr` (uint8): Coding rate (5-8) + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +await connection.setRadioParams( + 915000000, // 915 MHz + 125000, // 125 kHz bandwidth + 7, // SF7 + 5 // CR 4/5 +); +``` + +--- + +### SetTxPower +Set transmit power level. + +**Format:** +``` +[SetTxPower] [1B: txPower] +``` + +**Parameters:** +- `txPower` (uint8): Power level in dBm (device-specific range) + +**Response:** `Ok` or `Err` + +--- + +### ResetPath +Clear routing path for a contact (force rediscovery). + +**Format:** +``` +[ResetPath] [32B: pubKey] +``` + +**Parameters:** +- `pubKey` (32 bytes): Contact's public key + +**Response:** `Ok` or `Err` + +--- + +### SetAdvertLatLon +Set device's GPS coordinates for advertisements. + +**Format:** +``` +[SetAdvertLatLon] [4B: lat] [4B: lon] +``` + +**Parameters:** +- `lat` (int32 LE): Latitude × 10000 +- `lon` (int32 LE): Longitude × 10000 + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +// Set location to 46.0569°N, 14.5058°E +await connection.setAdvertLatLong(460569, 145058); +``` + +--- + +### RemoveContact +Delete contact from device. + +**Format:** +``` +[RemoveContact] [32B: pubKey] +``` + +**Parameters:** +- `pubKey` (32 bytes): Contact's public key + +**Response:** `Ok` or `Err` + +--- + +### ShareContact +Broadcast contact to mesh network. + +**Format:** +``` +[ShareContact] [32B: pubKey] +``` + +**Parameters:** +- `pubKey` (32 bytes): Contact to share + +**Response:** `Ok` or `Err` + +--- + +### ExportContact +Export contact as advertisement packet bytes. + +**Format:** +``` +[ExportContact] [32B: pubKey]? +``` + +**Parameters:** +- `pubKey` (32 bytes, optional): Contact to export, or omit for self + +**Response:** `ExportContact` + +**Response Format:** +``` +[ExportContact] [N bytes: advertPacketBytes] +``` + +--- + +### ImportContact +Import contact from advertisement packet bytes. + +**Format:** +``` +[ImportContact] [N bytes: advertPacketBytes] +``` + +**Parameters:** +- `advertPacketBytes`: Complete advertisement packet + +**Response:** `Ok` or `Err` + +--- + +### Reboot +Reboot the device. + +**Format:** +``` +[Reboot] [string: "reboot"] +``` + +**Response:** None (device reboots) + +**Example:** +```javascript +await connection.reboot(); +// Device will disconnect and reboot +``` + +--- + +### GetBatteryVoltage +Read device's battery voltage. + +**Format:** +``` +[GetBatteryVoltage] +``` + +**Response:** `BatteryVoltage` + +**Response Format:** +``` +[BatteryVoltage] [2B: batteryMilliVolts] +``` + +**Example:** +```javascript +const { batteryMilliVolts } = await connection.getBatteryVoltage(); +console.log(`Battery: ${batteryMilliVolts / 1000}V`); +``` + +--- + +### DeviceQuery +Query device firmware information. + +**Format:** +``` +[DeviceQuery] [1B: appTargetVer] +``` + +**Parameters:** +- `appTargetVer` (uint8): Protocol version app expects (e.g., 1) + +**Response:** `DeviceInfo` + +**Response Format:** +``` +[DeviceInfo] [1B: firmwareVer] [6B: reserved] [12B: buildDate] [string: model] +``` + +**Example:** +```javascript +const info = await connection.deviceQuery(1); +console.log(`Firmware v${info.firmwareVer}, ${info.manufacturerModel}`); +``` + +--- + +### ExportPrivateKey +Export device's Ed25519 private key. + +**Format:** +``` +[ExportPrivateKey] +``` + +**Response:** `PrivateKey` or `Disabled` + +**Response Format (PrivateKey):** +``` +[PrivateKey] [64B: privateKey] +``` + +**Security Note:** May be disabled in firmware for security. + +--- + +### ImportPrivateKey +Import Ed25519 private key to device. + +**Format:** +``` +[ImportPrivateKey] [64B: privateKey] +``` + +**Parameters:** +- `privateKey` (64 bytes): Ed25519 private key + +**Response:** `Ok`, `Err`, or `Disabled` + +**Security Note:** May be disabled in firmware. + +--- + +### SendRawData +Send raw custom data through mesh. + +**Format:** +``` +[SendRawData] [1B: pathLen] [N bytes: path] [M bytes: rawData] +``` + +**Parameters:** +- `pathLen` (uint8): Length of routing path +- `path` (N bytes): Routing path +- `rawData` (M bytes): Custom payload + +**Response:** `Ok` or `Err` + +--- + +### SendLogin +Authenticate with repeater or room server. + +**Format:** +``` +[SendLogin] [32B: publicKey] [string: password] +``` + +**Parameters:** +- `publicKey` (32 bytes): Server's public key +- `password` (string): Login password (max 15 chars) + +**Response:** `Sent` + +**Push:** `LoginSuccess` (when authenticated) + +**Example:** +```javascript +await connection.login(repeaterPublicKey, "mypassword"); +// Waits for LoginSuccess push +``` + +--- + +### SendStatusReq +Request status information from repeater. + +**Format:** +``` +[SendStatusReq] [32B: publicKey] +``` + +**Parameters:** +- `publicKey` (32 bytes): Repeater's public key + +**Response:** `Sent` + +**Push:** `StatusResponse` (with repeater stats) + +**Status Response Format:** +```javascript +{ + batt_milli_volts: uint16, // Battery voltage (mV) + curr_tx_queue_len: uint16, // Transmit queue length + noise_floor: int16, // Noise floor (dBm) + last_rssi: int16, // Last RSSI (dBm) + n_packets_recv: uint32, // Total packets received + n_packets_sent: uint32, // Total packets sent + total_air_time_secs: uint32, // Total air time (seconds) + total_up_time_secs: uint32, // Uptime (seconds) + n_sent_flood: uint32, // Flood packets sent + n_sent_direct: uint32, // Direct packets sent + n_recv_flood: uint32, // Flood packets received + n_recv_direct: uint32, // Direct packets received + err_events: uint16, // Error events + last_snr: int16, // Last SNR (×4) + n_direct_dups: uint16, // Duplicate direct packets + n_flood_dups: uint16, // Duplicate flood packets +} +``` + +--- + +### SendTelemetryReq +Request telemetry data from contact. + +**Format:** +``` +[SendTelemetryReq] [3B: reserved] [32B: publicKey] +``` + +**Parameters:** +- `reserved` (3 bytes): Reserved (set to 0) +- `publicKey` (32 bytes): Contact's public key + +**Response:** `Sent` + +**Push:** `TelemetryResponse` (with Cayenne LPP data) + +**Example:** +```javascript +const telemetry = await connection.getTelemetry(contactPublicKey); +console.log(telemetry.lppSensorData); // Parse with Cayenne LPP parser +``` + +--- + +### SendBinaryReq +Send custom binary request to contact. + +**Format:** +``` +[SendBinaryReq] [32B: publicKey] [N bytes: requestCodeAndParams] +``` + +**Parameters:** +- `publicKey` (32 bytes): Target contact +- `requestCodeAndParams` (variable): Application-specific request + +**Response:** `Sent` + +**Push:** `BinaryResponse` (with tag and response data) + +**Binary Request Types:** +- `GetNeighbours` (0x00): Query repeater for neighbor list + +--- + +### GetChannel +Get channel configuration by index. + +**Format:** +``` +[GetChannel] [1B: channelIdx] +``` + +**Parameters:** +- `channelIdx` (uint8): Channel index (0-N) + +**Response:** `ChannelInfo` or `Err` + +**Response Format:** +``` +[ChannelInfo] [1B: idx] [32B: name] [16B: secret] +``` + +**Example:** +```javascript +const channel = await connection.getChannel(0); +console.log(`Channel: ${channel.name}`); +``` + +--- + +### SetChannel +Set channel configuration. + +**Format:** +``` +[SetChannel] [1B: channelIdx] [32B: name] [16B: secret] +``` + +**Parameters:** +- `channelIdx` (uint8): Channel index +- `name` (32 bytes): Channel name as C-string +- `secret` (16 bytes): AES-128 shared key + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +// Delete channel +await connection.deleteChannel(0); +// Internally: setChannel(0, "", new Uint8Array(16)) +``` + +--- + +### SignStart +Start signing session. + +**Format:** +``` +[SignStart] +``` + +**Response:** `SignStart` + +**Response Format:** +``` +[SignStart] [1B: reserved] [4B: maxSignDataLen] +``` + +--- + +### SignData +Send data chunk to sign. + +**Format:** +``` +[SignData] [N bytes: dataToSign] +``` + +**Parameters:** +- `dataToSign`: Data chunk (max 128 bytes) + +**Response:** `Ok` (send next chunk) + +--- + +### SignFinish +Finish signing and retrieve signature. + +**Format:** +``` +[SignFinish] +``` + +**Response:** `Signature` + +**Response Format:** +``` +[Signature] [64B: signature] +``` + +**Example:** +```javascript +const signature = await connection.sign(data); +// Automatically handles chunking +``` + +--- + +### SendTracePath +Trace path through mesh network. + +**Format:** +``` +[SendTracePath] [4B: tag] [4B: auth] [1B: flags] [N bytes: path] +``` + +**Parameters:** +- `tag` (uint32 LE): Random tag for matching response +- `auth` (uint32 LE): Authentication code (usually 0) +- `flags` (uint8): Trace flags +- `path` (variable): Routing path to trace + +**Response:** `Sent` + +**Push:** `TraceData` + +**Trace Data Format:** +```javascript +{ + reserved: uint8, + pathLen: uint8, + flags: uint8, + tag: uint32, + authCode: uint32, + pathHashes: Uint8Array, // Node IDs + pathSnrs: Uint8Array, // SNR at each hop + lastSnr: float, // Final SNR (÷4) +} +``` + +**Example:** +```javascript +const trace = await connection.tracePath(path); +console.log(`Path length: ${trace.pathLen}`); +console.log(`SNRs: ${trace.pathSnrs}`); +``` + +--- + +### SetOtherParams +Set miscellaneous device parameters. + +**Format:** +``` +[SetOtherParams] [1B: manualAddContacts] +``` + +**Parameters:** +- `manualAddContacts` (uint8): 0=auto-add, 1=manual-add + +**Response:** `Ok` or `Err` + +**Example:** +```javascript +await connection.setAutoAddContacts(); // Auto-add from advertisements +await connection.setManualAddContacts(); // Require manual addition +``` + +--- + +## Binary Request Types + +Sent via `SendBinaryReq` command: + +### GetNeighbours (0x00) +Query repeater for neighbor list. + +**Request Format:** +``` +[0x00] [1B: version] [1B: count] [2B: offset] [1B: orderBy] [1B: prefixLen] [4B: random] +``` + +**Parameters:** +- `version` (uint8): Request version (0) +- `count` (uint8): Max neighbors to return +- `offset` (uint16 LE): Pagination offset +- `orderBy` (uint8): Sort order + - 0: Newest to oldest + - 1: Oldest to newest + - 2: Strongest to weakest (SNR) + - 3: Weakest to strongest +- `prefixLen` (uint8): Public key prefix length (1-32) +- `random` (uint32 LE): Random blob for hash uniqueness + +**Response Format:** +``` +[2B: totalCount] [2B: resultsCount] [repeated: neighbor entries] +``` + +**Neighbor Entry:** +``` +[N bytes: pubKeyPrefix] [4B: heardSecondsAgo] [1B: snr] +``` + +**Example:** +```javascript +const result = await connection.getNeighbours( + repeaterPublicKey, + 10, // count + 0, // offset + 2, // order by strongest + 8 // 8-byte prefix +); +console.log(`Total neighbors: ${result.totalNeighboursCount}`); +result.neighbours.forEach(n => { + console.log(` ${n.publicKeyPrefix.toString('hex')} - SNR: ${n.snr} dB`); +}); +``` + +--- + +## Response Details + +### SelfInfo +Device information response. + +**Format:** +``` +[SelfInfo] [1B: type] [1B: txPower] [1B: maxTxPower] [32B: publicKey] + [4B: advLat] [4B: advLon] [3B: reserved] [1B: manualAddContacts] + [4B: radioFreq] [4B: radioBw] [1B: radioSf] [1B: radioCr] [string: name] +``` + +**Fields:** +```javascript +{ + type: uint8, // Device type + txPower: uint8, // Current TX power (dBm) + maxTxPower: uint8, // Maximum TX power + publicKey: Uint8Array, // 32-byte public key + advLat: int32, // GPS latitude (×10000) + advLon: int32, // GPS longitude (×10000) + reserved: Uint8Array, // 3 bytes reserved + manualAddContacts: uint8, // 0=auto, 1=manual + radioFreq: uint32, // Frequency (Hz) + radioBw: uint32, // Bandwidth (Hz) + radioSf: uint8, // Spreading factor + radioCr: uint8, // Coding rate + name: string, // Device name +} +``` + +--- + +### Contact +Contact entry response. + +**Format:** +``` +[Contact] [32B: publicKey] [1B: type] [1B: flags] [1B: outPathLen] [64B: outPath] + [32B: advName] [4B: lastAdvert] [4B: advLat] [4B: advLon] [4B: lastMod] +``` + +**Fields:** +```javascript +{ + publicKey: Uint8Array, // 32-byte public key + type: uint8, // 0=none, 1=chat, 2=repeater, 3=room + flags: uint8, // Contact flags + outPathLen: int8, // Path length + outPath: Uint8Array, // 64-byte path (padded) + advName: string, // Name (32-byte C-string) + lastAdvert: uint32, // Last advertisement time + advLat: uint32, // GPS latitude (×10000) + advLon: uint32, // GPS longitude (×10000) + lastMod: uint32, // Last modification time +} +``` + +--- + +### ContactMsgRecv +Contact message received. + +**Format:** +``` +[ContactMsgRecv] [6B: pubKeyPrefix] [1B: pathLen] [1B: txtType] [4B: senderTimestamp] [string: text] +``` + +**Fields:** +```javascript +{ + pubKeyPrefix: Uint8Array, // 6-byte sender public key prefix + pathLen: uint8, // Hop count (0xFF=direct) + txtType: uint8, // Message type + senderTimestamp: uint32, // Sender's timestamp + text: string, // Message text +} +``` + +--- + +### ChannelMsgRecv +Channel message received. + +**Format:** +``` +[ChannelMsgRecv] [1B: channelIdx] [1B: pathLen] [1B: txtType] [4B: senderTimestamp] [string: text] +``` + +**Fields:** +```javascript +{ + channelIdx: int8, // Channel index (0=public) + pathLen: uint8, // Hop count (0xFF=direct) + txtType: uint8, // Message type + senderTimestamp: uint32, // Sender's timestamp + text: string, // Message text +} +``` + +--- + +### Sent +Message sent to mesh network. + +**Format:** +``` +[Sent] [1B: result] [4B: expectedAckCrc] [4B: estTimeout] +``` + +**Fields:** +```javascript +{ + result: int8, // Send result code + expectedAckCrc: uint32, // CRC for ACK matching + estTimeout: uint32, // Estimated timeout (ms) +} +``` + +--- + +## Push Notifications + +### PathUpdated +Routing path updated for contact. + +**Format:** +``` +[PathUpdated] [32B: publicKey] +``` + +--- + +### SendConfirmed +Message ACK received from network. + +**Format:** +``` +[SendConfirmed] [4B: ackCode] [4B: roundTrip] +``` + +**Fields:** +```javascript +{ + ackCode: uint32, // ACK code (matches expectedAckCrc) + roundTrip: uint32, // Round-trip time (ms) +} +``` + +--- + +### MsgWaiting +Messages queued on device. + +**Format:** +``` +[MsgWaiting] +``` + +**Action:** Call `SyncNextMessage` to retrieve. + +--- + +### NewAdvert +New contact advertised on network. + +**Format:** +``` +[NewAdvert] [32B: publicKey] [1B: type] [1B: flags] [1B: outPathLen] [64B: outPath] + [32B: advName] [4B: lastAdvert] [4B: advLat] [4B: advLon] [4B: lastMod] +``` + +(Same structure as `Contact` response) + +--- + +## Error Codes + +**Err Response Format:** +``` +[Err] [1B: errCode]? +``` + +Error codes are application-specific. Check firmware documentation for specific codes. + +--- + +## Usage Patterns + +### Initialize Connection +```javascript +// Called automatically on connect +await connection.onConnected(); +// Sends: AppStart with protocol version +``` + +### Send Message +```javascript +const contact = await connection.findContactByName("Alice"); +await connection.sendTextMessage(contact.publicKey, "Hello!"); +``` + +### Sync Messages +```javascript +const messages = await connection.getWaitingMessages(); +messages.forEach(msg => { + if (msg.contactMessage) { + console.log(`From contact: ${msg.contactMessage.text}`); + } else if (msg.channelMessage) { + console.log(`From channel: ${msg.channelMessage.text}`); + } +}); +``` + +### Monitor Events +```javascript +connection.on('NewAdvert', (data) => { + console.log(`New contact: ${data.advName}`); +}); + +connection.on('SendConfirmed', (data) => { + console.log(`Message confirmed in ${data.roundTrip}ms`); +}); +``` + +--- + +## Implementation Notes + +### Timeouts +Most commands have default timeouts. For requests expecting mesh responses: +- Use `estTimeout` from `Sent` response +- Add extra buffer time (e.g., +1000ms) +- Implement exponential backoff for retries + +### Buffering +BLE packets may arrive fragmented: +- Buffer incoming data until complete frame received +- Use frame length headers when available +- Implement packet boundary detection + +### Thread Safety +Connection is event-driven: +- Use promises for request/response pattern +- Remove event listeners after use +- Handle concurrent requests carefully + +### Security +- Verify signatures on received advertisements +- Validate public keys before import +- Sanitize user input (names, messages) +- Rate limit commands to prevent DoS + +--- + +## References + +- [meshcore.js Connection class](https://github.com/meshcore-dev/meshcore.js/blob/main/src/connection/connection.js) +- [MeshCore Firmware](https://github.com/meshcore-dev/MeshCore) +- [MESHCORE_PROTOCOL.md](MESHCORE_PROTOCOL.md) - Mesh packet protocol +- [MESHCORE_QUICK_REFERENCE.md](MESHCORE_QUICK_REFERENCE.md) - Quick reference card + +--- + +**Document Version:** 1.0 +**Last Updated:** 2025-10-14 +**Compatible with:** MeshCore firmware v1.9.0+, meshcore.js v1.x diff --git a/MESHCORE_PROTOCOL.md b/MESHCORE_PROTOCOL.md new file mode 100644 index 0000000..18c8153 --- /dev/null +++ b/MESHCORE_PROTOCOL.md @@ -0,0 +1,1163 @@ +# MeshCore Protocol Specification + +Complete protocol documentation extracted from [MeshCore C++ implementation](https://github.com/meshcore-dev/MeshCore) and [meshcore.js](https://github.com/meshcore-dev/meshcore.js). + +## Overview + +MeshCore is a mesh networking protocol designed for low-power, long-range communication using LoRa radio and BLE connectivity. This document describes two distinct protocols: + +1. **Mesh Packet Protocol** - Complex routing protocol for mesh network communication + - Supports flood and direct routing + - End-to-end encryption with Ed25519/AES-128 + - Maximum payload: 184 bytes + - Used for device-to-device communication over LoRa + +2. **BLE Command Protocol** - Simple command/response protocol for local device control + - Nordic UART Service (NUS) profile + - Commands: get contacts, send messages, request telemetry + - Used for smartphone app ↔ MeshCore device communication + +**Key Features:** +- End-to-end encryption (AES-128-CTR) +- Digital signatures (Ed25519) +- Advertisement-based node discovery +- Group messaging support +- Telemetry data (GPS, battery, temperature) +- Anonymous messaging with forward secrecy + +## Table of Contents +1. [Protocol Constants](#protocol-constants) +2. [Packet Structure](#packet-structure) +3. [Header Encoding](#header-encoding) +4. [Payload Types](#payload-types) + - [Payload Structures by Type](#payload-structures-by-type) + - [Advertisement App Data Format](#advertisement-app-data-format) +5. [Route Types](#route-types) +6. [Cryptography](#cryptography) +7. [Binary Serialization](#binary-serialization) +8. [Validation Rules](#validation-rules) +9. [Special Features](#special-features) +10. [JavaScript Implementation Notes](#javascript-implementation-notes) +11. [BLE Transport Layer](#ble-transport-layer) + +--- + +## Protocol Constants + +### Size Limits + +| Constant | Value | Description | +|----------|-------|-------------| +| `MAX_PACKET_PAYLOAD` | 184 bytes | Maximum payload data size | +| `MAX_PATH_SIZE` | 64 bytes | Maximum routing path size | +| `MAX_TRANS_UNIT` | 255 bytes | Maximum transmission unit | +| `MAX_ADVERT_DATA_SIZE` | 32 bytes | Maximum advertisement data | +| `MAX_HASH_SIZE` | 8 bytes | Maximum hash size for routing | +| `PATH_HASH_SIZE` | 1 byte | Path hash size (V1) | + +### Cryptographic Sizes + +| Constant | Value | Description | +|----------|-------|-------------| +| `PUB_KEY_SIZE` | 32 bytes | Ed25519 public key size | +| `PRV_KEY_SIZE` | 64 bytes | Ed25519 private key size | +| `SEED_SIZE` | 32 bytes | Key generation seed size | +| `SIGNATURE_SIZE` | 64 bytes | Ed25519 signature size | +| `CIPHER_KEY_SIZE` | 16 bytes | AES-128 key size | +| `CIPHER_BLOCK_SIZE` | 16 bytes | AES block size | +| `CIPHER_MAC_SIZE` | 2 bytes | Message authentication code size (V1) | + +### Derived Constants + +```cpp +MAX_COMBINED_PATH = MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE + = 184 - 2 - 16 = 166 bytes +``` + +--- + +## Packet Structure + +### Member Variables + +```cpp +class Packet { + uint8_t header; // 1 byte: route type, payload type, version + uint16_t payload_len; // 2 bytes: payload data length + uint16_t path_len; // 2 bytes: routing path length + uint16_t transport_codes[2]; // 4 bytes: optional transport metadata + uint8_t path[MAX_PATH_SIZE]; // 64 bytes: routing path buffer + uint8_t payload[MAX_PACKET_PAYLOAD]; // 184 bytes: payload data buffer + int8_t _snr; // 1 byte: signal-to-noise ratio (×4) +}; +``` + +### Binary Layout (Wire Format) + +``` ++-------------------+--------+ +| Header | 1 byte | ++-------------------+--------+ +| Transport Code[0] | 2 bytes| (conditional, only if ROUTE_TYPE_TRANSPORT_*) +| Transport Code[1] | 2 bytes| ++-------------------+--------+ +| Path Length | 1 byte | ++-------------------+--------+ +| Path Data | N bytes| (N = path_len, max 64) ++-------------------+--------+ +| Payload Data | M bytes| (M = payload_len, max 184) ++-------------------+--------+ +``` + +**Total Packet Size Formula:** +``` +size = 2 + path_len + payload_len + (hasTransportCodes() ? 4 : 0) +``` + +**Minimum Packet Size:** 2 bytes (header + path_len with empty path and payload) +**Maximum Packet Size:** 250 bytes (2 + 4 + 64 + 184) + +--- + +## Header Encoding + +The header byte encodes three fields using bit manipulation: + +``` +Bit Layout: ++--------+--------+--------+--------+--------+--------+--------+--------+ +| Ver1 | Ver0 | Type3 | Type2 | Type1 | Type0 | Route1 | Route0 | ++--------+--------+--------+--------+--------+--------+--------+--------+ + Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0 +``` + +### Encoding Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `PH_ROUTE_MASK` | 0x03 | Mask for route type (bits 0-1) | +| `PH_TYPE_SHIFT` | 2 | Left shift for payload type | +| `PH_TYPE_MASK` | 0x0F | Mask for payload type (4 bits) | +| `PH_VER_SHIFT` | 6 | Left shift for payload version | +| `PH_VER_MASK` | 0x03 | Mask for payload version (bits 6-7) | + +### Encoding/Decoding Operations + +**Encoding:** +```cpp +header = (route_type & PH_ROUTE_MASK) | + ((payload_type & PH_TYPE_MASK) << PH_TYPE_SHIFT) | + ((payload_ver & PH_VER_MASK) << PH_VER_SHIFT); +``` + +**Decoding:** +```cpp +route_type = header & PH_ROUTE_MASK; +payload_type = (header >> PH_TYPE_SHIFT) & PH_TYPE_MASK; +payload_ver = (header >> PH_VER_SHIFT) & PH_VER_MASK; +``` + +### Example Header Values + +| Route | Type | Ver | Binary | Hex | Description | +|-------|------|-----|--------|-----|-------------| +| FLOOD | REQ | V1 | 00000001 | 0x01 | Flood routed request, version 1 | +| DIRECT | TXT_MSG | V1 | 00001010 | 0x0A | Direct text message, version 1 | +| TRANSPORT_FLOOD | ACK | V1 | 00001100 | 0x0C | Flood ACK with transport codes | + +--- + +## Payload Types + +| Name | Value | Description | Use Case | +|------|-------|-------------|----------| +| `PAYLOAD_TYPE_REQ` | 0x00 | Request message | Command or query to peer | +| `PAYLOAD_TYPE_RESPONSE` | 0x01 | Response message | Reply to REQ packet | +| `PAYLOAD_TYPE_TXT_MSG` | 0x02 | Text message | User-to-user chat message | +| `PAYLOAD_TYPE_ACK` | 0x03 | Acknowledgment | Confirm packet receipt | +| `PAYLOAD_TYPE_ADVERT` | 0x04 | Advertisement | Node presence announcement | +| `PAYLOAD_TYPE_GRP_TXT` | 0x05 | Group text message | Multi-recipient text | +| `PAYLOAD_TYPE_GRP_DATA` | 0x06 | Group data | Multi-recipient binary data | +| `PAYLOAD_TYPE_ANON_REQ` | 0x07 | Anonymous request | Request without sender ID | +| `PAYLOAD_TYPE_PATH` | 0x08 | Path discovery | Route discovery/return | +| `PAYLOAD_TYPE_TRACE` | 0x09 | Trace packet | Network diagnostics | +| `PAYLOAD_TYPE_MULTIPART` | 0x0A | Multi-part message | Large message fragmentation | +| `PAYLOAD_TYPE_RAW_CUSTOM` | 0x0F | Raw custom data | Application-specific payload | + +### Payload Type Categories + +**Control Messages:** +- ACK, PATH, TRACE + +**User Messages:** +- TXT_MSG, GRP_TXT + +**Data Transfer:** +- REQ, RESPONSE, GRP_DATA, RAW_CUSTOM, MULTIPART + +**Network Management:** +- ADVERT, ANON_REQ + +### Payload Structures by Type + +Each payload type has a specific binary structure. All encrypted payloads include a 2-byte MAC at the end. + +#### PAYLOAD_TYPE_REQ (0x00) + +**Structure:** +``` ++-------------------+----------+ +| Dest Hash | 1 byte | First byte of destination public key +| Src Hash | 1 byte | First byte of source public key +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Timestamp (variable length) +- Request blob (application-specific) + +**Use Case:** Authenticated request from known sender to known recipient + +--- + +#### PAYLOAD_TYPE_RESPONSE (0x01) + +**Structure:** +``` ++-------------------+----------+ +| Dest Hash | 1 byte | First byte of destination public key +| Src Hash | 1 byte | First byte of source public key +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Timestamp (variable length) +- Response blob (application-specific) + +**Use Case:** Reply to REQ or ANON_REQ packet + +--- + +#### PAYLOAD_TYPE_TXT_MSG (0x02) + +**Structure:** +``` ++-------------------+----------+ +| Dest Hash | 1 byte | First byte of destination public key +| Src Hash | 1 byte | First byte of source public key +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Timestamp (variable length) +- Text message (UTF-8 string) + +**Use Case:** Person-to-person text messages + +--- + +#### PAYLOAD_TYPE_ACK (0x03) + +**Structure:** +``` ++-------------------+----------+ +| ACK Code | N bytes | Application-specific acknowledgment data ++-------------------+----------+ +``` + +**No Encryption:** ACK packets are typically unencrypted + +**Use Case:** Confirm receipt of packets, simple acknowledgments + +--- + +#### PAYLOAD_TYPE_ADVERT (0x04) + +**Structure:** +``` ++-------------------+----------+ +| Public Key | 32 bytes | Ed25519 public key of advertiser +| Timestamp | 4 bytes | Unix timestamp (uint32, little-endian) +| App Data | N bytes | Application-specific advertisement data +| Signature | 64 bytes | Ed25519 signature over above fields ++-------------------+----------+ +``` + +**Minimum Size:** 100 bytes (32 + 4 + 0 + 64) +**Maximum Size:** 132 bytes (32 + 4 + 32 + 64) with MAX_ADVERT_DATA_SIZE + +**Signature Verification:** +```cpp +// Build message to verify +message = public_key || timestamp || app_data + +// Verify Ed25519 signature +bool valid = ed25519_verify(signature, message, public_key); +``` + +**Use Case:** Node presence announcement, identity broadcast, service discovery + +### Advertisement App Data Format + +The App Data field has a structured format for node advertisements: + +**Binary Structure:** +``` ++-------------------+----------+ +| Flags | 1 byte | Type (4 bits) + Feature flags (4 bits) +| Latitude | 4 bytes | (Optional) int32 LE, divide by 10000 +| Longitude | 4 bytes | (Optional) int32 LE, divide by 10000 +| Battery | 1 byte | (Optional) percentage 0-100 +| Temperature | 1 byte | (Optional) signed int8, degrees Celsius +| Name | N bytes | (Optional) null-terminated UTF-8 string ++-------------------+----------+ +``` + +**Flags Byte Layout:** +``` +Bit Layout: ++--------+--------+--------+--------+--------+--------+--------+--------+ +| Name | Temp | Battery| LatLon | Type3 | Type2 | Type1 | Type0 | ++--------+--------+--------+--------+--------+--------+--------+--------+ + Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0 +``` + +**Type Field (bits 0-3):** +```cpp +#define ADV_TYPE_NONE 0x00 // Unknown/undefined type +#define ADV_TYPE_CHAT 0x01 // User/team member node +#define ADV_TYPE_REPEATER 0x02 // Network repeater node +#define ADV_TYPE_ROOM 0x03 // Group chat room/channel +``` + +**Feature Flags (bits 4-7):** +```cpp +#define ADV_LATLON_MASK 0x10 // Bit 4: Latitude/Longitude present +#define ADV_BATTERY_MASK 0x20 // Bit 5: Battery level present +#define ADV_TEMPERATURE_MASK 0x40 // Bit 6: Temperature present +#define ADV_NAME_MASK 0x80 // Bit 7: Name string present +``` + +**Extracting Fields:** +```cpp +uint8_t flags = app_data[0]; +uint8_t type = flags & 0x0F; +bool has_latlon = (flags & ADV_LATLON_MASK) != 0; +bool has_battery = (flags & ADV_BATTERY_MASK) != 0; +bool has_temp = (flags & ADV_TEMPERATURE_MASK) != 0; +bool has_name = (flags & ADV_NAME_MASK) != 0; +``` + +**Example App Data Parsing:** + +```javascript +// Example 1: CHAT node with GPS and name +// Flags: 0x91 (CHAT | LATLON_MASK | NAME_MASK) +// = 10010001 binary +const flags = 0x91; +const type = flags & 0x0F; // 0x01 = CHAT +const hasLatLon = flags & 0x10; // true +const hasName = flags & 0x80; // true + +// Bytes: [0x91] [lat: 4B] [lon: 4B] [name: "Alice\0"] +// Total: 1 + 4 + 4 + 6 = 15 bytes + +// Example 2: REPEATER with GPS, battery, and temp +// Flags: 0x72 (REPEATER | LATLON_MASK | BATTERY_MASK | TEMP_MASK) +const flags = 0x72; +const type = flags & 0x0F; // 0x02 = REPEATER +const hasLatLon = flags & 0x10; // true +const hasBattery = flags & 0x20; // true +const hasTemp = flags & 0x40; // true + +// Bytes: [0x72] [lat: 4B] [lon: 4B] [battery: 1B] [temp: 1B] +// Total: 1 + 4 + 4 + 1 + 1 = 11 bytes + +// Example 3: ROOM with only name +// Flags: 0x83 (ROOM | NAME_MASK) +const flags = 0x83; +const type = flags & 0x0F; // 0x03 = ROOM +const hasName = flags & 0x80; // true + +// Bytes: [0x83] [name: "SAR Team Alpha\0"] +// Total: 1 + 15 = 16 bytes +``` + +**GPS Coordinate Encoding:** +```cpp +// Encoding (on device) +int32_t lat_encoded = (int32_t)(latitude * 10000.0); +int32_t lon_encoded = (int32_t)(longitude * 10000.0); + +// Decoding (on receiver) +double latitude = lat_encoded / 10000.0; +double longitude = lon_encoded / 10000.0; + +// Example: 46.0569°N, 14.5058°E +// Encoded: 460569, 145058 +// 4 decimal places precision (~11m accuracy) +``` + +**Complete Parsing Example (JavaScript):** +```javascript +function parseAdvertAppData(appData) { + const reader = new BufferReader(appData); + const flags = reader.readByte(); + + const type = flags & 0x0F; + const result = { type }; + + // Parse lat/lon if present + if (flags & 0x10) { + result.lat = reader.readInt32LE() / 10000.0; + result.lon = reader.readInt32LE() / 10000.0; + } + + // Parse battery if present + if (flags & 0x20) { + result.battery = reader.readByte(); // 0-100% + } + + // Parse temperature if present + if (flags & 0x40) { + result.temperature = reader.readInt8(); // -128 to +127°C + } + + // Parse name if present (remaining bytes) + if (flags & 0x80) { + result.name = reader.readString(); // null-terminated UTF-8 + } + + return result; +} +``` + +**Advertisement Frequency:** +- Typically broadcast every 30-60 seconds +- Can be triggered on-demand for discovery +- Should include timestamp to detect stale advertisements + +**Security Considerations:** +1. **Always verify signature** before trusting advertisement data +2. **Check timestamp** to reject old/replayed advertisements +3. **Validate GPS coordinates** are within reasonable ranges +4. **Sanitize name strings** before display (max length, valid UTF-8) +5. **Rate limit** advertisement processing to prevent DoS + +--- + +#### PAYLOAD_TYPE_GRP_TXT (0x05) + +**Structure:** +``` ++-------------------+----------+ +| Channel Hash | 1 byte | First byte of group channel hash +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Timestamp (variable length) +- Message text in format: `"sender_name: message_text"` + +**Security Note:** Unverified sender identity - anyone with the group key can send + +**Use Case:** Group chat messages to a channel + +--- + +#### PAYLOAD_TYPE_GRP_DATA (0x06) + +**Structure:** +``` ++-------------------+----------+ +| Channel Hash | 1 byte | First byte of group channel hash +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Timestamp (variable length) +- Binary data blob (application-specific) + +**Use Case:** Group data broadcast (telemetry, coordinates, binary files) + +--- + +#### PAYLOAD_TYPE_ANON_REQ (0x07) + +**Structure:** +``` ++-------------------+----------+ +| Dest Hash | 1 byte | First byte of destination public key +| Ephemeral Pub Key | 32 bytes | Temporary Ed25519 public key +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Minimum Size:** 35 bytes (1 + 32 + 2 + 0) + +**Encrypted Data Contains:** +- Application-specific request data + +**Key Derivation:** +- Recipient uses their private key + ephemeral public key to derive shared secret +- Sender discards ephemeral private key after sending (forward secrecy) + +**Use Case:** Anonymous requests, forward-secret communications + +--- + +#### PAYLOAD_TYPE_PATH (0x08) + +**Structure:** +``` ++-------------------+----------+ +| Dest Hash | 1 byte | First byte of destination public key +| Src Hash | 1 byte | First byte of source public key +| MAC | 2 bytes | Message authentication code +| Encrypted Data | N bytes | AES-128-CTR encrypted payload ++-------------------+----------+ +``` + +**Encrypted Data Contains:** +- Path data: sequence of node hashes showing discovered route +- Extra metadata (optional) + +**Use Case:** Return discovered routing path to sender + +--- + +#### PAYLOAD_TYPE_TRACE (0x09) + +**Structure:** +``` ++-------------------+----------+ +| Trace Data | N bytes | Application-specific trace payload ++-------------------+----------+ +``` + +**No Standard Format:** Application defines structure + +**Common Usage:** +- Collecting SNR (signal-to-noise ratio) at each hop +- Measuring latency through network +- Network topology discovery +- Debugging routing issues + +**Path Field:** Used to accumulate node IDs and SNR measurements as packet propagates + +--- + +#### PAYLOAD_TYPE_RAW_CUSTOM (0x0F) + +**Structure:** +``` ++-------------------+----------+ +| Custom Data | N bytes | Completely application-defined ++-------------------+----------+ +``` + +**No Standard Format:** Application has full control over: +- Encryption scheme (or no encryption) +- Data encoding +- Protocol semantics + +**Use Case:** Application-specific protocols, custom encryption, proprietary formats + +--- + +### Hash Prefixes + +Several payload types use 1-byte "hash" fields that represent the first byte of a 32-byte public key: + +```cpp +uint8_t dest_hash = public_key[0]; +``` + +**Purpose:** +- Quick filtering: nodes can ignore packets not addressed to them +- Space efficiency: 1 byte vs 32 bytes +- Collision rate: 1/256 (acceptable for mesh routing) + +**Collision Handling:** +- When hash matches, validate full public key after decryption +- If decryption fails, packet was for a different node with same hash prefix + +--- + +### Encrypted Payload Format + +All encrypted payloads use this structure: + +``` ++-------------------+----------+ +| Encrypted Data | N bytes | AES-128-CTR ciphertext +| MAC | 2 bytes | Authentication code (included in total payload_len) ++-------------------+----------+ +``` + +**Decryption Process:** +1. Extract last 2 bytes as MAC +2. Verify MAC over encrypted data (bytes 0 to N-2) +3. If valid, decrypt using AES-128-CTR with derived cipher key +4. If invalid, silently discard packet + +**Common Encrypted Data Structure:** +``` ++-------------------+----------+ +| Timestamp | 4 bytes | Unix timestamp (uint32) for replay protection +| Payload Data | N bytes | Application-specific data ++-------------------+----------+ +``` + +--- + +## Route Types + +| Name | Value | Description | Transport Codes | +|------|-------|-------------|-----------------| +| `ROUTE_TYPE_TRANSPORT_FLOOD` | 0x00 | Flood routing with metadata | Yes (4 bytes) | +| `ROUTE_TYPE_FLOOD` | 0x01 | Simple flood routing | No | +| `ROUTE_TYPE_DIRECT` | 0x02 | Direct peer-to-peer | No | +| `ROUTE_TYPE_TRANSPORT_DIRECT` | 0x03 | Direct with metadata | Yes (4 bytes) | + +### Routing Behavior + +**FLOOD Mode (0x01, 0x00):** +- Packet is retransmitted by all receiving nodes +- Path field accumulates node IDs as packet propagates +- Used for network-wide broadcasts and discovery +- Path prevents routing loops + +**DIRECT Mode (0x02, 0x03):** +- Packet routed only through specified path +- Path field contains complete route to destination +- Used for established peer-to-peer connections +- More efficient than flood routing + +**Transport Codes:** +- When present (types 0x00 and 0x03), add 4 bytes after header +- Two 16-bit unsigned integers for transport layer metadata +- Use cases: sequence numbers, retry counts, QoS flags + +--- + +## Cryptography + +### Encryption Scheme + +**Algorithm:** AES-128-CTR with custom MAC +**Key Derivation:** ECDH using Ed25519 keys +**Authentication:** 2-byte MAC (CIPHER_MAC_SIZE) + +### Shared Secret Calculation + +```cpp +// Given: local private key (64 bytes), remote public key (32 bytes) +uint8_t shared_secret[32]; +calcSharedSecret(local_prv_key, remote_pub_key, shared_secret); +``` + +### Packet Encryption Process + +1. Calculate shared secret from sender private key and recipient public key +2. Generate cipher key from shared secret +3. Encrypt payload using AES-128-CTR +4. Calculate MAC over encrypted payload +5. Append MAC to encrypted data (total: payload_len + 2) + +### Packet Decryption Process + +1. Extract MAC from last 2 bytes of payload +2. Calculate expected MAC over encrypted data +3. Compare MACs (constant-time comparison required) +4. If MAC valid, decrypt payload using AES-128-CTR +5. If MAC invalid, discard packet + +**Security Note:** MAC-then-decrypt pattern requires constant-time MAC comparison to prevent timing attacks. + +### Digital Signatures + +**Algorithm:** Ed25519 +**Signature Size:** 64 bytes + +Used for: +- Advertisement packet authentication +- Path discovery verification +- Identity proofs + +--- + +## Binary Serialization + +### writeTo() Method + +Serializes packet to byte array: + +```cpp +size_t writeTo(uint8_t* buffer, size_t buffer_size) { + size_t offset = 0; + + // 1. Write header byte + buffer[offset++] = header; + + // 2. Write transport codes (if present) + if (hasTransportCodes()) { + buffer[offset++] = (uint8_t)(transport_codes[0] & 0xFF); + buffer[offset++] = (uint8_t)(transport_codes[0] >> 8); + buffer[offset++] = (uint8_t)(transport_codes[1] & 0xFF); + buffer[offset++] = (uint8_t)(transport_codes[1] >> 8); + } + + // 3. Write path length + buffer[offset++] = (uint8_t)path_len; + + // 4. Write path data + memcpy(buffer + offset, path, path_len); + offset += path_len; + + // 5. Write payload data + memcpy(buffer + offset, payload, payload_len); + offset += payload_len; + + return offset; // Total bytes written +} +``` + +### readFrom() Method + +Deserializes packet from byte array: + +```cpp +bool readFrom(const uint8_t* buffer, size_t buffer_size) { + size_t offset = 0; + + // 1. Read header byte + if (offset >= buffer_size) return false; + header = buffer[offset++]; + + // 2. Read transport codes (if present) + if (hasTransportCodes()) { + if (offset + 4 > buffer_size) return false; + transport_codes[0] = buffer[offset] | (buffer[offset+1] << 8); + offset += 2; + transport_codes[1] = buffer[offset] | (buffer[offset+1] << 8); + offset += 2; + } + + // 3. Read path length + if (offset >= buffer_size) return false; + path_len = buffer[offset++]; + + // 4. Validate path length + if (path_len > MAX_PATH_SIZE) return false; + if (offset + path_len > buffer_size) return false; + + // 5. Read path data + memcpy(path, buffer + offset, path_len); + offset += path_len; + + // 6. Calculate and validate payload length + payload_len = buffer_size - offset; + if (payload_len > MAX_PACKET_PAYLOAD) return false; + + // 7. Read payload data + memcpy(payload, buffer + offset, payload_len); + + return true; // Success +} +``` + +--- + +## Validation Rules + +### Packet Acceptance Criteria + +A valid packet must satisfy: + +1. **Header Validation:** + - Route type ≤ 3 (valid route type) + - Payload type ≤ 15 (4-bit field) + - Payload version ≤ 3 (2-bit field) + +2. **Path Validation:** + - `path_len ≤ MAX_PATH_SIZE` (64 bytes) + - Path data must not exceed buffer size + +3. **Payload Validation:** + - `payload_len ≤ MAX_PACKET_PAYLOAD` (184 bytes) + - Payload data must not exceed buffer size + - For encrypted packets: payload_len ≥ CIPHER_MAC_SIZE (2 bytes) + +4. **Size Validation:** + - Total packet size ≤ MAX_TRANS_UNIT (255 bytes) + - Minimum size: 2 bytes (header + path_len) + +5. **Cryptographic Validation (if encrypted):** + - MAC must match calculated value + - Decryption must succeed without errors + +### Error Handling + +**Invalid Packets:** +- Silently discarded (no error response) +- Logged for debugging if trace enabled + +**Malformed Data:** +- `readFrom()` returns `false` +- Packet object left in undefined state +- Caller must not use packet after failed read + +--- + +## Packet Hash Calculation + +Used for duplicate detection and routing loop prevention: + +```cpp +void calculatePacketHash(uint8_t* hash_out, size_t hash_len) { + // Initialize SHA256 + SHA256 sha256; + sha256.reset(); + + // 1. Hash payload type + uint8_t type = (header >> PH_TYPE_SHIFT) & PH_TYPE_MASK; + sha256.update(&type, 1); + + // 2. Hash path length (only for TRACE packets) + if (type == PAYLOAD_TYPE_TRACE) { + uint8_t plen = (uint8_t)path_len; + sha256.update(&plen, 1); + } + + // 3. Hash payload data + sha256.update(payload, payload_len); + + // 4. Finalize and copy to output + uint8_t full_hash[32]; + sha256.finalize(full_hash, 32); + memcpy(hash_out, full_hash, hash_len); +} +``` + +**Hash Properties:** +- Based on SHA256 +- Configurable output length (typically MAX_HASH_SIZE = 8 bytes) +- Includes payload type and payload data +- TRACE packets include path_len to detect routing changes + +--- + +## Protocol Version + +**Current Version:** V1 (PAYLOAD_VER_1 = 0x00) + +**Version Features:** +- V1: 2-byte MAC, 1-byte path hash +- V2-V4: Reserved for future use + +**Version Compatibility:** +- Nodes must reject packets with unsupported versions +- Forward compatibility requires checking version before processing + +--- + +## Implementation Notes + +### Performance Considerations + +**Buffer Management:** +- Pre-allocate packet buffers to avoid dynamic allocation +- Use stack allocation for temporary packets +- Pool frequently used packet objects + +**Crypto Optimization:** +- Cache shared secrets for active connections +- Use hardware AES acceleration if available +- Batch MAC calculations when possible + +**Routing Efficiency:** +- Maintain routing table cache for direct routes +- Limit flood packet retransmissions (hop count) +- Implement exponential backoff for retries + +### Security Best Practices + +1. **Always validate MAC** before decrypting +2. **Use constant-time comparison** for MAC validation +3. **Clear sensitive data** from memory after use +4. **Implement replay protection** using sequence numbers +5. **Rate limit** flood packets to prevent DoS attacks + +### Interoperability + +This specification is based on the [MeshCore C++ implementation](https://github.com/meshcore-dev/MeshCore) and is compatible with: + +- MeshCore firmware (ESP32, nRF52, STM32) +- meshcore.js library +- This Flutter application + +**Byte Order:** All multi-byte integers use **little-endian** encoding. + +--- + +## Special Features + +### Do Not Retransmit Flag + +Packets can be marked to prevent retransmission: + +```javascript +packet.markDoNotRetransmit(); // Sets header to 0xFF +if (packet.isMarkedDoNotRetransmit()) { + // Don't retransmit this packet +} +``` + +**When to Use:** +- Packets already flooded to entire network +- Time-sensitive data that's no longer relevant +- Preventing routing loops in edge cases + +**Implementation:** Header value of `0xFF` is reserved as a special marker + +--- + +## JavaScript Implementation Notes + +The JavaScript implementation (meshcore.js) provides a convenient API for packet parsing: + +```javascript +// Parse packet from bytes +const packet = Packet.fromBytes(bytes); + +// Access parsed header fields +console.log(packet.route_type_string); // "FLOOD" or "DIRECT" +console.log(packet.payload_type_string); // "TXT_MSG", "ADVERT", etc. +console.log(packet.payload_version); // 0, 1, 2, or 3 + +// Parse payload based on type +const parsed = packet.parsePayload(); +if (packet.payload_type === Packet.PAYLOAD_TYPE_ADVERT) { + console.log(parsed.public_key); // 32-byte Uint8Array + console.log(parsed.timestamp); // Unix timestamp + console.log(parsed.app_data); // Application data +} +``` + +**Supported Payload Parsers:** +- `PAYLOAD_TYPE_REQ` → `{ src, dest, encrypted }` +- `PAYLOAD_TYPE_RESPONSE` → `{ src, dest }` +- `PAYLOAD_TYPE_TXT_MSG` → `{ src, dest }` +- `PAYLOAD_TYPE_ACK` → `{ ack_code }` +- `PAYLOAD_TYPE_ADVERT` → `{ public_key, timestamp, app_data }` +- `PAYLOAD_TYPE_ANON_REQ` → `{ src, dest }` (src is 32-byte ephemeral key) +- `PAYLOAD_TYPE_PATH` → `{ src, dest }` + +**Note:** The JavaScript parsers extract the unencrypted header fields only. Encrypted data decryption requires implementing the crypto layer. + +--- + +## BLE Transport Layer + +MeshCore packets are transported over Bluetooth Low Energy (BLE) using the Nordic UART Service (NUS) profile. + +### BLE Service Specification + +**Service UUID:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` (Nordic UART Service) + +**Characteristics:** + +| Characteristic | UUID | Properties | Description | +|----------------|------|------------|-------------| +| RX | `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` | Write, Write Without Response | Client → Device (commands) | +| TX | `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` | Notify | Device → Client (responses) | + +### BLE vs Mesh Protocol + +**Important Distinction:** + +The BLE transport layer uses a **different protocol** than the mesh packet protocol documented above. + +**BLE Protocol:** +- Simple command/response format +- Used for **local device communication** only +- Commands to query device state, send messages, request data +- Not forwarded through mesh network + +**Mesh Protocol:** +- Complex packet structure with routing +- Used for **mesh network communication** +- Packets can be flooded or routed through multiple hops +- Carries encrypted user data + +### BLE Command Protocol + +Commands sent over BLE RX characteristic: + +| Command | Code | Description | Parameters | +|---------|------|-------------|------------| +| Get Contacts | `0x04` | Request list of known contacts | None | +| Send Message | `0x02` | Send text message | 32B pubkey, 2B length, text | +| Request Telemetry | `0x27` (39) | Request telemetry data | 32B contact pubkey | + +### BLE Response Protocol + +Responses received over BLE TX characteristic: + +| Response | Code | Description | Structure | +|----------|------|-------------|-----------| +| Contact Info | `0x03` | Contact details | 32B pubkey, 1B type, 64B name, 4B lat, 4B lon | +| Message Received | `0x07` | Incoming message | 1B type, 4B src, 4B dest, 2B length, text | +| Telemetry | `0x8B` (139) | Cayenne LPP telemetry | 4B pubkey prefix, LPP data | + +### BLE Message Format Examples + +**Get Contacts Request:** +``` +RX: [0x04] +Total: 1 byte +``` + +**Send Message Request:** +``` +RX: [0x02] [32 bytes: recipient pubkey] [2 bytes: length] [N bytes: UTF-8 text] +Example (hex): 02 A1B2C3D4...pubkey...E5F6 0B00 48656C6C6F20576F726C64 + ^cmd ^----- 32 bytes -----^ ^len ^----- "Hello World" -----^ +Total: 1 + 32 + 2 + N bytes +``` + +**Contact Response:** +``` +TX: [0x03] [32 bytes: pubkey] [1 byte: type] [64 bytes: name] [4 bytes: lat] [4 bytes: lon] +Type: 0=none, 1=chat, 2=repeater, 3=room +Lat/Lon: int32 little-endian, divide by 10000 for degrees +Total: 105 bytes +``` + +**Message Received:** +``` +TX: [0x07] [1 byte: msg type] [4 bytes: src prefix] [4 bytes: dest prefix] [2 bytes: length] [N bytes: text] +Msg Type: 0=contact, 1=channel +Total: 1 + 1 + 4 + 4 + 2 + N bytes +``` + +**Telemetry Response:** +``` +TX: [0x8B] [4 bytes: contact pubkey prefix] [N bytes: Cayenne LPP payload] +Total: 5 + N bytes +``` + +### Cayenne LPP Format (Telemetry) + +Cayenne Low Power Payload format used for telemetry data: + +**Structure:** +``` +[Channel] [Type] [Data...] +``` + +**Supported Types:** + +| Type | Code | Data Format | Description | +|------|------|-------------|-------------| +| GPS | `0x88` (136) | 12 bytes | lat(4B) + lon(4B) + alt(4B), divide lat/lon by 10000, alt by 100 | +| Temperature | `0x67` (103) | 2 bytes | int16 LE, divide by 10 for °C | +| Analog Input | `0x02` | 2 bytes | uint16 LE, divide by 100 for volts (battery) | + +**Example Telemetry Packet:** +``` +Channel 1, GPS: [01] [88] [A0 C2 06 00] [30 67 02 00] [2C 01 00 00] + ^ch ^type ^-- lat --^ ^-- lon --^ ^-- alt --^ +Decoded: lat=443040/10000=44.304°, lon=157488/10000=15.7488°, alt=300/100=3.00m + +Channel 2, Temp: [02] [67] [0E 01] + ^ch ^type ^-value-^ +Decoded: temp=270/10=27.0°C + +Channel 3, Battery: [03] [02] [90 01] + ^ch ^type ^-value-^ +Decoded: battery=400/100=4.00V +``` + +### BLE vs Mesh Packet Flow + +``` +┌─────────────┐ ┌──────────────┐ +│ Flutter │ ← BLE Commands → │ MeshCore │ +│ App │ (Simple) │ Device │ +└─────────────┘ └──────┬───────┘ + │ + │ Mesh Packets + │ (Complex) + │ + ┌───────▼───────┐ + │ LoRa/Radio │ + │ Mesh │ + │ Network │ + └───────────────┘ +``` + +**Data Flow Example:** + +1. App sends "Get Contacts" (BLE command 0x04) +2. Device responds with Contact Info (BLE response 0x03) for each contact +3. User sends message via app (BLE command 0x02) +4. Device creates **mesh packet** (PAYLOAD_TYPE_TXT_MSG) and broadcasts on LoRa +5. Remote device receives mesh packet, forwards to its BLE-connected app +6. Remote app receives message (BLE response 0x07) + +### Implementation Notes + +**BLE MTU Limitations:** +- Default MTU: 23 bytes (20 bytes usable data) +- Extended MTU: up to 512 bytes (device dependent) +- Long messages may require fragmentation + +**Buffering:** +- BLE TX notifications arrive in chunks +- App must buffer partial packets until complete +- Use packet length headers to detect boundaries + +**Connection Management:** +- Maintain single BLE connection to MeshCore device +- Device acts as BLE peripheral (server) +- App acts as BLE central (client) +- Reconnect automatically on disconnection + +**Flutter Implementation:** +See `lib/services/meshcore_ble_service.dart` for complete BLE protocol implementation. + +--- + +## References + +### Source Code +- [MeshCore GitHub Repository](https://github.com/meshcore-dev/MeshCore) - C++ firmware implementation +- [Packet.h](https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h) - C++ packet class definition +- [Packet.cpp](https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.cpp) - C++ packet serialization +- [Mesh.h](https://github.com/meshcore-dev/MeshCore/blob/main/src/Mesh.h) - C++ mesh networking +- [Mesh.cpp](https://github.com/meshcore-dev/MeshCore/blob/main/src/Mesh.cpp) - C++ routing implementation +- [MeshCore.h](https://github.com/meshcore-dev/MeshCore/blob/main/src/MeshCore.h) - C++ protocol constants +- [meshcore.js Packet.js](https://github.com/meshcore-dev/meshcore.js) - JavaScript implementation + +### Documentation +- This document provides implementation details for the MeshCore SAR Flutter application +- Compatible with MeshCore firmware v1.x protocol specification + +--- + +**Document Version:** 1.1 +**Last Updated:** 2025-10-14 +**Protocol Version:** V1 diff --git a/MESHCORE_QUICK_REFERENCE.md b/MESHCORE_QUICK_REFERENCE.md new file mode 100644 index 0000000..8c0b64b --- /dev/null +++ b/MESHCORE_QUICK_REFERENCE.md @@ -0,0 +1,216 @@ +# MeshCore Quick Reference Card + +Quick lookup for MeshCore protocol constants and structures. + +## BLE Service (App ↔ Device) + +**Service UUID:** `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` +- **RX:** `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` (Write - Commands) +- **TX:** `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` (Notify - Responses) + +### BLE Commands (RX) + +| Code | Command | Format | +|------|---------|--------| +| `0x04` | Get Contacts | `[0x04]` | +| `0x02` | Send Message | `[0x02][32B pubkey][2B len][text]` | +| `0x27` | Get Telemetry | `[0x27][32B pubkey]` | + +### BLE Responses (TX) + +| Code | Response | Format | +|------|----------|--------| +| `0x03` | Contact Info | `[0x03][32B pubkey][1B type][64B name][4B lat][4B lon]` | +| `0x07` | Message | `[0x07][1B type][4B src][4B dest][2B len][text]` | +| `0x8B` | Telemetry | `[0x8B][4B pubkey][Cayenne LPP data]` | + +--- + +## Mesh Packet Structure (LoRa Network) + +``` +[Header: 1B] [Path Len: 1B] [Path: 0-64B] [Payload: 0-184B] +``` + +### Header Encoding + +``` +Bits: [Ver:2][Type:4][Route:2] +Route = header & 0x03 +Type = (header >> 2) & 0x0F +Ver = (header >> 6) & 0x03 +``` + +### Route Types + +| Code | Name | Description | +|------|------|-------------| +| `0x01` | FLOOD | Broadcast to all nodes | +| `0x02` | DIRECT | Point-to-point via path | + +### Payload Types + +| Code | Name | Encrypted | Structure | +|------|------|-----------|-----------| +| `0x00` | REQ | ✓ | `[1B dest][1B src][2B MAC][encrypted]` | +| `0x01` | RESPONSE | ✓ | `[1B dest][1B src][2B MAC][encrypted]` | +| `0x02` | TXT_MSG | ✓ | `[1B dest][1B src][2B MAC][encrypted]` | +| `0x03` | ACK | ✗ | `[ack data]` | +| `0x04` | ADVERT | ✗ | `[32B pubkey][4B ts][app][64B sig]` | +| `0x05` | GRP_TXT | ✓ | `[1B chan][2B MAC][encrypted]` | +| `0x06` | GRP_DATA | ✓ | `[1B chan][2B MAC][encrypted]` | +| `0x07` | ANON_REQ | ✓ | `[1B dest][32B ephemeral][2B MAC][enc]` | +| `0x08` | PATH | ✓ | `[1B dest][1B src][2B MAC][encrypted]` | +| `0x09` | TRACE | ✗ | `[trace data]` | +| `0x0F` | RAW_CUSTOM | ? | Application-defined | + +--- + +## Advertisement App Data + +**Format:** `[Flags:1B][Lat:4B?][Lon:4B?][Battery:1B?][Temp:1B?][Name:NB?]` + +### Flags Byte + +``` +Bits: [Name:1][Temp:1][Batt:1][LatLon:1][Type:4] +Type = flags & 0x0F +Has GPS = flags & 0x10 +Has Batt = flags & 0x20 +Has Temp = flags & 0x40 +Has Name = flags & 0x80 +``` + +### Contact Types + +| Code | Name | Description | +|------|------|-------------| +| `0x00` | NONE | Unknown | +| `0x01` | CHAT | Team member (shown on map) | +| `0x02` | REPEATER | Network node | +| `0x03` | ROOM | Group channel | + +--- + +## Cayenne LPP (Telemetry) + +**Format:** `[Channel:1B][Type:1B][Data]` + +| Type | Code | Data | Decoding | +|------|------|------|----------| +| GPS | `0x88` | 12B | lat/lon ÷ 10000, alt ÷ 100 | +| Temp | `0x67` | 2B | int16 ÷ 10 for °C | +| Analog | `0x02` | 2B | uint16 ÷ 100 for volts | + +**Example:** +``` +[01][88][A0C20600][30670200][2C010000] + ^ch ^gps ^-lat-^ ^-lon-^ ^-alt-^ +GPS: 44.304°N, 15.7488°E, 3.00m +``` + +--- + +## Constants + +### Size Limits +- Max Packet Payload: 184 bytes +- Max Path Size: 64 bytes +- Max Advert Data: 32 bytes +- Public Key: 32 bytes +- Private Key: 64 bytes +- Signature: 64 bytes +- MAC: 2 bytes +- Cipher Block: 16 bytes + +### Coordinate Encoding +```dart +// Encode +int32 encoded = (double degrees * 10000).toInt(); + +// Decode +double degrees = encoded / 10000.0; + +// Precision: 4 decimal places (~11m accuracy) +``` + +--- + +## Common Operations + +### Parse BLE Contact Response +```dart +final pubkey = data.sublist(1, 33); // 32 bytes +final type = data[33]; // 0-3 +final name = data.sublist(34, 98); // 64 bytes +final lat = ByteData.view(data.buffer) + .getInt32(98, Endian.little) / 10000.0; +final lon = ByteData.view(data.buffer) + .getInt32(102, Endian.little) / 10000.0; +``` + +### Parse Mesh Packet Header +```dart +final header = packet[0]; +final routeType = header & 0x03; +final payloadType = (header >> 2) & 0x0F; +final version = (header >> 6) & 0x03; +final isFlood = routeType == 0x01; +final isTxtMsg = payloadType == 0x02; +``` + +### Parse Advertisement Flags +```dart +final flags = appData[0]; +final contactType = flags & 0x0F; +final hasGPS = (flags & 0x10) != 0; +final hasBattery = (flags & 0x20) != 0; +final hasTemp = (flags & 0x40) != 0; +final hasName = (flags & 0x80) != 0; +``` + +### Parse Cayenne LPP GPS +```dart +if (data[1] == 0x88) { // GPS type + final lat = ByteData.view(data.buffer) + .getInt32(2, Endian.little) / 10000.0; + final lon = ByteData.view(data.buffer) + .getInt32(6, Endian.little) / 10000.0; + final alt = ByteData.view(data.buffer) + .getInt32(10, Endian.little) / 100.0; +} +``` + +--- + +## Security Notes + +1. **Always verify signatures** on ADVERT packets +2. **Validate MAC** before decrypting encrypted payloads +3. **Check timestamps** to prevent replay attacks +4. **Sanitize strings** before display (max length, UTF-8 validation) +5. **Rate limit** packet processing to prevent DoS +6. Use **constant-time comparison** for MAC validation + +--- + +## Flutter Implementation + +**Main Files:** +- `lib/services/meshcore_ble_service.dart` - BLE protocol +- `lib/services/buffer_reader.dart` - Binary parsing +- `lib/services/buffer_writer.dart` - Binary encoding +- `lib/services/cayenne_lpp_parser.dart` - Telemetry decoding + +--- + +## Additional Documentation + +- **[MESHCORE_PROTOCOL.md](MESHCORE_PROTOCOL.md)** - Complete mesh packet protocol specification +- **[MESHCORE_BLE_PROTOCOL.md](MESHCORE_BLE_PROTOCOL.md)** - Complete BLE command/response protocol +- **[CLAUDE.md](CLAUDE.md)** - Project overview and development guide + +--- + +**Document Version:** 1.0 +**Last Updated:** 2025-10-14 diff --git a/lib/main.dart b/lib/main.dart index f7d1484..2b1a3ab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,11 +23,19 @@ class MeshCoreSarApp extends StatefulWidget { class _MeshCoreSarAppState extends State { AppThemeMode _themeMode = AppThemeMode.system; + bool _isInitialized = false; @override void initState() { super.initState(); - _loadThemePreference(); + _initializeApp(); + } + + Future _initializeApp() async { + await _loadThemePreference(); + setState(() { + _isInitialized = true; + }); } Future _loadThemePreference() async { @@ -46,12 +54,29 @@ class _MeshCoreSarAppState extends State { @override Widget build(BuildContext context) { + if (!_isInitialized) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + return MultiProvider( providers: [ // Core providers ChangeNotifierProvider(create: (_) => ConnectionProvider()), ChangeNotifierProvider(create: (_) => ContactsProvider()), - ChangeNotifierProvider(create: (_) => MessagesProvider()), + ChangeNotifierProvider( + create: (_) { + final provider = MessagesProvider(); + // Initialize messages provider asynchronously + provider.initialize(); + return provider; + }, + ), ChangeNotifierProvider(create: (_) => MapProvider()), // Tile cache service diff --git a/lib/models/ble_packet_log.dart b/lib/models/ble_packet_log.dart new file mode 100644 index 0000000..a7f2b0d --- /dev/null +++ b/lib/models/ble_packet_log.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +/// Represents a logged BLE packet with timestamp and metadata +class BlePacketLog { + final DateTime timestamp; + final Uint8List rawData; + final PacketDirection direction; + final int? responseCode; + final String? description; + + BlePacketLog({ + required this.timestamp, + required this.rawData, + required this.direction, + this.responseCode, + this.description, + }); + + /// Convert raw data to hex string for display + String get hexData { + return rawData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + } + + /// Get short summary of the packet + String get summary { + final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; + final code = responseCode != null ? '0x${responseCode!.toRadixString(16).padLeft(2, '0')}' : 'N/A'; + return '[$dir] Code: $code, Size: ${rawData.length} bytes'; + } + + /// Convert to CSV format for export + String toCsvRow() { + final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; + final code = responseCode?.toString() ?? ''; + final hex = hexData; + final desc = description ?? ''; + return '${timestamp.toIso8601String()},$dir,${rawData.length},$code,"$hex","$desc"'; + } + + /// Convert to human-readable log format + String toLogString() { + final dir = direction == PacketDirection.rx ? 'RX' : 'TX'; + final code = responseCode != null ? ' [0x${responseCode!.toRadixString(16).padLeft(2, '0')}]' : ''; + final desc = description != null ? ' - $description' : ''; + return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc'; + } +} + +enum PacketDirection { + rx, // Received from device + tx, // Sent to device +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 047183b..3948138 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -80,24 +80,57 @@ class AppProvider with ChangeNotifier { // Load contacts await connectionProvider.getContacts(); + // Sync any waiting messages from device queue + await _syncMessages(); + notifyListeners(); } catch (e) { debugPrint('Initialization error: $e'); } } + /// Sync messages from device queue + Future _syncMessages() async { + if (!connectionProvider.deviceInfo.isConnected) return; + + try { + debugPrint('🔄 [AppProvider] Starting message sync...'); + final messageCount = await connectionProvider.syncAllMessages(); + debugPrint('✅ [AppProvider] Synced $messageCount messages'); + } catch (e) { + debugPrint('❌ [AppProvider] Message sync error: $e'); + } + } + /// Refresh data (contacts, messages) Future refresh() async { if (!connectionProvider.deviceInfo.isConnected) return; try { await connectionProvider.getContacts(); + await _syncMessages(); notifyListeners(); } catch (e) { debugPrint('Refresh error: $e'); } } + /// Manually sync messages (useful for pull-to-refresh) + Future syncMessages() async { + if (!connectionProvider.deviceInfo.isConnected) return 0; + + try { + debugPrint('🔄 [AppProvider] Manual message sync requested'); + final messageCount = await connectionProvider.syncAllMessages(); + debugPrint('✅ [AppProvider] Synced $messageCount messages'); + notifyListeners(); + return messageCount; + } catch (e) { + debugPrint('❌ [AppProvider] Message sync error: $e'); + return 0; + } + } + /// Clear all data void clearAllData() { contactsProvider.clearContacts(); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index ff135ec..0c60d07 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -41,6 +41,9 @@ class ConnectionProvider with ChangeNotifier { int get rxPacketCount => _bleService.rxPacketCount; int get txPacketCount => _bleService.txPacketCount; + // Message sync state + bool _noMoreMessages = false; + // Callbacks for other providers Function(Contact)? onContactReceived; Function(List)? onContactsComplete; @@ -104,6 +107,11 @@ class ConnectionProvider with ChangeNotifier { onTelemetryReceived?.call(publicKey, lppData); }; + _bleService.onNoMoreMessages = () { + print('📥 [Provider] Received NoMoreMessages signal'); + _noMoreMessages = true; + }; + _bleService.onSelfInfoReceived = (selfInfo) { print('📥 [Provider] Received SelfInfo:'); print(' TX Power: ${selfInfo['txPower']} / ${selfInfo['maxTxPower']} dBm'); @@ -297,7 +305,8 @@ class ConnectionProvider with ChangeNotifier { } /// Request telemetry from contact - Future requestTelemetry(Uint8List contactPublicKey) async { + /// [zeroHop] - if true, only direct connection (no mesh forwarding) + Future requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async { if (!_bleService.isConnected) { _error = 'Not connected to device'; notifyListeners(); @@ -305,7 +314,7 @@ class ConnectionProvider with ChangeNotifier { } try { - await _bleService.requestTelemetry(contactPublicKey); + await _bleService.requestTelemetry(contactPublicKey, zeroHop: zeroHop); } catch (e) { _error = 'Failed to request telemetry: $e'; notifyListeners(); @@ -421,6 +430,66 @@ class ConnectionProvider with ChangeNotifier { } } + /// Sync messages from device queue + /// Call this repeatedly until no more messages are available + Future syncNextMessage() async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return false; + } + + try { + await _bleService.syncNextMessage(); + return true; + } catch (e) { + _error = 'Failed to sync message: $e'; + notifyListeners(); + return false; + } + } + + /// Sync all waiting messages from device + Future syncAllMessages() async { + if (!_bleService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return 0; + } + + int count = 0; + _noMoreMessages = false; // Reset flag + + try { + print('🔄 [Provider] Starting message sync...'); + // Keep syncing until we get NoMoreMessages response + // The device will send ContactMsgRecv or ChannelMsgRecv responses + // until it sends NoMoreMessages + for (int i = 0; i < 100; i++) { // Safety limit + if (_noMoreMessages) { + print('✅ [Provider] Message sync complete - NoMoreMessages received after $count requests'); + break; + } + + await _bleService.syncNextMessage(); + count++; + + // Small delay to allow response to be processed + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (!_noMoreMessages && count >= 100) { + print('⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests'); + } + + return count; + } catch (e) { + _error = 'Failed to sync messages: $e'; + notifyListeners(); + return count; + } + } + /// Clear error message void clearError() { _error = null; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ac43421..08c6575 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -1,11 +1,14 @@ import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; +import '../services/message_storage_service.dart'; /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { final List _messages = []; final Map _sarMarkers = {}; + final MessageStorageService _storageService = MessageStorageService(); + bool _isInitialized = false; List get messages => List.unmodifiable(_messages); @@ -32,6 +35,38 @@ class MessagesProvider with ChangeNotifier { List get objectMarkers => sarMarkers.where((m) => m.type == SarMarkerType.object).toList(); + bool get isInitialized => _isInitialized; + + /// Initialize and load persisted messages + Future initialize() async { + if (_isInitialized) return; + + try { + print('📦 [MessagesProvider] Loading persisted messages...'); + final storedMessages = await _storageService.loadMessages(); + + // Add stored messages + _messages.addAll(storedMessages); + + // Extract SAR markers from stored messages + for (final message in storedMessages) { + if (message.isSarMarker) { + final marker = message.toSarMarker(); + if (marker != null) { + _sarMarkers[marker.id] = marker; + } + } + } + + _isInitialized = true; + print('✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages'); + notifyListeners(); + } catch (e) { + print('❌ [MessagesProvider] Error initializing: $e'); + _isInitialized = true; // Mark as initialized even on error + } + } + /// Add a message void addMessage(Message message) { _messages.add(message); @@ -44,6 +79,9 @@ class MessagesProvider with ChangeNotifier { } } + // Persist to storage asynchronously + _persistMessages(); + notifyListeners(); } @@ -59,9 +97,22 @@ class MessagesProvider with ChangeNotifier { } } } + + // Persist to storage asynchronously + _persistMessages(); + notifyListeners(); } + /// Persist messages to storage (async, non-blocking) + Future _persistMessages() async { + try { + await _storageService.saveMessages(_messages); + } catch (e) { + print('❌ [MessagesProvider] Error persisting messages: $e'); + } + } + /// Get messages for a specific contact List getMessagesForContact(String senderKeyShort) { return _messages @@ -120,6 +171,7 @@ class MessagesProvider with ChangeNotifier { /// Clear all messages void clearMessages() { _messages.clear(); + _persistMessages(); notifyListeners(); } @@ -133,9 +185,15 @@ class MessagesProvider with ChangeNotifier { void clearAll() { _messages.clear(); _sarMarkers.clear(); + _persistMessages(); notifyListeners(); } + /// Get storage statistics + Future> getStorageStats() async { + return await _storageService.getStorageStats(); + } + /// Get message statistics Map get messageStats { return { diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index c6a4cb3..996ba36 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -249,6 +249,16 @@ class _ContactTile extends StatelessWidget { tooltip: 'Request telemetry', ), onTap: () => _showContactDetails(context, contact), + onLongPress: () { + final connectionProvider = context.read(); + connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Pinging ${contact.displayName} (direct connection)...'), + duration: const Duration(seconds: 2), + ), + ); + }, ), ); } @@ -344,11 +354,29 @@ class _ContactTile extends StatelessWidget { ), ), const SizedBox(height: 8), - if (contact.telemetry!.batteryPercentage != null) + if (contact.telemetry!.batteryMilliVolts != null) + _DetailRow( + 'Voltage', + '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + ) + else if (contact.telemetry!.batteryPercentage != null) _DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'), if (contact.telemetry!.temperature != null) _DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), - _DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'), + if (contact.telemetry!.humidity != null) + _DetailRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), + if (contact.telemetry!.pressure != null) + _DetailRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), + if (contact.telemetry!.gpsLocation != null) + _DetailRow( + 'GPS (Telemetry)', + '${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}', + ), + _DetailRow( + 'Updated', + '${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})', + ), ], ], ), @@ -418,4 +446,35 @@ class _ContactTile extends StatelessWidget { if (percentage > 20) return Colors.orange; return Colors.red; } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day); + + if (timestampDate == today) { + // Today - show time only + return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; + } else { + // Another day - show date and time + return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}'; + } + } + + String _formatTimeAgo(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inSeconds < 60) { + return '${diff.inSeconds}s ago'; + } else if (diff.inMinutes < 60) { + return '${diff.inMinutes}m ago'; + } else if (diff.inHours < 24) { + return '${diff.inHours}h ago'; + } else if (diff.inDays == 1) { + return 'yesterday'; + } else { + return '${diff.inDays}d ago'; + } + } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4181fdc..7b30faa 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,6 +9,8 @@ import 'map_tab.dart'; import 'map_management_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; +import 'packet_log_screen.dart'; +import 'message_history_screen.dart'; class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -231,6 +233,25 @@ class _HomeScreenState extends State with SingleTickerProviderStateM PopupMenuButton( icon: const Icon(Icons.more_vert), itemBuilder: (context) => [ + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.history), + SizedBox(width: 8), + Text('Message History'), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const MessageHistoryScreen(), + ), + ); + }); + }, + ), PopupMenuItem( child: const Row( children: [ @@ -440,21 +461,34 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), const SizedBox(width: 8), // Disconnect button (prominent, icon only) - FilledButton( - onPressed: () async { - await provider.disconnect(); - if (context.mounted) { - context.read().clearAllData(); - } + // Long press to open packet log viewer + GestureDetector( + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PacketLogScreen( + bleService: provider.bleService, + ), + ), + ); }, - style: FilledButton.styleFrom( - backgroundColor: Colors.red.shade700, - foregroundColor: Colors.white, - padding: const EdgeInsets.all(10), - minimumSize: const Size(40, 40), - shape: const CircleBorder(), + child: FilledButton( + onPressed: () async { + await provider.disconnect(); + if (context.mounted) { + context.read().clearAllData(); + } + }, + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.all(10), + minimumSize: const Size(40, 40), + shape: const CircleBorder(), + ), + child: const Icon(Icons.power_settings_new, size: 20), ), - child: const Icon(Icons.power_settings_new, size: 20), ), ], ), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index cbafd58..b08be0c 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1544,8 +1544,14 @@ class _DetailedCompassDialogState extends State<_DetailedCompassDialog> { ); } - // Show battery if available - if (_selectedContact!.telemetry?.batteryPercentage != null) { + // Show voltage/battery if available + if (_selectedContact!.telemetry?.batteryMilliVolts != null) { + final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3); + final percent = _selectedContact!.telemetry!.batteryPercentage != null + ? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)' + : ''; + additionalInfo = 'Voltage: ${volts}V$percent'; + } else if (_selectedContact!.telemetry?.batteryPercentage != null) { additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%'; } } else if (_selectedSarMarker != null) { diff --git a/lib/screens/message_history_screen.dart b/lib/screens/message_history_screen.dart new file mode 100644 index 0000000..307363f --- /dev/null +++ b/lib/screens/message_history_screen.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/messages_provider.dart'; +import '../models/message.dart'; + +/// Screen to view all stored message history +class MessageHistoryScreen extends StatefulWidget { + const MessageHistoryScreen({super.key}); + + @override + State createState() => _MessageHistoryScreenState(); +} + +class _MessageHistoryScreenState extends State { + String _searchQuery = ''; + MessageFilter _filter = MessageFilter.all; + final TextEditingController _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _showStorageInfo(BuildContext context) async { + final messagesProvider = context.read(); + final stats = await messagesProvider.getStorageStats(); + + if (!mounted) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Storage Information'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoRow( + label: 'Total Messages', + value: '${stats['messageCount']}', + ), + const SizedBox(height: 8), + _InfoRow( + label: 'Storage Size', + value: '${stats['storageSizeKB']} KB', + ), + const SizedBox(height: 8), + _InfoRow( + label: 'Storage Size (bytes)', + value: '${stats['storageSizeBytes']} bytes', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + void _showClearConfirmation(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear All Messages?'), + content: const Text( + 'This will permanently delete all stored messages. This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + context.read().clearAll(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('All messages cleared'), + backgroundColor: Colors.green, + ), + ); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('Clear All'), + ), + ], + ), + ); + } + + List _filterMessages(List messages) { + // Apply search filter + var filtered = messages.where((msg) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + return msg.text.toLowerCase().contains(query) || + msg.displaySender.toLowerCase().contains(query); + }).toList(); + + // Apply type filter + switch (_filter) { + case MessageFilter.all: + break; + case MessageFilter.contact: + filtered = filtered.where((m) => m.isContactMessage).toList(); + break; + case MessageFilter.channel: + filtered = filtered.where((m) => m.isChannelMessage).toList(); + break; + case MessageFilter.sarMarker: + filtered = filtered.where((m) => m.isSarMarker).toList(); + break; + } + + // Sort by most recent first + filtered.sort((a, b) => b.sentAt.compareTo(a.sentAt)); + + return filtered; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Message History'), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + tooltip: 'Storage Info', + onPressed: () => _showStorageInfo(context), + ), + IconButton( + icon: const Icon(Icons.delete_forever), + tooltip: 'Clear All', + onPressed: () => _showClearConfirmation(context), + ), + ], + ), + body: Consumer( + builder: (context, messagesProvider, child) { + final messages = _filterMessages(messagesProvider.messages); + + return Column( + children: [ + // Search and filter bar + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Column( + children: [ + // Search field + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search messages...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _searchController.clear(); + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 8), + // Filter chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'All (${messagesProvider.messages.length})', + isSelected: _filter == MessageFilter.all, + onTap: () => setState(() => _filter = MessageFilter.all), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Contacts (${messagesProvider.contactMessages.length})', + isSelected: _filter == MessageFilter.contact, + onTap: () => setState(() => _filter = MessageFilter.contact), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Channels (${messagesProvider.channelMessages.length})', + isSelected: _filter == MessageFilter.channel, + onTap: () => setState(() => _filter = MessageFilter.channel), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'SAR (${messagesProvider.sarMarkerMessages.length})', + isSelected: _filter == MessageFilter.sarMarker, + onTap: () => setState(() => _filter = MessageFilter.sarMarker), + ), + ], + ), + ), + ], + ), + ), + + // Message list + Expanded( + child: messages.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _searchQuery.isNotEmpty + ? Icons.search_off + : Icons.inbox_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty + ? 'No messages found' + : 'No messages stored', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + _searchQuery.isNotEmpty + ? 'Try a different search term' + : 'Messages will appear here once received', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return _MessageHistoryCard(message: message); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +enum MessageFilter { + all, + contact, + channel, + sarMarker, +} + +class _FilterChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _FilterChip({ + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => onTap(), + selectedColor: Theme.of(context).colorScheme.primaryContainer, + checkmarkColor: Theme.of(context).colorScheme.onPrimaryContainer, + ); + } +} + +class _MessageHistoryCard extends StatelessWidget { + final Message message; + + const _MessageHistoryCard({required this.message}); + + String _formatDateTime(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day); + + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + final timeStr = '$hour:$minute'; + + if (messageDate == today) { + return 'Today $timeStr'; + } else if (messageDate == today.subtract(const Duration(days: 1))) { + return 'Yesterday $timeStr'; + } else if (now.difference(dateTime).inDays < 7) { + final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + final weekday = weekdays[dateTime.weekday - 1]; + return '$weekday $timeStr'; + } else { + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + final month = months[dateTime.month - 1]; + return '$month ${dateTime.day}, ${dateTime.year} $timeStr'; + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + children: [ + // Type icon + Icon( + message.isChannelMessage ? Icons.tag : Icons.person, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + // Sender + Expanded( + child: Text( + message.displaySender, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // SAR badge + if (message.isSarMarker) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'SAR', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + + // Message content + if (message.isSarMarker && message.sarMarkerType != null) ...[ + Row( + children: [ + Text( + message.sarMarkerType!.emoji, + style: const TextStyle(fontSize: 24), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.sarMarkerType!.displayName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (message.sarGpsCoordinates != null) + Text( + '${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ], + ), + ] else + Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 8), + + // Footer row + Row( + children: [ + Icon( + Icons.access_time, + size: 12, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + const SizedBox(width: 4), + Text( + _formatDateTime(message.sentAt), + style: Theme.of(context).textTheme.bodySmall, + ), + const Spacer(), + Text( + 'Received: ${_formatDateTime(message.receivedAt)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({ + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index fa77fcb..4eb2483 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -6,6 +6,7 @@ import '../providers/messages_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/map_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/app_provider.dart'; import '../models/message.dart'; import '../models/sar_marker.dart'; @@ -24,6 +25,10 @@ class _MessagesTabState extends State { int _characterCount = 0; static const int _maxCharacters = 160; + // Message recipient selection + String? _selectedRecipientId; // null = broadcast to public channel (channel 0) + MessageRecipientType _recipientType = MessageRecipientType.room; + @override void initState() { super.initState(); @@ -62,30 +67,39 @@ class _MessagesTabState extends State { } try { - // Default to sending to room/channel (first available room) - final rooms = contactsProvider.rooms; + if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) { + // Send to specific contact + final contact = contactsProvider.contacts.firstWhere( + (c) => c.publicKeyHex == _selectedRecipientId, + ); + await connectionProvider.sendTextMessage( + contactPublicKey: contact.publicKey, + text: text, + ); + } else { + // Send to room/channel + // Default to channel 0 (public channel) if no specific room selected + int channelIdx = 0; - if (rooms.isNotEmpty) { - // Send to first available room - final defaultRoom = rooms.first; - final channelIdx = defaultRoom.outPath.isNotEmpty - ? defaultRoom.outPath[0] - : 0; + if (_selectedRecipientId != null) { + // Try to find selected room + final rooms = contactsProvider.rooms; + try { + final targetRoom = rooms.firstWhere( + (r) => r.publicKeyHex == _selectedRecipientId, + ); + if (targetRoom.outPath.isNotEmpty) { + channelIdx = targetRoom.outPath[0]; + } + } catch (e) { + // Room not found, use default channel 0 + } + } await connectionProvider.sendChannelMessage( channelIdx: channelIdx, text: text, ); - } else { - // No rooms available, show error - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No channels available'), - backgroundColor: Colors.orange, - ), - ); - return; } _textController.clear(); @@ -110,6 +124,82 @@ class _MessagesTabState extends State { } } + void _showRecipientSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => _RecipientSelectorSheet( + selectedRecipientId: _selectedRecipientId, + selectedRecipientType: _recipientType, + onSelect: (recipientId, recipientType) { + setState(() { + _selectedRecipientId = recipientId; + _recipientType = recipientType; + }); + + // Fetch messages from the newly selected channel/room + _syncMessagesForRecipient(); + }, + ), + ); + } + + /// Sync messages when recipient changes + Future _syncMessagesForRecipient() async { + final appProvider = context.read(); + + if (!appProvider.connectionProvider.deviceInfo.isConnected) { + return; + } + + try { + debugPrint('🔄 [MessagesTab] Syncing messages after channel/room change...'); + final messageCount = await appProvider.syncMessages(); + + if (!mounted) return; + if (messageCount > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Synced $messageCount message${messageCount == 1 ? '' : 's'} from ${_getRecipientDisplayName()}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + debugPrint('❌ [MessagesTab] Error syncing messages: $e'); + } + } + + String _getRecipientDisplayName() { + final contactsProvider = context.read(); + + if (_selectedRecipientId == null) { + // Default to public channel + return 'Public Channel'; + } + + if (_recipientType == MessageRecipientType.contact) { + try { + final contact = contactsProvider.contacts.firstWhere( + (c) => c.publicKeyHex == _selectedRecipientId, + ); + return contact.displayName; + } catch (e) { + return 'Public Channel'; + } + } else { + try { + final room = contactsProvider.rooms.firstWhere( + (r) => r.publicKeyHex == _selectedRecipientId, + ); + return room.displayName; + } catch (e) { + return 'Public Channel'; + } + } + } + void _showSarDialog() { showModalBottomSheet( context: context, @@ -151,30 +241,40 @@ class _MessagesTabState extends State { ? '$sarMessage $notes' : sarMessage; - // Default to sending to room/channel (first available room) - final rooms = contactsProvider.rooms; + // Send to selected recipient (contact or room) + if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) { + // Send to specific contact + final contact = contactsProvider.contacts.firstWhere( + (c) => c.publicKeyHex == _selectedRecipientId, + ); + await connectionProvider.sendTextMessage( + contactPublicKey: contact.publicKey, + text: fullMessage, + ); + } else { + // Send to room/channel + // Default to channel 0 (public channel) if no specific room selected + int channelIdx = 0; - if (rooms.isNotEmpty) { - // Send to first available room - final defaultRoom = rooms.first; - final channelIdx = defaultRoom.outPath.isNotEmpty - ? defaultRoom.outPath[0] - : 0; + if (_selectedRecipientId != null) { + // Try to find selected room + final rooms = contactsProvider.rooms; + try { + final targetRoom = rooms.firstWhere( + (r) => r.publicKeyHex == _selectedRecipientId, + ); + if (targetRoom.outPath.isNotEmpty) { + channelIdx = targetRoom.outPath[0]; + } + } catch (e) { + // Room not found, use default channel 0 + } + } await connectionProvider.sendChannelMessage( channelIdx: channelIdx, text: fullMessage, ); - } else { - // No rooms available, show error - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No channels available'), - backgroundColor: Colors.orange, - ), - ); - return; } if (!mounted) return; @@ -197,11 +297,80 @@ class _MessagesTabState extends State { } + Future _handleRefresh() async { + final appProvider = context.read(); + final messageCount = await appProvider.syncMessages(); + + if (!mounted) return; + if (messageCount > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Synced $messageCount message${messageCount == 1 ? '' : 's'}'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + } + + List _getFilteredMessages(MessagesProvider messagesProvider) { + // If viewing a specific contact, show all their messages indefinitely + if (_recipientType == MessageRecipientType.contact && _selectedRecipientId != null) { + final contactMessages = messagesProvider.contactMessages + .where((m) => m.senderPublicKeyPrefix != null) + .toList(); + + // Filter by selected contact + return contactMessages + .where((m) { + final senderHex = m.senderPublicKeyPrefix! + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return _selectedRecipientId!.startsWith(senderHex); + }) + .toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + } + + // For channels/rooms, limit to recent 100 messages + if (_recipientType == MessageRecipientType.room) { + if (_selectedRecipientId != null) { + // Filter by specific channel + final contactsProvider = context.read(); + try { + final room = contactsProvider.rooms.firstWhere( + (r) => r.publicKeyHex == _selectedRecipientId, + ); + final channelIdx = room.outPath.isNotEmpty ? room.outPath[0] : 0; + return messagesProvider + .getMessagesForChannel(channelIdx) + .take(100) + .toList(); + } catch (e) { + // Room not found, show all channel messages + return messagesProvider.channelMessages + .take(100) + .toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + } + } + + // Default: show recent channel messages (public channel) + return messagesProvider.channelMessages + .take(100) + .toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + } + + // Fallback: show all recent messages + return messagesProvider.getRecentMessages(count: 100); + } + @override Widget build(BuildContext context) { return Consumer( builder: (context, messagesProvider, child) { - final messages = messagesProvider.getRecentMessages(count: 100); + final messages = _getFilteredMessages(messagesProvider); return Column( children: [ @@ -231,28 +400,31 @@ class _MessagesTabState extends State { ], ), ) - : ListView.builder( - reverse: true, - padding: const EdgeInsets.all(8), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return _MessageBubble( - message: message, - onTap: message.isSarMarker && - message.sarGpsCoordinates != null - ? () { - final mapProvider = - context.read(); - mapProvider.navigateToLocation( - location: message.sarGpsCoordinates!, - zoom: 15.0, - ); - widget.onNavigateToMap(); - } - : null, - ); - }, + : RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView.builder( + reverse: true, + padding: const EdgeInsets.all(8), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return _MessageBubble( + message: message, + onTap: message.isSarMarker && + message.sarGpsCoordinates != null + ? () { + final mapProvider = + context.read(); + mapProvider.navigateToLocation( + location: message.sarGpsCoordinates!, + zoom: 15.0, + ); + widget.onNavigateToMap(); + } + : null, + ); + }, + ), ), ), @@ -268,66 +440,115 @@ class _MessagesTabState extends State { ), ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + child: Column( children: [ - // SAR quick action button - IconButton( - icon: const Icon(Icons.add_location_alt), - tooltip: 'Send SAR marker', - onPressed: _showSarDialog, - style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 8), - // Text field with embedded send button - Expanded( - child: TextField( - controller: _textController, - focusNode: _focusNode, - maxLength: _maxCharacters, - maxLines: null, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - hintText: 'Message to channel...', - hintStyle: const TextStyle(fontSize: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - isDense: true, - counterText: _characterCount >= 150 - ? '$_characterCount/$_maxCharacters' - : '', - counterStyle: TextStyle( - fontSize: 10, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : Theme.of(context).textTheme.bodySmall?.color, - ), - suffixIcon: IconButton( - icon: Icon( - Icons.send_rounded, - size: 22, - color: _textController.text.trim().isEmpty - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.primary, + // Recipient selector bar + Consumer( + builder: (context, contactsProvider, child) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: _showRecipientSelector, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + _recipientType == MessageRecipientType.contact + ? Icons.person + : Icons.tag, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'To: ${_getRecipientDisplayName()}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.arrow_drop_down, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), ), - onPressed: _textController.text.trim().isEmpty - ? null - : _sendMessage, - tooltip: 'Send', + ), + ); + }, + ), + // Message input row + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // SAR quick action button + IconButton( + icon: const Icon(Icons.add_location_alt), + tooltip: 'Send SAR marker', + onPressed: _showSarDialog, + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, ), ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - ), + const SizedBox(width: 8), + // Text field with embedded send button + Expanded( + child: TextField( + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + maxLines: null, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: const TextStyle(fontSize: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + isDense: true, + counterText: _characterCount >= 150 + ? '$_characterCount/$_maxCharacters' + : '', + counterStyle: TextStyle( + fontSize: 10, + color: _characterCount > _maxCharacters * 0.9 + ? Colors.orange + : Theme.of(context).textTheme.bodySmall?.color, + ), + suffixIcon: IconButton( + icon: Icon( + Icons.send_rounded, + size: 22, + color: _textController.text.trim().isEmpty + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.primary, + ), + onPressed: _textController.text.trim().isEmpty + ? null + : _sendMessage, + tooltip: 'Send', + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ), + ], ), ], ), @@ -948,3 +1169,211 @@ class _MarkerTypeChip extends StatelessWidget { ); } } + +// Message recipient type enum +enum MessageRecipientType { + contact, + room, +} + +// Recipient Selector Sheet +class _RecipientSelectorSheet extends StatefulWidget { + final String? selectedRecipientId; + final MessageRecipientType selectedRecipientType; + final void Function(String?, MessageRecipientType) onSelect; + + const _RecipientSelectorSheet({ + required this.selectedRecipientId, + required this.selectedRecipientType, + required this.onSelect, + }); + + @override + State<_RecipientSelectorSheet> createState() => _RecipientSelectorSheetState(); +} + +class _RecipientSelectorSheetState extends State<_RecipientSelectorSheet> with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: widget.selectedRecipientType == MessageRecipientType.contact ? 0 : 1, + ); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + children: [ + Text( + 'Select Recipient', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + // Tab bar + TabBar( + controller: _tabController, + tabs: const [ + Tab( + icon: Icon(Icons.person), + text: 'Contacts', + ), + Tab( + icon: Icon(Icons.tag), + text: 'Channels', + ), + ], + ), + // Tab view + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Contacts tab + Consumer( + builder: (context, contactsProvider, child) { + final contacts = contactsProvider.chatContacts; + + if (contacts.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('No contacts available'), + ], + ), + ); + } + + return ListView.builder( + itemCount: contacts.length, + itemBuilder: (context, index) { + final contact = contacts[index]; + final isSelected = widget.selectedRecipientType == MessageRecipientType.contact && + widget.selectedRecipientId == contact.publicKeyHex; + + return ListTile( + leading: CircleAvatar( + child: contact.roleEmoji != null + ? Text(contact.roleEmoji!) + : const Icon(Icons.person), + ), + title: Text(contact.displayName), + subtitle: Text( + contact.publicKeyShort, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + trailing: isSelected + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + selected: isSelected, + onTap: () { + widget.onSelect(contact.publicKeyHex, MessageRecipientType.contact); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + // Channels/Rooms tab + Consumer( + builder: (context, contactsProvider, child) { + final rooms = contactsProvider.rooms; + + if (rooms.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.tag, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text('No channels available'), + ], + ), + ); + } + + return ListView.builder( + itemCount: rooms.length, + itemBuilder: (context, index) { + final room = rooms[index]; + final isSelected = widget.selectedRecipientType == MessageRecipientType.room && + widget.selectedRecipientId == room.publicKeyHex; + + return ListTile( + leading: const CircleAvatar( + child: Icon(Icons.tag), + ), + title: Text(room.displayName), + subtitle: Text( + room.publicKeyShort, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + trailing: isSelected + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + selected: isSelected, + onTap: () { + widget.onSelect(room.publicKeyHex, MessageRecipientType.room); + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart new file mode 100644 index 0000000..56afe6e --- /dev/null +++ b/lib/screens/packet_log_screen.dart @@ -0,0 +1,568 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import '../models/ble_packet_log.dart'; +import '../services/meshcore_ble_service.dart'; + +class PacketLogScreen extends StatefulWidget { + final MeshCoreBleService bleService; + + const PacketLogScreen({ + super.key, + required this.bleService, + }); + + @override + State createState() => _PacketLogScreenState(); +} + +class _PacketLogScreenState extends State { + bool _autoScroll = true; + final ScrollController _scrollController = ScrollController(); + String _searchQuery = ''; + PacketDirection? _filterDirection; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + List get _filteredLogs { + var logs = widget.bleService.packetLogs; + + // Filter by direction + if (_filterDirection != null) { + logs = logs.where((log) => log.direction == _filterDirection).toList(); + } + + // Filter by search query + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + logs = logs.where((log) { + return log.hexData.toLowerCase().contains(query) || + (log.description?.toLowerCase().contains(query) ?? false) || + log.summary.toLowerCase().contains(query); + }).toList(); + } + + return logs; + } + + Future _exportLogs(BuildContext context) async { + try { + final logs = _filteredLogs; + if (logs.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No logs to export')), + ); + } + return; + } + + // Create CSV content + final buffer = StringBuffer(); + buffer.writeln('Timestamp,Direction,Size (bytes),Code,Hex Data,Description'); + for (final log in logs) { + buffer.writeln(log.toCsvRow()); + } + + // Save to temporary file + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv'); + await file.writeAsString(buffer.toString()); + + // Share the file + await Share.shareXFiles( + [XFile(file.path)], + subject: 'MeshCore BLE Packet Logs', + text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } + } + + Future _exportAsText(BuildContext context) async { + try { + final logs = _filteredLogs; + if (logs.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No logs to export')), + ); + } + return; + } + + // Create text content + final buffer = StringBuffer(); + buffer.writeln('MeshCore BLE Packet Logs'); + buffer.writeln('=' * 80); + buffer.writeln('Exported: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Total packets: ${logs.length}'); + buffer.writeln('=' * 80); + buffer.writeln(); + + for (final log in logs) { + buffer.writeln(log.toLogString()); + } + + // Save to temporary file + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt'); + await file.writeAsString(buffer.toString()); + + // Share the file + await Share.shareXFiles( + [XFile(file.path)], + subject: 'MeshCore BLE Packet Logs', + text: 'Exported ${logs.length} BLE packets from MeshCore SAR app', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } + } + + void _copyToClipboard(BuildContext context, BlePacketLog log) { + Clipboard.setData(ClipboardData(text: log.hexData)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Hex data copied to clipboard'), + duration: Duration(seconds: 1), + ), + ); + } + + void _clearLogs(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Packet Logs'), + content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + widget.bleService.clearPacketLogs(); + Navigator.pop(context); + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Packet logs cleared')), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Clear'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final logs = _filteredLogs; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('BLE Packet Logs'), + Text( + '${logs.length} packets', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + // Direction filter + PopupMenuButton( + icon: Icon(_filterDirection == null + ? Icons.filter_list + : _filterDirection == PacketDirection.rx + ? Icons.arrow_downward + : Icons.arrow_upward), + tooltip: 'Filter by direction', + onSelected: (direction) { + setState(() { + _filterDirection = direction; + }); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: null, + child: Row( + children: [ + Icon(Icons.filter_list, + color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null), + const SizedBox(width: 8), + Text('All', + style: TextStyle( + fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)), + ], + ), + ), + PopupMenuItem( + value: PacketDirection.rx, + child: Row( + children: [ + Icon(Icons.arrow_downward, + color: _filterDirection == PacketDirection.rx + ? Theme.of(context).colorScheme.primary + : null), + const SizedBox(width: 8), + Text('RX (Received)', + style: TextStyle( + fontWeight: + _filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)), + ], + ), + ), + PopupMenuItem( + value: PacketDirection.tx, + child: Row( + children: [ + Icon(Icons.arrow_upward, + color: _filterDirection == PacketDirection.tx + ? Theme.of(context).colorScheme.primary + : null), + const SizedBox(width: 8), + Text('TX (Sent)', + style: TextStyle( + fontWeight: + _filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)), + ], + ), + ), + ], + ), + // Auto-scroll toggle + IconButton( + icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), + tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll', + onPressed: () { + setState(() { + _autoScroll = !_autoScroll; + }); + }, + ), + // Export menu + PopupMenuButton( + icon: const Icon(Icons.share), + tooltip: 'Export logs', + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'csv', + child: Row( + children: [ + Icon(Icons.table_chart), + SizedBox(width: 8), + Text('Export as CSV'), + ], + ), + ), + const PopupMenuItem( + value: 'txt', + child: Row( + children: [ + Icon(Icons.text_snippet), + SizedBox(width: 8), + Text('Export as Text'), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'csv') { + _exportLogs(context); + } else if (value == 'txt') { + _exportAsText(context); + } + }, + ), + // Clear logs + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Clear logs', + onPressed: () => _clearLogs(context), + ), + ], + ), + body: Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + decoration: InputDecoration( + hintText: 'Search logs...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + // Logs list + Expanded( + child: logs.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.list_alt, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty || _filterDirection != null + ? 'No matching packets found' + : 'No packets logged yet', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + if (_searchQuery.isNotEmpty || _filterDirection != null) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: () { + setState(() { + _searchQuery = ''; + _filterDirection = null; + }); + }, + icon: const Icon(Icons.clear_all), + label: const Text('Clear filters'), + ), + ], + ], + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + + // Auto-scroll to bottom + if (_autoScroll && index == logs.length - 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + return _PacketLogCard( + log: log, + onCopy: () => _copyToClipboard(context, log), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _PacketLogCard extends StatelessWidget { + final BlePacketLog log; + final VoidCallback onCopy; + + const _PacketLogCard({ + required this.log, + required this.onCopy, + }); + + @override + Widget build(BuildContext context) { + final isRx = log.direction == PacketDirection.rx; + final directionColor = isRx ? Colors.green : Colors.blue; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: ExpansionTile( + leading: CircleAvatar( + backgroundColor: directionColor.withOpacity(0.2), + child: Icon( + isRx ? Icons.arrow_downward : Icons.arrow_upward, + color: directionColor, + size: 20, + ), + ), + title: Row( + children: [ + Text( + isRx ? 'RX' : 'TX', + style: TextStyle( + fontWeight: FontWeight.bold, + color: directionColor, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + if (log.description != null) + Flexible( + child: Text( + log.description!, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ) + else + Text( + 'Code: ${log.responseCode != null ? "0x${log.responseCode!.toRadixString(16).padLeft(2, '0')}" : "N/A"}', + style: const TextStyle(fontSize: 14), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + '${log.rawData.length} bytes • ${_formatTimestamp(log.timestamp)}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Hex data + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hex: ', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + Expanded( + child: SelectableText( + log.hexData, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + tooltip: 'Copy hex data', + onPressed: onCopy, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 8), + // Metadata + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _InfoChip( + icon: Icons.schedule, + label: log.timestamp.toIso8601String(), + ), + _InfoChip( + icon: Icons.data_usage, + label: '${log.rawData.length} bytes', + ), + if (log.responseCode != null) + _InfoChip( + icon: Icons.tag, + label: 'Code: 0x${log.responseCode!.toRadixString(16).padLeft(2, '0')} (${log.responseCode})', + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + String _formatTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inSeconds < 60) { + return '${diff.inSeconds}s ago'; + } else if (diff.inMinutes < 60) { + return '${diff.inMinutes}m ago'; + } else if (diff.inHours < 24) { + return '${diff.inHours}h ago'; + } else { + return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}'; + } + } +} + +class _InfoChip extends StatelessWidget { + final IconData icon; + final String label; + + const _InfoChip({ + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Chip( + avatar: Icon(icon, size: 16), + label: Text( + label, + style: const TextStyle(fontSize: 11), + ), + padding: const EdgeInsets.all(4), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} diff --git a/lib/services/buffer_reader.dart b/lib/services/buffer_reader.dart index a259c3a..3a4746b 100644 --- a/lib/services/buffer_reader.dart +++ b/lib/services/buffer_reader.dart @@ -50,6 +50,22 @@ class BufferReader { return value > 32767 ? value - 65536 : value; } + /// Read unsigned 16-bit integer (big-endian) + int readUInt16BE() { + if (_offset + 2 > _buffer.length) { + throw Exception('Buffer overflow: attempting to read beyond buffer length'); + } + final value = (_buffer[_offset] << 8) | _buffer[_offset + 1]; + _offset += 2; + return value; + } + + /// Read signed 16-bit integer (big-endian) + int readInt16BE() { + final value = readUInt16BE(); + return value > 32767 ? value - 65536 : value; + } + /// Read unsigned 32-bit integer (little-endian) int readUInt32LE() { if (_offset + 4 > _buffer.length) { diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 1913076..d568ecf 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -49,7 +49,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppAnalogInput: - final rawValue = reader.readInt16LE(); + final rawValue = reader.readInt16BE(); final value = rawValue / 100.0; print(' Analog Input (raw): $rawValue'); print(' Analog Input (volts): ${value}V'); @@ -63,7 +63,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppAnalogOutput: - final rawValue = reader.readInt16LE(); + final rawValue = reader.readInt16BE(); final value = rawValue / 100.0; print(' Analog Output (raw): $rawValue'); print(' Analog Output (volts): ${value}V'); @@ -71,7 +71,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppIlluminanceSensor: - final value = reader.readUInt16LE(); + final value = reader.readUInt16BE(); print(' Illuminance: $value lux'); extraSensorData['illuminance_$channel'] = value; break; @@ -83,7 +83,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppTemperatureSensor: - final rawValue = reader.readInt16LE(); + final rawValue = reader.readInt16BE(); temperature = rawValue / 10.0; print(' Temperature (raw): $rawValue'); print(' Temperature: ${temperature?.toStringAsFixed(1)}°C'); @@ -97,22 +97,22 @@ class CayenneLppParser { break; case MeshCoreConstants.lppAccelerometer: - final x = reader.readInt16LE() / 1000.0; - final y = reader.readInt16LE() / 1000.0; - final z = reader.readInt16LE() / 1000.0; + final x = reader.readInt16BE() / 1000.0; + final y = reader.readInt16BE() / 1000.0; + final z = reader.readInt16BE() / 1000.0; print(' Accelerometer: x=$x, y=$y, z=$z'); extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z}; break; case MeshCoreConstants.lppBarometer: - final rawValue = reader.readUInt16LE(); + final rawValue = reader.readUInt16BE(); pressure = rawValue / 10.0; print(' Barometer (raw): $rawValue'); print(' Barometer: ${pressure?.toStringAsFixed(1)} hPa'); break; case MeshCoreConstants.lppVoltageSensor: - final rawValue = reader.readUInt16LE(); + final rawValue = reader.readUInt16BE(); final value = rawValue / 100.0; print(' Voltage (raw): $rawValue'); print(' Voltage: ${value}V'); @@ -123,9 +123,9 @@ class CayenneLppParser { break; case MeshCoreConstants.lppGyrometer: - final x = reader.readInt16LE() / 100.0; - final y = reader.readInt16LE() / 100.0; - final z = reader.readInt16LE() / 100.0; + final x = reader.readInt16BE() / 100.0; + final y = reader.readInt16BE() / 100.0; + final z = reader.readInt16BE() / 100.0; print(' Gyrometer: x=$x, y=$y, z=$z'); extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z}; break; diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 0cf725f..d1252a7 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/contact.dart'; import '../models/contact_telemetry.dart'; import '../models/message.dart'; +import '../models/ble_packet_log.dart'; import 'buffer_reader.dart'; import 'buffer_writer.dart'; import 'meshcore_constants.dart'; @@ -15,6 +16,7 @@ typedef OnContactsCompleteCallback = void Function(List contacts); typedef OnMessageCallback = void Function(Message message); typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); typedef OnSelfInfoCallback = void Function(Map selfInfo); +typedef OnNoMoreMessagesCallback = void Function(); typedef OnErrorCallback = void Function(String error); typedef OnConnectionStateCallback = void Function(bool isConnected); @@ -32,6 +34,7 @@ class MeshCoreBleService { OnMessageCallback? onMessageReceived; OnTelemetryCallback? onTelemetryReceived; OnSelfInfoCallback? onSelfInfoReceived; + OnNoMoreMessagesCallback? onNoMoreMessages; OnErrorCallback? onError; // Internal state @@ -49,6 +52,11 @@ class MeshCoreBleService { VoidCallback? onRxActivity; VoidCallback? onTxActivity; + // Packet logging + final List _packetLogs = []; + List get packetLogs => List.unmodifiable(_packetLogs); + static const int _maxLogSize = 1000; // Keep last 1000 packets + /// Scan for MeshCore devices Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* { try { @@ -238,6 +246,10 @@ class MeshCoreBleService { throw Exception('Characteristic does not support write operations'); } + // Log TX packet (extract command code from first byte) + final commandCode = data.isNotEmpty ? data[0] : null; + _logPacket(data, PacketDirection.tx, responseCode: commandCode); + // Increment TX packet counter and trigger activity indicator _txPacketCount++; onTxActivity?.call(); @@ -261,17 +273,22 @@ class MeshCoreBleService { return; } + final dataBytes = Uint8List.fromList(data); + // Increment RX packet counter and trigger activity indicator _rxPacketCount++; onRxActivity?.call(); print(' Raw data: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - final reader = BufferReader(Uint8List.fromList(data)); + final reader = BufferReader(dataBytes); final responseCode = reader.readByte(); print(' Response code: $responseCode (0x${responseCode.toRadixString(16)})'); print(' Remaining bytes: ${reader.remainingBytesCount}'); + // Log RX packet (before processing so we capture everything) + _logPacket(dataBytes, PacketDirection.rx, responseCode: responseCode); + switch (responseCode) { case MeshCoreConstants.respContactsStart: print(' → Handling ContactsStart'); @@ -317,6 +334,14 @@ class MeshCoreBleService { print(' → Handling LogRxData push'); _handleLogRxData(reader); break; + case MeshCoreConstants.pushNewAdvert: + print(' → Handling NewAdvert push'); + _handleNewAdvert(reader); + break; + case MeshCoreConstants.respNoMoreMessages: + print(' → Response: No More Messages'); + onNoMoreMessages?.call(); + break; case MeshCoreConstants.respOk: print(' → Response: OK'); break; @@ -708,6 +733,79 @@ class MeshCoreBleService { } } + /// Handle NewAdvert push + void _handleNewAdvert(BufferReader reader) { + try { + print(' [NewAdvert] Parsing new advertisement...'); + print(' Remaining bytes: ${reader.remainingBytesCount}'); + + // NewAdvert format is identical to Contact response: + // - 32 bytes: public key + // - 1 byte: type + // - 1 byte: flags + // - 1 byte: outPathLen + // - 64 bytes: outPath + // - 32 bytes: advName (null-terminated string) + // - 4 bytes: lastAdvert (uint32) + // - 4 bytes: advLat (int32) + // - 4 bytes: advLon (int32) + // - 4 bytes: lastMod (uint32) + + final publicKey = reader.readBytes(32); + print(' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + + final typeByte = reader.readByte(); + final type = ContactType.fromValue(typeByte); + print(' Type byte: $typeByte → Type: $type'); + + final flags = reader.readByte(); + print(' Flags: $flags (0x${flags.toRadixString(16).padLeft(2, '0')})'); + + final outPathLen = reader.readInt8(); + print(' Out path length: $outPathLen'); + + final outPath = reader.readBytes(64); + print(' Out path: ${outPath.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...'); + + final advName = reader.readCString(32); + print(' Advertised name: "$advName"'); + + final lastAdvert = reader.readUInt32LE(); + print(' Last advert timestamp: $lastAdvert'); + + final advLat = reader.readInt32LE(); + print(' Latitude (raw int32): $advLat'); + print(' Latitude (decimal): ${advLat / 1000000.0}°'); + + final advLon = reader.readInt32LE(); + print(' Longitude (raw int32): $advLon'); + print(' Longitude (decimal): ${advLon / 1000000.0}°'); + + final lastMod = reader.readUInt32LE(); + print(' Last modified timestamp: $lastMod'); + + final contact = Contact( + publicKey: publicKey, + type: type, + flags: flags, + outPathLen: outPathLen, + outPath: outPath, + advName: advName, + lastAdvert: lastAdvert, + advLat: advLat, + advLon: advLon, + lastMod: lastMod, + ); + + print(' ✅ [NewAdvert] Parsed successfully - new contact advertised on network'); + // Call the contact received callback to add/update the contact + onContactReceived?.call(contact); + } catch (e) { + print(' ❌ [NewAdvert] Parsing error: $e'); + onError?.call('NewAdvert parsing error: $e'); + } + } + /// Send AppStart command Future _sendAppStart() async { print('📤 [BLE] Preparing AppStart command...'); @@ -780,10 +878,11 @@ class MeshCoreBleService { } /// Request telemetry from contact - Future requestTelemetry(Uint8List contactPublicKey) async { + /// [zeroHop] - if true, only direct connection (no mesh forwarding) + Future requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async { final writer = BufferWriter(); writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq); - writer.writeByte(0); // reserved + writer.writeByte(zeroHop ? 0 : 255); // hop count: 0 = direct only, 255 = unlimited writer.writeByte(0); // reserved writer.writeByte(0); // reserved writer.writeBytes(contactPublicKey); @@ -797,6 +896,14 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// Sync next message from device queue + /// Returns true if a message was retrieved, false if no more messages + Future syncNextMessage() async { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSyncNextMessage); + await _writeData(writer.toBytes()); + } + /// Set device time Future setDeviceTime() async { final writer = BufferWriter(); @@ -862,6 +969,87 @@ class MeshCoreBleService { await _writeData(writer.toBytes()); } + /// Log a packet + void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { + // Add new packet + _packetLogs.add(BlePacketLog( + timestamp: DateTime.now(), + rawData: data, + direction: direction, + responseCode: responseCode, + description: _getPacketDescription(responseCode, direction), + )); + + // Limit log size to prevent memory issues + if (_packetLogs.length > _maxLogSize) { + _packetLogs.removeAt(0); + } + } + + /// Get human-readable description of packet + String? _getPacketDescription(int? code, PacketDirection direction) { + if (direction == PacketDirection.tx) { + // TX packets - command codes + switch (code) { + case MeshCoreConstants.cmdGetContacts: + return 'Get Contacts'; + case MeshCoreConstants.cmdSendTxtMsg: + return 'Send Text Message'; + case MeshCoreConstants.cmdSendChannelTxtMsg: + return 'Send Channel Message'; + case MeshCoreConstants.cmdSendTelemetryReq: + return 'Request Telemetry'; + case MeshCoreConstants.cmdDeviceQuery: + return 'Device Query'; + case MeshCoreConstants.cmdAppStart: + return 'App Start'; + default: + return null; + } + } else { + // RX packets - response codes + switch (code) { + case MeshCoreConstants.respContactsStart: + return 'Contacts Start'; + case MeshCoreConstants.respContact: + return 'Contact Info'; + case MeshCoreConstants.respEndOfContacts: + return 'End of Contacts'; + case MeshCoreConstants.respSent: + return 'Message Sent'; + case MeshCoreConstants.respContactMsgRecv: + return 'Contact Message'; + case MeshCoreConstants.respChannelMsgRecv: + return 'Channel Message'; + case MeshCoreConstants.pushTelemetryResponse: + return 'Telemetry Data'; + case MeshCoreConstants.respDeviceInfo: + return 'Device Info'; + case MeshCoreConstants.respSelfInfo: + return 'Self Info'; + case MeshCoreConstants.pushAdvert: + return 'Advertisement'; + case MeshCoreConstants.pushLogRxData: + return 'Log RX Data'; + case MeshCoreConstants.pushNewAdvert: + return 'New Advertisement'; + case MeshCoreConstants.respNoMoreMessages: + return 'No More Messages'; + case MeshCoreConstants.respOk: + return 'OK'; + case MeshCoreConstants.respErr: + return 'ERROR'; + default: + return null; + } + } + } + + /// Clear packet logs + void clearPacketLogs() { + _packetLogs.clear(); + } + /// Reset packet counters void resetCounters() { _rxPacketCount = 0; @@ -872,5 +1060,6 @@ class MeshCoreBleService { void dispose() { _txSubscription?.cancel(); _pendingContacts.clear(); + _packetLogs.clear(); } } diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart new file mode 100644 index 0000000..cbd5db8 --- /dev/null +++ b/lib/services/message_storage_service.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/message.dart'; +import '../models/sar_marker.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service for persisting messages to local storage +class MessageStorageService { + static const String _messagesKey = 'stored_messages'; + static const int _maxStoredMessages = 1000; // Store up to 1000 messages + + /// Save messages to persistent storage + Future saveMessages(List messages) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert messages to JSON + final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); + + // Limit to max stored messages (keep most recent) + final limitedList = jsonList.length > _maxStoredMessages + ? jsonList.sublist(jsonList.length - _maxStoredMessages) + : jsonList; + + final jsonString = jsonEncode(limitedList); + await prefs.setString(_messagesKey, jsonString); + + print('✅ [MessageStorage] Saved ${limitedList.length} messages to storage'); + } catch (e) { + print('❌ [MessageStorage] Error saving messages: $e'); + } + } + + /// Load messages from persistent storage + Future> loadMessages() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messagesKey); + + if (jsonString == null || jsonString.isEmpty) { + print('ℹ️ [MessageStorage] No stored messages found'); + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + final messages = jsonList + .map((json) => _messageFromJson(json as Map)) + .where((msg) => msg != null) + .cast() + .toList(); + + print('✅ [MessageStorage] Loaded ${messages.length} messages from storage'); + return messages; + } catch (e) { + print('❌ [MessageStorage] Error loading messages: $e'); + return []; + } + } + + /// Clear all stored messages + Future clearMessages() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_messagesKey); + print('✅ [MessageStorage] Cleared all stored messages'); + } catch (e) { + print('❌ [MessageStorage] Error clearing messages: $e'); + } + } + + /// Get storage statistics + Future> getStorageStats() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_messagesKey); + + if (jsonString == null || jsonString.isEmpty) { + return { + 'messageCount': 0, + 'storageSizeBytes': 0, + 'storageSizeKB': 0, + }; + } + + final sizeBytes = jsonString.length; + final jsonList = jsonDecode(jsonString) as List; + + return { + 'messageCount': jsonList.length, + 'storageSizeBytes': sizeBytes, + 'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2), + }; + } catch (e) { + print('❌ [MessageStorage] Error getting storage stats: $e'); + return { + 'messageCount': 0, + 'storageSizeBytes': 0, + 'storageSizeKB': 0, + }; + } + } + + /// Convert Message to JSON + Map _messageToJson(Message message) { + return { + 'id': message.id, + 'messageType': message.messageType.name, + 'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null + ? base64Encode(message.senderPublicKeyPrefix!) + : null, + 'channelIdx': message.channelIdx, + 'pathLen': message.pathLen, + 'textType': message.textType.value, + 'senderTimestamp': message.senderTimestamp, + 'text': message.text, + 'isSarMarker': message.isSarMarker, + 'sarMarkerType': message.sarMarkerType?.name, + 'sarGpsLat': message.sarGpsCoordinates?.latitude, + 'sarGpsLon': message.sarGpsCoordinates?.longitude, + 'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch, + 'senderName': message.senderName, + }; + } + + /// Convert JSON to Message + Message? _messageFromJson(Map json) { + try { + return Message( + id: json['id'] as String, + messageType: MessageType.values.firstWhere( + (e) => e.name == json['messageType'], + orElse: () => MessageType.contact, + ), + senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null + ? Uint8List.fromList( + base64Decode(json['senderPublicKeyPrefix'] as String)) + : null, + channelIdx: json['channelIdx'] as int?, + pathLen: json['pathLen'] as int, + textType: MessageTextType.fromValue(json['textType'] as int), + senderTimestamp: json['senderTimestamp'] as int, + text: json['text'] as String, + isSarMarker: json['isSarMarker'] as bool? ?? false, + sarMarkerType: json['sarMarkerType'] != null + ? SarMarkerType.values.firstWhere( + (e) => e.name == json['sarMarkerType'], + orElse: () => SarMarkerType.unknown, + ) + : null, + sarGpsCoordinates: json['sarGpsLat'] != null && + json['sarGpsLon'] != null + ? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double) + : null, + receivedAt: DateTime.fromMillisecondsSinceEpoch( + json['receivedAtMillis'] as int), + senderName: json['senderName'] as String?, + ); + } catch (e) { + print('❌ [MessageStorage] Error parsing message from JSON: $e'); + return null; + } + } +} diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index b614e97..ea2df4a 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -216,11 +216,21 @@ class MapMarkers { '${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}', ), ], - if (contact.displayBattery != null) + if (contact.telemetry?.batteryMilliVolts != null) + _InfoRow( + 'Voltage', + '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + ) + else if (contact.displayBattery != null) _InfoRow('Battery', '${contact.displayBattery!.round()}%'), if (contact.telemetry?.temperature != null) _InfoRow( 'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), + if (contact.telemetry?.humidity != null) + _InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), + if (contact.telemetry?.pressure != null) + _InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), _InfoRow('Last Seen', contact.timeSinceLastSeen), _InfoRow('Public Key', contact.publicKeyShort), ],