feat: replace SSE client with direct TCP/WiFi connection

- Remove SseClientService and ConnectionMode.sseClient
- Add ConnectionMode.tcp with MeshCoreTcpService (port 5000)
- NetworkScannerService: scan TCP port 5000 via raw Socket.connect
- mDNS service type changed to _meshcore._tcp
- connection_dialog: connect via connectTcp() instead of connectToSseServer()
- home_screen: show WiFi icon and label for TCP mode
- Bump meshcore_client to pick up TCP transport support
This commit is contained in:
Janez T
2026-03-04 19:11:16 +01:00
parent c6b3b86c2f
commit 84a6f81c26
7 changed files with 349 additions and 1475 deletions

View File

@@ -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';
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<HomeScreen>
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<HomeScreen>
return Consumer<ConnectionProvider>(
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<HomeScreen>
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<HomeScreen>
),
),
],
if (isSseConnected && !isBleConnected) ...[
if (isTcpConnected) ...[
const SizedBox(width: 3),
Text(
'SSE',
'WiFi',
style: const TextStyle(
fontSize: 11,
color: Colors.green,

View File

@@ -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<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> 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<Set<String>> _getLocalIpAddresses() async {
final Set<String> localIps = {};
// ── Helpers ────────────────────────────────────────────────────────────────
Future<Set<String>> _getLocalIpAddresses() async {
final ips = <String>{};
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<List<String>> _getLocalNetworkRange() async {
final List<String> 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<DiscoveredServer?> _checkServer(String ip, int port) async {
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
Future<DiscoveredServer?> _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<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
// ── mDNS discovery ─────────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
final found = <DiscoveredServer>[];
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<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) {
debugPrint('⚠️ [NetworkScanner] Scan already in progress');
return [];
}
// ── Port scan fallback ─────────────────────────────────────────────────────
_isScanning = true;
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
final scanPort = port ?? defaultPort;
List<DiscoveredServer> discoveredServers = [];
final found = <DiscoveredServer>[];
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<List<DiscoveredServer>> 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<List<DiscoveredServer>> _scanByPortAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> 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<bool> 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;
}

View File

@@ -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<void> 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<void> 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<void> _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<void> _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<String, dynamic>;
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<void> _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<String, dynamic>;
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<void> _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<void> _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<String, dynamic>;
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<bool> 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<String, dynamic>;
return data['success'] as bool? ?? false;
} catch (e) {
debugPrint('❌ [SseClient] Error sending message: $e');
rethrow;
}
}
/// Send channel message to server
Future<void> 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<void> 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<String, String> _getHeaders() {
final headers = <String, String>{};
if (_authToken != null) {
headers['Authorization'] = 'Bearer $_authToken';
}
return headers;
}
/// Convert JSON to Message
Message _messageFromJson(Map<String, dynamic> 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<int>(),
)
: 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<int>())
: 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<String, dynamic> json) {
return Contact(
publicKey: Uint8List.fromList((json['publicKey'] as List).cast<int>()),
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<int>()),
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();
}
}

View File

@@ -20,7 +20,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _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<ConnectionDialog>
}
Widget _buildNetworkServersTab() {
final connectionProvider = context.watch<ConnectionProvider>();
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<ConnectionDialog>
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<ConnectionDialog>
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<ConnectionDialog>
// Mark this server as connecting
setState(() {
_connectingToServerUrl = server.serverUrl;
_connectingToServerKey = serverKey;
});
try {
@@ -607,8 +563,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
);
}
await connectionProvider.connectToSseServer(
serverUrl: server.serverUrl,
await connectionProvider.connectTcp(
server.ipAddress,
server.port,
);
await appProvider.initialize();
@@ -619,7 +576,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
// Clear connecting state on error
if (mounted) {
setState(() {
_connectingToServerUrl = null;
_connectingToServerKey = null;
});
// Clean up error message (remove "Exception: " prefix)

View File

@@ -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"