mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
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:
@@ -17,8 +17,8 @@ enum ConnectionMode {
|
|||||||
/// Act as SSE server - share BLE device with multiple clients
|
/// Act as SSE server - share BLE device with multiple clients
|
||||||
sseServer,
|
sseServer,
|
||||||
|
|
||||||
/// Connect to remote SSE server - no direct BLE connection
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
sseClient,
|
tcp,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectionModeExtension on ConnectionMode {
|
extension ConnectionModeExtension on ConnectionMode {
|
||||||
@@ -28,8 +28,8 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
case ConnectionMode.sseServer:
|
||||||
return 'Share Device (Server)';
|
return 'Share Device (Server)';
|
||||||
case ConnectionMode.sseClient:
|
case ConnectionMode.tcp:
|
||||||
return 'Connect to Server';
|
return 'Direct (WiFi)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
case ConnectionMode.sseServer:
|
||||||
return 'Share BLE device with multiple clients over network';
|
return 'Share BLE device with multiple clients over network';
|
||||||
case ConnectionMode.sseClient:
|
case ConnectionMode.tcp:
|
||||||
return 'Connect to remote server without BLE';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:vibration/vibration.dart';
|
import 'package:vibration/vibration.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
|
import '../models/device_info.dart' show ConnectionMode;
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -298,7 +299,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final isConnected =
|
final isConnected =
|
||||||
provider.deviceInfo.isConnected ||
|
provider.deviceInfo.isConnected ||
|
||||||
provider.isSseClientConnected;
|
provider.deviceInfo.isConnected;
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -446,9 +447,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
return Consumer<ConnectionProvider>(
|
return Consumer<ConnectionProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final deviceInfo = provider.deviceInfo;
|
final deviceInfo = provider.deviceInfo;
|
||||||
final isBleConnected = deviceInfo.isConnected;
|
final isConnected = deviceInfo.isConnected;
|
||||||
final isSseConnected = provider.isSseClientConnected;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
final isConnected = isBleConnected || isSseConnected;
|
final isBleConnected = isConnected && !isTcpConnected;
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
// Disconnected state: show connect button
|
||||||
@@ -536,10 +537,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isSseConnected
|
isTcpConnected
|
||||||
? Icons.wifi
|
? Icons.wifi
|
||||||
: Icons.bluetooth_connected,
|
: Icons.bluetooth_connected,
|
||||||
color: isSseConnected
|
color: isTcpConnected
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (deviceInfo.signalRssi != null
|
: (deviceInfo.signalRssi != null
|
||||||
? BatteryDisplayHelper.getSignalColor(
|
? BatteryDisplayHelper.getSignalColor(
|
||||||
@@ -561,10 +562,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isSseConnected && !isBleConnected) ...[
|
if (isTcpConnected) ...[
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'SSE',
|
'WiFi',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
|
|||||||
@@ -1,213 +1,155 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:nsd/nsd.dart';
|
import 'package:nsd/nsd.dart';
|
||||||
|
|
||||||
/// Discovered SSE server on the network
|
/// Discovered MeshCore device on the network (TCP/WiFi)
|
||||||
class DiscoveredServer {
|
class DiscoveredServer {
|
||||||
final String ipAddress;
|
final String ipAddress;
|
||||||
final int port;
|
final int port;
|
||||||
final int responseTime; // in milliseconds
|
final int responseTime; // milliseconds
|
||||||
final String serverUrl;
|
|
||||||
|
|
||||||
DiscoveredServer({
|
const DiscoveredServer({
|
||||||
required this.ipAddress,
|
required this.ipAddress,
|
||||||
required this.port,
|
required this.port,
|
||||||
required this.responseTime,
|
required this.responseTime,
|
||||||
}) : serverUrl = 'http://$ipAddress:$port';
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
|
||||||
return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) =>
|
||||||
if (identical(this, other)) return true;
|
other is DiscoveredServer &&
|
||||||
return other is DiscoveredServer &&
|
other.ipAddress == ipAddress &&
|
||||||
other.ipAddress == ipAddress &&
|
other.port == port;
|
||||||
other.port == port;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(ipAddress, port);
|
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.
|
/// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel
|
||||||
/// Falls back to port scanning (12929) if no services are discovered.
|
/// TCP-connect port scan of the local /24 subnet.
|
||||||
/// Uses parallel scanning (20 IPs at once) for fast discovery.
|
|
||||||
class NetworkScannerService {
|
class NetworkScannerService {
|
||||||
static const int defaultPort = 12929;
|
static const int defaultPort = 5000;
|
||||||
static const String serviceType = '_meshcore-sse._tcp';
|
static const String serviceType = '_meshcore._tcp';
|
||||||
static const int parallelScans = 20;
|
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);
|
static const Duration bonjourTimeout = Duration(seconds: 5);
|
||||||
|
|
||||||
Discovery? _activeDiscovery;
|
Discovery? _activeDiscovery;
|
||||||
|
|
||||||
/// Callback for when a server is discovered
|
|
||||||
Function(DiscoveredServer)? onServerDiscovered;
|
Function(DiscoveredServer)? onServerDiscovered;
|
||||||
|
|
||||||
/// Callback for scan progress updates
|
|
||||||
Function(int scanned, int total)? onProgressUpdate;
|
Function(int scanned, int total)? onProgressUpdate;
|
||||||
|
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
|
|
||||||
/// Cached discovered servers from the last scan
|
|
||||||
List<DiscoveredServer> _cachedServers = [];
|
List<DiscoveredServer> _cachedServers = [];
|
||||||
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
||||||
|
|
||||||
/// Whether we have cached results from a previous scan
|
|
||||||
bool get hasCachedResults => _cachedServers.isNotEmpty;
|
bool get hasCachedResults => _cachedServers.isNotEmpty;
|
||||||
|
|
||||||
/// Get all local IP addresses
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
Future<Set<String>> _getLocalIpAddresses() async {
|
|
||||||
final Set<String> localIps = {};
|
|
||||||
|
|
||||||
|
Future<Set<String>> _getLocalIpAddresses() async {
|
||||||
|
final ips = <String>{};
|
||||||
try {
|
try {
|
||||||
final interfaces = await NetworkInterface.list();
|
for (final iface in await NetworkInterface.list()) {
|
||||||
for (final interface in interfaces) {
|
for (final addr in iface.addresses) {
|
||||||
for (final addr in interface.addresses) {
|
if (addr.type == InternetAddressType.IPv4) ips.add(addr.address);
|
||||||
if (addr.type == InternetAddressType.IPv4) {
|
|
||||||
localIps.add(addr.address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
|
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
|
||||||
}
|
}
|
||||||
|
return ips;
|
||||||
return localIps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get local network IP range to scan
|
|
||||||
Future<List<String>> _getLocalNetworkRange() async {
|
Future<List<String>> _getLocalNetworkRange() async {
|
||||||
final List<String> ips = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all network interfaces
|
for (final iface in await NetworkInterface.list()) {
|
||||||
final interfaces = await NetworkInterface.list();
|
for (final addr in iface.addresses) {
|
||||||
|
|
||||||
for (final interface in interfaces) {
|
|
||||||
for (final addr in interface.addresses) {
|
|
||||||
// Only scan IPv4 addresses that are not loopback
|
|
||||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||||
final ip = addr.address;
|
final parts = addr.address.split('.');
|
||||||
final parts = ip.split('.');
|
|
||||||
|
|
||||||
if (parts.length == 4) {
|
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]}';
|
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
||||||
|
debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24');
|
||||||
// Scan from .1 to .254 (skip .0 and .255)
|
return [for (int i = 1; i <= 254; i++) '$subnet.$i'];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e');
|
debugPrint('❌ [NetworkScanner] Error getting network range: $e');
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
return ips;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an IP has an SSE server running
|
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
|
||||||
Future<DiscoveredServer?> _checkServer(String ip, int port) async {
|
Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
Socket? socket;
|
||||||
try {
|
try {
|
||||||
final stopwatch = Stopwatch()..start();
|
socket = await Socket.connect(
|
||||||
final url = Uri.parse('http://$ip:$port/api/status');
|
ip,
|
||||||
|
port,
|
||||||
final response = await http.get(url).timeout(scanTimeout);
|
timeout: connectTimeout,
|
||||||
|
);
|
||||||
stopwatch.stop();
|
sw.stop();
|
||||||
|
debugPrint(
|
||||||
if (response.statusCode == 200) {
|
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
|
||||||
debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)');
|
return DiscoveredServer(
|
||||||
|
ipAddress: ip,
|
||||||
return DiscoveredServer(
|
port: port,
|
||||||
ipAddress: ip,
|
responseTime: sw.elapsedMilliseconds,
|
||||||
port: port,
|
);
|
||||||
responseTime: stopwatch.elapsedMilliseconds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} on TimeoutException {
|
|
||||||
// Timeout - server not responding, ignore
|
|
||||||
} on SocketException {
|
} on SocketException {
|
||||||
// Connection refused - no server at this IP, ignore
|
// Connection refused or timed out — no device here
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Other errors - ignore
|
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port — $e');
|
||||||
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e');
|
} finally {
|
||||||
|
socket?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover servers using Bonjour/mDNS
|
// ── mDNS discovery ─────────────────────────────────────────────────────────
|
||||||
Future<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
|
|
||||||
|
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
|
||||||
final scanPort = port ?? defaultPort;
|
final scanPort = port ?? defaultPort;
|
||||||
final List<DiscoveredServer> discoveredServers = [];
|
final found = <DiscoveredServer>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...');
|
debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...');
|
||||||
|
|
||||||
// Get local IP addresses to filter out
|
|
||||||
final localIps = await _getLocalIpAddresses();
|
final localIps = await _getLocalIpAddresses();
|
||||||
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
|
|
||||||
|
|
||||||
// Start discovery with IP lookup
|
|
||||||
_activeDiscovery = await startDiscovery(
|
_activeDiscovery = await startDiscovery(
|
||||||
serviceType,
|
serviceType,
|
||||||
ipLookupType: IpLookupType.any,
|
ipLookupType: IpLookupType.any,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for discovery to find services
|
|
||||||
await Future.delayed(bonjourTimeout);
|
await Future.delayed(bonjourTimeout);
|
||||||
|
|
||||||
// Process discovered services
|
for (final service in _activeDiscovery?.services ?? []) {
|
||||||
final services = _activeDiscovery?.services ?? [];
|
for (final addr in service.addresses ?? []) {
|
||||||
debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services');
|
if (localIps.contains(addr.address)) continue;
|
||||||
|
final result =
|
||||||
for (final service in services) {
|
await _checkDevice(addr.address, service.port ?? scanPort);
|
||||||
if (service.addresses != null && service.addresses!.isNotEmpty) {
|
if (result != null) {
|
||||||
for (final address in service.addresses!) {
|
found.add(result);
|
||||||
// Skip if this is a local IP address
|
onServerDiscovered?.call(result);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop discovery
|
|
||||||
await stopDiscovery(_activeDiscovery!);
|
await stopDiscovery(_activeDiscovery!);
|
||||||
_activeDiscovery = null;
|
_activeDiscovery = null;
|
||||||
|
debugPrint(
|
||||||
debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.');
|
'✅ [NetworkScanner] mDNS done. Found ${found.length} devices.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e');
|
debugPrint('⚠️ [NetworkScanner] mDNS failed: $e');
|
||||||
if (_activeDiscovery != null) {
|
if (_activeDiscovery != null) {
|
||||||
try {
|
try {
|
||||||
await stopDiscovery(_activeDiscovery!);
|
await stopDiscovery(_activeDiscovery!);
|
||||||
@@ -216,132 +158,70 @@ class NetworkScannerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return discoveredServers;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan the local network for SSE servers
|
// ── Port scan fallback ─────────────────────────────────────────────────────
|
||||||
/// 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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_isScanning = true;
|
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
|
||||||
final scanPort = port ?? defaultPort;
|
final scanPort = port ?? defaultPort;
|
||||||
List<DiscoveredServer> discoveredServers = [];
|
final found = <DiscoveredServer>[];
|
||||||
|
|
||||||
try {
|
final localIps = await _getLocalIpAddresses();
|
||||||
// Try Bonjour/mDNS discovery first
|
final ips = await _getLocalNetworkRange();
|
||||||
discoveredServers = await _discoverViaBonjourAsync(port: scanPort);
|
if (ips.isEmpty) return [];
|
||||||
|
|
||||||
// Fall back to port scanning if Bonjour found nothing
|
debugPrint(
|
||||||
if (discoveredServers.isEmpty) {
|
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
|
||||||
debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...');
|
|
||||||
discoveredServers = await _scanByPortAsync(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
|
scanned += batch.length;
|
||||||
_cachedServers = discoveredServers;
|
onProgressUpdate?.call(scanned, ips.length);
|
||||||
} catch (e) {
|
}
|
||||||
debugPrint('❌ [NetworkScanner] Scan error: $e');
|
|
||||||
|
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 {
|
} finally {
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return discoveredServers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fallback port scanning method
|
/// Verify a previously discovered device is still reachable.
|
||||||
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
|
|
||||||
Future<bool> verifyServer(DiscoveredServer server) async {
|
Future<bool> verifyServer(DiscoveredServer server) async {
|
||||||
try {
|
final result = await _checkDevice(server.ipAddress, server.port);
|
||||||
debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...');
|
return result != null;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearCache() => _cachedServers = [];
|
||||||
|
|
||||||
|
void stopScan() => _isScanning = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
final List<DiscoveredServer> _discoveredServers = [];
|
final List<DiscoveredServer> _discoveredServers = [];
|
||||||
int _scannedCount = 0;
|
int _scannedCount = 0;
|
||||||
int _totalToScan = 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
|
// Named listener method for proper cleanup
|
||||||
void _onTabChanged() {
|
void _onTabChanged() {
|
||||||
@@ -361,61 +361,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNetworkServersTab() {
|
Widget _buildNetworkServersTab() {
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final bool showingCachedResults =
|
final bool showingCachedResults =
|
||||||
!_networkScanner.isScanning &&
|
!_networkScanner.isScanning &&
|
||||||
_networkScanner.hasCachedResults &&
|
_networkScanner.hasCachedResults &&
|
||||||
_discoveredServers.isNotEmpty;
|
_discoveredServers.isNotEmpty;
|
||||||
final bool isConnectingToSse = connectionProvider.isSseClientConnecting;
|
|
||||||
final int sseReconnectAttempt =
|
|
||||||
connectionProvider.sseClientReconnectionAttempt;
|
|
||||||
final int sseMaxReconnects =
|
|
||||||
connectionProvider.sseClientMaxReconnectionAttempts;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
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
|
// Info banner
|
||||||
Container(
|
Container(
|
||||||
margin: EdgeInsets.fromLTRB(
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
16,
|
|
||||||
isConnectingToSse && sseReconnectAttempt > 0 ? 8 : 16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
@@ -432,7 +387,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
child: Text(
|
child: Text(
|
||||||
showingCachedResults
|
showingCachedResults
|
||||||
? 'Showing cached results. Tap refresh to rescan.'
|
? '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(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -505,10 +460,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
itemCount: _discoveredServers.length,
|
itemCount: _discoveredServers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final server = _discoveredServers[index];
|
final server = _discoveredServers[index];
|
||||||
|
final serverKey = '${server.ipAddress}:${server.port}';
|
||||||
final isConnectingToThisServer =
|
final isConnectingToThisServer =
|
||||||
_connectingToServerUrl == server.serverUrl;
|
_connectingToServerKey == serverKey;
|
||||||
final isAnyConnectionInProgress =
|
final isAnyConnectionInProgress =
|
||||||
isConnectingToSse || _connectingToServerUrl != null;
|
_connectingToServerKey != null;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
@@ -593,7 +549,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
|
|
||||||
// Mark this server as connecting
|
// Mark this server as connecting
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerUrl = server.serverUrl;
|
_connectingToServerKey = serverKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -607,8 +563,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await connectionProvider.connectToSseServer(
|
await connectionProvider.connectTcp(
|
||||||
serverUrl: server.serverUrl,
|
server.ipAddress,
|
||||||
|
server.port,
|
||||||
);
|
);
|
||||||
await appProvider.initialize();
|
await appProvider.initialize();
|
||||||
|
|
||||||
@@ -619,7 +576,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
|||||||
// Clear connecting state on error
|
// Clear connecting state on error
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_connectingToServerUrl = null;
|
_connectingToServerKey = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up error message (remove "Exception: " prefix)
|
// Clean up error message (remove "Exception: " prefix)
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "624e3d3cf6ea32d8245cc85d5b599f30ca910501"
|
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user