feat: Implement automatic reconnection handling and enhance message processing with sender identification

This commit is contained in:
Janez T
2025-10-15 14:53:55 +02:00
parent f50b429e34
commit 829b8605eb
9 changed files with 371 additions and 54 deletions

View File

@@ -165,6 +165,24 @@ class Message {
/// Check if this is a sent message (not received) /// Check if this is a sent message (not received)
bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received; bool get isSentMessage => deliveryStatus != MessageDeliveryStatus.received;
/// Check if this message is from self (own message)
/// [selfPublicKey] - the device's own public key (first 6 bytes)
bool isFromSelf(Uint8List? selfPublicKey) {
if (selfPublicKey == null || selfPublicKey.length < 6) return false;
// Compare sender public key prefix with self public key prefix
if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) {
return senderPublicKeyPrefix![0] == selfPublicKey[0] &&
senderPublicKeyPrefix![1] == selfPublicKey[1] &&
senderPublicKeyPrefix![2] == selfPublicKey[2] &&
senderPublicKeyPrefix![3] == selfPublicKey[3] &&
senderPublicKeyPrefix![4] == selfPublicKey[4] &&
senderPublicKeyPrefix![5] == selfPublicKey[5];
}
return false;
}
Message copyWith({ Message copyWith({
String? id, String? id,
MessageType? messageType, MessageType? messageType,

View File

@@ -53,7 +53,24 @@ class AppProvider with ChangeNotifier {
// When a message is received // When a message is received
connectionProvider.onMessageReceived = (message) { connectionProvider.onMessageReceived = (message) {
messagesProvider.addMessage(message); // Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage(
message,
contactLookup: (name) {
// Find contact by name and return their public key hex (first 12 chars for 6 bytes)
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (e) {
// No matching contact found
return '';
}
},
);
// Optionally update sender name from contacts // Optionally update sender name from contacts
if (message.senderPublicKeyPrefix != null) { if (message.senderPublicKeyPrefix != null) {

View File

@@ -42,6 +42,11 @@ class ConnectionProvider with ChangeNotifier {
int get rxPacketCount => _bleService.rxPacketCount; int get rxPacketCount => _bleService.rxPacketCount;
int get txPacketCount => _bleService.txPacketCount; int get txPacketCount => _bleService.txPacketCount;
// Reconnection state (exposed from BLE service)
bool get isReconnecting => _bleService.isReconnecting;
int get reconnectionAttempt => _bleService.reconnectionAttempt;
int get maxReconnectionAttempts => _bleService.maxReconnectionAttempts;
// Message sync state // Message sync state
bool _noMoreMessages = false; bool _noMoreMessages = false;
@@ -75,15 +80,24 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
connectionState: isConnected connectionState: isConnected
? ConnectionState.connected ? ConnectionState.connected
: ConnectionState.disconnected, : (_bleService.isReconnecting
? ConnectionState.connecting
: ConnectionState.disconnected),
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
); );
print(' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}'); print(' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}');
print(' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}'); print(' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}');
print(' isReconnecting: ${_bleService.isReconnecting}');
notifyListeners(); notifyListeners();
print(' Notified listeners'); print(' Notified listeners');
}; };
_bleService.onReconnectionAttempt = (attemptNumber, maxAttempts) {
print('🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts');
// Notify UI to update reconnection status display
notifyListeners();
};
_bleService.onError = (error) { _bleService.onError = (error) {
print('⚠️ [Provider] BLE error received: $error'); print('⚠️ [Provider] BLE error received: $error');
print(' Current connection state: ${_deviceInfo.connectionState}'); print(' Current connection state: ${_deviceInfo.connectionState}');
@@ -400,6 +414,13 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Cancel ongoing reconnection attempts
/// This is useful when the user wants to manually disconnect during reconnection
void cancelReconnection() {
print('🔴 [Provider] User requested cancellation of reconnection');
disconnect();
}
/// Get contacts from device /// Get contacts from device
Future<void> getContacts() async { Future<void> getContacts() async {
if (!_bleService.isConnected) { if (!_bleService.isConnected) {
@@ -479,6 +500,8 @@ class ConnectionProvider with ChangeNotifier {
/// Send channel message /// Send channel message
/// ///
/// [messageId] - optional message ID to track delivery status /// [messageId] - optional message ID to track delivery status
/// Note: Channel messages are ephemeral (not persisted), so they're marked
/// as "sent" immediately upon receiving OK response from the device.
Future<void> sendChannelMessage({ Future<void> sendChannelMessage({
required int channelIdx, required int channelIdx,
required String text, required String text,
@@ -491,16 +514,19 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId);
print(' Added message ID to pending queue BEFORE sending: $messageId');
}
await _bleService.sendChannelMessage( await _bleService.sendChannelMessage(
channelIdx: channelIdx, channelIdx: channelIdx,
text: text, text: text,
); );
// Channel messages are ephemeral (not persisted) - mark as "sent" immediately
// They don't have ACK/TAG mechanism like direct messages
if (messageId != null) {
print('✅ [Provider] Channel message sent successfully - marking as sent: $messageId');
// Use a dummy ACK tag (0) and timeout (0) for channel messages
// This will trigger the callback to mark the message as "sent"
onMessageSent?.call(messageId, 0, 0);
}
} catch (e) { } catch (e) {
_error = 'Failed to send channel message: $e'; _error = 'Failed to send channel message: $e';
notifyListeners(); notifyListeners();

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
@@ -79,15 +80,41 @@ class MessagesProvider with ChangeNotifier {
} }
/// Add a message /// Add a message
void addMessage(Message message) { /// If [contactLookup] function is provided, it will be used to match channel
/// message senders with known contacts by name
void addMessage(Message message, {String Function(String name)? contactLookup}) {
// Always enhance message with SAR parser to detect SAR markers // Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message); final enhancedMessage = SarMessageParser.enhanceMessage(message);
// For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage;
if (enhancedMessage.isChannelMessage &&
enhancedMessage.senderName != null &&
contactLookup != null) {
// Look up contact public key by name
final publicKeyHex = contactLookup(enhancedMessage.senderName!);
if (publicKeyHex.isNotEmpty) {
// Convert hex string to bytes (first 6 bytes)
final publicKeyBytes = <int>[];
for (int i = 0; i < 12 && i < publicKeyHex.length; i += 2) {
final byteString = publicKeyHex.substring(i, i + 2);
publicKeyBytes.add(int.parse(byteString, radix: 16));
}
if (publicKeyBytes.length == 6) {
// Add public key prefix to message
finalMessage = enhancedMessage.copyWith(
senderPublicKeyPrefix: Uint8List.fromList(publicKeyBytes),
);
}
}
}
// Debug: Check if message is SAR // Debug: Check if message is SAR
if (message.text.startsWith('S:')) { if (message.text.startsWith('S:')) {
print('🔍 [MessagesProvider] Processing SAR message: ${message.text}'); print('🔍 [MessagesProvider] Processing SAR message: ${message.text}');
print(' isSarMarker: ${enhancedMessage.isSarMarker}'); print(' isSarMarker: ${finalMessage.isSarMarker}');
print(' sarMarkerType: ${enhancedMessage.sarMarkerType}'); print(' sarMarkerType: ${finalMessage.sarMarkerType}');
} }
// Check for duplicates before adding // Check for duplicates before adding
@@ -95,17 +122,17 @@ class MessagesProvider with ChangeNotifier {
// - Mesh network retransmissions // - Mesh network retransmissions
// - Multiple paths in the network // - Multiple paths in the network
// - Syncing messages from device queue // - Syncing messages from device queue
if (_isDuplicate(enhancedMessage)) { if (_isDuplicate(finalMessage)) {
print('⚠️ [MessagesProvider] Duplicate message detected, skipping: ${enhancedMessage.id}'); print('⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}');
print(' Text: ${enhancedMessage.text.substring(0, enhancedMessage.text.length > 50 ? 50 : enhancedMessage.text.length)}...'); print(' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...');
return; // Skip duplicate return; // Skip duplicate
} }
_messages.add(enhancedMessage); _messages.add(finalMessage);
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (enhancedMessage.isSarMarker) { if (finalMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker(); final marker = finalMessage.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
@@ -348,33 +375,40 @@ class MessagesProvider with ChangeNotifier {
if (index != -1) { if (index != -1) {
final message = _messages[index]; final message = _messages[index];
print(' Current status: ${message.deliveryStatus}'); print(' Current status: ${message.deliveryStatus}');
print(' Message type: ${message.messageType}');
print(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...'); print(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent, deliveryStatus: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag, expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs, suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
// Track by ACK tag for matching with delivery confirmation // Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
_pendingSentMessages[expectedAckTag] = updatedMessage; if (expectedAckTag > 0 && suggestedTimeoutMs > 0) {
print(' ✅ Added to pending messages map with ACK: $expectedAckTag'); // Track by ACK tag for matching with delivery confirmation
print(' Total pending messages: ${_pendingSentMessages.length}'); _pendingSentMessages[expectedAckTag] = updatedMessage;
print(' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}'); print(' ✅ Added to pending messages map with ACK: $expectedAckTag');
print(' Total pending messages: ${_pendingSentMessages.length}');
print(' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}');
// Start timeout timer // Start timeout timer
_timeoutTimers[expectedAckTag] = Timer( _timeoutTimers[expectedAckTag] = Timer(
Duration(milliseconds: suggestedTimeoutMs), Duration(milliseconds: suggestedTimeoutMs),
() { () {
print('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)'); print('⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)');
if (_pendingSentMessages.containsKey(expectedAckTag)) { if (_pendingSentMessages.containsKey(expectedAckTag)) {
markMessageFailed(messageId); markMessageFailed(messageId);
} }
}, },
); );
print('⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)');
} else {
print(' Channel message (no ACK tracking) - marked as sent immediately');
}
print('⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)');
print(' Calling notifyListeners() to update UI with "sent" status'); print(' Calling notifyListeners() to update UI with "sent" status');
_persistMessages(); _persistMessages();

View File

@@ -335,28 +335,64 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
Text( Text(
isConnected isConnected
? deviceInfo.displayName ?? 'Connected' ? deviceInfo.displayName ?? 'Connected'
: 'Disconnected', : (provider.isReconnecting
? 'Reconnecting... (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})'
: 'Disconnected'),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey[600], color: provider.isReconnecting
? Colors.orange[600]
: Colors.grey[600],
), ),
), ),
], ],
), ),
), ),
if (!isConnected) if (!isConnected)
ElevatedButton.icon( Row(
onPressed: () => _showConnectionDialog(context), mainAxisSize: MainAxisSize.min,
icon: const Icon(Icons.bluetooth, size: 18), children: [
label: const Text('Connect'), ElevatedButton.icon(
style: ElevatedButton.styleFrom( onPressed: provider.isReconnecting
backgroundColor: Colors.white, ? null // Disable button during reconnection
foregroundColor: Colors.black87, : () => _showConnectionDialog(context),
elevation: 0, icon: provider.isReconnecting
shape: RoundedRectangleBorder( ? const SizedBox(
borderRadius: BorderRadius.circular(20), width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.black54),
),
)
: const Icon(Icons.bluetooth, size: 18),
label: Text(provider.isReconnecting
? 'Reconnecting (${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts})'
: 'Connect'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
), ),
), // Cancel button during reconnection
if (provider.isReconnecting) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () => provider.cancelReconnection(),
icon: const Icon(Icons.close, size: 20),
tooltip: 'Cancel reconnection',
style: IconButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(8),
),
),
],
],
) )
else else
Row( Row(

View File

@@ -503,6 +503,11 @@ class _MessageBubble extends StatelessWidget {
final isSarMarker = message.isSarMarker; final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
// Get device's own public key from ConnectionProvider
final connectionProvider = context.read<ConnectionProvider>();
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
final isOwnMessage = message.isFromSelf(selfPublicKey);
// Debug: Log message details // Debug: Log message details
if (message.text.startsWith('S:')) { if (message.text.startsWith('S:')) {
debugPrint('🎨 [MessageBubble] Rendering SAR message:'); debugPrint('🎨 [MessageBubble] Rendering SAR message:');
@@ -519,14 +524,19 @@ class _MessageBubble extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSarMarker color: isSarMarker
? _getSarMarkerColor(context, isDarkMode) ? _getSarMarkerColor(context, isDarkMode)
: Theme.of(context).colorScheme.surfaceVariant, : _getMessageBubbleColor(context, isOwnMessage, isDarkMode),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: isSarMarker border: isSarMarker
? Border.all( ? Border.all(
color: _getSarMarkerBorderColor(context, isDarkMode), color: _getSarMarkerBorderColor(context, isDarkMode),
width: 3, width: 3,
) )
: null, : isOwnMessage
? Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
width: 2,
)
: null,
boxShadow: isSarMarker boxShadow: isSarMarker
? [ ? [
BoxShadow( BoxShadow(
@@ -574,15 +584,18 @@ class _MessageBubble extends StatelessWidget {
), ),
) )
else ...[ else ...[
if (message.isChannelMessage) if (isOwnMessage)
Icon(Icons.account_circle, size: 16, color: Theme.of(context).colorScheme.primary)
else if (message.isChannelMessage)
const Icon(Icons.tag, size: 16) const Icon(Icons.tag, size: 16)
else else
const Icon(Icons.person, size: 16), const Icon(Icons.person, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
message.displaySender, isOwnMessage ? 'You' : message.displaySender,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isOwnMessage ? Theme.of(context).colorScheme.primary : null,
), ),
), ),
], ],
@@ -735,6 +748,18 @@ class _MessageBubble extends StatelessWidget {
} }
} }
Color _getMessageBubbleColor(BuildContext context, bool isOwnMessage, bool isDarkMode) {
if (isOwnMessage) {
// Own messages: slightly highlighted with primary color tint
return isDarkMode
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.15);
} else {
// Others' messages: default surface color
return Theme.of(context).colorScheme.surfaceVariant;
}
}
Color _getSarMarkerColor(BuildContext context, bool isDarkMode) { Color _getSarMarkerColor(BuildContext context, bool isDarkMode) {
if (message.sarMarkerType == null) { if (message.sarMarkerType == null) {
return Theme.of(context).colorScheme.primaryContainer; return Theme.of(context).colorScheme.primaryContainer;

View File

@@ -5,20 +5,34 @@ import '../meshcore_constants.dart';
/// Callback types for connection events /// Callback types for connection events
typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnErrorCallback = void Function(String error); typedef OnErrorCallback = void Function(String error);
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
/// Manages BLE connection lifecycle /// Manages BLE connection lifecycle with automatic reconnection
class BleConnectionManager { class BleConnectionManager {
BluetoothDevice? _device; BluetoothDevice? _device;
BluetoothCharacteristic? _rxCharacteristic; BluetoothCharacteristic? _rxCharacteristic;
BluetoothCharacteristic? _txCharacteristic; BluetoothCharacteristic? _txCharacteristic;
bool _isConnected = false; bool _isConnected = false;
// Reconnection state
bool _reconnectionEnabled = true;
bool _isReconnecting = false;
int _reconnectionAttempt = 0;
Timer? _reconnectionTimer;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
static const int _maxReconnectionAttempts = 5;
static const List<int> _reconnectionDelaysMs = [1000, 2000, 3000, 5000, 10000]; // Exponential backoff
// Callbacks // Callbacks
OnConnectionStateCallback? onConnectionStateChanged; OnConnectionStateCallback? onConnectionStateChanged;
OnErrorCallback? onError; OnErrorCallback? onError;
OnReconnectionAttemptCallback? onReconnectionAttempt;
// Getters // Getters
bool get isConnected => _isConnected; bool get isConnected => _isConnected;
bool get isReconnecting => _isReconnecting;
int get reconnectionAttempt => _reconnectionAttempt;
int get maxReconnectionAttempts => _maxReconnectionAttempts;
BluetoothDevice? get device => _device; BluetoothDevice? get device => _device;
BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic; BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic;
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic; BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
@@ -138,9 +152,13 @@ class BleConnectionManager {
print('✅ [BLE] Notifications enabled'); print('✅ [BLE] Notifications enabled');
_isConnected = true; _isConnected = true;
_reconnectionAttempt = 0; // Reset reconnection counter on successful connection
print('🔵 [BLE] Notifying connection state change: connected'); print('🔵 [BLE] Notifying connection state change: connected');
onConnectionStateChanged?.call(true); onConnectionStateChanged?.call(true);
// Monitor connection state for automatic reconnection
_setupConnectionMonitoring();
print('✅✅✅ [BLE] Connection completed successfully!'); print('✅✅✅ [BLE] Connection completed successfully!');
return true; return true;
} catch (e) { } catch (e) {
@@ -156,6 +174,11 @@ class BleConnectionManager {
/// Disconnect from device /// Disconnect from device
Future<void> disconnect() async { Future<void> disconnect() async {
try { try {
print('🔴 [BLE] Disconnect requested by user');
// Disable reconnection before disconnecting
_reconnectionEnabled = false;
_cancelReconnection();
await _device?.disconnect(); await _device?.disconnect();
_isConnected = false; _isConnected = false;
_device = null; _device = null;
@@ -167,8 +190,126 @@ class BleConnectionManager {
} }
} }
/// Setup connection monitoring for automatic reconnection
void _setupConnectionMonitoring() {
print('🔵 [BLE] Setting up connection monitoring for device: ${_device?.platformName}');
// Cancel any existing subscription
_connectionStateSubscription?.cancel();
// Monitor connection state changes
_connectionStateSubscription = _device?.connectionState.listen((state) {
print('🔔 [BLE] Connection state changed: $state');
if (state == BluetoothConnectionState.disconnected) {
print('⚠️ [BLE] Device disconnected unexpectedly!');
_isConnected = false;
onConnectionStateChanged?.call(false);
// Attempt automatic reconnection if enabled
if (_reconnectionEnabled && !_isReconnecting) {
print('🔄 [BLE] Starting automatic reconnection...');
_attemptReconnection();
}
} else if (state == BluetoothConnectionState.connected) {
print('✅ [BLE] Device connected');
_isConnected = true;
_reconnectionAttempt = 0;
_isReconnecting = false;
onConnectionStateChanged?.call(true);
}
});
}
/// Attempt to reconnect to the device
Future<void> _attemptReconnection() async {
if (_device == null || _isReconnecting || !_reconnectionEnabled) {
return;
}
_isReconnecting = true;
_reconnectionAttempt++;
print('🔄 [BLE] Reconnection attempt $_reconnectionAttempt of $_maxReconnectionAttempts');
onReconnectionAttempt?.call(_reconnectionAttempt, _maxReconnectionAttempts);
if (_reconnectionAttempt > _maxReconnectionAttempts) {
print('❌ [BLE] Max reconnection attempts reached. Giving up.');
_isReconnecting = false;
onError?.call('Connection lost. Max reconnection attempts ($_maxReconnectionAttempts) reached.');
return;
}
// Calculate delay with exponential backoff
final delayIndex = (_reconnectionAttempt - 1).clamp(0, _reconnectionDelaysMs.length - 1);
final delayMs = _reconnectionDelaysMs[delayIndex];
print('🔄 [BLE] Waiting ${delayMs}ms before reconnection attempt $_reconnectionAttempt...');
// Wait before attempting reconnection
_reconnectionTimer = Timer(Duration(milliseconds: delayMs), () async {
if (!_reconnectionEnabled) {
print('🔄 [BLE] Reconnection cancelled by user');
_isReconnecting = false;
return;
}
try {
print('🔄 [BLE] Attempting to reconnect...');
// Try to reconnect
final success = await connect(_device!);
if (success) {
print('✅ [BLE] Reconnection successful!');
_isReconnecting = false;
_reconnectionAttempt = 0;
} else {
print('❌ [BLE] Reconnection attempt $_reconnectionAttempt failed');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call('Connection lost. Unable to reconnect after $_maxReconnectionAttempts attempts.');
}
}
} catch (e) {
print('❌ [BLE] Reconnection attempt $_reconnectionAttempt error: $e');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call('Connection lost. Unable to reconnect: $e');
}
}
});
}
/// Cancel ongoing reconnection attempts
void _cancelReconnection() {
print('🔴 [BLE] Cancelling reconnection attempts');
_reconnectionTimer?.cancel();
_reconnectionTimer = null;
_isReconnecting = false;
_reconnectionAttempt = 0;
_connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
}
/// Enable automatic reconnection (useful after user manually disconnects)
void enableReconnection() {
print('🔵 [BLE] Re-enabling automatic reconnection');
_reconnectionEnabled = true;
}
/// Dispose resources /// Dispose resources
void dispose() { void dispose() {
print('🔴 [BLE] Disposing BLE connection manager');
_cancelReconnection();
_device = null; _device = null;
_rxCharacteristic = null; _rxCharacteristic = null;
_txCharacteristic = null; _txCharacteristic = null;

View File

@@ -30,6 +30,7 @@ typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error); typedef OnErrorCallback = void Function(String error);
typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
/// MeshCore BLE Service - coordinates BLE communication components /// MeshCore BLE Service - coordinates BLE communication components
class MeshCoreBleService { class MeshCoreBleService {
@@ -40,6 +41,7 @@ class MeshCoreBleService {
// Event callbacks // Event callbacks
OnConnectionStateCallback? onConnectionStateChanged; OnConnectionStateCallback? onConnectionStateChanged;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnContactCallback? onContactReceived; OnContactCallback? onContactReceived;
OnContactsCompleteCallback? onContactsComplete; OnContactsCompleteCallback? onContactsComplete;
OnMessageCallback? onMessageReceived; OnMessageCallback? onMessageReceived;
@@ -77,6 +79,10 @@ class MeshCoreBleService {
_connectionManager.onError = (error) { _connectionManager.onError = (error) {
onError?.call(error); onError?.call(error);
}; };
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
print('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts');
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
};
// Command sender callbacks // Command sender callbacks
_commandSender.onError = (error) { _commandSender.onError = (error) {
@@ -148,6 +154,9 @@ class MeshCoreBleService {
// Getters // Getters
bool get isConnected => _connectionManager.isConnected; bool get isConnected => _connectionManager.isConnected;
bool get isReconnecting => _connectionManager.isReconnecting;
int get reconnectionAttempt => _connectionManager.reconnectionAttempt;
int get maxReconnectionAttempts => _connectionManager.maxReconnectionAttempts;
int get rxPacketCount => _responseHandler.rxPacketCount; int get rxPacketCount => _responseHandler.rxPacketCount;
int get txPacketCount => _commandSender.txPacketCount; int get txPacketCount => _commandSender.txPacketCount;
List<BlePacketLog> get packetLogs { List<BlePacketLog> get packetLogs {

View File

@@ -111,6 +111,16 @@ class FrameParser {
text = reader.readString(); text = reader.readString();
} }
// Parse sender name from channel message format: "<sender_name>: <actual_message>"
String? senderName;
String actualMessage = text;
if (text.contains(': ')) {
final colonIndex = text.indexOf(': ');
senderName = text.substring(0, colonIndex);
actualMessage = text.substring(colonIndex + 2); // Skip ": "
}
return Message( return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx', id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
messageType: MessageType.channel, messageType: MessageType.channel,
@@ -118,7 +128,8 @@ class FrameParser {
pathLen: pathLen, pathLen: pathLen,
textType: txtType, textType: txtType,
senderTimestamp: senderTimestamp, senderTimestamp: senderTimestamp,
text: text, text: actualMessage, // Store the actual message without sender prefix
senderName: senderName, // Store extracted sender name
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
); );
} }