feat: Enhance MeshCoreBleService with new callbacks and message handling

- Added new callback types for path updates, message sent, message delivered, status responses, binary responses, and battery/storage information.
- Implemented handling for binary responses and path updates, including parsing and notifying via callbacks.
- Updated message sending logic to include acknowledgment and delivery confirmation.
- Enhanced log parsing for received data, including detailed interpretations and analysis.
- Introduced status request functionality to query operational status from repeater or sensor nodes.
- Updated battery and storage information handling to provide detailed metrics and trigger callbacks.
- Deprecated legacy methods in favor of more robust alternatives.
This commit is contained in:
Janez T
2025-10-15 09:31:19 +02:00
parent 05b90d7f1f
commit 9124b53073
17 changed files with 5685 additions and 190 deletions

View File

@@ -1,6 +1,44 @@
import 'dart:typed_data';
import '../services/meshcore_opcode_names.dart';
/// Decoded LOG_RX_DATA packet structure
class LogRxDataInfo {
final int? airtimeMs;
final Uint8List? senderPublicKey;
final int? ackCode;
final List<String> embeddedStrings;
final double entropy;
final bool isLikelyEncrypted;
LogRxDataInfo({
this.airtimeMs,
this.senderPublicKey,
this.ackCode,
this.embeddedStrings = const [],
required this.entropy,
required this.isLikelyEncrypted,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 6) return null;
return senderPublicKey!
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
}
String get summary {
final parts = <String>[];
if (airtimeMs != null) parts.add('airtime:${airtimeMs}ms');
if (ackCode != null) parts.add('ACK:$ackCode');
if (senderKeyShort != null) parts.add('from:$senderKeyShort');
if (embeddedStrings.isNotEmpty) parts.add('strings:${embeddedStrings.length}');
if (isLikelyEncrypted) parts.add('encrypted');
return parts.join(', ');
}
}
/// Represents a logged BLE packet with timestamp and metadata
class BlePacketLog {
final DateTime timestamp;
@@ -8,6 +46,7 @@ class BlePacketLog {
final PacketDirection direction;
final int? responseCode;
final String? description;
final LogRxDataInfo? logRxDataInfo; // Decoded LOG_RX_DATA information
BlePacketLog({
required this.timestamp,
@@ -15,6 +54,7 @@ class BlePacketLog {
required this.direction,
this.responseCode,
this.description,
this.logRxDataInfo,
});
/// Convert raw data to hex string for display
@@ -63,7 +103,8 @@ class BlePacketLog {
final dir = direction == PacketDirection.rx ? 'RX' : 'TX';
final code = responseCode != null ? ' [$opcodeDescription]' : '';
final desc = description != null ? ' - $description' : '';
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc';
final logRxInfo = logRxDataInfo != null ? ' [${logRxDataInfo!.summary}]' : '';
return '${timestamp.toIso8601String()} [$dir]$code ${rawData.length} bytes: $hexData$desc$logRxInfo';
}
}

View File

@@ -16,6 +16,8 @@ class DeviceInfo {
final ConnectionState connectionState;
final int? batteryMilliVolts;
final double? batteryPercentage;
final int? storageUsedKb;
final int? storageTotalKb;
final int? signalRssi;
final double? signalSnr;
final DateTime? lastUpdate;
@@ -54,6 +56,8 @@ class DeviceInfo {
this.connectionState = ConnectionState.disconnected,
this.batteryMilliVolts,
this.batteryPercentage,
this.storageUsedKb,
this.storageTotalKb,
this.signalRssi,
this.signalSnr,
this.lastUpdate,
@@ -112,6 +116,32 @@ class DeviceInfo {
return 'Critical';
}
/// Get storage usage percentage (0-100)
double? get storageUsedPercent {
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
return null;
}
return (storageUsedKb! / storageTotalKb!) * 100.0;
}
/// Get storage available in KB
int? get storageAvailableKb {
if (storageUsedKb == null || storageTotalKb == null) {
return null;
}
return storageTotalKb! - storageUsedKb!;
}
/// Get human-readable storage status
String get storageStatus {
final percent = storageUsedPercent;
if (percent == null) return 'Unknown';
if (percent < 50) return 'Plenty Available';
if (percent < 80) return 'Moderate Usage';
if (percent < 95) return 'Low Space';
return 'Critical - Nearly Full';
}
/// Get signal strength category
String get signalStrength {
if (signalRssi == null) return 'Unknown';
@@ -145,6 +175,8 @@ class DeviceInfo {
ConnectionState? connectionState,
int? batteryMilliVolts,
double? batteryPercentage,
int? storageUsedKb,
int? storageTotalKb,
int? signalRssi,
double? signalSnr,
DateTime? lastUpdate,
@@ -177,6 +209,8 @@ class DeviceInfo {
connectionState: connectionState ?? this.connectionState,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
storageUsedKb: storageUsedKb ?? this.storageUsedKb,
storageTotalKb: storageTotalKb ?? this.storageTotalKb,
signalRssi: signalRssi ?? this.signalRssi,
signalSnr: signalSnr ?? this.signalSnr,
lastUpdate: lastUpdate ?? this.lastUpdate,

View File

@@ -25,6 +25,15 @@ enum MessageType {
channel,
}
/// Message delivery status
enum MessageDeliveryStatus {
sending, // Message is being sent
sent, // Message queued with expected ACK
delivered, // Delivery confirmed (ACK received)
failed, // Delivery failed
received, // Message received from another contact
}
/// MeshCore message model
class Message {
final String id;
@@ -45,6 +54,13 @@ class Message {
final DateTime receivedAt;
final String? senderName;
// Delivery tracking (for sent messages)
final MessageDeliveryStatus deliveryStatus;
final int? expectedAckTag; // Expected ACK/TAG from SENT response
final int? suggestedTimeoutMs; // Suggested timeout from SENT response
final int? roundTripTimeMs; // RTT from SEND_CONFIRMED
final DateTime? deliveredAt; // When delivery was confirmed
Message({
required this.id,
required this.messageType,
@@ -59,6 +75,11 @@ class Message {
this.sarGpsCoordinates,
required this.receivedAt,
this.senderName,
this.deliveryStatus = MessageDeliveryStatus.received,
this.expectedAckTag,
this.suggestedTimeoutMs,
this.roundTripTimeMs,
this.deliveredAt,
});
/// Get sender public key as hex string
@@ -120,6 +141,28 @@ class Message {
);
}
/// Get friendly delivery status description
String get deliveryStatusText {
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
return 'Sending...';
case MessageDeliveryStatus.sent:
return 'Sent';
case MessageDeliveryStatus.delivered:
if (roundTripTimeMs != null) {
return 'Delivered (${roundTripTimeMs}ms)';
}
return 'Delivered';
case MessageDeliveryStatus.failed:
return 'Failed';
case MessageDeliveryStatus.received:
return '';
}
}
/// Check if this is a sent message (not received)
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
Message copyWith({
String? id,
MessageType? messageType,
@@ -134,6 +177,11 @@ class Message {
LatLng? sarGpsCoordinates,
DateTime? receivedAt,
String? senderName,
MessageDeliveryStatus? deliveryStatus,
int? expectedAckTag,
int? suggestedTimeoutMs,
int? roundTripTimeMs,
DateTime? deliveredAt,
}) {
return Message(
id: id ?? this.id,
@@ -149,6 +197,11 @@ class Message {
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
receivedAt: receivedAt ?? this.receivedAt,
senderName: senderName ?? this.senderName,
deliveryStatus: deliveryStatus ?? this.deliveryStatus,
expectedAckTag: expectedAckTag ?? this.expectedAckTag,
suggestedTimeoutMs: suggestedTimeoutMs ?? this.suggestedTimeoutMs,
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt,
);
}

View File

@@ -70,6 +70,30 @@ class AppProvider with ChangeNotifier {
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
contactsProvider.updateTelemetry(publicKey, lppData);
};
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
// Trigger a contact sync to get the updated path information
// This happens asynchronously to avoid blocking the event handler
Future.delayed(const Duration(milliseconds: 100), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContacts();
}
});
};
// When a message is sent (RESP_CODE_SENT received)
connectionProvider.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
debugPrint('📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag');
messagesProvider.markMessageSent(messageId, expectedAckTag, suggestedTimeoutMs);
};
// When a message is delivered (PUSH_CODE_SEND_CONFIRMED received)
connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) {
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
}
/// Initialize the app (load contacts, sync time, etc.)
@@ -89,8 +113,8 @@ class AppProvider with ChangeNotifier {
// Automatically login to all saved rooms
await _autoLoginToRooms();
// Sync any waiting messages from device queue
await _syncMessages();
// Note: Messages are synced automatically via PUSH_CODE_MSG_WAITING events
// No need to manually sync here - the BLE service handles this via callbacks
notifyListeners();
} catch (e) {
@@ -195,40 +219,32 @@ class AppProvider with ChangeNotifier {
}
}
/// Sync messages from device queue
Future<void> _syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return;
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
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)
/// Refresh data (contacts only - messages are handled via events)
Future<void> refresh() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
await connectionProvider.getContacts();
await _syncMessages();
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
notifyListeners();
} catch (e) {
debugPrint('Refresh error: $e');
}
}
/// Manually sync messages (useful for pull-to-refresh)
/// Manually sync messages (only for explicit user pull-to-refresh)
/// Note: Messages are automatically synced via PUSH_CODE_MSG_WAITING events
/// This method should ONLY be called when the user explicitly pulls to refresh
Future<int> syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return 0;
try {
debugPrint('🔄 [AppProvider] Manual message sync requested');
debugPrint('🔄 [AppProvider] Manual message sync requested (user initiated)');
final messageCount = await connectionProvider.syncAllMessages();
debugPrint('✅ [AppProvider] Synced $messageCount messages');
debugPrint('✅ [AppProvider] Manual sync completed: $messageCount messages');
notifyListeners();
return messageCount;
} catch (e) {

View File

@@ -50,13 +50,22 @@ class ConnectionProvider with ChangeNotifier {
final Map<String, RoomLoginState> _roomLoginStates = {};
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
// Track sent message IDs by ACK tag for delivery confirmation
final Map<int, String> _ackTagToMessageId = {};
final List<String> _pendingSentMessageIds = []; // Queue of pending message IDs
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)? onBinaryResponse;
Function(Uint8List publicKey)? onPathUpdated;
Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag)? onLoginSuccess;
Function(Uint8List publicKeyPrefix)? onLoginFail;
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)? onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
ConnectionProvider() {
_initializeBleService();
@@ -115,14 +124,23 @@ class ConnectionProvider with ChangeNotifier {
onTelemetryReceived?.call(publicKey, lppData);
};
_bleService.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
print('📥 [Provider] Binary response received');
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Tag: $tag');
print(' Response data: ${responseData.length} bytes');
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
};
_bleService.onNoMoreMessages = () {
print('📥 [Provider] Received NoMoreMessages signal');
_noMoreMessages = true;
};
_bleService.onMessageWaiting = () {
print('📥 [Provider] Received MsgWaiting push - auto-fetching messages');
print('📥 [Provider] PUSH_CODE_MSG_WAITING received - auto-fetching messages via event');
// Automatically fetch messages when push notification received
// This is the CORRECT way to receive messages - room server pushes them
syncAllMessages();
};
@@ -169,6 +187,46 @@ class ConnectionProvider with ChangeNotifier {
// which will trigger onContactReceived callback and add/update the contact
};
_bleService.onPathUpdated = (publicKey) {
print('📥 [Provider] Path updated for contact');
print(' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
print(' Note: Mesh network discovered a new/better routing path to this contact');
// Forward the callback to ContactsProvider to trigger contact sync
onPathUpdated?.call(publicKey);
};
_bleService.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode) {
print('📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms');
// Pop the first pending message ID from the queue (FIFO)
// This assumes messages are sent sequentially and SENT responses arrive in order
if (_pendingSentMessageIds.isNotEmpty) {
final messageId = _pendingSentMessageIds.removeAt(0);
print(' Matched with message ID: $messageId');
// Store the ACK tag to message ID mapping for delivery confirmation
_ackTagToMessageId[expectedAckTag] = messageId;
// Notify callback with message ID
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
} else {
print('⚠️ [Provider] SENT response received but no pending message IDs');
}
};
_bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
print('📥 [Provider] Message delivered - ACK code: $ackCode, RTT: ${roundTripTimeMs}ms');
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_bleService.onStatusResponse = (publicKeyPrefix, statusData) {
print('📥 [Provider] Status response received from node');
print(' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Status data: ${statusData.length} bytes');
// Forward the callback to whoever needs it (e.g., ContactsProvider)
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_bleService.onDeviceInfoReceived = (deviceInfo) {
print('📥 [Provider] Received DeviceInfo:');
print(' Firmware Version: ${deviceInfo['firmwareVersion']}');
@@ -218,6 +276,30 @@ class ConnectionProvider with ChangeNotifier {
};
// Activity indicators
_bleService.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
print('📥 [Provider] Received BatteryAndStorage:');
print(' Battery: ${millivolts}mV (${(millivolts / 1000.0).toStringAsFixed(2)}V)');
if (usedKb != null) {
print(' Storage Used: ${usedKb}KB');
}
if (totalKb != null) {
print(' Storage Total: ${totalKb}KB');
if (totalKb > 0 && usedKb != null) {
final usedPercent = (usedKb / totalKb) * 100.0;
print(' Storage Usage: ${usedPercent.toStringAsFixed(1)}%');
}
}
_deviceInfo = _deviceInfo.copyWith(
batteryMilliVolts: millivolts,
storageUsedKb: usedKb,
storageTotalKb: totalKb,
lastUpdate: DateTime.now(),
);
notifyListeners();
print('✅ [Provider] Device info updated with BatteryAndStorage');
};
_bleService.onRxActivity = () {
_rxActivity = true;
notifyListeners();
@@ -361,31 +443,53 @@ class ConnectionProvider with ChangeNotifier {
}
/// Send text message to contact
Future<void> sendTextMessage({
///
/// Returns true if the message was successfully sent to the BLE service.
/// Note: This doesn't mean the message was delivered over the mesh network,
/// only that it was queued on the companion radio.
///
/// [messageId] - optional message ID to track delivery status
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
String? messageId,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
return false;
}
try {
// Send the message
await _bleService.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
);
// If message ID provided, add it to the pending queue
// When the SENT response arrives, it will be matched with this message ID
// Note: Messages must be sent sequentially for this to work correctly
if (messageId != null) {
_pendingSentMessageIds.add(messageId);
print(' Added message ID to pending queue: $messageId');
}
return true;
} catch (e) {
_error = 'Failed to send message: $e';
notifyListeners();
return false;
}
}
/// Send channel message
///
/// [messageId] - optional message ID to track delivery status
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
String? messageId,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
@@ -398,6 +502,12 @@ class ConnectionProvider with ChangeNotifier {
channelIdx: channelIdx,
text: text,
);
// If message ID provided, add it to the pending queue
if (messageId != null) {
_pendingSentMessageIds.add(messageId);
print(' Added message ID to pending queue: $messageId');
}
} catch (e) {
_error = 'Failed to send channel message: $e';
notifyListeners();
@@ -406,6 +516,7 @@ class ConnectionProvider with ChangeNotifier {
/// Request telemetry from contact
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
@Deprecated('Use requestBinary() instead for better functionality')
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
@@ -421,6 +532,55 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:
/// - Telemetry data (use MeshCoreConstants.binaryReqGetTelemetryData)
/// - Average/min/max telemetry (use MeshCoreConstants.binaryReqGetAvgMinMax)
/// - Access list (use MeshCoreConstants.binaryReqGetAccessList)
/// - Neighbors list (use MeshCoreConstants.binaryReqGetNeighbours)
///
/// Response arrives via onBinaryResponse callback with matching tag.
///
/// Example - request telemetry:
/// ```dart
/// connectionProvider.onBinaryResponse = (prefix, tag, data) {
/// // Parse telemetry data (Cayenne LPP format)
/// final telemetry = CayenneLppParser.parse(data);
/// };
/// await connectionProvider.requestBinary(
/// contactPublicKey: contact.publicKey,
/// requestType: MeshCoreConstants.binaryReqGetTelemetryData,
/// );
/// ```
Future<void> requestBinary({
required Uint8List contactPublicKey,
required int requestType,
Uint8List? additionalParams,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
// Build request data: request type byte + optional params
final requestData = Uint8List.fromList([
requestType,
if (additionalParams != null) ...additionalParams,
]);
await _bleService.sendBinaryRequest(
contactPublicKey: contactPublicKey,
requestData: requestData,
);
} catch (e) {
_error = 'Failed to send binary request: $e';
notifyListeners();
}
}
/// Get device time from companion radio to detect clock drift
Future<void> getDeviceTime() async {
if (!_bleService.isConnected) {
@@ -595,6 +755,29 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Request battery and storage information
///
/// Queries the companion radio for:
/// - Battery voltage in millivolts
/// - Used storage in KB (if available)
/// - Total storage in KB (if available)
///
/// Results arrive via onBatteryAndStorage callback and update deviceInfo.
Future<void> getBatteryAndStorage() async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.getBatteryAndStorage();
} catch (e) {
_error = 'Failed to get battery and storage: $e';
notifyListeners();
}
}
/// Sync messages from device queue
/// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async {
@@ -633,6 +816,7 @@ class ConnectionProvider with ChangeNotifier {
// The device will send ContactMsgRecv or ChannelMsgRecv responses
// until it sends NoMoreMessages
for (int i = 0; i < 100; i++) { // Safety limit
// Check flag BEFORE sending (not after)
if (_noMoreMessages) {
print('✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests');
break;
@@ -702,6 +886,33 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Request status from repeater or sensor node
///
/// Sends a status request to query operational status of a node.
/// Results will be delivered via onStatusResponse callback.
///
/// Example usage:
/// ```dart
/// connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) {
/// print('Status from node: ${utf8.decode(statusData)}');
/// };
/// await connectionProvider.requestStatus(repeaterContact.publicKey);
/// ```
Future<void> requestStatus(Uint8List contactPublicKey) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.sendStatusRequest(contactPublicKey);
} catch (e) {
_error = 'Failed to send status request: $e';
notifyListeners();
}
}
/// Clear error message
void clearError() {
_error = null;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/sar_marker.dart';
@@ -11,6 +12,12 @@ class MessagesProvider with ChangeNotifier {
final MessageStorageService _storageService = MessageStorageService();
bool _isInitialized = false;
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
// Track timeout timers for pending messages
final Map<int, Timer> _timeoutTimers = {};
List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages =>
@@ -83,6 +90,17 @@ class MessagesProvider with ChangeNotifier {
print(' sarMarkerType: ${enhancedMessage.sarMarkerType}');
}
// Check for duplicates before adding
// Messages can arrive multiple times due to:
// - Mesh network retransmissions
// - Multiple paths in the network
// - Syncing messages from device queue
if (_isDuplicate(enhancedMessage)) {
print('⚠️ [MessagesProvider] Duplicate message detected, skipping: ${enhancedMessage.id}');
print(' Text: ${enhancedMessage.text.substring(0, enhancedMessage.text.length > 50 ? 50 : enhancedMessage.text.length)}...');
return; // Skip duplicate
}
_messages.add(enhancedMessage);
// If it's a SAR marker message, extract and store the marker
@@ -99,12 +117,65 @@ class MessagesProvider with ChangeNotifier {
notifyListeners();
}
/// Check if a message is a duplicate
///
/// Messages are considered duplicates if they have:
/// 1. Same sender public key prefix (for contact messages)
/// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp
/// 4. Same text content
bool _isDuplicate(Message message) {
return _messages.any((existing) {
// Check message type matches
if (existing.messageType != message.messageType) {
return false;
}
// Check sender matches
if (message.isContactMessage) {
// For contact messages, compare sender public key prefix
if (existing.senderKeyShort != message.senderKeyShort) {
return false;
}
} else if (message.isChannelMessage) {
// For channel messages, compare channel index
if (existing.channelIdx != message.channelIdx) {
return false;
}
}
// Check timestamp matches (sender timestamp is the unique identifier from the sender)
if (existing.senderTimestamp != message.senderTimestamp) {
return false;
}
// Check text content matches
if (existing.text != message.text) {
return false;
}
// All criteria match - this is a duplicate
return true;
});
}
/// Add multiple messages
void addMessages(List<Message> messages) {
int addedCount = 0;
int duplicateCount = 0;
for (final message in messages) {
// Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
// Check for duplicates
if (_isDuplicate(enhancedMessage)) {
duplicateCount++;
continue; // Skip duplicate
}
_messages.add(enhancedMessage);
addedCount++;
if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker();
@@ -114,6 +185,8 @@ class MessagesProvider with ChangeNotifier {
}
}
print('📥 [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates');
// Persist to storage asynchronously
_persistMessages();
@@ -231,4 +304,158 @@ class MessagesProvider with ChangeNotifier {
'object': objectMarkers.length,
};
}
/// Add a sent message with initial status
void addSentMessage(Message message) {
// Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
// Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_isDuplicate(enhancedMessage)) {
print('⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}');
return;
}
// Add message with sending status
final sendingMessage = enhancedMessage.copyWith(
deliveryStatus: MessageDeliveryStatus.sending,
);
_messages.add(sendingMessage);
// If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker();
if (marker != null) {
_sarMarkers[marker.id] = marker;
}
}
_persistMessages();
notifyListeners();
}
/// Update message status to sent with ACK tag
void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) {
print('📤 [MessagesProvider] markMessageSent called');
print(' Message ID: $messageId');
print(' Expected ACK tag: $expectedAckTag');
print(' Timeout: ${suggestedTimeoutMs}ms');
final index = _messages.indexWhere((m) => m.id == messageId);
print(' Message index in list: $index');
if (index != -1) {
final message = _messages[index];
print(' Current status: ${message.deliveryStatus}');
final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag,
suggestedTimeoutMs: suggestedTimeoutMs,
);
_messages[index] = updatedMessage;
// Track by ACK tag for matching with delivery confirmation
_pendingSentMessages[expectedAckTag] = updatedMessage;
print(' Added to pending messages map with ACK: $expectedAckTag');
print(' Total pending messages: ${_pendingSentMessages.length}');
// Start timeout timer
_timeoutTimers[expectedAckTag] = Timer(
Duration(milliseconds: suggestedTimeoutMs),
() {
print('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)');
if (_pendingSentMessages.containsKey(expectedAckTag)) {
markMessageFailed(messageId);
}
},
);
print('⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)');
_persistMessages();
notifyListeners();
} else {
print('⚠️ [MessagesProvider] Message not found in list: $messageId');
}
}
/// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
print('🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
print(' Current pending messages: ${_pendingSentMessages.keys.toList()}');
print(' Looking for ACK: $ackCode');
// Find message by ACK code
final message = _pendingSentMessages[ackCode];
if (message != null) {
print(' ✅ Found message: ${message.id}');
final index = _messages.indexWhere((m) => m.id == message.id);
print(' Message index in list: $index');
if (index != -1) {
final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(),
);
_messages[index] = updatedMessage;
// Cancel timeout timer
_timeoutTimers[ackCode]?.cancel();
_timeoutTimers.remove(ackCode);
// Remove from pending
_pendingSentMessages.remove(ackCode);
print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)');
print(' Calling notifyListeners() to update UI');
_persistMessages();
notifyListeners();
} else {
print('⚠️ [MessagesProvider] Message not found in list (index=-1)');
}
} else {
print('⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode');
print(' This means either:');
print(' 1. markMessageSent() was never called for this message');
print(' 2. The ACK code doesn\'t match the expected ACK tag from RESP_CODE_SENT');
print(' 3. The message was already delivered or timed out');
}
}
/// Update message status to failed
void markMessageFailed(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
final message = _messages[index];
final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.failed,
);
_messages[index] = updatedMessage;
// Cancel timeout timer if it exists
if (message.expectedAckTag != null) {
_timeoutTimers[message.expectedAckTag]?.cancel();
_timeoutTimers.remove(message.expectedAckTag);
_pendingSentMessages.remove(message.expectedAckTag);
}
print('❌ [MessagesProvider] Message $messageId marked as failed');
_persistMessages();
notifyListeners();
}
}
@override
void dispose() {
// Cancel all pending timeout timers
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
super.dispose();
}
}

View File

@@ -370,24 +370,25 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
fontWeight: FontWeight.bold,
),
),
Wrap(
spacing: 8,
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
IconButton.outlined(
onPressed: _isBroadcasting ? null : _broadcastNow,
icon: _isBroadcasting
? const SizedBox(
width: 16,
height: 16,
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sensors, size: 18),
label: const Text('Broadcast'),
: const Icon(Icons.sensors),
tooltip: 'Broadcast',
),
ElevatedButton.icon(
const SizedBox(width: 8),
IconButton.filled(
onPressed: _savePublicInfo,
icon: const Icon(Icons.save, size: 18),
label: const Text('Save'),
icon: const Icon(Icons.save),
tooltip: 'Save',
),
],
),
@@ -492,10 +493,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
IconButton.filled(
onPressed: _saveRadioSettings,
icon: const Icon(Icons.save, size: 18),
label: const Text('Save'),
icon: const Icon(Icons.save),
tooltip: 'Save',
),
],
),

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
@@ -12,14 +13,17 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/map_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../models/map_layer.dart';
import '../models/message.dart';
import '../services/tile_cache_service.dart';
import '../services/background_location_service.dart';
import '../widgets/map_markers.dart';
import '../widgets/map_debug_info.dart';
import 'map_management_screen.dart';
import 'messages_tab.dart';
class MapTab extends StatefulWidget {
const MapTab({super.key});
@@ -45,6 +49,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
StreamSubscription<CompassEvent>? _compassStreamSubscription;
final BackgroundLocationService _backgroundLocationService = BackgroundLocationService();
// Dropped pin state
LatLng? _droppedPinLocation;
bool _isDraggingPin = false;
final GlobalKey _pinMarkerKey = GlobalKey();
// Saved map position (loaded from SharedPreferences)
LatLng? _savedMapCenter;
double? _savedMapZoom;
@@ -682,6 +691,166 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
await _backgroundLocationService.stopTracking();
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(double lat1, double lon1, double lat2, double lon2) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
/// Show SAR dialog with pre-populated location from map long press
void _showSarDialogWithLocation(LatLng location) {
// Create a Position object from the LatLng coordinates
final position = Position(
latitude: location.latitude,
longitude: location.longitude,
timestamp: DateTime.now(),
accuracy: 0.0, // Unknown accuracy for map-selected point
altitude: 0.0,
altitudeAccuracy: 0.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => SarUpdateSheet(
prePopulatedPosition: position,
allowLocationUpdate: false, // Don't allow changing to current location
onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async {
await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel);
},
),
);
}
Future<void> _sendSarMessage(
SarMarkerType sarType,
Position position,
String? notes,
Uint8List? roomPublicKey,
bool sendToChannel,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Not connected to device'),
backgroundColor: Colors.red,
),
);
return;
}
if (!sendToChannel && roomPublicKey == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a room to send SAR marker'),
backgroundColor: Colors.red,
),
);
return;
}
try {
// Format: S:<emoji>:<latitude>,<longitude>
final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}';
// Add notes if provided
final fullMessage = notes != null && notes.isNotEmpty
? '$sarMessage $notes'
: sarMessage;
if (sendToChannel) {
// Send to public channel (ephemeral, over-the-air only)
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: fullMessage,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${sarType.displayName} marker broadcast to public channel'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
} else {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: fullMessage,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!,
text: fullMessage,
messageId: messageId, // Pass message ID so it can be tracked
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${sarType.displayName} marker sent to room'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send SAR marker: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -695,7 +864,17 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
children: [
// Map widget
_isInitialized
? FlutterMap(
? Listener(
onPointerMove: (PointerMoveEvent event) {
// Track pointer movement for mobile drag (onPointerHover doesn't work on mobile)
if (_isDraggingPin) {
final latLng = _mapController.camera.screenOffsetToLatLng(event.localPosition);
setState(() {
_droppedPinLocation = latLng;
});
}
},
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
// Use saved position if available, otherwise use calculated center
@@ -703,8 +882,10 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
initialZoom: _savedMapZoom ?? _defaultZoom,
minZoom: 0, // Allow full zoom out to see world view
maxZoom: _currentLayer.maxZoom, // Respect current layer's maximum
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all,
interactionOptions: InteractionOptions(
flags: _isDraggingPin
? InteractiveFlag.none // Disable map interaction while dragging pin
: InteractiveFlag.all,
),
onMapEvent: (event) {
// Save map position when user stops panning/zooming
@@ -712,6 +893,65 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
_saveMapPosition();
}
},
onLongPress: (tapPosition, point) {
// Drop a pin at long press location (if no pin exists)
if (_droppedPinLocation == null) {
setState(() {
_droppedPinLocation = point;
});
}
},
onPointerDown: (event, point) {
// Check if pointer is near the pin to start dragging
if (_droppedPinLocation != null) {
final distance = _calculateDistanceInMeters(
_droppedPinLocation!.latitude,
_droppedPinLocation!.longitude,
point.latitude,
point.longitude,
);
// If within ~50m of pin, start dragging
if (distance <= 50) {
setState(() {
_isDraggingPin = true;
});
}
}
},
onPointerHover: (event, point) {
// Update pin location while dragging
if (_isDraggingPin) {
setState(() {
_droppedPinLocation = point;
});
}
},
onPointerUp: (event, point) {
// Stop dragging on pointer release
if (_isDraggingPin) {
setState(() {
_isDraggingPin = false;
});
}
},
onTap: (tapPosition, point) {
// Clear dropped pin if tapping elsewhere (not on the pin itself)
if (_droppedPinLocation != null && !_isDraggingPin) {
// Check if tap is far from the pin
final distance = _calculateDistanceInMeters(
_droppedPinLocation!.latitude,
_droppedPinLocation!.longitude,
point.latitude,
point.longitude,
);
// If tap is more than ~50m away, clear pin
if (distance > 50) {
setState(() {
_droppedPinLocation = null;
});
}
}
},
),
children: [
TileLayer(
@@ -783,10 +1023,81 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
),
),
// Dropped pin marker with label
if (_droppedPinLocation != null)
Marker(
key: _pinMarkerKey,
point: _droppedPinLocation!,
width: 200,
height: 100,
rotate: false,
child: GestureDetector(
onTap: () {
// Only open dialog if not dragging
if (!_isDraggingPin) {
_showSarDialogWithLocation(_droppedPinLocation!);
// Clear the pin after opening dialog
setState(() {
_droppedPinLocation = null;
});
}
},
child: Opacity(
// Make pin slightly transparent while dragging
opacity: _isDraggingPin ? 0.7 : 1.0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Label
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _isDraggingPin ? Colors.orange : Colors.red,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
_isDraggingPin ? 'Drag to Position' : 'Create SAR Marker',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
// Pin icon pointing down
Icon(
Icons.location_pin,
color: _isDraggingPin ? Colors.orange : Colors.red,
size: 48,
shadows: const [
Shadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
],
),
),
),
),
],
),
],
)
),
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -7,7 +7,6 @@ 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';
import '../models/contact.dart';
@@ -98,7 +97,7 @@ class _MessagesTabState extends State<MessagesTab> {
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => _SarUpdateSheet(
builder: (context) => SarUpdateSheet(
onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async {
await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel);
},
@@ -114,6 +113,7 @@ class _MessagesTabState extends State<MessagesTab> {
bool sendToChannel,
) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
@@ -162,12 +162,43 @@ class _MessagesTabState extends State<MessagesTab> {
),
);
} else {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: fullMessage,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Send SAR message to selected room (persisted and immutable)
await connectionProvider.sendTextMessage(
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!,
text: fullMessage,
messageId: messageId, // Pass message ID so it can be tracked
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -189,21 +220,7 @@ class _MessagesTabState extends State<MessagesTab> {
}
Future<void> _handleRefresh() async {
final appProvider = context.read<AppProvider>();
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),
),
);
}
}
// Removed _handleRefresh() - messages are synced automatically via PUSH_CODE_MSG_WAITING events
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Show ALL messages regardless of recipient selection
@@ -245,31 +262,28 @@ class _MessagesTabState extends State<MessagesTab> {
],
),
)
: 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>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap();
}
: null,
);
},
),
: 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>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap();
}
: null,
);
},
),
),
@@ -365,6 +379,70 @@ class _MessageBubble extends StatelessWidget {
this.onTap,
});
Future<void> _retryFailedMessage(BuildContext context, Message failedMessage) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Not connected to device'),
backgroundColor: Colors.red,
),
);
return;
}
try {
// Create new message ID for retry
final retryMessageId = '${failedMessage.id}_retry';
// Create retry message
final retryMessage = failedMessage.copyWith(
id: retryMessageId,
deliveryStatus: MessageDeliveryStatus.sending,
);
// Add retry message to provider
messagesProvider.addSentMessage(retryMessage);
// Resend the message
if (failedMessage.messageType == MessageType.contact) {
// Direct message retry - NOT YET IMPLEMENTED
// Would need to look up contact's full public key by senderKeyShort
messagesProvider.markMessageFailed(retryMessageId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Direct message retry not yet implemented'),
backgroundColor: Colors.orange,
),
);
} else if (failedMessage.messageType == MessageType.channel) {
// Channel message retry
await connectionProvider.sendChannelMessage(
channelIdx: failedMessage.channelIdx ?? 0,
text: failedMessage.text,
messageId: retryMessageId,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Retrying message...'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Retry failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
final isSarMarker = message.isSarMarker;
@@ -514,12 +592,94 @@ class _MessageBubble extends StatelessWidget {
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
// Delivery status for sent messages
if (message.isSentMessage) ...[
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getDeliveryStatusIcon(message.deliveryStatus),
size: 14,
color: _getDeliveryStatusColor(message.deliveryStatus),
),
const SizedBox(width: 4),
Text(
message.deliveryStatusText,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: _getDeliveryStatusColor(message.deliveryStatus),
fontStyle: FontStyle.italic,
),
),
// Show retry button for failed messages
if (message.deliveryStatus == MessageDeliveryStatus.failed) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => _retryFailedMessage(context, message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.refresh, size: 12, color: Colors.orange),
const SizedBox(width: 4),
Text(
'Retry',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
],
),
],
],
),
),
);
}
IconData _getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Icons.schedule;
case MessageDeliveryStatus.sent:
return Icons.check;
case MessageDeliveryStatus.delivered:
return Icons.done_all;
case MessageDeliveryStatus.failed:
return Icons.error_outline;
case MessageDeliveryStatus.received:
return Icons.inbox;
}
}
Color _getDeliveryStatusColor(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Colors.orange;
case MessageDeliveryStatus.sent:
return Colors.blue;
case MessageDeliveryStatus.delivered:
return Colors.green;
case MessageDeliveryStatus.failed:
return Colors.red;
case MessageDeliveryStatus.received:
return Colors.grey;
}
}
Color _getSarMarkerColor(BuildContext context, bool isDarkMode) {
if (message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primaryContainer;
@@ -605,17 +765,24 @@ class _MessageBubble extends StatelessWidget {
}
}
// SAR Update Sheet
class _SarUpdateSheet extends StatefulWidget {
// SAR Update Sheet (public so it can be used from map_tab.dart)
class SarUpdateSheet extends StatefulWidget {
final Future<void> Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend;
final Position? prePopulatedPosition;
final bool allowLocationUpdate;
const _SarUpdateSheet({required this.onSend});
const SarUpdateSheet({
super.key,
required this.onSend,
this.prePopulatedPosition,
this.allowLocationUpdate = true,
});
@override
State<_SarUpdateSheet> createState() => _SarUpdateSheetState();
State<SarUpdateSheet> createState() => _SarUpdateSheetState();
}
class _SarUpdateSheetState extends State<_SarUpdateSheet> {
class _SarUpdateSheetState extends State<SarUpdateSheet> {
SarMarkerType _selectedType = SarMarkerType.foundPerson;
Position? _currentPosition;
bool _loadingLocation = false;
@@ -626,7 +793,12 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
@override
void initState() {
super.initState();
_getCurrentLocation();
// Use pre-populated position if provided, otherwise get current location
if (widget.prePopulatedPosition != null) {
_currentPosition = widget.prePopulatedPosition;
} else {
_getCurrentLocation();
}
_setDefaultDestination();
}
@@ -956,13 +1128,39 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
const SizedBox(height: 24),
// Location display
const Text(
'Current Location',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
Row(
children: [
const Text(
'Location',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (!widget.allowLocationUpdate) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.5),
width: 1,
),
),
child: const Text(
'From Map',
style: TextStyle(
color: Colors.blue,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 12),
if (_loadingLocation)
@@ -1065,13 +1263,15 @@ class _SarUpdateSheetState extends State<_SarUpdateSheet> {
),
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20, color: Colors.white),
onPressed: _getCurrentLocation,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Refresh location',
),
// Only show refresh button if location updates are allowed
if (widget.allowLocationUpdate)
IconButton(
icon: const Icon(Icons.refresh, size: 20, color: Colors.white),
onPressed: _getCurrentLocation,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Refresh location',
),
],
),
if (_currentPosition!.accuracy != null) ...[

View File

@@ -24,6 +24,12 @@ typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs);
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error);
typedef OnConnectionStateCallback = void Function(bool isConnected);
@@ -47,6 +53,12 @@ class MeshCoreBleService {
OnLoginSuccessCallback? onLoginSuccess;
OnLoginFailCallback? onLoginFail;
OnAdvertReceivedCallback? onAdvertReceived;
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError;
// Internal state
@@ -337,6 +349,10 @@ class MeshCoreBleService {
print(' → Handling TelemetryResponse');
_handleTelemetryResponse(reader);
break;
case MeshCoreConstants.pushBinaryResponse:
print(' → Handling BinaryResponse');
_handleBinaryResponse(reader);
break;
case MeshCoreConstants.respDeviceInfo:
print(' → Handling DeviceInfo');
_handleDeviceInfo(reader);
@@ -349,6 +365,10 @@ class MeshCoreBleService {
print(' → Handling Advert push');
_handleAdvert(reader);
break;
case MeshCoreConstants.pushPathUpdated:
print(' → Handling PathUpdated push');
_handlePathUpdated(reader);
break;
case MeshCoreConstants.pushLogRxData:
print(' → Handling LogRxData push');
_handleLogRxData(reader);
@@ -373,10 +393,18 @@ class MeshCoreBleService {
print(' → Handling LoginFail push');
_handleLoginFail(reader);
break;
case MeshCoreConstants.pushStatusResponse:
print(' → Handling StatusResponse push');
_handleStatusResponse(reader);
break;
case MeshCoreConstants.respCurrTime:
print(' → Handling CurrentTime');
_handleCurrentTime(reader);
break;
case MeshCoreConstants.respBatteryVoltage:
print(' → Handling BatteryAndStorage');
_handleBatteryAndStorage(reader);
break;
case MeshCoreConstants.respNoMoreMessages:
print(' → Response: No More Messages');
onNoMoreMessages?.call();
@@ -488,17 +516,20 @@ class MeshCoreBleService {
if (reader.remainingBytesCount >= 9) {
final sendType = reader.readByte();
final sendTypeStr = sendType == 1 ? 'flood' : 'direct';
final isFloodMode = sendType == 1;
print(' Send type: $sendType ($sendTypeStr)');
final expectedAckOrTag = reader.readBytes(4);
print(' Expected ACK/TAG: ${expectedAckOrTag.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
final expectedAckOrTagBytes = reader.readBytes(4);
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes)).getUint32(0, Endian.little);
print(' Expected ACK/TAG: ${expectedAckOrTagBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $expectedAckTag)');
final suggestedTimeout = reader.readUInt32LE();
print(' Suggested timeout: ${suggestedTimeout}ms');
print(' ✅ [Sent] Message sent successfully ($sendTypeStr mode, timeout: ${suggestedTimeout}ms)');
// TODO: Store ACK/TAG to match with PUSH_CODE_SEND_CONFIRMED later
// Notify provider that message was sent
onMessageSent?.call(expectedAckTag, suggestedTimeout, isFloodMode);
} else {
print(' ⚠️ [Sent] Insufficient data for full parsing');
}
@@ -529,27 +560,29 @@ class MeshCoreBleService {
// Handle different message types
String text;
Uint8List? signature;
Uint8List? senderPrefixExtra;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [64-byte signature][UTF-8 text]
print(' Signed message detected - extracting signature');
// Signed message format: [4-byte sender prefix][UTF-8 text]
// Note: Despite the name "signed", this doesn't contain a cryptographic signature
// It contains 4 extra bytes of the sender's public key prefix for verification
print(' Signed message detected - extracting extra sender prefix');
if (reader.remainingBytesCount < 64) {
print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)');
// Try to read as plain text anyway
text = reader.readString();
} else {
signature = reader.readBytes(64);
print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...');
if (reader.remainingBytesCount >= 4) {
senderPrefixExtra = reader.readBytes(4);
print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Remaining bytes are the actual text
if (reader.hasRemaining) {
text = reader.readString();
} else {
text = '';
print(' ⚠️ No text content after signature');
print(' ⚠️ No text content after sender prefix');
}
} else {
print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)');
// Read remaining bytes as text anyway
text = reader.readString();
}
} else {
// Plain text message
@@ -598,27 +631,29 @@ class MeshCoreBleService {
// Handle different message types
String text;
Uint8List? signature;
Uint8List? senderPrefixExtra;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [64-byte signature][UTF-8 text]
print(' Signed message detected - extracting signature');
// Signed message format: [4-byte sender prefix][UTF-8 text]
// Note: Despite the name "signed", this doesn't contain a cryptographic signature
// It contains 4 extra bytes of the sender's public key prefix for verification
print(' Signed message detected - extracting extra sender prefix');
if (reader.remainingBytesCount < 64) {
print(' ⚠️ Insufficient bytes for signature (${reader.remainingBytesCount} < 64)');
// Try to read as plain text anyway
text = reader.readString();
} else {
signature = reader.readBytes(64);
print(' Signature (first 16 bytes): ${signature.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}...');
if (reader.remainingBytesCount >= 4) {
senderPrefixExtra = reader.readBytes(4);
print(' Extra sender prefix (4 bytes): ${senderPrefixExtra.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Remaining bytes are the actual text
if (reader.hasRemaining) {
text = reader.readString();
} else {
text = '';
print(' ⚠️ No text content after signature');
print(' ⚠️ No text content after sender prefix');
}
} else {
print(' ⚠️ Insufficient bytes for sender prefix (${reader.remainingBytesCount} < 4)');
// Read remaining bytes as text anyway
text = reader.readString();
}
} else {
// Plain text message
@@ -670,6 +705,41 @@ class MeshCoreBleService {
}
}
/// Handle BinaryResponse push (PUSH_CODE_BINARY_RESPONSE 0x8C)
///
/// Protocol format:
/// - 1 byte: reserved (zero)
/// - 4 bytes: tag (uint32, matches RESP_CODE_SENT expected_ack_or_tag)
/// - N bytes: response data (remainder of frame)
void _handleBinaryResponse(BufferReader reader) {
try {
print(' [BinaryResponse] Parsing binary response...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
final reserved = reader.readByte();
print(' Reserved byte: $reserved');
final tag = reader.readUInt32LE();
print(' Tag: $tag (matches RESP_CODE_SENT expected_ack_or_tag)');
final responseData = reader.readRemainingBytes();
print(' Response data length: ${responseData.length} bytes');
print(' Response data (hex): ${responseData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Extract public key prefix from response data if present
// Note: The firmware doesn't include the sender's public key prefix in binary responses
// The app must track which request corresponds to which tag
// For now, we'll use an empty prefix and rely on the tag for matching
final emptyPrefix = Uint8List(6);
print(' ✅ [BinaryResponse] Parsed successfully');
onBinaryResponse?.call(emptyPrefix, tag, responseData);
} catch (e) {
print(' ❌ [BinaryResponse] Parsing error: $e');
onError?.call('Binary response parsing error: $e');
}
}
/// Handle DeviceInfo response
/// Handle DeviceInfo response (RESP_CODE_DEVICE_INFO)
///
@@ -934,24 +1004,111 @@ class MeshCoreBleService {
}
}
/// Handle PathUpdated push (PUSH_CODE_PATH_UPDATED)
///
/// This push notification indicates that the mesh network has discovered
/// a new or better routing path to a contact. The companion radio sends
/// this notification when a contact's out_path is updated.
///
/// Protocol format:
/// - 32 bytes: public key of the contact whose path was updated
///
/// The app can use this to:
/// - Trigger a contact sync to get the updated path
/// - Show network topology changes in the UI
/// - Update signal quality indicators
void _handlePathUpdated(BufferReader reader) {
try {
print(' [PathUpdated] Parsing path updated push notification...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
// PathUpdated format: 32 bytes public key
if (reader.remainingBytesCount >= 32) {
final publicKey = reader.readBytes(32);
final publicKeyPrefix = publicKey.sublist(0, 6);
final publicKeyFull = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
print(' 📡 PATH UPDATED FOR CONTACT:');
print(' Public key prefix (6 bytes): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Public key (full 32 bytes): $publicKeyFull');
print(' The mesh network has discovered a new/better routing path to this contact');
print(' The companion radio has updated the contact\'s out_path');
print(' Recommended action: Call CMD_GET_CONTACTS to sync the updated contact info');
// Notify callback so app can trigger contact sync or update UI
onPathUpdated?.call(publicKey);
} else {
print(' ⚠️ [PathUpdated] Insufficient data: expected 32 bytes, got ${reader.remainingBytesCount}');
}
// Consume any remaining bytes
if (reader.hasRemaining) {
final extraBytes = reader.readRemainingBytes();
print(' ⚠️ [PathUpdated] Extra bytes found: ${extraBytes.length} bytes');
print(' Extra data (hex): ${extraBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
}
print(' ✅ [PathUpdated] Parsed successfully');
} catch (e) {
print(' ❌ [PathUpdated] Parsing error: $e');
// Don't call onError - path updates are informational
}
}
/// Handle LogRxData push (PUSH_CODE_LOG_RX_DATA)
///
/// This push notification contains diagnostic/debug data from the companion radio
/// about packets it received over the air. The format is device-specific and may
/// contain encrypted or encoded data from the radio firmware.
/// This push notification contains diagnostic data about packets received over-the-air.
/// Based on MyMesh.cpp logRxRaw() implementation:
///
/// Frame format (after 0x88 opcode):
/// - Byte 0: SNR × 4 (signed int8, divide by 4 to get SNR in dB)
/// - Byte 1: RSSI (signed int8, in dBm)
/// - Bytes 2+: Raw over-the-air packet data (encrypted mesh packet)
///
/// The "raw" data is the actual LoRa packet received from another mesh node,
/// which is typically encrypted and has high entropy.
void _handleLogRxData(BufferReader reader) {
try {
print(' [LogRxData] Parsing log rx data...');
print(' [LogRxData] Parsing log rx data from over-the-air packet...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
final data = reader.readRemainingBytes();
print(' Data length: ${data.length} bytes');
// Parse signal quality metrics (first 2 bytes)
if (data.length < 2) {
print(' ⚠️ [LogRxData] Insufficient data (need at least 2 bytes for SNR+RSSI)');
return;
}
final snrRaw = data[0];
final snrDb = (snrRaw.toSigned(8)) / 4.0; // Convert from int8 and divide by 4
print(' SNR: ${snrDb.toStringAsFixed(2)} dB (raw byte: 0x${snrRaw.toRadixString(16).padLeft(2, '0')})');
final rssiDbm = data[1].toSigned(8); // Signed int8
print(' RSSI: $rssiDbm dBm (raw byte: 0x${data[1].toRadixString(16).padLeft(2, '0')})');
// Remaining bytes are the raw over-the-air packet
if (data.length <= 2) {
print(' ⚠️ [LogRxData] No raw packet data after signal metrics');
return;
}
final rawPacketData = data.sublist(2);
print(' Raw packet data: ${rawPacketData.length} bytes');
print(' This is the encrypted LoRa packet received from another mesh node');
// Variables to store decoded information
int? airtimeMs;
Uint8List? senderPublicKey;
int? ackCode;
final List<String> embeddedStrings = [];
// Enhanced hex dump with 16 bytes per line for readability
print(' 📊 HEX DUMP:');
for (int i = 0; i < data.length; i += 16) {
final end = (i + 16 < data.length) ? i + 16 : data.length;
final chunk = data.sublist(i, end);
print(' 📊 RAW PACKET HEX DUMP:');
for (int i = 0; i < rawPacketData.length; i += 16) {
final end = (i + 16 < rawPacketData.length) ? i + 16 : rawPacketData.length;
final chunk = rawPacketData.sublist(i, end);
// Offset column (4 hex digits)
final offset = i.toRadixString(16).padLeft(4, '0');
@@ -972,42 +1129,192 @@ class MeshCoreBleService {
print(' $offset: ${hexBytes.padRight(47)} | $ascii');
}
// Attempt to decode structure
print(' 🔍 STRUCTURE ANALYSIS:');
// 🔥 FORCED DECODING - Try ALL possible interpretations
print(' 🔥 FORCED DECODING - EXHAUSTIVE ANALYSIS:');
print('');
if (data.length >= 4) {
// Try to parse potential timestamp at beginning (uint32 LE)
final timestamp = ByteData.sublistView(Uint8List.fromList(data.sublist(0, 4)))
.getUint32(0, Endian.little);
print(' [Bytes 0-3] Potential timestamp (uint32 LE): $timestamp');
// ========== INTERPRETATION 1: All Possible uint32 Values ==========
print(' 🔍 [INTERPRETATION 1] All uint32 LE values at each offset:');
for (int offset = 0; offset <= rawPacketData.length - 4; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getUint32(0, Endian.little);
final valueHex = '0x${value.toRadixString(16).padLeft(8, '0')}';
// Check if timestamp is reasonable (between 2020 and 2030)
String interpretation = '';
// Check if it's a valid timestamp
const minTimestamp = 1577836800; // 2020-01-01
const maxTimestamp = 1893456000; // 2030-01-01
if (timestamp >= minTimestamp && timestamp <= maxTimestamp) {
print(' As epoch: ${DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)}');
print(' ✅ Valid timestamp!');
} else {
print(' ⚠️ Timestamp out of reasonable range (not epoch seconds)');
if (value >= minTimestamp && value <= maxTimestamp) {
final date = DateTime.fromMillisecondsSinceEpoch(value * 1000);
interpretation = ' → TIMESTAMP: $date';
} else if (value < 100000) {
interpretation = ' → Airtime/Duration: ${value}ms';
} else if (value > 900000000 && value < 1000000000) {
interpretation = ' → Radio freq: ${value / 1000} MHz';
}
print(' [Offset $offset] uint32: $value ($valueHex)$interpretation');
}
print('');
// ========== INTERPRETATION 2: All Possible int32 Values ==========
print(' 🔍 [INTERPRETATION 2] All int32 LE values (for GPS coordinates):');
for (int offset = 0; offset <= rawPacketData.length - 4; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 4))).getInt32(0, Endian.little);
final latLon = value / 1000000.0;
String interpretation = '';
if (latLon >= -90 && latLon <= 90) {
interpretation = ' → Possible GPS: ${latLon.toStringAsFixed(6)}°';
}
print(' [Offset $offset] int32: $value${latLon.toStringAsFixed(6)}$interpretation');
}
print('');
// ========== INTERPRETATION 3: All uint16 Values ==========
print(' 🔍 [INTERPRETATION 3] All uint16 LE values:');
for (int offset = 0; offset <= rawPacketData.length - 2; offset++) {
final value = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(offset, offset + 2))).getUint16(0, Endian.little);
print(' [Offset $offset] uint16: $value (0x${value.toRadixString(16).padLeft(4, '0')})');
}
print('');
// ========== INTERPRETATION 4: Byte Pair Analysis ==========
print(' 🔍 [INTERPRETATION 4] Byte pair correlation (detect patterns):');
final Map<int, List<int>> bytePairs = {};
for (int i = 0; i < rawPacketData.length - 1; i++) {
final key = rawPacketData[i];
bytePairs.putIfAbsent(key, () => []);
bytePairs[key]!.add(rawPacketData[i + 1]);
}
// Check if this contains a public key (32-byte sequence starting around byte 4)
if (data.length >= 36) {
final potentialPubKey = data.sublist(4, 36);
final pubKeyPrefix = potentialPubKey.sublist(0, 6);
print(' [Bytes 4-35] Potential public key (32 bytes):');
print(' Prefix: ${pubKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Full: ${potentialPubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
print(' This might be the sender\'s public key from over-the-air packet');
// Find repeating patterns
final repeatingPatterns = bytePairs.entries.where((e) => e.value.length > 1);
if (repeatingPatterns.isNotEmpty) {
print(' Repeating byte transitions found:');
for (final entry in repeatingPatterns) {
print(' Byte 0x${entry.key.toRadixString(16).padLeft(2, '0')}${entry.value.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(', ')}');
}
} else {
print(' No repeating byte transitions (high randomness)');
}
print('');
// ========== INTERPRETATION 5: Nibble Distribution ==========
print(' 🔍 [INTERPRETATION 5] Nibble (half-byte) distribution:');
final Map<int, int> nibbleHist = {};
for (final byte in rawPacketData) {
final high = (byte >> 4) & 0x0F;
final low = byte & 0x0F;
nibbleHist[high] = (nibbleHist[high] ?? 0) + 1;
nibbleHist[low] = (nibbleHist[low] ?? 0) + 1;
}
final sortedNibbles = nibbleHist.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
print(' Top nibble frequencies:');
for (int i = 0; i < (sortedNibbles.length < 5 ? sortedNibbles.length : 5); i++) {
final entry = sortedNibbles[i];
final bar = '' * ((entry.value / sortedNibbles[0].value * 20).round());
print(' 0x${entry.key.toRadixString(16)}: ${entry.value.toString().padLeft(3)} $bar');
}
print('');
// ========== INTERPRETATION 6: XOR Pattern Detection ==========
print(' 🔍 [INTERPRETATION 6] XOR pattern detection (simple encryption):');
final List<int> xorKeys = [0x00, 0xFF, 0xAA, 0x55, 0x42, 0x69];
for (final xorKey in xorKeys) {
final xored = rawPacketData.map((b) => b ^ xorKey).toList();
final printableCount = xored.where((b) => b >= 32 && b <= 126).length;
final printableRatio = printableCount / xored.length;
if (printableRatio > 0.3) {
final preview = String.fromCharCodes(xored.take(20).map((b) => b >= 32 && b <= 126 ? b : 46));
print(' XOR key 0x${xorKey.toRadixString(16).padLeft(2, '0')}: ${(printableRatio * 100).toStringAsFixed(1)}% printable → "$preview..."');
}
}
print('');
// ========== INTERPRETATION 7: Sliding Window CRC/Checksum ==========
print(' 🔍 [INTERPRETATION 7] Checksum/CRC candidates (last 1-4 bytes):');
if (rawPacketData.length >= 2) {
// Try last byte as checksum
final lastByte = rawPacketData[rawPacketData.length - 1];
final payload = rawPacketData.sublist(0, rawPacketData.length - 1);
final simpleSum = payload.reduce((a, b) => (a + b) & 0xFF);
final xorSum = payload.reduce((a, b) => a ^ b);
print(' Last byte: 0x${lastByte.toRadixString(16).padLeft(2, '0')}');
print(' Simple sum (mod 256): 0x${simpleSum.toRadixString(16).padLeft(2, '0')} ${simpleSum == lastByte ? '✅ MATCH!' : ''}');
print(' XOR checksum: 0x${xorSum.toRadixString(16).padLeft(2, '0')} ${xorSum == lastByte ? '✅ MATCH!' : ''}');
}
if (rawPacketData.length >= 3) {
final last2 = ByteData.sublistView(Uint8List.fromList(rawPacketData.sublist(rawPacketData.length - 2))).getUint16(0, Endian.little);
print(' Last 2 bytes (uint16 LE): 0x${last2.toRadixString(16).padLeft(4, '0')} ($last2)');
}
print('');
// ========== INTERPRETATION 8: Bit Pattern Analysis ==========
print(' 🔍 [INTERPRETATION 8] Bit-level analysis:');
int bitCount1 = 0;
int bitCount0 = 0;
for (final byte in rawPacketData) {
for (int bit = 0; bit < 8; bit++) {
if ((byte & (1 << bit)) != 0) {
bitCount1++;
} else {
bitCount0++;
}
}
}
final bitRatio = bitCount1 / (bitCount0 + bitCount1);
print(' Bit 1 count: $bitCount1 (${(bitRatio * 100).toStringAsFixed(1)}%)');
print(' Bit 0 count: $bitCount0 (${((1 - bitRatio) * 100).toStringAsFixed(1)}%)');
print(' Balance: ${(bitRatio - 0.5).abs() < 0.05 ? '✅ Well-balanced (likely encrypted/random)' : '⚠️ Imbalanced (may have structure)'}');
print('');
// ========== INTERPRETATION 9: LoRa Modulation Params ==========
print(' 🔍 [INTERPRETATION 9] LoRa modulation parameter candidates:');
for (int i = 0; i < rawPacketData.length; i++) {
final byte = rawPacketData[i];
// Check if it could be spreading factor (7-12)
if (byte >= 7 && byte <= 12) {
print(' [Offset $i] Possible SF (Spreading Factor): $byte');
}
// Check if it could be coding rate (5-8)
if (byte >= 5 && byte <= 8) {
print(' [Offset $i] Possible CR (Coding Rate): $byte');
}
// Check if it could be bandwidth index (0-9)
if (byte >= 0 && byte <= 9) {
final bwValues = [7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500];
print(' [Offset $i] Possible BW index: $byte${bwValues[byte]} kHz');
}
}
print('');
// ========== Final Structure Analysis ==========
print(' 🔍 STRUCTURE ANALYSIS:');
// Calculate entropy to detect encryption
final uniqueBytes = rawPacketData.toSet().length;
final entropy = uniqueBytes / rawPacketData.length;
final isLikelyEncrypted = entropy > 0.7;
print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${rawPacketData.length} unique bytes)');
if (isLikelyEncrypted) {
print(' High entropy suggests encrypted or compressed data');
}
// Look for printable strings (runs of 4+ printable characters)
final strings = <String>[];
StringBuffer currentString = StringBuffer();
for (int i = 0; i < data.length; i++) {
final byte = data[i];
for (int i = 0; i < rawPacketData.length; i++) {
final byte = rawPacketData[i];
if (byte >= 32 && byte <= 126) {
// Printable ASCII
currentString.write(String.fromCharCode(byte));
@@ -1028,20 +1335,38 @@ class MeshCoreBleService {
print(' Embedded strings found:');
for (final str in strings) {
print(' → "$str"');
embeddedStrings.add(str);
}
} else {
print(' No printable strings found (likely encrypted/binary data)');
}
// Check if this might be an encrypted packet (high entropy)
final uniqueBytes = data.toSet().length;
final entropy = uniqueBytes / data.length;
print(' Entropy: ${(entropy * 100).toStringAsFixed(1)}% (${uniqueBytes}/${data.length} unique bytes)');
if (entropy > 0.7) {
print(' High entropy suggests encrypted or compressed data');
}
print(' ✅ [LogRxData] Forced decode complete');
print(' ✅ [LogRxData] Parsed successfully');
// Create decoded info for packet log
final logRxDataInfo = LogRxDataInfo(
airtimeMs: airtimeMs,
senderPublicKey: senderPublicKey,
ackCode: ackCode,
embeddedStrings: embeddedStrings,
entropy: entropy,
isLikelyEncrypted: isLikelyEncrypted,
);
// Update the most recent packet log entry with decoded information
if (_packetLogs.isNotEmpty) {
final lastLog = _packetLogs.last;
if (lastLog.responseCode == MeshCoreConstants.pushLogRxData) {
_packetLogs[_packetLogs.length - 1] = BlePacketLog(
timestamp: lastLog.timestamp,
rawData: lastLog.rawData,
direction: lastLog.direction,
responseCode: lastLog.responseCode,
description: lastLog.description,
logRxDataInfo: logRxDataInfo,
);
}
}
} catch (e) {
print(' ❌ [LogRxData] Parsing error: $e');
// Don't call onError - logs are informational
@@ -1132,15 +1457,17 @@ class MeshCoreBleService {
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 ackCodeBytes = reader.readBytes(4);
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes)).getUint32(0, Endian.little);
print(' ACK code: ${ackCodeBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')} (uint32: $ackCode)');
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
// Notify provider that message was delivered
onMessageDelivered?.call(ackCode, roundTripTime);
} else {
print(' ⚠️ [SendConfirmed] Insufficient data for full parsing');
}
@@ -1234,6 +1561,72 @@ class MeshCoreBleService {
}
}
/// Handle StatusResponse push (PUSH_CODE_STATUS_RESPONSE)
///
/// This push notification is received in response to CMD_SEND_STATUS_REQ.
/// It contains status information from a repeater or sensor node.
///
/// Protocol format (PUSH_CODE_STATUS_RESPONSE, 0x87):
/// - 1 byte: reserved (zero)
/// - 6 bytes: public key prefix (first 6 bytes of responding node)
/// - N bytes: status data (remainder of frame, format depends on node type)
///
/// The status data format is node-specific and may include:
/// - Repeater nodes: uptime, message counts, relay statistics
/// - Sensor nodes: sensor readings, battery level, operational state
/// - Room nodes: user counts, message storage stats
void _handleStatusResponse(BufferReader reader) {
try {
print(' [StatusResponse] Parsing status response...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
if (reader.remainingBytesCount >= 7) {
final reserved = reader.readByte();
print(' Reserved: $reserved');
final publicKeyPrefix = reader.readBytes(6);
print(' Node public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
// Read remaining status data
final statusData = reader.readRemainingBytes();
print(' Status data: ${statusData.length} bytes');
print(' Status data (hex): ${statusData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
// Try to decode as ASCII text if printable
try {
final statusText = utf8.decode(statusData, allowMalformed: true);
if (statusText.isNotEmpty && _isPrintableAscii(statusText)) {
print(' Status data (text): $statusText');
}
} catch (e) {
// Not text data, that's fine
}
print(' ✅ [StatusResponse] Received status response from node');
onStatusResponse?.call(publicKeyPrefix, statusData);
} else {
print(' ⚠️ [StatusResponse] Insufficient data for full parsing');
}
} catch (e) {
print(' ❌ [StatusResponse] Parsing error: $e');
onError?.call('Status response parsing error: $e');
}
}
/// Check if a string contains only printable ASCII characters
bool _isPrintableAscii(String text) {
for (int i = 0; i < text.length; i++) {
final code = text.codeUnitAt(i);
if (code < 32 || code > 126) {
// Not printable ASCII (except newlines and tabs which are common)
if (code != 10 && code != 13 && code != 9) {
return false;
}
}
}
return true;
}
/// Handle CurrentTime response (RESP_CODE_CURR_TIME)
///
/// Protocol format:
@@ -1273,6 +1666,64 @@ class MeshCoreBleService {
}
}
/// Handle BatteryAndStorage response (RESP_CODE_BATT_AND_STORAGE)
///
/// Protocol format (RESP_CODE_BATT_AND_STORAGE, code 12):
/// - 2 bytes: Millivolts (uint16)
/// - 4 bytes: (Optional) Used KB (uint32)
/// - 4 bytes: (Optional) Total KB (uint32, zero if unknown)
void _handleBatteryAndStorage(BufferReader reader) {
try {
print(' [BatteryAndStorage] Parsing battery and storage info...');
print(' Remaining bytes: ${reader.remainingBytesCount}');
if (reader.remainingBytesCount >= 2) {
// Battery voltage is always present (uint16)
final millivolts = reader.readUInt16LE();
final voltage = millivolts / 1000.0;
print(' Battery: ${millivolts}mV (${voltage.toStringAsFixed(2)}V)');
// Storage fields are optional
int? usedKb;
int? totalKb;
if (reader.remainingBytesCount >= 8) {
// Both storage fields present
usedKb = reader.readUInt32LE();
totalKb = reader.readUInt32LE();
print(' Storage Used: ${usedKb}KB');
print(' Storage Total: ${totalKb}KB');
if (totalKb > 0) {
final usedPercent = (usedKb / totalKb) * 100.0;
final availableKb = totalKb - usedKb;
print(' Storage Available: ${availableKb}KB (${(100 - usedPercent).toStringAsFixed(1)}% free)');
print(' Storage Usage: ${usedPercent.toStringAsFixed(1)}%');
} else {
print(' Storage Total is 0 (size unknown)');
}
} else if (reader.remainingBytesCount >= 4) {
// Only used KB present
usedKb = reader.readUInt32LE();
print(' Storage Used: ${usedKb}KB');
print(' Storage Total: Not available');
} else {
print(' Storage: Not available');
}
// Trigger callback
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
print(' ✅ [BatteryAndStorage] Parsed successfully');
} else {
print(' ⚠️ [BatteryAndStorage] Insufficient data (need at least 2 bytes for battery)');
}
} catch (e) {
print(' ❌ [BatteryAndStorage] Parsing error: $e');
onError?.call('BatteryAndStorage parsing error: $e');
}
}
/// Handle Error response (RESP_CODE_ERR)
///
/// Protocol format:
@@ -1457,6 +1908,7 @@ class MeshCoreBleService {
/// Request telemetry from contact
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
@Deprecated('Use sendBinaryRequest() instead for better functionality')
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
@@ -1467,13 +1919,62 @@ class MeshCoreBleService {
await _writeData(writer.toBytes());
}
/// Get battery voltage
Future<void> getBatteryVoltage() async {
/// Send binary request to contact (CMD_SEND_BINARY_REQ)
///
/// Modern replacement for requestTelemetry() with better functionality.
/// Supports multiple request types including telemetry, access lists, and neighbors.
///
/// Protocol format:
/// - 1 byte: command code (50)
/// - 32 bytes: contact public key
/// - N bytes: request code and params (requestData)
///
/// Common request codes (first byte of requestData):
/// - 0x03: Get telemetry data (equivalent to old requestTelemetry)
/// - 0x04: Get average/min/max telemetry
/// - 0x05: Get access list
/// - 0x06: Get neighbors list
///
/// Response arrives via onBinaryResponse callback with matching tag.
///
/// Example - request telemetry:
/// ```dart
/// await sendBinaryRequest(
/// contactPublicKey: contact.publicKey,
/// requestData: Uint8List.fromList([0x03]), // BINARY_REQ_GET_TELEMETRY_DATA
/// );
/// ```
Future<void> sendBinaryRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
writer.writeBytes(contactPublicKey); // 32 bytes
writer.writeBytes(requestData); // request code + params
await _writeData(writer.toBytes());
}
/// Get battery voltage and storage information
///
/// Sends CMD_GET_BATT_AND_STORAGE (20) to query:
/// - Battery voltage in millivolts (uint16)
/// - Used storage in KB (optional uint32)
/// - Total storage in KB (optional uint32, 0 if unknown)
///
/// Response arrives via onBatteryAndStorage callback
Future<void> getBatteryAndStorage() async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
await _writeData(writer.toBytes());
}
/// Legacy method name for backward compatibility
@Deprecated('Use getBatteryAndStorage() instead')
Future<void> getBatteryVoltage() async {
await getBatteryAndStorage();
}
/// Sync next message from device queue
/// Returns true if a message was retrieved, false if no more messages
Future<void> syncNextMessage() async {
@@ -1592,20 +2093,20 @@ class MeshCoreBleService {
/// Send login request to room or repeater
///
/// This sends a PAYLOAD_TYPE_ANON_REQ packet via the companion radio.
/// The companion radio encodes it and sends it to the room server.
/// This sends a login request to the room server via the companion radio.
///
/// Protocol format (CMD_SEND_LOGIN):
/// **ACTUAL Protocol format (CMD_SEND_LOGIN):**
/// - 1 byte: command code (26)
/// - 4 bytes: sender timestamp (uint32, epoch seconds - current time)
/// - 4 bytes: sync_since timestamp (uint32, epoch seconds - 0 for all messages)
/// - 32 bytes: room public key
/// - N bytes: password (varchar, max 15 bytes, null-terminated)
///
/// NOTE: The documentation was wrong - there are NO timestamp/sync_since params
/// in the companion radio protocol. The companion radio's sendLogin() function
/// handles timestamp internally when it creates the PAYLOAD_TYPE_ANON_REQ packet.
///
/// Response: PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86)
///
/// After successful login, the room server will PUSH messages where
/// post_timestamp > sync_since directly to the companion radio.
/// After successful login, the room server will automatically PUSH stored messages.
///
/// IMPORTANT: The companion radio must have the room contact in its own
/// internal contact table. If you get ERR_CODE_NOT_FOUND (2), the radio
@@ -1616,31 +2117,58 @@ class MeshCoreBleService {
Future<void> loginToRoom({
required Uint8List roomPublicKey,
required String password,
int syncSince = 0, // 0 = get all messages
}) async {
if (password.length > 15) {
throw ArgumentError('Password exceeds 15 character limit');
}
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // epoch seconds
print('🔐 [BLE] Preparing login request:');
print(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Password: ${"*" * password.length} (${password.length} chars)');
print(' Sender timestamp: $now (${DateTime.fromMillisecondsSinceEpoch(now * 1000)})');
print(' Sync since: $syncSince (${syncSince == 0 ? "all messages" : "messages after timestamp $syncSince"})');
print(' ⚠️ NOTE: The companion radio must have this room in its contact table');
print(' If you get ERR_CODE_NOT_FOUND, the room needs to advertise first or use CMD_ADD_UPDATE_CONTACT');
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendLogin);
writer.writeUInt32LE(now); // sender timestamp
writer.writeUInt32LE(syncSince); // sync messages since this timestamp (0 = all)
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
writer.writeBytes(roomPublicKey); // 32 bytes
writer.writeString(password); // Max 15 bytes, null-terminated
await _writeData(writer.toBytes());
}
/// Send status request to repeater or sensor node
///
/// This sends a status request (CMD_SEND_STATUS_REQ, 0x1B) to a repeater
/// or sensor node to query its current operational status.
///
/// Protocol format (CMD_SEND_STATUS_REQ):
/// - 1 byte: command code (27)
/// - 32 bytes: public key of target node (repeater or sensor)
///
/// Response: PUSH_CODE_STATUS_RESPONSE (0x87) push notification
///
/// The status data format is node-specific:
/// - Repeater nodes: uptime, message counts, relay statistics
/// - Sensor nodes: sensor readings, battery level, operational state
/// - Room nodes: user counts, message storage statistics
///
/// Example usage:
/// ```dart
/// bleService.onStatusResponse = (publicKeyPrefix, statusData) {
/// print('Status from node: ${utf8.decode(statusData)}');
/// };
/// await bleService.sendStatusRequest(repeaterContact.publicKey);
/// ```
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
print('📊 [BLE] Preparing status request:');
print(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
print(' Requesting status from repeater/sensor node');
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
writer.writeBytes(contactPublicKey); // 32 bytes
await _writeData(writer.toBytes());
}
/// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
// Add new packet
@@ -1675,6 +2203,8 @@ class MeshCoreBleService {
return 'Device Query';
case MeshCoreConstants.cmdAppStart:
return 'App Start';
case MeshCoreConstants.cmdSendStatusReq:
return 'Status Request';
default:
return null;
}
@@ -1701,10 +2231,14 @@ class MeshCoreBleService {
return 'Self Info';
case MeshCoreConstants.pushAdvert:
return 'Advertisement';
case MeshCoreConstants.pushPathUpdated:
return 'Path Updated';
case MeshCoreConstants.pushLogRxData:
return 'Log RX Data';
case MeshCoreConstants.pushNewAdvert:
return 'New Advertisement';
case MeshCoreConstants.pushStatusResponse:
return 'Status Response';
case MeshCoreConstants.respNoMoreMessages:
return 'No More Messages';
case MeshCoreConstants.respOk: