mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Move BLE communication layer (command queue, frame parser/builder, protocol constants, data models) into a standalone reusable Dart package at ../meshcore_client. App model files become thin re-export wrappers, keeping all existing consumers working without import changes.
2220 lines
73 KiB
Dart
2220 lines
73 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||
import 'package:crypto/crypto.dart';
|
||
import '../models/device_info.dart';
|
||
import '../models/room_login_state.dart';
|
||
import '../models/sse_server_config.dart';
|
||
import 'package:meshcore_client/meshcore_client.dart';
|
||
import '../services/sse_server_service.dart';
|
||
import '../services/sse_client_service.dart';
|
||
import '../utils/sar_message_parser.dart';
|
||
import 'helpers/room_login_manager.dart';
|
||
import 'helpers/message_delivery_tracker.dart';
|
||
import 'helpers/ping_tracker.dart';
|
||
|
||
/// Pending send operation for auto-recovery
|
||
class _PendingSendOperation {
|
||
final Uint8List contactPublicKey;
|
||
final String text;
|
||
final String? messageId;
|
||
final Contact? contact;
|
||
final int retryAttempt;
|
||
|
||
_PendingSendOperation({
|
||
required this.contactPublicKey,
|
||
required this.text,
|
||
this.messageId,
|
||
this.contact,
|
||
this.retryAttempt = 0,
|
||
});
|
||
}
|
||
|
||
/// Result of a ping (telemetry request) operation
|
||
class PingResult {
|
||
final bool success;
|
||
final bool usedFlooding;
|
||
final bool timedOut;
|
||
final bool retriedWithFlooding;
|
||
|
||
const PingResult({
|
||
required this.success,
|
||
required this.usedFlooding,
|
||
required this.timedOut,
|
||
this.retriedWithFlooding = false,
|
||
});
|
||
}
|
||
|
||
/// Scanned device with RSSI information
|
||
class ScannedDevice {
|
||
final BluetoothDevice device;
|
||
final int rssi;
|
||
|
||
ScannedDevice({required this.device, required this.rssi});
|
||
}
|
||
|
||
/// Connection Provider - manages MeshCore BLE connection
|
||
class ConnectionProvider with ChangeNotifier {
|
||
final MeshCoreBleService _bleService = MeshCoreBleService();
|
||
final SseServerService _sseServer = SseServerService();
|
||
final SseClientService _sseClient = SseClientService();
|
||
|
||
/// Expose BLE service for background location tracking
|
||
MeshCoreBleService get bleService => _bleService;
|
||
|
||
/// Current connection mode
|
||
ConnectionMode _connectionMode = ConnectionMode.ble;
|
||
ConnectionMode get connectionMode => _connectionMode;
|
||
|
||
/// SSE server configuration
|
||
SseServerConfig _sseServerConfig = const SseServerConfig();
|
||
SseServerConfig get sseServerConfig => _sseServerConfig;
|
||
|
||
/// SSE client server URL
|
||
String? _sseClientServerUrl;
|
||
String? get sseClientServerUrl => _sseClientServerUrl;
|
||
|
||
DeviceInfo _deviceInfo = DeviceInfo();
|
||
DeviceInfo get deviceInfo => _deviceInfo;
|
||
|
||
final List<ScannedDevice> _scannedDevices = [];
|
||
List<ScannedDevice> get scannedDevices => _scannedDevices;
|
||
|
||
bool _isScanning = false;
|
||
bool get isScanning => _isScanning;
|
||
|
||
String? _error;
|
||
String? get error => _error;
|
||
|
||
// Activity indicators (for blinking)
|
||
bool _rxActivity = false;
|
||
bool _txActivity = false;
|
||
bool get rxActivity => _rxActivity;
|
||
bool get txActivity => _txActivity;
|
||
|
||
Timer? _rxActivityTimer;
|
||
Timer? _txActivityTimer;
|
||
|
||
// Periodic cleanup timer for stale ACK mappings
|
||
Timer? _ackCleanupTimer;
|
||
|
||
// Packet counters
|
||
int get rxPacketCount => _bleService.rxPacketCount;
|
||
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;
|
||
|
||
// SSE client connection state
|
||
bool get isSseClientConnecting => _sseClient.isConnecting;
|
||
int get sseClientReconnectionAttempt => _sseClient.reconnectionAttempts;
|
||
int get sseClientMaxReconnectionAttempts => _sseClient.maxReconnectionAttempts;
|
||
|
||
// Message sync state
|
||
bool _noMoreMessages = false;
|
||
// Prevent overlapping/too-frequent sync requests
|
||
bool _isSyncingMessages = false;
|
||
DateTime? _lastSyncNextRequestedAt;
|
||
static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
|
||
|
||
// Completer to wait for response before sending next sync request
|
||
Completer<bool>? _syncResponseCompleter;
|
||
|
||
// Lightweight guards for other commands that can be double-tapped
|
||
bool _isLoginInProgress = false;
|
||
DateTime? _lastLoginRequestedAt;
|
||
static const Duration _minLoginInterval = Duration(seconds: 1);
|
||
|
||
bool _isStatusRequestInProgress = false;
|
||
DateTime? _lastStatusRequestedAt;
|
||
static const Duration _minStatusInterval = Duration(milliseconds: 200);
|
||
|
||
bool _isAdvertInProgress = false;
|
||
DateTime? _lastAdvertRequestedAt;
|
||
static const Duration _minAdvertInterval = Duration(milliseconds: 500);
|
||
|
||
// Helper instances
|
||
final RoomLoginManager _roomLoginManager = RoomLoginManager();
|
||
final MessageDeliveryTracker _messageDeliveryTracker =
|
||
MessageDeliveryTracker();
|
||
final PingTracker _pingTracker = PingTracker();
|
||
|
||
// Expose room login states
|
||
Map<String, RoomLoginState> get roomLoginStates =>
|
||
_roomLoginManager.roomLoginStates;
|
||
|
||
// Callbacks for other providers
|
||
Function(Contact)? onContactReceived;
|
||
Function(List<Contact>)? onContactsComplete;
|
||
Function(Message)? onMessageReceived;
|
||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||
Function(int channelIdx, String channelName, Uint8List secret, int? flags)? onChannelInfoReceived;
|
||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||
onBinaryResponse;
|
||
Function(Uint8List publicKey)? onContactDeleted;
|
||
VoidCallback? onContactsFull;
|
||
Function(Uint8List publicKey)? onAdvertReceived;
|
||
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(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||
onMessageEchoDetected;
|
||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||
|
||
// Track pending send operations for auto-recovery
|
||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||
|
||
ConnectionProvider() {
|
||
_initializeBleService();
|
||
}
|
||
|
||
void _initializeBleService() {
|
||
_bleService.onConnectionStateChanged = (isConnected) {
|
||
debugPrint('🔔 [Provider] Connection state callback fired: $isConnected');
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
connectionState: isConnected
|
||
? ConnectionState.connected
|
||
: (_bleService.isReconnecting
|
||
? ConnectionState.connecting
|
||
: ConnectionState.disconnected),
|
||
lastUpdate: DateTime.now(),
|
||
);
|
||
debugPrint(
|
||
' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}',
|
||
);
|
||
debugPrint(
|
||
' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}',
|
||
);
|
||
debugPrint(' isReconnecting: ${_bleService.isReconnecting}');
|
||
|
||
// Start/stop ACK cleanup timer based on connection state
|
||
if (isConnected) {
|
||
_startAckCleanupTimer();
|
||
} else {
|
||
_stopAckCleanupTimer();
|
||
}
|
||
|
||
notifyListeners();
|
||
debugPrint(' Notified listeners');
|
||
};
|
||
|
||
_bleService.onReconnectionAttempt = (attemptNumber, maxAttempts) {
|
||
debugPrint(
|
||
'🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts',
|
||
);
|
||
// Notify UI to update reconnection status display
|
||
notifyListeners();
|
||
};
|
||
|
||
_bleService.onError = (error, {int? errorCode}) {
|
||
debugPrint('⚠️ [Provider] BLE error received: $error');
|
||
debugPrint(' Error code: ${errorCode ?? "none"}');
|
||
debugPrint(' Current connection state: ${_deviceInfo.connectionState}');
|
||
|
||
_error = error;
|
||
|
||
// Only set connection state to error if we're not already connected
|
||
// Data parsing errors after connection shouldn't disconnect us
|
||
if (_deviceInfo.connectionState != ConnectionState.connected) {
|
||
debugPrint(' Setting connection state to error');
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
connectionState: ConnectionState.error,
|
||
);
|
||
} else {
|
||
debugPrint(
|
||
' Keeping connection state as connected (ignoring data parsing error)',
|
||
);
|
||
}
|
||
|
||
notifyListeners();
|
||
};
|
||
|
||
_bleService.onContactNotFound = (contactPublicKey) async {
|
||
debugPrint(
|
||
'🔧 [Provider] Contact not found error detected - initiating auto-recovery',
|
||
);
|
||
|
||
if (contactPublicKey == null) {
|
||
debugPrint(' ⚠️ No contact public key available for recovery');
|
||
return;
|
||
}
|
||
|
||
// Generate operation ID from public key
|
||
final operationId = contactPublicKey
|
||
.sublist(0, 6)
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join(':');
|
||
final pendingOp = _pendingSendOperations[operationId];
|
||
|
||
if (pendingOp == null || pendingOp.contact == null) {
|
||
debugPrint(
|
||
' ⚠️ No pending operation found for recovery: $operationId',
|
||
);
|
||
return;
|
||
}
|
||
|
||
debugPrint(
|
||
' 📋 Found pending operation for: ${pendingOp.contact!.advName}',
|
||
);
|
||
debugPrint(' 📤 Step 1: Adding contact to radio...');
|
||
|
||
try {
|
||
// Step 1: Add the contact to the radio
|
||
await _bleService.addOrUpdateContact(pendingOp.contact!);
|
||
|
||
// Small delay to ensure contact is added before retrying
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
|
||
debugPrint(' ✅ Contact added successfully');
|
||
debugPrint(' 🔄 Step 2: Retrying message send...');
|
||
|
||
// IMPORTANT: Re-track the message before retrying (auto-recovery bypasses sendTextMessage)
|
||
if (pendingOp.messageId != null) {
|
||
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
|
||
debugPrint(' 📝 Re-tracked message: ${pendingOp.messageId}');
|
||
}
|
||
|
||
// Step 2: Retry the send operation
|
||
await _bleService.sendTextMessage(
|
||
contactPublicKey: pendingOp.contactPublicKey,
|
||
text: pendingOp.text,
|
||
attempt: pendingOp.retryAttempt,
|
||
);
|
||
|
||
debugPrint(' ✅ Auto-recovery completed - message resent');
|
||
|
||
// Clear pending operation after successful recovery
|
||
_pendingSendOperations.remove(operationId);
|
||
} catch (e) {
|
||
debugPrint(' ❌ Auto-recovery failed: $e');
|
||
_error = 'Auto-recovery failed: $e';
|
||
notifyListeners();
|
||
|
||
// Clear pending operation after failed recovery
|
||
_pendingSendOperations.remove(operationId);
|
||
}
|
||
};
|
||
|
||
_bleService.onContactReceived = (contact) {
|
||
debugPrint('📥 [Provider] Contact received (0x8A): "${contact.advName}"');
|
||
debugPrint(' Forwarding to AppProvider via onContactReceived callback');
|
||
onContactReceived?.call(contact);
|
||
};
|
||
|
||
_bleService.onContactsComplete = (contacts) {
|
||
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length} contacts');
|
||
debugPrint(' Forwarding to AppProvider via onContactsComplete callback');
|
||
onContactsComplete?.call(contacts);
|
||
};
|
||
|
||
_bleService.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
|
||
};
|
||
|
||
_bleService.onContactDeleted = (publicKey) {
|
||
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)');
|
||
onContactDeleted?.call(publicKey);
|
||
};
|
||
|
||
_bleService.onContactsFull = () {
|
||
debugPrint('⚠️ [Provider] Contacts storage is full');
|
||
onContactsFull?.call();
|
||
};
|
||
|
||
_bleService.onMessageReceived = (message) {
|
||
// Parse SAR markers
|
||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||
onMessageReceived?.call(enhancedMessage);
|
||
|
||
// Complete sync response completer (message received = continue syncing)
|
||
if (_syncResponseCompleter != null &&
|
||
!_syncResponseCompleter!.isCompleted) {
|
||
_syncResponseCompleter!.complete(true);
|
||
}
|
||
};
|
||
|
||
_bleService.onTelemetryReceived = (publicKey, lppData) {
|
||
debugPrint('📥 [Provider] Telemetry response (0x8B) received');
|
||
debugPrint(
|
||
' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||
);
|
||
debugPrint(' LPP data: ${lppData.length} bytes');
|
||
// Mark ping as successful if this was a ping request
|
||
_pingTracker.markPingSuccessful(publicKey);
|
||
debugPrint(' Forwarding to AppProvider via onTelemetryReceived callback');
|
||
onTelemetryReceived?.call(publicKey, lppData);
|
||
};
|
||
|
||
_bleService.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
|
||
debugPrint('📥 [Provider] Binary response received');
|
||
debugPrint(
|
||
' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||
);
|
||
debugPrint(' Tag: $tag');
|
||
debugPrint(' Response data: ${responseData.length} bytes');
|
||
// Mark ping as successful if this was a ping request
|
||
// Binary responses can also be telemetry responses (newer firmware)
|
||
_pingTracker.markPingSuccessful(publicKeyPrefix);
|
||
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
|
||
};
|
||
|
||
_bleService.onNoMoreMessages = () {
|
||
debugPrint('📥 [Provider] Received NoMoreMessages signal');
|
||
_noMoreMessages = true;
|
||
|
||
// Complete sync response completer (no more messages = stop syncing)
|
||
if (_syncResponseCompleter != null &&
|
||
!_syncResponseCompleter!.isCompleted) {
|
||
_syncResponseCompleter!.complete(false);
|
||
}
|
||
};
|
||
|
||
_bleService.onMessageWaiting = () {
|
||
debugPrint(
|
||
'📥 [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();
|
||
};
|
||
|
||
_bleService
|
||
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||
debugPrint('📥 [Provider] Login successful to room');
|
||
debugPrint(
|
||
' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||
);
|
||
debugPrint(' Permissions: $permissions, Admin: $isAdmin, Tag: $tag');
|
||
|
||
// Update room login state via helper
|
||
await _roomLoginManager.handleLoginSuccess(
|
||
publicKeyPrefix: publicKeyPrefix,
|
||
permissions: permissions,
|
||
isAdmin: isAdmin,
|
||
tag: tag,
|
||
);
|
||
notifyListeners();
|
||
|
||
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
|
||
};
|
||
|
||
_bleService.onLoginFail = (publicKeyPrefix) {
|
||
debugPrint('📥 [Provider] Login failed to room');
|
||
debugPrint(
|
||
' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||
);
|
||
|
||
// Update room login state to logged out via helper
|
||
_roomLoginManager.handleLoginFail(publicKeyPrefix: publicKeyPrefix);
|
||
notifyListeners();
|
||
|
||
onLoginFail?.call(publicKeyPrefix);
|
||
};
|
||
|
||
_bleService.onAdvertReceived = (publicKey) {
|
||
debugPrint('📥 [Provider] Advert received from node');
|
||
debugPrint(
|
||
' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||
);
|
||
// Forward to AppProvider to trigger contact update
|
||
// The radio may send only PUSH_CODE_ADVERT (0x80) for existing contacts
|
||
// instead of PUSH_CODE_NEW_ADVERT (0x8A), so we need to handle this
|
||
onAdvertReceived?.call(publicKey);
|
||
};
|
||
|
||
_bleService.onPathUpdated = (publicKey) {
|
||
debugPrint('📥 [Provider] Path updated for contact');
|
||
debugPrint(
|
||
' Public key: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
|
||
);
|
||
debugPrint(
|
||
' 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, contactPublicKey) {
|
||
debugPrint(
|
||
'📥 [Provider] Message sent - ACK tag: $expectedAckTag, timeout: ${suggestedTimeoutMs}ms',
|
||
);
|
||
|
||
// Pop message ID from FIFO queue (matches send order)
|
||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||
|
||
if (messageId != null) {
|
||
debugPrint(' ✅ Matched with message ID: $messageId');
|
||
|
||
// Check if approaching firmware limit (8 pending ACKs max)
|
||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||
debugPrint(
|
||
' ⚠️ WARNING: ${_messageDeliveryTracker.pendingCount} pending ACKs (firmware limit: 8)',
|
||
);
|
||
debugPrint(' ⚠️ Firmware may drop ACK tracking if limit exceeded!');
|
||
}
|
||
|
||
// Store the ACK tag to message ID mapping for delivery confirmation
|
||
_messageDeliveryTracker.mapAckTagToMessageId(expectedAckTag, messageId);
|
||
|
||
// Notify callback with message ID
|
||
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
|
||
} else {
|
||
debugPrint(
|
||
'⚠️ [Provider] SENT response received but no pending message IDs',
|
||
);
|
||
}
|
||
};
|
||
|
||
_bleService.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||
debugPrint(
|
||
'📥 [Provider] Message delivered - ACK code: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||
);
|
||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
||
};
|
||
|
||
_bleService
|
||
.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||
debugPrint(
|
||
'🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount',
|
||
);
|
||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
||
};
|
||
|
||
_bleService.onStatusResponse = (publicKeyPrefix, statusData) {
|
||
debugPrint('📥 [Provider] Status response received from node');
|
||
debugPrint(
|
||
' Public key prefix: ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||
);
|
||
debugPrint(' Status data: ${statusData.length} bytes');
|
||
// Forward the callback to whoever needs it (e.g., ContactsProvider)
|
||
onStatusResponse?.call(publicKeyPrefix, statusData);
|
||
};
|
||
|
||
_bleService.onDeviceInfoReceived = (deviceInfo) {
|
||
debugPrint('📥 [Provider] Received DeviceInfo:');
|
||
debugPrint(' Firmware Version: ${deviceInfo['firmwareVersion']}');
|
||
debugPrint(' Max Contacts: ${deviceInfo['maxContacts']}');
|
||
debugPrint(' Max Channels: ${deviceInfo['maxChannels']}');
|
||
debugPrint(' BLE PIN: ${deviceInfo['blePin']}');
|
||
debugPrint(' Build Date: ${deviceInfo['firmwareBuildDate']}');
|
||
debugPrint(' Model: ${deviceInfo['manufacturerModel']}');
|
||
debugPrint(' Version: ${deviceInfo['semanticVersion']}');
|
||
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
firmwareVersion: deviceInfo['firmwareVersion'] as int?,
|
||
maxContacts: deviceInfo['maxContacts'] as int?,
|
||
maxChannels: deviceInfo['maxChannels'] as int?,
|
||
blePin: deviceInfo['blePin'] as int?,
|
||
firmwareBuildDate: deviceInfo['firmwareBuildDate'] as String?,
|
||
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
||
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
||
);
|
||
notifyListeners();
|
||
debugPrint('✅ [Provider] Device info updated with DeviceInfo');
|
||
|
||
// Update SSE server with device name if running
|
||
if (_sseServer.isRunning) {
|
||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||
}
|
||
};
|
||
|
||
_bleService.onSelfInfoReceived = (selfInfo) {
|
||
debugPrint('📥 [Provider] Received SelfInfo:');
|
||
debugPrint(
|
||
' TX Power: ${selfInfo['txPower']} / ${selfInfo['maxTxPower']} dBm',
|
||
);
|
||
debugPrint(
|
||
' Radio: freq=${selfInfo['radioFreq']}, bw=${selfInfo['radioBw']}, sf=${selfInfo['radioSf']}, cr=${selfInfo['radioCr']}',
|
||
);
|
||
debugPrint(
|
||
' Position: ${selfInfo['advLat'] / 1000000.0}, ${selfInfo['advLon'] / 1000000.0}',
|
||
);
|
||
debugPrint(' Self Name: ${selfInfo['selfName']}');
|
||
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
deviceType: selfInfo['deviceType'] as int?,
|
||
txPower: selfInfo['txPower'] as int?,
|
||
maxTxPower: selfInfo['maxTxPower'] as int?,
|
||
publicKey: selfInfo['publicKey'] as Uint8List?,
|
||
advLat: selfInfo['advLat'] as int?,
|
||
advLon: selfInfo['advLon'] as int?,
|
||
manualAddContacts: selfInfo['manualAddContacts'] as bool?,
|
||
radioFreq: selfInfo['radioFreq'] as int?,
|
||
radioBw: selfInfo['radioBw'] as int?,
|
||
radioSf: selfInfo['radioSf'] as int?,
|
||
radioCr: selfInfo['radioCr'] as int?,
|
||
selfName: selfInfo['selfName'] as String?,
|
||
);
|
||
notifyListeners();
|
||
debugPrint('✅ [Provider] Device info updated with SelfInfo');
|
||
|
||
// Update SSE server with device name if running
|
||
if (_sseServer.isRunning) {
|
||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||
}
|
||
};
|
||
|
||
// Activity indicators
|
||
|
||
_bleService.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
||
debugPrint('📥 [Provider] Received BatteryAndStorage:');
|
||
debugPrint(
|
||
' Battery: ${millivolts}mV (${(millivolts / 1000.0).toStringAsFixed(2)}V)',
|
||
);
|
||
if (usedKb != null) {
|
||
debugPrint(' Storage Used: ${usedKb}KB');
|
||
}
|
||
if (totalKb != null) {
|
||
debugPrint(' Storage Total: ${totalKb}KB');
|
||
if (totalKb > 0 && usedKb != null) {
|
||
final usedPercent = (usedKb / totalKb) * 100.0;
|
||
debugPrint(' Storage Usage: ${usedPercent.toStringAsFixed(1)}%');
|
||
}
|
||
}
|
||
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
batteryMilliVolts: millivolts,
|
||
storageUsedKb: usedKb,
|
||
storageTotalKb: totalKb,
|
||
lastUpdate: DateTime.now(),
|
||
);
|
||
notifyListeners();
|
||
debugPrint('✅ [Provider] Device info updated with BatteryAndStorage');
|
||
};
|
||
_bleService.onRxActivity = () {
|
||
_rxActivity = true;
|
||
notifyListeners();
|
||
|
||
// Reset after 100ms
|
||
_rxActivityTimer?.cancel();
|
||
_rxActivityTimer = Timer(const Duration(milliseconds: 100), () {
|
||
_rxActivity = false;
|
||
notifyListeners();
|
||
});
|
||
};
|
||
|
||
_bleService.onTxActivity = () {
|
||
_txActivity = true;
|
||
notifyListeners();
|
||
|
||
// Reset after 100ms
|
||
_txActivityTimer?.cancel();
|
||
_txActivityTimer = Timer(const Duration(milliseconds: 100), () {
|
||
_txActivity = false;
|
||
notifyListeners();
|
||
});
|
||
};
|
||
|
||
_bleService.onRssiUpdate = (rssi) {
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
signalRssi: rssi,
|
||
lastUpdate: DateTime.now(),
|
||
);
|
||
notifyListeners();
|
||
};
|
||
}
|
||
|
||
/// Start scanning for MeshCore devices
|
||
Future<void> startScan() async {
|
||
debugPrint('🔍 [Provider] startScan() called');
|
||
_isScanning = true;
|
||
_scannedDevices.clear();
|
||
_error = null;
|
||
notifyListeners();
|
||
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
||
|
||
try {
|
||
await for (final scanResult in _bleService.scanForDevices(
|
||
timeout: const Duration(seconds: 10),
|
||
)) {
|
||
debugPrint('📱 [Provider] Scan result received from scan stream');
|
||
final device = scanResult.device;
|
||
final rssi = scanResult.rssi;
|
||
|
||
if (!_scannedDevices.any((d) => d.device.remoteId == device.remoteId)) {
|
||
_scannedDevices.add(ScannedDevice(device: device, rssi: rssi));
|
||
debugPrint(
|
||
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
|
||
);
|
||
notifyListeners();
|
||
} else {
|
||
// Update RSSI if device already exists
|
||
final index = _scannedDevices.indexWhere(
|
||
(d) => d.device.remoteId == device.remoteId,
|
||
);
|
||
if (index != -1 && _scannedDevices[index].rssi != rssi) {
|
||
_scannedDevices[index] = ScannedDevice(device: device, rssi: rssi);
|
||
debugPrint(
|
||
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
|
||
);
|
||
notifyListeners();
|
||
} else {
|
||
debugPrint(
|
||
' ⏭️ [Provider] Device already in list with same RSSI, skipping',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('❌ [Provider] Scan error: $e');
|
||
_error = 'Scan error: $e';
|
||
} finally {
|
||
debugPrint('🏁 [Provider] Scan completed');
|
||
_isScanning = false;
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Stop scanning
|
||
Future<void> stopScan() async {
|
||
await FlutterBluePlus.stopScan();
|
||
_isScanning = false;
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Connect to a device
|
||
Future<bool> connect(BluetoothDevice device) async {
|
||
debugPrint(
|
||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||
);
|
||
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
deviceId: device.remoteId.toString(),
|
||
deviceName: device.platformName.isNotEmpty
|
||
? device.platformName
|
||
: 'Unknown',
|
||
connectionState: ConnectionState.connecting,
|
||
);
|
||
_error = null;
|
||
debugPrint('✅ [Provider] Device info updated to connecting state');
|
||
notifyListeners();
|
||
|
||
debugPrint('🔵 [Provider] Calling BLE service connect()...');
|
||
final success = await _bleService.connect(device);
|
||
|
||
if (success) {
|
||
debugPrint('✅ [Provider] BLE service connect() returned success');
|
||
} else {
|
||
debugPrint('❌ [Provider] BLE service connect() returned failure');
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
connectionState: ConnectionState.error,
|
||
);
|
||
notifyListeners();
|
||
}
|
||
return success;
|
||
}
|
||
|
||
/// Disconnect from device
|
||
Future<void> disconnect() async {
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
connectionState: ConnectionState.disconnecting,
|
||
);
|
||
notifyListeners();
|
||
|
||
// Disconnect from BLE if connected
|
||
await _bleService.disconnect();
|
||
|
||
// Disconnect from SSE if connected
|
||
if (_sseClient.isConnected) {
|
||
await disconnectFromSseServer();
|
||
}
|
||
|
||
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
|
||
_roomLoginManager
|
||
.clearRoomLoginStates(); // Clear login states on disconnect
|
||
_pingTracker.clearAll(); // Clear pending pings on disconnect
|
||
_pendingSendOperations.clear(); // Clear pending operations on disconnect
|
||
_messageDeliveryTracker.clearTracking(); // Clear ACK tracking on disconnect
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Cancel ongoing reconnection attempts
|
||
/// This is useful when the user wants to manually disconnect during reconnection
|
||
void cancelReconnection() {
|
||
debugPrint('🔴 [Provider] User requested cancellation of reconnection');
|
||
disconnect();
|
||
}
|
||
|
||
/// Start periodic cleanup of stale ACK mappings
|
||
///
|
||
/// Runs every minute to clean up ACK tags that haven't received
|
||
/// delivery confirmation within 5 minutes.
|
||
void _startAckCleanupTimer() {
|
||
_stopAckCleanupTimer(); // Cancel any existing timer first
|
||
|
||
debugPrint('🧹 [ConnectionProvider] Starting ACK cleanup timer (1 minute interval)');
|
||
_ackCleanupTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||
final cleanedCount = _messageDeliveryTracker.cleanupStaleAcks();
|
||
if (cleanedCount > 0) {
|
||
debugPrint('🧹 [ConnectionProvider] Cleaned up $cleanedCount stale ACK mappings');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Stop periodic cleanup timer
|
||
void _stopAckCleanupTimer() {
|
||
_ackCleanupTimer?.cancel();
|
||
_ackCleanupTimer = null;
|
||
}
|
||
|
||
/// Get ACK tracking diagnostics
|
||
///
|
||
/// Returns diagnostic information about pending ACKs for debugging.
|
||
/// Useful for troubleshooting message delivery issues.
|
||
Map<String, dynamic> getAckTrackingDiagnostics() {
|
||
return _messageDeliveryTracker.getDiagnostics();
|
||
}
|
||
|
||
/// Get contacts from device
|
||
Future<void> getContacts() async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.getContacts();
|
||
} catch (e) {
|
||
_error = 'Failed to get contacts: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Get a single contact by public key from device
|
||
///
|
||
/// This is more efficient than getContacts() when you only need to refresh
|
||
/// one specific contact (e.g., after receiving an advertisement or path update).
|
||
///
|
||
/// The contact will be delivered via the onContactReceived callback.
|
||
Future<void> getContact(Uint8List publicKey) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.getContactByKey(publicKey);
|
||
} catch (e) {
|
||
_error = 'Failed to get contact: $e';
|
||
debugPrint('⚠️ [Provider] Failed to get contact by key, falling back to full contact sync');
|
||
// Fallback to full contact sync if command not supported
|
||
await _bleService.getContacts();
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Sync all channels from device
|
||
Future<void> syncChannels({int? maxChannels}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Use maxChannels from device info if available, otherwise default to 40
|
||
final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40;
|
||
await _bleService.syncAllChannels(maxChannels: channelCount);
|
||
} catch (e) {
|
||
_error = 'Failed to sync channels: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Configure the default public channel (channel 0) with the well-known secret
|
||
///
|
||
/// This MUST be called after connecting to the device and before sending any
|
||
/// channel messages. Without this configuration, channel messages will fail
|
||
/// with ERR_CODE_NOT_FOUND.
|
||
///
|
||
/// The public channel uses a well-known pre-shared key that all MeshCore
|
||
/// devices use for the default public channel.
|
||
Future<void> configureDefaultPublicChannel() async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
debugPrint(
|
||
'📻 [Provider] Configuring default public channel (channel 0)',
|
||
);
|
||
debugPrint(
|
||
' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
|
||
);
|
||
await _bleService.setChannel(
|
||
channelIdx: 0,
|
||
channelName: 'Public Channel',
|
||
secret: MeshCoreConstants.defaultPublicChannelSecret,
|
||
);
|
||
debugPrint('✅ [Provider] Public channel configured successfully');
|
||
} catch (e) {
|
||
_error = 'Failed to configure public channel: $e';
|
||
debugPrint('❌ [Provider] Public channel configuration failed: $e');
|
||
debugPrint(
|
||
' This may be normal if the channel is pre-configured in firmware',
|
||
);
|
||
notifyListeners();
|
||
rethrow; // Re-throw to notify caller of failure
|
||
}
|
||
}
|
||
|
||
/// Find next available empty channel slot (1-39)
|
||
///
|
||
/// Returns the channel index of the first empty slot, or null if all slots are in use.
|
||
/// Skips slot 0 (reserved for Public Channel).
|
||
/// Callback to get channel info for empty slot detection
|
||
/// This should be set by AppProvider to query ChannelsProvider
|
||
Function(int channelIdx)? getChannelInfo;
|
||
|
||
/// Check if a specific channel slot is empty
|
||
Future<bool> isChannelSlotEmpty(int channelIdx) async {
|
||
if (!_bleService.isConnected) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// First check if we already have info about this channel
|
||
if (getChannelInfo != null) {
|
||
final channel = getChannelInfo!(channelIdx);
|
||
if (channel != null) {
|
||
final channelName = (channel as dynamic).name as String?;
|
||
return channelName == null || channelName.isEmpty;
|
||
}
|
||
}
|
||
|
||
// If not cached, query the device
|
||
await _bleService.getChannel(channelIdx);
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// Check again after query
|
||
if (getChannelInfo != null) {
|
||
final channel = getChannelInfo!(channelIdx);
|
||
if (channel != null) {
|
||
final channelName = (channel as dynamic).name as String?;
|
||
return channelName == null || channelName.isEmpty;
|
||
}
|
||
}
|
||
|
||
// If still no info, assume it's empty
|
||
return true;
|
||
} catch (e) {
|
||
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<int?> findNextEmptyChannelSlot() async {
|
||
if (!_bleService.isConnected) {
|
||
throw Exception('Not connected to device');
|
||
}
|
||
|
||
try {
|
||
debugPrint('🔍 [Provider] Finding next empty channel slot...');
|
||
|
||
// maxChannels from device info, or default to 40
|
||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||
|
||
// Check each slot starting from 1 (skip 0 = public channel)
|
||
for (int i = 1; i < maxChannels; i++) {
|
||
// First check cache
|
||
if (getChannelInfo != null) {
|
||
final channel = getChannelInfo!(i);
|
||
if (channel != null) {
|
||
final channelName = (channel as dynamic).name as String?;
|
||
if (channelName != null && channelName.isNotEmpty) {
|
||
debugPrint(' ⏭️ Slot $i occupied: "$channelName"');
|
||
continue; // Skip occupied slots
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slot appears empty in cache, verify by querying device
|
||
debugPrint(' 🔍 Checking slot $i...');
|
||
final isEmpty = await isChannelSlotEmpty(i);
|
||
if (isEmpty) {
|
||
debugPrint(' ✅ Found empty slot: $i');
|
||
return i;
|
||
}
|
||
}
|
||
|
||
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
||
return null;
|
||
} catch (e) {
|
||
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Create a new channel with automatic slot assignment
|
||
///
|
||
/// Finds the next available empty channel slot and configures it with the
|
||
/// provided name and secret. The secret is converted from an ASCII string
|
||
/// to a 16-byte key using MD5 hashing.
|
||
///
|
||
/// [channelName] - Name for the channel (max 31 characters)
|
||
/// [channelSecret] - ASCII password for the channel (will be hashed to 16 bytes)
|
||
///
|
||
/// Throws an exception if all slots are in use or if the channel configuration fails.
|
||
Future<void> createChannel({
|
||
required String channelName,
|
||
required String channelSecret,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
throw Exception('Not connected to device');
|
||
}
|
||
|
||
try {
|
||
debugPrint('📻 [Provider] Creating new channel...');
|
||
debugPrint(' Name: $channelName');
|
||
|
||
// Determine channel type
|
||
final bool isHashChannel = channelName.startsWith('#');
|
||
|
||
// Check for duplicate channels
|
||
int? existingSlot;
|
||
if (getChannelInfo != null) {
|
||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||
for (int i = 1; i < maxChannels; i++) {
|
||
final channel = getChannelInfo!(i);
|
||
if (channel != null) {
|
||
final existingName = (channel as dynamic).name as String?;
|
||
if (existingName != null && existingName.isNotEmpty) {
|
||
// For hash channels (#name), check exact match to prevent duplicates
|
||
if (isHashChannel && existingName == channelName) {
|
||
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i');
|
||
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.');
|
||
}
|
||
// For private channels, check name match to allow overwrite
|
||
else if (!isHashChannel && existingName == channelName) {
|
||
debugPrint(' ℹ️ Private channel "$channelName" found in slot $i - will overwrite');
|
||
existingSlot = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Determine slot to use
|
||
final int slotIdx;
|
||
if (existingSlot != null) {
|
||
// Overwrite existing private channel
|
||
slotIdx = existingSlot;
|
||
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
||
} else {
|
||
// Find next empty slot for new channel
|
||
final emptySlot = await findNextEmptyChannelSlot();
|
||
if (emptySlot == null) {
|
||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
||
}
|
||
slotIdx = emptySlot;
|
||
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
||
}
|
||
|
||
// Generate secret
|
||
final List<int> secretBytes;
|
||
if (isHashChannel) {
|
||
// Hash channel: auto-generate secret from name using SHA256
|
||
debugPrint(' Channel type: Hash channel (#)');
|
||
secretBytes = _generateHashChannelSecret(channelName);
|
||
debugPrint(' Secret auto-generated from channel name using SHA256');
|
||
} else {
|
||
// Private channel: use explicit secret with MD5
|
||
debugPrint(' Channel type: Private channel');
|
||
secretBytes = _convertSecretToBytes(channelSecret);
|
||
debugPrint(' Secret converted to 16-byte key using MD5');
|
||
}
|
||
|
||
// Send CMD_SET_CHANNEL to radio
|
||
await _bleService.setChannel(
|
||
channelIdx: slotIdx,
|
||
channelName: channelName,
|
||
secret: secretBytes,
|
||
);
|
||
|
||
debugPrint('✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx');
|
||
|
||
// Small delay to allow the response to propagate
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// Refresh channels to update UI
|
||
// The channel info will be received via onChannelInfoReceived callback
|
||
await _bleService.getChannel(slotIdx);
|
||
} catch (e) {
|
||
_error = 'Failed to create channel: $e';
|
||
debugPrint('❌ [Provider] Channel creation failed: $e');
|
||
notifyListeners();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Delete a channel and remove it from the UI
|
||
///
|
||
/// Clears the channel slot on the device and removes it from both
|
||
/// ChannelsProvider and ContactsProvider. The slot becomes available for reuse.
|
||
///
|
||
/// [channelIdx] - Channel slot index (1-39). Channel 0 (public) cannot be deleted.
|
||
///
|
||
/// Throws an exception if the channel cannot be deleted or if channel 0 is specified.
|
||
Future<void> deleteChannel(int channelIdx) async {
|
||
if (!_bleService.isConnected) {
|
||
throw Exception('Not connected to device');
|
||
}
|
||
|
||
if (channelIdx == 0) {
|
||
throw Exception('Cannot delete the public channel');
|
||
}
|
||
|
||
try {
|
||
debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...');
|
||
|
||
// Delete channel on device (sets empty name and zeroed secret)
|
||
await _bleService.deleteChannel(channelIdx);
|
||
|
||
debugPrint('✅ [Provider] Channel deleted successfully from slot $channelIdx');
|
||
|
||
// Small delay to allow the response to propagate
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// Refresh channels to update UI
|
||
// The empty channel will trigger removal via onChannelInfoReceived callback
|
||
await _bleService.getChannel(channelIdx);
|
||
} catch (e) {
|
||
_error = 'Failed to delete channel: $e';
|
||
debugPrint('❌ [Provider] Channel deletion failed: $e');
|
||
notifyListeners();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Generate secret for hash channel using SHA256
|
||
/// Same algorithm as Channel model for consistency
|
||
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
|
||
List<int> _generateHashChannelSecret(String channelName) {
|
||
final bytes = utf8.encode(channelName);
|
||
final digest = sha256.convert(bytes);
|
||
return digest.bytes.sublist(0, 16);
|
||
}
|
||
|
||
/// Convert ASCII secret string to 16-byte key using MD5 hash
|
||
/// Used for private channels with explicit secrets
|
||
List<int> _convertSecretToBytes(String asciiSecret) {
|
||
// Use MD5 hash to convert any length ASCII string to exactly 16 bytes
|
||
// This provides a deterministic and secure way to generate channel keys
|
||
return md5.convert(utf8.encode(asciiSecret)).bytes;
|
||
}
|
||
|
||
/// Add or update a contact on the companion radio
|
||
///
|
||
/// This manually adds a contact to the radio's internal contact table.
|
||
/// Useful when a room contact was deleted or never advertised yet.
|
||
Future<void> addOrUpdateContact(Contact contact) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.addOrUpdateContact(contact);
|
||
} catch (e) {
|
||
_error = 'Failed to add/update contact: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Send text message to contact
|
||
///
|
||
/// 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
|
||
/// [contact] - optional contact object for path status logging
|
||
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
|
||
Future<bool> sendTextMessage({
|
||
required Uint8List contactPublicKey,
|
||
required String text,
|
||
String? messageId,
|
||
Contact? contact,
|
||
int retryAttempt = 0,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return false;
|
||
}
|
||
|
||
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
||
// Rate limit at 7 to stay under the limit
|
||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||
final pendingCount = _messageDeliveryTracker.pendingCount;
|
||
debugPrint(
|
||
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
|
||
);
|
||
debugPrint('⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...');
|
||
|
||
// Wait briefly for some ACKs to arrive, then proceed anyway
|
||
// (User action shouldn't be blocked forever)
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
if (_messageDeliveryTracker.shouldRateLimit) {
|
||
debugPrint(
|
||
'⚠️ Still at limit after wait - proceeding anyway (may lose ACK tracking)',
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Log path status and retry info
|
||
if (contact != null) {
|
||
if (retryAttempt > 0) {
|
||
debugPrint(
|
||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
||
);
|
||
} else {
|
||
debugPrint(
|
||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
||
);
|
||
}
|
||
debugPrint(' Type: ${contact.type.displayName}');
|
||
debugPrint(' Path status: ${contact.pathDescription}');
|
||
if (contact.hasPath) {
|
||
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)');
|
||
} else {
|
||
debugPrint(' ⚠️ No path available - will use flood mode');
|
||
}
|
||
} else if (retryAttempt > 0) {
|
||
debugPrint(
|
||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
||
);
|
||
}
|
||
|
||
// Track pending operation for auto-recovery (if contact not found in radio)
|
||
if (contact != null) {
|
||
final operationId = contactPublicKey
|
||
.sublist(0, 6)
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join(':');
|
||
_pendingSendOperations[operationId] = _PendingSendOperation(
|
||
contactPublicKey: contactPublicKey,
|
||
text: text,
|
||
messageId: messageId,
|
||
contact: contact,
|
||
retryAttempt: retryAttempt,
|
||
);
|
||
debugPrint(
|
||
' 📝 Tracked pending operation for auto-recovery: $operationId',
|
||
);
|
||
}
|
||
|
||
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
|
||
// The SENT response can arrive so quickly that if we track after sending,
|
||
// the callback will fire before we add the message ID to the queue.
|
||
//
|
||
// NOTE: For grouped messages, we no longer need complex contact-keyed tracking.
|
||
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
||
// We still track here for the SENT response callback to work.
|
||
if (messageId != null) {
|
||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
||
debugPrint(' 📝 Tracked pending message: $messageId');
|
||
}
|
||
|
||
// Send the message with retry attempt info
|
||
await _bleService.sendTextMessage(
|
||
contactPublicKey: contactPublicKey,
|
||
text: text,
|
||
attempt: retryAttempt,
|
||
);
|
||
|
||
// Clear pending operation after successful send (no error)
|
||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||
if (contact != null) {
|
||
final operationId = contactPublicKey
|
||
.sublist(0, 6)
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join(':');
|
||
// Use a small delay to allow error response to arrive before clearing
|
||
Future.delayed(const Duration(milliseconds: 500), () {
|
||
_pendingSendOperations.remove(operationId);
|
||
});
|
||
}
|
||
|
||
return true;
|
||
} catch (e) {
|
||
_error = 'Failed to send message: $e';
|
||
notifyListeners();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// Send channel message
|
||
///
|
||
/// [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({
|
||
required int channelIdx,
|
||
required String text,
|
||
String? messageId,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
debugPrint('📨 [ConnectionProvider] sendChannelMessage called:');
|
||
debugPrint(' Channel: $channelIdx');
|
||
debugPrint(' Text: $text');
|
||
debugPrint(' MessageID: $messageId');
|
||
|
||
await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text);
|
||
|
||
debugPrint('✅ [ConnectionProvider] BLE send completed');
|
||
debugPrint(
|
||
' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}',
|
||
);
|
||
|
||
// Channel messages are ephemeral (not persisted) - mark as "sent" immediately
|
||
// They don't have ACK/TAG mechanism like direct messages
|
||
if (messageId != null) {
|
||
debugPrint('✅ [ConnectionProvider] Channel message sent successfully');
|
||
debugPrint(' Message ID: $messageId');
|
||
debugPrint(' onMessageSent callback exists: ${onMessageSent != null}');
|
||
|
||
// Track for echo detection
|
||
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
|
||
debugPrint(' Calling trackSentChannelMessage...');
|
||
_bleService.trackSentChannelMessage(messageId);
|
||
debugPrint(' trackSentChannelMessage completed');
|
||
|
||
// Small delay to ensure the message is in the MessagesProvider list
|
||
// before we try to mark it as sent
|
||
await Future.delayed(const Duration(milliseconds: 50));
|
||
|
||
// Use a dummy ACK tag (0) and timeout (0) for channel messages
|
||
// This will trigger the callback to mark the message as "sent"
|
||
debugPrint(' Calling onMessageSent callback...');
|
||
onMessageSent?.call(messageId, 0, 0);
|
||
debugPrint(' onMessageSent callback completed');
|
||
}
|
||
} catch (e) {
|
||
_error = 'Failed to send channel message: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Request telemetry from contact
|
||
///
|
||
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
||
/// Depending on device firmware version, the response will be either:
|
||
/// - PUSH_CODE_TELEMETRY_RESPONSE (0x8B) - older firmware
|
||
/// - PUSH_CODE_BINARY_RESPONSE (0x8C) - newer firmware
|
||
///
|
||
/// Both response types are handled via callbacks:
|
||
/// - onTelemetryReceived (for 0x8B)
|
||
/// - onBinaryResponse (for 0x8C)
|
||
///
|
||
/// The app properly handles BOTH response types, so this method is NOT
|
||
/// deprecated and should continue to be used for telemetry requests.
|
||
///
|
||
/// [zeroHop] - if true, only direct connection (no mesh forwarding)
|
||
Future<void> requestTelemetry(
|
||
Uint8List contactPublicKey, {
|
||
bool zeroHop = false,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.requestTelemetry(contactPublicKey, zeroHop: zeroHop);
|
||
} catch (e) {
|
||
_error = 'Failed to request telemetry: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Smart ping with automatic fallback to flooding
|
||
///
|
||
/// Sends a telemetry request (ping) to a contact, and if no response is
|
||
/// received within timeout, automatically retries with flooding mode.
|
||
///
|
||
/// Returns a PingResult with information about the response.
|
||
///
|
||
/// [contact] - the contact to ping (used to determine if path exists)
|
||
/// [onRetryWithFlooding] - optional callback when fallback to flooding occurs
|
||
Future<PingResult> smartPing({
|
||
required Uint8List contactPublicKey,
|
||
required bool hasPath,
|
||
Function()? onRetryWithFlooding,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return PingResult(success: false, usedFlooding: false, timedOut: true);
|
||
}
|
||
|
||
// First attempt: Use zeroHop (direct) if we have a path, otherwise use flooding
|
||
final bool firstAttemptDirect = hasPath;
|
||
|
||
try {
|
||
// Track the ping request
|
||
final pingFuture = _pingTracker.trackPing(
|
||
publicKey: contactPublicKey,
|
||
wasDirectAttempt: firstAttemptDirect,
|
||
);
|
||
|
||
// Send the ping
|
||
await _bleService.requestTelemetry(contactPublicKey, zeroHop: true);
|
||
|
||
// Wait for response or timeout
|
||
final bool gotResponse = await pingFuture;
|
||
|
||
if (gotResponse) {
|
||
// Success on first attempt
|
||
return PingResult(
|
||
success: true,
|
||
usedFlooding: !firstAttemptDirect,
|
||
timedOut: false,
|
||
);
|
||
}
|
||
|
||
// First attempt timed out - retry with flooding if first was direct
|
||
if (firstAttemptDirect) {
|
||
debugPrint(
|
||
'⚠️ [Provider] Ping timeout on direct attempt, retrying with flooding...',
|
||
);
|
||
onRetryWithFlooding?.call();
|
||
|
||
// Track the retry
|
||
final retryFuture = _pingTracker.trackPing(
|
||
publicKey: contactPublicKey,
|
||
wasDirectAttempt: false,
|
||
);
|
||
|
||
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
|
||
await _bleService.requestTelemetry(contactPublicKey, zeroHop: true);
|
||
|
||
// Wait for response or timeout
|
||
final bool gotRetryResponse = await retryFuture;
|
||
|
||
return PingResult(
|
||
success: gotRetryResponse,
|
||
usedFlooding: true,
|
||
timedOut: !gotRetryResponse,
|
||
retriedWithFlooding: true,
|
||
);
|
||
}
|
||
|
||
// First attempt was already flooding and it timed out
|
||
return PingResult(success: false, usedFlooding: true, timedOut: true);
|
||
} catch (e) {
|
||
_error = 'Failed to ping contact: $e';
|
||
notifyListeners();
|
||
return PingResult(success: false, usedFlooding: false, timedOut: true);
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.getDeviceTime();
|
||
} catch (e) {
|
||
_error = 'Failed to get device time: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Set device time to current time
|
||
Future<void> syncDeviceTime() async {
|
||
if (!_bleService.isConnected) return;
|
||
|
||
try {
|
||
await _bleService.setDeviceTime();
|
||
} catch (e) {
|
||
_error = 'Failed to sync time: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Set advertised name
|
||
Future<void> setAdvertName(String name) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.setAdvertName(name);
|
||
} catch (e) {
|
||
_error = 'Failed to set name: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Set advertised position
|
||
Future<void> setAdvertLatLon({
|
||
required double latitude,
|
||
required double longitude,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.setAdvertLatLon(
|
||
latitude: latitude,
|
||
longitude: longitude,
|
||
);
|
||
} catch (e) {
|
||
_error = 'Failed to set position: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Send self advertisement to mesh network
|
||
///
|
||
/// Broadcasts the device's current advertisement data (name, location, etc.)
|
||
/// to the mesh network. Use this after updating position or name to notify
|
||
/// other nodes of the change.
|
||
///
|
||
/// [floodMode] - if true, broadcast to entire mesh (default for SAR ops)
|
||
/// if false, only send to direct neighbors (zero-hop)
|
||
Future<void> sendSelfAdvert({bool floodMode = true}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (_isAdvertInProgress) return;
|
||
// Throttle rapid advert requests
|
||
final now = DateTime.now();
|
||
if (_lastAdvertRequestedAt != null) {
|
||
final elapsed = now.difference(_lastAdvertRequestedAt!);
|
||
if (elapsed < _minAdvertInterval) {
|
||
final wait = _minAdvertInterval - elapsed;
|
||
await Future.delayed(wait);
|
||
}
|
||
}
|
||
_isAdvertInProgress = true;
|
||
await _bleService.sendSelfAdvert(floodMode: floodMode);
|
||
_lastAdvertRequestedAt = DateTime.now();
|
||
} catch (e) {
|
||
_error = 'Failed to send advertisement: $e';
|
||
notifyListeners();
|
||
} finally {
|
||
_isAdvertInProgress = false;
|
||
}
|
||
}
|
||
|
||
/// Set radio parameters
|
||
Future<void> setRadioParams({
|
||
required int frequency,
|
||
required int bandwidth,
|
||
required int spreadingFactor,
|
||
required int codingRate,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.setRadioParams(
|
||
frequency: frequency,
|
||
bandwidth: bandwidth,
|
||
spreadingFactor: spreadingFactor,
|
||
codingRate: codingRate,
|
||
);
|
||
} catch (e) {
|
||
_error = 'Failed to set radio params: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Set transmit power
|
||
Future<void> setTxPower(int powerDbm) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.setTxPower(powerDbm);
|
||
} catch (e) {
|
||
_error = 'Failed to set TX power: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Set other parameters (telemetry modes, advert location policy)
|
||
Future<void> setOtherParams({
|
||
required int manualAddContacts,
|
||
required int telemetryModes,
|
||
required int advertLocationPolicy,
|
||
int multiAcks = 0,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.setOtherParams(
|
||
manualAddContacts: manualAddContacts,
|
||
telemetryModes: telemetryModes,
|
||
advertLocationPolicy: advertLocationPolicy,
|
||
multiAcks: multiAcks,
|
||
);
|
||
} catch (e) {
|
||
_error = 'Failed to set other params: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Request fresh device info (triggers SelfInfo response)
|
||
Future<void> refreshDeviceInfo() async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// The device query command triggers a SelfInfo response
|
||
await _bleService.refreshDeviceInfo();
|
||
} catch (e) {
|
||
_error = 'Failed to refresh device info: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
// Prevent re-entrancy and too-fast triggers
|
||
if (_isSyncingMessages) {
|
||
// Another sync (single or loop) is in progress
|
||
return false;
|
||
}
|
||
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Enforce a small gap between consecutive requests
|
||
final now = DateTime.now();
|
||
if (_lastSyncNextRequestedAt != null) {
|
||
final elapsed = now.difference(_lastSyncNextRequestedAt!);
|
||
if (elapsed < _minSyncNextInterval) {
|
||
final remaining = _minSyncNextInterval - elapsed;
|
||
await Future.delayed(remaining);
|
||
}
|
||
}
|
||
|
||
_isSyncingMessages = true;
|
||
await _bleService.syncNextMessage();
|
||
_lastSyncNextRequestedAt = DateTime.now();
|
||
return true;
|
||
} catch (e) {
|
||
_error = 'Failed to sync message: $e';
|
||
notifyListeners();
|
||
return false;
|
||
} finally {
|
||
_isSyncingMessages = false;
|
||
}
|
||
}
|
||
|
||
/// Sync all waiting messages from device
|
||
Future<int> syncAllMessages() async {
|
||
if (_isSyncingMessages) {
|
||
// Already syncing; avoid overlapping loops
|
||
return 0;
|
||
}
|
||
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return 0;
|
||
}
|
||
|
||
int count = 0;
|
||
_noMoreMessages = false; // Reset flag
|
||
|
||
try {
|
||
_isSyncingMessages = true;
|
||
debugPrint('🔄 [Provider] Starting message sync loop...');
|
||
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
|
||
|
||
// Keep syncing until we get NoMoreMessages response
|
||
// The device will send ContactMsgRecv or ChannelMsgRecv responses
|
||
// until it sends NoMoreMessages
|
||
for (int i = 0; i < 100; i++) {
|
||
// Safety limit
|
||
// Check flag BEFORE sending (not after)
|
||
if (_noMoreMessages) {
|
||
debugPrint(
|
||
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests',
|
||
);
|
||
break;
|
||
}
|
||
|
||
debugPrint(
|
||
'📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE',
|
||
);
|
||
|
||
// Create new completer for this request
|
||
_syncResponseCompleter = Completer<bool>();
|
||
|
||
// Respect the minimum interval between requests
|
||
final now = DateTime.now();
|
||
if (_lastSyncNextRequestedAt != null) {
|
||
final elapsed = now.difference(_lastSyncNextRequestedAt!);
|
||
if (elapsed < _minSyncNextInterval) {
|
||
final remaining = _minSyncNextInterval - elapsed;
|
||
await Future.delayed(remaining);
|
||
}
|
||
}
|
||
|
||
await _bleService.syncNextMessage();
|
||
_lastSyncNextRequestedAt = DateTime.now();
|
||
count++;
|
||
|
||
// Wait for response (true = message received, false = no more messages)
|
||
// Timeout after 2 seconds to prevent hanging
|
||
final hasMore = await _syncResponseCompleter!.future.timeout(
|
||
const Duration(seconds: 2),
|
||
onTimeout: () {
|
||
debugPrint('⚠️ [Provider] Sync timeout - no response after 2s');
|
||
return false;
|
||
},
|
||
);
|
||
|
||
debugPrint(
|
||
' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages',
|
||
);
|
||
|
||
if (!hasMore) {
|
||
debugPrint(' ✅ No more messages available, stopping sync');
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!_noMoreMessages && count >= 100) {
|
||
debugPrint(
|
||
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
|
||
);
|
||
}
|
||
|
||
debugPrint(
|
||
'🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages',
|
||
);
|
||
return count;
|
||
} catch (e) {
|
||
debugPrint('❌ [Provider] Failed to sync messages: $e');
|
||
_error = 'Failed to sync messages: $e';
|
||
notifyListeners();
|
||
return count;
|
||
} finally {
|
||
_isSyncingMessages = false;
|
||
_syncResponseCompleter = null;
|
||
}
|
||
}
|
||
|
||
/// Login to a room or repeater
|
||
///
|
||
/// Sends login request with password. Results will be delivered via
|
||
/// onLoginSuccess or onLoginFail callbacks.
|
||
///
|
||
/// Example usage:
|
||
/// ```dart
|
||
/// connectionProvider.onLoginSuccess = (pkPrefix, perms, isAdmin, tag) {
|
||
/// debugPrint('Successfully logged in to room!');
|
||
/// };
|
||
/// connectionProvider.onLoginFail = (pkPrefix) {
|
||
/// debugPrint('Login failed - incorrect password');
|
||
/// };
|
||
/// await connectionProvider.loginToRoom(
|
||
/// roomPublicKey: contact.publicKey,
|
||
/// password: 'secret123',
|
||
/// );
|
||
/// ```
|
||
Future<void> loginToRoom({
|
||
required Uint8List roomPublicKey,
|
||
required String password,
|
||
}) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (_isLoginInProgress) return;
|
||
// Throttle rapid login attempts
|
||
final now = DateTime.now();
|
||
if (_lastLoginRequestedAt != null) {
|
||
final elapsed = now.difference(_lastLoginRequestedAt!);
|
||
if (elapsed < _minLoginInterval) {
|
||
final wait = _minLoginInterval - elapsed;
|
||
await Future.delayed(wait);
|
||
}
|
||
}
|
||
_isLoginInProgress = true;
|
||
await _bleService.loginToRoom(
|
||
roomPublicKey: roomPublicKey,
|
||
password: password,
|
||
);
|
||
_lastLoginRequestedAt = DateTime.now();
|
||
} catch (e) {
|
||
_error = 'Failed to send login request: $e';
|
||
notifyListeners();
|
||
} finally {
|
||
_isLoginInProgress = false;
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
/// debugPrint('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 {
|
||
if (_isStatusRequestInProgress) return;
|
||
// Throttle rapid status requests
|
||
final now = DateTime.now();
|
||
if (_lastStatusRequestedAt != null) {
|
||
final elapsed = now.difference(_lastStatusRequestedAt!);
|
||
if (elapsed < _minStatusInterval) {
|
||
final wait = _minStatusInterval - elapsed;
|
||
await Future.delayed(wait);
|
||
}
|
||
}
|
||
_isStatusRequestInProgress = true;
|
||
await _bleService.sendStatusRequest(contactPublicKey);
|
||
_lastStatusRequestedAt = DateTime.now();
|
||
} catch (e) {
|
||
_error = 'Failed to send status request: $e';
|
||
notifyListeners();
|
||
} finally {
|
||
_isStatusRequestInProgress = false;
|
||
}
|
||
}
|
||
|
||
/// Reset routing path for a contact
|
||
///
|
||
/// Clears the learned path to a contact, forcing the next message to use
|
||
/// flood routing to discover a new route. Useful when:
|
||
/// - A mobile repeater has moved and the path is broken
|
||
/// - You want to find a better/shorter route
|
||
/// - Direct messages are timing out due to path issues
|
||
///
|
||
/// After calling this, the device will automatically fall back to flood mode
|
||
/// for the next message to this contact, and learn a new path from the response.
|
||
Future<void> resetPath(Uint8List contactPublicKey) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.resetPath(contactPublicKey);
|
||
} catch (e) {
|
||
_error = 'Failed to reset path: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Remove a contact from the companion radio
|
||
///
|
||
/// Deletes the contact from the device's internal contact table.
|
||
/// The contact will no longer appear in the contact list and all
|
||
/// routing information will be cleared.
|
||
Future<void> removeContact(Uint8List contactPublicKey) async {
|
||
if (!_bleService.isConnected) {
|
||
_error = 'Not connected to device';
|
||
notifyListeners();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _bleService.removeContact(contactPublicKey);
|
||
} catch (e) {
|
||
_error = 'Failed to remove contact: $e';
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
/// Clear error message
|
||
void clearError() {
|
||
_error = null;
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Get login state for a room by public key prefix
|
||
RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) {
|
||
return _roomLoginManager.getRoomLoginState(publicKeyPrefix);
|
||
}
|
||
|
||
/// Check if logged into a specific room
|
||
bool isLoggedIntoRoom(Uint8List publicKeyPrefix) {
|
||
return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix);
|
||
}
|
||
|
||
// ============================================================================
|
||
// SSE Server Methods
|
||
// ============================================================================
|
||
|
||
/// Start SSE server to share BLE device with multiple clients
|
||
Future<void> startSseServer(SseServerConfig config) async {
|
||
if (_sseServer.isRunning) {
|
||
debugPrint('⚠️ [ConnectionProvider] SSE server already running');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
debugPrint('🚀 [ConnectionProvider] Starting SSE server...');
|
||
_sseServerConfig = config;
|
||
|
||
// Wire up callbacks
|
||
_sseServer.onSendMessage = (recipientPublicKey, text) async {
|
||
// Convert hex string to Uint8List
|
||
final bytes = <int>[];
|
||
for (int i = 0; i < recipientPublicKey.length; i += 2) {
|
||
bytes.add(int.parse(recipientPublicKey.substring(i, i + 2), radix: 16));
|
||
}
|
||
return await sendTextMessage(
|
||
contactPublicKey: Uint8List.fromList(bytes),
|
||
text: text,
|
||
);
|
||
};
|
||
|
||
_sseServer.onSendChannelMessage = (channelIdx, text) async {
|
||
await sendChannelMessage(channelIdx: channelIdx, text: text);
|
||
};
|
||
|
||
_sseServer.onSyncContacts = () async {
|
||
await getContacts();
|
||
};
|
||
|
||
await _sseServer.startServer(config);
|
||
|
||
// Set initial device name
|
||
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
|
||
|
||
_connectionMode = ConnectionMode.sseServer;
|
||
notifyListeners();
|
||
|
||
debugPrint('✅ [ConnectionProvider] SSE server started');
|
||
} catch (e) {
|
||
_error = 'Failed to start SSE server: $e';
|
||
debugPrint('❌ [ConnectionProvider] Failed to start SSE server: $e');
|
||
notifyListeners();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Stop SSE server
|
||
Future<void> stopSseServer() async {
|
||
if (!_sseServer.isRunning) {
|
||
return;
|
||
}
|
||
|
||
debugPrint('🛑 [ConnectionProvider] Stopping SSE server...');
|
||
await _sseServer.stopServer();
|
||
|
||
if (_connectionMode == ConnectionMode.sseServer) {
|
||
_connectionMode = ConnectionMode.ble;
|
||
}
|
||
|
||
notifyListeners();
|
||
debugPrint('✅ [ConnectionProvider] SSE server stopped');
|
||
}
|
||
|
||
/// Broadcast message to SSE clients (call this when receiving messages from BLE)
|
||
void broadcastMessageToSseClients(Message message) {
|
||
if (_sseServer.isRunning) {
|
||
_sseServer.broadcastMessage(message);
|
||
}
|
||
}
|
||
|
||
/// Broadcast contact to SSE clients (call this when receiving contacts from BLE)
|
||
void broadcastContactToSseClients(Contact contact) {
|
||
if (_sseServer.isRunning) {
|
||
_sseServer.broadcastContact(contact);
|
||
}
|
||
}
|
||
|
||
/// Get SSE server status
|
||
bool get isSseServerRunning => _sseServer.isRunning;
|
||
|
||
/// Get number of connected SSE clients
|
||
int get sseClientCount => _sseServer.connectedClients;
|
||
|
||
// ============================================================================
|
||
// SSE Client Methods
|
||
// ============================================================================
|
||
|
||
/// Connect to remote SSE server
|
||
Future<void> connectToSseServer({
|
||
required String serverUrl,
|
||
String? authToken,
|
||
}) async {
|
||
if (_sseClient.isConnected) {
|
||
debugPrint('⚠️ [ConnectionProvider] SSE client already connected');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
debugPrint('🔌 [ConnectionProvider] Connecting to SSE server: $serverUrl');
|
||
_sseClientServerUrl = serverUrl;
|
||
|
||
// Wire up callbacks
|
||
_sseClient.onMessageReceived = (message) {
|
||
debugPrint('📥 [ConnectionProvider] Received message from SSE server');
|
||
onMessageReceived?.call(message);
|
||
};
|
||
|
||
_sseClient.onContactReceived = (contact) {
|
||
debugPrint('📥 [ConnectionProvider] Received contact from SSE server');
|
||
onContactReceived?.call(contact);
|
||
};
|
||
|
||
_sseClient.onConnectionStateChanged = (isConnected) {
|
||
debugPrint('🔔 [ConnectionProvider] SSE client connection state changed: $isConnected');
|
||
if (isConnected) {
|
||
debugPrint('✅ [ConnectionProvider] SSE client connected - updating UI state');
|
||
} else {
|
||
debugPrint('❌ [ConnectionProvider] SSE client disconnected - updating UI state');
|
||
}
|
||
_deviceInfo = _deviceInfo.copyWith(
|
||
connectionState: isConnected
|
||
? ConnectionState.connected
|
||
: ConnectionState.disconnected,
|
||
);
|
||
notifyListeners();
|
||
};
|
||
|
||
_sseClient.onError = (error) {
|
||
debugPrint('❌ [ConnectionProvider] SSE client error: $error');
|
||
_error = error;
|
||
notifyListeners();
|
||
};
|
||
|
||
debugPrint('📌 [ConnectionProvider] SSE callbacks registered, starting connection...');
|
||
await _sseClient.connect(serverUrl: serverUrl, authToken: authToken);
|
||
|
||
_connectionMode = ConnectionMode.sseClient;
|
||
notifyListeners();
|
||
|
||
debugPrint('✅ [ConnectionProvider] Connected to SSE server');
|
||
debugPrint('📊 [ConnectionProvider] SSE client state: isConnected=${_sseClient.isConnected}');
|
||
debugPrint('📊 [ConnectionProvider] DeviceInfo state: connectionState=${_deviceInfo.connectionState}, isConnected=${_deviceInfo.isConnected}');
|
||
} catch (e) {
|
||
_error = 'Failed to connect to SSE server: $e';
|
||
debugPrint('❌ [ConnectionProvider] Failed to connect to SSE server: $e');
|
||
notifyListeners();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Disconnect from SSE server
|
||
Future<void> disconnectFromSseServer() async {
|
||
if (!_sseClient.isConnected) {
|
||
return;
|
||
}
|
||
|
||
debugPrint('🔌 [ConnectionProvider] Disconnecting from SSE server...');
|
||
await _sseClient.disconnect();
|
||
|
||
_sseClientServerUrl = null;
|
||
|
||
if (_connectionMode == ConnectionMode.sseClient) {
|
||
_connectionMode = ConnectionMode.ble;
|
||
}
|
||
|
||
notifyListeners();
|
||
debugPrint('✅ [ConnectionProvider] Disconnected from SSE server');
|
||
}
|
||
|
||
/// Send message via SSE client (when in client mode)
|
||
Future<bool> sendMessageViaSseClient({
|
||
required Uint8List contactPublicKey,
|
||
required String text,
|
||
}) async {
|
||
if (!_sseClient.isConnected) {
|
||
throw Exception('Not connected to SSE server');
|
||
}
|
||
|
||
final publicKeyHex = contactPublicKey
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join('');
|
||
|
||
return await _sseClient.sendMessage(
|
||
recipientPublicKey: publicKeyHex,
|
||
text: text,
|
||
);
|
||
}
|
||
|
||
/// Send channel message via SSE client (when in client mode)
|
||
Future<void> sendChannelMessageViaSseClient({
|
||
required int channelIdx,
|
||
required String text,
|
||
}) async {
|
||
if (!_sseClient.isConnected) {
|
||
throw Exception('Not connected to SSE server');
|
||
}
|
||
|
||
await _sseClient.sendChannelMessage(
|
||
channelIdx: channelIdx,
|
||
text: text,
|
||
);
|
||
}
|
||
|
||
/// Get SSE client connection status
|
||
bool get isSseClientConnected => _sseClient.isConnected;
|
||
|
||
/// Set connection mode
|
||
void setConnectionMode(ConnectionMode mode) {
|
||
_connectionMode = mode;
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Update SSE server configuration
|
||
void updateSseServerConfig(SseServerConfig config) {
|
||
_sseServerConfig = config;
|
||
notifyListeners();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_rxActivityTimer?.cancel();
|
||
_txActivityTimer?.cancel();
|
||
_bleService.dispose();
|
||
_sseServer.stopServer();
|
||
_sseClient.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|