diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index bbff67a..1c466cd 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -17,8 +17,8 @@ enum ConnectionMode { /// Act as SSE server - share BLE device with multiple clients sseServer, - /// Connect to remote SSE server - no direct BLE connection - sseClient, + /// Direct TCP/WiFi connection to MeshCore device (port 5000) + tcp, } extension ConnectionModeExtension on ConnectionMode { @@ -28,8 +28,8 @@ extension ConnectionModeExtension on ConnectionMode { return 'Direct (BLE)'; case ConnectionMode.sseServer: return 'Share Device (Server)'; - case ConnectionMode.sseClient: - return 'Connect to Server'; + case ConnectionMode.tcp: + return 'Direct (WiFi)'; } } @@ -39,8 +39,8 @@ extension ConnectionModeExtension on ConnectionMode { return 'Direct BLE connection to MeshCore device'; case ConnectionMode.sseServer: return 'Share BLE device with multiple clients over network'; - case ConnectionMode.sseClient: - return 'Connect to remote server without BLE'; + case ConnectionMode.tcp: + return 'Direct WiFi/TCP connection to MeshCore device'; } } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 317cde6..3f51784 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -8,7 +8,6 @@ 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'; @@ -54,15 +53,21 @@ class ScannedDevice { ScannedDevice({required this.device, required this.rssi}); } -/// Connection Provider - manages MeshCore BLE connection +/// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi) class ConnectionProvider with ChangeNotifier { final MeshCoreBleService _bleService = MeshCoreBleService(); final SseServerService _sseServer = SseServerService(); - final SseClientService _sseClient = SseClientService(); + 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; @@ -71,9 +76,9 @@ class ConnectionProvider with ChangeNotifier { SseServerConfig _sseServerConfig = const SseServerConfig(); SseServerConfig get sseServerConfig => _sseServerConfig; - /// SSE client server URL - String? _sseClientServerUrl; - String? get sseClientServerUrl => _sseClientServerUrl; + /// TCP host last connected to (for display / reconnection info) + String? _tcpHost; + String? get tcpHost => _tcpHost; DeviceInfo _deviceInfo = DeviceInfo(); DeviceInfo get deviceInfo => _deviceInfo; @@ -100,19 +105,13 @@ class ConnectionProvider with ChangeNotifier { Timer? _ackCleanupTimer; // Packet counters - int get rxPacketCount => _bleService.rxPacketCount; - int get txPacketCount => _bleService.txPacketCount; + int get rxPacketCount => _activeService.rxPacketCount; + int get txPacketCount => _activeService.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; + // Reconnection state + bool get isReconnecting => _activeService.isReconnecting; + int get reconnectionAttempt => _activeService.reconnectionAttempt; + int get maxReconnectionAttempts => _activeService.maxReconnectionAttempts; // Message sync state bool _noMoreMessages = false; @@ -175,16 +174,19 @@ class ConnectionProvider with ChangeNotifier { final Map _pendingSendOperations = {}; ConnectionProvider() { - _initializeBleService(); + _wireServiceCallbacks(_bleService); } - void _initializeBleService() { - _bleService.onConnectionStateChanged = (isConnected) { + /// 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 - : (_bleService.isReconnecting + : (service.isReconnecting ? ConnectionState.connecting : ConnectionState.disconnected), lastUpdate: DateTime.now(), @@ -195,7 +197,7 @@ class ConnectionProvider with ChangeNotifier { debugPrint( ' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}', ); - debugPrint(' isReconnecting: ${_bleService.isReconnecting}'); + debugPrint(' isReconnecting: ${service.isReconnecting}'); // Start/stop ACK cleanup timer based on connection state if (isConnected) { @@ -208,325 +210,156 @@ class ConnectionProvider with ChangeNotifier { debugPrint(' Notified listeners'); }; - _bleService.onReconnectionAttempt = (attemptNumber, maxAttempts) { + service.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}'); - + service.onError = (error, {int? errorCode}) { + debugPrint('⚠️ [Provider] Error received: $error'); _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', - ); + service.onContactNotFound = (contactPublicKey) async { + debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery'); + if (contactPublicKey == null) return; - 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...'); + if (pendingOp == null || pendingOp.contact == null) return; try { - // Step 1: Add the contact to the radio - await _bleService.addOrUpdateContact(pendingOp.contact!); - - // Small delay to ensure contact is added before retrying + await _activeService.addOrUpdateContact(pendingOp.contact!); 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( + await _activeService.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'); + service.onContactReceived = (contact) { + debugPrint('📥 [Provider] Contact received: "${contact.advName}"'); onContactReceived?.call(contact); }; - _bleService.onContactsComplete = (contacts) { - debugPrint( - '📥 [Provider] Contacts sync complete: ${contacts.length} contacts', - ); - debugPrint(' Forwarding to AppProvider via onContactsComplete callback'); + service.onContactsComplete = (contacts) { + debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); onContactsComplete?.call(contacts); }; - _bleService.onChannelInfoReceived = + service.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); - }; + service.onContactDeleted = (publicKey) => onContactDeleted?.call(publicKey); + service.onContactsFull = () => onContactsFull?.call(); - _bleService.onContactsFull = () { - debugPrint('⚠️ [Provider] Contacts storage is full'); - onContactsFull?.call(); - }; - - _bleService.onMessageReceived = (message) { - // Parse SAR markers + service.onMessageReceived = (message) { 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 + service.onTelemetryReceived = (publicKey, lppData) { + debugPrint('📥 [Provider] Telemetry received'); _pingTracker.markPingSuccessful(publicKey); - debugPrint( - ' Forwarding to AppProvider via onTelemetryReceived callback', - ); onTelemetryReceived?.call(publicKey, lppData); }; - _bleService.onBinaryResponse = (publicKeyPrefix, tag, responseData) { + service.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'); + service.onNoMoreMessages = () { _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 + service.onMessageWaiting = () { + debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); 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'); + 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); + }; - // 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 + service.onLoginFail = (publicKeyPrefix) { _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); - }; + service.onAdvertReceived = (publicKey) => onAdvertReceived?.call(publicKey); + service.onPathUpdated = (publicKey) => onPathUpdated?.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 = + service.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); - }; + service.onMessageDelivered = (ackCode, roundTripTimeMs) => + onMessageDelivered?.call(ackCode, roundTripTimeMs); - _bleService - .onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { - debugPrint( - '🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount', - ); - onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm); - }; + service.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) => + 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); - }; + service.onStatusResponse = (publicKeyPrefix, statusData) => + onStatusResponse?.call(publicKeyPrefix, statusData); - _bleService.onRawDataReceived = (payload, snrRaw, rssiDbm) { - onRawDataReceived?.call(payload, snrRaw, rssiDbm); - }; - - _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']}'); + 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?, @@ -538,9 +371,6 @@ class ConnectionProvider with ChangeNotifier { clientRepeat: deviceInfo['clientRepeat'] as bool?, ); 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, @@ -548,19 +378,8 @@ class ConnectionProvider with ChangeNotifier { } }; - _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']}'); - + service.onSelfInfoReceived = (selfInfo) { + debugPrint('📥 [Provider] SelfInfo received'); _deviceInfo = _deviceInfo.copyWith( deviceType: selfInfo['deviceType'] as int?, txPower: selfInfo['txPower'] as int?, @@ -576,9 +395,6 @@ class ConnectionProvider with ChangeNotifier { 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, @@ -586,24 +402,7 @@ class ConnectionProvider with ChangeNotifier { } }; - // 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)}%'); - } - } - + service.onBatteryAndStorage = (millivolts, usedKb, totalKb) { _deviceInfo = _deviceInfo.copyWith( batteryMilliVolts: millivolts, storageUsedKb: usedKb, @@ -611,13 +410,11 @@ class ConnectionProvider with ChangeNotifier { lastUpdate: DateTime.now(), ); notifyListeners(); - debugPrint('✅ [Provider] Device info updated with BatteryAndStorage'); }; - _bleService.onRxActivity = () { + + service.onRxActivity = () { _rxActivity = true; notifyListeners(); - - // Reset after 100ms _rxActivityTimer?.cancel(); _rxActivityTimer = Timer(const Duration(milliseconds: 100), () { _rxActivity = false; @@ -625,17 +422,14 @@ class ConnectionProvider with ChangeNotifier { }); }; - _bleService.onAllowedRepeatFreqReceived = (ranges) { - debugPrint('📥 [Provider] Received AllowedRepeatFreq: $ranges'); + service.onAllowedRepeatFreqReceived = (ranges) { _deviceInfo = _deviceInfo.copyWith(allowedRepeatFreqRanges: ranges); notifyListeners(); }; - _bleService.onTxActivity = () { + service.onTxActivity = () { _txActivity = true; notifyListeners(); - - // Reset after 100ms _txActivityTimer?.cancel(); _txActivityTimer = Timer(const Duration(milliseconds: 100), () { _txActivity = false; @@ -643,7 +437,7 @@ class ConnectionProvider with ChangeNotifier { }); }; - _bleService.onRssiUpdate = (rssi) { + service.onRssiUpdate = (rssi) { _deviceInfo = _deviceInfo.copyWith( signalRssi: rssi, lastUpdate: DateTime.now(), @@ -742,6 +536,51 @@ class ConnectionProvider with ChangeNotifier { return success; } + /// Connect to a MeshCore device over TCP/WiFi (port 5000) + Future 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 disconnectTcp() async { + if (_tcpService != null) { + await _tcpService!.disconnect(); + _tcpService!.dispose(); + _tcpService = null; + } + _tcpHost = null; + _connectionMode = ConnectionMode.ble; + _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); + _roomLoginManager.clearRoomLoginStates(); + _pingTracker.clearAll(); + _pendingSendOperations.clear(); + _messageDeliveryTracker.clearTracking(); + notifyListeners(); + } + /// Disconnect from device Future disconnect() async { _deviceInfo = _deviceInfo.copyWith( @@ -749,20 +588,18 @@ class ConnectionProvider with ChangeNotifier { ); notifyListeners(); - // Disconnect from BLE if connected - await _bleService.disconnect(); - - // Disconnect from SSE if connected - if (_sseClient.isConnected) { - await disconnectFromSseServer(); + if (_connectionMode == ConnectionMode.tcp) { + await disconnectTcp(); + return; } + await _bleService.disconnect(); + _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 + _roomLoginManager.clearRoomLoginStates(); + _pingTracker.clearAll(); + _pendingSendOperations.clear(); + _messageDeliveryTracker.clearTracking(); notifyListeners(); } @@ -809,14 +646,14 @@ class ConnectionProvider with ChangeNotifier { /// Get contacts from device Future getContacts() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.getContacts(); + await _activeService.getContacts(); } catch (e) { _error = 'Failed to get contacts: $e'; notifyListeners(); @@ -830,28 +667,28 @@ class ConnectionProvider with ChangeNotifier { /// /// The contact will be delivered via the onContactReceived callback. Future getContact(Uint8List publicKey) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.getContactByKey(publicKey); + 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 _bleService.getContacts(); + await _activeService.getContacts(); notifyListeners(); } } /// Sync all channels from device Future syncChannels({int? maxChannels}) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -860,7 +697,7 @@ class ConnectionProvider with ChangeNotifier { try { // Use maxChannels from device info if available, otherwise default to 40 final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40; - await _bleService.syncAllChannels(maxChannels: channelCount); + await _activeService.syncAllChannels(maxChannels: channelCount); } catch (e) { _error = 'Failed to sync channels: $e'; notifyListeners(); @@ -876,7 +713,7 @@ class ConnectionProvider with ChangeNotifier { /// The public channel uses a well-known pre-shared key that all MeshCore /// devices use for the default public channel. Future configureDefaultPublicChannel() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -889,7 +726,7 @@ class ConnectionProvider with ChangeNotifier { debugPrint( ' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', ); - await _bleService.setChannel( + await _activeService.setChannel( channelIdx: 0, channelName: 'Public Channel', secret: MeshCoreConstants.defaultPublicChannelSecret, @@ -916,7 +753,7 @@ class ConnectionProvider with ChangeNotifier { /// Check if a specific channel slot is empty Future isChannelSlotEmpty(int channelIdx) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { return false; } @@ -931,7 +768,7 @@ class ConnectionProvider with ChangeNotifier { } // If not cached, query the device - await _bleService.getChannel(channelIdx); + await _activeService.getChannel(channelIdx); await Future.delayed(const Duration(milliseconds: 100)); // Check again after query @@ -952,7 +789,7 @@ class ConnectionProvider with ChangeNotifier { } Future findNextEmptyChannelSlot() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { throw Exception('Not connected to device'); } @@ -1007,7 +844,7 @@ class ConnectionProvider with ChangeNotifier { required String channelName, required String channelSecret, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { throw Exception('Not connected to device'); } @@ -1082,7 +919,7 @@ class ConnectionProvider with ChangeNotifier { } // Send CMD_SET_CHANNEL to radio - await _bleService.setChannel( + await _activeService.setChannel( channelIdx: slotIdx, channelName: channelName, secret: secretBytes, @@ -1097,7 +934,7 @@ class ConnectionProvider with ChangeNotifier { // Refresh channels to update UI // The channel info will be received via onChannelInfoReceived callback - await _bleService.getChannel(slotIdx); + await _activeService.getChannel(slotIdx); } catch (e) { _error = 'Failed to create channel: $e'; debugPrint('❌ [Provider] Channel creation failed: $e'); @@ -1115,7 +952,7 @@ class ConnectionProvider with ChangeNotifier { /// /// Throws an exception if the channel cannot be deleted or if channel 0 is specified. Future deleteChannel(int channelIdx) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { throw Exception('Not connected to device'); } @@ -1127,7 +964,7 @@ class ConnectionProvider with ChangeNotifier { debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...'); // Delete channel on device (sets empty name and zeroed secret) - await _bleService.deleteChannel(channelIdx); + await _activeService.deleteChannel(channelIdx); debugPrint( '✅ [Provider] Channel deleted successfully from slot $channelIdx', @@ -1138,7 +975,7 @@ class ConnectionProvider with ChangeNotifier { // Refresh channels to update UI // The empty channel will trigger removal via onChannelInfoReceived callback - await _bleService.getChannel(channelIdx); + await _activeService.getChannel(channelIdx); } catch (e) { _error = 'Failed to delete channel: $e'; debugPrint('❌ [Provider] Channel deletion failed: $e'); @@ -1169,14 +1006,14 @@ class ConnectionProvider with ChangeNotifier { /// This manually adds a contact to the radio's internal contact table. /// Useful when a room contact was deleted or never advertised yet. Future addOrUpdateContact(Contact contact) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.addOrUpdateContact(contact); + await _activeService.addOrUpdateContact(contact); } catch (e) { _error = 'Failed to add/update contact: $e'; notifyListeners(); @@ -1199,7 +1036,7 @@ class ConnectionProvider with ChangeNotifier { Contact? contact, int retryAttempt = 0, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return false; @@ -1283,7 +1120,7 @@ class ConnectionProvider with ChangeNotifier { } // Send the message with retry attempt info - await _bleService.sendTextMessage( + await _activeService.sendTextMessage( contactPublicKey: contactPublicKey, text: text, attempt: retryAttempt, @@ -1320,7 +1157,7 @@ class ConnectionProvider with ChangeNotifier { required String text, String? messageId, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1332,7 +1169,7 @@ class ConnectionProvider with ChangeNotifier { debugPrint(' Text: $text'); debugPrint(' MessageID: $messageId'); - await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text); + await _activeService.sendChannelMessage(channelIdx: channelIdx, text: text); debugPrint('✅ [ConnectionProvider] BLE send completed'); debugPrint( @@ -1349,7 +1186,7 @@ class ConnectionProvider with ChangeNotifier { // Track for echo detection // The BLE handler will capture the packet via LOG_RX_DATA and associate it debugPrint(' Calling trackSentChannelMessage...'); - _bleService.trackSentChannelMessage( + _activeService.trackSentChannelMessage( messageId, channelIdx: channelIdx, plainText: text, @@ -1379,8 +1216,8 @@ class ConnectionProvider with ChangeNotifier { required int contactPathLen, required Uint8List payload, }) async { - if (!_bleService.isConnected) return; - await _bleService.sendRawVoicePacket( + if (!_activeService.isConnected) return; + await _activeService.sendRawVoicePacket( contactPathLen: contactPathLen, contactPath: contactPath, payload: payload, @@ -1406,14 +1243,14 @@ class ConnectionProvider with ChangeNotifier { Uint8List contactPublicKey, { bool zeroHop = false, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.requestTelemetry(contactPublicKey, zeroHop: zeroHop); + await _activeService.requestTelemetry(contactPublicKey, zeroHop: zeroHop); } catch (e) { _error = 'Failed to request telemetry: $e'; notifyListeners(); @@ -1434,7 +1271,7 @@ class ConnectionProvider with ChangeNotifier { required bool hasPath, Function()? onRetryWithFlooding, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return PingResult(success: false, usedFlooding: false, timedOut: true); @@ -1451,7 +1288,7 @@ class ConnectionProvider with ChangeNotifier { ); // Send the ping - await _bleService.requestTelemetry(contactPublicKey, zeroHop: true); + await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); // Wait for response or timeout final bool gotResponse = await pingFuture; @@ -1479,7 +1316,7 @@ class ConnectionProvider with ChangeNotifier { ); // Retry with flooding (zeroHop=true acts as broadcast to neighbors) - await _bleService.requestTelemetry(contactPublicKey, zeroHop: true); + await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); // Wait for response or timeout final bool gotRetryResponse = await retryFuture; @@ -1527,7 +1364,7 @@ class ConnectionProvider with ChangeNotifier { required int requestType, Uint8List? additionalParams, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1540,7 +1377,7 @@ class ConnectionProvider with ChangeNotifier { if (additionalParams != null) ...additionalParams, ]); - await _bleService.sendBinaryRequest( + await _activeService.sendBinaryRequest( contactPublicKey: contactPublicKey, requestData: requestData, ); @@ -1552,14 +1389,14 @@ class ConnectionProvider with ChangeNotifier { /// Get device time from companion radio to detect clock drift Future getDeviceTime() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.getDeviceTime(); + await _activeService.getDeviceTime(); } catch (e) { _error = 'Failed to get device time: $e'; notifyListeners(); @@ -1568,10 +1405,10 @@ class ConnectionProvider with ChangeNotifier { /// Set device time to current time Future syncDeviceTime() async { - if (!_bleService.isConnected) return; + if (!_activeService.isConnected) return; try { - await _bleService.setDeviceTime(); + await _activeService.setDeviceTime(); } catch (e) { _error = 'Failed to sync time: $e'; notifyListeners(); @@ -1580,14 +1417,14 @@ class ConnectionProvider with ChangeNotifier { /// Set advertised name Future setAdvertName(String name) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.setAdvertName(name); + await _activeService.setAdvertName(name); } catch (e) { _error = 'Failed to set name: $e'; notifyListeners(); @@ -1599,14 +1436,14 @@ class ConnectionProvider with ChangeNotifier { required double latitude, required double longitude, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.setAdvertLatLon( + await _activeService.setAdvertLatLon( latitude: latitude, longitude: longitude, ); @@ -1625,7 +1462,7 @@ class ConnectionProvider with ChangeNotifier { /// [floodMode] - if true, broadcast to entire mesh (default for SAR ops) /// if false, only send to direct neighbors (zero-hop) Future sendSelfAdvert({bool floodMode = true}) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1643,7 +1480,7 @@ class ConnectionProvider with ChangeNotifier { } } _isAdvertInProgress = true; - await _bleService.sendSelfAdvert(floodMode: floodMode); + await _activeService.sendSelfAdvert(floodMode: floodMode); _lastAdvertRequestedAt = DateTime.now(); } catch (e) { _error = 'Failed to send advertisement: $e'; @@ -1661,14 +1498,14 @@ class ConnectionProvider with ChangeNotifier { required int codingRate, bool? repeat, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.setRadioParams( + await _activeService.setRadioParams( frequency: frequency, bandwidth: bandwidth, spreadingFactor: spreadingFactor, @@ -1683,9 +1520,9 @@ class ConnectionProvider with ChangeNotifier { /// Request the list of allowed repeat frequency ranges from the device (firmware v9+) Future getAllowedRepeatFreq() async { - if (!_bleService.isConnected) return; + if (!_activeService.isConnected) return; try { - await _bleService.getAllowedRepeatFreq(); + await _activeService.getAllowedRepeatFreq(); } catch (e) { debugPrint('Failed to get allowed repeat freq: $e'); } @@ -1693,14 +1530,14 @@ class ConnectionProvider with ChangeNotifier { /// Set transmit power Future setTxPower(int powerDbm) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.setTxPower(powerDbm); + await _activeService.setTxPower(powerDbm); } catch (e) { _error = 'Failed to set TX power: $e'; notifyListeners(); @@ -1714,14 +1551,14 @@ class ConnectionProvider with ChangeNotifier { required int advertLocationPolicy, int multiAcks = 0, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.setOtherParams( + await _activeService.setOtherParams( manualAddContacts: manualAddContacts, telemetryModes: telemetryModes, advertLocationPolicy: advertLocationPolicy, @@ -1735,7 +1572,7 @@ class ConnectionProvider with ChangeNotifier { /// Request fresh device info (triggers SelfInfo response) Future refreshDeviceInfo() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1743,9 +1580,9 @@ class ConnectionProvider with ChangeNotifier { try { // The device query command triggers a SelfInfo response - await _bleService.refreshDeviceInfo(); + await _activeService.refreshDeviceInfo(); // Also request allowed repeat frequencies (firmware v9+, no-op on older firmware) - await _bleService.getAllowedRepeatFreq(); + await _activeService.getAllowedRepeatFreq(); } catch (e) { _error = 'Failed to refresh device info: $e'; notifyListeners(); @@ -1761,14 +1598,14 @@ class ConnectionProvider with ChangeNotifier { /// /// Results arrive via onBatteryAndStorage callback and update deviceInfo. Future getBatteryAndStorage() async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.getBatteryAndStorage(); + await _activeService.getBatteryAndStorage(); } catch (e) { _error = 'Failed to get battery and storage: $e'; notifyListeners(); @@ -1784,7 +1621,7 @@ class ConnectionProvider with ChangeNotifier { return false; } - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return false; @@ -1802,7 +1639,7 @@ class ConnectionProvider with ChangeNotifier { } _isSyncingMessages = true; - await _bleService.syncNextMessage(); + await _activeService.syncNextMessage(); _lastSyncNextRequestedAt = DateTime.now(); return true; } catch (e) { @@ -1821,7 +1658,7 @@ class ConnectionProvider with ChangeNotifier { return 0; } - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return 0; @@ -1865,7 +1702,7 @@ class ConnectionProvider with ChangeNotifier { } } - await _bleService.syncNextMessage(); + await _activeService.syncNextMessage(); _lastSyncNextRequestedAt = DateTime.now(); count++; @@ -1932,7 +1769,7 @@ class ConnectionProvider with ChangeNotifier { required Uint8List roomPublicKey, required String password, }) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1950,7 +1787,7 @@ class ConnectionProvider with ChangeNotifier { } } _isLoginInProgress = true; - await _bleService.loginToRoom( + await _activeService.loginToRoom( roomPublicKey: roomPublicKey, password: password, ); @@ -1976,7 +1813,7 @@ class ConnectionProvider with ChangeNotifier { /// await connectionProvider.requestStatus(repeaterContact.publicKey); /// ``` Future requestStatus(Uint8List contactPublicKey) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; @@ -1994,7 +1831,7 @@ class ConnectionProvider with ChangeNotifier { } } _isStatusRequestInProgress = true; - await _bleService.sendStatusRequest(contactPublicKey); + await _activeService.sendStatusRequest(contactPublicKey); _lastStatusRequestedAt = DateTime.now(); } catch (e) { _error = 'Failed to send status request: $e'; @@ -2015,14 +1852,14 @@ class ConnectionProvider with ChangeNotifier { /// 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 resetPath(Uint8List contactPublicKey) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.resetPath(contactPublicKey); + await _activeService.resetPath(contactPublicKey); } catch (e) { _error = 'Failed to reset path: $e'; notifyListeners(); @@ -2035,14 +1872,14 @@ class ConnectionProvider with ChangeNotifier { /// The contact will no longer appear in the contact list and all /// routing information will be cleared. Future removeContact(Uint8List contactPublicKey) async { - if (!_bleService.isConnected) { + if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); return; } try { - await _bleService.removeContact(contactPublicKey); + await _activeService.removeContact(contactPublicKey); } catch (e) { _error = 'Failed to remove contact: $e'; notifyListeners(); @@ -2157,140 +1994,6 @@ class ConnectionProvider with ChangeNotifier { /// Get number of connected SSE clients int get sseClientCount => _sseServer.connectedClients; - // ============================================================================ - // SSE Client Methods - // ============================================================================ - - /// Connect to remote SSE server - Future 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 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 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 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; @@ -2308,8 +2011,8 @@ class ConnectionProvider with ChangeNotifier { _rxActivityTimer?.cancel(); _txActivityTimer?.cancel(); _bleService.dispose(); + _tcpService?.dispose(); _sseServer.stopServer(); - _sseClient.dispose(); super.dispose(); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index bf30896..7445e4a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:vibration/vibration.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; +import '../models/device_info.dart' show ConnectionMode; import '../providers/messages_provider.dart'; import '../providers/contacts_provider.dart'; import '../theme/app_theme.dart'; @@ -298,7 +299,7 @@ class _HomeScreenState extends State builder: (context, provider, child) { final isConnected = provider.deviceInfo.isConnected || - provider.isSseClientConnected; + provider.deviceInfo.isConnected; if (isConnected) { return IconButton( onPressed: () async { @@ -446,9 +447,9 @@ class _HomeScreenState extends State return Consumer( builder: (context, provider, child) { final deviceInfo = provider.deviceInfo; - final isBleConnected = deviceInfo.isConnected; - final isSseConnected = provider.isSseClientConnected; - final isConnected = isBleConnected || isSseConnected; + final isConnected = deviceInfo.isConnected; + final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; + final isBleConnected = isConnected && !isTcpConnected; if (!isConnected) { // Disconnected state: show connect button @@ -536,10 +537,10 @@ class _HomeScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Icon( - isSseConnected + isTcpConnected ? Icons.wifi : Icons.bluetooth_connected, - color: isSseConnected + color: isTcpConnected ? Colors.green : (deviceInfo.signalRssi != null ? BatteryDisplayHelper.getSignalColor( @@ -561,10 +562,10 @@ class _HomeScreenState extends State ), ), ], - if (isSseConnected && !isBleConnected) ...[ + if (isTcpConnected) ...[ const SizedBox(width: 3), Text( - 'SSE', + 'WiFi', style: const TextStyle( fontSize: 11, color: Colors.green, diff --git a/lib/services/network_scanner_service.dart b/lib/services/network_scanner_service.dart index 4989b38..56c95fc 100644 --- a/lib/services/network_scanner_service.dart +++ b/lib/services/network_scanner_service.dart @@ -1,213 +1,155 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; import 'package:nsd/nsd.dart'; -/// Discovered SSE server on the network +/// Discovered MeshCore device on the network (TCP/WiFi) class DiscoveredServer { final String ipAddress; final int port; - final int responseTime; // in milliseconds - final String serverUrl; + final int responseTime; // milliseconds - DiscoveredServer({ + const DiscoveredServer({ required this.ipAddress, required this.port, required this.responseTime, - }) : serverUrl = 'http://$ipAddress:$port'; + }); @override - String toString() { - return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)'; - } + String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)'; @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is DiscoveredServer && - other.ipAddress == ipAddress && - other.port == port; - } + bool operator ==(Object other) => + other is DiscoveredServer && + other.ipAddress == ipAddress && + other.port == port; @override int get hashCode => Object.hash(ipAddress, port); } -/// Network Scanner Service +/// Discovers MeshCore devices running the TCP/WiFi server (port 5000). /// -/// Discovers SSE servers on the local network using Bonjour/mDNS. -/// Falls back to port scanning (12929) if no services are discovered. -/// Uses parallel scanning (20 IPs at once) for fast discovery. +/// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel +/// TCP-connect port scan of the local /24 subnet. class NetworkScannerService { - static const int defaultPort = 12929; - static const String serviceType = '_meshcore-sse._tcp'; + static const int defaultPort = 5000; + static const String serviceType = '_meshcore._tcp'; static const int parallelScans = 20; - static const Duration scanTimeout = Duration(seconds: 2); + static const Duration connectTimeout = Duration(seconds: 2); static const Duration bonjourTimeout = Duration(seconds: 5); Discovery? _activeDiscovery; - /// Callback for when a server is discovered Function(DiscoveredServer)? onServerDiscovered; - - /// Callback for scan progress updates Function(int scanned, int total)? onProgressUpdate; bool _isScanning = false; bool get isScanning => _isScanning; - /// Cached discovered servers from the last scan List _cachedServers = []; List get cachedServers => List.unmodifiable(_cachedServers); - - /// Whether we have cached results from a previous scan bool get hasCachedResults => _cachedServers.isNotEmpty; - /// Get all local IP addresses - Future> _getLocalIpAddresses() async { - final Set localIps = {}; + // ── Helpers ──────────────────────────────────────────────────────────────── + Future> _getLocalIpAddresses() async { + final ips = {}; try { - final interfaces = await NetworkInterface.list(); - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (addr.type == InternetAddressType.IPv4) { - localIps.add(addr.address); - } + for (final iface in await NetworkInterface.list()) { + for (final addr in iface.addresses) { + if (addr.type == InternetAddressType.IPv4) ips.add(addr.address); } } } catch (e) { debugPrint('❌ [NetworkScanner] Error getting local IPs: $e'); } - - return localIps; + return ips; } - /// Get local network IP range to scan Future> _getLocalNetworkRange() async { - final List ips = []; - try { - // Get all network interfaces - final interfaces = await NetworkInterface.list(); - - for (final interface in interfaces) { - for (final addr in interface.addresses) { - // Only scan IPv4 addresses that are not loopback + for (final iface in await NetworkInterface.list()) { + for (final addr in iface.addresses) { if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { - final ip = addr.address; - final parts = ip.split('.'); - + final parts = addr.address.split('.'); if (parts.length == 4) { - // Generate range for the same subnet (e.g., 192.168.1.1-254) final subnet = '${parts[0]}.${parts[1]}.${parts[2]}'; - - // Scan from .1 to .254 (skip .0 and .255) - for (int i = 1; i <= 254; i++) { - ips.add('$subnet.$i'); - } - - debugPrint('📡 [NetworkScanner] Will scan subnet: $subnet.0/24'); - // Only scan first viable subnet - return ips; + debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24'); + return [for (int i = 1; i <= 254; i++) '$subnet.$i']; } } } } } catch (e) { - debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e'); + debugPrint('❌ [NetworkScanner] Error getting network range: $e'); } - - return ips; + return []; } - /// Check if an IP has an SSE server running - Future _checkServer(String ip, int port) async { + /// Try a raw TCP connect to check if the MeshCore TCP server is listening. + Future _checkDevice(String ip, int port) async { + final sw = Stopwatch()..start(); + Socket? socket; try { - final stopwatch = Stopwatch()..start(); - final url = Uri.parse('http://$ip:$port/api/status'); - - final response = await http.get(url).timeout(scanTimeout); - - stopwatch.stop(); - - if (response.statusCode == 200) { - debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)'); - - return DiscoveredServer( - ipAddress: ip, - port: port, - responseTime: stopwatch.elapsedMilliseconds, - ); - } - } on TimeoutException { - // Timeout - server not responding, ignore + socket = await Socket.connect( + ip, + port, + timeout: connectTimeout, + ); + sw.stop(); + debugPrint( + '✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)'); + return DiscoveredServer( + ipAddress: ip, + port: port, + responseTime: sw.elapsedMilliseconds, + ); } on SocketException { - // Connection refused - no server at this IP, ignore + // Connection refused or timed out — no device here } catch (e) { - // Other errors - ignore - debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e'); + debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port — $e'); + } finally { + socket?.destroy(); } - return null; } - /// Discover servers using Bonjour/mDNS - Future> _discoverViaBonjourAsync({int? port}) async { + // ── mDNS discovery ───────────────────────────────────────────────────────── + + Future> _discoverViaMdns({int? port}) async { final scanPort = port ?? defaultPort; - final List discoveredServers = []; + final found = []; try { - debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...'); - - // Get local IP addresses to filter out + debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...'); final localIps = await _getLocalIpAddresses(); - debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}'); - // Start discovery with IP lookup _activeDiscovery = await startDiscovery( serviceType, ipLookupType: IpLookupType.any, ); - // Wait for discovery to find services await Future.delayed(bonjourTimeout); - // Process discovered services - final services = _activeDiscovery?.services ?? []; - debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services'); - - for (final service in services) { - if (service.addresses != null && service.addresses!.isNotEmpty) { - for (final address in service.addresses!) { - // Skip if this is a local IP address - if (localIps.contains(address.address)) { - debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${address.address}'); - continue; - } - - // Verify service is actually reachable - final result = await _checkServer( - address.address, - service.port ?? scanPort, - ); - - if (result != null) { - discoveredServers.add(result); - onServerDiscovered?.call(result); - } + for (final service in _activeDiscovery?.services ?? []) { + for (final addr in service.addresses ?? []) { + if (localIps.contains(addr.address)) continue; + final result = + await _checkDevice(addr.address, service.port ?? scanPort); + if (result != null) { + found.add(result); + onServerDiscovered?.call(result); } } } - // Stop discovery await stopDiscovery(_activeDiscovery!); _activeDiscovery = null; - - debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.'); + debugPrint( + '✅ [NetworkScanner] mDNS done. Found ${found.length} devices.'); } catch (e) { - debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e'); + debugPrint('⚠️ [NetworkScanner] mDNS failed: $e'); if (_activeDiscovery != null) { try { await stopDiscovery(_activeDiscovery!); @@ -216,132 +158,70 @@ class NetworkScannerService { } } - return discoveredServers; + return found; } - /// Scan the local network for SSE servers - /// First tries Bonjour/mDNS, then falls back to port scanning if nothing found - Future> scan({int? port}) async { - if (_isScanning) { - debugPrint('⚠️ [NetworkScanner] Scan already in progress'); - return []; - } + // ── Port scan fallback ───────────────────────────────────────────────────── - _isScanning = true; + Future> _scanByPort({int? port}) async { final scanPort = port ?? defaultPort; - List discoveredServers = []; + final found = []; - try { - // Try Bonjour/mDNS discovery first - discoveredServers = await _discoverViaBonjourAsync(port: scanPort); + final localIps = await _getLocalIpAddresses(); + final ips = await _getLocalNetworkRange(); + if (ips.isEmpty) return []; - // Fall back to port scanning if Bonjour found nothing - if (discoveredServers.isEmpty) { - debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...'); - discoveredServers = await _scanByPortAsync(port: scanPort); + debugPrint( + '🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort'); + + int scanned = 0; + for (int i = 0; i < ips.length; i += parallelScans) { + final batch = ips.skip(i).take(parallelScans).toList(); + final results = + await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort))); + + for (final result in results) { + if (result != null && !localIps.contains(result.ipAddress)) { + found.add(result); + onServerDiscovered?.call(result); + } } - // Cache the results - _cachedServers = discoveredServers; - } catch (e) { - debugPrint('❌ [NetworkScanner] Scan error: $e'); + scanned += batch.length; + onProgressUpdate?.call(scanned, ips.length); + } + + return found; + } + + // ── Public API ───────────────────────────────────────────────────────────── + + /// Scan for MeshCore WiFi devices. Tries mDNS first, falls back to port scan. + Future> scan({int? port}) async { + if (_isScanning) return []; + _isScanning = true; + + try { + var found = await _discoverViaMdns(port: port); + if (found.isEmpty) { + debugPrint( + '🔍 [NetworkScanner] mDNS found nothing, falling back to port scan'); + found = await _scanByPort(port: port); + } + _cachedServers = found; + return found; } finally { _isScanning = false; } - - return discoveredServers; } - /// Fallback port scanning method - Future> _scanByPortAsync({int? port}) async { - final scanPort = port ?? defaultPort; - final List discoveredServers = []; - - try { - debugPrint('🔍 [NetworkScanner] Starting port scan on port $scanPort...'); - - // Get local IP addresses to filter out - final localIps = await _getLocalIpAddresses(); - debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}'); - - final ips = await _getLocalNetworkRange(); - - if (ips.isEmpty) { - debugPrint('⚠️ [NetworkScanner] No network interfaces found'); - return []; - } - - debugPrint('📊 [NetworkScanner] Scanning ${ips.length} IPs with $parallelScans parallel connections'); - - int scannedCount = 0; - - // Scan in batches of 20 parallel connections - for (int i = 0; i < ips.length; i += parallelScans) { - final batch = ips.skip(i).take(parallelScans).toList(); - - // Scan batch in parallel - final futures = batch.map((ip) => _checkServer(ip, scanPort)).toList(); - final results = await Future.wait(futures); - - // Collect discovered servers (excluding local IPs) - for (int j = 0; j < results.length; j++) { - final result = results[j]; - if (result != null) { - // Skip if this is a local IP address - if (localIps.contains(result.ipAddress)) { - debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${result.ipAddress}'); - continue; - } - - discoveredServers.add(result); - onServerDiscovered?.call(result); - } - } - - scannedCount += batch.length; - onProgressUpdate?.call(scannedCount, ips.length); - } - - debugPrint('✅ [NetworkScanner] Port scan complete. Found ${discoveredServers.length} servers.'); - } catch (e) { - debugPrint('❌ [NetworkScanner] Port scan error: $e'); - } - - return discoveredServers; - } - - /// Clear cached results (useful for forcing a fresh scan) - void clearCache() { - _cachedServers = []; - debugPrint('🗑️ [NetworkScanner] Cache cleared'); - } - - /// Stop ongoing scan - void stopScan() { - if (_isScanning) { - debugPrint('🛑 [NetworkScanner] Stopping scan...'); - _isScanning = false; - } - } - - /// Verify that a previously discovered server is still available - /// Returns true if server is reachable, false otherwise + /// Verify a previously discovered device is still reachable. Future verifyServer(DiscoveredServer server) async { - try { - debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...'); - - final result = await _checkServer(server.ipAddress, server.port); - - if (result != null) { - debugPrint('✅ [NetworkScanner] Server verified at ${server.ipAddress}:${server.port}'); - return true; - } else { - debugPrint('❌ [NetworkScanner] Server no longer available at ${server.ipAddress}:${server.port}'); - return false; - } - } catch (e) { - debugPrint('❌ [NetworkScanner] Server verification failed: $e'); - return false; - } + final result = await _checkDevice(server.ipAddress, server.port); + return result != null; } + + void clearCache() => _cachedServers = []; + + void stopScan() => _isScanning = false; } diff --git a/lib/services/sse_client_service.dart b/lib/services/sse_client_service.dart deleted file mode 100644 index 2c68d34..0000000 --- a/lib/services/sse_client_service.dart +++ /dev/null @@ -1,667 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io' as io; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:http/io_client.dart' as io_client; -import '../models/message.dart'; -import '../models/contact.dart'; -import 'package:latlong2/latlong.dart'; - -/// SSE Client Service -/// -/// Connects to a remote SSE server to receive messages and contacts in real-time. -/// This enables multiple app instances to share a single MeshCore BLE device -/// without direct BLE connections. -class SseClientService { - String? _serverUrl; - String? _authToken; - http.Client? _httpClient; - StreamSubscription? _messageSubscription; - StreamSubscription? _contactSubscription; - bool _isConnected = false; - bool _isConnecting = false; - bool _hasConnectedBefore = - false; // Track if we've ever successfully connected - Timer? _reconnectTimer; - Timer? _heartbeatTimer; - int _reconnectAttempts = 0; - static const int _maxReconnectAttempts = 10; - static const Duration _reconnectDelay = Duration(seconds: 5); - - /// Callback for when a message is received - Function(Message)? onMessageReceived; - - /// Callback for when a contact is received - Function(Contact)? onContactReceived; - - /// Callback for connection state changes - Function(bool isConnected)? onConnectionStateChanged; - - /// Callback for errors - Function(String error)? onError; - - /// Check if client is connected - bool get isConnected => _isConnected; - - /// Check if client is currently connecting - bool get isConnecting => _isConnecting; - - /// Get current reconnection attempt number - int get reconnectionAttempts => _reconnectAttempts; - - /// Get maximum reconnection attempts - int get maxReconnectionAttempts => _maxReconnectAttempts; - - /// Get server URL - String? get serverUrl => _serverUrl; - - /// Connect to SSE server - Future connect({required String serverUrl, String? authToken}) async { - if (_isConnected) { - debugPrint('⚠️ [SseClient] Already connected'); - return; - } - - _serverUrl = serverUrl; - _authToken = authToken; - _isConnecting = true; - - debugPrint( - '🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)', - ); - - try { - // Create a new HTTP client with custom configuration for SSE streaming - // Using IOClient with custom HttpClient for better control over connection settings - final ioHttpClient = io.HttpClient(); - ioHttpClient.connectionTimeout = const Duration(seconds: 10); - ioHttpClient.idleTimeout = const Duration( - hours: 1, - ); // Keep SSE connections alive - _httpClient = io_client.IOClient(ioHttpClient); - - // Test server availability - await _checkServerStatus(); - - // Fetch initial message history - await _fetchMessageHistory(); - - // Fetch initial contact list - await _fetchContacts(); - - // Subscribe to SSE streams - debugPrint('🔗 [SseClient] Subscribing to message stream...'); - debugPrint( - '🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}', - ); - await _subscribeToMessages(); - debugPrint('🔗 [SseClient] Subscribing to contact stream...'); - await _subscribeToContacts(); - debugPrint('🔗 [SseClient] All subscriptions complete'); - - _isConnected = true; - _isConnecting = false; - _hasConnectedBefore = true; // Mark that we've successfully connected - _reconnectAttempts = 0; - debugPrint('🔔 [SseClient] Calling onConnectionStateChanged(true)'); - onConnectionStateChanged?.call(true); - - // Start heartbeat to detect connection loss - _startHeartbeat(); - - debugPrint('✅ [SseClient] Connected successfully'); - } catch (e) { - _isConnecting = false; - _httpClient?.close(); - _httpClient = null; - debugPrint('❌ [SseClient] Connection failed: $e'); - onError?.call('Connection failed: $e'); - - // Only auto-reconnect if we've successfully connected before - // Initial connection failures should be handled by the user - if (_hasConnectedBefore) { - _scheduleReconnect(); - } - } - } - - /// Disconnect from SSE server - Future disconnect() async { - debugPrint('🔌 [SseClient] Disconnecting...'); - - _isConnected = false; - _isConnecting = false; - _hasConnectedBefore = false; // Reset on manual disconnect - _reconnectTimer?.cancel(); - _heartbeatTimer?.cancel(); - await _messageSubscription?.cancel(); - await _contactSubscription?.cancel(); - _httpClient?.close(); - - _serverUrl = null; - _authToken = null; - _httpClient = null; - - onConnectionStateChanged?.call(false); - - debugPrint('✅ [SseClient] Disconnected'); - } - - /// Check server status - Future _checkServerStatus() async { - final url = Uri.parse('$_serverUrl/api/status'); - - try { - final response = await http - .get(url, headers: _getHeaders()) - .timeout(const Duration(seconds: 5)); - - if (response.statusCode != 200) { - throw Exception('Server returned ${response.statusCode}'); - } - - final data = jsonDecode(response.body); - debugPrint('📊 [SseClient] Server status: ${data['status']}'); - debugPrint(' Connected clients: ${data['connectedClients']}'); - debugPrint(' Messages: ${data['messageCount']}'); - debugPrint(' Contacts: ${data['contactCount']}'); - } catch (e) { - // Wrap the error with more user-friendly message - throw Exception(_formatConnectionError(e)); - } - } - - /// Format connection error to be more user-friendly - String _formatConnectionError(dynamic error) { - final errorStr = error.toString(); - - // Extract the actual server URL being connected to - final serverUri = Uri.tryParse(_serverUrl ?? ''); - final host = serverUri?.host ?? 'unknown'; - final port = serverUri?.port ?? 0; - - if (errorStr.contains('Connection refused')) { - return 'Server not available at $host:$port. The server may be offline or not running.'; - } else if (errorStr.contains('TimeoutException') || - errorStr.contains('timed out')) { - return 'Connection to $host:$port timed out. Check your network connection.'; - } else if (errorStr.contains('SocketException')) { - return 'Network error connecting to $host:$port. Check your network connection.'; - } else if (errorStr.contains('Failed host lookup')) { - return 'Could not resolve hostname: $host'; - } - - // Return the original error if we can't make it more user-friendly - return errorStr; - } - - /// Fetch message history on connect - Future _fetchMessageHistory() async { - try { - final url = Uri.parse('$_serverUrl/api/messages/history'); - final response = await http - .get(url, headers: _getHeaders()) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw Exception( - 'Failed to fetch message history: ${response.statusCode}', - ); - } - - final data = jsonDecode(response.body) as Map; - final messages = data['messages'] as List; - - debugPrint( - '📥 [SseClient] Received ${messages.length} messages from history', - ); - - for (final msgJson in messages) { - try { - final message = _messageFromJson(msgJson); - onMessageReceived?.call(message); - } catch (e) { - debugPrint('⚠️ [SseClient] Failed to parse message: $e'); - } - } - } catch (e) { - debugPrint('❌ [SseClient] Error fetching message history: $e'); - // Don't throw - continue with connection even if history fetch fails - } - } - - /// Fetch contacts on connect - Future _fetchContacts() async { - try { - final url = Uri.parse('$_serverUrl/api/contacts'); - final response = await http - .get(url, headers: _getHeaders()) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw Exception('Failed to fetch contacts: ${response.statusCode}'); - } - - final data = jsonDecode(response.body) as Map; - final contacts = data['contacts'] as List; - - debugPrint('📥 [SseClient] Received ${contacts.length} contacts'); - - for (final contactJson in contacts) { - try { - final contact = _contactFromJson(contactJson); - onContactReceived?.call(contact); - } catch (e) { - debugPrint('⚠️ [SseClient] Failed to parse contact: $e'); - } - } - } catch (e) { - debugPrint('❌ [SseClient] Error fetching contacts: $e'); - // Don't throw - continue with connection even if contacts fetch fails - } - } - - /// Subscribe to SSE message stream - Future _subscribeToMessages() async { - try { - if (_httpClient == null) { - throw Exception('HTTP client not initialized'); - } - - debugPrint('📡 [SseClient] Creating message stream request...'); - final url = Uri.parse('$_serverUrl/sse/messages'); - final request = http.Request('GET', url); - request.headers.addAll(_getHeaders()); - request.headers['Accept'] = 'text/event-stream'; - request.headers['Cache-Control'] = 'no-cache'; - - debugPrint('📡 [SseClient] Sending message stream request to $url'); - debugPrint('📡 [SseClient] Request headers: ${request.headers}'); - - final streamedResponse = await _httpClient! - .send(request) - .timeout( - const Duration(seconds: 10), - onTimeout: () { - debugPrint('❌ [SseClient] Timeout waiting for response headers'); - throw TimeoutException( - 'Message stream connection timed out after 10 seconds', - ); - }, - ); - - debugPrint( - '📡 [SseClient] Received response with status: ${streamedResponse.statusCode}', - ); - debugPrint( - '📡 [SseClient] Response headers: ${streamedResponse.headers}', - ); - debugPrint( - '📡 [SseClient] Response content length: ${streamedResponse.contentLength}', - ); - debugPrint( - '📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}', - ); - - if (streamedResponse.statusCode != 200) { - throw Exception( - 'SSE messages subscription failed: ${streamedResponse.statusCode}', - ); - } - - debugPrint( - '📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}', - ); - debugPrint('📡 [SseClient] Setting up stream listener...'); - - _messageSubscription = streamedResponse.stream - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen( - (line) { - debugPrint('📨 [SseClient] Received line: "$line"'); - _handleSseLine(line, 'message'); - }, - onError: (error, stackTrace) { - debugPrint('❌ [SseClient] Message stream error: $error'); - debugPrint(' Stack trace: $stackTrace'); - _handleDisconnect(); - }, - onDone: () { - debugPrint( - '⚠️ [SseClient] Message stream closed (onDone called)', - ); - _handleDisconnect(); - }, - cancelOnError: false, - ); - - debugPrint('✅ [SseClient] Message stream listener set up successfully'); - } catch (e) { - debugPrint('❌ [SseClient] Error subscribing to message stream: $e'); - rethrow; - } - } - - /// Subscribe to SSE contact stream - Future _subscribeToContacts() async { - try { - if (_httpClient == null) { - throw Exception('HTTP client not initialized'); - } - - debugPrint('📡 [SseClient] Creating contact stream request...'); - final url = Uri.parse('$_serverUrl/sse/contacts'); - final request = http.Request('GET', url); - request.headers.addAll(_getHeaders()); - request.headers['Accept'] = 'text/event-stream'; - request.headers['Cache-Control'] = 'no-cache'; - - debugPrint('📡 [SseClient] Sending contact stream request to $url'); - final streamedResponse = await _httpClient! - .send(request) - .timeout( - const Duration(seconds: 10), - onTimeout: () { - throw TimeoutException( - 'Contact stream connection timed out after 10 seconds', - ); - }, - ); - - if (streamedResponse.statusCode != 200) { - throw Exception( - 'SSE contacts subscription failed: ${streamedResponse.statusCode}', - ); - } - - debugPrint( - '📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}', - ); - debugPrint('📡 [SseClient] Setting up contact stream listener...'); - - _contactSubscription = streamedResponse.stream - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen( - (line) { - debugPrint('📨 [SseClient] Received contact line: "$line"'); - _handleSseLine(line, 'contact'); - }, - onError: (error, stackTrace) { - debugPrint('❌ [SseClient] Contact stream error: $error'); - debugPrint(' Stack trace: $stackTrace'); - _handleDisconnect(); - }, - onDone: () { - debugPrint( - '⚠️ [SseClient] Contact stream closed (onDone called)', - ); - _handleDisconnect(); - }, - cancelOnError: false, - ); - - debugPrint('✅ [SseClient] Contact stream listener set up successfully'); - } catch (e) { - debugPrint('❌ [SseClient] Error subscribing to contact stream: $e'); - rethrow; - } - } - - /// Handle SSE line - String _eventType = ''; - void _handleSseLine(String line, String streamType) { - if (line.isEmpty) { - // Event complete, reset - _eventType = ''; - return; - } - - if (line.startsWith('event:')) { - _eventType = line.substring(6).trim(); - } else if (line.startsWith('data:')) { - final jsonData = line.substring(5).trim(); - try { - final data = jsonDecode(jsonData) as Map; - - if (streamType == 'message' && _eventType == 'message') { - final message = _messageFromJson(data); - onMessageReceived?.call(message); - } else if (streamType == 'contact' && _eventType == 'contact') { - final contact = _contactFromJson(data); - onContactReceived?.call(contact); - } - } catch (e) { - debugPrint('⚠️ [SseClient] Failed to parse SSE data: $e'); - } - } - } - - /// Handle disconnect - void _handleDisconnect() { - if (!_isConnected) return; - - _isConnected = false; - onConnectionStateChanged?.call(false); - - _scheduleReconnect(); - } - - /// Schedule reconnection attempt - void _scheduleReconnect() { - if (_reconnectAttempts >= _maxReconnectAttempts) { - debugPrint('❌ [SseClient] Max reconnection attempts reached'); - onError?.call('Max reconnection attempts reached'); - return; - } - - _reconnectAttempts++; - final delay = _reconnectDelay * _reconnectAttempts; - - debugPrint( - '🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s', - ); - - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(delay, () { - if (_serverUrl != null) { - connect(serverUrl: _serverUrl!, authToken: _authToken); - } - }); - } - - /// Start heartbeat to detect connection loss - void _startHeartbeat() { - _heartbeatTimer?.cancel(); - _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), ( - timer, - ) async { - try { - await _checkServerStatus(); - } catch (e) { - debugPrint('⚠️ [SseClient] Heartbeat failed: $e'); - _handleDisconnect(); - } - }); - } - - /// Send message to server - Future sendMessage({ - required String recipientPublicKey, - required String text, - }) async { - if (!_isConnected || _serverUrl == null) { - throw Exception('Not connected to server'); - } - - try { - final url = Uri.parse('$_serverUrl/api/messages'); - final response = await http - .post( - url, - headers: {..._getHeaders(), 'Content-Type': 'application/json'}, - body: jsonEncode({ - 'recipientPublicKey': recipientPublicKey, - 'text': text, - }), - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw Exception('Send message failed: ${response.statusCode}'); - } - - final data = jsonDecode(response.body) as Map; - return data['success'] as bool? ?? false; - } catch (e) { - debugPrint('❌ [SseClient] Error sending message: $e'); - rethrow; - } - } - - /// Send channel message to server - Future sendChannelMessage({ - required int channelIdx, - required String text, - }) async { - if (!_isConnected || _serverUrl == null) { - throw Exception('Not connected to server'); - } - - try { - final url = Uri.parse('$_serverUrl/api/messages/channel'); - final response = await http - .post( - url, - headers: {..._getHeaders(), 'Content-Type': 'application/json'}, - body: jsonEncode({'channelIdx': channelIdx, 'text': text}), - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw Exception('Send channel message failed: ${response.statusCode}'); - } - } catch (e) { - debugPrint('❌ [SseClient] Error sending channel message: $e'); - rethrow; - } - } - - /// Request contact sync - Future syncContacts() async { - if (!_isConnected || _serverUrl == null) { - throw Exception('Not connected to server'); - } - - try { - final url = Uri.parse('$_serverUrl/api/contacts/sync'); - final response = await http - .post(url, headers: _getHeaders()) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw Exception('Contact sync failed: ${response.statusCode}'); - } - - debugPrint('✅ [SseClient] Contact sync requested'); - } catch (e) { - debugPrint('❌ [SseClient] Error syncing contacts: $e'); - rethrow; - } - } - - /// Get headers for HTTP requests - Map _getHeaders() { - final headers = {}; - if (_authToken != null) { - headers['Authorization'] = 'Bearer $_authToken'; - } - return headers; - } - - /// Convert JSON to Message - Message _messageFromJson(Map json) { - return Message( - id: json['id'] as String, - messageType: MessageType.values.firstWhere( - (e) => e.name == json['messageType'], - orElse: () => MessageType.contact, - ), - senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null - ? Uint8List.fromList( - (json['senderPublicKeyPrefix'] as List).cast(), - ) - : null, - channelIdx: json['channelIdx'] as int?, - pathLen: json['pathLen'] as int, - textType: MessageTextType.fromValue(json['textType'] as int), - senderTimestamp: json['senderTimestamp'] as int, - text: json['text'] as String, - isSarMarker: json['isSarMarker'] as bool? ?? false, - sarGpsCoordinates: json['sarGpsCoordinates'] != null - ? LatLng( - (json['sarGpsCoordinates']['latitude'] as num).toDouble(), - (json['sarGpsCoordinates']['longitude'] as num).toDouble(), - ) - : null, - sarNotes: json['sarNotes'] as String?, - sarCustomEmoji: json['sarCustomEmoji'] as String?, - sarColorIndex: json['sarColorIndex'] as int?, - receivedAt: DateTime.parse(json['receivedAt'] as String), - senderName: json['senderName'] as String?, - deliveryStatus: MessageDeliveryStatus.values.firstWhere( - (e) => e.name == json['deliveryStatus'], - orElse: () => MessageDeliveryStatus.received, - ), - expectedAckTag: json['expectedAckTag'] as int?, - suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?, - roundTripTimeMs: json['roundTripTimeMs'] as int?, - deliveredAt: json['deliveredAt'] != null - ? DateTime.parse(json['deliveredAt'] as String) - : null, - recipientPublicKey: json['recipientPublicKey'] != null - ? Uint8List.fromList((json['recipientPublicKey'] as List).cast()) - : null, - retryAttempt: json['retryAttempt'] as int? ?? 0, - lastRetryAt: json['lastRetryAt'] != null - ? DateTime.parse(json['lastRetryAt'] as String) - : null, - usedFloodFallback: json['usedFloodFallback'] as bool? ?? false, - isRead: json['isRead'] as bool? ?? false, - echoCount: json['echoCount'] as int? ?? 0, - firstEchoAt: json['firstEchoAt'] != null - ? DateTime.parse(json['firstEchoAt'] as String) - : null, - lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?, - lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?, - lastEchoAt: json['lastEchoAt'] != null - ? DateTime.parse(json['lastEchoAt'] as String) - : null, - isDrawing: json['isDrawing'] as bool? ?? false, - drawingId: json['drawingId'] as String?, - ); - } - - /// Convert JSON to Contact - Contact _contactFromJson(Map json) { - return Contact( - publicKey: Uint8List.fromList((json['publicKey'] as List).cast()), - type: ContactType.fromValue(json['type'] as int), - flags: json['flags'] as int, - outPathLen: json['outPathLen'] as int, - outPath: Uint8List.fromList((json['outPath'] as List).cast()), - advName: json['advName'] as String, - lastAdvert: json['lastAdvert'] as int, - advLat: json['advLat'] as int, - advLon: json['advLon'] as int, - lastMod: json['lastMod'] as int, - ); - } - - /// Dispose resources - void dispose() { - disconnect(); - } -} diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index b4d31e5..9e14f5a 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -20,7 +20,7 @@ class _ConnectionDialogState extends State final List _discoveredServers = []; int _scannedCount = 0; int _totalToScan = 0; - String? _connectingToServerUrl; // Track which server is being connected to + String? _connectingToServerKey; // Track which server is being connected to (ip:port) // Named listener method for proper cleanup void _onTabChanged() { @@ -361,61 +361,16 @@ class _ConnectionDialogState extends State } Widget _buildNetworkServersTab() { - final connectionProvider = context.watch(); final bool showingCachedResults = !_networkScanner.isScanning && _networkScanner.hasCachedResults && _discoveredServers.isNotEmpty; - final bool isConnectingToSse = connectionProvider.isSseClientConnecting; - final int sseReconnectAttempt = - connectionProvider.sseClientReconnectionAttempt; - final int sseMaxReconnects = - connectionProvider.sseClientMaxReconnectionAttempts; return Column( children: [ - // SSE Reconnection banner (show when reconnecting) - if (isConnectingToSse && sseReconnectAttempt > 0) - Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme.of(context).colorScheme.onTertiaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Reconnecting to server... (Attempt $sseReconnectAttempt/$sseMaxReconnects)', - style: TextStyle( - color: Theme.of(context).colorScheme.onTertiaryContainer, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - // Info banner Container( - margin: EdgeInsets.fromLTRB( - 16, - isConnectingToSse && sseReconnectAttempt > 0 ? 8 : 16, - 16, - 16, - ), + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, @@ -432,7 +387,7 @@ class _ConnectionDialogState extends State child: Text( showingCachedResults ? 'Showing cached results. Tap refresh to rescan.' - : 'Scanning local network for shared MeshCore devices on port 12929', + : 'Scanning local network for MeshCore WiFi devices on port 5000', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 13, @@ -505,10 +460,11 @@ class _ConnectionDialogState extends State itemCount: _discoveredServers.length, itemBuilder: (context, index) { final server = _discoveredServers[index]; + final serverKey = '${server.ipAddress}:${server.port}'; final isConnectingToThisServer = - _connectingToServerUrl == server.serverUrl; + _connectingToServerKey == serverKey; final isAnyConnectionInProgress = - isConnectingToSse || _connectingToServerUrl != null; + _connectingToServerKey != null; return Container( margin: const EdgeInsets.symmetric( @@ -593,7 +549,7 @@ class _ConnectionDialogState extends State // Mark this server as connecting setState(() { - _connectingToServerUrl = server.serverUrl; + _connectingToServerKey = serverKey; }); try { @@ -607,8 +563,9 @@ class _ConnectionDialogState extends State ); } - await connectionProvider.connectToSseServer( - serverUrl: server.serverUrl, + await connectionProvider.connectTcp( + server.ipAddress, + server.port, ); await appProvider.initialize(); @@ -619,7 +576,7 @@ class _ConnectionDialogState extends State // Clear connecting state on error if (mounted) { setState(() { - _connectingToServerUrl = null; + _connectingToServerKey = null; }); // Clean up error message (remove "Exception: " prefix) diff --git a/pubspec.lock b/pubspec.lock index 1e32e02..dec2120 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -883,7 +883,7 @@ packages: description: path: "." ref: main - resolved-ref: "624e3d3cf6ea32d8245cc85d5b599f30ca910501" + resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0"