Files
meshcore-sar_android/lib/providers/connection_provider.dart
2026-03-14 16:34:31 +01:00

2401 lines
75 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart';
import '../models/contact.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' hide Contact;
import '../services/sse_server_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 device connection (BLE or TCP/WiFi)
class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService();
final SseServerService _sseServer = SseServerService();
MeshCoreTcpService? _tcpService;
/// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService;
/// Active service — BLE or TCP depending on current mode
MeshCoreServiceBase get _activeService =>
(_connectionMode == ConnectionMode.tcp && _tcpService != null)
? _tcpService!
: _bleService;
/// Current connection mode
ConnectionMode _connectionMode = ConnectionMode.ble;
ConnectionMode get connectionMode => _connectionMode;
/// SSE server configuration
SseServerConfig _sseServerConfig = const SseServerConfig();
SseServerConfig get sseServerConfig => _sseServerConfig;
/// TCP host last connected to (for display / reconnection info)
String? _tcpHost;
String? get tcpHost => _tcpHost;
DeviceInfo _deviceInfo = DeviceInfo();
DeviceInfo get deviceInfo => _deviceInfo;
final List<ScannedDevice> _scannedDevices = [];
List<ScannedDevice> get scannedDevices => _scannedDevices;
bool _isScanning = false;
bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
final Set<int> _pendingDeletedChannelIndices = <int>{};
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 => _activeService.rxPacketCount;
int get txPacketCount => _activeService.txPacketCount;
// Reconnection state
bool get isReconnecting => _activeService.isReconnecting;
int get reconnectionAttempt => _activeService.reconnectionAttempt;
int get maxReconnectionAttempts => _activeService.maxReconnectionAttempts;
// Message sync state
bool _noMoreMessages = false;
// Prevent overlapping/too-frequent sync requests
bool _isSyncingMessages = false;
// If MSG_WAITING arrives while a sync loop is active, queue one more pass.
bool _syncRequestedWhileBusy = false;
DateTime? _lastSyncNextRequestedAt;
static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
// Completer to wait for response before sending next sync request
Completer<bool>? _syncResponseCompleter;
// Completer to wait for contacts sync to finish
Completer<void>? _contactsSyncCompleter;
// 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();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// 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;
Future<Contact?> Function({
required String messageId,
required Contact contact,
required int retryAttempt,
})?
prepareDirectMessageSendCallback;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
bool _pendingAutomaticMessageSync = false;
ConnectionProvider() {
_wireServiceCallbacks(_bleService);
}
/// Wire all shared event callbacks onto [service].
/// Called for both BLE and TCP services so the provider handles events
/// identically regardless of transport.
void _wireServiceCallbacks(MeshCoreServiceBase service) {
service.onConnectionStateChanged = (isConnected) {
debugPrint('🔔 [Provider] Connection state callback fired: $isConnected');
_deviceInfo = _deviceInfo.copyWith(
connectionState: isConnected
? ConnectionState.connected
: (service.isReconnecting
? ConnectionState.connecting
: ConnectionState.disconnected),
lastUpdate: DateTime.now(),
);
debugPrint(
' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}',
);
debugPrint(
' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}',
);
debugPrint(' isReconnecting: ${service.isReconnecting}');
// Start/stop ACK cleanup timer based on connection state
if (isConnected) {
_startAckCleanupTimer();
} else {
_stopAckCleanupTimer();
}
notifyListeners();
debugPrint(' Notified listeners');
};
service.onReconnectionAttempt = (attemptNumber, maxAttempts) {
debugPrint(
'🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts',
);
notifyListeners();
};
service.onError = (error, {int? errorCode}) {
debugPrint('⚠️ [Provider] Error received: $error');
_error = error;
if (_deviceInfo.connectionState != ConnectionState.connected) {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
}
notifyListeners();
};
service.onContactNotFound = (contactPublicKey) async {
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
if (contactPublicKey == null) return;
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) return;
try {
await _activeService.addOrUpdateContact(pendingOp.contact!);
if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
}
await _activeService.sendTextMessage(
contactPublicKey: pendingOp.contactPublicKey,
text: pendingOp.text,
attempt: pendingOp.retryAttempt,
);
_pendingSendOperations.remove(operationId);
} catch (e) {
debugPrint(' ❌ Auto-recovery failed: $e');
_error = 'Auto-recovery failed: $e';
notifyListeners();
_pendingSendOperations.remove(operationId);
}
};
service.onContactReceived = (contact) {
debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
onContactReceived?.call(contact);
};
service.onContactsComplete = (contacts) {
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete();
}
onContactsComplete?.call(contacts);
};
service.onChannelInfoReceived =
(int channelIdx, String channelName, Uint8List secret, int? flags) {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
service.onContactDeleted = (publicKey) => onContactDeleted?.call(publicKey);
service.onContactsFull = () => onContactsFull?.call();
service.onMessageReceived = (message) {
final enhancedMessage = SarMessageParser.enhanceMessage(message);
onMessageReceived?.call(enhancedMessage);
if (_syncResponseCompleter != null &&
!_syncResponseCompleter!.isCompleted) {
_syncResponseCompleter!.complete(true);
}
};
service.onTelemetryReceived = (publicKey, lppData) {
debugPrint('📥 [Provider] Telemetry received');
_pingTracker.markPingSuccessful(publicKey);
onTelemetryReceived?.call(publicKey, lppData);
};
service.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
debugPrint('📥 [Provider] Binary response received');
_pingTracker.markPingSuccessful(publicKeyPrefix);
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
};
service.onNoMoreMessages = () {
_noMoreMessages = true;
if (_syncResponseCompleter != null &&
!_syncResponseCompleter!.isCompleted) {
_syncResponseCompleter!.complete(false);
}
};
service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
);
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) {
_syncRequestedWhileBusy = true;
debugPrint(
' ↪️ [Provider] Sync already running; queued follow-up sync',
);
return;
}
unawaited(syncAllMessages());
};
service.onLoginSuccess =
(publicKeyPrefix, permissions, isAdmin, tag) async {
await _roomLoginManager.handleLoginSuccess(
publicKeyPrefix: publicKeyPrefix,
permissions: permissions,
isAdmin: isAdmin,
tag: tag,
);
notifyListeners();
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
service.onLoginFail = (publicKeyPrefix) {
_roomLoginManager.handleLoginFail(publicKeyPrefix: publicKeyPrefix);
notifyListeners();
onLoginFail?.call(publicKeyPrefix);
};
service.onAdvertReceived = (publicKey) => onAdvertReceived?.call(publicKey);
service.onPathUpdated = (publicKey) => onPathUpdated?.call(publicKey);
service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag,
messageId,
);
onMessageSent?.call(messageId, expectedAckTag, suggestedTimeoutMs);
}
};
service.onMessageDelivered = (ackCode, roundTripTimeMs) =>
onMessageDelivered?.call(ackCode, roundTripTimeMs);
service.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) =>
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
service.onStatusResponse = (publicKeyPrefix, statusData) =>
onStatusResponse?.call(publicKeyPrefix, statusData);
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] DeviceInfo received');
_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?,
clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
);
notifyListeners();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
};
service.onSelfInfoReceived = (selfInfo) {
debugPrint('📥 [Provider] SelfInfo received');
_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();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
};
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
_deviceInfo = _deviceInfo.copyWith(
batteryMilliVolts: millivolts,
storageUsedKb: usedKb,
storageTotalKb: totalKb,
lastUpdate: DateTime.now(),
);
notifyListeners();
};
service.onRxActivity = () {
_rxActivity = true;
notifyListeners();
_rxActivityTimer?.cancel();
_rxActivityTimer = Timer(const Duration(milliseconds: 100), () {
_rxActivity = false;
notifyListeners();
});
};
service.onAllowedRepeatFreqReceived = (ranges) {
_deviceInfo = _deviceInfo.copyWith(allowedRepeatFreqRanges: ranges);
notifyListeners();
};
service.onTxActivity = () {
_txActivity = true;
notifyListeners();
_txActivityTimer?.cancel();
_txActivityTimer = Timer(const Duration(milliseconds: 100), () {
_txActivity = false;
notifyListeners();
});
};
service.onRssiUpdate = (rssi) {
_deviceInfo = _deviceInfo.copyWith(
signalRssi: rssi,
lastUpdate: DateTime.now(),
);
notifyListeners();
};
}
/// Start scanning for MeshCore devices
Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true;
_scannedDevices.clear();
_error = null;
_notifyListenersSafely();
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}',
);
_notifyListenersSafely();
} 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',
);
_notifyListenersSafely();
} 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;
_notifyListenersSafely();
}
}
/// Stop scanning
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
_isScanning = false;
_notifyListenersSafely();
}
void _notifyListenersSafely() {
final phase = SchedulerBinding.instance.schedulerPhase;
if (phase == SchedulerPhase.transientCallbacks ||
phase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
return;
}
notifyListeners();
}
/// Connect to a device
Future<bool> connect(BluetoothDevice device) async {
debugPrint(
'🔵 [Provider] connect() called for device: ${device.platformName}',
);
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
// Ensure we route commands to BLE, not a stale TCP service.
_connectionMode = ConnectionMode.ble;
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty
? device.platformName
: 'Unknown',
connectionState: ConnectionState.connecting,
);
_error = null;
_resetSyncState();
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;
}
/// Connect to a MeshCore device over TCP/WiFi (port 5000)
Future<bool> connectTcp(String host, int port) async {
debugPrint('🌐 [Provider] connectTcp() $host:$port');
_tcpHost = host;
_deviceInfo = _deviceInfo.copyWith(
deviceId: '$host:$port',
deviceName: host,
connectionState: ConnectionState.connecting,
);
_error = null;
notifyListeners();
// Create fresh TCP service and wire its callbacks
_tcpService?.dispose();
_tcpService = MeshCoreTcpService();
_wireServiceCallbacks(_tcpService!);
_connectionMode = ConnectionMode.tcp;
final success = await _tcpService!.connect(host, port);
if (!success) {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
notifyListeners();
}
return success;
}
/// Disconnect from TCP/WiFi device
Future<void> disconnectTcp() async {
if (_tcpService != null) {
await _tcpService!.disconnect();
_tcpService!.dispose();
_tcpService = null;
}
_tcpHost = null;
_connectionMode = ConnectionMode.ble;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
_pendingSendOperations.clear();
_messageDeliveryTracker.clearTracking();
notifyListeners();
}
/// Disconnect from device
Future<void> disconnect() async {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.disconnecting,
);
notifyListeners();
if (_connectionMode == ConnectionMode.tcp) {
await disconnectTcp();
return;
}
await _bleService.disconnect();
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
_pendingSendOperations.clear();
_messageDeliveryTracker.clearTracking();
notifyListeners();
}
/// Reset message sync state so the next connect/reconnect can sync cleanly.
void _resetSyncState() {
if (_syncResponseCompleter != null &&
!_syncResponseCompleter!.isCompleted) {
_syncResponseCompleter!.complete(false);
}
_syncResponseCompleter = null;
if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete();
}
_contactsSyncCompleter = null;
_isSyncingMessages = false;
_syncRequestedWhileBusy = false;
_noMoreMessages = false;
_pendingAutomaticMessageSync = false;
}
/// 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.
///
/// Waits for the device to finish sending all contacts (up to 5 s timeout)
/// so callers don't need an arbitrary delay.
Future<void> getContacts() async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_contactsSyncCompleter = Completer<void>();
await _activeService.getContacts();
await _contactsSyncCompleter!.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
debugPrint(
'⚠️ [Provider] Contacts sync timeout - proceeding without full list',
);
},
);
} catch (e) {
_error = 'Failed to get contacts: $e';
notifyListeners();
} finally {
_contactsSyncCompleter = null;
}
}
/// 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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 _activeService.getContacts();
notifyListeners();
}
}
/// Sync all channels from device
Future<void> syncChannels({int? maxChannels}) async {
if (!_activeService.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;
debugPrint(
'📻 [Provider] Syncing channels with tolerant per-slot reads (1-${channelCount - 1})...',
);
for (int i = 1; i < channelCount; i++) {
try {
await _activeService.getChannel(i);
} catch (e) {
debugPrint(
'⚠️ [Provider] Channel sync skipped slot $i after read failure: $e',
);
}
}
debugPrint('✅ [Provider] Channel sync complete');
} 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 (!_activeService.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 _activeService.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;
@visibleForTesting
static bool channelHasConfiguredSecret(Uint8List secret) {
return secret.any((byte) => byte != 0);
}
@visibleForTesting
static bool isDuplicateChannelName({
required String requestedName,
required String existingName,
}) {
return requestedName.startsWith('#') && existingName == requestedName;
}
@visibleForTesting
static int? firstAvailableChannelSlot({
required Set<int> occupiedIndices,
required int maxChannels,
}) {
for (int i = 1; i < maxChannels; i++) {
if (!occupiedIndices.contains(i)) {
return i;
}
}
return null;
}
bool _channelSlotIsOccupied(Object channel) {
final channelName = (channel as dynamic).name as String?;
final secret = (channel as dynamic).secret;
final hasConfiguredSecret =
secret is Uint8List && channelHasConfiguredSecret(secret);
return (channelName != null && channelName.isNotEmpty) ||
hasConfiguredSecret;
}
/// Check if a specific channel slot is empty
Future<bool> isChannelSlotEmpty(int channelIdx) async {
if (!_activeService.isConnected) {
return false;
}
try {
// First check if we already have info about this channel
if (getChannelInfo != null) {
final channel = getChannelInfo!(channelIdx);
if (channel != null) {
return !_channelSlotIsOccupied(channel);
}
}
// If not cached, query the device (awaits the BLE response)
await _activeService.getChannel(channelIdx);
// Check again after query
if (getChannelInfo != null) {
final channel = getChannelInfo!(channelIdx);
if (channel != null) {
return !_channelSlotIsOccupied(channel);
}
}
// If we still have no authoritative answer after an on-device query,
// treat the slot as occupied/unknown so we never overwrite a channel
// because of stale or delayed state propagation.
debugPrint(
' ⚠️ Slot $channelIdx state is still unknown after query; refusing to treat it as empty',
);
return false;
} catch (e) {
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
if (_isEmptyChannelQueryError(e)) {
debugPrint(
' Treating slot $channelIdx as empty because the device reported it was not found',
);
return true;
}
return false;
}
}
bool _isEmptyChannelQueryError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('not found');
}
bool _isChannelRefreshTimeoutError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('0x1f') && message.contains('timed out');
}
bool shouldTreatChannelInfoAsDeleted(
int channelIdx,
String channelName,
Uint8List secret,
) {
final isEmptyInfo =
channelIdx != 0 &&
channelName.isEmpty &&
secret.every((byte) => byte == 0);
if (!isEmptyInfo) {
return false;
}
return _pendingDeletedChannelIndices.remove(channelIdx);
}
Set<int> _syncedOccupiedChannelIndices({required int maxChannels}) {
if (getChannelInfo == null) {
throw Exception('Channel state is unavailable');
}
final occupiedIndices = <int>{};
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel != null && _channelSlotIsOccupied(channel)) {
occupiedIndices.add(i);
}
}
return occupiedIndices;
}
Future<int?> findNextEmptyChannelSlot() async {
if (!_activeService.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;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final occupiedIndices = _syncedOccupiedChannelIndices(
maxChannels: maxChannels,
);
final emptySlot = firstAvailableChannelSlot(
occupiedIndices: occupiedIndices,
maxChannels: maxChannels,
);
if (emptySlot != null) {
debugPrint(' ✅ Found empty slot from synced channels: $emptySlot');
return emptySlot;
}
debugPrint(
' ❌ Synced channels report all custom slots occupied ($maxCustomChannels total)',
);
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 (!_activeService.isConnected) {
throw Exception('Not connected to device');
}
try {
debugPrint('📻 [Provider] Creating new channel...');
debugPrint(' Name: $channelName');
final bool isHashChannel = channelName.startsWith('#');
final secretBytes = isHashChannel
? _generateHashChannelSecret(channelName)
: _convertSecretToBytes(channelSecret);
await _createChannelWithSecretBytes(
channelName: channelName,
secretBytes: secretBytes,
isHashChannel: isHashChannel,
);
} catch (e) {
_error = 'Failed to create channel: $e';
debugPrint('❌ [Provider] Channel creation failed: $e');
notifyListeners();
rethrow;
}
}
Future<void> _createChannelWithSecretBytes({
required String channelName,
required List<int> secretBytes,
required bool isHashChannel,
}) async {
final maxChannels = _deviceInfo.maxChannels ?? 40;
if (getChannelInfo != null) {
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel == null) {
continue;
}
final existingName = (channel as dynamic).name as String?;
if (existingName != null &&
existingName.isNotEmpty &&
isDuplicateChannelName(
requestedName: channelName,
existingName: existingName,
)) {
debugPrint(
' ⚠️ Hash channel "$channelName" already exists in slot $i',
);
throw Exception(
'Channel "$channelName" already exists. Hash channels cannot be duplicated.',
);
}
}
}
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final slotIdx = await findNextEmptyChannelSlot();
if (slotIdx == null) {
throw Exception(
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
);
}
debugPrint(
' Channel type: ${isHashChannel ? "Hash channel (#)" : "Private channel"}',
);
debugPrint(' Using empty slot: $slotIdx');
try {
await _activeService.setChannel(
channelIdx: slotIdx,
channelName: channelName,
secret: secretBytes,
);
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx');
}
/// 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 (!_activeService.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...');
_pendingDeletedChannelIndices.add(channelIdx);
// Delete channel on device (sets empty name and zeroed secret)
await _activeService.deleteChannel(channelIdx);
debugPrint(
'✅ [Provider] Channel deleted successfully from slot $channelIdx',
);
// Soft-reset local UI state immediately, matching meshcore-open's
// behavior, then reconcile with an on-device refresh below.
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
// Refresh channel from device to reconcile UI state.
// BLE commands are processed in order by firmware, so the
// getChannel response reflects the completed deletion.
await _activeService.getChannel(channelIdx);
} catch (e) {
_pendingDeletedChannelIndices.remove(channelIdx);
_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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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, >0 = retries)
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
String? messageId,
Contact? contact,
int retryAttempt = 0,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return false;
}
var effectiveContact = contact;
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
if (messageId != null &&
effectiveContact != null &&
prepareDirectMessageSendCallback != null) {
effectiveContact = await prepareDirectMessageSendCallback!(
messageId: messageId,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
}
// 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 confirmation...',
);
// Wait for a slot to free up (delivery/timeout), max 500ms
await _messageDeliveryTracker.waitForSlot();
if (_messageDeliveryTracker.shouldRateLimit) {
debugPrint(
'⚠️ Still at limit after wait - proceeding anyway (may lose ACK tracking)',
);
}
}
try {
// Log path status and retry info
if (effectiveContact != null) {
if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
);
} else {
debugPrint(
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
);
}
debugPrint(' Type: ${effectiveContact.type.displayName}');
debugPrint(' Path status: ${effectiveContact.routeSummary}');
if (effectiveContact.routeHasPath) {
debugPrint(
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
);
} else {
debugPrint(' ⚠️ No path available - will use flood mode');
}
} else if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
);
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (effectiveContact != 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: effectiveContact,
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.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId');
}
// Send the message with retry attempt info
await _activeService.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
attempt: retryAttempt,
);
// Clear pending operation — any ERR_CODE_NOT_FOUND has already been
// handled synchronously by onContactNotFound before sendTextMessage returns.
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
_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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
debugPrint('📨 [ConnectionProvider] sendChannelMessage called:');
debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
await _activeService.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...');
_activeService.trackSentChannelMessage(
messageId,
channelIdx: channelIdx,
plainText: text,
);
debugPrint(' trackSentChannelMessage completed');
// 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();
}
}
/// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25).
/// Only works for contacts with a known direct route (outPathLen >= 0).
Future<void> sendRawVoicePacket({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
}) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: contactPathLen,
contactPath: contactPath,
payload: payload,
);
}
/// Send a raw private zero-hop payload.
///
/// This wraps the raw custom transport using an empty path to match the
/// firmware's private multicast behavior.
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: 0,
contactPath: Uint8List(0),
payload: payload,
);
}
/// 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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
if (!_activeService.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 _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// 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.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// 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);
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// 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 (!_activeService.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 _activeService.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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.getDeviceTime();
} catch (e) {
_error = 'Failed to get device time: $e';
notifyListeners();
}
}
/// Set device time to current time
Future<void> syncDeviceTime() async {
if (!_activeService.isConnected) return;
try {
await _activeService.setDeviceTime();
} catch (e) {
_error = 'Failed to sync time: $e';
notifyListeners();
}
}
Future<void> factoryResetDevice() async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
await _activeService.factoryReset();
} catch (e) {
_error = 'Failed to wipe device data: $e';
notifyListeners();
}
}
/// Set advertised name
Future<void> setAdvertName(String name) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 (!_activeService.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 _activeService.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,
bool? repeat,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.setRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
repeat: repeat,
);
} catch (e) {
_error = 'Failed to set radio params: $e';
notifyListeners();
}
}
/// Request the list of allowed repeat frequency ranges from the device (firmware v9+)
Future<void> getAllowedRepeatFreq() async {
if (!_activeService.isConnected) return;
try {
await _activeService.getAllowedRepeatFreq();
} catch (e) {
debugPrint('Failed to get allowed repeat freq: $e');
}
}
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 (_isSpectrumScanActive) return;
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
// The device query command triggers a SelfInfo response
await _activeService.refreshDeviceInfo();
// Also request allowed repeat frequencies (firmware v9+, no-op on older firmware)
await _activeService.getAllowedRepeatFreq();
} 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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.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 {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) {
// Another sync (single or loop) is in progress
return false;
}
if (!_activeService.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 _activeService.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({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
);
return 0;
}
if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
return 0;
}
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return 0;
}
int totalCount = 0;
try {
_pendingAutomaticMessageSync = false;
_isSyncingMessages = true;
do {
_syncRequestedWhileBusy = false;
_noMoreMessages = false; // Reset flag per pass
int passCount = 0;
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 $passCount 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 _activeService.syncNextMessage();
_lastSyncNextRequestedAt = DateTime.now();
passCount++;
totalCount++;
// 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 && passCount >= 100) {
debugPrint(
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
);
}
if (_syncRequestedWhileBusy) {
debugPrint(
'↻ [Provider] MSG_WAITING received during sync; running another pass',
);
}
} while (_syncRequestedWhileBusy && _activeService.isConnected);
debugPrint(
'🏁 [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages',
);
return totalCount;
} catch (e) {
debugPrint('❌ [Provider] Failed to sync messages: $e');
_error = 'Failed to sync messages: $e';
notifyListeners();
return totalCount;
} finally {
_isSyncingMessages = false;
_syncResponseCompleter = null;
}
}
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
void clearPendingAutomaticMessageSync() {
_pendingAutomaticMessageSync = false;
}
/// 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 (!_activeService.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 _activeService.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 (!_activeService.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 _activeService.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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
await _activeService.resetPath(contactPublicKey);
} catch (e) {
_error = 'Failed to reset path: $e';
notifyListeners();
}
}
Future<void> setContactRoute(
Contact contact, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set route: $e';
notifyListeners();
rethrow;
}
}
/// 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 (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
throw Exception(_error);
}
try {
await _activeService.removeContact(contactPublicKey);
} catch (e) {
_error = 'Failed to remove contact: $e';
notifyListeners();
rethrow;
}
}
/// 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;
/// 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();
_stopAckCleanupTimer();
_bleService.dispose();
_tcpService?.dispose();
_sseServer.stopServer();
super.dispose();
}
}