feat: Add TCP connection support and UI integration

- Implemented TCP transport service for native platforms.
- Added TCP connection screen with input fields for host and port.
- Integrated TCP connection options into the scanner and USB screens.
- Updated localization files for new TCP-related strings.
- Added tests for TCP connection flow and error handling.
- Enhanced USB screen to include TCP connection option.
- Improved layout to ensure no overflow in narrow widths for scanner and USB screens.
This commit is contained in:
just-stuff-tm
2026-03-07 20:07:19 -05:00
parent a1b77bb29b
commit 7a2bb20bf7
43 changed files with 2391 additions and 123 deletions
+92 -7
View File
@@ -22,6 +22,7 @@ import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@@ -86,7 +87,7 @@ enum MeshCoreConnectionState {
disconnecting,
}
enum MeshCoreTransportType { bluetooth, usb }
enum MeshCoreTransportType { bluetooth, usb, tcp }
class RepeaterBatterySnapshot {
final int millivolts;
@@ -116,6 +117,8 @@ class MeshCoreConnector extends ChangeNotifier {
bool _manualDisconnect = false;
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
StreamSubscription<Uint8List>? _usbFrameSubscription;
final MeshCoreTcpManager _tcpManager = MeshCoreTcpManager();
StreamSubscription<Uint8List>? _tcpFrameSubscription;
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = [];
@@ -255,6 +258,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb;
String? get activeTcpEndpoint => _tcpManager.activeEndpoint;
bool get isTcpTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.tcp;
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
@@ -659,6 +666,7 @@ class MeshCoreConnector extends ChangeNotifier {
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
_usbManager.setDebugLogService(_appDebugLogService);
_tcpManager.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@@ -964,6 +972,70 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> connectTcp({required String host, required int port}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
_appDebugLogService?.warn(
'connectTcp ignored: already $_state',
tag: 'TCP',
);
return;
}
_appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP');
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
_resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.tcp;
_setState(MeshCoreConnectionState.connecting);
try {
await _tcpFrameSubscription?.cancel();
_tcpFrameSubscription = null;
await _tcpManager.connect(host: host, port: port);
notifyListeners();
await Future<void>.delayed(const Duration(milliseconds: 200));
_tcpFrameSubscription = _tcpManager.frameStream.listen(
_handleFrame,
onError: (error, stackTrace) {
_appDebugLogService?.error('TCP transport error: $error', tag: 'TCP');
unawaited(disconnect(manual: false));
},
onDone: () {
_appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP');
unawaited(disconnect(manual: false));
},
);
_setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
}
if (!gotSelfInfo) {
throw StateError('Timed out waiting for SELF_INFO during TCP connect');
}
await syncTime();
} catch (error) {
_appDebugLogService?.error('TCP connection error: $error', tag: 'TCP');
await disconnect(manual: false);
rethrow;
}
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
@@ -1230,6 +1302,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get _shouldGateInitialChannelSync =>
_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp ||
(_activeTransport == MeshCoreTransportType.bluetooth &&
PlatformInfo.isWeb);
@@ -1276,9 +1349,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
final transportAtDisconnect = _activeTransport;
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb
? 'USB'
: 'BLE';
final transportLabel = switch (transportAtDisconnect) {
MeshCoreTransportType.bluetooth => 'BLE',
MeshCoreTransportType.usb => 'USB',
MeshCoreTransportType.tcp => 'TCP',
};
_appDebugLogService?.info(
'Starting disconnect transport=$transportLabel manual=$manual',
@@ -1298,6 +1373,9 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbManager.disconnect();
await _tcpFrameSubscription?.cancel();
_tcpFrameSubscription = null;
await _tcpManager.disconnect();
await _notifySubscription?.cancel();
_notifySubscription = null;
@@ -1379,6 +1457,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data);
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpManager.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
@@ -2338,7 +2418,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (_pendingDeferredChannelSyncAfterContacts &&
(_activeTransport == MeshCoreTransportType.bluetooth ||
_activeTransport == MeshCoreTransportType.usb)) {
_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp)) {
_pendingDeferredChannelSyncAfterContacts = false;
_pendingInitialChannelSync = false;
unawaited(getChannels());
@@ -2505,14 +2586,16 @@ class MeshCoreConnector extends ChangeNotifier {
if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) {
_pendingInitialContactsSync = true;
} else if (_activeTransport == MeshCoreTransportType.usb) {
} else if (_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp) {
_pendingDeferredChannelSyncAfterContacts = true;
getContacts();
} else {
getContacts();
}
if (_shouldGateInitialChannelSync &&
_activeTransport != MeshCoreTransportType.usb) {
_activeTransport != MeshCoreTransportType.usb &&
_activeTransport != MeshCoreTransportType.tcp) {
_maybeStartInitialChannelSync();
}
}
@@ -4274,12 +4357,14 @@ class MeshCoreConnector extends ChangeNotifier {
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
_usbFrameSubscription?.cancel();
_tcpFrameSubscription?.cancel();
_notifySubscription?.cancel();
_notifyListenersTimer?.cancel();
_reconnectTimer?.cancel();
_batteryPollTimer?.cancel();
_receivedFramesController.close();
_usbManager.dispose();
_tcpManager.dispose();
// Flush pending unread writes before disposal
_unreadStore.flush();
+37
View File
@@ -0,0 +1,37 @@
import 'dart:typed_data';
import '../services/app_debug_log_service.dart';
import '../services/tcp_transport_service.dart';
class MeshCoreTcpManager {
final TcpTransportService _service = TcpTransportService();
AppDebugLogService? _debugLog;
String? get activeEndpoint => _service.activeEndpoint;
bool get isConnected => _service.isConnected;
Stream<Uint8List> get frameStream => _service.frameStream;
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
Future<void> connect({required String host, required int port}) async {
_debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP');
await _service.connect(host: host, port: port);
}
Future<void> disconnect() async {
if (!_service.isConnected && _service.activeEndpoint == null) {
return;
}
_debugLog?.info('TcpManager.disconnect', tag: 'TCP');
await _service.disconnect();
}
Future<void> write(Uint8List data) => _service.write(data);
void dispose() {
_service.dispose();
}
}
@@ -53,6 +53,9 @@ class MeshCoreUsbManager {
}
Future<void> disconnect() async {
if (!_service.isConnected && _activePortKey == null) {
return;
}
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect();
_activePortKey = null;