mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
feat: Enhance Device Configuration Screen with Device Info and Telemetry Modes
- Added a new section to display device information including Device Type, Max Contacts, Max Channels, Telemetry Modes, and Manual Add Contacts. - Implemented helper methods to convert device types and telemetry modes to user-friendly strings. - Removed Message History Screen and its references from the Home Screen. - Simplified message sending logic in Messages Tab to always send to the public channel. - Introduced a new channel selection feature in the SAR Update Sheet for sending messages. - Updated MeshCore BLE service to handle send confirmation responses and improved message sending protocols.
This commit is contained in:
517
CLAUDE.md
517
CLAUDE.md
@@ -68,72 +68,513 @@ lib/
|
||||
|
||||
### MeshCore Protocol
|
||||
|
||||
The app implements the MeshCore BLE protocol based on https://github.com/meshcore-dev/meshcore.js
|
||||
The app implements the MeshCore BLE Companion Radio protocol based on https://github.com/meshcore-dev/meshcore.js
|
||||
|
||||
**BLE Service**: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
**RX Characteristic** (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
**TX Characteristic** (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
|
||||
|
||||
#### Command Codes (Client → Device)
|
||||
- `4`: Get contacts list
|
||||
- `2`: Send text message
|
||||
- `39`: Request telemetry
|
||||
#### Protocol Overview
|
||||
|
||||
#### Response Codes (Device → Client)
|
||||
- `3`: Contact information
|
||||
- `7`: Message received
|
||||
- `0x8B` (139): Telemetry response (Cayenne LPP format)
|
||||
The companion radio acts as a 'server', responding to requests from the connected app (the 'client').
|
||||
|
||||
#### Binary Protocol Format
|
||||
**Frame Delimiters**:
|
||||
- **BLE**: A frame is a single characteristic value (BLE link layer handles integrity)
|
||||
- **USB**:
|
||||
- Outbound (radio → app): Starts with `>` (0x3E), 2-byte length (LE), then frame data
|
||||
- Inbound (app → radio): Starts with `<` (0x3C), 2-byte length (LE), then frame data
|
||||
|
||||
All protocol messages use little-endian byte order.
|
||||
**NOTE**: All uint32 values use Little Endian byte order!
|
||||
|
||||
**Get Contacts Request**:
|
||||
#### Command Codes (App → Radio)
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| 1 | CMD_APP_START | First command after connection, returns RESP_CODE_SELF_INFO(5) |
|
||||
| 2 | CMD_SEND_TXT_MSG | Send text message to contact (DM) |
|
||||
| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send flood-mode text message to channel |
|
||||
| 4 | CMD_GET_CONTACTS | Sync contacts list (optional 'since' param) |
|
||||
| 5 | CMD_GET_DEVICE_TIME | Get device clock (epoch secs, UTC) |
|
||||
| 6 | CMD_SET_DEVICE_TIME | Set device clock |
|
||||
| 7 | CMD_SEND_SELF_ADVERT | Send Advertisement packet (optional flood-mode) |
|
||||
| 8 | CMD_SET_ADVERT_NAME | Update node name in advertisements |
|
||||
| 9 | CMD_ADD_UPDATE_CONTACT | Add or modify a contact |
|
||||
| 10 | CMD_SYNC_NEXT_MESSAGE | Get next text message from queue |
|
||||
| 11 | CMD_SET_RADIO_PARAMS | Save new radio parameters |
|
||||
| 12 | CMD_SET_RADIO_TX_POWER | Set radio TX power level |
|
||||
| 13 | CMD_RESET_PATH | Reset out_path for a contact |
|
||||
| 14 | CMD_SET_ADVERT_LATLON | Update lat/lon in advertisements |
|
||||
| 15 | CMD_REMOVE_CONTACT | Remove a contact |
|
||||
| 16 | CMD_SHARE_CONTACT | Share contact via zero-hop advert |
|
||||
| 17 | CMD_EXPORT_CONTACT | Export contact as 'business card' |
|
||||
| 18 | CMD_IMPORT_CONTACT | Import contact from 'business card' |
|
||||
| 19 | CMD_REBOOT | Reboot companion device |
|
||||
| 20 | CMD_GET_BATT_AND_STORAGE | Get battery mV and storage stats |
|
||||
| 21 | CMD_SET_TUNING_PARAMS | Set tuning parameters |
|
||||
| 22 | CMD_DEVICE_QUERY | First command to send, returns RESP_CODE_DEVICE_INFO(13) |
|
||||
| 25 | CMD_SEND_RAW_DATA | Transmit PAYLOAD_TYPE_RAW_CUSTOM packet |
|
||||
| 26 | CMD_SEND_LOGIN | Send login request to repeater/room |
|
||||
| 27 | CMD_SEND_STATUS_REQ | Send status request to repeater/sensor |
|
||||
| 36 | CMD_SEND_TRACE_PATH | Initiate TRACE packet with SNR collection |
|
||||
| 37 | CMD_SET_DEVICE_PIN | Set BLE PIN code |
|
||||
| 38 | CMD_SET_OTHER_PARAMS | Set various other parameters |
|
||||
| 39 | CMD_SEND_TELEMETRY_REQ | Send telemetry request to node |
|
||||
| 40 | CMD_GET_CUSTOM_VARS | Retrieve all custom variables |
|
||||
| 41 | CMD_SET_CUSTOM_VAR | Set single custom variable |
|
||||
| 42 | CMD_GET_ADVERT_PATH | Query last advert path for contact |
|
||||
| 43 | CMD_GET_TUNING_PARAMS | Get airtime-factor and rx-delay settings |
|
||||
| 50 | CMD_SEND_BINARY_REQ | Send binary request to node (preferred over CMD_SEND_TELEMETRY_REQ) |
|
||||
| 51 | CMD_FACTORY_RESET | Erase flash file system |
|
||||
|
||||
#### Response Codes (Radio → App)
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| 0 | RESP_CODE_OK | Success |
|
||||
| 1 | RESP_CODE_ERR | Error (includes err_code) |
|
||||
| 2 | RESP_CODE_CONTACTS_START | Start of contacts sync sequence |
|
||||
| 3 | RESP_CODE_CONTACT | Single contact information |
|
||||
| 4 | RESP_CODE_END_OF_CONTACTS | End of contacts sync sequence |
|
||||
| 5 | RESP_CODE_SELF_INFO | Node's own information |
|
||||
| 6 | RESP_CODE_SENT | Message sent with expected ACK/TAG |
|
||||
| 7 | RESP_CODE_CONTACT_MSG_RECV | Contact message received |
|
||||
| 8 | RESP_CODE_CHANNEL_MSG_RECV | Channel message received |
|
||||
| 9 | RESP_CODE_CURR_TIME | Current device time |
|
||||
| 10 | RESP_CODE_NO_MORE_MESSAGES | Message queue empty |
|
||||
| 11 | RESP_CODE_EXPORT_CONTACT | Contact export data |
|
||||
| 12 | RESP_CODE_BATT_AND_STORAGE | Battery and storage info |
|
||||
| 13 | RESP_CODE_DEVICE_INFO | Device firmware and hardware info |
|
||||
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables state |
|
||||
| 22 | RESP_CODE_ADVERT_PATH | Last advert path for contact |
|
||||
|
||||
#### Push Notifications (Radio → App, Async)
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| 0x80 | PUSH_CODE_ADVERT | New advertisement packet received |
|
||||
| 0x81 | PUSH_CODE_PATH_UPDATED | Contact received new path |
|
||||
| 0x82 | PUSH_CODE_SEND_CONFIRMED | Message ACK received |
|
||||
| 0x83 | PUSH_CODE_MSG_WAITING | New text message received |
|
||||
| 0x84 | PUSH_CODE_RAW_DATA | PAYLOAD_TYPE_RAW_CUSTOM received |
|
||||
| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Login response successful |
|
||||
| 0x86 | PUSH_CODE_LOGIN_FAIL | Login response failed |
|
||||
| 0x87 | PUSH_CODE_STATUS_RESPONSE | Status response received |
|
||||
| 0x89 | PUSH_CODE_TRACE_DATA | TRACE packet reached end of path |
|
||||
| 0x8A | PUSH_CODE_NEW_ADVERT | New contact advert (manual_add_contacts=1) |
|
||||
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Telemetry response received |
|
||||
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary response received |
|
||||
|
||||
#### Frame Formats
|
||||
|
||||
**CMD_DEVICE_QUERY (22)**:
|
||||
```
|
||||
[0x04] - Command code
|
||||
[0x16] - Command code (22)
|
||||
[1 byte] - App target version (protocol version app understands)
|
||||
```
|
||||
|
||||
**Contact Response**:
|
||||
**RESP_CODE_DEVICE_INFO (13)**:
|
||||
```
|
||||
[0x03] - Response code
|
||||
[0x0D] - Response code (13)
|
||||
[1 byte] - Firmware version
|
||||
[1 byte] - Max contacts ÷ 2 (ver 3+)
|
||||
[1 byte] - Max channels (ver 3+)
|
||||
[4 bytes] - BLE PIN (uint32, ver 3+)
|
||||
[12 bytes] - Firmware build date (ASCII null-terminated, e.g., "19 Feb 2025")
|
||||
[40 bytes] - Manufacturer model (ASCII null-terminated)
|
||||
[20 bytes] - Semantic version (ASCII null-terminated)
|
||||
```
|
||||
|
||||
**CMD_APP_START (1)**:
|
||||
```
|
||||
[0x01] - Command code (1)
|
||||
[1 byte] - App version
|
||||
[6 bytes] - Reserved (zeros)
|
||||
[N bytes] - App name (remainder of frame, varchar)
|
||||
```
|
||||
|
||||
**RESP_CODE_SELF_INFO (5)**:
|
||||
```
|
||||
[0x05] - Response code (5)
|
||||
[1 byte] - Type (ADV_TYPE_*)
|
||||
[1 byte] - TX power in dBm (current)
|
||||
[1 byte] - Max TX power radio supports
|
||||
[32 bytes] - Public key
|
||||
[1 byte] - Contact type (0=none, 1=chat, 2=repeater, 3=room)
|
||||
[64 bytes] - Advertised name (null-terminated string)
|
||||
[4 bytes] - Latitude (int32, divide by 10000 for degrees)
|
||||
[4 bytes] - Longitude (int32, divide by 10000 for degrees)
|
||||
[4 bytes] - Advert latitude * 1E6 (int32)
|
||||
[4 bytes] - Advert longitude * 1E6 (int32)
|
||||
[1 byte] - Multi ACKs (0=no extra, 1=send extra ACK)
|
||||
[1 byte] - Advert location policy (0=don't share, 1=share)
|
||||
[1 byte] - Telemetry modes (bits 0-1: Base mode, bits 2-3: Location mode)
|
||||
Modes: 0=DENY, 1=apply contact.flags, 2=ALLOW ALL
|
||||
[1 byte] - Manual add contacts (0 or 1)
|
||||
[4 bytes] - Radio freq * 1000 (uint32)
|
||||
[4 bytes] - Radio bandwidth (kHz) * 1000 (uint32)
|
||||
[1 byte] - Spreading factor
|
||||
[1 byte] - Coding rate
|
||||
[N bytes] - Name (remainder of frame, varchar)
|
||||
```
|
||||
|
||||
**Message Received**:
|
||||
**CMD_GET_CONTACTS (4)**:
|
||||
```
|
||||
[0x07] - Response code
|
||||
[1 byte] - Message type (0=contact, 1=channel)
|
||||
[4 bytes] - Sender public key prefix
|
||||
[4 bytes] - Recipient public key prefix
|
||||
[2 bytes] - Text length (uint16)
|
||||
[N bytes] - UTF-8 text
|
||||
[0x04] - Command code (4)
|
||||
[4 bytes] - (Optional) Since timestamp (uint32, last contact.lastmod received)
|
||||
```
|
||||
|
||||
**Send Text Message**:
|
||||
**RESP_CODE_CONTACTS_START (2)**:
|
||||
```
|
||||
[0x02] - Command code
|
||||
[32 bytes] - Recipient public key
|
||||
[2 bytes] - Text length (uint16)
|
||||
[N bytes] - UTF-8 text
|
||||
[0x02] - Response code (2)
|
||||
[4 bytes] - Total contact count (uint32)
|
||||
```
|
||||
|
||||
**Request Telemetry**:
|
||||
**RESP_CODE_CONTACT (3)**:
|
||||
```
|
||||
[0x27] (39) - Command code
|
||||
[32 bytes] - Contact public key
|
||||
[0x03] - Response code (3)
|
||||
[32 bytes] - Public key
|
||||
[1 byte] - Type (ADV_TYPE_*)
|
||||
[1 byte] - Flags
|
||||
[1 byte] - Out path length (signed)
|
||||
[64 bytes] - Out path
|
||||
[32 bytes] - Advertised name (null-terminated)
|
||||
[4 bytes] - Last advert timestamp (uint32)
|
||||
[4 bytes] - Advert latitude * 1E6 (int32)
|
||||
[4 bytes] - Advert longitude * 1E6 (int32)
|
||||
[4 bytes] - Last modified timestamp (uint32)
|
||||
```
|
||||
|
||||
**Telemetry Response**:
|
||||
**RESP_CODE_END_OF_CONTACTS (4)**:
|
||||
```
|
||||
[0x8B] (139) - Response code
|
||||
[4 bytes] - Contact public key prefix
|
||||
[N bytes] - Cayenne LPP payload
|
||||
[0x04] - Response code (4)
|
||||
[4 bytes] - Most recent lastmod (uint32, use for next 'since' param)
|
||||
```
|
||||
|
||||
**CMD_SET_DEVICE_TIME (6)**:
|
||||
```
|
||||
[0x06] - Command code (6)
|
||||
[4 bytes] - Epoch seconds (uint32)
|
||||
```
|
||||
|
||||
**RESP_CODE_CURR_TIME (9)**:
|
||||
```
|
||||
[0x09] - Response code (9)
|
||||
[4 bytes] - Epoch seconds (uint32)
|
||||
```
|
||||
|
||||
**CMD_SEND_SELF_ADVERT (7)**:
|
||||
```
|
||||
[0x07] - Command code (7)
|
||||
[1 byte] - (Optional) Type: 1=flood, 0=zero-hop (default)
|
||||
```
|
||||
|
||||
**CMD_SET_ADVERT_NAME (8)**:
|
||||
```
|
||||
[0x08] - Command code (8)
|
||||
[N bytes] - Name (remainder of frame, varchar)
|
||||
```
|
||||
|
||||
**CMD_SET_ADVERT_LATLON (14)**:
|
||||
```
|
||||
[0x0E] - Command code (14)
|
||||
[4 bytes] - Latitude * 1E6 (int32)
|
||||
[4 bytes] - Longitude * 1E6 (int32)
|
||||
[4 bytes] - (Optional) Altitude (int32, future support)
|
||||
```
|
||||
|
||||
**CMD_ADD_UPDATE_CONTACT (9)**:
|
||||
```
|
||||
[0x09] - Command code (9)
|
||||
[32 bytes] - Public key
|
||||
[1 byte] - Type (ADV_TYPE_*)
|
||||
[1 byte] - Flags
|
||||
[1 byte] - Out path length (signed)
|
||||
[64 bytes] - Out path
|
||||
[32 bytes] - Advertised name (null-terminated)
|
||||
[4 bytes] - Last advert timestamp (uint32)
|
||||
[4 bytes] - (Optional) Advert latitude * 1E6 (int32)
|
||||
[4 bytes] - (Optional) Advert longitude * 1E6 (int32)
|
||||
```
|
||||
|
||||
**CMD_REMOVE_CONTACT (15)**:
|
||||
```
|
||||
[0x0F] - Command code (15)
|
||||
[32 bytes] - Public key
|
||||
```
|
||||
|
||||
**CMD_SHARE_CONTACT (16)**:
|
||||
```
|
||||
[0x10] - Command code (16)
|
||||
[32 bytes] - Public key
|
||||
```
|
||||
|
||||
**CMD_EXPORT_CONTACT (17)**:
|
||||
```
|
||||
[0x11] - Command code (17)
|
||||
[32 bytes] - (Optional) Public key (if omitted, export SELF)
|
||||
```
|
||||
|
||||
**RESP_CODE_EXPORT_CONTACT (11)**:
|
||||
```
|
||||
[0x0B] - Response code (11)
|
||||
[N bytes] - Card data (remainder of frame)
|
||||
Format: "meshcore://{hex(card_data)}"
|
||||
```
|
||||
|
||||
**CMD_IMPORT_CONTACT (18)**:
|
||||
```
|
||||
[0x12] - Command code (18)
|
||||
[N bytes] - Card data (remainder of frame)
|
||||
```
|
||||
|
||||
**CMD_RESET_PATH (13)**:
|
||||
```
|
||||
[0x0D] - Command code (13)
|
||||
[32 bytes] - Public key
|
||||
```
|
||||
|
||||
**CMD_SEND_TXT_MSG (2)**:
|
||||
```
|
||||
[0x02] - Command code (2)
|
||||
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||
[1 byte] - Attempt (0-3, attempt number)
|
||||
[4 bytes] - Sender timestamp (uint32)
|
||||
[6 bytes] - Recipient public key prefix (first 6 bytes)
|
||||
[N bytes] - Text (remainder of frame, varchar, max 160 bytes)
|
||||
```
|
||||
|
||||
**CMD_SEND_CHANNEL_TXT_MSG (3)**:
|
||||
```
|
||||
[0x03] - Command code (3)
|
||||
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||
[1 byte] - Channel index (reserved, 0 for 'public')
|
||||
[4 bytes] - Sender timestamp (uint32)
|
||||
[N bytes] - Text (remainder of frame, max 160 - len(advert_name) - 2)
|
||||
```
|
||||
|
||||
**RESP_CODE_SENT (6)**:
|
||||
```
|
||||
[0x06] - Response code (6)
|
||||
[1 byte] - Send type: 1=flood, 0=direct
|
||||
[4 bytes] - Expected ACK code or TAG
|
||||
[4 bytes] - Suggested timeout (uint32, milliseconds)
|
||||
```
|
||||
|
||||
**PUSH_CODE_SEND_CONFIRMED (0x82)**:
|
||||
```
|
||||
[0x82] - Push code
|
||||
[4 bytes] - ACK code
|
||||
[4 bytes] - Round trip time (uint32, milliseconds)
|
||||
```
|
||||
|
||||
**RESP_CODE_CONTACT_MSG_RECV (7)**:
|
||||
```
|
||||
[0x07] - Response code (7)
|
||||
[6 bytes] - Sender public key prefix (first 6 bytes)
|
||||
[1 byte] - Path length (0xFF if direct, else hop count for flood-mode)
|
||||
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||
[4 bytes] - Sender timestamp (uint32)
|
||||
[N bytes] - Text (remainder of frame, varchar)
|
||||
```
|
||||
|
||||
**RESP_CODE_CHANNEL_MSG_RECV (8)**:
|
||||
```
|
||||
[0x08] - Response code (8)
|
||||
[1 byte] - Channel index (reserved, 0 for 'public')
|
||||
[1 byte] - Path length (0xFF if direct, else hop count for flood-mode)
|
||||
[1 byte] - Text type (TXT_TYPE_*, 0=plain)
|
||||
[4 bytes] - Sender timestamp (uint32)
|
||||
[N bytes] - Text (remainder of frame, varchar)
|
||||
```
|
||||
|
||||
**CMD_SET_RADIO_PARAMS (11)**:
|
||||
```
|
||||
[0x0B] - Command code (11)
|
||||
[4 bytes] - Radio freq * 1000 (uint32)
|
||||
[4 bytes] - Radio bandwidth (kHz) * 1000 (uint32)
|
||||
[1 byte] - Spreading factor
|
||||
[1 byte] - Coding rate
|
||||
```
|
||||
|
||||
**CMD_SET_RADIO_TX_POWER (12)**:
|
||||
```
|
||||
[0x0C] - Command code (12)
|
||||
[1 byte] - TX power in dBm
|
||||
```
|
||||
|
||||
**CMD_SET_TUNING_PARAMS (21) / RESP_CODE_TUNING_PARAMS**:
|
||||
```
|
||||
[0x15] - Command/Response code (21)
|
||||
[4 bytes] - RX delay base * 1000 (uint32)
|
||||
[4 bytes] - Airtime factor * 1000 (uint32)
|
||||
[8 bytes] - Reserved (set to zero)
|
||||
```
|
||||
|
||||
**CMD_SET_OTHER_PARAMS (38)**:
|
||||
```
|
||||
[0x26] - Command code (38)
|
||||
[1 byte] - Manual add contacts (0 or 1)
|
||||
[1 byte] - (Optional v5+) Telemetry modes
|
||||
[1 byte] - (Optional v5+) Advert location policy
|
||||
[1 byte] - (Optional v7+) Multi ACKs (0=no extra, 1=send extra)
|
||||
```
|
||||
|
||||
**RESP_CODE_BATT_AND_STORAGE (12)**:
|
||||
```
|
||||
[0x0C] - Response code (12)
|
||||
[2 bytes] - Millivolts (uint16)
|
||||
[4 bytes] - (Optional) Used KB (uint32)
|
||||
[4 bytes] - (Optional) Total KB (uint32, zero if unknown)
|
||||
```
|
||||
|
||||
**CMD_SEND_RAW_DATA (25)**:
|
||||
```
|
||||
[0x19] - Command code (25)
|
||||
[1 byte] - Path length
|
||||
[N bytes] - Path (variable length)
|
||||
[M bytes] - Payload (remainder of frame)
|
||||
```
|
||||
|
||||
**PUSH_CODE_RAW_DATA (0x84)**:
|
||||
```
|
||||
[0x84] - Push code
|
||||
[1 byte] - SNR * 4 (signed)
|
||||
[1 byte] - RSSI (signed)
|
||||
[1 byte] - Reserved (0xFF)
|
||||
[N bytes] - Payload (remainder of frame)
|
||||
```
|
||||
|
||||
**CMD_SEND_LOGIN (26)**:
|
||||
```
|
||||
[0x1A] - Command code (26)
|
||||
[32 bytes] - Public key (repeater or room server)
|
||||
[N bytes] - Password (remainder of frame, varchar, max 15 bytes)
|
||||
```
|
||||
|
||||
**PUSH_CODE_LOGIN_SUCCESS (0x85)**:
|
||||
```
|
||||
[0x85] - Push code
|
||||
[1 byte] - Permissions (lowest bit=is_admin)
|
||||
[6 bytes] - Public key prefix (first 6 bytes)
|
||||
[4 bytes] - Tag (int32)
|
||||
[1 byte] - (V7+) New permissions
|
||||
```
|
||||
|
||||
**CMD_SEND_STATUS_REQ (27)**:
|
||||
```
|
||||
[0x1B] - Command code (27)
|
||||
[32 bytes] - Public key (repeater or sensor)
|
||||
```
|
||||
|
||||
**PUSH_CODE_STATUS_RESPONSE (0x87)**:
|
||||
```
|
||||
[0x87] - Push code
|
||||
[1 byte] - Reserved (zero)
|
||||
[6 bytes] - Public key prefix (first 6 bytes)
|
||||
[N bytes] - Status data (remainder of frame)
|
||||
```
|
||||
|
||||
**CMD_SEND_TELEMETRY_REQ (39)**:
|
||||
```
|
||||
[0x27] - Command code (39)
|
||||
[3 bytes] - Reserved (zeros)
|
||||
[32 bytes] - Public key (destination node)
|
||||
```
|
||||
|
||||
**PUSH_CODE_TELEMETRY_RESPONSE (0x8B)**:
|
||||
```
|
||||
[0x8B] - Push code
|
||||
[1 byte] - Reserved (zero)
|
||||
[6 bytes] - Public key prefix (first 6 bytes)
|
||||
[N bytes] - LPP sensor data (Cayenne LPP format, remainder of frame)
|
||||
```
|
||||
|
||||
**CMD_SEND_BINARY_REQ (50)** *(Preferred over CMD_SEND_TELEMETRY_REQ)*:
|
||||
```
|
||||
[0x32] - Command code (50)
|
||||
[32 bytes] - Public key (contact to send request to)
|
||||
[N bytes] - Request code and params (remainder of frame)
|
||||
```
|
||||
|
||||
**PUSH_CODE_BINARY_RESPONSE (0x8C)**:
|
||||
```
|
||||
[0x8C] - Push code
|
||||
[1 byte] - Reserved (zero)
|
||||
[4 bytes] - Tag (uint32, matches RESP_CODE_SENT expected_ack_or_tag)
|
||||
[N bytes] - Response data (remainder of frame)
|
||||
```
|
||||
|
||||
**CMD_SEND_TRACE_PATH (36)**:
|
||||
```
|
||||
[0x24] - Command code (36)
|
||||
[4 bytes] - Tag (int32, random initiator tag)
|
||||
[4 bytes] - Auth code (int32, optional authentication)
|
||||
[1 byte] - Flags (zero for now)
|
||||
[N bytes] - Path (remainder of frame, hashes for TRACE to follow)
|
||||
```
|
||||
|
||||
**PUSH_CODE_TRACE_DATA (0x89)**:
|
||||
```
|
||||
[0x89] - Push code
|
||||
[1 byte] - Reserved (zero)
|
||||
[1 byte] - Path length
|
||||
[1 byte] - Flags (zero for now)
|
||||
[4 bytes] - Tag (int32)
|
||||
[4 bytes] - Auth code (int32)
|
||||
[N bytes] - Path hashes (variable length)
|
||||
[N+1 bytes] - Path SNRs (last byte = SNR for last hop, each byte = SNR * 4)
|
||||
```
|
||||
|
||||
**CMD_SET_DEVICE_PIN (37)**:
|
||||
```
|
||||
[0x25] - Command code (37)
|
||||
[4 bytes] - BLE PIN (uint32)
|
||||
```
|
||||
|
||||
**CMD_GET_ADVERT_PATH (42)**:
|
||||
```
|
||||
[0x2A] - Command code (42)
|
||||
[1 byte] - Reserved (zero)
|
||||
[32 bytes] - Public key (contact being queried)
|
||||
```
|
||||
|
||||
**RESP_CODE_ADVERT_PATH (22)**:
|
||||
```
|
||||
[0x16] - Response code (22)
|
||||
[4 bytes] - Receive timestamp (uint32, by local clock)
|
||||
[1 byte] - Path length
|
||||
[N bytes] - Path (variable length)
|
||||
```
|
||||
|
||||
**CMD_FACTORY_RESET (51)**:
|
||||
```
|
||||
[0x33] - Command code (51)
|
||||
[5 bytes] - ASCII "reset" (confirmation)
|
||||
```
|
||||
|
||||
**RESP_CODE_ERR (1)**:
|
||||
```
|
||||
[0x01] - Response code (1)
|
||||
[1 byte] - Error code (ERR_CODE_*)
|
||||
```
|
||||
|
||||
#### Constants
|
||||
|
||||
**ADV_TYPE (Advertisement/Contact Type)**:
|
||||
- `0` - ADV_TYPE_NONE (unknown/invalid)
|
||||
- `1` - ADV_TYPE_CHAT (team member, shown on map)
|
||||
- `2` - ADV_TYPE_REPEATER (network repeater node)
|
||||
- `3` - ADV_TYPE_ROOM (communication channel/room)
|
||||
|
||||
**TXT_TYPE (Text Message Type)**:
|
||||
- `0` - TXT_TYPE_PLAIN (plain text message)
|
||||
- `1` - TXT_TYPE_CLI_DATA (CLI command)
|
||||
- `2` - TXT_TYPE_SIGNED_PLAIN (plain text, signed by sender)
|
||||
|
||||
**ERR_CODE (Error Codes)**:
|
||||
- `1` - ERR_CODE_UNSUPPORTED_CMD
|
||||
- `2` - ERR_CODE_NOT_FOUND
|
||||
- `3` - ERR_CODE_TABLE_FULL
|
||||
- `4` - ERR_CODE_BAD_STATE
|
||||
- `5` - ERR_CODE_FILE_IO_ERROR
|
||||
- `6` - ERR_CODE_ILLEGAL_ARG
|
||||
|
||||
### Cayenne LPP Format
|
||||
|
||||
Telemetry data uses Cayenne Low Power Payload format:
|
||||
|
||||
@@ -34,10 +34,19 @@ class DeviceInfo {
|
||||
final int? radioCr;
|
||||
final String? selfName;
|
||||
|
||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
|
||||
// Firmware info
|
||||
final int? firmwareVersion;
|
||||
final String? firmwareBuildDate;
|
||||
final String? manufacturerModel;
|
||||
final String? semanticVersion;
|
||||
|
||||
DeviceInfo({
|
||||
this.deviceId,
|
||||
@@ -60,9 +69,16 @@ class DeviceInfo {
|
||||
this.radioSf,
|
||||
this.radioCr,
|
||||
this.selfName,
|
||||
this.maxContacts,
|
||||
this.maxChannels,
|
||||
this.telemetryModes,
|
||||
this.blePin,
|
||||
this.multiAcks,
|
||||
this.advertLocPolicy,
|
||||
this.firmwareVersion,
|
||||
this.firmwareBuildDate,
|
||||
this.manufacturerModel,
|
||||
this.semanticVersion,
|
||||
});
|
||||
|
||||
/// Check if device is connected
|
||||
@@ -144,9 +160,16 @@ class DeviceInfo {
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
String? selfName,
|
||||
int? maxContacts,
|
||||
int? maxChannels,
|
||||
int? telemetryModes,
|
||||
int? blePin,
|
||||
int? multiAcks,
|
||||
int? advertLocPolicy,
|
||||
int? firmwareVersion,
|
||||
String? firmwareBuildDate,
|
||||
String? manufacturerModel,
|
||||
String? semanticVersion,
|
||||
}) {
|
||||
return DeviceInfo(
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
@@ -169,9 +192,16 @@ class DeviceInfo {
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
radioCr: radioCr ?? this.radioCr,
|
||||
selfName: selfName ?? this.selfName,
|
||||
maxContacts: maxContacts ?? this.maxContacts,
|
||||
maxChannels: maxChannels ?? this.maxChannels,
|
||||
telemetryModes: telemetryModes ?? this.telemetryModes,
|
||||
blePin: blePin ?? this.blePin,
|
||||
multiAcks: multiAcks ?? this.multiAcks,
|
||||
advertLocPolicy: advertLocPolicy ?? this.advertLocPolicy,
|
||||
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
|
||||
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
|
||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ContactsTab extends StatelessWidget {
|
||||
class ContactsTab extends StatefulWidget {
|
||||
const ContactsTab({super.key});
|
||||
|
||||
@override
|
||||
State<ContactsTab> createState() => _ContactsTabState();
|
||||
}
|
||||
|
||||
class _ContactsTabState extends State<ContactsTab> {
|
||||
Future<void> _handleRefresh() async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await appProvider.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ContactsProvider>(
|
||||
@@ -32,7 +44,7 @@ class ContactsTab extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connect to a device and refresh to load contacts',
|
||||
'Connect to a device to load contacts',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -41,9 +53,11 @@ class ContactsTab extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
// Team Members (Chat contacts)
|
||||
if (chatContacts.isNotEmpty) ...[
|
||||
_SectionHeader(
|
||||
@@ -75,7 +89,8 @@ class ContactsTab extends StatelessWidget {
|
||||
),
|
||||
...rooms.map((contact) => _ContactTile(contact: contact)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -234,19 +249,32 @@ class _ContactTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(contact.publicKey);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Requesting telemetry from ${contact.displayName}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Message icon - only for chat contacts
|
||||
if (contact.type == ContactType.chat)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.message, size: 20),
|
||||
onPressed: () => _showDirectMessageDialog(context, contact),
|
||||
tooltip: 'Send direct message',
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Request telemetry',
|
||||
// Telemetry refresh button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(contact.publicKey);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Requesting telemetry from ${contact.displayName}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Request telemetry',
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _showContactDetails(context, contact),
|
||||
onLongPress: () {
|
||||
@@ -263,6 +291,15 @@ class _ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showDirectMessageDialog(BuildContext context, Contact contact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _DirectMessageSheet(contact: contact),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactDetails(BuildContext context, Contact contact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -478,3 +515,236 @@ class _ContactTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Message Sheet Widget
|
||||
class _DirectMessageSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _DirectMessageSheet({required this.contact});
|
||||
|
||||
@override
|
||||
State<_DirectMessageSheet> createState() => _DirectMessageSheetState();
|
||||
}
|
||||
|
||||
class _DirectMessageSheetState extends State<_DirectMessageSheet> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
int _characterCount = 0;
|
||||
static const int _maxCharacters = 160;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_updateCharacterCount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateCharacterCount() {
|
||||
setState(() {
|
||||
_characterCount = _textController.text.length;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendDirectMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Not connected to device'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send direct message to contact
|
||||
await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: widget.contact.publicKey,
|
||||
text: text,
|
||||
);
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context); // Close the dialog
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Direct message sent to ${widget.contact.displayName}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to send: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1E1E1E),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Direct Message',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Info banner
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'This message will be sent directly to ${widget.contact.displayName}. It will also appear in the main messages feed.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Message input
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2D2D2D),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: 3,
|
||||
autofocus: true,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type your message...',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.grey),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.grey),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.white),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendDirectMessage(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendDirectMessage,
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('Send Direct Message'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,48 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getDeviceTypeString(int? deviceType) {
|
||||
if (deviceType == null) return 'Unknown';
|
||||
switch (deviceType) {
|
||||
case 0:
|
||||
return 'None/Unknown';
|
||||
case 1:
|
||||
return 'Chat Node';
|
||||
case 2:
|
||||
return 'Repeater';
|
||||
case 3:
|
||||
return 'Room/Channel Server';
|
||||
default:
|
||||
return 'Type $deviceType';
|
||||
}
|
||||
}
|
||||
|
||||
String _getTelemetryModesString(deviceInfo) {
|
||||
if (deviceInfo.telemetryModes == null) return 'Unknown';
|
||||
|
||||
final telemetryModes = deviceInfo.telemetryModes!;
|
||||
final baseMode = telemetryModes & 0x03; // bits 0-1
|
||||
final locationMode = (telemetryModes >> 2) & 0x03; // bits 2-3
|
||||
|
||||
String baseModeStr = _getTelemetryModeString(baseMode);
|
||||
String locationModeStr = _getTelemetryModeString(locationMode);
|
||||
|
||||
return 'Base: $baseModeStr, Loc: $locationModeStr';
|
||||
}
|
||||
|
||||
String _getTelemetryModeString(int mode) {
|
||||
switch (mode) {
|
||||
case 0:
|
||||
return 'Deny';
|
||||
case 1:
|
||||
return 'By Contact';
|
||||
case 2:
|
||||
return 'Allow All';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _useCurrentLocation() async {
|
||||
try {
|
||||
// Check if location services are enabled
|
||||
@@ -474,6 +516,101 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Device Information Section (Read-only)
|
||||
_SectionHeader(
|
||||
title: 'Device Information',
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Device Information'),
|
||||
content: const Text(
|
||||
'This information is provided by the MeshCore device '
|
||||
'and cannot be edited. Tap refresh to update.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('GOT IT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
iconSize: 20,
|
||||
),
|
||||
),
|
||||
|
||||
_SettingTile(
|
||||
icon: Icons.numbers,
|
||||
label: 'Device Type',
|
||||
isFirst: true,
|
||||
trailing: Text(
|
||||
_getDeviceTypeString(deviceInfo.deviceType),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingTile(
|
||||
icon: Icons.groups,
|
||||
label: 'Max Contacts',
|
||||
trailing: Text(
|
||||
deviceInfo.maxContacts != null
|
||||
? deviceInfo.maxContacts.toString()
|
||||
: 'Unknown',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingTile(
|
||||
icon: Icons.tag,
|
||||
label: 'Max Channels',
|
||||
trailing: Text(
|
||||
deviceInfo.maxChannels != null
|
||||
? deviceInfo.maxChannels.toString()
|
||||
: 'Unknown',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingTile(
|
||||
icon: Icons.settings_suggest,
|
||||
label: 'Telemetry Modes',
|
||||
trailing: Text(
|
||||
_getTelemetryModesString(deviceInfo),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_SettingTile(
|
||||
icon: Icons.group_add,
|
||||
label: 'Manual Add Contacts',
|
||||
isLast: true,
|
||||
trailing: Text(
|
||||
deviceInfo.manualAddContacts == true ? 'Enabled' : 'Disabled',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Public Info Section
|
||||
_SectionHeader(
|
||||
title: 'Public Info',
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -233,43 +232,6 @@ class _HomeScreenState extends State<HomeScreen> 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: [
|
||||
Icon(Icons.refresh),
|
||||
SizedBox(width: 8),
|
||||
Text('Refresh Contacts'),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
await appProvider.refresh();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Refreshed contacts')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Row(
|
||||
children: [
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
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<MessageHistoryScreen> createState() => _MessageHistoryScreenState();
|
||||
}
|
||||
|
||||
class _MessageHistoryScreenState extends State<MessageHistoryScreen> {
|
||||
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<MessagesProvider>();
|
||||
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<MessagesProvider>().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<Message> _filterMessages(List<Message> 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<MessagesProvider>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
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();
|
||||
@@ -53,7 +49,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
@@ -67,40 +62,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
try {
|
||||
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 (_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,
|
||||
);
|
||||
}
|
||||
// Always send to public channel (channel 0)
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: 0,
|
||||
text: text,
|
||||
);
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
@@ -108,7 +74,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Message sent'),
|
||||
content: Text('Message sent to public channel'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
@@ -124,81 +90,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
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 all messages when recipient changes (for sending context)
|
||||
Future<void> _syncMessagesForRecipient() async {
|
||||
final appProvider = context.read<AppProvider>();
|
||||
|
||||
if (!appProvider.connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [MessagesTab] Syncing messages after recipient 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'}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesTab] Error syncing messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _getRecipientDisplayName() {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
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(
|
||||
@@ -206,8 +97,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _SarUpdateSheet(
|
||||
onSend: (sarType, position, notes) async {
|
||||
await _sendSarMessage(sarType, position, notes);
|
||||
onSend: (sarType, position, notes, channelIdx) async {
|
||||
await _sendSarMessage(sarType, position, notes, channelIdx);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -217,9 +108,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
SarMarkerType sarType,
|
||||
Position position,
|
||||
String? notes,
|
||||
int channelIdx,
|
||||
) async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
@@ -241,46 +132,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
? '$sarMessage $notes'
|
||||
: sarMessage;
|
||||
|
||||
// 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 (_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,
|
||||
);
|
||||
}
|
||||
// Send SAR message to selected room/channel
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: channelIdx,
|
||||
text: fullMessage,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
final channelName = channelIdx == 0 ? 'Public Channel' : 'Channel $channelIdx';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${sarType.displayName} marker sent'),
|
||||
content: Text('${sarType.displayName} marker sent to $channelName'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -393,54 +255,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
// Recipient selector bar
|
||||
Consumer<ContactsProvider>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Message input row
|
||||
Row(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// SAR quick action button
|
||||
@@ -503,8 +318,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -764,7 +577,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
|
||||
// SAR Update Sheet
|
||||
class _SarUpdateSheet extends StatefulWidget {
|
||||
final Future<void> Function(SarMarkerType, Position, String?) onSend;
|
||||
final Future<void> Function(SarMarkerType, Position, String?, int) onSend;
|
||||
|
||||
const _SarUpdateSheet({required this.onSend});
|
||||
|
||||
@@ -777,6 +590,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
||||
Position? _currentPosition;
|
||||
bool _loadingLocation = false;
|
||||
String? _locationError;
|
||||
int _selectedChannelIdx = 0; // Default to Public Channel (channel 0)
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -942,6 +756,82 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Room/Channel selection
|
||||
const Text(
|
||||
'Send To',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
// Build list of available rooms/channels
|
||||
final rooms = contactsProvider.rooms;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2D2D),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _selectedChannelIdx,
|
||||
dropdownColor: const Color(0xFF2D2D2D),
|
||||
isExpanded: true,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
icon: const Icon(Icons.arrow_drop_down, color: Colors.white),
|
||||
items: [
|
||||
// Public Channel (always available)
|
||||
const DropdownMenuItem<int>(
|
||||
value: 0,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.public, size: 18, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Text('Public Channel'),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Room channels
|
||||
...rooms.asMap().entries.map((entry) {
|
||||
final idx = entry.key + 1; // Rooms start at channel 1
|
||||
final room = entry.value;
|
||||
return DropdownMenuItem<int>(
|
||||
value: idx,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.tag, size: 18, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedChannelIdx = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Location display
|
||||
const Text(
|
||||
'Current Location',
|
||||
@@ -1135,6 +1025,7 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
|
||||
_notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
_selectedChannelIdx,
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
@@ -1237,210 +1128,3 @@ 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<ContactsProvider>(
|
||||
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<ContactsProvider>(
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,10 @@ class MeshCoreBleService {
|
||||
print(' → Handling NewAdvert push');
|
||||
_handleNewAdvert(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushSendConfirmed:
|
||||
print(' → Handling SendConfirmed push');
|
||||
_handleSendConfirmed(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respNoMoreMessages:
|
||||
print(' → Response: No More Messages');
|
||||
onNoMoreMessages?.call();
|
||||
@@ -442,35 +446,34 @@ class MeshCoreBleService {
|
||||
_pendingContacts.clear();
|
||||
}
|
||||
|
||||
/// Handle Sent confirmation response
|
||||
/// Handle Sent confirmation response (RESP_CODE_SENT)
|
||||
///
|
||||
/// Protocol format:
|
||||
/// - 1 byte: send type (1=flood, 0=direct)
|
||||
/// - 4 bytes: expected ACK code or TAG
|
||||
/// - 4 bytes: suggested timeout (uint32, milliseconds)
|
||||
void _handleSentConfirmation(BufferReader reader) {
|
||||
try {
|
||||
print(' [Sent] Parsing sent confirmation...');
|
||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||
|
||||
// Sent confirmation format (from protocol):
|
||||
// - 1 byte: reserved
|
||||
// - 4 bytes: public key prefix (recipient)
|
||||
// - 2 bytes: message ID
|
||||
// - 2 bytes: reserved
|
||||
|
||||
if (reader.remainingBytesCount >= 9) {
|
||||
final reserved1 = reader.readByte();
|
||||
print(' Reserved: $reserved1');
|
||||
final sendType = reader.readByte();
|
||||
final sendTypeStr = sendType == 1 ? 'flood' : 'direct';
|
||||
print(' Send type: $sendType ($sendTypeStr)');
|
||||
|
||||
final pubKeyPrefix = reader.readBytes(4);
|
||||
print(' Recipient public key prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
|
||||
final expectedAckOrTag = reader.readBytes(4);
|
||||
print(' Expected ACK/TAG: ${expectedAckOrTag.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
|
||||
final messageId = reader.readUInt16LE();
|
||||
print(' Message ID: $messageId');
|
||||
final suggestedTimeout = reader.readUInt32LE();
|
||||
print(' Suggested timeout: ${suggestedTimeout}ms');
|
||||
|
||||
if (reader.remainingBytesCount >= 2) {
|
||||
final reserved2 = reader.readUInt16LE();
|
||||
print(' Reserved2: $reserved2');
|
||||
}
|
||||
print(' ✅ [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)');
|
||||
|
||||
// TODO: Store ACK/TAG to match with PUSH_CODE_SEND_CONFIRMED later
|
||||
} else {
|
||||
print(' ⚠️ [Sent] Insufficient data for full parsing');
|
||||
}
|
||||
|
||||
print(' ✅ [Sent] Message sent confirmation');
|
||||
} catch (e) {
|
||||
print(' ❌ [Sent] Parsing error: $e');
|
||||
// Don't call onError - sent confirmations are informational
|
||||
@@ -814,6 +817,35 @@ class MeshCoreBleService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle SendConfirmed push (PUSH_CODE_SEND_CONFIRMED)
|
||||
///
|
||||
/// Protocol format:
|
||||
/// - 4 bytes: ACK code
|
||||
/// - 4 bytes: round trip time (uint32, milliseconds)
|
||||
void _handleSendConfirmed(BufferReader reader) {
|
||||
try {
|
||||
print(' [SendConfirmed] Parsing send confirmed...');
|
||||
print(' Remaining bytes: ${reader.remainingBytesCount}');
|
||||
|
||||
if (reader.remainingBytesCount >= 8) {
|
||||
final ackCode = reader.readBytes(4);
|
||||
print(' ACK code: ${ackCode.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
|
||||
final roundTripTime = reader.readUInt32LE();
|
||||
print(' Round trip time: ${roundTripTime}ms');
|
||||
|
||||
print(' ✅ [SendConfirmed] Message delivery confirmed (RTT: ${roundTripTime}ms)');
|
||||
|
||||
// TODO: Match ACK code with pending sends and notify UI
|
||||
} else {
|
||||
print(' ⚠️ [SendConfirmed] Insufficient data for full parsing');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ [SendConfirmed] Parsing error: $e');
|
||||
// Don't call onError - confirmations are informational
|
||||
}
|
||||
}
|
||||
|
||||
/// Send AppStart command
|
||||
Future<void> _sendAppStart() async {
|
||||
final writer = BufferWriter();
|
||||
@@ -845,31 +877,60 @@ class MeshCoreBleService {
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send text message to contact
|
||||
/// Send text message to contact (DM)
|
||||
///
|
||||
/// Protocol format (CMD_SEND_TXT_MSG):
|
||||
/// - 1 byte: command code (2)
|
||||
/// - 1 byte: text type (TXT_TYPE_*, 0=plain)
|
||||
/// - 1 byte: attempt (0-3, attempt number)
|
||||
/// - 4 bytes: sender timestamp (uint32, epoch seconds)
|
||||
/// - 6 bytes: recipient public key prefix (first 6 bytes)
|
||||
/// - N bytes: text (remainder of frame, varchar, max 160 bytes)
|
||||
Future<void> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
int textType = 0, // TXT_TYPE_PLAIN
|
||||
int attempt = 0,
|
||||
}) async {
|
||||
if (text.length > 160) {
|
||||
throw ArgumentError('Text message exceeds 160 character limit');
|
||||
}
|
||||
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(0); // attempt
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeBytes(contactPublicKey.sublist(0, 6));
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
|
||||
writer.writeByte(textType); // TXT_TYPE_*
|
||||
writer.writeByte(attempt); // 0-3
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds
|
||||
writer.writeBytes(contactPublicKey.sublist(0, 6)); // first 6 bytes of public key
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send channel text message
|
||||
/// Send flood-mode text message to channel
|
||||
///
|
||||
/// Protocol format (CMD_SEND_CHANNEL_TXT_MSG):
|
||||
/// - 1 byte: command code (3)
|
||||
/// - 1 byte: text type (TXT_TYPE_*, 0=plain)
|
||||
/// - 1 byte: channel index (reserved, 0 for 'public')
|
||||
/// - 4 bytes: sender timestamp (uint32, epoch seconds)
|
||||
/// - N bytes: text (remainder of frame, max 160 - len(advert_name) - 2)
|
||||
///
|
||||
/// Note: For SAR messages, ensure text starts with "S:<emoji>:<lat>,<lon>"
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
int textType = 0, // TXT_TYPE_PLAIN
|
||||
}) async {
|
||||
// Note: Max length depends on advert name length, but typically ~140 chars
|
||||
if (text.length > 160) {
|
||||
throw ArgumentError('Channel message too long (max ~160 characters)');
|
||||
}
|
||||
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(channelIdx);
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
|
||||
writer.writeByte(textType); // TXT_TYPE_*
|
||||
writer.writeByte(channelIdx); // 0 for 'public' channel
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000); // epoch seconds
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user