Refactor code for improved readability and consistency across multiple files

- Updated formatting in `drawing_toolbar.dart` for better readability, including breaking long lines and ensuring consistent indentation.
- Enhanced the `recipient_selector_sheet.dart` by adjusting line breaks and improving the layout of text styles.
- Refactored `sar_update_sheet.dart` to improve readability, including restructuring widget layouts and ensuring consistent formatting.
- Cleaned up imports and removed unnecessary lines in `widget_test.dart`.
This commit is contained in:
Janez T
2025-10-22 09:49:41 +02:00
parent 021ce21cbe
commit 53ed690f51
31 changed files with 2399 additions and 1469 deletions

View File

@@ -1,7 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
@@ -32,7 +30,9 @@ class BackgroundLocationService {
/// additional platform-specific configuration is required.
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
if (!_isInitialized || _bleService == null) {
debugPrint('⚠️ [BackgroundLocation] Service not initialized or BLE service null');
debugPrint(
'⚠️ [BackgroundLocation] Service not initialized or BLE service null',
);
return false;
}
@@ -52,7 +52,9 @@ class BackgroundLocationService {
}
if (permission == LocationPermission.deniedForever) {
debugPrint('⚠️ [BackgroundLocation] Location permission permanently denied');
debugPrint(
'⚠️ [BackgroundLocation] Location permission permanently denied',
);
return false;
}
@@ -64,60 +66,77 @@ class BackgroundLocationService {
// Start listening to position updates
Position? lastPosition;
try {
_positionSubscription = Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
debugPrint('📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}');
// Calculate distance from last position
if (lastPosition != null) {
final distance = Geolocator.distanceBetween(
lastPosition!.latitude,
lastPosition!.longitude,
position.latitude,
position.longitude,
);
debugPrint(' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)');
// Skip if haven't moved enough
if (distance < distanceThreshold) {
return;
}
}
// Update last position
lastPosition = position;
// Save to preferences
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
// Update device's advertised location
if (_bleService != null && _bleService!.isConnected) {
try {
debugPrint('📤 [BackgroundLocation] Updating device location...');
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
debugPrint(
'📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}',
);
// Send advertisement to mesh network
debugPrint('📡 [BackgroundLocation] Broadcasting self advertisement...');
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint('✅ [BackgroundLocation] Location update sent successfully');
} catch (e) {
debugPrint('❌ [BackgroundLocation] Failed to send location update: $e');
}
} else {
debugPrint('⚠️ [BackgroundLocation] BLE disconnected, cannot send update');
}
});
// Calculate distance from last position
if (lastPosition != null) {
final distance = Geolocator.distanceBetween(
lastPosition!.latitude,
lastPosition!.longitude,
position.latitude,
position.longitude,
);
debugPrint('✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold');
debugPrint(
' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)',
);
// Skip if haven't moved enough
if (distance < distanceThreshold) {
return;
}
}
// Update last position
lastPosition = position;
// Save to preferences
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
// Update device's advertised location
if (_bleService != null && _bleService!.isConnected) {
try {
debugPrint(
'📤 [BackgroundLocation] Updating device location...',
);
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Send advertisement to mesh network
debugPrint(
'📡 [BackgroundLocation] Broadcasting self advertisement...',
);
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint(
'✅ [BackgroundLocation] Location update sent successfully',
);
} catch (e) {
debugPrint(
'❌ [BackgroundLocation] Failed to send location update: $e',
);
}
} else {
debugPrint(
'⚠️ [BackgroundLocation] BLE disconnected, cannot send update',
);
}
});
debugPrint(
'✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold',
);
return true;
} catch (e) {
debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e');
@@ -141,7 +160,9 @@ class BackgroundLocationService {
Future<void> updateDistanceThreshold(double distance) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyDistance, distance);
debugPrint('📏 [BackgroundLocation] Distance threshold updated to ${distance}m');
debugPrint(
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
);
// Restart tracking if currently enabled
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
/// Type of response expected from a command
@@ -95,7 +94,8 @@ class BleCommandQueue {
Duration? timeout,
}) async {
// Determine timeout based on response type
final cmdTimeout = timeout ??
final cmdTimeout =
timeout ??
(responseType == CommandResponseType.data
? const Duration(seconds: 10)
: const Duration(seconds: 5));
@@ -114,7 +114,9 @@ class BleCommandQueue {
_queue.add(command);
onQueueSizeChanged?.call(_queue.length);
debugPrint('📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})');
debugPrint(
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
);
// Start processing if not already running
if (!_isProcessing) {
@@ -125,9 +127,13 @@ class BleCommandQueue {
return command.completer.future.timeout(
cmdTimeout,
onTimeout: () {
debugPrint('⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s');
debugPrint(
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
);
_pendingResponses.remove(commandCode);
throw TimeoutException('Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out');
throw TimeoutException(
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
);
},
);
}
@@ -152,7 +158,9 @@ class BleCommandQueue {
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
if (remainingDelay > 0) {
debugPrint('⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command');
debugPrint(
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
);
await Future.delayed(Duration(milliseconds: remainingDelay));
}
}
@@ -170,7 +178,9 @@ class BleCommandQueue {
// Execute command (handled by BleCommandSender)
// The completer will be completed by completeCommand() when response arrives
debugPrint('📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}');
debugPrint(
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
);
// For fire-and-forget commands, complete immediately
if (command.responseType == CommandResponseType.none) {
@@ -209,7 +219,9 @@ class BleCommandQueue {
void completeCommand<T>(int responseCode, T data) {
final command = _pendingResponses.remove(responseCode);
if (command != null) {
debugPrint('✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}');
debugPrint(
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
);
if (!command.completer.isCompleted) {
command.completer.complete(data);
}
@@ -219,10 +231,16 @@ class BleCommandQueue {
/// Complete a pending command with error
///
/// Called by BleResponseHandler when RESP_CODE_ERR is received
void completeCommandWithError(int commandCode, String error, {int? errorCode}) {
void completeCommandWithError(
int commandCode,
String error, {
int? errorCode,
}) {
final command = _pendingResponses.remove(commandCode);
if (command != null) {
debugPrint('❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)');
debugPrint(
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
);
if (!command.completer.isCompleted) {
command.completer.completeError(
Exception('Command failed: $error (error code: $errorCode)'),
@@ -245,7 +263,9 @@ class BleCommandQueue {
/// Clear all pending commands (use with caution)
void clear() {
debugPrint('🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)');
debugPrint(
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
);
// Complete all pending commands with error
for (final command in _pendingResponses.values) {

View File

@@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../meshcore_opcode_names.dart';
@@ -130,10 +129,13 @@ class BleCommandSender {
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
debugPrint(' Data size: ${data.length} bytes');
debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
debugPrint(
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
// Check if the characteristic supports write without response
final supportsWriteWithoutResponse = _rxCharacteristic!.properties.writeWithoutResponse;
final supportsWriteWithoutResponse =
_rxCharacteristic!.properties.writeWithoutResponse;
final supportsWrite = _rxCharacteristic!.properties.write;
if (supportsWriteWithoutResponse) {
@@ -160,15 +162,21 @@ class BleCommandSender {
}
/// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
void _logPacket(
Uint8List data,
PacketDirection direction, {
int? responseCode,
}) {
// Add new packet
_packetLogs.add(BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
));
_packetLogs.add(
BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
),
);
// Limit log size to prevent memory issues
if (_packetLogs.length > _maxLogSize) {

View File

@@ -17,24 +17,38 @@ import 'ble_command_queue.dart';
typedef OnContactCallback = void Function(Contact contact);
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
typedef OnTelemetryCallback =
void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
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 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, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
typedef OnChannelInfoCallback =
void Function(int channelIdx, String channelName);
typedef OnMessageEchoDetectedCallback =
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
/// Processes incoming responses from the BLE device
class BleResponseHandler {
@@ -118,12 +132,18 @@ class BleResponseHandler {
final responseCode = reader.readByte();
// Get opcode name for logging
final opcodeName = MeshCoreOpcodeNames.getOpcodeName(responseCode, isTx: false);
final opcodeHex = '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
final opcodeName = MeshCoreOpcodeNames.getOpcodeName(
responseCode,
isTx: false,
);
final opcodeHex =
'0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
debugPrint('📥 [RX] Received: $opcodeName ($opcodeHex)');
debugPrint(' Data size: ${data.length} bytes');
debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
debugPrint(
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
debugPrint(' Payload: ${reader.remainingBytesCount} bytes');
// Log RX packet (before processing so we capture everything)
@@ -254,7 +274,9 @@ class BleResponseHandler {
try {
final contact = FrameParser.parseContact(reader);
debugPrint(' ✅ [Contact] Parsed successfully: ${contact.advName}');
debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})');
debugPrint(
' outPathLen: ${contact.outPathLen} (${contact.pathDescription})',
);
_pendingContacts.add(contact);
onContactReceived?.call(contact);
} catch (e) {
@@ -416,7 +438,9 @@ class BleResponseHandler {
/// Handle LogRxData push - includes extensive decoding logic
void _handleLogRxData(BufferReader reader) {
try {
debugPrint(' [LogRxData] Parsing log rx data from over-the-air packet...');
debugPrint(
' [LogRxData] Parsing log rx data from over-the-air packet...',
);
final data = reader.readRemainingBytes();
if (data.length < 2) {
@@ -445,25 +469,35 @@ class BleResponseHandler {
final payloadType = (header >> 2) & 0x0F;
final pathLen = rawPacketData[1];
debugPrint(' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
debugPrint(
' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
);
if (pathLen > 0 && rawPacketData.length >= 2 + pathLen) {
final path = rawPacketData.sublist(2, 2 + pathLen);
final pathStr = path.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join('');
final pathStr = path
.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}')
.join('');
debugPrint(' Path ($pathLen hops): $pathStr');
// Highlight multi-hop packets
if (pathLen > 1) {
debugPrint(' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}');
debugPrint(
' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}',
);
}
// Check if our node hash is in the path
if (_ourNodeHash != null && path.contains(_ourNodeHash!)) {
debugPrint(' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅');
debugPrint(
' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅',
);
if (path[0] == _ourNodeHash) {
debugPrint(' 👉 WE are the original sender!');
} else {
debugPrint(' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network');
debugPrint(
' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network',
);
}
} else {
debugPrint(' Does NOT contain our hash (not our message)');
@@ -526,7 +560,11 @@ class BleResponseHandler {
hash = hash & 0xFFFFFFFF; // Keep 32-bit
}
if (packet.length > 16) {
for (int i = packet.length ~/ 2; i < packet.length ~/ 2 + 8 && i < packet.length; i++) {
for (
int i = packet.length ~/ 2;
i < packet.length ~/ 2 + 8 && i < packet.length;
i++
) {
hash = ((hash << 5) - hash) + packet[i];
hash = hash & 0xFFFFFFFF;
}
@@ -543,7 +581,9 @@ class BleResponseHandler {
/// Check if received packet is an echo of a sent message
void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) {
try {
debugPrint(' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes');
debugPrint(
' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes',
);
// Need at least header + path_len
if (rawPacket.length < 2) {
@@ -553,7 +593,9 @@ class BleResponseHandler {
final header = rawPacket[0];
final payloadType = (header >> 2) & 0x0F;
debugPrint(' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
debugPrint(
' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
);
if (payloadType != 0x05) {
debugPrint(' ⚠️ [Echo] Not GRP_TXT, ignoring');
return; // Only track GRP_TXT
@@ -568,10 +610,13 @@ class BleResponseHandler {
// Extract path for unique echo tracking
final path = rawPacket.sublist(2, 2 + pathLen);
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final pathSignature = path
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
final containsOurHash =
_ourNodeHash != null && path.contains(_ourNodeHash!);
if (!containsOurHash) {
// This packet doesn't have our hash in the path, so it's not our message
return;
@@ -599,9 +644,16 @@ class BleResponseHandler {
debugPrint(' Unique paths: ${tracker.uniqueEchoPaths.length}');
// Notify callback
onMessageEchoDetected?.call(tracker.messageId, tracker.echoCount, snrRaw, rssiDbm);
onMessageEchoDetected?.call(
tracker.messageId,
tracker.echoCount,
snrRaw,
rssiDbm,
);
} else {
debugPrint(' ♻️ [Echo] Duplicate path (already counted): $pathSignature');
debugPrint(
' ♻️ [Echo] Duplicate path (already counted): $pathSignature',
);
}
}
@@ -631,7 +683,9 @@ class BleResponseHandler {
// Store by message ID temporarily
_sentMessageTrackers[messageId] = tracker;
debugPrint(' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)');
debugPrint(
' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)',
);
debugPrint(' 📊 [Echo] Total trackers: ${_sentMessageTrackers.length}');
// Cleanup if too many trackers
@@ -649,8 +703,12 @@ class BleResponseHandler {
/// Set our node hash for packet identification
void setOurNodeHash(int nodeHash) {
_ourNodeHash = nodeHash;
debugPrint(' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}');
debugPrint(' [Echo] Will track packets containing our hash in the path');
debugPrint(
' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}',
);
debugPrint(
' [Echo] Will track packets containing our hash in the path',
);
}
/// Associate a captured packet with a sent message
@@ -666,7 +724,9 @@ class BleResponseHandler {
/// [3+] = rest of path + encrypted payload
void _associatePacketWithSentMessage(Uint8List rawPacket) {
try {
debugPrint(' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}');
debugPrint(
' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}',
);
// Need at least 3 bytes: header + path_len + first path byte
if (rawPacket.length < 3) {
@@ -677,8 +737,11 @@ class BleResponseHandler {
// Check if this is a GRP_TXT packet (payload type = 0x05)
final header = rawPacket[0];
final payloadType = (header >> 2) & 0x0F;
debugPrint(' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}');
if (payloadType != 0x05) { // Not a group message
debugPrint(
' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}',
);
if (payloadType != 0x05) {
// Not a group message
debugPrint(' ⚠️ [Echo] Not GRP_TXT, skipping association');
return;
}
@@ -694,16 +757,21 @@ class BleResponseHandler {
// Extract the path from the packet for unique echo tracking
final path = rawPacket.sublist(2, 2 + pathLen);
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final pathSignature = path
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
final containsOurHash =
_ourNodeHash != null && path.contains(_ourNodeHash!);
if (!containsOurHash) {
// This packet doesn't have our hash in the path, so it's not our message
return;
}
debugPrint(' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature');
debugPrint(
' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature',
);
// Extract encrypted payload (everything after path)
final payloadStart = 2 + pathLen;
@@ -754,13 +822,17 @@ class BleResponseHandler {
/// Remove expired trackers
void _cleanupExpiredTrackers() {
final expiredCount = _sentMessageTrackers.values.where((t) => t.isExpired).length;
final expiredCount = _sentMessageTrackers.values
.where((t) => t.isExpired)
.length;
if (expiredCount > 0) {
debugPrint(' 🧹 [Echo] Cleaning up $expiredCount expired tracker(s)');
}
_sentMessageTrackers.removeWhere((key, tracker) {
if (tracker.isExpired && tracker.packetHashHex == 'pending') {
debugPrint(' ⏱️ [Echo] Tracker expired without capturing: ${tracker.messageId}');
debugPrint(
' ⏱️ [Echo] Tracker expired without capturing: ${tracker.messageId}',
);
}
return tracker.isExpired;
});
@@ -774,7 +846,9 @@ class BleResponseHandler {
final sortedEntries = _sentMessageTrackers.entries.toList()
..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime));
final toRemove = sortedEntries.take(_sentMessageTrackers.length - _maxTrackers);
final toRemove = sortedEntries.take(
_sentMessageTrackers.length - _maxTrackers,
);
for (final entry in toRemove) {
_sentMessageTrackers.remove(entry.key);
}
@@ -787,7 +861,9 @@ class BleResponseHandler {
try {
final contact = FrameParser.parseContact(reader);
debugPrint(' ✅ [NewAdvert] Parsed successfully: ${contact.advName}');
debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})');
debugPrint(
' outPathLen: ${contact.outPathLen} (${contact.pathDescription})',
);
onContactReceived?.call(contact);
} catch (e) {
debugPrint(' ❌ [NewAdvert] Parsing error: $e');
@@ -937,7 +1013,7 @@ class BleResponseHandler {
final channelIdx = info['channelIdx'] as int;
final channelName = info['channelName'] as String;
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "${channelName}"');
debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "$channelName"');
onChannelInfoReceived?.call(channelIdx, channelName);
}
} catch (e) {
@@ -956,14 +1032,17 @@ class BleResponseHandler {
// Complete any pending ACK command with error
_commandQueue?.completeCommandWithError(
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
errorMsg,
errorCode: errorCode,
);
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
debugPrint(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
if (errorCode == 2) {
// ERR_CODE_NOT_FOUND
debugPrint(
' ⚠️ [Error] Contact not found in radio - attempting auto-recovery',
);
onContactNotFound?.call(_lastContactPublicKey);
}
@@ -980,14 +1059,20 @@ class BleResponseHandler {
}
/// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
_packetLogs.add(BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
));
void _logPacket(
Uint8List data,
PacketDirection direction, {
int? responseCode,
}) {
_packetLogs.add(
BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
),
);
if (_packetLogs.length > _maxLogSize) {
_packetLogs.removeAt(0);

View File

@@ -12,7 +12,9 @@ class CayenneLppParser {
static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...');
debugPrint(' Data length: ${data.length} bytes');
debugPrint(' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
debugPrint(
' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
final reader = BufferReader(data);
@@ -28,13 +30,17 @@ class CayenneLppParser {
while (reader.hasRemaining) {
try {
fieldCount++;
debugPrint(' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}');
debugPrint(
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
);
final channel = reader.readByte();
debugPrint(' Channel: $channel');
final type = reader.readByte();
debugPrint(' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})');
debugPrint(
' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})',
);
switch (type) {
case MeshCoreConstants.lppDigitalInput:
@@ -59,7 +65,9 @@ class CayenneLppParser {
if (channel == 0 || channel == 1) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)');
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
break;
@@ -87,14 +95,16 @@ class CayenneLppParser {
final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue');
debugPrint(' Temperature: ${temperature?.toStringAsFixed(1)}°C');
debugPrint(
' Temperature: ${temperature.toStringAsFixed(1)}°C',
);
break;
case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte();
humidity = rawValue / 2.0;
debugPrint(' Humidity (raw): $rawValue');
debugPrint(' Humidity: ${humidity?.toStringAsFixed(1)}%');
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
break;
case MeshCoreConstants.lppAccelerometer:
@@ -102,14 +112,18 @@ class CayenneLppParser {
final y = reader.readInt16BE() / 1000.0;
final z = reader.readInt16BE() / 1000.0;
debugPrint(' Accelerometer: x=$x, y=$y, z=$z');
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
extraSensorData['accelerometer_$channel'] = {
'x': x,
'y': y,
'z': z,
};
break;
case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0;
debugPrint(' Barometer (raw): $rawValue');
debugPrint(' Barometer: ${pressure?.toStringAsFixed(1)} hPa');
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
break;
case MeshCoreConstants.lppVoltageSensor:
@@ -120,7 +134,9 @@ class CayenneLppParser {
// Treat voltage sensor as battery reading
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)');
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
break;
case MeshCoreConstants.lppGyrometer:
@@ -138,14 +154,18 @@ class CayenneLppParser {
final lat = rawLat / 1000000.0;
final lon = rawLon / 1000000.0;
final alt = rawAlt / 100.0;
debugPrint(' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt');
debugPrint(' GPS Location: ${lat}°, ${lon}°, altitude=${alt}m');
debugPrint(
' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt',
);
debugPrint(' GPS Location: $lat°, $lon°, altitude=${alt}m');
gpsLocation = LatLng(lat, lon);
extraSensorData['altitude_$channel'] = alt;
break;
default:
debugPrint(' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes');
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
);
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount);
break;
@@ -159,9 +179,15 @@ class CayenneLppParser {
debugPrint(' Parsed $fieldCount fields');
debugPrint(' ✅ [CayenneLPP] Parsing complete');
debugPrint(' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}');
debugPrint(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}');
debugPrint(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}');
debugPrint(
' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}',
);
debugPrint(
' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}',
);
debugPrint(
' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}',
);
// IMPORTANT: Cayenne LPP format does NOT include a timestamp field.
// We use DateTime.now() as the timestamp, which represents when the data
@@ -173,7 +199,9 @@ class CayenneLppParser {
// - The actual age of the telemetry data cannot be determined from the LPP format
// - Devices may cache telemetry for hours and send it later when requested
final parseTimestamp = DateTime.now();
debugPrint(' Timestamp: $parseTimestamp (parse time, NOT device collection time)');
debugPrint(
' Timestamp: $parseTimestamp (parse time, NOT device collection time)',
);
return ContactTelemetry(
gpsLocation: gpsLocation,

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
@@ -17,7 +16,9 @@ class ContactStorageService {
final prefs = await SharedPreferences.getInstance();
// Convert contacts to JSON
final jsonList = contacts.map((contact) => _contactToJson(contact)).toList();
final jsonList = contacts
.map((contact) => _contactToJson(contact))
.toList();
// Limit to max stored contacts (keep most recent)
final limitedList = jsonList.length > _maxStoredContacts
@@ -27,7 +28,9 @@ class ContactStorageService {
final jsonString = jsonEncode(limitedList);
await prefs.setString(_contactsKey, jsonString);
debugPrint('✅ [ContactStorage] Saved ${limitedList.length} contacts to storage');
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contacts: $e');
}
@@ -55,16 +58,23 @@ class ContactStorageService {
// Filter out contacts with the excluded public key
final filteredContacts = excludePublicKey != null
? contacts.where((contact) {
final matches = _publicKeysMatch(contact.publicKey, excludePublicKey);
final matches = _publicKeysMatch(
contact.publicKey,
excludePublicKey,
);
if (matches) {
debugPrint(' [ContactStorage] Excluding contact with matching public key: ${contact.advName}');
debugPrint(
' [ContactStorage] Excluding contact with matching public key: ${contact.advName}',
);
}
return !matches;
}).toList()
: contacts;
debugPrint('✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}');
debugPrint(
'✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}',
);
return filteredContacts;
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contacts: $e');
@@ -99,11 +109,7 @@ class ContactStorageService {
final jsonString = prefs.getString(_contactsKey);
if (jsonString == null || jsonString.isEmpty) {
return {
'contactCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
final sizeBytes = jsonString.length;
@@ -116,11 +122,7 @@ class ContactStorageService {
};
} catch (e) {
debugPrint('❌ [ContactStorage] Error getting storage stats: $e');
return {
'contactCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
}
@@ -137,7 +139,9 @@ class ContactStorageService {
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null ? _telemetryToJson(contact.telemetry!) : null,
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
};
}
@@ -145,7 +149,9 @@ class ContactStorageService {
Contact? _contactFromJson(Map<String, dynamic> json) {
try {
return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
type: ContactType.fromValue(json['type'] as int),
flags: json['flags'] as int,
outPathLen: json['outPathLen'] as int,
@@ -200,7 +206,8 @@ class ContactStorageService {
humidity: json['humidity'] as double?,
pressure: json['pressure'] as double?,
timestamp: DateTime.fromMillisecondsSinceEpoch(
json['timestampMillis'] as int),
json['timestampMillis'] as int,
),
extraSensorData: json['extraSensorData'] as Map<String, dynamic>?,
);
} catch (e) {

View File

@@ -129,7 +129,7 @@ class MbtilesService {
final mbtiles = MbTiles(mbtilesPath: file.path);
// Get metadata from MBTiles
final metadata = await mbtiles.getMetadata();
final metadata = mbtiles.getMetadata();
// Convert bounds object to string if available
String? boundsStr;
@@ -222,7 +222,7 @@ class MbtilesService {
final mbtiles = MbTiles(mbtilesPath: file.path);
// Try to get metadata to check for compression hints
final metadata = await mbtiles.getMetadata();
final metadata = mbtiles.getMetadata();
final format = metadata.format;
// For Geofabrik files, format is 'pbf' and data is gzipped

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/contact.dart';
@@ -15,26 +14,41 @@ import 'meshcore_constants.dart';
typedef OnContactCallback = void Function(Contact contact);
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
typedef OnTelemetryCallback =
void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag);
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 OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
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 OnMessageSentCallback =
void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
typedef OnMessageDeliveredCallback =
void Function(int ackCode, int roundTripTimeMs);
typedef OnMessageEchoDetectedCallback =
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
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, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
typedef OnChannelInfoCallback =
void Function(int channelIdx, String channelName);
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// MeshCore BLE Service - coordinates BLE communication components
@@ -89,7 +103,9 @@ class MeshCoreBleService {
onError?.call(error);
};
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
debugPrint('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts');
debugPrint(
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
);
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
};
_connectionManager.onRssiUpdate = (rssi) {
@@ -136,9 +152,10 @@ class MeshCoreBleService {
_responseHandler.onMessageWaiting = () {
onMessageWaiting?.call();
};
_responseHandler.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) {
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_responseHandler.onLoginSuccess =
(publicKeyPrefix, permissions, isAdmin, tag) {
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_responseHandler.onLoginFail = (publicKeyPrefix) {
onLoginFail?.call(publicKeyPrefix);
};
@@ -148,15 +165,17 @@ class MeshCoreBleService {
_responseHandler.onPathUpdated = (publicKey) {
onPathUpdated?.call(publicKey);
};
_responseHandler.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode) {
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode);
};
_responseHandler.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode) {
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode);
};
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_responseHandler.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onMessageEchoDetected =
(messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
@@ -189,13 +208,18 @@ class MeshCoreBleService {
int get txPacketCount => _commandSender.txPacketCount;
List<BlePacketLog> get packetLogs {
// Merge logs from both sender and handler
final allLogs = [..._commandSender.packetLogs, ..._responseHandler.packetLogs];
final allLogs = [
..._commandSender.packetLogs,
..._responseHandler.packetLogs,
];
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return allLogs;
}
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({Duration timeout = const Duration(seconds: 10)}) {
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) {
return _connectionManager.scanForDevices(timeout: timeout);
}
@@ -212,7 +236,9 @@ class MeshCoreBleService {
// Setup response handler with TX characteristic
if (_connectionManager.txCharacteristic != null) {
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
_responseHandler.subscribeToNotifications(
_connectionManager.txCharacteristic!,
);
}
// Send initial device query and wait for responses
@@ -240,20 +266,26 @@ class MeshCoreBleService {
Future<void> _sendDeviceQuery() async {
// STEP 1: Send device query FIRST to get device capabilities
// This is the first command to send per protocol documentation
debugPrint('🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...');
final deviceInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildDeviceQuery(),
MeshCoreConstants.respDeviceInfo,
debugPrint(
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
);
final deviceInfo = await _commandSender
.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildDeviceQuery(),
MeshCoreConstants.respDeviceInfo,
);
debugPrint(
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
);
debugPrint('✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}');
// STEP 2: Send app start to initialize the app session
// This is the first command after connection per protocol documentation
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
final selfInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildAppStart(),
MeshCoreConstants.respSelfInfo,
);
final selfInfo = await _commandSender
.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildAppStart(),
MeshCoreConstants.respSelfInfo,
);
debugPrint('✅ [Service] Self info received: node initialized');
// STEP 3: Set device clock AFTER initialization
@@ -278,7 +310,9 @@ class MeshCoreBleService {
Future<void> addOrUpdateContact(Contact contact) async {
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
debugPrint(' Name: ${contact.advName}');
debugPrint(' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
debugPrint(
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' Type: ${contact.type} (${contact.type.value})');
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
@@ -300,18 +334,22 @@ class MeshCoreBleService {
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey,
text: text,
textType: textType,
attempt: attempt,
));
await _commandSender.writeData(
FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey,
text: text,
textType: textType,
attempt: attempt,
),
);
}
/// Send flood-mode text message to channel
/// Track a sent channel message for echo detection
void trackSentChannelMessage(String messageId) {
debugPrint('🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId');
debugPrint(
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
);
_responseHandler.trackSentMessage(messageId, null);
}
@@ -333,20 +371,24 @@ class MeshCoreBleService {
// Channel messages use fire-and-forget (no ACK expected)
// The firmware responds with RESP_CODE_OK but we don't wait for it
await _commandSender.writeData(FrameBuilder.buildSendChannelTxtMsg(
channelIdx: channelIdx,
text: text,
textType: textType,
));
await _commandSender.writeData(
FrameBuilder.buildSendChannelTxtMsg(
channelIdx: channelIdx,
text: text,
textType: textType,
),
);
}
/// Request telemetry from contact (deprecated)
@Deprecated('Use sendBinaryRequest() instead for better functionality')
Future<void> requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async {
await _commandSender.writeData(FrameBuilder.buildSendTelemetryReq(
contactPublicKey,
zeroHop: zeroHop,
));
Future<void> requestTelemetry(
Uint8List contactPublicKey, {
bool zeroHop = false,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
);
}
/// Send binary request to contact
@@ -354,10 +396,12 @@ class MeshCoreBleService {
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
await _commandSender.writeData(FrameBuilder.buildSendBinaryReq(
contactPublicKey: contactPublicKey,
requestData: requestData,
));
await _commandSender.writeData(
FrameBuilder.buildSendBinaryReq(
contactPublicKey: contactPublicKey,
requestData: requestData,
),
);
}
/// Get battery voltage and storage information
@@ -388,12 +432,16 @@ class MeshCoreBleService {
/// Send self advertisement packet to mesh network
Future<void> sendSelfAdvert({bool floodMode = true}) async {
await _commandSender.writeData(FrameBuilder.buildSendSelfAdvert(floodMode: floodMode));
await _commandSender.writeData(
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
);
}
/// Set advertised name
Future<void> setAdvertName(String name) async {
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertName(name));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetAdvertName(name),
);
}
/// Set advertised latitude and longitude
@@ -402,10 +450,12 @@ class MeshCoreBleService {
required double longitude,
}) async {
// This command returns OK (0x00) response, so wait for acknowledgment
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon(
latitude: latitude,
longitude: longitude,
));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetAdvertLatLon(
latitude: latitude,
longitude: longitude,
),
);
}
/// Set radio parameters
@@ -415,17 +465,21 @@ class MeshCoreBleService {
required int spreadingFactor,
required int codingRate,
}) async {
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
),
);
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetTxPower(powerDbm));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetTxPower(powerDbm),
);
}
/// Set other parameters
@@ -435,12 +489,14 @@ class MeshCoreBleService {
required int advertLocationPolicy,
int multiAcks = 0,
}) async {
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetOtherParams(
manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes,
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetOtherParams(
manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes,
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
),
);
}
/// Send login request to room or repeater
@@ -453,37 +509,55 @@ class MeshCoreBleService {
}
debugPrint('🔐 [BLE] Preparing login request:');
debugPrint(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
debugPrint(' Password: ${"*" * password.length} (${password.length} chars)');
debugPrint(
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(
' Password: ${"*" * password.length} (${password.length} chars)',
);
await _commandSender.writeData(FrameBuilder.buildSendLogin(
roomPublicKey: roomPublicKey,
password: password,
));
await _commandSender.writeData(
FrameBuilder.buildSendLogin(
roomPublicKey: roomPublicKey,
password: password,
),
);
}
/// Send status request to repeater or sensor node
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
debugPrint('📊 [BLE] Preparing status request:');
debugPrint(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
debugPrint(
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(FrameBuilder.buildSendStatusReq(contactPublicKey));
await _commandSender.writeData(
FrameBuilder.buildSendStatusReq(contactPublicKey),
);
}
/// Reset path for a contact - forces next message to flood and re-learn route
Future<void> resetPath(Uint8List contactPublicKey) async {
debugPrint('🔄 [BLE] Resetting path for contact:');
debugPrint(' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
debugPrint(
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey));
await _commandSender.writeData(
FrameBuilder.buildResetPath(contactPublicKey),
);
}
/// Remove a contact from the companion radio
Future<void> removeContact(Uint8List contactPublicKey) async {
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
debugPrint(' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}');
debugPrint(
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(FrameBuilder.buildRemoveContact(contactPublicKey));
await _commandSender.writeData(
FrameBuilder.buildRemoveContact(contactPublicKey),
);
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
}
@@ -506,11 +580,13 @@ class MeshCoreBleService {
debugPrint(' Channel name: $channelName');
debugPrint(' Secret length: ${secret.length} bytes');
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetChannel(
channelIdx: channelIdx,
channelName: channelName,
secret: secret,
));
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetChannel(
channelIdx: channelIdx,
channelName: channelName,
secret: secret,
),
);
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent successfully');
}

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
@@ -27,7 +26,9 @@ class MessageStorageService {
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
debugPrint('✅ [MessageStorage] Saved ${limitedList.length} messages to storage');
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving messages: $e');
}
@@ -51,7 +52,9 @@ class MessageStorageService {
.cast<Message>()
.toList();
debugPrint('✅ [MessageStorage] Loaded ${messages.length} messages from storage');
debugPrint(
'✅ [MessageStorage] Loaded ${messages.length} messages from storage',
);
return messages;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading messages: $e');
@@ -77,11 +80,7 @@ class MessageStorageService {
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return {
'messageCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
final sizeBytes = jsonString.length;
@@ -94,11 +93,7 @@ class MessageStorageService {
};
} catch (e) {
debugPrint('❌ [MessageStorage] Error getting storage stats: $e');
return {
'messageCount': 0,
'storageSizeBytes': 0,
'storageSizeKB': 0,
};
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
}
}
@@ -144,7 +139,8 @@ class MessageStorageService {
),
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
? Uint8List.fromList(
base64Decode(json['senderPublicKeyPrefix'] as String))
base64Decode(json['senderPublicKeyPrefix'] as String),
)
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
@@ -158,12 +154,13 @@ class MessageStorageService {
orElse: () => SarMarkerType.unknown,
)
: null,
sarGpsCoordinates: json['sarGpsLat'] != null &&
json['sarGpsLon'] != null
sarGpsCoordinates:
json['sarGpsLat'] != null && json['sarGpsLon'] != null
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
: null,
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int),
json['receivedAtMillis'] as int,
),
senderName: json['senderName'] as String?,
deliveryStatus: json['deliveryStatus'] != null
? MessageDeliveryStatus.values.firstWhere(
@@ -176,11 +173,13 @@ class MessageStorageService {
roundTripTimeMs: json['roundTripTimeMs'] as int?,
deliveredAt: json['deliveredAtMillis'] != null
? DateTime.fromMillisecondsSinceEpoch(
json['deliveredAtMillis'] as int)
json['deliveredAtMillis'] as int,
)
: null,
recipientPublicKey: json['recipientPublicKey'] != null
? Uint8List.fromList(
base64Decode(json['recipientPublicKey'] as String))
base64Decode(json['recipientPublicKey'] as String),
)
: null,
isRead: json['isRead'] as bool? ?? false,
);

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
@@ -44,7 +43,9 @@ class NotificationService {
tz.initializeTimeZones();
// Android initialization settings
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
// iOS initialization settings
final darwinSettings = DarwinInitializationSettings(
@@ -85,25 +86,34 @@ class NotificationService {
try {
// iOS permissions
final iosPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
critical: true, // Request critical alert permission for urgent SAR notifications
critical:
true, // Request critical alert permission for urgent SAR notifications
);
_permissionGranted = granted ?? false;
debugPrint('📱 [NotificationService] iOS permissions granted: $_permissionGranted');
debugPrint(
'📱 [NotificationService] iOS permissions granted: $_permissionGranted',
);
}
// Android 13+ permissions
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
_permissionGranted = granted ?? false;
debugPrint('🤖 [NotificationService] Android permissions granted: $_permissionGranted');
debugPrint(
'🤖 [NotificationService] Android permissions granted: $_permissionGranted',
);
}
} catch (e) {
debugPrint('⚠️ [NotificationService] Error requesting permissions: $e');
@@ -114,7 +124,9 @@ class NotificationService {
Future<void> _createNotificationChannels() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin == null) return;
@@ -152,7 +164,9 @@ class NotificationService {
/// Handle notification tap (foreground)
void _onNotificationResponse(NotificationResponse response) {
debugPrint('🔔 [NotificationService] Notification tapped: ${response.payload}');
debugPrint(
'🔔 [NotificationService] Notification tapped: ${response.payload}',
);
// TODO: Navigate to map tab and show SAR marker
// This would require a callback to the app layer
}
@@ -166,18 +180,23 @@ class NotificationService {
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint('⚠️ [NotificationService] Not initialized, skipping notification');
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint('⚠️ [NotificationService] Permission not granted, skipping notification');
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId = _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
final notificationId =
_sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = _buildNotificationTitle(type, localizations);
@@ -222,7 +241,8 @@ class NotificationService {
badgeNumber: 1,
threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT',
interruptionLevel: InterruptionLevel.critical, // Critical alert (bypasses silent mode)
interruptionLevel:
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
);
// Combined notification details
@@ -250,7 +270,10 @@ class NotificationService {
}
/// Build notification title based on SAR marker type
String _buildNotificationTitle(SarMarkerType type, AppLocalizations? localizations) {
String _buildNotificationTitle(
SarMarkerType type,
AppLocalizations? localizations,
) {
if (localizations == null) {
return '🚨 ${type.displayName} Detected';
}
@@ -330,27 +353,33 @@ class NotificationService {
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint('⚠️ [NotificationService] Not initialized, skipping notification');
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint('⚠️ [NotificationService] Permission not granted, skipping notification');
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId = _messageNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
final notificationId =
_messageNotificationId +
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
final body = messageText.length > 200
? '${messageText.substring(0, 200)}...'
@@ -381,7 +410,9 @@ class NotificationService {
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: isChannelMessage ? 'channel_messages' : 'direct_messages',
threadIdentifier: isChannelMessage
? 'channel_messages'
: 'direct_messages',
subtitle: senderName,
);
@@ -404,7 +435,9 @@ class NotificationService {
debugPrint(' Sender: $senderName');
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
} catch (e) {
debugPrint('❌ [NotificationService] Error showing message notification: $e');
debugPrint(
'❌ [NotificationService] Error showing message notification: $e',
);
}
}
@@ -432,7 +465,9 @@ class NotificationService {
Future<bool> areNotificationsEnabled() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final enabled = await androidPlugin.areNotificationsEnabled();
return enabled ?? false;
@@ -441,7 +476,9 @@ class NotificationService {
// For iOS, assume enabled if permission was granted
return _permissionGranted;
} catch (e) {
debugPrint('⚠️ [NotificationService] Error checking notification status: $e');
debugPrint(
'⚠️ [NotificationService] Error checking notification status: $e',
);
return false;
}
}
@@ -451,7 +488,9 @@ class NotificationService {
try {
return await _notificationsPlugin.pendingNotificationRequests();
} catch (e) {
debugPrint('⚠️ [NotificationService] Error getting pending notifications: $e');
debugPrint(
'⚠️ [NotificationService] Error getting pending notifications: $e',
);
return [];
}
}