From 22a53439b1b7b0a103795165ec5636159b2e86c5 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Sun, 1 Mar 2026 23:08:51 -0500
Subject: [PATCH 01/53] Initialize USB Supoport for Andriod and Desktop
---
android/app/build.gradle.kts | 3 +-
android/app/src/main/AndroidManifest.xml | 1 +
.../meshcore/meshcore_open/MainActivity.kt | 310 +++++++++++-
android/build.gradle.kts | 1 +
lib/connector/meshcore_connector.dart | 151 +++++-
lib/l10n/app_bg.arb | 11 +-
lib/l10n/app_de.arb | 11 +-
lib/l10n/app_en.arb | 9 +
lib/l10n/app_es.arb | 11 +-
lib/l10n/app_fr.arb | 11 +-
lib/l10n/app_it.arb | 11 +-
lib/l10n/app_localizations.dart | 54 +++
lib/l10n/app_localizations_bg.dart | 31 ++
lib/l10n/app_localizations_de.dart | 32 ++
lib/l10n/app_localizations_en.dart | 31 ++
lib/l10n/app_localizations_es.dart | 32 ++
lib/l10n/app_localizations_fr.dart | 32 ++
lib/l10n/app_localizations_it.dart | 32 ++
lib/l10n/app_localizations_nl.dart | 31 ++
lib/l10n/app_localizations_pl.dart | 31 ++
lib/l10n/app_localizations_pt.dart | 31 ++
lib/l10n/app_localizations_ru.dart | 32 ++
lib/l10n/app_localizations_sk.dart | 31 ++
lib/l10n/app_localizations_sl.dart | 31 ++
lib/l10n/app_localizations_sv.dart | 31 ++
lib/l10n/app_localizations_uk.dart | 32 ++
lib/l10n/app_localizations_zh.dart | 27 ++
lib/l10n/app_nl.arb | 11 +-
lib/l10n/app_pl.arb | 11 +-
lib/l10n/app_pt.arb | 11 +-
lib/l10n/app_ru.arb | 11 +-
lib/l10n/app_sk.arb | 11 +-
lib/l10n/app_sl.arb | 11 +-
lib/l10n/app_sv.arb | 11 +-
lib/l10n/app_uk.arb | 11 +-
lib/l10n/app_zh.arb | 11 +-
lib/main.dart | 4 +-
lib/screens/connection_choice_screen.dart | 201 ++++++++
lib/screens/scanner_screen.dart | 24 +-
lib/screens/usb_screen.dart | 456 ++++++++++++++++++
lib/services/usb_serial_service.dart | 284 +++++++++++
linux/flutter/generated_plugins.cmake | 1 +
pubspec.yaml | 1 +
windows/CMakeLists.txt | 8 +-
windows/flutter/generated_plugins.cmake | 1 +
45 files changed, 2083 insertions(+), 47 deletions(-)
create mode 100644 lib/screens/connection_choice_screen.dart
create mode 100644 lib/screens/usb_screen.dart
create mode 100644 lib/services/usb_serial_service.dart
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index e0a8029..2e8f47f 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.meshcore.meshcore_open"
compileSdk = flutter.compileSdkVersion
- ndkVersion = flutter.ndkVersion
+ ndkVersion = "29.0.14206865"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -84,4 +84,5 @@ flutter {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+ implementation("com.github.mik3y:usb-serial-for-android:3.9.0")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b8dd623..4ff626f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,7 @@
+
+ when (call.method) {
+ "listPorts" -> result.success(listUsbPorts())
+ "connect" -> handleUsbConnect(call, result)
+ "write" -> handleUsbWrite(call, result)
+ "disconnect" -> {
+ closeUsbConnection()
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName)
+ .setStreamHandler(
+ object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
+ eventSink = events
+ }
+
+ override fun onCancel(arguments: Any?) {
+ eventSink = null
+ }
+ },
+ )
+ }
+
+ override fun onDestroy() {
+ closeUsbConnection()
+ unregisterReceiver(permissionReceiver)
+ super.onDestroy()
+ }
+
+ private fun registerUsbPermissionReceiver() {
+ val filter = IntentFilter(usbPermissionAction)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("DEPRECATION")
+ registerReceiver(permissionReceiver, filter)
+ }
+ }
+
+ private fun listUsbPorts(): List {
+ val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
+ return drivers.map { driver ->
+ val device = driver.device
+ val productName = device.productName ?: "USB Serial Device"
+ val vendorProduct =
+ String.format(
+ Locale.US,
+ "VID:%04X PID:%04X",
+ device.vendorId,
+ device.productId,
+ )
+ "${device.deviceName} - $productName - $vendorProduct"
+ }
+ }
+
+ private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) {
+ val portName = call.argument("portName")
+ val baudRate = call.argument("baudRate") ?: 115200
+ if (portName.isNullOrBlank()) {
+ result.error("usb_invalid_port", "Port name is required", null)
+ return
+ }
+
+ val device = findUsbDevice(portName)
+ if (device == null) {
+ result.error("usb_device_missing", "USB device not found for $portName", null)
+ return
+ }
+
+ if (usbManager.hasPermission(device)) {
+ openUsbDevice(device, baudRate, result)
+ return
+ }
+
+ if (pendingConnectResult != null) {
+ result.error("usb_busy", "Another USB permission request is already pending", null)
+ return
+ }
+
+ pendingConnectResult = result
+ pendingConnectPortName = portName
+ pendingConnectBaudRate = baudRate
+
+ val permissionIntent = PendingIntent.getBroadcast(
+ this,
+ 0,
+ Intent(usbPermissionAction).setPackage(packageName),
+ pendingIntentFlags(),
+ )
+ usbManager.requestPermission(device, permissionIntent)
+ }
+
+ private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
+ val data = call.argument("data")
+ val port = usbPort
+ if (data == null) {
+ result.error("usb_invalid_data", "Data is required", null)
+ return
+ }
+ if (port == null) {
+ result.error("usb_not_connected", "USB serial port is not connected", null)
+ return
+ }
+
+ try {
+ port.write(data, 1000)
+ result.success(null)
+ } catch (error: Exception) {
+ result.error("usb_write_failed", error.message, null)
+ }
+ }
+
+ private fun findUsbDevice(portName: String): UsbDevice? {
+ return usbManager.deviceList.values.firstOrNull { it.deviceName == portName }
+ }
+
+ private fun openUsbDevice(
+ device: UsbDevice,
+ baudRate: Int,
+ result: MethodChannel.Result,
+ ) {
+ try {
+ closeUsbConnection()
+
+ val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
+ if (driver == null) {
+ result.error("usb_driver_missing", "No USB serial driver for ${device.deviceName}", null)
+ return
+ }
+
+ val connection = usbManager.openDevice(device)
+ if (connection == null) {
+ result.error(
+ "usb_open_failed",
+ "UsbManager could not open ${device.deviceName}",
+ null,
+ )
+ return
+ }
+
+ val port = firstPort(driver)
+ if (port == null) {
+ connection.close()
+ result.error("usb_port_missing", "No USB serial port exposed by ${device.deviceName}", null)
+ return
+ }
+
+ port.open(connection)
+ port.setParameters(
+ baudRate,
+ 8,
+ UsbSerialPort.STOPBITS_1,
+ UsbSerialPort.PARITY_NONE,
+ )
+ port.rts = false
+ port.dtr = true
+
+ usbConnection = connection
+ usbPort = port
+
+ ioManager =
+ SerialInputOutputManager(
+ port,
+ object : SerialInputOutputManager.Listener {
+ override fun onNewData(data: ByteArray) {
+ mainHandler.post {
+ eventSink?.success(data)
+ }
+ }
+
+ override fun onRunError(e: Exception) {
+ mainHandler.post {
+ eventSink?.error(
+ "usb_io_error",
+ e.message ?: "USB serial I/O error",
+ null,
+ )
+ }
+ closeUsbConnection()
+ }
+ },
+ ).also { manager ->
+ manager.start()
+ }
+
+ result.success(null)
+ } catch (error: Exception) {
+ closeUsbConnection()
+ result.error("usb_connect_failed", error.message, null)
+ }
+ }
+
+ private fun firstPort(driver: UsbSerialDriver): UsbSerialPort? {
+ return driver.ports.firstOrNull()
+ }
+
+ private fun closeUsbConnection() {
+ try {
+ ioManager?.stop()
+ } catch (_: Exception) {
+ }
+ ioManager = null
+
+ try {
+ usbPort?.close()
+ } catch (_: Exception) {
+ }
+ usbPort = null
+
+ try {
+ usbConnection?.close()
+ } catch (_: Exception) {
+ }
+ usbConnection = null
+ }
+
+ private fun pendingIntentFlags(): Int {
+ var flags = PendingIntent.FLAG_UPDATE_CURRENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ flags = flags or PendingIntent.FLAG_MUTABLE
+ }
+ return flags
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index dbee657..eeea458 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -2,6 +2,7 @@ allprojects {
repositories {
google()
mavenCentral()
+ maven(url = "https://jitpack.io")
}
}
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index c57a85a..f514f15 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -20,6 +20,7 @@ import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/notification_service.dart';
+import '../services/usb_serial_service.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@@ -82,6 +83,8 @@ enum MeshCoreConnectionState {
disconnecting,
}
+enum MeshCoreTransportType { bluetooth, usb }
+
class RepeaterBatterySnapshot {
final int millivolts;
final DateTime updatedAt;
@@ -108,6 +111,10 @@ class MeshCoreConnector extends ChangeNotifier {
String? _lastDeviceId;
String? _lastDeviceDisplayName;
bool _manualDisconnect = false;
+ final UsbSerialService _usbSerialService = UsbSerialService();
+ StreamSubscription? _usbFrameSubscription;
+ MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
+ String? _activeUsbPort;
final List _scanResults = [];
final List _contacts = [];
@@ -154,6 +161,8 @@ class MeshCoreConnector extends ChangeNotifier {
bool _hasLoadedChannels = false;
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
+ bool _hasReceivedDeviceInfo = false;
+ bool _pendingInitialChannelSync = false;
bool _preserveContactsOnRefresh = false;
static const int _defaultMaxContacts = 32;
static const int _defaultMaxChannels = 8;
@@ -217,6 +226,12 @@ class MeshCoreConnector extends ChangeNotifier {
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
+ MeshCoreTransportType get activeTransport => _activeTransport;
+ String? get activeUsbPort => _activeUsbPort;
+ bool get isUsbTransportConnected =>
+ _state == MeshCoreConnectionState.connected &&
+ _activeTransport == MeshCoreTransportType.usb;
+
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
@@ -742,12 +757,17 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ Future> listUsbPorts() => _usbSerialService.listPorts();
+
Future connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return;
}
+ _activeTransport = MeshCoreTransportType.bluetooth;
+ _activeUsbPort = null;
+
await stopScan();
_setState(MeshCoreConnectionState.connecting);
_device = device;
@@ -832,6 +852,8 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
@@ -845,9 +867,6 @@ class MeshCoreConnector extends ChangeNotifier {
// Keep device clock aligned on every connection.
await syncTime();
-
- // Fetch channels so we can track unread counts for incoming messages
- unawaited(getChannels());
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -855,6 +874,63 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ Future connectUsb({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_state == MeshCoreConnectionState.connecting ||
+ _state == MeshCoreConnectionState.connected) {
+ return;
+ }
+
+ _activeTransport = MeshCoreTransportType.bluetooth;
+ _activeUsbPort = null;
+
+ await stopScan();
+ _cancelReconnectTimer();
+ _manualDisconnect = false;
+ _activeTransport = MeshCoreTransportType.usb;
+ _activeUsbPort = portName;
+ unawaited(_backgroundService?.start());
+ _setState(MeshCoreConnectionState.connecting);
+
+ try {
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ await _usbSerialService.connect(portName: portName, baudRate: baudRate);
+ await Future.delayed(const Duration(milliseconds: 200));
+ _usbFrameSubscription = _usbSerialService.frameStream.listen(
+ _handleFrame,
+ onError: (error, stackTrace) {
+ debugPrint('USB transport error: $error');
+ unawaited(disconnect(manual: false));
+ },
+ onDone: () {
+ unawaited(disconnect(manual: false));
+ },
+ );
+
+ _setState(MeshCoreConnectionState.connected);
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = true;
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ }
+
+ await syncTime();
+ } catch (error) {
+ debugPrint('USB connection error: $error');
+ await disconnect(manual: false);
+ rethrow;
+ }
+ }
+
Future _waitForSelfInfo({required Duration timeout}) async {
if (_selfPublicKey != null) return true;
if (!isConnected) return false;
@@ -886,7 +962,10 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
- bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null;
+ bool get _shouldAutoReconnect =>
+ !_manualDisconnect &&
+ _lastDeviceId != null &&
+ _activeTransport == MeshCoreTransportType.bluetooth;
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@@ -930,6 +1009,7 @@ class MeshCoreConnector extends ChangeNotifier {
Future disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
+ final transportAtDisconnect = _activeTransport;
if (manual) {
_manualDisconnect = true;
@@ -941,6 +1021,10 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling();
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ await _usbSerialService.disconnect();
+
await _notifySubscription?.cancel();
_notifySubscription = null;
@@ -980,6 +1064,8 @@ class MeshCoreConnector extends ChangeNotifier {
_repeaterBatterySnapshots.clear();
_batteryRequested = false;
_awaitingSelfInfo = false;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -993,8 +1079,11 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
+ _activeTransport = MeshCoreTransportType.bluetooth;
+ _activeUsbPort = null;
+
_setState(MeshCoreConnectionState.disconnected);
- if (!manual) {
+ if (!manual && transportAtDisconnect == MeshCoreTransportType.bluetooth) {
_scheduleReconnect();
}
}
@@ -1004,24 +1093,29 @@ class MeshCoreConnector extends ChangeNotifier {
String? channelSendQueueId,
bool expectsGenericAck = false,
}) async {
- if (!isConnected || _rxCharacteristic == null) {
+ if (!isConnected) {
throw Exception("Not connected to a MeshCore device");
}
-
_bleDebugLogService?.logFrame(data, outgoing: true);
- // Prefer write without response when supported; fall back to write with response.
- final properties = _rxCharacteristic!.properties;
- final canWriteWithoutResponse = properties.writeWithoutResponse;
- final canWriteWithResponse = properties.write;
- if (!canWriteWithoutResponse && !canWriteWithResponse) {
- throw Exception("MeshCore RX characteristic does not support write");
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ await _usbSerialService.write(data);
+ } else {
+ if (_rxCharacteristic == null) {
+ throw Exception("MeshCore RX characteristic does not support write");
+ }
+ // Prefer write without response when supported; fall back to write with response.
+ final properties = _rxCharacteristic!.properties;
+ final canWriteWithoutResponse = properties.writeWithoutResponse;
+ final canWriteWithResponse = properties.write;
+ if (!canWriteWithoutResponse && !canWriteWithResponse) {
+ throw Exception("MeshCore RX characteristic does not support write");
+ }
+ await _rxCharacteristic!.write(
+ data.toList(),
+ withoutResponse: canWriteWithoutResponse,
+ );
}
-
- await _rxCharacteristic!.write(
- data.toList(),
- withoutResponse: canWriteWithoutResponse,
- );
_trackPendingGenericAck(
data,
channelSendQueueId: channelSendQueueId,
@@ -2000,10 +2094,12 @@ class MeshCoreConnector extends ChangeNotifier {
// Auto-fetch contacts after getting self info
getContacts();
+ _maybeStartInitialChannelSync();
}
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
+ _hasReceivedDeviceInfo = true;
_firmwareVerCode = frame[1];
// Parse client_repeat from firmware v9+ (byte 80)
@@ -2027,12 +2123,25 @@ class MeshCoreConnector extends ChangeNotifier {
if (nextMaxChannels > previousMaxChannels) {
unawaited(loadChannelSettings(maxChannels: nextMaxChannels));
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
- if (isConnected) {
+ if (isConnected && !_pendingInitialChannelSync) {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
}
notifyListeners();
+ _maybeStartInitialChannelSync();
+ }
+
+ void _maybeStartInitialChannelSync() {
+ if (!_pendingInitialChannelSync || !isConnected) {
+ return;
+ }
+ if (_selfPublicKey == null || !_hasReceivedDeviceInfo) {
+ return;
+ }
+
+ _pendingInitialChannelSync = false;
+ unawaited(getChannels(maxChannels: _maxChannels));
}
void _handleNoMoreMessages() {
@@ -3591,6 +3700,8 @@ class MeshCoreConnector extends ChangeNotifier {
_txCharacteristic = null;
// Preserve deviceId and displayName for UI display during reconnection
// They're only cleared on manual disconnect via disconnect() method
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -3671,10 +3782,12 @@ class MeshCoreConnector extends ChangeNotifier {
void dispose() {
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
+ _usbFrameSubscription?.cancel();
_notifySubscription?.cancel();
_reconnectTimer?.cancel();
_batteryPollTimer?.cancel();
_receivedFramesController.close();
+ _usbSerialService.dispose();
// Flush pending unread writes before disposal
_unreadStore.flush();
diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb
index 2dbcf5e..0d64508 100644
--- a/lib/l10n/app_bg.arb
+++ b/lib/l10n/app_bg.arb
@@ -1801,5 +1801,14 @@
"contacts_unread": "Непрочетено",
"contacts_searchRepeaters": "Търсене на {number}{str} повтарящи се...",
"contacts_searchContactsNoNumber": "Търси контакти...",
- "contacts_searchUsers": "Търсене на {number}{str} потребители..."
+ "contacts_searchUsers": "Търсене на {number}{str} потребители...",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceTitle": "Изберете метода на връзка.",
+ "connectionChoiceSubtitle": "Изберете как искате да получите вашия устройство MeshCore.",
+ "usbScreenTitle": "Връзката чрез USB ще бъде налична скоро.",
+ "usbScreenSubtitle": "Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.",
+ "usbScreenStatus": "Ще бъде достъпно скоро",
+ "usbScreenNote": "След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.",
+ "usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново."
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 07190a9..0c49f8d 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -1829,5 +1829,14 @@
"contacts_searchRepeaters": "Suche {number}{str} Repeater...",
"contacts_searchFavorites": "Suche {number}{str} Favoriten...",
"contacts_searchUsers": "Suche {number}{str} Benutzer...",
- "contacts_searchRoomServers": "Suche {number}{str} Raumserver..."
+ "contacts_searchRoomServers": "Suche {number}{str} Raumserver...",
+ "connectionChoiceSubtitle": "Wählen Sie, wie Sie Ihr MeshCore-Gerät erreichen möchten.",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceTitle": "Wählen Sie Ihre bevorzugte Verbindungsmethode.",
+ "usbScreenTitle": "Die USB-Verbindung wird bald verfügbar sein.",
+ "usbScreenSubtitle": "Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.",
+ "usbScreenStatus": "Bald verfügbar",
+ "usbScreenNote": "Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.",
+ "usbScreenEmptyState": "Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie."
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index f0b0587..9719c7c 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -46,6 +46,15 @@
}
},
"scanner_title": "MeshCore Open",
+ "connectionChoiceTitle": "Choose your connection method",
+ "connectionChoiceSubtitle": "Select how you would like to reach your MeshCore device.",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "Connect over USB",
+ "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
+ "usbScreenStatus": "Select a USB device",
+ "usbScreenNote": "USB serial is active on supported Android devices and desktop platforms.",
+ "usbScreenEmptyState": "No USB devices found. Plug one in and refresh.",
"scanner_scanning": "Scanning for devices...",
"scanner_connecting": "Connecting...",
"scanner_disconnecting": "Disconnecting...",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 47765c6..48431e3 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1829,5 +1829,14 @@
"contacts_searchFavorites": "Buscar {number}{str} Favoritos...",
"contacts_searchUsers": "Buscar {number}{str} Usuarios...",
"contacts_searchRepeaters": "Buscar {number}{str} Repetidores...",
- "contacts_searchRoomServers": "Buscar {number}{str} servidores de sala..."
+ "contacts_searchRoomServers": "Buscar {number}{str} servidores de sala...",
+ "connectionChoiceTitle": "Seleccione su método de conexión.",
+ "connectionChoiceSubtitle": "Seleccione la forma en que desea acceder a su dispositivo MeshCore.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "La conexión USB estará disponible próximamente.",
+ "usbScreenSubtitle": "Estamos creando una conexión en serie para dispositivos Android y de escritorio.",
+ "usbScreenStatus": "Próximamente",
+ "usbScreenNote": "Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.",
+ "usbScreenEmptyState": "No se detectaron dispositivos USB. Conecte uno y vuelva a intentar."
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index b742dc9..61f6551 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1801,5 +1801,14 @@
"contacts_searchUsers": "Rechercher {number}{str} utilisateurs...",
"contacts_searchRoomServers": "Rechercher {number}{str} serveurs de salle...",
"contacts_searchRepeaters": "Rechercher {number}{str} Répéteurs...",
- "contacts_searchContactsNoNumber": "Rechercher des contacts..."
+ "contacts_searchContactsNoNumber": "Rechercher des contacts...",
+ "connectionChoiceTitle": "Choisissez votre méthode de connexion.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceSubtitle": "Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.",
+ "usbScreenTitle": "La connexion USB sera disponible prochainement.",
+ "usbScreenSubtitle": "Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.",
+ "usbScreenStatus": "Bientôt",
+ "usbScreenNote": "Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.",
+ "usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Connectez-en un et rafraîchissez."
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 82adad8..827d1e7 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1801,5 +1801,14 @@
"contacts_searchFavorites": "Cerca {number}{str} Preferiti...",
"contacts_unread": "Non letti",
"contacts_searchRepeaters": "Cerca {number}{str} Ripetitori...",
- "contacts_searchRoomServers": "Cerca {number}{str} server Room..."
+ "contacts_searchRoomServers": "Cerca {number}{str} server Room...",
+ "connectionChoiceTitle": "Scegli il metodo di connessione che preferisci.",
+ "connectionChoiceSubtitle": "Seleziona il metodo che preferisci per accedere al tuo dispositivo MeshCore.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "La connessione USB sarà disponibile a breve.",
+ "usbScreenSubtitle": "Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.",
+ "usbScreenStatus": "Arriverà presto",
+ "usbScreenNote": "Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.",
+ "usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e riavviare."
}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index c48994c..162b760 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -316,6 +316,60 @@ abstract class AppLocalizations {
/// **'MeshCore Open'**
String get scanner_title;
+ /// No description provided for @connectionChoiceTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Choose your connection method'**
+ String get connectionChoiceTitle;
+
+ /// No description provided for @connectionChoiceSubtitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Select how you would like to reach your MeshCore device.'**
+ String get connectionChoiceSubtitle;
+
+ /// No description provided for @connectionChoiceUsbLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'USB'**
+ String get connectionChoiceUsbLabel;
+
+ /// No description provided for @connectionChoiceBluetoothLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Bluetooth'**
+ String get connectionChoiceBluetoothLabel;
+
+ /// No description provided for @usbScreenTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Connect over USB'**
+ String get usbScreenTitle;
+
+ /// No description provided for @usbScreenSubtitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Choose a detected serial device and connect directly to your MeshCore node.'**
+ String get usbScreenSubtitle;
+
+ /// No description provided for @usbScreenStatus.
+ ///
+ /// In en, this message translates to:
+ /// **'Select a USB device'**
+ String get usbScreenStatus;
+
+ /// No description provided for @usbScreenNote.
+ ///
+ /// In en, this message translates to:
+ /// **'USB serial is active on supported Android devices and desktop platforms.'**
+ String get usbScreenNote;
+
+ /// No description provided for @usbScreenEmptyState.
+ ///
+ /// In en, this message translates to:
+ /// **'No USB devices found. Plug one in and refresh.'**
+ String get usbScreenEmptyState;
+
/// No description provided for @scanner_scanning.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart
index c168b7c..861bf6a 100644
--- a/lib/l10n/app_localizations_bg.dart
+++ b/lib/l10n/app_localizations_bg.dart
@@ -108,6 +108,37 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Изберете метода на връзка.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Изберете как искате да получите вашия устройство MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Връзката чрез USB ще бъде налична скоро.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.';
+
+ @override
+ String get usbScreenStatus => 'Ще бъде достъпно скоро';
+
+ @override
+ String get usbScreenNote =>
+ 'След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Няма открити USB устройства. Включете едно и опитайте отново.';
+
@override
String get scanner_scanning => 'Сканиране за устройства...';
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index c7eb927..f768dbb 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -108,6 +108,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle =>
+ 'Wählen Sie Ihre bevorzugte Verbindungsmethode.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Wählen Sie, wie Sie Ihr MeshCore-Gerät erreichen möchten.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Die USB-Verbindung wird bald verfügbar sein.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.';
+
+ @override
+ String get usbScreenStatus => 'Bald verfügbar';
+
+ @override
+ String get usbScreenNote =>
+ 'Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.';
+
@override
String get scanner_scanning => 'Scannen nach Geräten...';
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 4458062..2c287f7 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -108,6 +108,37 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Choose your connection method';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Select how you would like to reach your MeshCore device.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Connect over USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Choose a detected serial device and connect directly to your MeshCore node.';
+
+ @override
+ String get usbScreenStatus => 'Select a USB device';
+
+ @override
+ String get usbScreenNote =>
+ 'USB serial is active on supported Android devices and desktop platforms.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'No USB devices found. Plug one in and refresh.';
+
@override
String get scanner_scanning => 'Scanning for devices...';
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index ce4b615..32680ab 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -108,6 +108,38 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Seleccione su método de conexión.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Seleccione la forma en que desea acceder a su dispositivo MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle =>
+ 'La conexión USB estará disponible próximamente.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Estamos creando una conexión en serie para dispositivos Android y de escritorio.';
+
+ @override
+ String get usbScreenStatus => 'Próximamente';
+
+ @override
+ String get usbScreenNote =>
+ 'Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'No se detectaron dispositivos USB. Conecte uno y vuelva a intentar.';
+
@override
String get scanner_scanning => 'Escaneando dispositivos...';
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 6118444..dae3478 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -108,6 +108,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Choisissez votre méthode de connexion.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle =>
+ 'La connexion USB sera disponible prochainement.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.';
+
+ @override
+ String get usbScreenStatus => 'Bientôt';
+
+ @override
+ String get usbScreenNote =>
+ 'Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Aucun périphérique USB n\'a été trouvé. Connectez-en un et rafraîchissez.';
+
@override
String get scanner_scanning => 'Recherche de périphériques...';
diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart
index 20873b9..b138671 100644
--- a/lib/l10n/app_localizations_it.dart
+++ b/lib/l10n/app_localizations_it.dart
@@ -108,6 +108,38 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle =>
+ 'Scegli il metodo di connessione che preferisci.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Seleziona il metodo che preferisci per accedere al tuo dispositivo MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'La connessione USB sarà disponibile a breve.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.';
+
+ @override
+ String get usbScreenStatus => 'Arriverà presto';
+
+ @override
+ String get usbScreenNote =>
+ 'Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nessun dispositivo USB rilevato. Collegare uno e riavviare.';
+
@override
String get scanner_scanning => 'Scansione in corso per i dispositivi...';
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index 323981d..f582abc 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -108,6 +108,37 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Kies uw verbindingsmethode';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Kies hoe u uw MeshCore-apparaat wilt bereiken.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'USB-verbinding is binnenkort beschikbaar.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.';
+
+ @override
+ String get usbScreenStatus => 'Komende week';
+
+ @override
+ String get usbScreenNote =>
+ 'Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Geen USB-apparaten gevonden. Sluit er een aan en herlaad.';
+
@override
String get scanner_scanning => 'Scannen naar apparaten...';
diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart
index e033359..2f103b5 100644
--- a/lib/l10n/app_localizations_pl.dart
+++ b/lib/l10n/app_localizations_pl.dart
@@ -108,6 +108,37 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Wybierz metodę połączenia.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Wybierz, w jaki sposób chcesz uzyskać dostęp do swojego urządzenia MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Połączenie USB będzie dostępne wkrótce.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.';
+
+ @override
+ String get usbScreenStatus => 'Wkrótce';
+
+ @override
+ String get usbScreenNote =>
+ 'Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.';
+
@override
String get scanner_scanning => 'Skanowanie urządzeń...';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index dd19452..283bada 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -108,6 +108,37 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Escolha o método de conexão desejado.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Selecione a forma como você deseja acessar seu dispositivo MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'A conexão USB estará disponível em breve.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.';
+
+ @override
+ String get usbScreenStatus => 'Em breve';
+
+ @override
+ String get usbScreenNote =>
+ 'Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nenhum dispositivo USB encontrado. Conecte um e atualize.';
+
@override
String get scanner_scanning => 'Procurando por dispositivos...';
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index 5f5591d..1687782 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -108,6 +108,38 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Выберите способ подключения';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Выберите, каким способом вы хотите получить свой устройство MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle =>
+ 'Подключение через USB будет доступно в ближайшее время.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.';
+
+ @override
+ String get usbScreenStatus => 'Скоро';
+
+ @override
+ String get usbScreenNote =>
+ 'Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Не обнаружено никаких устройств USB. Подключите одно из них и обновите список.';
+
@override
String get scanner_scanning => 'Поиск устройств...';
diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart
index 82e4d06..8949090 100644
--- a/lib/l10n/app_localizations_sk.dart
+++ b/lib/l10n/app_localizations_sk.dart
@@ -108,6 +108,37 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Vyberte si metódu prepojenia.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Vyberte si, ako chcete dosiahnuť váš zariadenie MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Pripojenie cez USB bude k dispozícii čoskoro.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.';
+
+ @override
+ String get usbScreenStatus => 'Čoskoro';
+
+ @override
+ String get usbScreenNote =>
+ 'Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.';
+
@override
String get scanner_scanning => 'Skrívania zariadení...';
diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart
index 9b1bfc9..bd0c4d5 100644
--- a/lib/l10n/app_localizations_sl.dart
+++ b/lib/l10n/app_localizations_sl.dart
@@ -108,6 +108,37 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Izberite svoj način povezave.';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Izberite, kako želite dostopati do svojega naprave MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Vnos preko USB-ja bo v kratkem na voljo.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Gradimo pot za serijsko povezavo za Android in računalnike.';
+
+ @override
+ String get usbScreenStatus => 'Čez kratko časa';
+
+ @override
+ String get usbScreenNote =>
+ 'Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite.';
+
@override
String get scanner_scanning => 'Skeniram za naprave...';
diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart
index ddfcf41..183250d 100644
--- a/lib/l10n/app_localizations_sv.dart
+++ b/lib/l10n/app_localizations_sv.dart
@@ -108,6 +108,37 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Välj din anslutningsmetod';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Välj hur du vill komma åt din MeshCore-enhet.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'USB-anslutning kommer snart';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.';
+
+ @override
+ String get usbScreenStatus => 'Kommer snart';
+
+ @override
+ String get usbScreenNote =>
+ 'När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Inga USB-enheter hittades. Anslut en och uppdatera.';
+
@override
String get scanner_scanning => 'Söker efter enheter...';
diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart
index b44a7cb..19feaac 100644
--- a/lib/l10n/app_localizations_uk.dart
+++ b/lib/l10n/app_localizations_uk.dart
@@ -108,6 +108,38 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceTitle => 'Виберіть спосіб зв\'язку';
+
+ @override
+ String get connectionChoiceSubtitle =>
+ 'Виберіть, яким способом ви бажаєте отримати доступ до вашого пристрою MeshCore.';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle =>
+ 'Підключення через USB буде доступне найближчим часом.';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Ми створюємо серійний шлях з\'єднання для Android та десктопних комп\'ютерів.';
+
+ @override
+ String get usbScreenStatus => 'Скоро';
+
+ @override
+ String get usbScreenNote =>
+ 'Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.';
+
@override
String get scanner_scanning => 'Пошук пристроїв...';
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index b3d85ed..fdb9f1d 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -108,6 +108,33 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get scanner_title => '连接设备';
+ @override
+ String get connectionChoiceTitle => '选择您的连接方式';
+
+ @override
+ String get connectionChoiceSubtitle => '请选择您希望如何访问 MeshCore 设备的选项。';
+
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => '蓝牙';
+
+ @override
+ String get usbScreenTitle => 'USB 连接即将推出';
+
+ @override
+ String get usbScreenSubtitle => '我们正在构建一个基于串行的连接路径,用于Android和桌面设备。';
+
+ @override
+ String get usbScreenStatus => '即将推出';
+
+ @override
+ String get usbScreenNote => '一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。';
+
+ @override
+ String get usbScreenEmptyState => '未找到任何 USB 设备。请插入一个,然后刷新。';
+
@override
String get scanner_scanning => '正在搜索设备...';
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
index 35cb375..2f09405 100644
--- a/lib/l10n/app_nl.arb
+++ b/lib/l10n/app_nl.arb
@@ -1801,5 +1801,14 @@
"contacts_searchContactsNoNumber": "Zoek contacten...",
"contacts_searchUsers": "Zoek {number}{str} gebruikers...",
"contacts_searchFavorites": "Zoek {number}{str} favorieten...",
- "contacts_searchRoomServers": "Zoek {number}{str} Room servers..."
+ "contacts_searchRoomServers": "Zoek {number}{str} Room servers...",
+ "connectionChoiceTitle": "Kies uw verbindingsmethode",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceSubtitle": "Kies hoe u uw MeshCore-apparaat wilt bereiken.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "USB-verbinding is binnenkort beschikbaar.",
+ "usbScreenSubtitle": "We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.",
+ "usbScreenStatus": "Komende week",
+ "usbScreenNote": "Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.",
+ "usbScreenEmptyState": "Geen USB-apparaten gevonden. Sluit er een aan en herlaad."
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 23c4cbc..8988e02 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -1801,5 +1801,14 @@
"contacts_searchFavorites": "Wyszukaj {number}{str} ulubione...",
"contacts_searchRoomServers": "Wyszukaj {number}{str} serwerów Room...",
"contacts_searchUsers": "Wyszukaj {number}{str} Użytkowników...",
- "contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników..."
+ "contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników...",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceSubtitle": "Wybierz, w jaki sposób chcesz uzyskać dostęp do swojego urządzenia MeshCore.",
+ "connectionChoiceTitle": "Wybierz metodę połączenia.",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "Połączenie USB będzie dostępne wkrótce.",
+ "usbScreenSubtitle": "Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.",
+ "usbScreenStatus": "Wkrótce",
+ "usbScreenNote": "Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.",
+ "usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj."
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 05792c6..296590f 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -1801,5 +1801,14 @@
"contacts_searchUsers": "Pesquisar {number}{str} Usuários...",
"contacts_searchContactsNoNumber": "Pesquisar Contatos...",
"contacts_unread": "Não lido",
- "contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala..."
+ "contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala...",
+ "connectionChoiceSubtitle": "Selecione a forma como você deseja acessar seu dispositivo MeshCore.",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceTitle": "Escolha o método de conexão desejado.",
+ "usbScreenTitle": "A conexão USB estará disponível em breve.",
+ "usbScreenSubtitle": "Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.",
+ "usbScreenStatus": "Em breve",
+ "usbScreenNote": "Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.",
+ "usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize."
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 9460f44..65b3792 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1041,5 +1041,14 @@
"contacts_unread": "Непрочитанное",
"contacts_searchRoomServers": "Поиск {number}{str} серверов комнат...",
"contacts_searchFavorites": "Поиск {number}{str} избранного...",
- "contacts_searchUsers": "Поиск {number}{str} пользователей..."
+ "contacts_searchUsers": "Поиск {number}{str} пользователей...",
+ "connectionChoiceSubtitle": "Выберите, каким способом вы хотите получить свой устройство MeshCore.",
+ "connectionChoiceTitle": "Выберите способ подключения",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "Подключение через USB будет доступно в ближайшее время.",
+ "usbScreenSubtitle": "Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.",
+ "usbScreenStatus": "Скоро",
+ "usbScreenNote": "Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.",
+ "usbScreenEmptyState": "Не обнаружено никаких устройств USB. Подключите одно из них и обновите список."
}
diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb
index 5bc00c6..7178166 100644
--- a/lib/l10n/app_sk.arb
+++ b/lib/l10n/app_sk.arb
@@ -1801,5 +1801,14 @@
"contacts_searchRepeaters": "Hľadať {number}{str} opakovače...",
"contacts_searchUsers": "Hľadať {number}{str} používateľov...",
"contacts_searchContactsNoNumber": "Hľadať kontakty...",
- "contacts_unread": "Neprečítané"
+ "contacts_unread": "Neprečítané",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceTitle": "Vyberte si metódu prepojenia.",
+ "connectionChoiceSubtitle": "Vyberte si, ako chcete dosiahnuť váš zariadenie MeshCore.",
+ "usbScreenTitle": "Pripojenie cez USB bude k dispozícii čoskoro.",
+ "usbScreenSubtitle": "Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.",
+ "usbScreenStatus": "Čoskoro",
+ "usbScreenNote": "Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.",
+ "usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte."
}
diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb
index 226a715..416106a 100644
--- a/lib/l10n/app_sl.arb
+++ b/lib/l10n/app_sl.arb
@@ -1801,5 +1801,14 @@
"contacts_searchRoomServers": "Išči {number}{str} strežnikov sob...",
"contacts_searchContactsNoNumber": "Iskanje stikov...",
"contacts_searchRepeaters": "Išči {number}{str} ponavljalnike...",
- "contacts_searchUsers": "Išči {number}{str} uporabnikov..."
+ "contacts_searchUsers": "Išči {number}{str} uporabnikov...",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceTitle": "Izberite svoj način povezave.",
+ "connectionChoiceSubtitle": "Izberite, kako želite dostopati do svojega naprave MeshCore.",
+ "usbScreenTitle": "Vnos preko USB-ja bo v kratkem na voljo.",
+ "usbScreenSubtitle": "Gradimo pot za serijsko povezavo za Android in računalnike.",
+ "usbScreenStatus": "Čez kratko časa",
+ "usbScreenNote": "Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.",
+ "usbScreenEmptyState": "Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite."
}
diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb
index bfccfff..9bbcd31 100644
--- a/lib/l10n/app_sv.arb
+++ b/lib/l10n/app_sv.arb
@@ -1801,5 +1801,14 @@
"contacts_searchRepeaters": "Sök {number}{str} upprepningsenheter...",
"contacts_searchFavorites": "Sök {number}{str} Favoriter...",
"contacts_searchUsers": "Sök {number}{str} användare...",
- "contacts_searchRoomServers": "Sök {number}{str} Room-servrar..."
+ "contacts_searchRoomServers": "Sök {number}{str} Room-servrar...",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceSubtitle": "Välj hur du vill komma åt din MeshCore-enhet.",
+ "connectionChoiceTitle": "Välj din anslutningsmetod",
+ "usbScreenTitle": "USB-anslutning kommer snart",
+ "usbScreenSubtitle": "Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.",
+ "usbScreenStatus": "Kommer snart",
+ "usbScreenNote": "När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.",
+ "usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera."
}
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index 6063bc8..9a9919c 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -1801,5 +1801,14 @@
"contacts_searchFavorites": "Пошук {number}{str} улюблених...",
"contacts_searchContactsNoNumber": "Пошук контактів...",
"contacts_searchRepeaters": "Пошук {number}{str} ретрансляторів...",
- "contacts_unread": "Непрочитане"
+ "contacts_unread": "Непрочитане",
+ "connectionChoiceSubtitle": "Виберіть, яким способом ви бажаєте отримати доступ до вашого пристрою MeshCore.",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceTitle": "Виберіть спосіб зв'язку",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "Підключення через USB буде доступне найближчим часом.",
+ "usbScreenSubtitle": "Ми створюємо серійний шлях з'єднання для Android та десктопних комп'ютерів.",
+ "usbScreenStatus": "Скоро",
+ "usbScreenNote": "Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.",
+ "usbScreenEmptyState": "Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте."
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index db4953e..660b221 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -1806,5 +1806,14 @@
"contacts_searchRepeaters": "搜索 {number}{str} 重复器...",
"contacts_searchContactsNoNumber": "搜索联系人...",
"contacts_searchRoomServers": "搜索 {number}{str} 房间服务器...",
- "contacts_searchFavorites": "搜索 {number}{str} 收藏..."
+ "contacts_searchFavorites": "搜索 {number}{str} 收藏...",
+ "connectionChoiceSubtitle": "请选择您希望如何访问 MeshCore 设备的选项。",
+ "connectionChoiceBluetoothLabel": "蓝牙",
+ "connectionChoiceTitle": "选择您的连接方式",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "USB 连接即将推出",
+ "usbScreenSubtitle": "我们正在构建一个基于串行的连接路径,用于Android和桌面设备。",
+ "usbScreenStatus": "即将推出",
+ "usbScreenNote": "一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。",
+ "usbScreenEmptyState": "未找到任何 USB 设备。请插入一个,然后刷新。"
}
diff --git a/lib/main.dart b/lib/main.dart
index 9e53e21..dd503fd 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -8,7 +8,7 @@ import 'screens/chrome_required_screen.dart';
import 'utils/platform_info.dart';
import 'connector/meshcore_connector.dart';
-import 'screens/scanner_screen.dart';
+import 'screens/connection_choice_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
@@ -192,7 +192,7 @@ class MeshCoreApp extends StatelessWidget {
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
- : const ScannerScreen(),
+ : const ConnectionChoiceScreen(),
);
},
),
diff --git a/lib/screens/connection_choice_screen.dart b/lib/screens/connection_choice_screen.dart
new file mode 100644
index 0000000..a2ea183
--- /dev/null
+++ b/lib/screens/connection_choice_screen.dart
@@ -0,0 +1,201 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import '../l10n/l10n.dart';
+import 'scanner_screen.dart';
+import 'usb_screen.dart';
+
+/// Entry point that lets the user choose between USB or Bluetooth.
+class ConnectionChoiceScreen extends StatelessWidget {
+ const ConnectionChoiceScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = context.l10n;
+ final theme = Theme.of(context);
+ return Scaffold(
+ appBar: AppBar(
+ title: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(l10n.appTitle, textAlign: TextAlign.center),
+ ),
+ centerTitle: true,
+ automaticallyImplyLeading: false,
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final availableHeight = constraints.maxHeight.isFinite
+ ? constraints.maxHeight
+ : 600.0;
+ final gap = math.max(
+ 8.0,
+ math.min(20.0, availableHeight * 0.035),
+ );
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Flexible(
+ flex: 3,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ l10n.connectionChoiceTitle,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: math.max(4.0, gap * 0.5)),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ l10n.connectionChoiceSubtitle,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: gap),
+ Expanded(
+ flex: 4,
+ child: _ConnectionMethodButton(
+ icon: Icons.usb,
+ label: l10n.connectionChoiceUsbLabel,
+ color: theme.colorScheme.primaryContainer,
+ iconColor: theme.colorScheme.onPrimaryContainer,
+ onPressed: () {
+ debugPrint(
+ 'ConnectionChoiceScreen: USB selected, opening UsbScreen',
+ );
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const UsbScreen()),
+ );
+ },
+ ),
+ ),
+ SizedBox(height: gap),
+ Expanded(
+ flex: 4,
+ child: _ConnectionMethodButton(
+ icon: Icons.bluetooth,
+ label: l10n.connectionChoiceBluetoothLabel,
+ color: theme.colorScheme.surfaceContainerHighest,
+ iconColor: theme.colorScheme.onSurfaceVariant,
+ onPressed: () {
+ debugPrint(
+ 'ConnectionChoiceScreen: Bluetooth selected, opening ScannerScreen',
+ );
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const ScannerScreen(),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ConnectionMethodButton extends StatelessWidget {
+ const _ConnectionMethodButton({
+ required this.icon,
+ required this.label,
+ required this.onPressed,
+ required this.color,
+ required this.iconColor,
+ });
+
+ final IconData icon;
+ final String label;
+ final VoidCallback onPressed;
+ final Color color;
+ final Color iconColor;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: color,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
+ minimumSize: const Size.fromHeight(0),
+ ),
+ onPressed: onPressed,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final availableHeight = constraints.maxHeight.isFinite
+ ? constraints.maxHeight
+ : 200.0;
+ final availableWidth = constraints.maxWidth.isFinite
+ ? constraints.maxWidth
+ : 320.0;
+ final isCompact = availableHeight < 72.0 || availableWidth < 180.0;
+ final baseGap = isCompact ? 8.0 : 12.0;
+ final content = Flex(
+ direction: isCompact ? Axis.horizontal : Axis.vertical,
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(icon, size: isCompact ? 24.0 : 60.0, color: iconColor),
+ SizedBox(
+ width: isCompact ? baseGap : 0,
+ height: isCompact ? 0 : baseGap,
+ ),
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ overflow: TextOverflow.visible,
+ style:
+ (isCompact
+ ? theme.textTheme.titleMedium
+ : theme.textTheme.titleLarge)
+ ?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ ],
+ );
+
+ return Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: math.max(0, availableWidth - 12),
+ maxHeight: math.max(0, availableHeight - 12),
+ ),
+ child: content,
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart
index 4017408..713239f 100644
--- a/lib/screens/scanner_screen.dart
+++ b/lib/screens/scanner_screen.dart
@@ -20,6 +20,7 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State {
bool _changedNavigation = false;
+ late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
late StreamSubscription _bluetoothStateSubscription;
@@ -27,12 +28,12 @@ class _ScannerScreenState extends State {
@override
void initState() {
super.initState();
- final connector = Provider.of(context, listen: false);
+ _connector = Provider.of(context, listen: false);
_connectionListener = () {
- if (connector.state == MeshCoreConnectionState.disconnected) {
+ if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
- } else if (connector.state == MeshCoreConnectionState.connected &&
+ } else if (_connector.state == MeshCoreConnectionState.connected &&
!_changedNavigation) {
_changedNavigation = true;
if (mounted) {
@@ -43,7 +44,7 @@ class _ScannerScreenState extends State {
}
};
- connector.addListener(_connectionListener);
+ _connector.addListener(_connectionListener);
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
(state) {
@@ -53,7 +54,7 @@ class _ScannerScreenState extends State {
});
// Cancel scan if Bluetooth turns off while scanning
if (state != BluetoothAdapterState.on) {
- unawaited(connector.stopScan());
+ unawaited(_connector.stopScan());
}
}
},
@@ -65,16 +66,25 @@ class _ScannerScreenState extends State {
@override
void dispose() {
- final connector = Provider.of(context, listen: false);
- connector.removeListener(_connectionListener);
+ _connector.removeListener(_connectionListener);
unawaited(_bluetoothStateSubscription.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
+ final canPop = Navigator.of(context).canPop();
return Scaffold(
appBar: AppBar(
+ leading: canPop
+ ? IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () {
+ debugPrint('ScannerScreen: back button pressed');
+ Navigator.of(context).maybePop();
+ },
+ )
+ : null,
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
new file mode 100644
index 0000000..e542d61
--- /dev/null
+++ b/lib/screens/usb_screen.dart
@@ -0,0 +1,456 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../connector/meshcore_connector.dart';
+import '../l10n/l10n.dart';
+import 'contacts_screen.dart';
+
+class UsbScreen extends StatefulWidget {
+ const UsbScreen({super.key});
+
+ @override
+ State createState() => _UsbScreenState();
+}
+
+class _UsbScreenState extends State {
+ final List _ports = [];
+ bool _isLoadingPorts = true;
+ bool _isConnecting = false;
+ bool _navigatedToContacts = false;
+ String? _selectedPort;
+ String? _errorText;
+ late final MeshCoreConnector _connector;
+ late final VoidCallback _connectionListener;
+
+ @override
+ void initState() {
+ super.initState();
+ _connector = context.read();
+ _connectionListener = () {
+ if (!mounted) return;
+ if (_connector.state == MeshCoreConnectionState.disconnected) {
+ _navigatedToContacts = false;
+ if (_isConnecting) {
+ setState(() {
+ _isConnecting = false;
+ });
+ }
+ }
+ if (_connector.state == MeshCoreConnectionState.connected &&
+ _connector.isUsbTransportConnected &&
+ !_navigatedToContacts) {
+ _navigatedToContacts = true;
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(builder: (_) => const ContactsScreen()),
+ );
+ }
+ };
+ _connector.addListener(_connectionListener);
+ unawaited(_loadPorts());
+ }
+
+ @override
+ void dispose() {
+ _connector.removeListener(_connectionListener);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final l10n = context.l10n;
+
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () {
+ debugPrint('UsbScreen: back button pressed');
+ Navigator.of(context).maybePop();
+ },
+ ),
+ title: Text(
+ l10n.connectionChoiceUsbLabel,
+ style: theme.textTheme.titleLarge,
+ ),
+ centerTitle: true,
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final availableHeight = constraints.maxHeight.isFinite
+ ? constraints.maxHeight
+ : 600.0;
+ final availableWidth = constraints.maxWidth.isFinite
+ ? constraints.maxWidth
+ : 800.0;
+ final gap = math.max(8.0, math.min(16.0, availableHeight * 0.025));
+ final iconSize = math.max(
+ 28.0,
+ math.min(72.0, availableHeight * 0.12),
+ );
+ final isNarrow = availableWidth < 460.0;
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Flexible(
+ flex: 3,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.usb,
+ size: iconSize,
+ color: theme.colorScheme.primary,
+ ),
+ SizedBox(height: gap),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ l10n.usbScreenTitle,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: math.max(4.0, gap * 0.5)),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ l10n.usbScreenSubtitle,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
+ SizedBox(height: gap),
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Chip(
+ label: Text(
+ _selectedPort == null
+ ? l10n.usbScreenStatus
+ : _friendlyPortName(_selectedPort!),
+ overflow: TextOverflow.ellipsis,
+ ),
+ backgroundColor:
+ theme.colorScheme.surfaceContainerHighest,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: gap),
+ Expanded(child: _buildPortList(context)),
+ if (_errorText != null) ...[
+ SizedBox(height: gap),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ _errorText!,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ ),
+ ),
+ ),
+ ],
+ SizedBox(height: gap),
+ if (isNarrow)
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ OutlinedButton.icon(
+ onPressed: _isLoadingPorts || _isConnecting
+ ? null
+ : () {
+ debugPrint(
+ 'UsbScreen: refresh ports pressed',
+ );
+ _loadPorts();
+ },
+ icon: const Icon(Icons.refresh),
+ label: Text(l10n.repeater_refresh),
+ ),
+ SizedBox(height: gap),
+ FilledButton.icon(
+ onPressed: _canConnect
+ ? () {
+ final rawPortName = _normalizedPortName(
+ _selectedPort!,
+ );
+ debugPrint(
+ 'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)',
+ );
+ _connectSelectedPort();
+ }
+ : null,
+ icon: _isConnecting
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : const Icon(Icons.usb),
+ label: Text(l10n.common_connect),
+ ),
+ ],
+ )
+ else
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _isLoadingPorts || _isConnecting
+ ? null
+ : () {
+ debugPrint(
+ 'UsbScreen: refresh ports pressed',
+ );
+ _loadPorts();
+ },
+ icon: const Icon(Icons.refresh),
+ label: Text(l10n.repeater_refresh),
+ ),
+ ),
+ SizedBox(width: gap),
+ Expanded(
+ child: FilledButton.icon(
+ onPressed: _canConnect
+ ? () {
+ final rawPortName = _normalizedPortName(
+ _selectedPort!,
+ );
+ debugPrint(
+ 'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)',
+ );
+ _connectSelectedPort();
+ }
+ : null,
+ icon: _isConnecting
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ )
+ : const Icon(Icons.usb),
+ label: Text(l10n.common_connect),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: math.max(4.0, gap * 0.75)),
+ Flexible(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ l10n.usbScreenNote,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ bool get _canConnect =>
+ !_isLoadingPorts &&
+ !_isConnecting &&
+ _selectedPort != null &&
+ _selectedPort!.isNotEmpty;
+
+ Widget _buildPortList(BuildContext context) {
+ final theme = Theme.of(context);
+ final l10n = context.l10n;
+
+ if (_isLoadingPorts) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const CircularProgressIndicator(),
+ const SizedBox(height: 12),
+ Text(l10n.common_loading),
+ ],
+ ),
+ );
+ }
+
+ if (_ports.isEmpty) {
+ return Center(
+ child: Text(
+ l10n.usbScreenEmptyState,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+
+ return ListView.separated(
+ itemCount: _ports.length,
+ itemBuilder: (context, index) {
+ final port = _ports[index];
+ final isSelected = port == _selectedPort;
+ final displayName = _friendlyPortName(port);
+ final rawName = _normalizedPortName(port);
+ final showRawName = rawName != displayName;
+ return Material(
+ color: isSelected
+ ? theme.colorScheme.primaryContainer
+ : theme.colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(16),
+ child: ListTile(
+ onTap: _isConnecting
+ ? null
+ : () {
+ setState(() {
+ _selectedPort = port;
+ _errorText = null;
+ });
+ debugPrint('UsbScreen: selected port $port');
+ },
+ leading: Icon(
+ Icons.usb,
+ color: isSelected
+ ? theme.colorScheme.onPrimaryContainer
+ : theme.colorScheme.onSurfaceVariant,
+ ),
+ title: Text(
+ displayName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.titleMedium?.copyWith(
+ color: isSelected ? theme.colorScheme.onPrimaryContainer : null,
+ ),
+ ),
+ subtitle: showRawName
+ ? Text(
+ rawName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: isSelected
+ ? theme.colorScheme.onPrimaryContainer
+ : theme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ : null,
+ trailing: isSelected
+ ? Icon(
+ Icons.check_circle,
+ color: theme.colorScheme.onPrimaryContainer,
+ )
+ : null,
+ ),
+ );
+ },
+ separatorBuilder: (context, index) => const SizedBox(height: 10),
+ );
+ }
+
+ Future _loadPorts() async {
+ if (!mounted) return;
+
+ setState(() {
+ _isLoadingPorts = true;
+ _errorText = null;
+ });
+
+ try {
+ final ports = await _connector.listUsbPorts();
+ if (!mounted) return;
+ setState(() {
+ _ports
+ ..clear()
+ ..addAll(ports);
+ if (_ports.isEmpty) {
+ _selectedPort = null;
+ } else if (!_ports.contains(_selectedPort)) {
+ _selectedPort = _ports.first;
+ }
+ _isLoadingPorts = false;
+ });
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _ports.clear();
+ _selectedPort = null;
+ _errorText = error.toString();
+ _isLoadingPorts = false;
+ });
+ }
+ }
+
+ Future _connectSelectedPort() async {
+ final selectedPort = _selectedPort;
+ if (selectedPort == null || selectedPort.isEmpty) {
+ return;
+ }
+ final rawPortName = _normalizedPortName(selectedPort);
+
+ setState(() {
+ _isConnecting = true;
+ _errorText = null;
+ });
+
+ try {
+ await _connector.connectUsb(portName: rawPortName);
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _isConnecting = false;
+ _errorText = error.toString();
+ });
+ }
+ }
+
+ String _normalizedPortName(String portLabel) {
+ final separatorIndex = portLabel.indexOf(' - ');
+ final normalized = separatorIndex >= 0
+ ? portLabel.substring(0, separatorIndex)
+ : portLabel;
+ return normalized.trim();
+ }
+
+ String _friendlyPortName(String portLabel) {
+ final separatorIndex = portLabel.indexOf(' - ');
+ if (separatorIndex < 0) {
+ return portLabel.trim();
+ }
+ final friendlyName = portLabel.substring(separatorIndex + 3).trim();
+ if (friendlyName.isEmpty) {
+ return _normalizedPortName(portLabel);
+ }
+ return friendlyName;
+ }
+}
diff --git a/lib/services/usb_serial_service.dart b/lib/services/usb_serial_service.dart
new file mode 100644
index 0000000..7e9027d
--- /dev/null
+++ b/lib/services/usb_serial_service.dart
@@ -0,0 +1,284 @@
+import 'dart:async';
+
+import 'package:flserial/flserial.dart';
+import 'package:flserial/flserial_exception.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+/// Wraps the native flserial plugin to expose a stream of raw bytes for the
+/// MeshCore connector to consume.
+class UsbSerialService {
+ UsbSerialService();
+
+ static const MethodChannel _androidMethodChannel = MethodChannel(
+ 'meshcore_open/android_usb_serial',
+ );
+ static const EventChannel _androidEventChannel = EventChannel(
+ 'meshcore_open/android_usb_serial_events',
+ );
+ static const int _serialTxFrameStart = 0x3c;
+ static const int _serialRxFrameStart = 0x3e;
+ static const int _serialHeaderLength = 3;
+
+ final StreamController _frameController =
+ StreamController.broadcast();
+ final FlSerial _serial = FlSerial();
+ final List _rxBuffer = [];
+ StreamSubscription? _androidDataSubscription;
+ StreamSubscription? _dataSubscription;
+ UsbSerialStatus _status = UsbSerialStatus.disconnected;
+ String? _connectedPortName;
+
+ UsbSerialStatus get status => _status;
+ String? get activePortName => _connectedPortName;
+ Stream get frameStream => _frameController.stream;
+ bool get _useAndroidUsbHost =>
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+
+ bool get isConnected {
+ if (_useAndroidUsbHost) {
+ return _status == UsbSerialStatus.connected;
+ }
+ return _status == UsbSerialStatus.connected &&
+ _serial.isOpen() == FlOpenStatus.open;
+ }
+
+ Future> listPorts() async {
+ if (_useAndroidUsbHost) {
+ final ports = await _androidMethodChannel.invokeListMethod(
+ 'listPorts',
+ );
+ return ports ?? [];
+ }
+ return Future.value(FlSerial.listPorts());
+ }
+
+ Future connect({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_status == UsbSerialStatus.connected ||
+ _status == UsbSerialStatus.connecting) {
+ throw StateError('USB serial transport is already active');
+ }
+
+ _status = UsbSerialStatus.connecting;
+ final normalizedPortName = _normalizePortName(portName);
+
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('connect', {
+ 'portName': normalizedPortName,
+ 'baudRate': baudRate,
+ });
+ debugPrint(
+ 'USB serial opened port=$normalizedPortName on Android via USB host bridge',
+ );
+ } on PlatformException catch (error) {
+ _status = UsbSerialStatus.disconnected;
+ throw StateError(error.message ?? error.code);
+ }
+ } else {
+ _serial.init();
+
+ try {
+ final status = _serial.openPort(normalizedPortName, baudRate);
+ if (status != FlOpenStatus.open) {
+ throw StateError(
+ 'Failed to open USB port $normalizedPortName ($status)',
+ );
+ }
+ _serial.setByteSize8();
+ _serial.setBitParityNone();
+ _serial.setStopBits1();
+ _serial.setFlowControlNone();
+ _serial.setRTS(false);
+ _serial.setDTR(true);
+ debugPrint(
+ 'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
+ );
+ } on FlSerialException catch (error) {
+ _serial.free();
+ _status = UsbSerialStatus.disconnected;
+ throw StateError(
+ 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
+ );
+ } catch (error) {
+ _serial.free();
+ _status = UsbSerialStatus.disconnected;
+ rethrow;
+ }
+ }
+
+ _connectedPortName = normalizedPortName;
+ if (_useAndroidUsbHost) {
+ _androidDataSubscription = _androidEventChannel
+ .receiveBroadcastStream()
+ .listen(
+ _handleAndroidData,
+ onError: _handleSerialError,
+ onDone: _handleSerialDone,
+ );
+ } else {
+ _dataSubscription = _serial.onSerialData.stream.listen(
+ _handleSerialData,
+ onError: _handleSerialError,
+ onDone: _handleSerialDone,
+ );
+ }
+ _status = UsbSerialStatus.connected;
+ }
+
+ Future write(Uint8List data) async {
+ if (!isConnected) {
+ throw StateError('USB serial port is not open');
+ }
+ final packet = Uint8List(_serialHeaderLength + data.length);
+ packet[0] = _serialTxFrameStart;
+ packet[1] = data.length & 0xff;
+ packet[2] = (data.length >> 8) & 0xff;
+ packet.setRange(_serialHeaderLength, packet.length, data);
+ _logFrameSummary('USB TX frame', data);
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('write', {
+ 'data': packet,
+ });
+ } on PlatformException catch (error) {
+ throw StateError(error.message ?? error.code);
+ }
+ } else {
+ _serial.write(packet);
+ }
+ }
+
+ Future disconnect() async {
+ if (_status == UsbSerialStatus.disconnected) return;
+
+ _status = UsbSerialStatus.disconnecting;
+ _connectedPortName = null;
+ await _androidDataSubscription?.cancel();
+ _androidDataSubscription = null;
+ await _dataSubscription?.cancel();
+ _dataSubscription = null;
+
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('disconnect');
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ } else {
+ try {
+ if (_serial.isOpen() == FlOpenStatus.open) {
+ _serial.closePort();
+ }
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+
+ _serial.free();
+ }
+ _status = UsbSerialStatus.disconnected;
+ }
+
+ void dispose() {
+ unawaited(disconnect());
+ unawaited(_frameController.close());
+ }
+
+ void _handleSerialData(FlSerialEventArgs event) {
+ try {
+ final bytes = event.serial.readList();
+ if (bytes.isNotEmpty) {
+ _ingestRawBytes(Uint8List.fromList(bytes));
+ }
+ } catch (error, stack) {
+ _frameController.addError(error, stack);
+ }
+ }
+
+ void _handleAndroidData(dynamic data) {
+ if (data is Uint8List) {
+ _ingestRawBytes(data);
+ return;
+ }
+ if (data is ByteData) {
+ _ingestRawBytes(data.buffer.asUint8List());
+ return;
+ }
+ _frameController.addError(
+ StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
+ );
+ }
+
+ void _handleSerialError(Object error, [StackTrace? stackTrace]) {
+ _frameController.addError(error, stackTrace);
+ }
+
+ void _handleSerialDone() {
+ unawaited(disconnect());
+ }
+
+ String _normalizePortName(String portName) {
+ final separatorIndex = portName.indexOf(' - ');
+ final normalized = separatorIndex >= 0
+ ? portName.substring(0, separatorIndex)
+ : portName;
+ return normalized.trim();
+ }
+
+ void _ingestRawBytes(Uint8List bytes) {
+ if (bytes.isEmpty) {
+ return;
+ }
+ _rxBuffer.addAll(bytes);
+ _drainRxBuffer();
+ }
+
+ void _drainRxBuffer() {
+ while (true) {
+ if (_rxBuffer.isEmpty) {
+ return;
+ }
+
+ if (_rxBuffer.first != _serialRxFrameStart &&
+ _rxBuffer.first != _serialTxFrameStart) {
+ _rxBuffer.removeAt(0);
+ continue;
+ }
+
+ if (_rxBuffer.length < _serialHeaderLength) {
+ return;
+ }
+
+ final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
+ final packetLength = _serialHeaderLength + payloadLength;
+ if (_rxBuffer.length < packetLength) {
+ return;
+ }
+
+ final frameStart = _rxBuffer.first;
+ final payload = Uint8List.fromList(
+ _rxBuffer.sublist(_serialHeaderLength, packetLength),
+ );
+ _rxBuffer.removeRange(0, packetLength);
+ if (frameStart != _serialRxFrameStart) {
+ debugPrint(
+ 'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}',
+ );
+ }
+ _frameController.add(payload);
+ }
+ }
+
+ void _logFrameSummary(String prefix, Uint8List bytes) {
+ if (bytes.isEmpty) {
+ debugPrint('$prefix len=0');
+ return;
+ }
+ debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
+ }
+}
+
+enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index f16b4c3..379e36f 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ flserial
)
set(PLUGIN_BUNDLED_LIBRARIES)
diff --git a/pubspec.yaml b/pubspec.yaml
index f85530f..9e82770 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -38,6 +38,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_blue_plus: ^2.1.0
+ flserial: ^0.3.5
provider: ^6.1.5+1
shared_preferences: ^2.2.2
uuid: ^4.3.3
diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt
index daf32c2..97c813c 100644
--- a/windows/CMakeLists.txt
+++ b/windows/CMakeLists.txt
@@ -89,9 +89,11 @@ endif()
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
-install(DIRECTORY "${NATIVE_ASSETS_DIR}"
- DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
- COMPONENT Runtime)
+if(EXISTS "${NATIVE_ASSETS_DIR}")
+ install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 4c358e7..f02857f 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+ flserial
flutter_local_notifications_windows
)
From c23a1da430ad785c30ee865a3184803cafa6f8f7 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 00:27:49 -0500
Subject: [PATCH 02/53] Add web serial support and USB tests
---
lib/connector/meshcore_connector.dart | 15 +-
lib/screens/connection_choice_screen.dart | 71 ++--
lib/screens/usb_screen.dart | 37 +-
lib/services/usb_serial_frame_codec.dart | 70 ++++
lib/services/usb_serial_service.dart | 286 +------------
lib/services/usb_serial_service_native.dart | 247 +++++++++++
lib/services/usb_serial_service_web.dart | 385 ++++++++++++++++++
lib/utils/usb_port_labels.dart | 57 +++
.../services/usb_serial_frame_codec_test.dart | 84 ++++
test/utils/usb_port_labels_test.dart | 76 ++++
10 files changed, 997 insertions(+), 331 deletions(-)
create mode 100644 lib/services/usb_serial_frame_codec.dart
create mode 100644 lib/services/usb_serial_service_native.dart
create mode 100644 lib/services/usb_serial_service_web.dart
create mode 100644 lib/utils/usb_port_labels.dart
create mode 100644 test/services/usb_serial_frame_codec_test.dart
create mode 100644 test/utils/usb_port_labels_test.dart
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index f514f15..d7731f1 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -31,6 +31,7 @@ import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import '../utils/app_logger.dart';
import '../utils/battery_utils.dart';
+import '../utils/platform_info.dart';
import 'meshcore_protocol.dart';
class MeshCoreUuids {
@@ -744,7 +745,9 @@ class MeshCoreConnector extends ChangeNotifier {
);
await Future.delayed(timeout);
- await stopScan();
+ if (!PlatformInfo.isWeb) {
+ await stopScan();
+ }
}
Future stopScan() async {
@@ -898,6 +901,9 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbSerialService.connect(portName: portName, baudRate: baudRate);
+ if (PlatformInfo.isWeb) {
+ await stopScan();
+ }
await Future.delayed(const Duration(milliseconds: 200));
_usbFrameSubscription = _usbSerialService.frameStream.listen(
_handleFrame,
@@ -2087,6 +2093,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (frame.length > 58) {
_selfName = readCString(frame, 58, frame.length - 58);
}
+ final selfName = _selfName?.trim();
+ if (_activeTransport == MeshCoreTransportType.usb &&
+ selfName != null &&
+ selfName.isNotEmpty) {
+ _usbSerialService.updateConnectedLabel(selfName);
+ _activeUsbPort = _usbSerialService.activePortName ?? _activeUsbPort;
+ }
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
diff --git a/lib/screens/connection_choice_screen.dart b/lib/screens/connection_choice_screen.dart
index a2ea183..e4abe7f 100644
--- a/lib/screens/connection_choice_screen.dart
+++ b/lib/screens/connection_choice_screen.dart
@@ -157,30 +157,55 @@ class _ConnectionMethodButton extends StatelessWidget {
? constraints.maxWidth
: 320.0;
final isCompact = availableHeight < 72.0 || availableWidth < 180.0;
- final baseGap = isCompact ? 8.0 : 12.0;
- final content = Flex(
- direction: isCompact ? Axis.horizontal : Axis.vertical,
- mainAxisSize: MainAxisSize.min,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(icon, size: isCompact ? 24.0 : 60.0, color: iconColor),
- SizedBox(
- width: isCompact ? baseGap : 0,
- height: isCompact ? 0 : baseGap,
- ),
- Text(
- label,
- textAlign: TextAlign.center,
- maxLines: 1,
- overflow: TextOverflow.visible,
- style:
- (isCompact
+ final useTightVertical = !isCompact && availableHeight < 120.0;
+ final baseGap = isCompact
+ ? 8.0
+ : (useTightVertical
+ ? math.max(4.0, math.min(8.0, availableHeight * 0.06))
+ : 12.0);
+ final labelStyle =
+ (isCompact
+ ? theme.textTheme.titleMedium
+ : (useTightVertical
? theme.textTheme.titleMedium
- : theme.textTheme.titleLarge)
- ?.copyWith(fontWeight: FontWeight.w600),
- ),
- ],
- );
+ : theme.textTheme.titleLarge))
+ ?.copyWith(fontWeight: FontWeight.w600);
+ final verticalIconSize = useTightVertical
+ ? math.max(32.0, math.min(48.0, availableHeight * 0.42))
+ : 60.0;
+ final content = isCompact
+ ? Row(
+ mainAxisSize: MainAxisSize.max,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(icon, size: 24.0, color: iconColor),
+ SizedBox(width: baseGap),
+ Flexible(
+ child: Text(
+ label,
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: labelStyle,
+ ),
+ ),
+ ],
+ )
+ : Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(icon, size: verticalIconSize, color: iconColor),
+ SizedBox(height: baseGap),
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ overflow: TextOverflow.visible,
+ style: labelStyle,
+ ),
+ ],
+ );
return Center(
child: FittedBox(
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index e542d61..c3c1066 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
+import '../utils/usb_port_labels.dart';
import 'contacts_screen.dart';
class UsbScreen extends StatefulWidget {
@@ -31,6 +32,14 @@ class _UsbScreenState extends State {
_connector = context.read();
_connectionListener = () {
if (!mounted) return;
+ final activeUsbPort = _connector.activeUsbPort;
+ if (activeUsbPort != null &&
+ activeUsbPort.isNotEmpty &&
+ activeUsbPort != _selectedPort) {
+ setState(() {
+ _selectedPort = activeUsbPort;
+ });
+ }
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
if (_isConnecting) {
@@ -192,7 +201,7 @@ class _UsbScreenState extends State {
FilledButton.icon(
onPressed: _canConnect
? () {
- final rawPortName = _normalizedPortName(
+ final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
debugPrint(
@@ -236,7 +245,7 @@ class _UsbScreenState extends State {
child: FilledButton.icon(
onPressed: _canConnect
? () {
- final rawPortName = _normalizedPortName(
+ final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
debugPrint(
@@ -322,7 +331,7 @@ class _UsbScreenState extends State {
final port = _ports[index];
final isSelected = port == _selectedPort;
final displayName = _friendlyPortName(port);
- final rawName = _normalizedPortName(port);
+ final rawName = normalizeUsbPortName(port);
final showRawName = rawName != displayName;
return Material(
color: isSelected
@@ -416,7 +425,7 @@ class _UsbScreenState extends State {
if (selectedPort == null || selectedPort.isEmpty) {
return;
}
- final rawPortName = _normalizedPortName(selectedPort);
+ final rawPortName = normalizeUsbPortName(selectedPort);
setState(() {
_isConnecting = true;
@@ -434,23 +443,5 @@ class _UsbScreenState extends State {
}
}
- String _normalizedPortName(String portLabel) {
- final separatorIndex = portLabel.indexOf(' - ');
- final normalized = separatorIndex >= 0
- ? portLabel.substring(0, separatorIndex)
- : portLabel;
- return normalized.trim();
- }
-
- String _friendlyPortName(String portLabel) {
- final separatorIndex = portLabel.indexOf(' - ');
- if (separatorIndex < 0) {
- return portLabel.trim();
- }
- final friendlyName = portLabel.substring(separatorIndex + 3).trim();
- if (friendlyName.isEmpty) {
- return _normalizedPortName(portLabel);
- }
- return friendlyName;
- }
+ String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
}
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
new file mode 100644
index 0000000..ee4a17c
--- /dev/null
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -0,0 +1,70 @@
+import 'dart:typed_data';
+
+const int usbSerialTxFrameStart = 0x3c;
+const int usbSerialRxFrameStart = 0x3e;
+const int usbSerialHeaderLength = 3;
+
+Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
+ final packet = Uint8List(usbSerialHeaderLength + payload.length);
+ packet[0] = usbSerialTxFrameStart;
+ packet[1] = payload.length & 0xff;
+ packet[2] = (payload.length >> 8) & 0xff;
+ packet.setRange(usbSerialHeaderLength, packet.length, payload);
+ return packet;
+}
+
+class UsbSerialDecodedPacket {
+ const UsbSerialDecodedPacket({
+ required this.frameStart,
+ required this.payload,
+ });
+
+ final int frameStart;
+ final Uint8List payload;
+
+ bool get isRxFrame => frameStart == usbSerialRxFrameStart;
+}
+
+class UsbSerialFrameDecoder {
+ final List _rxBuffer = [];
+
+ List ingest(Uint8List bytes) {
+ if (bytes.isEmpty) {
+ return const [];
+ }
+
+ _rxBuffer.addAll(bytes);
+ final packets = [];
+
+ while (true) {
+ if (_rxBuffer.isEmpty) {
+ return packets;
+ }
+
+ if (_rxBuffer.first != usbSerialRxFrameStart &&
+ _rxBuffer.first != usbSerialTxFrameStart) {
+ _rxBuffer.removeAt(0);
+ continue;
+ }
+
+ if (_rxBuffer.length < usbSerialHeaderLength) {
+ return packets;
+ }
+
+ final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
+ final packetLength = usbSerialHeaderLength + payloadLength;
+ if (_rxBuffer.length < packetLength) {
+ return packets;
+ }
+
+ final frameStart = _rxBuffer.first;
+ final payload = Uint8List.fromList(
+ _rxBuffer.sublist(usbSerialHeaderLength, packetLength),
+ );
+ _rxBuffer.removeRange(0, packetLength);
+ packets.add(
+ UsbSerialDecodedPacket(frameStart: frameStart, payload: payload),
+ );
+ }
+ }
+}
diff --git a/lib/services/usb_serial_service.dart b/lib/services/usb_serial_service.dart
index 7e9027d..343d0ea 100644
--- a/lib/services/usb_serial_service.dart
+++ b/lib/services/usb_serial_service.dart
@@ -1,284 +1,2 @@
-import 'dart:async';
-
-import 'package:flserial/flserial.dart';
-import 'package:flserial/flserial_exception.dart';
-import 'package:flutter/foundation.dart';
-import 'package:flutter/services.dart';
-
-/// Wraps the native flserial plugin to expose a stream of raw bytes for the
-/// MeshCore connector to consume.
-class UsbSerialService {
- UsbSerialService();
-
- static const MethodChannel _androidMethodChannel = MethodChannel(
- 'meshcore_open/android_usb_serial',
- );
- static const EventChannel _androidEventChannel = EventChannel(
- 'meshcore_open/android_usb_serial_events',
- );
- static const int _serialTxFrameStart = 0x3c;
- static const int _serialRxFrameStart = 0x3e;
- static const int _serialHeaderLength = 3;
-
- final StreamController _frameController =
- StreamController.broadcast();
- final FlSerial _serial = FlSerial();
- final List _rxBuffer = [];
- StreamSubscription? _androidDataSubscription;
- StreamSubscription? _dataSubscription;
- UsbSerialStatus _status = UsbSerialStatus.disconnected;
- String? _connectedPortName;
-
- UsbSerialStatus get status => _status;
- String? get activePortName => _connectedPortName;
- Stream get frameStream => _frameController.stream;
- bool get _useAndroidUsbHost =>
- !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
-
- bool get isConnected {
- if (_useAndroidUsbHost) {
- return _status == UsbSerialStatus.connected;
- }
- return _status == UsbSerialStatus.connected &&
- _serial.isOpen() == FlOpenStatus.open;
- }
-
- Future> listPorts() async {
- if (_useAndroidUsbHost) {
- final ports = await _androidMethodChannel.invokeListMethod(
- 'listPorts',
- );
- return ports ?? [];
- }
- return Future.value(FlSerial.listPorts());
- }
-
- Future connect({
- required String portName,
- int baudRate = 115200,
- }) async {
- if (_status == UsbSerialStatus.connected ||
- _status == UsbSerialStatus.connecting) {
- throw StateError('USB serial transport is already active');
- }
-
- _status = UsbSerialStatus.connecting;
- final normalizedPortName = _normalizePortName(portName);
-
- if (_useAndroidUsbHost) {
- try {
- await _androidMethodChannel.invokeMethod('connect', {
- 'portName': normalizedPortName,
- 'baudRate': baudRate,
- });
- debugPrint(
- 'USB serial opened port=$normalizedPortName on Android via USB host bridge',
- );
- } on PlatformException catch (error) {
- _status = UsbSerialStatus.disconnected;
- throw StateError(error.message ?? error.code);
- }
- } else {
- _serial.init();
-
- try {
- final status = _serial.openPort(normalizedPortName, baudRate);
- if (status != FlOpenStatus.open) {
- throw StateError(
- 'Failed to open USB port $normalizedPortName ($status)',
- );
- }
- _serial.setByteSize8();
- _serial.setBitParityNone();
- _serial.setStopBits1();
- _serial.setFlowControlNone();
- _serial.setRTS(false);
- _serial.setDTR(true);
- debugPrint(
- 'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
- );
- } on FlSerialException catch (error) {
- _serial.free();
- _status = UsbSerialStatus.disconnected;
- throw StateError(
- 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
- );
- } catch (error) {
- _serial.free();
- _status = UsbSerialStatus.disconnected;
- rethrow;
- }
- }
-
- _connectedPortName = normalizedPortName;
- if (_useAndroidUsbHost) {
- _androidDataSubscription = _androidEventChannel
- .receiveBroadcastStream()
- .listen(
- _handleAndroidData,
- onError: _handleSerialError,
- onDone: _handleSerialDone,
- );
- } else {
- _dataSubscription = _serial.onSerialData.stream.listen(
- _handleSerialData,
- onError: _handleSerialError,
- onDone: _handleSerialDone,
- );
- }
- _status = UsbSerialStatus.connected;
- }
-
- Future write(Uint8List data) async {
- if (!isConnected) {
- throw StateError('USB serial port is not open');
- }
- final packet = Uint8List(_serialHeaderLength + data.length);
- packet[0] = _serialTxFrameStart;
- packet[1] = data.length & 0xff;
- packet[2] = (data.length >> 8) & 0xff;
- packet.setRange(_serialHeaderLength, packet.length, data);
- _logFrameSummary('USB TX frame', data);
- if (_useAndroidUsbHost) {
- try {
- await _androidMethodChannel.invokeMethod('write', {
- 'data': packet,
- });
- } on PlatformException catch (error) {
- throw StateError(error.message ?? error.code);
- }
- } else {
- _serial.write(packet);
- }
- }
-
- Future disconnect() async {
- if (_status == UsbSerialStatus.disconnected) return;
-
- _status = UsbSerialStatus.disconnecting;
- _connectedPortName = null;
- await _androidDataSubscription?.cancel();
- _androidDataSubscription = null;
- await _dataSubscription?.cancel();
- _dataSubscription = null;
-
- if (_useAndroidUsbHost) {
- try {
- await _androidMethodChannel.invokeMethod('disconnect');
- } catch (_) {
- // Ignore errors while closing.
- }
- } else {
- try {
- if (_serial.isOpen() == FlOpenStatus.open) {
- _serial.closePort();
- }
- } catch (_) {
- // Ignore errors while closing.
- }
-
- _serial.free();
- }
- _status = UsbSerialStatus.disconnected;
- }
-
- void dispose() {
- unawaited(disconnect());
- unawaited(_frameController.close());
- }
-
- void _handleSerialData(FlSerialEventArgs event) {
- try {
- final bytes = event.serial.readList();
- if (bytes.isNotEmpty) {
- _ingestRawBytes(Uint8List.fromList(bytes));
- }
- } catch (error, stack) {
- _frameController.addError(error, stack);
- }
- }
-
- void _handleAndroidData(dynamic data) {
- if (data is Uint8List) {
- _ingestRawBytes(data);
- return;
- }
- if (data is ByteData) {
- _ingestRawBytes(data.buffer.asUint8List());
- return;
- }
- _frameController.addError(
- StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
- );
- }
-
- void _handleSerialError(Object error, [StackTrace? stackTrace]) {
- _frameController.addError(error, stackTrace);
- }
-
- void _handleSerialDone() {
- unawaited(disconnect());
- }
-
- String _normalizePortName(String portName) {
- final separatorIndex = portName.indexOf(' - ');
- final normalized = separatorIndex >= 0
- ? portName.substring(0, separatorIndex)
- : portName;
- return normalized.trim();
- }
-
- void _ingestRawBytes(Uint8List bytes) {
- if (bytes.isEmpty) {
- return;
- }
- _rxBuffer.addAll(bytes);
- _drainRxBuffer();
- }
-
- void _drainRxBuffer() {
- while (true) {
- if (_rxBuffer.isEmpty) {
- return;
- }
-
- if (_rxBuffer.first != _serialRxFrameStart &&
- _rxBuffer.first != _serialTxFrameStart) {
- _rxBuffer.removeAt(0);
- continue;
- }
-
- if (_rxBuffer.length < _serialHeaderLength) {
- return;
- }
-
- final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
- final packetLength = _serialHeaderLength + payloadLength;
- if (_rxBuffer.length < packetLength) {
- return;
- }
-
- final frameStart = _rxBuffer.first;
- final payload = Uint8List.fromList(
- _rxBuffer.sublist(_serialHeaderLength, packetLength),
- );
- _rxBuffer.removeRange(0, packetLength);
- if (frameStart != _serialRxFrameStart) {
- debugPrint(
- 'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}',
- );
- }
- _frameController.add(payload);
- }
- }
-
- void _logFrameSummary(String prefix, Uint8List bytes) {
- if (bytes.isEmpty) {
- debugPrint('$prefix len=0');
- return;
- }
- debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
- }
-}
-
-enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
+export 'usb_serial_service_native.dart'
+ if (dart.library.js_interop) 'usb_serial_service_web.dart';
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
new file mode 100644
index 0000000..f69c6fc
--- /dev/null
+++ b/lib/services/usb_serial_service_native.dart
@@ -0,0 +1,247 @@
+import 'dart:async';
+
+import 'package:flserial/flserial.dart';
+import 'package:flserial/flserial_exception.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import '../utils/usb_port_labels.dart';
+import 'usb_serial_frame_codec.dart';
+
+/// Wraps the native flserial plugin to expose a stream of raw bytes for the
+/// MeshCore connector to consume.
+class UsbSerialService {
+ UsbSerialService();
+
+ static const MethodChannel _androidMethodChannel = MethodChannel(
+ 'meshcore_open/android_usb_serial',
+ );
+ static const EventChannel _androidEventChannel = EventChannel(
+ 'meshcore_open/android_usb_serial_events',
+ );
+ final StreamController _frameController =
+ StreamController.broadcast();
+ final FlSerial _serial = FlSerial();
+ final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
+ StreamSubscription? _androidDataSubscription;
+ StreamSubscription? _dataSubscription;
+ UsbSerialStatus _status = UsbSerialStatus.disconnected;
+ String? _connectedPortName;
+
+ UsbSerialStatus get status => _status;
+ String? get activePortName => _connectedPortName;
+ Stream get frameStream => _frameController.stream;
+ bool get _useAndroidUsbHost =>
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+
+ bool get isConnected {
+ if (_useAndroidUsbHost) {
+ return _status == UsbSerialStatus.connected;
+ }
+ return _status == UsbSerialStatus.connected &&
+ _serial.isOpen() == FlOpenStatus.open;
+ }
+
+ Future> listPorts() async {
+ if (_useAndroidUsbHost) {
+ final ports = await _androidMethodChannel.invokeListMethod(
+ 'listPorts',
+ );
+ return ports ?? [];
+ }
+ return Future.value(FlSerial.listPorts());
+ }
+
+ Future connect({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_status == UsbSerialStatus.connected ||
+ _status == UsbSerialStatus.connecting) {
+ throw StateError('USB serial transport is already active');
+ }
+
+ _status = UsbSerialStatus.connecting;
+ final normalizedPortName = normalizeUsbPortName(portName);
+
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('connect', {
+ 'portName': normalizedPortName,
+ 'baudRate': baudRate,
+ });
+ debugPrint(
+ 'USB serial opened port=$normalizedPortName on Android via USB host bridge',
+ );
+ } on PlatformException catch (error) {
+ _status = UsbSerialStatus.disconnected;
+ throw StateError(error.message ?? error.code);
+ }
+ } else {
+ _serial.init();
+
+ try {
+ final status = _serial.openPort(normalizedPortName, baudRate);
+ if (status != FlOpenStatus.open) {
+ throw StateError(
+ 'Failed to open USB port $normalizedPortName ($status)',
+ );
+ }
+ _serial.setByteSize8();
+ _serial.setBitParityNone();
+ _serial.setStopBits1();
+ _serial.setFlowControlNone();
+ _serial.setRTS(false);
+ _serial.setDTR(true);
+ debugPrint(
+ 'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
+ );
+ } on FlSerialException catch (error) {
+ _serial.free();
+ _status = UsbSerialStatus.disconnected;
+ throw StateError(
+ 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
+ );
+ } catch (error) {
+ _serial.free();
+ _status = UsbSerialStatus.disconnected;
+ rethrow;
+ }
+ }
+
+ _connectedPortName = normalizedPortName;
+ if (_useAndroidUsbHost) {
+ _androidDataSubscription = _androidEventChannel
+ .receiveBroadcastStream()
+ .listen(
+ _handleAndroidData,
+ onError: _handleSerialError,
+ onDone: _handleSerialDone,
+ );
+ } else {
+ _dataSubscription = _serial.onSerialData.stream.listen(
+ _handleSerialData,
+ onError: _handleSerialError,
+ onDone: _handleSerialDone,
+ );
+ }
+ _status = UsbSerialStatus.connected;
+ }
+
+ Future write(Uint8List data) async {
+ if (!isConnected) {
+ throw StateError('USB serial port is not open');
+ }
+ final packet = wrapUsbSerialTxFrame(data);
+ _logFrameSummary('USB TX frame', data);
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('write', {
+ 'data': packet,
+ });
+ } on PlatformException catch (error) {
+ throw StateError(error.message ?? error.code);
+ }
+ } else {
+ _serial.write(packet);
+ }
+ }
+
+ Future disconnect() async {
+ if (_status == UsbSerialStatus.disconnected) return;
+
+ _status = UsbSerialStatus.disconnecting;
+ _connectedPortName = null;
+ await _androidDataSubscription?.cancel();
+ _androidDataSubscription = null;
+ await _dataSubscription?.cancel();
+ _dataSubscription = null;
+
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('disconnect');
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ } else {
+ try {
+ if (_serial.isOpen() == FlOpenStatus.open) {
+ _serial.closePort();
+ }
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+
+ _serial.free();
+ }
+ _status = UsbSerialStatus.disconnected;
+ }
+
+ void updateConnectedLabel(String label) {
+ final trimmed = label.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ _connectedPortName = trimmed;
+ }
+
+ void dispose() {
+ unawaited(disconnect());
+ unawaited(_frameController.close());
+ }
+
+ void _handleSerialData(FlSerialEventArgs event) {
+ try {
+ final bytes = event.serial.readList();
+ if (bytes.isNotEmpty) {
+ _ingestRawBytes(Uint8List.fromList(bytes));
+ }
+ } catch (error, stack) {
+ _frameController.addError(error, stack);
+ }
+ }
+
+ void _handleAndroidData(dynamic data) {
+ if (data is Uint8List) {
+ _ingestRawBytes(data);
+ return;
+ }
+ if (data is ByteData) {
+ _ingestRawBytes(data.buffer.asUint8List());
+ return;
+ }
+ _frameController.addError(
+ StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
+ );
+ }
+
+ void _handleSerialError(Object error, [StackTrace? stackTrace]) {
+ _frameController.addError(error, stackTrace);
+ }
+
+ void _handleSerialDone() {
+ unawaited(disconnect());
+ }
+
+ void _ingestRawBytes(Uint8List bytes) {
+ for (final packet in _frameDecoder.ingest(bytes)) {
+ if (!packet.isRxFrame) {
+ debugPrint(
+ 'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ );
+ continue;
+ }
+ _frameController.add(packet.payload);
+ }
+ }
+
+ void _logFrameSummary(String prefix, Uint8List bytes) {
+ if (bytes.isEmpty) {
+ debugPrint('$prefix len=0');
+ return;
+ }
+ debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
+ }
+}
+
+enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
new file mode 100644
index 0000000..67844df
--- /dev/null
+++ b/lib/services/usb_serial_service_web.dart
@@ -0,0 +1,385 @@
+import 'dart:async';
+import 'dart:js_interop';
+import 'dart:js_interop_unsafe';
+
+import 'package:flutter/foundation.dart';
+import 'package:web/web.dart' as web;
+
+import '../utils/usb_port_labels.dart';
+import 'usb_serial_frame_codec.dart';
+
+class UsbSerialService {
+ UsbSerialService();
+
+ static const Map _knownUsbNames = {
+ '2886:1667': 'Seeed Wio Tracker L1',
+ };
+ static final Map _deviceNamesByPortKey = {};
+
+ final StreamController _frameController =
+ StreamController.broadcast();
+ final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
+
+ UsbSerialStatus _status = UsbSerialStatus.disconnected;
+ JSObject? _port;
+ JSObject? _reader;
+ JSObject? _writer;
+ String? _connectedPortName;
+ String? _connectedPortKey;
+
+ UsbSerialStatus get status => _status;
+ String? get activePortName => _connectedPortName;
+ Stream get frameStream => _frameController.stream;
+ bool get isConnected => _status == UsbSerialStatus.connected;
+
+ JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
+ bool get _isSupported => _navigator.has('serial');
+ JSObject? get _serial {
+ if (!_isSupported) {
+ return null;
+ }
+ final serial = _navigator['serial'];
+ return serial == null ? null : serial as JSObject;
+ }
+
+ Future> listPorts() async {
+ if (!_isSupported) {
+ return const [];
+ }
+
+ final ports = await _getAuthorizedPorts();
+ if (ports.isEmpty) {
+ return const [usbRequestPortLabel];
+ }
+ return ports.map(_displayLabelForPort).toList(growable: false);
+ }
+
+ Future connect({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_status == UsbSerialStatus.connected ||
+ _status == UsbSerialStatus.connecting) {
+ throw StateError('USB serial transport is already active');
+ }
+ if (!_isSupported) {
+ throw UnsupportedError('Web Serial is not supported by this browser.');
+ }
+
+ _status = UsbSerialStatus.connecting;
+
+ try {
+ final requestedPortName = normalizeUsbPortName(portName);
+ final authorizedPorts = await _getAuthorizedPorts();
+ _port = _selectPort(authorizedPorts, requestedPortName);
+
+ _port ??= await _requestPort();
+ if (_port == null) {
+ throw StateError('No USB serial device selected');
+ }
+
+ await _openPort(_port!, baudRate);
+ _connectedPortKey = _portKeyFor(_port!);
+ _connectedPortName = _buildDisplayLabel(_connectedPortKey!);
+ _writer = _getWriter(_port!);
+ _reader = _getReader(_port!);
+ _status = UsbSerialStatus.connected;
+ unawaited(_pumpReads());
+
+ debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
+ } catch (error) {
+ _status = UsbSerialStatus.disconnected;
+ _connectedPortName = null;
+ rethrow;
+ }
+ }
+
+ Future write(Uint8List data) async {
+ if (!isConnected || _writer == null) {
+ throw StateError('USB serial port is not open');
+ }
+
+ final packet = wrapUsbSerialTxFrame(data);
+ _logFrameSummary('USB TX frame', data);
+
+ final promise = _writer!.callMethod>(
+ 'write'.toJS,
+ packet.toJS,
+ );
+ await promise.toDart;
+ }
+
+ Future disconnect() async {
+ if (_status == UsbSerialStatus.disconnected) return;
+
+ _status = UsbSerialStatus.disconnecting;
+ final reader = _reader;
+ final writer = _writer;
+ final port = _port;
+
+ _reader = null;
+ _writer = null;
+ _port = null;
+ _connectedPortName = null;
+ _connectedPortKey = null;
+
+ if (reader != null) {
+ try {
+ await reader.callMethod>('cancel'.toJS).toDart;
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ _releaseLock(reader);
+ }
+
+ if (writer != null) {
+ _releaseLock(writer);
+ }
+
+ if (port != null) {
+ try {
+ await port.callMethod>('close'.toJS).toDart;
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ }
+
+ _status = UsbSerialStatus.disconnected;
+ }
+
+ void updateConnectedLabel(String label) {
+ final trimmed = label.trim();
+ final portKey = _connectedPortKey;
+ if (trimmed.isEmpty || portKey == null) {
+ return;
+ }
+ _deviceNamesByPortKey[portKey] = trimmed;
+ _connectedPortName = _buildDisplayLabel(portKey);
+ }
+
+ void dispose() {
+ unawaited(disconnect());
+ unawaited(_frameController.close());
+ }
+
+ Future> _getAuthorizedPorts() async {
+ final serial = _serial;
+ if (serial == null) {
+ return const [];
+ }
+ final result = await serial
+ .callMethod>('getPorts'.toJS)
+ .toDart;
+ return _toObjectList(result);
+ }
+
+ Future _requestPort() async {
+ final serial = _serial;
+ if (serial == null) {
+ return null;
+ }
+ final result = await serial
+ .callMethod>('requestPort'.toJS)
+ .toDart;
+ return result == null ? null : result as JSObject;
+ }
+
+ JSObject? _selectPort(List ports, String requestedPortName) {
+ if (ports.isEmpty) {
+ return null;
+ }
+ if (requestedPortName.isEmpty || requestedPortName == usbRequestPortLabel) {
+ return ports.first;
+ }
+ for (final port in ports) {
+ final description = _describePort(port);
+ if (description == requestedPortName) {
+ return port;
+ }
+ }
+ return null;
+ }
+
+ Future _openPort(JSObject port, int baudRate) {
+ final options = JSObject()..['baudRate'] = baudRate.toJS;
+ return port.callMethod>('open'.toJS, options).toDart;
+ }
+
+ JSObject? _getReader(JSObject port) {
+ final readable = port.getProperty('readable'.toJS);
+ if (readable == null) {
+ throw StateError('Web Serial port is not readable');
+ }
+ final readableObject = readable as JSObject;
+ return readableObject.callMethod('getReader'.toJS) as JSObject;
+ }
+
+ JSObject? _getWriter(JSObject port) {
+ final writable = port.getProperty('writable'.toJS);
+ if (writable == null) {
+ throw StateError('Web Serial port is not writable');
+ }
+ final writableObject = writable as JSObject;
+ return writableObject.callMethod('getWriter'.toJS) as JSObject;
+ }
+
+ Future _pumpReads() async {
+ final reader = _reader;
+ if (reader == null) return;
+
+ try {
+ while (_status == UsbSerialStatus.connected &&
+ identical(reader, _reader)) {
+ final result = await reader
+ .callMethod>('read'.toJS)
+ .toDart;
+ if (result == null) {
+ break;
+ }
+ final resultObject = result as JSObject;
+
+ final doneValue = resultObject.getProperty('done'.toJS);
+ final done = doneValue != null && doneValue.dartify() == true;
+ if (done) {
+ break;
+ }
+
+ final value = resultObject.getProperty('value'.toJS);
+ final bytes = _coerceBytes(value);
+ if (bytes != null && bytes.isNotEmpty) {
+ _ingestRawBytes(bytes);
+ }
+ }
+ } catch (error, stackTrace) {
+ if (_status == UsbSerialStatus.connected) {
+ _frameController.addError(error, stackTrace);
+ }
+ } finally {
+ _releaseLock(reader);
+ if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
+ _frameController.addError(StateError('USB serial connection closed'));
+ }
+ }
+ }
+
+ Uint8List? _coerceBytes(JSAny? value) {
+ if (value == null) return null;
+ try {
+ return (value as JSUint8Array).toDart;
+ } catch (_) {
+ // Fall back to array-like coercion below.
+ }
+
+ final object = value as JSObject;
+ if (object.has('length')) {
+ final lengthValue = object.getProperty('length'.toJS)?.dartify();
+ if (lengthValue is num) {
+ final length = lengthValue.toInt();
+ final bytes = Uint8List(length);
+ for (var i = 0; i < length; i++) {
+ final item = object.getProperty(i.toString().toJS)?.dartify();
+ if (item is num) {
+ bytes[i] = item.toInt();
+ }
+ }
+ return bytes;
+ }
+ }
+
+ return null;
+ }
+
+ List _toObjectList(JSAny? value) {
+ if (value == null) {
+ return const [];
+ }
+ final object = value as JSObject;
+ if (!object.has('length')) {
+ return const [];
+ }
+
+ final lengthValue = object.getProperty('length'.toJS)?.dartify();
+ if (lengthValue is! num) {
+ return const [];
+ }
+
+ final length = lengthValue.toInt();
+ final items = [];
+ for (var i = 0; i < length; i++) {
+ final item = object.getProperty(i.toString().toJS);
+ if (item != null) {
+ items.add(item as JSObject);
+ }
+ }
+ return items;
+ }
+
+ String _describePort(JSObject port) {
+ try {
+ final info = port.callMethod('getInfo'.toJS);
+ if (info == null) {
+ return usbRequestPortLabel;
+ }
+ final infoObject = info as JSObject;
+
+ final vendorId = infoObject
+ .getProperty('usbVendorId'.toJS)
+ ?.dartify();
+ final productId = infoObject
+ .getProperty('usbProductId'.toJS)
+ ?.dartify();
+ final hasVendor = vendorId is num;
+ final hasProduct = productId is num;
+
+ return describeWebUsbPort(
+ vendorId: hasVendor ? vendorId.toInt() : null,
+ productId: hasProduct ? productId.toInt() : null,
+ knownUsbNames: _knownUsbNames,
+ );
+ } catch (_) {
+ return usbRequestPortLabel;
+ }
+ }
+
+ String _portKeyFor(JSObject port) => _describePort(port);
+
+ String _displayLabelForPort(JSObject port) =>
+ _buildDisplayLabel(_portKeyFor(port));
+
+ String _buildDisplayLabel(String portKey) {
+ return buildUsbDisplayLabel(
+ basePortLabel: portKey,
+ deviceName: _deviceNamesByPortKey[portKey],
+ );
+ }
+
+ void _releaseLock(JSObject resource) {
+ try {
+ resource.callMethod('releaseLock'.toJS);
+ } catch (_) {
+ // Ignore lock release failures.
+ }
+ }
+
+ void _ingestRawBytes(Uint8List bytes) {
+ for (final packet in _frameDecoder.ingest(bytes)) {
+ if (!packet.isRxFrame) {
+ debugPrint(
+ 'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ );
+ continue;
+ }
+ _frameController.add(packet.payload);
+ }
+ }
+
+ void _logFrameSummary(String prefix, Uint8List bytes) {
+ if (bytes.isEmpty) {
+ debugPrint('$prefix len=0');
+ return;
+ }
+ debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
+ }
+}
+
+enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
diff --git a/lib/utils/usb_port_labels.dart b/lib/utils/usb_port_labels.dart
new file mode 100644
index 0000000..6430f95
--- /dev/null
+++ b/lib/utils/usb_port_labels.dart
@@ -0,0 +1,57 @@
+const String usbRequestPortLabel = 'Choose USB Device';
+
+String normalizeUsbPortName(String portLabel) {
+ final separatorIndex = portLabel.indexOf(' - ');
+ final normalized = separatorIndex >= 0
+ ? portLabel.substring(0, separatorIndex)
+ : portLabel;
+ return normalized.trim();
+}
+
+String friendlyUsbPortName(String portLabel) {
+ final separatorIndex = portLabel.indexOf(' - ');
+ if (separatorIndex < 0) {
+ return portLabel.trim();
+ }
+ final friendlyName = portLabel.substring(separatorIndex + 3).trim();
+ if (friendlyName.isEmpty) {
+ return normalizeUsbPortName(portLabel);
+ }
+ return friendlyName;
+}
+
+String describeWebUsbPort({
+ required int? vendorId,
+ required int? productId,
+ Map knownUsbNames = const {},
+}) {
+ if (vendorId == null && productId == null) {
+ return usbRequestPortLabel;
+ }
+
+ final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();
+ final productHex = productId?.toRadixString(16).padLeft(4, '0').toUpperCase();
+ final knownName = (vendorHex != null && productHex != null)
+ ? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}']
+ : null;
+
+ final parts = [knownName ?? 'Web Serial Device'];
+ if (vendorHex != null) {
+ parts.add('VID:$vendorHex');
+ }
+ if (productHex != null) {
+ parts.add('PID:$productHex');
+ }
+ return '${parts.first} (${parts.skip(1).join(' ')})';
+}
+
+String buildUsbDisplayLabel({
+ required String basePortLabel,
+ String? deviceName,
+}) {
+ final trimmedName = deviceName?.trim() ?? '';
+ if (trimmedName.isEmpty) {
+ return basePortLabel;
+ }
+ return '$basePortLabel - $trimmedName';
+}
diff --git a/test/services/usb_serial_frame_codec_test.dart b/test/services/usb_serial_frame_codec_test.dart
new file mode 100644
index 0000000..f0ce186
--- /dev/null
+++ b/test/services/usb_serial_frame_codec_test.dart
@@ -0,0 +1,84 @@
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
+
+void main() {
+ test('wrapUsbSerialTxFrame prefixes tx header and payload length', () {
+ final packet = wrapUsbSerialTxFrame(Uint8List.fromList([0x16, 0x03]));
+
+ expect(
+ packet,
+ orderedEquals([usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]),
+ );
+ });
+
+ test('UsbSerialFrameDecoder buffers partial frames until complete', () {
+ final decoder = UsbSerialFrameDecoder();
+
+ final firstChunk = decoder.ingest(
+ Uint8List.fromList([usbSerialRxFrameStart, 0x03]),
+ );
+ final secondChunk = decoder.ingest(
+ Uint8List.fromList([0x00, 0x05, 0x06, 0x07]),
+ );
+
+ expect(firstChunk, isEmpty);
+ expect(secondChunk, hasLength(1));
+ expect(secondChunk.single.isRxFrame, isTrue);
+ expect(secondChunk.single.payload, orderedEquals([0x05, 0x06, 0x07]));
+ });
+
+ test(
+ 'UsbSerialFrameDecoder drops leading noise and parses multiple frames',
+ () {
+ final decoder = UsbSerialFrameDecoder();
+
+ final packets = decoder.ingest(
+ Uint8List.fromList([
+ 0x00,
+ 0x01,
+ usbSerialRxFrameStart,
+ 0x01,
+ 0x00,
+ 0x55,
+ usbSerialRxFrameStart,
+ 0x02,
+ 0x00,
+ 0x66,
+ 0x77,
+ ]),
+ );
+
+ expect(packets, hasLength(2));
+ expect(packets[0].payload, orderedEquals([0x55]));
+ expect(packets[1].payload, orderedEquals([0x66, 0x77]));
+ },
+ );
+
+ test(
+ 'UsbSerialFrameDecoder preserves tx packets so caller can ignore them',
+ () {
+ final decoder = UsbSerialFrameDecoder();
+
+ final packets = decoder.ingest(
+ Uint8List.fromList([
+ usbSerialTxFrameStart,
+ 0x01,
+ 0x00,
+ 0x22,
+ usbSerialRxFrameStart,
+ 0x01,
+ 0x00,
+ 0x33,
+ ]),
+ );
+
+ expect(packets, hasLength(2));
+ expect(packets[0].isRxFrame, isFalse);
+ expect(packets[0].payload, orderedEquals([0x22]));
+ expect(packets[1].isRxFrame, isTrue);
+ expect(packets[1].payload, orderedEquals([0x33]));
+ },
+ );
+}
diff --git a/test/utils/usb_port_labels_test.dart b/test/utils/usb_port_labels_test.dart
new file mode 100644
index 0000000..4fef509
--- /dev/null
+++ b/test/utils/usb_port_labels_test.dart
@@ -0,0 +1,76 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/utils/usb_port_labels.dart';
+
+void main() {
+ test('normalizeUsbPortName strips friendly suffix from composite label', () {
+ expect(
+ normalizeUsbPortName(
+ 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
+ ),
+ 'COM6',
+ );
+ });
+
+ test('friendlyUsbPortName prefers suffix when present', () {
+ expect(
+ friendlyUsbPortName(
+ 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
+ ),
+ 'USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
+ );
+ });
+
+ test(
+ 'friendlyUsbPortName falls back to normalized port when suffix is empty',
+ () {
+ expect(friendlyUsbPortName('COM6 - '), 'COM6');
+ },
+ );
+
+ test('describeWebUsbPort uses known VID/PID names when available', () {
+ expect(
+ describeWebUsbPort(
+ vendorId: 0x2886,
+ productId: 0x1667,
+ knownUsbNames: const {
+ '2886:1667': 'Seeed Wio Tracker L1',
+ },
+ ),
+ 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
+ );
+ });
+
+ test('describeWebUsbPort falls back to generic label for unknown device', () {
+ expect(
+ describeWebUsbPort(vendorId: 0x1234, productId: 0x5678),
+ 'Web Serial Device (VID:1234 PID:5678)',
+ );
+ });
+
+ test('describeWebUsbPort returns chooser label when no usb ids exist', () {
+ expect(
+ describeWebUsbPort(vendorId: null, productId: null),
+ usbRequestPortLabel,
+ );
+ });
+
+ test('buildUsbDisplayLabel appends device-reported name when available', () {
+ expect(
+ buildUsbDisplayLabel(
+ basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
+ deviceName: 'KD3CGK mesh-utility.org',
+ ),
+ 'Seeed Wio Tracker L1 (VID:2886 PID:1667) - KD3CGK mesh-utility.org',
+ );
+ });
+
+ test('buildUsbDisplayLabel keeps base label when custom name is blank', () {
+ expect(
+ buildUsbDisplayLabel(
+ basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
+ deviceName: ' ',
+ ),
+ 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
+ );
+ });
+}
From 5f4333398e0bc9cfe8d91e2fc6441cb6830abc45 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 00:41:52 -0500
Subject: [PATCH 03/53] Enhance Bluetooth scanning and notification handling
for web platform
---
lib/connector/meshcore_connector.dart | 36 ++++++++++++++++++---------
1 file changed, 24 insertions(+), 12 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index d7731f1..44c5170 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -738,7 +738,7 @@ class MeshCoreConnector extends ChangeNotifier {
});
await FlutterBluePlus.startScan(
- withKeywords: ["MeshCore-", "Whisper-"],
+ withKeywords: ["MeshCore-", "Whisper-", "Wismesh-", "WisCore-"],
webOptionalServices: [Guid(MeshCoreUuids.service)],
timeout: timeout,
androidScanMode: AndroidScanMode.lowLatency,
@@ -836,18 +836,30 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- // Retry setNotifyValue with increasing delays
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
+ if (PlatformInfo.isWeb) {
+ debugPrint('Starting setNotifyValue(true)');
+ debugPrint('Web: Calling setNotifyValue(true) without awaiting');
+ unawaited(() async {
+ try {
+ await _txCharacteristic!.setNotifyValue(true);
+ } catch (error) {
+ debugPrint('Web setNotifyValue error (ignoring): $error');
+ }
+ }());
+ debugPrint('setNotifyValue(true) configuration completed');
+ } else {
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
+ }
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ if (attempt == 2) rethrow;
}
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
From f462815775073eeaef81438fb125af45c8ae9d57 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 00:57:28 -0500
Subject: [PATCH 04/53] Refactor USB connection handling and improve
notification setup
---
lib/connector/meshcore_connector.dart | 84 +++++++++++++++------------
1 file changed, 48 insertions(+), 36 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 44c5170..4b5fe4f 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -738,16 +738,14 @@ class MeshCoreConnector extends ChangeNotifier {
});
await FlutterBluePlus.startScan(
- withKeywords: ["MeshCore-", "Whisper-", "Wismesh-", "WisCore-"],
+ withKeywords: ["MeshCore-", "Whisper-"],
webOptionalServices: [Guid(MeshCoreUuids.service)],
timeout: timeout,
androidScanMode: AndroidScanMode.lowLatency,
);
await Future.delayed(timeout);
- if (!PlatformInfo.isWeb) {
- await stopScan();
- }
+ await stopScan();
}
Future stopScan() async {
@@ -836,30 +834,17 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- if (PlatformInfo.isWeb) {
- debugPrint('Starting setNotifyValue(true)');
- debugPrint('Web: Calling setNotifyValue(true) without awaiting');
- unawaited(() async {
- try {
- await _txCharacteristic!.setNotifyValue(true);
- } catch (error) {
- debugPrint('Web setNotifyValue error (ignoring): $error');
- }
- }());
- debugPrint('setNotifyValue(true) configuration completed');
- } else {
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
- }
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
}
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
@@ -867,8 +852,6 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
- _hasReceivedDeviceInfo = false;
- _pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
@@ -882,6 +865,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Keep device clock aligned on every connection.
await syncTime();
+
+ // Fetch channels so we can track unread counts for incoming messages
+ unawaited(getChannels());
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -904,6 +890,7 @@ class MeshCoreConnector extends ChangeNotifier {
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
+ _resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.usb;
_activeUsbPort = portName;
unawaited(_backgroundService?.start());
@@ -929,16 +916,20 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
- _hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = true;
await _requestDeviceInfo();
_startBatteryPolling();
- final gotSelfInfo = await _waitForSelfInfo(
+ var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ }
+ if (!gotSelfInfo) {
+ throw StateError('Timed out waiting for SELF_INFO during connect');
}
await syncTime();
@@ -980,6 +971,19 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
+ void _resetConnectionHandshakeState() {
+ _selfPublicKey = null;
+ _selfName = null;
+ _selfLatitude = null;
+ _selfLongitude = null;
+ _awaitingSelfInfo = false;
+ _selfInfoRetryTimer?.cancel();
+ _selfInfoRetryTimer = null;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _hasReceivedDeviceInfo = false;
+ }
+
bool get _shouldAutoReconnect =>
!_manualDisconnect &&
_lastDeviceId != null &&
@@ -2119,12 +2123,16 @@ class MeshCoreConnector extends ChangeNotifier {
// Auto-fetch contacts after getting self info
getContacts();
- _maybeStartInitialChannelSync();
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ _maybeStartInitialChannelSync();
+ }
}
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
- _hasReceivedDeviceInfo = true;
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ _hasReceivedDeviceInfo = true;
+ }
_firmwareVerCode = frame[1];
// Parse client_repeat from firmware v9+ (byte 80)
@@ -2148,13 +2156,17 @@ class MeshCoreConnector extends ChangeNotifier {
if (nextMaxChannels > previousMaxChannels) {
unawaited(loadChannelSettings(maxChannels: nextMaxChannels));
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
- if (isConnected && !_pendingInitialChannelSync) {
+ if (isConnected &&
+ (_activeTransport != MeshCoreTransportType.usb ||
+ !_pendingInitialChannelSync)) {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
}
notifyListeners();
- _maybeStartInitialChannelSync();
+ if (_activeTransport == MeshCoreTransportType.usb) {
+ _maybeStartInitialChannelSync();
+ }
}
void _maybeStartInitialChannelSync() {
From 98f7c3b088d9daae2a9ca8b7595665388f280726 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 01:24:33 -0500
Subject: [PATCH 05/53] Refactor USB handling to improve connection management
and error cleanup
---
.../meshcore/meshcore_open/MainActivity.kt | 20 ++++++---
lib/connector/meshcore_connector.dart | 1 -
lib/services/usb_serial_frame_codec.dart | 43 ++++++++++++++-----
lib/services/usb_serial_service_web.dart | 33 ++++++++++++++
4 files changed, 81 insertions(+), 16 deletions(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index b327b06..11bca61 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -21,6 +21,8 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.Locale
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
class MainActivity : FlutterActivity() {
private val usbMethodChannelName = "meshcore_open/android_usb_serial"
@@ -29,6 +31,7 @@ class MainActivity : FlutterActivity() {
private lateinit var usbManager: UsbManager
private val mainHandler = Handler(Looper.getMainLooper())
+ private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var eventSink: EventChannel.EventSink? = null
private var usbConnection: UsbDeviceConnection? = null
@@ -112,6 +115,7 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
closeUsbConnection()
+ usbIoExecutor.shutdownNow()
unregisterReceiver(permissionReceiver)
super.onDestroy()
}
@@ -191,11 +195,17 @@ class MainActivity : FlutterActivity() {
return
}
- try {
- port.write(data, 1000)
- result.success(null)
- } catch (error: Exception) {
- result.error("usb_write_failed", error.message, null)
+ usbIoExecutor.execute {
+ try {
+ port.write(data, 1000)
+ mainHandler.post {
+ result.success(null)
+ }
+ } catch (error: Exception) {
+ mainHandler.post {
+ result.error("usb_write_failed", error.message, null)
+ }
+ }
}
}
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 4b5fe4f..392be9a 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -981,7 +981,6 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
- _hasReceivedDeviceInfo = false;
}
bool get _shouldAutoReconnect =>
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
index ee4a17c..f2ddbb6 100644
--- a/lib/services/usb_serial_frame_codec.dart
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -27,6 +27,7 @@ class UsbSerialDecodedPacket {
class UsbSerialFrameDecoder {
final List _rxBuffer = [];
+ int _startIndex = 0;
List ingest(Uint8List bytes) {
if (bytes.isEmpty) {
@@ -37,34 +38,56 @@ class UsbSerialFrameDecoder {
final packets = [];
while (true) {
- if (_rxBuffer.isEmpty) {
+ if (_startIndex >= _rxBuffer.length) {
+ _rxBuffer.clear();
+ _startIndex = 0;
return packets;
}
- if (_rxBuffer.first != usbSerialRxFrameStart &&
- _rxBuffer.first != usbSerialTxFrameStart) {
- _rxBuffer.removeAt(0);
+ if (_rxBuffer[_startIndex] != usbSerialRxFrameStart &&
+ _rxBuffer[_startIndex] != usbSerialTxFrameStart) {
+ _startIndex++;
+ _compactBufferIfNeeded();
continue;
}
- if (_rxBuffer.length < usbSerialHeaderLength) {
+ final availableLength = _rxBuffer.length - _startIndex;
+ if (availableLength < usbSerialHeaderLength) {
+ _compactBufferIfNeeded(force: true);
return packets;
}
- final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
+ final payloadLength =
+ _rxBuffer[_startIndex + 1] | (_rxBuffer[_startIndex + 2] << 8);
final packetLength = usbSerialHeaderLength + payloadLength;
- if (_rxBuffer.length < packetLength) {
+ if (availableLength < packetLength) {
+ _compactBufferIfNeeded(force: true);
return packets;
}
- final frameStart = _rxBuffer.first;
+ final frameStart = _rxBuffer[_startIndex];
final payload = Uint8List.fromList(
- _rxBuffer.sublist(usbSerialHeaderLength, packetLength),
+ _rxBuffer.sublist(
+ _startIndex + usbSerialHeaderLength,
+ _startIndex + packetLength,
+ ),
);
- _rxBuffer.removeRange(0, packetLength);
+ _startIndex += packetLength;
+ _compactBufferIfNeeded();
packets.add(
UsbSerialDecodedPacket(frameStart: frameStart, payload: payload),
);
}
}
+
+ void _compactBufferIfNeeded({bool force = false}) {
+ if (_startIndex == 0) {
+ return;
+ }
+ if (!force && _startIndex < 1024 && _startIndex < (_rxBuffer.length ~/ 2)) {
+ return;
+ }
+ _rxBuffer.removeRange(0, _startIndex);
+ _startIndex = 0;
+ }
}
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 67844df..1f0fcb9 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -88,8 +88,10 @@ class UsbSerialService {
debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
} catch (error) {
+ await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected;
_connectedPortName = null;
+ _connectedPortKey = null;
rethrow;
}
}
@@ -205,6 +207,37 @@ class UsbSerialService {
return port.callMethod>('open'.toJS, options).toDart;
}
+ Future _cleanupFailedConnect() async {
+ final reader = _reader;
+ final writer = _writer;
+ final port = _port;
+
+ _reader = null;
+ _writer = null;
+ _port = null;
+
+ if (reader != null) {
+ try {
+ await reader.callMethod>('cancel'.toJS).toDart;
+ } catch (_) {
+ // Ignore cleanup errors after a failed connect.
+ }
+ _releaseLock(reader);
+ }
+
+ if (writer != null) {
+ _releaseLock(writer);
+ }
+
+ if (port != null) {
+ try {
+ await port.callMethod>('close'.toJS).toDart;
+ } catch (_) {
+ // Ignore cleanup errors after a failed connect.
+ }
+ }
+ }
+
JSObject? _getReader(JSObject port) {
final readable = port.getProperty('readable'.toJS);
if (readable == null) {
From ee3af52c0fd99d5444553d2e846671c0cc83eeee Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 01:50:38 -0500
Subject: [PATCH 06/53] Add initial contacts sync handling for web Bluetooth
transport
---
lib/connector/meshcore_connector.dart | 128 +++++++++++++++++++-------
1 file changed, 95 insertions(+), 33 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 392be9a..4b39068 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -164,6 +164,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false;
bool _pendingInitialChannelSync = false;
+ bool _pendingInitialContactsSync = false;
bool _preserveContactsOnRefresh = false;
static const int _defaultMaxContacts = 32;
static const int _defaultMaxChannels = 8;
@@ -783,6 +784,9 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceDisplayName = _deviceDisplayName;
_manualDisconnect = false;
_cancelReconnectTimer();
+ if (PlatformInfo.isWeb) {
+ _resetConnectionHandshakeState();
+ }
unawaited(_backgroundService?.start());
notifyListeners();
@@ -799,12 +803,14 @@ class MeshCoreConnector extends ChangeNotifier {
license: License.free,
);
- // Request larger MTU for sending larger frames
- try {
- final mtu = await device.requestMtu(185);
- debugPrint('MTU set to: $mtu');
- } catch (e) {
- debugPrint('MTU request failed: $e, using default');
+ // Request larger MTU only on native platforms; web does not support it.
+ if (!PlatformInfo.isWeb) {
+ try {
+ final mtu = await device.requestMtu(185);
+ debugPrint('MTU set to: $mtu');
+ } catch (e) {
+ debugPrint('MTU request failed: $e, using default');
+ }
}
List services = await device.discoverServices();
@@ -834,17 +840,30 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
+ if (PlatformInfo.isWeb) {
+ debugPrint('Starting setNotifyValue(true)');
+ debugPrint('Web: Calling setNotifyValue(true) without awaiting');
+ unawaited(() async {
+ try {
+ await _txCharacteristic!.setNotifyValue(true);
+ } catch (error) {
+ debugPrint('Web setNotifyValue error (ignoring): $error');
+ }
+ }());
+ debugPrint('setNotifyValue(true) configuration completed');
+ } else {
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
+ }
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ if (attempt == 2) rethrow;
}
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
@@ -852,22 +871,34 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
+ if (_shouldGateInitialChannelSync) {
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = true;
+ }
await _requestDeviceInfo();
_startBatteryPolling();
- final gotSelfInfo = await _waitForSelfInfo(
- timeout: const Duration(seconds: 3),
- );
- if (!gotSelfInfo) {
- await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ // Chrome's Web Bluetooth stack commonly delays incoming notifications
+ // until the non-blocking notify setup settles. Avoid stacking extra
+ // startup writes while that is happening.
+ } else {
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ }
+
+ unawaited(syncTime());
}
- // Keep device clock aligned on every connection.
- await syncTime();
-
// Fetch channels so we can track unread counts for incoming messages
- unawaited(getChannels());
+ if (!_shouldGateInitialChannelSync) {
+ unawaited(getChannels());
+ }
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -981,6 +1012,7 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
}
bool get _shouldAutoReconnect =>
@@ -988,6 +1020,11 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceId != null &&
_activeTransport == MeshCoreTransportType.bluetooth;
+ bool get _shouldGateInitialChannelSync =>
+ _activeTransport == MeshCoreTransportType.usb ||
+ (_activeTransport == MeshCoreTransportType.bluetooth &&
+ PlatformInfo.isWeb);
+
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
@@ -1087,6 +1124,7 @@ class MeshCoreConnector extends ChangeNotifier {
_awaitingSelfInfo = false;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -1191,6 +1229,10 @@ class MeshCoreConnector extends ChangeNotifier {
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ return;
+ }
_selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
timer,
) {
@@ -1974,6 +2016,12 @@ class MeshCoreConnector extends ChangeNotifier {
_preserveContactsOnRefresh = false;
notifyListeners();
unawaited(_persistContacts());
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _isSyncingChannels &&
+ !_channelSyncInFlight) {
+ unawaited(_requestNextChannel());
+ }
if (!_didInitialQueueSync || _pendingQueueSync) {
_didInitialQueueSync = true;
_pendingQueueSync = false;
@@ -2120,16 +2168,22 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
notifyListeners();
- // Auto-fetch contacts after getting self info
- getContacts();
- if (_activeTransport == MeshCoreTransportType.usb) {
+ // Auto-fetch contacts after getting self info. On web BLE, defer this
+ // until after channel 0 so startup writes stay serialized.
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ _pendingInitialContactsSync = true;
+ } else {
+ getContacts();
+ }
+ if (_shouldGateInitialChannelSync) {
_maybeStartInitialChannelSync();
}
}
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
- if (_activeTransport == MeshCoreTransportType.usb) {
+ if (_shouldGateInitialChannelSync) {
_hasReceivedDeviceInfo = true;
}
_firmwareVerCode = frame[1];
@@ -2156,14 +2210,13 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(loadChannelSettings(maxChannels: nextMaxChannels));
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
if (isConnected &&
- (_activeTransport != MeshCoreTransportType.usb ||
- !_pendingInitialChannelSync)) {
+ (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
}
notifyListeners();
- if (_activeTransport == MeshCoreTransportType.usb) {
+ if (_shouldGateInitialChannelSync) {
_maybeStartInitialChannelSync();
}
}
@@ -3075,6 +3128,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Move to next channel
_nextChannelIndexToRequest++;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ channel.index == 0 &&
+ _pendingInitialContactsSync) {
+ _pendingInitialContactsSync = false;
+ unawaited(getContacts());
+ return;
+ }
unawaited(_requestNextChannel());
return;
} else {
@@ -3738,6 +3799,7 @@ class MeshCoreConnector extends ChangeNotifier {
// They're only cleared on manual disconnect via disconnect() method
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
From 2d1160d992de1c845d7dbb13477b21c089fba59a Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 02:34:46 -0500
Subject: [PATCH 07/53] Enhance BLE connection handling and improve USB
connection messaging
- Wrapped BLE scan and connection methods in try-catch blocks to handle errors gracefully and provide debug output.
- Added retry logic for service discovery on web platforms after transient disconnections.
- Updated USB connection messages in multiple languages to reflect active support on Android and desktop platforms.
- Improved loading indicators for contacts screen to show a spinner during data loading.
---
.../meshcore/meshcore_open/MainActivity.kt | 59 +++++++++++++++-
lib/connector/meshcore_connector.dart | 68 +++++++++++++++----
lib/l10n/app_bg.arb | 8 +--
lib/l10n/app_de.arb | 8 +--
lib/l10n/app_es.arb | 10 +--
lib/l10n/app_fr.arb | 10 +--
lib/l10n/app_it.arb | 8 +--
lib/l10n/app_localizations_bg.dart | 8 +--
lib/l10n/app_localizations_de.dart | 8 +--
lib/l10n/app_localizations_es.dart | 11 ++-
lib/l10n/app_localizations_fr.dart | 11 ++-
lib/l10n/app_localizations_it.dart | 8 +--
lib/l10n/app_localizations_nl.dart | 8 +--
lib/l10n/app_localizations_pl.dart | 8 +--
lib/l10n/app_localizations_pt.dart | 8 +--
lib/l10n/app_localizations_ru.dart | 11 ++-
lib/l10n/app_localizations_sk.dart | 8 +--
lib/l10n/app_localizations_sl.dart | 10 +--
lib/l10n/app_localizations_sv.dart | 8 +--
lib/l10n/app_localizations_uk.dart | 9 ++-
lib/l10n/app_localizations_zh.dart | 8 +--
lib/l10n/app_nl.arb | 8 +--
lib/l10n/app_pl.arb | 8 +--
lib/l10n/app_pt.arb | 8 +--
lib/l10n/app_ru.arb | 10 +--
lib/l10n/app_sk.arb | 8 +--
lib/l10n/app_sl.arb | 10 +--
lib/l10n/app_sv.arb | 8 +--
lib/l10n/app_uk.arb | 8 +--
lib/l10n/app_zh.arb | 8 +--
lib/screens/contacts_screen.dart | 9 ++-
31 files changed, 240 insertions(+), 140 deletions(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index 11bca61..dec4e28 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -37,6 +37,7 @@ class MainActivity : FlutterActivity() {
private var usbConnection: UsbDeviceConnection? = null
private var usbPort: UsbSerialPort? = null
private var ioManager: SerialInputOutputManager? = null
+ private var connectedDeviceName: String? = null
private var pendingConnectResult: MethodChannel.Result? = null
private var pendingConnectPortName: String? = null
@@ -45,7 +46,19 @@ class MainActivity : FlutterActivity() {
private val permissionReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action != usbPermissionAction) {
+ when (intent?.action) {
+ UsbManager.ACTION_USB_DEVICE_DETACHED -> {
+ handleUsbDetached(intent)
+ return
+ }
+ usbPermissionAction -> {
+ }
+ else -> {
+ return
+ }
+ }
+
+ if (intent.action != usbPermissionAction) {
return
}
@@ -116,12 +129,19 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
closeUsbConnection()
usbIoExecutor.shutdownNow()
- unregisterReceiver(permissionReceiver)
+ try {
+ unregisterReceiver(permissionReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
super.onDestroy()
}
private fun registerUsbPermissionReceiver() {
- val filter = IntentFilter(usbPermissionAction)
+ val filter =
+ IntentFilter().apply {
+ addAction(usbPermissionAction)
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@@ -256,6 +276,7 @@ class MainActivity : FlutterActivity() {
usbConnection = connection
usbPort = port
+ connectedDeviceName = device.deviceName
ioManager =
SerialInputOutputManager(
@@ -311,6 +332,38 @@ class MainActivity : FlutterActivity() {
} catch (_: Exception) {
}
usbConnection = null
+ connectedDeviceName = null
+ }
+
+ private fun handleUsbDetached(intent: Intent) {
+ val detachedDevice =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
+ }
+
+ val detachedName = detachedDevice?.deviceName ?: return
+
+ if (pendingConnectPortName == detachedName) {
+ pendingConnectResult?.error(
+ "usb_device_detached",
+ "USB device was removed before the connection completed",
+ null,
+ )
+ pendingConnectResult = null
+ pendingConnectPortName = null
+ }
+
+ if (connectedDeviceName == detachedName) {
+ closeUsbConnection()
+ eventSink?.error(
+ "usb_device_detached",
+ "USB device was disconnected",
+ null,
+ )
+ }
}
private fun pendingIntentFlags(): Int {
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 4b39068..9e0eff6 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -738,12 +738,18 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
});
- await FlutterBluePlus.startScan(
- withKeywords: ["MeshCore-", "Whisper-"],
- webOptionalServices: [Guid(MeshCoreUuids.service)],
- timeout: timeout,
- androidScanMode: AndroidScanMode.lowLatency,
- );
+ try {
+ await FlutterBluePlus.startScan(
+ withKeywords: ["MeshCore-", "Whisper-"],
+ webOptionalServices: [Guid(MeshCoreUuids.service)],
+ timeout: timeout,
+ androidScanMode: AndroidScanMode.lowLatency,
+ );
+ } catch (error) {
+ debugPrint('[BLE Scan] Scan/picker failure: $error');
+ _setState(MeshCoreConnectionState.disconnected);
+ rethrow;
+ }
await Future.delayed(timeout);
await stopScan();
@@ -791,17 +797,24 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
try {
+ final connectLabel = _deviceDisplayName ?? _deviceId;
+ debugPrint('[BLE Connect] Starting connect to $connectLabel');
_connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
}
});
- await device.connect(
- timeout: const Duration(seconds: 15),
- mtu: null,
- license: License.free,
- );
+ try {
+ await device.connect(
+ timeout: const Duration(seconds: 15),
+ mtu: null,
+ license: License.free,
+ );
+ } catch (error) {
+ debugPrint('[BLE Connect] device.connect() failure: $error');
+ rethrow;
+ }
// Request larger MTU only on native platforms; web does not support it.
if (!PlatformInfo.isWeb) {
@@ -813,7 +826,27 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- List services = await device.discoverServices();
+ late final List services;
+ try {
+ services = await device.discoverServices();
+ } catch (error) {
+ debugPrint('[BLE Connect] service discovery failure: $error');
+ if (PlatformInfo.isWeb &&
+ error.toString().contains('GATT Server is disconnected')) {
+ debugPrint(
+ '[BLE Connect] retrying service discovery after transient web disconnect',
+ );
+ await Future.delayed(const Duration(milliseconds: 300));
+ await device.connect(
+ timeout: const Duration(seconds: 15),
+ mtu: null,
+ license: License.free,
+ );
+ services = await device.discoverServices();
+ } else {
+ rethrow;
+ }
+ }
BluetoothService? uartService;
for (var service in services) {
@@ -847,6 +880,7 @@ class MeshCoreConnector extends ChangeNotifier {
try {
await _txCharacteristic!.setNotifyValue(true);
} catch (error) {
+ debugPrint('[BLE Connect] notify failure (web, ignored): $error');
debugPrint('Web setNotifyValue error (ignoring): $error');
}
}());
@@ -861,6 +895,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _txCharacteristic!.setNotifyValue(true);
notifySet = true;
} catch (e) {
+ debugPrint('[BLE Connect] notify failure: $e');
debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
if (attempt == 2) rethrow;
}
@@ -1231,6 +1266,15 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer?.cancel();
if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) {
+ _selfInfoRetryTimer = Timer(const Duration(seconds: 10), () {
+ if (!isConnected || !_awaitingSelfInfo) {
+ return;
+ }
+ if (_isLoadingContacts || _isSyncingChannels || _channelSyncInFlight) {
+ return;
+ }
+ unawaited(sendFrame(buildAppStartFrame()));
+ });
return;
}
_selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb
index 0d64508..8c99338 100644
--- a/lib/l10n/app_bg.arb
+++ b/lib/l10n/app_bg.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTitle": "Изберете метода на връзка.",
"connectionChoiceSubtitle": "Изберете как искате да получите вашия устройство MeshCore.",
- "usbScreenTitle": "Връзката чрез USB ще бъде налична скоро.",
- "usbScreenSubtitle": "Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.",
- "usbScreenStatus": "Ще бъде достъпно скоро",
- "usbScreenNote": "След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.",
+ "usbScreenNote": "USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.",
+ "usbScreenStatus": "Изберете USB устройство",
+ "usbScreenTitle": "Свързване чрез USB",
+ "usbScreenSubtitle": "Изберете открития сериен уред и свържете директно към вашия MeshCore възел.",
"usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново."
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 0c49f8d..44eca06 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -1834,9 +1834,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTitle": "Wählen Sie Ihre bevorzugte Verbindungsmethode.",
- "usbScreenTitle": "Die USB-Verbindung wird bald verfügbar sein.",
- "usbScreenSubtitle": "Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.",
- "usbScreenStatus": "Bald verfügbar",
- "usbScreenNote": "Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.",
+ "usbScreenSubtitle": "Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.",
+ "usbScreenNote": "USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.",
+ "usbScreenTitle": "Über USB verbinden",
+ "usbScreenStatus": "Wählen Sie ein USB-Gerät aus",
"usbScreenEmptyState": "Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie."
}
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 48431e3..a69503a 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1834,9 +1834,9 @@
"connectionChoiceSubtitle": "Seleccione la forma en que desea acceder a su dispositivo MeshCore.",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceUsbLabel": "USB",
- "usbScreenTitle": "La conexión USB estará disponible próximamente.",
- "usbScreenSubtitle": "Estamos creando una conexión en serie para dispositivos Android y de escritorio.",
- "usbScreenStatus": "Próximamente",
- "usbScreenNote": "Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.",
- "usbScreenEmptyState": "No se detectaron dispositivos USB. Conecte uno y vuelva a intentar."
+ "usbScreenStatus": "Seleccione un dispositivo USB",
+ "usbScreenNote": "La comunicación serial a través de USB está activa en dispositivos Android compatibles y en plataformas de escritorio.",
+ "usbScreenTitle": "Conecte mediante USB",
+ "usbScreenSubtitle": "Seleccione un dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.",
+ "usbScreenEmptyState": "No se encontraron dispositivos USB. Conecte uno y vuelva a cargar."
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 61f6551..2e22ee5 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceUsbLabel": "USB",
"connectionChoiceSubtitle": "Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.",
- "usbScreenTitle": "La connexion USB sera disponible prochainement.",
- "usbScreenSubtitle": "Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.",
- "usbScreenStatus": "Bientôt",
- "usbScreenNote": "Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.",
- "usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Connectez-en un et rafraîchissez."
+ "usbScreenStatus": "Sélectionnez un périphérique USB",
+ "usbScreenSubtitle": "Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.",
+ "usbScreenNote": "La communication série USB est active sur les appareils Android et les plateformes de bureau pris en charge.",
+ "usbScreenTitle": "Connectez via USB",
+ "usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Veuillez connecter un périphérique et rafraîchir la page."
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 827d1e7..14c37fa 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceSubtitle": "Seleziona il metodo che preferisci per accedere al tuo dispositivo MeshCore.",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceUsbLabel": "USB",
- "usbScreenTitle": "La connessione USB sarà disponibile a breve.",
- "usbScreenSubtitle": "Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.",
- "usbScreenStatus": "Arriverà presto",
- "usbScreenNote": "Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.",
+ "usbScreenNote": "La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.",
+ "usbScreenStatus": "Seleziona un dispositivo USB",
+ "usbScreenSubtitle": "Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.",
+ "usbScreenTitle": "Connessione tramite USB",
"usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e riavviare."
}
diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart
index 861bf6a..4eeedae 100644
--- a/lib/l10n/app_localizations_bg.dart
+++ b/lib/l10n/app_localizations_bg.dart
@@ -122,18 +122,18 @@ class AppLocalizationsBg extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'Връзката чрез USB ще бъде налична скоро.';
+ String get usbScreenTitle => 'Свързване чрез USB';
@override
String get usbScreenSubtitle =>
- 'Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.';
+ 'Изберете открития сериен уред и свържете директно към вашия MeshCore възел.';
@override
- String get usbScreenStatus => 'Ще бъде достъпно скоро';
+ String get usbScreenStatus => 'Изберете USB устройство';
@override
String get usbScreenNote =>
- 'След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.';
+ 'USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index f768dbb..a3ab54d 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -123,18 +123,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'Die USB-Verbindung wird bald verfügbar sein.';
+ String get usbScreenTitle => 'Über USB verbinden';
@override
String get usbScreenSubtitle =>
- 'Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.';
+ 'Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.';
@override
- String get usbScreenStatus => 'Bald verfügbar';
+ String get usbScreenStatus => 'Wählen Sie ein USB-Gerät aus';
@override
String get usbScreenNote =>
- 'Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.';
+ 'USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index 32680ab..cc7261a 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -122,23 +122,22 @@ class AppLocalizationsEs extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle =>
- 'La conexión USB estará disponible próximamente.';
+ String get usbScreenTitle => 'Conecte mediante USB';
@override
String get usbScreenSubtitle =>
- 'Estamos creando una conexión en serie para dispositivos Android y de escritorio.';
+ 'Seleccione un dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.';
@override
- String get usbScreenStatus => 'Próximamente';
+ String get usbScreenStatus => 'Seleccione un dispositivo USB';
@override
String get usbScreenNote =>
- 'Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.';
+ 'La comunicación serial a través de USB está activa en dispositivos Android compatibles y en plataformas de escritorio.';
@override
String get usbScreenEmptyState =>
- 'No se detectaron dispositivos USB. Conecte uno y vuelva a intentar.';
+ 'No se encontraron dispositivos USB. Conecte uno y vuelva a cargar.';
@override
String get scanner_scanning => 'Escaneando dispositivos...';
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index dae3478..5f7d70f 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -122,23 +122,22 @@ class AppLocalizationsFr extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle =>
- 'La connexion USB sera disponible prochainement.';
+ String get usbScreenTitle => 'Connectez via USB';
@override
String get usbScreenSubtitle =>
- 'Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.';
+ 'Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.';
@override
- String get usbScreenStatus => 'Bientôt';
+ String get usbScreenStatus => 'Sélectionnez un périphérique USB';
@override
String get usbScreenNote =>
- 'Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.';
+ 'La communication série USB est active sur les appareils Android et les plateformes de bureau pris en charge.';
@override
String get usbScreenEmptyState =>
- 'Aucun périphérique USB n\'a été trouvé. Connectez-en un et rafraîchissez.';
+ 'Aucun périphérique USB n\'a été trouvé. Veuillez connecter un périphérique et rafraîchir la page.';
@override
String get scanner_scanning => 'Recherche de périphériques...';
diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart
index b138671..aef53e1 100644
--- a/lib/l10n/app_localizations_it.dart
+++ b/lib/l10n/app_localizations_it.dart
@@ -123,18 +123,18 @@ class AppLocalizationsIt extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'La connessione USB sarà disponibile a breve.';
+ String get usbScreenTitle => 'Connessione tramite USB';
@override
String get usbScreenSubtitle =>
- 'Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.';
+ 'Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.';
@override
- String get usbScreenStatus => 'Arriverà presto';
+ String get usbScreenStatus => 'Seleziona un dispositivo USB';
@override
String get usbScreenNote =>
- 'Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.';
+ 'La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index f582abc..b1c3452 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -122,18 +122,18 @@ class AppLocalizationsNl extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'USB-verbinding is binnenkort beschikbaar.';
+ String get usbScreenTitle => 'Verbind via USB';
@override
String get usbScreenSubtitle =>
- 'We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.';
+ 'Kies een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.';
@override
- String get usbScreenStatus => 'Komende week';
+ String get usbScreenStatus => 'Selecteer een USB-apparaat';
@override
String get usbScreenNote =>
- 'Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.';
+ 'USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart
index 2f103b5..5b1712f 100644
--- a/lib/l10n/app_localizations_pl.dart
+++ b/lib/l10n/app_localizations_pl.dart
@@ -122,18 +122,18 @@ class AppLocalizationsPl extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'Połączenie USB będzie dostępne wkrótce.';
+ String get usbScreenTitle => 'Połącz przez USB';
@override
String get usbScreenSubtitle =>
- 'Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.';
+ 'Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.';
@override
- String get usbScreenStatus => 'Wkrótce';
+ String get usbScreenStatus => 'Wybierz urządzenie USB';
@override
String get usbScreenNote =>
- 'Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.';
+ 'Port szeregowy USB jest aktywny na urządzeniach z Androidem i platformach stacjonarnych, które obsługują tę funkcję.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 283bada..1bf5647 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -122,18 +122,18 @@ class AppLocalizationsPt extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'A conexão USB estará disponível em breve.';
+ String get usbScreenTitle => 'Conecte via USB';
@override
String get usbScreenSubtitle =>
- 'Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.';
+ 'Selecione o dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.';
@override
- String get usbScreenStatus => 'Em breve';
+ String get usbScreenStatus => 'Selecione um dispositivo USB';
@override
String get usbScreenNote =>
- 'Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.';
+ 'A comunicação serial USB está ativa em dispositivos Android e plataformas de desktop compatíveis.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index 1687782..6a25aa2 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -122,23 +122,22 @@ class AppLocalizationsRu extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle =>
- 'Подключение через USB будет доступно в ближайшее время.';
+ String get usbScreenTitle => 'Подключение через USB';
@override
String get usbScreenSubtitle =>
- 'Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.';
+ 'Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.';
@override
- String get usbScreenStatus => 'Скоро';
+ String get usbScreenStatus => 'Выберите USB-устройство';
@override
String get usbScreenNote =>
- 'Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.';
+ 'USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.';
@override
String get usbScreenEmptyState =>
- 'Не обнаружено никаких устройств USB. Подключите одно из них и обновите список.';
+ 'Не обнаружено устройств USB. Подключите одно из них и обновите список.';
@override
String get scanner_scanning => 'Поиск устройств...';
diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart
index 8949090..a120a7d 100644
--- a/lib/l10n/app_localizations_sk.dart
+++ b/lib/l10n/app_localizations_sk.dart
@@ -122,18 +122,18 @@ class AppLocalizationsSk extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'Pripojenie cez USB bude k dispozícii čoskoro.';
+ String get usbScreenTitle => 'Pripojte cez USB';
@override
String get usbScreenSubtitle =>
- 'Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.';
+ 'Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.';
@override
- String get usbScreenStatus => 'Čoskoro';
+ String get usbScreenStatus => 'Vyberte USB zariadenie';
@override
String get usbScreenNote =>
- 'Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.';
+ 'USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart
index bd0c4d5..8969898 100644
--- a/lib/l10n/app_localizations_sl.dart
+++ b/lib/l10n/app_localizations_sl.dart
@@ -122,22 +122,22 @@ class AppLocalizationsSl extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'Vnos preko USB-ja bo v kratkem na voljo.';
+ String get usbScreenTitle => 'Povežite preko USB';
@override
String get usbScreenSubtitle =>
- 'Gradimo pot za serijsko povezavo za Android in računalnike.';
+ 'Izberite zaznano serijsko napravo in se neposredno povežite z vašim MeshCore-om.';
@override
- String get usbScreenStatus => 'Čez kratko časa';
+ String get usbScreenStatus => 'Izberite USB naprave.';
@override
String get usbScreenNote =>
- 'Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.';
+ 'USB serijska povezava je aktivna na podprtih napravah Android in na desktop platformah.';
@override
String get usbScreenEmptyState =>
- 'Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite.';
+ 'Niti en USB naprave niso najdeni. Povežite eno in posodobite.';
@override
String get scanner_scanning => 'Skeniram za naprave...';
diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart
index 183250d..2cd731b 100644
--- a/lib/l10n/app_localizations_sv.dart
+++ b/lib/l10n/app_localizations_sv.dart
@@ -122,18 +122,18 @@ class AppLocalizationsSv extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle => 'USB-anslutning kommer snart';
+ String get usbScreenTitle => 'Anslut via USB';
@override
String get usbScreenSubtitle =>
- 'Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.';
+ 'Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.';
@override
- String get usbScreenStatus => 'Kommer snart';
+ String get usbScreenStatus => 'Välj en USB-enhet';
@override
String get usbScreenNote =>
- 'När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.';
+ 'USB-seriell kommunikation är aktiv på stöderliga Android-enheter och skrivbordsplattformar.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart
index 19feaac..08c2c0f 100644
--- a/lib/l10n/app_localizations_uk.dart
+++ b/lib/l10n/app_localizations_uk.dart
@@ -122,19 +122,18 @@ class AppLocalizationsUk extends AppLocalizations {
String get connectionChoiceBluetoothLabel => 'Bluetooth';
@override
- String get usbScreenTitle =>
- 'Підключення через USB буде доступне найближчим часом.';
+ String get usbScreenTitle => 'Підключити через USB';
@override
String get usbScreenSubtitle =>
- 'Ми створюємо серійний шлях з\'єднання для Android та десктопних комп\'ютерів.';
+ 'Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.';
@override
- String get usbScreenStatus => 'Скоро';
+ String get usbScreenStatus => 'Виберіть пристрій USB';
@override
String get usbScreenNote =>
- 'Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.';
+ 'USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index fdb9f1d..678c63b 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -121,16 +121,16 @@ class AppLocalizationsZh extends AppLocalizations {
String get connectionChoiceBluetoothLabel => '蓝牙';
@override
- String get usbScreenTitle => 'USB 连接即将推出';
+ String get usbScreenTitle => '通过USB连接';
@override
- String get usbScreenSubtitle => '我们正在构建一个基于串行的连接路径,用于Android和桌面设备。';
+ String get usbScreenSubtitle => '选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。';
@override
- String get usbScreenStatus => '即将推出';
+ String get usbScreenStatus => '选择一个 USB 设备';
@override
- String get usbScreenNote => '一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。';
+ String get usbScreenNote => '在支持的 Android 设备和桌面平台上,USB 串行通信功能已启用。';
@override
String get usbScreenEmptyState => '未找到任何 USB 设备。请插入一个,然后刷新。';
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
index 2f09405..338a3a2 100644
--- a/lib/l10n/app_nl.arb
+++ b/lib/l10n/app_nl.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceSubtitle": "Kies hoe u uw MeshCore-apparaat wilt bereiken.",
"connectionChoiceBluetoothLabel": "Bluetooth",
- "usbScreenTitle": "USB-verbinding is binnenkort beschikbaar.",
- "usbScreenSubtitle": "We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.",
- "usbScreenStatus": "Komende week",
- "usbScreenNote": "Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.",
+ "usbScreenSubtitle": "Kies een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.",
+ "usbScreenStatus": "Selecteer een USB-apparaat",
+ "usbScreenNote": "USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.",
+ "usbScreenTitle": "Verbind via USB",
"usbScreenEmptyState": "Geen USB-apparaten gevonden. Sluit er een aan en herlaad."
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 8988e02..6b47ad5 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceSubtitle": "Wybierz, w jaki sposób chcesz uzyskać dostęp do swojego urządzenia MeshCore.",
"connectionChoiceTitle": "Wybierz metodę połączenia.",
"connectionChoiceUsbLabel": "USB",
- "usbScreenTitle": "Połączenie USB będzie dostępne wkrótce.",
- "usbScreenSubtitle": "Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.",
- "usbScreenStatus": "Wkrótce",
- "usbScreenNote": "Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.",
+ "usbScreenTitle": "Połącz przez USB",
+ "usbScreenNote": "Port szeregowy USB jest aktywny na urządzeniach z Androidem i platformach stacjonarnych, które obsługują tę funkcję.",
+ "usbScreenSubtitle": "Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.",
+ "usbScreenStatus": "Wybierz urządzenie USB",
"usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj."
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 296590f..d6d50e8 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTitle": "Escolha o método de conexão desejado.",
- "usbScreenTitle": "A conexão USB estará disponível em breve.",
- "usbScreenSubtitle": "Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.",
- "usbScreenStatus": "Em breve",
- "usbScreenNote": "Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.",
+ "usbScreenNote": "A comunicação serial USB está ativa em dispositivos Android e plataformas de desktop compatíveis.",
+ "usbScreenSubtitle": "Selecione o dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.",
+ "usbScreenStatus": "Selecione um dispositivo USB",
+ "usbScreenTitle": "Conecte via USB",
"usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize."
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 65b3792..2b8ab81 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1046,9 +1046,9 @@
"connectionChoiceTitle": "Выберите способ подключения",
"connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth",
- "usbScreenTitle": "Подключение через USB будет доступно в ближайшее время.",
- "usbScreenSubtitle": "Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.",
- "usbScreenStatus": "Скоро",
- "usbScreenNote": "Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.",
- "usbScreenEmptyState": "Не обнаружено никаких устройств USB. Подключите одно из них и обновите список."
+ "usbScreenSubtitle": "Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.",
+ "usbScreenNote": "USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.",
+ "usbScreenStatus": "Выберите USB-устройство",
+ "usbScreenTitle": "Подключение через USB",
+ "usbScreenEmptyState": "Не обнаружено устройств USB. Подключите одно из них и обновите список."
}
diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb
index 7178166..46fe7e7 100644
--- a/lib/l10n/app_sk.arb
+++ b/lib/l10n/app_sk.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceTitle": "Vyberte si metódu prepojenia.",
"connectionChoiceSubtitle": "Vyberte si, ako chcete dosiahnuť váš zariadenie MeshCore.",
- "usbScreenTitle": "Pripojenie cez USB bude k dispozícii čoskoro.",
- "usbScreenSubtitle": "Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.",
- "usbScreenStatus": "Čoskoro",
- "usbScreenNote": "Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.",
+ "usbScreenStatus": "Vyberte USB zariadenie",
+ "usbScreenSubtitle": "Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.",
+ "usbScreenNote": "USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.",
+ "usbScreenTitle": "Pripojte cez USB",
"usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte."
}
diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb
index 416106a..1d8943e 100644
--- a/lib/l10n/app_sl.arb
+++ b/lib/l10n/app_sl.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceTitle": "Izberite svoj način povezave.",
"connectionChoiceSubtitle": "Izberite, kako želite dostopati do svojega naprave MeshCore.",
- "usbScreenTitle": "Vnos preko USB-ja bo v kratkem na voljo.",
- "usbScreenSubtitle": "Gradimo pot za serijsko povezavo za Android in računalnike.",
- "usbScreenStatus": "Čez kratko časa",
- "usbScreenNote": "Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.",
- "usbScreenEmptyState": "Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite."
+ "usbScreenSubtitle": "Izberite zaznano serijsko napravo in se neposredno povežite z vašim MeshCore-om.",
+ "usbScreenTitle": "Povežite preko USB",
+ "usbScreenStatus": "Izberite USB naprave.",
+ "usbScreenNote": "USB serijska povezava je aktivna na podprtih napravah Android in na desktop platformah.",
+ "usbScreenEmptyState": "Niti en USB naprave niso najdeni. Povežite eno in posodobite."
}
diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb
index 9bbcd31..5a55bcb 100644
--- a/lib/l10n/app_sv.arb
+++ b/lib/l10n/app_sv.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceSubtitle": "Välj hur du vill komma åt din MeshCore-enhet.",
"connectionChoiceTitle": "Välj din anslutningsmetod",
- "usbScreenTitle": "USB-anslutning kommer snart",
- "usbScreenSubtitle": "Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.",
- "usbScreenStatus": "Kommer snart",
- "usbScreenNote": "När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.",
+ "usbScreenTitle": "Anslut via USB",
+ "usbScreenNote": "USB-seriell kommunikation är aktiv på stöderliga Android-enheter och skrivbordsplattformar.",
+ "usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
+ "usbScreenStatus": "Välj en USB-enhet",
"usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera."
}
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index 9a9919c..5b0bde5 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -1806,9 +1806,9 @@
"connectionChoiceUsbLabel": "USB",
"connectionChoiceTitle": "Виберіть спосіб зв'язку",
"connectionChoiceBluetoothLabel": "Bluetooth",
- "usbScreenTitle": "Підключення через USB буде доступне найближчим часом.",
- "usbScreenSubtitle": "Ми створюємо серійний шлях з'єднання для Android та десктопних комп'ютерів.",
- "usbScreenStatus": "Скоро",
- "usbScreenNote": "Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.",
+ "usbScreenSubtitle": "Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.",
+ "usbScreenTitle": "Підключити через USB",
+ "usbScreenStatus": "Виберіть пристрій USB",
+ "usbScreenNote": "USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.",
"usbScreenEmptyState": "Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте."
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 660b221..c35cbaa 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -1811,9 +1811,9 @@
"connectionChoiceBluetoothLabel": "蓝牙",
"connectionChoiceTitle": "选择您的连接方式",
"connectionChoiceUsbLabel": "USB",
- "usbScreenTitle": "USB 连接即将推出",
- "usbScreenSubtitle": "我们正在构建一个基于串行的连接路径,用于Android和桌面设备。",
- "usbScreenStatus": "即将推出",
- "usbScreenNote": "一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。",
+ "usbScreenTitle": "通过USB连接",
+ "usbScreenSubtitle": "选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。",
+ "usbScreenStatus": "选择一个 USB 设备",
+ "usbScreenNote": "在支持的 Android 设备和桌面平台上,USB 串行通信功能已启用。",
"usbScreenEmptyState": "未找到任何 USB 设备。请插入一个,然后刷新。"
}
diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart
index eeecfb9..8c4cec4 100644
--- a/lib/screens/contacts_screen.dart
+++ b/lib/screens/contacts_screen.dart
@@ -384,8 +384,15 @@ class _ContactsScreenState extends State
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final contacts = connector.contacts;
+ final shouldShowStartupSpinner =
+ contacts.isEmpty &&
+ _groups.isEmpty &&
+ connector.isConnected &&
+ (connector.isLoadingContacts ||
+ connector.isLoadingChannels ||
+ connector.selfPublicKey == null);
- if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
+ if (shouldShowStartupSpinner) {
return const Center(child: CircularProgressIndicator());
}
From 9a0572e8e44d8262626816088536136cd2e850a8 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 02:46:50 -0500
Subject: [PATCH 08/53] Add payload length validation in USB frame decoder
---
lib/services/usb_serial_frame_codec.dart | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
index f2ddbb6..eb4c41e 100644
--- a/lib/services/usb_serial_frame_codec.dart
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -3,6 +3,7 @@ import 'dart:typed_data';
const int usbSerialTxFrameStart = 0x3c;
const int usbSerialRxFrameStart = 0x3e;
const int usbSerialHeaderLength = 3;
+const int usbSerialMaxPayloadLength = 172;
Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
final packet = Uint8List(usbSerialHeaderLength + payload.length);
@@ -59,6 +60,11 @@ class UsbSerialFrameDecoder {
final payloadLength =
_rxBuffer[_startIndex + 1] | (_rxBuffer[_startIndex + 2] << 8);
+ if (payloadLength > usbSerialMaxPayloadLength) {
+ _startIndex++;
+ _compactBufferIfNeeded();
+ continue;
+ }
final packetLength = usbSerialHeaderLength + payloadLength;
if (availableLength < packetLength) {
_compactBufferIfNeeded(force: true);
From 115689ad956dfa3edbddbf9b85ec797294f19442 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:09:50 -0500
Subject: [PATCH 09/53] Improve USB connection handling by preventing
connection attempts when already connected
---
.../meshcore/meshcore_open/MainActivity.kt | 152 ++++++++++--------
lib/screens/usb_screen.dart | 7 +
2 files changed, 93 insertions(+), 66 deletions(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index dec4e28..3d91e71 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -238,75 +238,95 @@ class MainActivity : FlutterActivity() {
baudRate: Int,
result: MethodChannel.Result,
) {
- try {
- closeUsbConnection()
+ usbIoExecutor.execute {
+ try {
+ closeUsbConnection()
- val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
- if (driver == null) {
- result.error("usb_driver_missing", "No USB serial driver for ${device.deviceName}", null)
- return
- }
-
- val connection = usbManager.openDevice(device)
- if (connection == null) {
- result.error(
- "usb_open_failed",
- "UsbManager could not open ${device.deviceName}",
- null,
- )
- return
- }
-
- val port = firstPort(driver)
- if (port == null) {
- connection.close()
- result.error("usb_port_missing", "No USB serial port exposed by ${device.deviceName}", null)
- return
- }
-
- port.open(connection)
- port.setParameters(
- baudRate,
- 8,
- UsbSerialPort.STOPBITS_1,
- UsbSerialPort.PARITY_NONE,
- )
- port.rts = false
- port.dtr = true
-
- usbConnection = connection
- usbPort = port
- connectedDeviceName = device.deviceName
-
- ioManager =
- SerialInputOutputManager(
- port,
- object : SerialInputOutputManager.Listener {
- override fun onNewData(data: ByteArray) {
- mainHandler.post {
- eventSink?.success(data)
- }
- }
-
- override fun onRunError(e: Exception) {
- mainHandler.post {
- eventSink?.error(
- "usb_io_error",
- e.message ?: "USB serial I/O error",
- null,
- )
- }
- closeUsbConnection()
- }
- },
- ).also { manager ->
- manager.start()
+ val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
+ if (driver == null) {
+ mainHandler.post {
+ result.error(
+ "usb_driver_missing",
+ "No USB serial driver for ${device.deviceName}",
+ null,
+ )
+ }
+ return@execute
}
- result.success(null)
- } catch (error: Exception) {
- closeUsbConnection()
- result.error("usb_connect_failed", error.message, null)
+ val connection = usbManager.openDevice(device)
+ if (connection == null) {
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ "UsbManager could not open ${device.deviceName}",
+ null,
+ )
+ }
+ return@execute
+ }
+
+ val port = firstPort(driver)
+ if (port == null) {
+ connection.close()
+ mainHandler.post {
+ result.error(
+ "usb_port_missing",
+ "No USB serial port exposed by ${device.deviceName}",
+ null,
+ )
+ }
+ return@execute
+ }
+
+ port.open(connection)
+ port.setParameters(
+ baudRate,
+ 8,
+ UsbSerialPort.STOPBITS_1,
+ UsbSerialPort.PARITY_NONE,
+ )
+ port.rts = false
+ port.dtr = true
+
+ usbConnection = connection
+ usbPort = port
+ connectedDeviceName = device.deviceName
+
+ ioManager =
+ SerialInputOutputManager(
+ port,
+ object : SerialInputOutputManager.Listener {
+ override fun onNewData(data: ByteArray) {
+ mainHandler.post {
+ eventSink?.success(data)
+ }
+ }
+
+ override fun onRunError(e: Exception) {
+ mainHandler.post {
+ eventSink?.error(
+ "usb_io_error",
+ e.message ?: "USB serial I/O error",
+ null,
+ )
+ }
+ closeUsbConnection()
+ }
+ },
+ ).also { manager ->
+ manager.start()
+ }
+
+ mainHandler.post {
+ result.success(null)
+ }
+ } catch (error: Exception) {
+ closeUsbConnection()
+ mainHandler.post {
+ result.error("usb_connect_failed", error.message, null)
+ }
+ }
}
}
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index c3c1066..fb62b95 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -425,6 +425,13 @@ class _UsbScreenState extends State {
if (selectedPort == null || selectedPort.isEmpty) {
return;
}
+ if (_connector.state != MeshCoreConnectionState.disconnected) {
+ setState(() {
+ _isConnecting = false;
+ _errorText = null;
+ });
+ return;
+ }
final rawPortName = normalizeUsbPortName(selectedPort);
setState(() {
From 3542adad1ddbfe888608ade435ca55b70ffe41cc Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:13:47 -0500
Subject: [PATCH 10/53] Update USB communication note for clarity in Swedish
localization
---
lib/l10n/app_localizations_sv.dart | 2 +-
lib/l10n/app_sv.arb | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart
index 2cd731b..9bfc64d 100644
--- a/lib/l10n/app_localizations_sv.dart
+++ b/lib/l10n/app_localizations_sv.dart
@@ -133,7 +133,7 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get usbScreenNote =>
- 'USB-seriell kommunikation är aktiv på stöderliga Android-enheter och skrivbordsplattformar.';
+ 'USB-seriell kommunikation är aktiv på kompatibla Android-enheter och datorplattformar.';
@override
String get usbScreenEmptyState =>
diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb
index 5a55bcb..f3e7dbd 100644
--- a/lib/l10n/app_sv.arb
+++ b/lib/l10n/app_sv.arb
@@ -1807,7 +1807,7 @@
"connectionChoiceSubtitle": "Välj hur du vill komma åt din MeshCore-enhet.",
"connectionChoiceTitle": "Välj din anslutningsmetod",
"usbScreenTitle": "Anslut via USB",
- "usbScreenNote": "USB-seriell kommunikation är aktiv på stöderliga Android-enheter och skrivbordsplattformar.",
+ "usbScreenNote": "USB-seriell kommunikation är aktiv på kompatibla Android-enheter och datorplattformar.",
"usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
"usbScreenStatus": "Välj en USB-enhet",
"usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera."
From 3cec3dc233b9c10a956ff7ff3b4b326a7e9425e0 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:24:19 -0500
Subject: [PATCH 11/53] Improve USB disconnection handling and add payload
length validation for USB frames
---
.../kotlin/com/meshcore/meshcore_open/MainActivity.kt | 8 ++++++--
lib/services/usb_serial_frame_codec.dart | 7 +++++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index 3d91e71..5955744 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -105,8 +105,12 @@ class MainActivity : FlutterActivity() {
"connect" -> handleUsbConnect(call, result)
"write" -> handleUsbWrite(call, result)
"disconnect" -> {
- closeUsbConnection()
- result.success(null)
+ usbIoExecutor.execute {
+ closeUsbConnection()
+ mainHandler.post {
+ result.success(null)
+ }
+ }
}
else -> result.notImplemented()
}
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
index eb4c41e..ebe1733 100644
--- a/lib/services/usb_serial_frame_codec.dart
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -6,6 +6,13 @@ const int usbSerialHeaderLength = 3;
const int usbSerialMaxPayloadLength = 172;
Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
+ if (payload.length > usbSerialMaxPayloadLength) {
+ throw ArgumentError.value(
+ payload.length,
+ 'payload.length',
+ 'USB serial payload exceeds $usbSerialMaxPayloadLength bytes',
+ );
+ }
final packet = Uint8List(usbSerialHeaderLength + payload.length);
packet[0] = usbSerialTxFrameStart;
packet[1] = payload.length & 0xff;
From 612612795a1c39e16b3f4ef7c2483975dc3290a1 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:24:35 -0500
Subject: [PATCH 12/53] Update French localization for connection choice
subtitle
---
lib/l10n/app_fr.arb | 2 +-
lib/l10n/app_localizations_fr.dart | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 2e22ee5..aa33073 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1805,7 +1805,7 @@
"connectionChoiceTitle": "Choisissez votre méthode de connexion.",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceUsbLabel": "USB",
- "connectionChoiceSubtitle": "Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.",
+ "connectionChoiceSubtitle": "Choisissez la méthode de connexion que vous préférez pour votre appareil MeshCore.",
"usbScreenStatus": "Sélectionnez un périphérique USB",
"usbScreenSubtitle": "Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.",
"usbScreenNote": "La communication série USB est active sur les appareils Android et les plateformes de bureau pris en charge.",
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 5f7d70f..aaca233 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -113,7 +113,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get connectionChoiceSubtitle =>
- 'Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.';
+ 'Choisissez la méthode de connexion que vous préférez pour votre appareil MeshCore.';
@override
String get connectionChoiceUsbLabel => 'USB';
From c041e0597292ba67bc4daec12487ceff084388af Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:39:21 -0500
Subject: [PATCH 13/53] Improve error message for unavailable RX characteristic
in USB communication
---
lib/connector/meshcore_connector.dart | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 9e0eff6..39a34f2 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -1196,7 +1196,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbSerialService.write(data);
} else {
if (_rxCharacteristic == null) {
- throw Exception("MeshCore RX characteristic does not support write");
+ throw Exception("MeshCore RX characteristic not available");
}
// Prefer write without response when supported; fall back to write with response.
final properties = _rxCharacteristic!.properties;
From 47c4e0fb8252f9752995d1a8740c34d4df89e1cd Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:47:38 -0500
Subject: [PATCH 14/53] Fix USB permission receiver registration for
compatibility with Android Tiramisu
---
.../src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt | 2 +-
lib/services/usb_serial_service_web.dart | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index 5955744..7ce40d4 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -147,7 +147,7 @@ class MainActivity : FlutterActivity() {
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED)
+ registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(permissionReceiver, filter)
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 1f0fcb9..5ceb6eb 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
+import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
From 4b2450631066c4bf70790524ebbca6b5adedb58d Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 03:53:43 -0500
Subject: [PATCH 15/53] Remove unused import of 'dart:typed_data' in
usb_serial_service_web.dart
---
lib/services/usb_serial_service_web.dart | 1 -
1 file changed, 1 deletion(-)
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 5ceb6eb..1f0fcb9 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
-import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
From dcad5c586de7ec91f977f69a434d8448d38fe940 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 04:11:52 -0500
Subject: [PATCH 16/53] Refactor USB connection handling to use scheduled
closure and improve error management in USB services
---
.../meshcore/meshcore_open/MainActivity.kt | 32 ++++++++++++-------
lib/connector/meshcore_connector.dart | 11 ++++++-
lib/services/usb_serial_service_native.dart | 32 +++++++++++++++----
lib/services/usb_serial_service_web.dart | 30 ++++++++++++++---
4 files changed, 81 insertions(+), 24 deletions(-)
diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
index 7ce40d4..e0e0723 100644
--- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt
@@ -105,11 +105,8 @@ class MainActivity : FlutterActivity() {
"connect" -> handleUsbConnect(call, result)
"write" -> handleUsbWrite(call, result)
"disconnect" -> {
- usbIoExecutor.execute {
- closeUsbConnection()
- mainHandler.post {
- result.success(null)
- }
+ scheduleCloseUsbConnection {
+ result.success(null)
}
}
else -> result.notImplemented()
@@ -315,7 +312,7 @@ class MainActivity : FlutterActivity() {
null,
)
}
- closeUsbConnection()
+ scheduleCloseUsbConnection()
}
},
).also { manager ->
@@ -338,6 +335,16 @@ class MainActivity : FlutterActivity() {
return driver.ports.firstOrNull()
}
+ private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
+ usbIoExecutor.execute {
+ closeUsbConnection()
+ if (onComplete != null) {
+ mainHandler.post(onComplete)
+ }
+ }
+ }
+
+ @Synchronized
private fun closeUsbConnection() {
try {
ioManager?.stop()
@@ -381,12 +388,13 @@ class MainActivity : FlutterActivity() {
}
if (connectedDeviceName == detachedName) {
- closeUsbConnection()
- eventSink?.error(
- "usb_device_detached",
- "USB device was disconnected",
- null,
- )
+ scheduleCloseUsbConnection {
+ eventSink?.error(
+ "usb_device_detached",
+ "USB device was disconnected",
+ null,
+ )
+ }
}
}
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 39a34f2..e3712ed 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -1266,14 +1266,23 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer?.cancel();
if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) {
- _selfInfoRetryTimer = Timer(const Duration(seconds: 10), () {
+ var attempts = 0;
+ const maxAttempts = 3;
+ _selfInfoRetryTimer = Timer.periodic(const Duration(seconds: 10), (
+ timer,
+ ) {
if (!isConnected || !_awaitingSelfInfo) {
+ timer.cancel();
return;
}
if (_isLoadingContacts || _isSyncingChannels || _channelSyncInFlight) {
return;
}
+ attempts += 1;
unawaited(sendFrame(buildAppStartFrame()));
+ if (attempts >= maxAttempts) {
+ timer.cancel();
+ }
});
return;
}
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
index f69c6fc..7f6eb11 100644
--- a/lib/services/usb_serial_service_native.dart
+++ b/lib/services/usb_serial_service_native.dart
@@ -186,8 +186,7 @@ class UsbSerialService {
}
void dispose() {
- unawaited(disconnect());
- unawaited(_frameController.close());
+ unawaited(disconnect().whenComplete(_closeFrameController));
}
void _handleSerialData(FlSerialEventArgs event) {
@@ -197,7 +196,7 @@ class UsbSerialService {
_ingestRawBytes(Uint8List.fromList(bytes));
}
} catch (error, stack) {
- _frameController.addError(error, stack);
+ _addFrameError(error, stack);
}
}
@@ -210,13 +209,13 @@ class UsbSerialService {
_ingestRawBytes(data.buffer.asUint8List());
return;
}
- _frameController.addError(
+ _addFrameError(
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
);
}
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
- _frameController.addError(error, stackTrace);
+ _addFrameError(error, stackTrace);
}
void _handleSerialDone() {
@@ -231,10 +230,31 @@ class UsbSerialService {
);
continue;
}
- _frameController.add(packet.payload);
+ _addFrame(packet.payload);
}
}
+ void _addFrame(Uint8List payload) {
+ if (_frameController.isClosed) {
+ return;
+ }
+ _frameController.add(payload);
+ }
+
+ void _addFrameError(Object error, [StackTrace? stackTrace]) {
+ if (_frameController.isClosed) {
+ return;
+ }
+ _frameController.addError(error, stackTrace);
+ }
+
+ Future _closeFrameController() async {
+ if (_frameController.isClosed) {
+ return;
+ }
+ await _frameController.close();
+ }
+
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 1f0fcb9..71e5127 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -160,8 +160,7 @@ class UsbSerialService {
}
void dispose() {
- unawaited(disconnect());
- unawaited(_frameController.close());
+ unawaited(disconnect().whenComplete(_closeFrameController));
}
Future> _getAuthorizedPorts() async {
@@ -285,12 +284,12 @@ class UsbSerialService {
}
} catch (error, stackTrace) {
if (_status == UsbSerialStatus.connected) {
- _frameController.addError(error, stackTrace);
+ _addFrameError(error, stackTrace);
}
} finally {
_releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
- _frameController.addError(StateError('USB serial connection closed'));
+ _addFrameError(StateError('USB serial connection closed'));
}
}
}
@@ -402,10 +401,31 @@ class UsbSerialService {
);
continue;
}
- _frameController.add(packet.payload);
+ _addFrame(packet.payload);
}
}
+ void _addFrame(Uint8List payload) {
+ if (_frameController.isClosed) {
+ return;
+ }
+ _frameController.add(payload);
+ }
+
+ void _addFrameError(Object error, [StackTrace? stackTrace]) {
+ if (_frameController.isClosed) {
+ return;
+ }
+ _frameController.addError(error, stackTrace);
+ }
+
+ Future _closeFrameController() async {
+ if (_frameController.isClosed) {
+ return;
+ }
+ await _frameController.close();
+ }
+
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
From ca5784f3f8c94149e572eff62459fc64ad2b5fd3 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 04:55:47 -0500
Subject: [PATCH 17/53] Add post-frame callback to ensure disconnection on
dispose when navigation hasn't changed
---
lib/screens/scanner_screen.dart | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart
index 713239f..69ddf6c 100644
--- a/lib/screens/scanner_screen.dart
+++ b/lib/screens/scanner_screen.dart
@@ -68,6 +68,11 @@ class _ScannerScreenState extends State {
void dispose() {
_connector.removeListener(_connectionListener);
unawaited(_bluetoothStateSubscription.cancel());
+ if (!_changedNavigation) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_connector.disconnect(manual: true));
+ });
+ }
super.dispose();
}
From 781090243cf0b2155024830b9d55a66c201341fd Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 05:28:31 -0500
Subject: [PATCH 18/53] Enhance USB functionality by adding request port label
management and platform support checks
---
lib/connector/meshcore_connector.dart | 4 ++
lib/screens/connection_choice_screen.dart | 24 +++++----
lib/screens/usb_screen.dart | 8 +++
lib/services/usb_serial_service_native.dart | 58 ++++++++++++++-------
lib/services/usb_serial_service_web.dart | 18 +++++--
lib/utils/platform_info.dart | 10 ++++
lib/utils/usb_port_labels.dart | 5 +-
7 files changed, 93 insertions(+), 34 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index e3712ed..3447eee 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -767,6 +767,10 @@ class MeshCoreConnector extends ChangeNotifier {
Future> listUsbPorts() => _usbSerialService.listPorts();
+ void setUsbRequestPortLabel(String label) {
+ _usbSerialService.setRequestPortLabel(label);
+ }
+
Future connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
diff --git a/lib/screens/connection_choice_screen.dart b/lib/screens/connection_choice_screen.dart
index e4abe7f..16634c6 100644
--- a/lib/screens/connection_choice_screen.dart
+++ b/lib/screens/connection_choice_screen.dart
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
+import '../utils/platform_info.dart';
import 'scanner_screen.dart';
import 'usb_screen.dart';
@@ -14,6 +15,7 @@ class ConnectionChoiceScreen extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
+ final usbSupported = PlatformInfo.supportsUsbSerial;
return Scaffold(
appBar: AppBar(
title: FittedBox(
@@ -82,14 +84,18 @@ class ConnectionChoiceScreen extends StatelessWidget {
label: l10n.connectionChoiceUsbLabel,
color: theme.colorScheme.primaryContainer,
iconColor: theme.colorScheme.onPrimaryContainer,
- onPressed: () {
- debugPrint(
- 'ConnectionChoiceScreen: USB selected, opening UsbScreen',
- );
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const UsbScreen()),
- );
- },
+ onPressed: usbSupported
+ ? () {
+ debugPrint(
+ 'ConnectionChoiceScreen: USB selected, opening UsbScreen',
+ );
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const UsbScreen(),
+ ),
+ );
+ }
+ : null,
),
),
SizedBox(height: gap),
@@ -133,7 +139,7 @@ class _ConnectionMethodButton extends StatelessWidget {
final IconData icon;
final String label;
- final VoidCallback onPressed;
+ final VoidCallback? onPressed;
final Color color;
final Color iconColor;
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index fb62b95..160f463 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -61,6 +61,12 @@ class _UsbScreenState extends State {
unawaited(_loadPorts());
}
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
+ }
+
@override
void dispose() {
_connector.removeListener(_connectionListener);
@@ -389,6 +395,7 @@ class _UsbScreenState extends State {
Future _loadPorts() async {
if (!mounted) return;
+ _connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
setState(() {
_isLoadingPorts = true;
@@ -425,6 +432,7 @@ class _UsbScreenState extends State {
if (selectedPort == null || selectedPort.isEmpty) {
return;
}
+ _connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
if (_connector.state != MeshCoreConnectionState.disconnected) {
setState(() {
_isConnecting = false;
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
index 7f6eb11..8467d3a 100644
--- a/lib/services/usb_serial_service_native.dart
+++ b/lib/services/usb_serial_service_native.dart
@@ -5,6 +5,7 @@ import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
+import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@@ -21,28 +22,38 @@ class UsbSerialService {
);
final StreamController _frameController =
StreamController.broadcast();
- final FlSerial _serial = FlSerial();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription? _androidDataSubscription;
StreamSubscription? _dataSubscription;
UsbSerialStatus _status = UsbSerialStatus.disconnected;
String? _connectedPortName;
+ FlSerial? _serial;
UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName;
Stream get frameStream => _frameController.stream;
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ bool get _useDesktopFlSerial =>
+ PlatformInfo.isWindows || PlatformInfo.isLinux;
+ bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
+ FlSerial get _nativeSerial => _serial ??= FlSerial();
bool get isConnected {
+ if (!_isSupportedPlatform) {
+ return false;
+ }
if (_useAndroidUsbHost) {
return _status == UsbSerialStatus.connected;
}
return _status == UsbSerialStatus.connected &&
- _serial.isOpen() == FlOpenStatus.open;
+ _serial?.isOpen() == FlOpenStatus.open;
}
Future> listPorts() async {
+ if (!_isSupportedPlatform) {
+ return const [];
+ }
if (_useAndroidUsbHost) {
final ports = await _androidMethodChannel.invokeListMethod(
'listPorts',
@@ -60,6 +71,9 @@ class UsbSerialService {
_status == UsbSerialStatus.connecting) {
throw StateError('USB serial transport is already active');
}
+ if (!_isSupportedPlatform) {
+ throw UnsupportedError('USB serial is not supported on this platform.');
+ }
_status = UsbSerialStatus.connecting;
final normalizedPortName = normalizeUsbPortName(portName);
@@ -78,32 +92,35 @@ class UsbSerialService {
throw StateError(error.message ?? error.code);
}
} else {
- _serial.init();
+ final serial = _nativeSerial;
+ serial.init();
try {
- final status = _serial.openPort(normalizedPortName, baudRate);
+ final status = serial.openPort(normalizedPortName, baudRate);
if (status != FlOpenStatus.open) {
throw StateError(
'Failed to open USB port $normalizedPortName ($status)',
);
}
- _serial.setByteSize8();
- _serial.setBitParityNone();
- _serial.setStopBits1();
- _serial.setFlowControlNone();
- _serial.setRTS(false);
- _serial.setDTR(true);
+ serial.setByteSize8();
+ serial.setBitParityNone();
+ serial.setStopBits1();
+ serial.setFlowControlNone();
+ serial.setRTS(false);
+ serial.setDTR(true);
debugPrint(
- 'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
+ 'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
);
} on FlSerialException catch (error) {
- _serial.free();
+ _serial?.free();
+ _serial = null;
_status = UsbSerialStatus.disconnected;
throw StateError(
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
);
} catch (error) {
- _serial.free();
+ _serial?.free();
+ _serial = null;
_status = UsbSerialStatus.disconnected;
rethrow;
}
@@ -119,7 +136,7 @@ class UsbSerialService {
onDone: _handleSerialDone,
);
} else {
- _dataSubscription = _serial.onSerialData.stream.listen(
+ _dataSubscription = _nativeSerial.onSerialData.stream.listen(
_handleSerialData,
onError: _handleSerialError,
onDone: _handleSerialDone,
@@ -143,7 +160,7 @@ class UsbSerialService {
throw StateError(error.message ?? error.code);
}
} else {
- _serial.write(packet);
+ _nativeSerial.write(packet);
}
}
@@ -165,18 +182,23 @@ class UsbSerialService {
}
} else {
try {
- if (_serial.isOpen() == FlOpenStatus.open) {
- _serial.closePort();
+ if (_serial?.isOpen() == FlOpenStatus.open) {
+ _serial?.closePort();
}
} catch (_) {
// Ignore errors while closing.
}
- _serial.free();
+ _serial?.free();
+ _serial = null;
}
_status = UsbSerialStatus.disconnected;
}
+ void setRequestPortLabel(String label) {
+ // Native implementations do not use a synthetic chooser row.
+ }
+
void updateConnectedLabel(String label) {
final trimmed = label.trim();
if (trimmed.isEmpty) {
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 71e5127..8c3900d 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -26,6 +26,7 @@ class UsbSerialService {
JSObject? _writer;
String? _connectedPortName;
String? _connectedPortKey;
+ String _requestPortLabel = 'Choose USB Device';
UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName;
@@ -49,7 +50,7 @@ class UsbSerialService {
final ports = await _getAuthorizedPorts();
if (ports.isEmpty) {
- return const [usbRequestPortLabel];
+ return [_requestPortLabel];
}
return ports.map(_displayLabelForPort).toList(growable: false);
}
@@ -159,6 +160,14 @@ class UsbSerialService {
_connectedPortName = _buildDisplayLabel(portKey);
}
+ void setRequestPortLabel(String label) {
+ final trimmed = label.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ _requestPortLabel = trimmed;
+ }
+
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
@@ -189,7 +198,7 @@ class UsbSerialService {
if (ports.isEmpty) {
return null;
}
- if (requestedPortName.isEmpty || requestedPortName == usbRequestPortLabel) {
+ if (requestedPortName.isEmpty || requestedPortName == _requestPortLabel) {
return ports.first;
}
for (final port in ports) {
@@ -350,7 +359,7 @@ class UsbSerialService {
try {
final info = port.callMethod('getInfo'.toJS);
if (info == null) {
- return usbRequestPortLabel;
+ return _requestPortLabel;
}
final infoObject = info as JSObject;
@@ -366,10 +375,11 @@ class UsbSerialService {
return describeWebUsbPort(
vendorId: hasVendor ? vendorId.toInt() : null,
productId: hasProduct ? productId.toInt() : null,
+ requestPortLabel: _requestPortLabel,
knownUsbNames: _knownUsbNames,
);
} catch (_) {
- return usbRequestPortLabel;
+ return _requestPortLabel;
}
}
diff --git a/lib/utils/platform_info.dart b/lib/utils/platform_info.dart
index dc8e27e..e3fd428 100644
--- a/lib/utils/platform_info.dart
+++ b/lib/utils/platform_info.dart
@@ -33,4 +33,14 @@ class PlatformInfo {
/// Whether the app is running on a desktop platform (macOS, Windows, or Linux).
static bool get isDesktop => isMacOS || isWindows || isLinux;
+
+ /// Whether the current platform supports a native USB serial backend.
+ static bool get supportsNativeUsbSerial => isAndroid || isWindows || isLinux;
+
+ /// Whether the current browser supports the Web Serial backend.
+ static bool get supportsWebSerial => isWeb && isChrome;
+
+ /// Whether USB serial is expected to be available on the current platform.
+ static bool get supportsUsbSerial =>
+ supportsNativeUsbSerial || supportsWebSerial;
}
diff --git a/lib/utils/usb_port_labels.dart b/lib/utils/usb_port_labels.dart
index 6430f95..ff32937 100644
--- a/lib/utils/usb_port_labels.dart
+++ b/lib/utils/usb_port_labels.dart
@@ -1,5 +1,3 @@
-const String usbRequestPortLabel = 'Choose USB Device';
-
String normalizeUsbPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - ');
final normalized = separatorIndex >= 0
@@ -23,10 +21,11 @@ String friendlyUsbPortName(String portLabel) {
String describeWebUsbPort({
required int? vendorId,
required int? productId,
+ String requestPortLabel = 'Choose USB Device',
Map knownUsbNames = const {},
}) {
if (vendorId == null && productId == null) {
- return usbRequestPortLabel;
+ return requestPortLabel;
}
final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();
From f39a22668e51bffdee1eef2abb5b8f1a94158a00 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 05:28:40 -0500
Subject: [PATCH 19/53] Add initial load scheduling and tests for USB screen
and frame codec functionality
---
lib/screens/usb_screen.dart | 6 +-
test/screens/usb_flow_test.dart | 125 ++++++++++++++++++
.../services/usb_serial_frame_codec_test.dart | 39 ++++++
test/utils/usb_port_labels_test.dart | 13 +-
4 files changed, 181 insertions(+), 2 deletions(-)
create mode 100644 test/screens/usb_flow_test.dart
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index 160f463..c99e10f 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -21,6 +21,7 @@ class _UsbScreenState extends State {
bool _isLoadingPorts = true;
bool _isConnecting = false;
bool _navigatedToContacts = false;
+ bool _didScheduleInitialLoad = false;
String? _selectedPort;
String? _errorText;
late final MeshCoreConnector _connector;
@@ -58,13 +59,16 @@ class _UsbScreenState extends State {
}
};
_connector.addListener(_connectionListener);
- unawaited(_loadPorts());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
+ if (!_didScheduleInitialLoad) {
+ _didScheduleInitialLoad = true;
+ unawaited(_loadPorts());
+ }
}
@override
diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart
new file mode 100644
index 0000000..499f894
--- /dev/null
+++ b/test/screens/usb_flow_test.dart
@@ -0,0 +1,125 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+import 'package:meshcore_open/connector/meshcore_connector.dart';
+import 'package:meshcore_open/l10n/app_localizations.dart';
+import 'package:meshcore_open/screens/connection_choice_screen.dart';
+import 'package:meshcore_open/screens/usb_screen.dart';
+import 'package:meshcore_open/utils/platform_info.dart';
+
+class _FakeMeshCoreConnector extends MeshCoreConnector {
+ _FakeMeshCoreConnector({
+ this.initialState = MeshCoreConnectionState.disconnected,
+ List? ports,
+ }) : _ports = ports ?? [];
+
+ final MeshCoreConnectionState initialState;
+ final List _ports;
+
+ String? requestPortLabel;
+ int connectUsbCalls = 0;
+ String? lastConnectPortName;
+ String? fakeActiveUsbPort;
+ bool fakeUsbTransportConnected = false;
+
+ @override
+ MeshCoreConnectionState get state => initialState;
+
+ @override
+ String? get activeUsbPort => fakeActiveUsbPort;
+
+ @override
+ bool get isUsbTransportConnected => fakeUsbTransportConnected;
+
+ @override
+ Future> listUsbPorts() async => List.from(_ports);
+
+ @override
+ Future connectUsb({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ connectUsbCalls += 1;
+ lastConnectPortName = portName;
+ }
+
+ @override
+ void setUsbRequestPortLabel(String label) {
+ requestPortLabel = label;
+ }
+}
+
+Widget _buildTestApp({
+ required MeshCoreConnector connector,
+ required Widget child,
+}) {
+ return ChangeNotifierProvider.value(
+ value: connector,
+ child: MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: child,
+ ),
+ );
+}
+
+void main() {
+ testWidgets('UsbScreen passes localized chooser label to connector', (
+ tester,
+ ) async {
+ final connector = _FakeMeshCoreConnector();
+
+ await tester.pumpWidget(
+ _buildTestApp(connector: connector, child: const UsbScreen()),
+ );
+ await tester.pumpAndSettle();
+
+ expect(connector.requestPortLabel, 'Select a USB device');
+ });
+
+ testWidgets(
+ 'UsbScreen does not call connectUsb when connector is not disconnected',
+ (tester) async {
+ final connector = _FakeMeshCoreConnector(
+ initialState: MeshCoreConnectionState.connected,
+ ports: ['COM6 - USB Serial Device (COM6)'],
+ );
+
+ await tester.pumpWidget(
+ _buildTestApp(connector: connector, child: const UsbScreen()),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
+ await tester.pump();
+
+ expect(connector.connectUsbCalls, 0);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ },
+ );
+
+ testWidgets('ConnectionChoiceScreen USB button reflects platform support', (
+ tester,
+ ) async {
+ final connector = _FakeMeshCoreConnector();
+
+ await tester.pumpWidget(
+ _buildTestApp(
+ connector: connector,
+ child: const ConnectionChoiceScreen(),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final usbButton = tester.widget(
+ find.widgetWithText(ElevatedButton, 'USB'),
+ );
+
+ if (PlatformInfo.supportsUsbSerial) {
+ expect(usbButton.onPressed, isNotNull);
+ } else {
+ expect(usbButton.onPressed, isNull);
+ }
+ });
+}
diff --git a/test/services/usb_serial_frame_codec_test.dart b/test/services/usb_serial_frame_codec_test.dart
index f0ce186..be4497e 100644
--- a/test/services/usb_serial_frame_codec_test.dart
+++ b/test/services/usb_serial_frame_codec_test.dart
@@ -13,6 +13,21 @@ void main() {
);
});
+ test('wrapUsbSerialTxFrame rejects payloads above protocol maximum', () {
+ final payload = Uint8List(usbSerialMaxPayloadLength + 1);
+
+ expect(
+ () => wrapUsbSerialTxFrame(payload),
+ throwsA(
+ isA().having(
+ (error) => error.name,
+ 'name',
+ 'payload.length',
+ ),
+ ),
+ );
+ });
+
test('UsbSerialFrameDecoder buffers partial frames until complete', () {
final decoder = UsbSerialFrameDecoder();
@@ -81,4 +96,28 @@ void main() {
expect(packets[1].payload, orderedEquals([0x33]));
},
);
+
+ test(
+ 'UsbSerialFrameDecoder drops oversized frames and resyncs on the next valid packet',
+ () {
+ final decoder = UsbSerialFrameDecoder();
+
+ final packets = decoder.ingest(
+ Uint8List.fromList([
+ usbSerialRxFrameStart,
+ 0xAD,
+ 0x00,
+ 0x99,
+ usbSerialRxFrameStart,
+ 0x01,
+ 0x00,
+ 0x44,
+ ]),
+ );
+
+ expect(packets, hasLength(1));
+ expect(packets.single.isRxFrame, isTrue);
+ expect(packets.single.payload, orderedEquals([0x44]));
+ },
+ );
}
diff --git a/test/utils/usb_port_labels_test.dart b/test/utils/usb_port_labels_test.dart
index 4fef509..e375005 100644
--- a/test/utils/usb_port_labels_test.dart
+++ b/test/utils/usb_port_labels_test.dart
@@ -50,7 +50,18 @@ void main() {
test('describeWebUsbPort returns chooser label when no usb ids exist', () {
expect(
describeWebUsbPort(vendorId: null, productId: null),
- usbRequestPortLabel,
+ 'Choose USB Device',
+ );
+ });
+
+ test('describeWebUsbPort uses caller-provided chooser label', () {
+ expect(
+ describeWebUsbPort(
+ vendorId: null,
+ productId: null,
+ requestPortLabel: 'Select a USB device',
+ ),
+ 'Select a USB device',
);
});
From a0feb129e15283a7beb483bc9cdc5c83c5be2a80 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 15:30:46 -0500
Subject: [PATCH 20/53] Add post-frame callback to disconnect USB transport on
dispose if not navigated to contacts
---
lib/screens/usb_screen.dart | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index c99e10f..38a7c67 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -74,6 +74,13 @@ class _UsbScreenState extends State {
@override
void dispose() {
_connector.removeListener(_connectionListener);
+ if (!_navigatedToContacts &&
+ _connector.activeTransport == MeshCoreTransportType.usb &&
+ _connector.state != MeshCoreConnectionState.disconnected) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_connector.disconnect(manual: true));
+ });
+ }
super.dispose();
}
From 5216e00807b2499aac723e7665122d139df5a568 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 15:54:39 -0500
Subject: [PATCH 21/53] Refactor USB port handling to introduce display labels
and improve state management
---
lib/connector/meshcore_connector.dart | 22 +++++++++-----
lib/screens/usb_screen.dart | 32 ++++++++++++---------
lib/services/usb_serial_service_native.dart | 18 ++++++++----
lib/services/usb_serial_service_web.dart | 3 +-
test/screens/usb_flow_test.dart | 32 +++++++++++++++++++++
5 files changed, 80 insertions(+), 27 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 3447eee..b91dd49 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -115,7 +115,8 @@ class MeshCoreConnector extends ChangeNotifier {
final UsbSerialService _usbSerialService = UsbSerialService();
StreamSubscription? _usbFrameSubscription;
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
- String? _activeUsbPort;
+ String? _activeUsbPortKey;
+ String? _activeUsbPortLabel;
final List _scanResults = [];
final List _contacts = [];
@@ -229,7 +230,9 @@ class MeshCoreConnector extends ChangeNotifier {
String get deviceIdLabel => _deviceId ?? 'Unknown';
MeshCoreTransportType get activeTransport => _activeTransport;
- String? get activeUsbPort => _activeUsbPort;
+ String? get activeUsbPort => _activeUsbPortKey;
+ String? get activeUsbPortDisplayLabel =>
+ _activeUsbPortLabel ?? _activeUsbPortKey;
bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb;
@@ -778,7 +781,8 @@ class MeshCoreConnector extends ChangeNotifier {
}
_activeTransport = MeshCoreTransportType.bluetooth;
- _activeUsbPort = null;
+ _activeUsbPortKey = null;
+ _activeUsbPortLabel = null;
await stopScan();
_setState(MeshCoreConnectionState.connecting);
@@ -955,14 +959,16 @@ class MeshCoreConnector extends ChangeNotifier {
}
_activeTransport = MeshCoreTransportType.bluetooth;
- _activeUsbPort = null;
+ _activeUsbPortKey = null;
+ _activeUsbPortLabel = null;
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
_resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.usb;
- _activeUsbPort = portName;
+ _activeUsbPortKey = portName;
+ _activeUsbPortLabel = portName;
unawaited(_backgroundService?.start());
_setState(MeshCoreConnectionState.connecting);
@@ -1178,7 +1184,8 @@ class MeshCoreConnector extends ChangeNotifier {
_reactionSendQueueSequence = 0;
_activeTransport = MeshCoreTransportType.bluetooth;
- _activeUsbPort = null;
+ _activeUsbPortKey = null;
+ _activeUsbPortLabel = null;
_setState(MeshCoreConnectionState.disconnected);
if (!manual && transportAtDisconnect == MeshCoreTransportType.bluetooth) {
@@ -2218,7 +2225,8 @@ class MeshCoreConnector extends ChangeNotifier {
selfName != null &&
selfName.isNotEmpty) {
_usbSerialService.updateConnectedLabel(selfName);
- _activeUsbPort = _usbSerialService.activePortName ?? _activeUsbPort;
+ _activeUsbPortLabel =
+ _usbSerialService.activePortDisplayLabel ?? _activeUsbPortLabel;
}
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index 38a7c67..69e95c4 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -23,6 +23,7 @@ class _UsbScreenState extends State {
bool _navigatedToContacts = false;
bool _didScheduleInitialLoad = false;
String? _selectedPort;
+ String? _connectedPortDisplayLabel;
String? _errorText;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
@@ -33,21 +34,19 @@ class _UsbScreenState extends State {
_connector = context.read();
_connectionListener = () {
if (!mounted) return;
- final activeUsbPort = _connector.activeUsbPort;
- if (activeUsbPort != null &&
- activeUsbPort.isNotEmpty &&
- activeUsbPort != _selectedPort) {
- setState(() {
- _selectedPort = activeUsbPort;
- });
- }
+ final activeUsbPortDisplayLabel = _connector.activeUsbPortDisplayLabel;
+ final shouldUpdateDisplayLabel =
+ activeUsbPortDisplayLabel != _connectedPortDisplayLabel;
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
- if (_isConnecting) {
- setState(() {
- _isConnecting = false;
- });
- }
+ setState(() {
+ _isConnecting = false;
+ _connectedPortDisplayLabel = activeUsbPortDisplayLabel;
+ });
+ } else if (shouldUpdateDisplayLabel) {
+ setState(() {
+ _connectedPortDisplayLabel = activeUsbPortDisplayLabel;
+ });
}
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isUsbTransportConnected &&
@@ -167,7 +166,12 @@ class _UsbScreenState extends State {
fit: BoxFit.scaleDown,
child: Chip(
label: Text(
- _selectedPort == null
+ _connectedPortDisplayLabel != null &&
+ _connectedPortDisplayLabel!.isNotEmpty
+ ? _friendlyPortName(
+ _connectedPortDisplayLabel!,
+ )
+ : _selectedPort == null
? l10n.usbScreenStatus
: _friendlyPortName(_selectedPort!),
overflow: TextOverflow.ellipsis,
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
index 8467d3a..f6b879b 100644
--- a/lib/services/usb_serial_service_native.dart
+++ b/lib/services/usb_serial_service_native.dart
@@ -26,11 +26,14 @@ class UsbSerialService {
StreamSubscription? _androidDataSubscription;
StreamSubscription? _dataSubscription;
UsbSerialStatus _status = UsbSerialStatus.disconnected;
- String? _connectedPortName;
+ String? _connectedPortKey;
+ String? _connectedPortLabel;
FlSerial? _serial;
UsbSerialStatus get status => _status;
- String? get activePortName => _connectedPortName;
+ String? get activePortKey => _connectedPortKey;
+ String? get activePortDisplayLabel =>
+ _connectedPortLabel ?? _connectedPortKey;
Stream get frameStream => _frameController.stream;
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
@@ -126,7 +129,8 @@ class UsbSerialService {
}
}
- _connectedPortName = normalizedPortName;
+ _connectedPortKey = normalizedPortName;
+ _connectedPortLabel = normalizedPortName;
if (_useAndroidUsbHost) {
_androidDataSubscription = _androidEventChannel
.receiveBroadcastStream()
@@ -168,7 +172,8 @@ class UsbSerialService {
if (_status == UsbSerialStatus.disconnected) return;
_status = UsbSerialStatus.disconnecting;
- _connectedPortName = null;
+ _connectedPortKey = null;
+ _connectedPortLabel = null;
await _androidDataSubscription?.cancel();
_androidDataSubscription = null;
await _dataSubscription?.cancel();
@@ -204,7 +209,10 @@ class UsbSerialService {
if (trimmed.isEmpty) {
return;
}
- _connectedPortName = trimmed;
+ _connectedPortLabel = buildUsbDisplayLabel(
+ basePortLabel: _connectedPortKey ?? trimmed,
+ deviceName: trimmed,
+ );
}
void dispose() {
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 8c3900d..87fe8e9 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -29,7 +29,8 @@ class UsbSerialService {
String _requestPortLabel = 'Choose USB Device';
UsbSerialStatus get status => _status;
- String? get activePortName => _connectedPortName;
+ String? get activePortKey => _connectedPortKey;
+ String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
Stream get frameStream => _frameController.stream;
bool get isConnected => _status == UsbSerialStatus.connected;
diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart
index 499f894..317dc92 100644
--- a/test/screens/usb_flow_test.dart
+++ b/test/screens/usb_flow_test.dart
@@ -21,6 +21,7 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
int connectUsbCalls = 0;
String? lastConnectPortName;
String? fakeActiveUsbPort;
+ String? fakeActiveUsbPortDisplayLabel;
bool fakeUsbTransportConnected = false;
@override
@@ -29,6 +30,10 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
@override
String? get activeUsbPort => fakeActiveUsbPort;
+ @override
+ String? get activeUsbPortDisplayLabel =>
+ fakeActiveUsbPortDisplayLabel ?? fakeActiveUsbPort;
+
@override
bool get isUsbTransportConnected => fakeUsbTransportConnected;
@@ -99,6 +104,33 @@ void main() {
},
);
+ testWidgets(
+ 'UsbScreen keeps raw selection while showing connector USB display label',
+ (tester) async {
+ final connector = _FakeMeshCoreConnector(
+ ports: ['COM6 - USB Serial Device (COM6)'],
+ );
+
+ await tester.pumpWidget(
+ _buildTestApp(connector: connector, child: const UsbScreen()),
+ );
+ await tester.pumpAndSettle();
+
+ connector.fakeActiveUsbPortDisplayLabel =
+ 'COM6 - KD3CGK mesh-utility.org';
+ connector.notifyListeners();
+ await tester.pump();
+
+ expect(find.text('KD3CGK mesh-utility.org'), findsOneWidget);
+
+ await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
+ await tester.pump();
+
+ expect(connector.connectUsbCalls, 1);
+ expect(connector.lastConnectPortName, 'COM6');
+ },
+ );
+
testWidgets('ConnectionChoiceScreen USB button reflects platform support', (
tester,
) async {
From 3cef9e81b6af56d9a56ef773a1d8b8a27fe8fee3 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:11:49 -0500
Subject: [PATCH 22/53] Remove unawaited background service start during USB
connection initialization
---
lib/connector/meshcore_connector.dart | 1 -
1 file changed, 1 deletion(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index b91dd49..50ae50c 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -969,7 +969,6 @@ class MeshCoreConnector extends ChangeNotifier {
_activeTransport = MeshCoreTransportType.usb;
_activeUsbPortKey = portName;
_activeUsbPortLabel = portName;
- unawaited(_backgroundService?.start());
_setState(MeshCoreConnectionState.connecting);
try {
From d6d11eaad2582e44669580c5c3e2036993b01d9c Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:20:41 -0500
Subject: [PATCH 23/53] Update active USB port key and label on connection,
notify listeners
---
lib/connector/meshcore_connector.dart | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 50ae50c..b3aaeab 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -975,6 +975,10 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbSerialService.connect(portName: portName, baudRate: baudRate);
+ _activeUsbPortKey = _usbSerialService.activePortKey ?? _activeUsbPortKey;
+ _activeUsbPortLabel =
+ _usbSerialService.activePortDisplayLabel ?? _activeUsbPortLabel;
+ notifyListeners();
if (PlatformInfo.isWeb) {
await stopScan();
}
From 98cdac4309f078c2d5723fb96b78c2bae881c973 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:49:04 -0500
Subject: [PATCH 24/53] Refactor MeshCoreConnector to streamline connection
handling and remove web-specific logic for contact synchronization... Back to
the way it was before.. For some reason the fix worked on my machine but
wwhen i built web from upstream it didnt work
---
lib/connector/meshcore_connector.dart | 156 +++++---------------------
1 file changed, 30 insertions(+), 126 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index b3aaeab..c5bb09f 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -165,7 +165,6 @@ class MeshCoreConnector extends ChangeNotifier {
bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false;
bool _pendingInitialChannelSync = false;
- bool _pendingInitialContactsSync = false;
bool _preserveContactsOnRefresh = false;
static const int _defaultMaxContacts = 32;
static const int _defaultMaxChannels = 8;
@@ -798,9 +797,6 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceDisplayName = _deviceDisplayName;
_manualDisconnect = false;
_cancelReconnectTimer();
- if (PlatformInfo.isWeb) {
- _resetConnectionHandshakeState();
- }
unawaited(_backgroundService?.start());
notifyListeners();
@@ -824,37 +820,15 @@ class MeshCoreConnector extends ChangeNotifier {
rethrow;
}
- // Request larger MTU only on native platforms; web does not support it.
- if (!PlatformInfo.isWeb) {
- try {
- final mtu = await device.requestMtu(185);
- debugPrint('MTU set to: $mtu');
- } catch (e) {
- debugPrint('MTU request failed: $e, using default');
- }
+ // Request larger MTU for sending larger frames
+ try {
+ final mtu = await device.requestMtu(185);
+ debugPrint('MTU set to: $mtu');
+ } catch (e) {
+ debugPrint('MTU request failed: $e, using default');
}
- late final List services;
- try {
- services = await device.discoverServices();
- } catch (error) {
- debugPrint('[BLE Connect] service discovery failure: $error');
- if (PlatformInfo.isWeb &&
- error.toString().contains('GATT Server is disconnected')) {
- debugPrint(
- '[BLE Connect] retrying service discovery after transient web disconnect',
- );
- await Future.delayed(const Duration(milliseconds: 300));
- await device.connect(
- timeout: const Duration(seconds: 15),
- mtu: null,
- license: License.free,
- );
- services = await device.discoverServices();
- } else {
- rethrow;
- }
- }
+ List services = await device.discoverServices();
BluetoothService? uartService;
for (var service in services) {
@@ -881,32 +855,18 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- if (PlatformInfo.isWeb) {
- debugPrint('Starting setNotifyValue(true)');
- debugPrint('Web: Calling setNotifyValue(true) without awaiting');
- unawaited(() async {
- try {
- await _txCharacteristic!.setNotifyValue(true);
- } catch (error) {
- debugPrint('[BLE Connect] notify failure (web, ignored): $error');
- debugPrint('Web setNotifyValue error (ignoring): $error');
- }
- }());
- debugPrint('setNotifyValue(true) configuration completed');
- } else {
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
- }
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('[BLE Connect] notify failure: $e');
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
+ // Retry setNotifyValue with increasing delays
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
}
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
@@ -921,27 +881,19 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo();
_startBatteryPolling();
- if (PlatformInfo.isWeb &&
- _activeTransport == MeshCoreTransportType.bluetooth) {
- // Chrome's Web Bluetooth stack commonly delays incoming notifications
- // until the non-blocking notify setup settles. Avoid stacking extra
- // startup writes while that is happening.
- } else {
- final gotSelfInfo = await _waitForSelfInfo(
- timeout: const Duration(seconds: 3),
- );
- if (!gotSelfInfo) {
- await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
- }
-
- unawaited(syncTime());
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
}
+ // Keep device clock aligned on every connection.
+ await syncTime();
+
// Fetch channels so we can track unread counts for incoming messages
- if (!_shouldGateInitialChannelSync) {
- unawaited(getChannels());
- }
+ unawaited(getChannels());
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -1060,7 +1012,6 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
- _pendingInitialContactsSync = false;
}
bool get _shouldAutoReconnect =>
@@ -1069,9 +1020,7 @@ class MeshCoreConnector extends ChangeNotifier {
_activeTransport == MeshCoreTransportType.bluetooth;
bool get _shouldGateInitialChannelSync =>
- _activeTransport == MeshCoreTransportType.usb ||
- (_activeTransport == MeshCoreTransportType.bluetooth &&
- PlatformInfo.isWeb);
+ _activeTransport == MeshCoreTransportType.usb;
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@@ -1172,7 +1121,6 @@ class MeshCoreConnector extends ChangeNotifier {
_awaitingSelfInfo = false;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
- _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -1278,28 +1226,6 @@ class MeshCoreConnector extends ChangeNotifier {
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
- if (PlatformInfo.isWeb &&
- _activeTransport == MeshCoreTransportType.bluetooth) {
- var attempts = 0;
- const maxAttempts = 3;
- _selfInfoRetryTimer = Timer.periodic(const Duration(seconds: 10), (
- timer,
- ) {
- if (!isConnected || !_awaitingSelfInfo) {
- timer.cancel();
- return;
- }
- if (_isLoadingContacts || _isSyncingChannels || _channelSyncInFlight) {
- return;
- }
- attempts += 1;
- unawaited(sendFrame(buildAppStartFrame()));
- if (attempts >= maxAttempts) {
- timer.cancel();
- }
- });
- return;
- }
_selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
timer,
) {
@@ -2083,12 +2009,6 @@ class MeshCoreConnector extends ChangeNotifier {
_preserveContactsOnRefresh = false;
notifyListeners();
unawaited(_persistContacts());
- if (PlatformInfo.isWeb &&
- _activeTransport == MeshCoreTransportType.bluetooth &&
- _isSyncingChannels &&
- !_channelSyncInFlight) {
- unawaited(_requestNextChannel());
- }
if (!_didInitialQueueSync || _pendingQueueSync) {
_didInitialQueueSync = true;
_pendingQueueSync = false;
@@ -2236,14 +2156,7 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
notifyListeners();
- // Auto-fetch contacts after getting self info. On web BLE, defer this
- // until after channel 0 so startup writes stay serialized.
- if (PlatformInfo.isWeb &&
- _activeTransport == MeshCoreTransportType.bluetooth) {
- _pendingInitialContactsSync = true;
- } else {
- getContacts();
- }
+ getContacts();
if (_shouldGateInitialChannelSync) {
_maybeStartInitialChannelSync();
}
@@ -3196,14 +3109,6 @@ class MeshCoreConnector extends ChangeNotifier {
// Move to next channel
_nextChannelIndexToRequest++;
- if (PlatformInfo.isWeb &&
- _activeTransport == MeshCoreTransportType.bluetooth &&
- channel.index == 0 &&
- _pendingInitialContactsSync) {
- _pendingInitialContactsSync = false;
- unawaited(getContacts());
- return;
- }
unawaited(_requestNextChannel());
return;
} else {
@@ -3867,7 +3772,6 @@ class MeshCoreConnector extends ChangeNotifier {
// They're only cleared on manual disconnect via disconnect() method
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
- _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
From c2f544eebaac9e9f0d3a6cb94b60b8ea2950c76a Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:11:35 -0500
Subject: [PATCH 25/53] I restored the Web BLE behavior in
[meshcore_connector.dart] to the earlier Windows/Chrome-working state aligned
with the logic that was present around commit
`fcef3de57837983a300634aa3e0a77622e945cc2`, What is back: - Web BLE resets
handshake state before connect - skips `requestMtu()` on web - retries
`discoverServices()` once on the transient web disconnect case - uses the
non-blocking web `setNotifyValue(true)` workaround again - skips the
immediate `SELF_INFO` wait/refresh stack on web BLE - defers contact loading
on web BLE until after channel `0` - uses the Web-specific bounded
`SELF_INFO` retry timer - re-enables initial channel-sync gating for web BLE
---
lib/connector/meshcore_connector.dart | 156 +++++++++++++++++++++-----
1 file changed, 126 insertions(+), 30 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index c5bb09f..b3aaeab 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -165,6 +165,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false;
bool _pendingInitialChannelSync = false;
+ bool _pendingInitialContactsSync = false;
bool _preserveContactsOnRefresh = false;
static const int _defaultMaxContacts = 32;
static const int _defaultMaxChannels = 8;
@@ -797,6 +798,9 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceDisplayName = _deviceDisplayName;
_manualDisconnect = false;
_cancelReconnectTimer();
+ if (PlatformInfo.isWeb) {
+ _resetConnectionHandshakeState();
+ }
unawaited(_backgroundService?.start());
notifyListeners();
@@ -820,15 +824,37 @@ class MeshCoreConnector extends ChangeNotifier {
rethrow;
}
- // Request larger MTU for sending larger frames
- try {
- final mtu = await device.requestMtu(185);
- debugPrint('MTU set to: $mtu');
- } catch (e) {
- debugPrint('MTU request failed: $e, using default');
+ // Request larger MTU only on native platforms; web does not support it.
+ if (!PlatformInfo.isWeb) {
+ try {
+ final mtu = await device.requestMtu(185);
+ debugPrint('MTU set to: $mtu');
+ } catch (e) {
+ debugPrint('MTU request failed: $e, using default');
+ }
}
- List services = await device.discoverServices();
+ late final List services;
+ try {
+ services = await device.discoverServices();
+ } catch (error) {
+ debugPrint('[BLE Connect] service discovery failure: $error');
+ if (PlatformInfo.isWeb &&
+ error.toString().contains('GATT Server is disconnected')) {
+ debugPrint(
+ '[BLE Connect] retrying service discovery after transient web disconnect',
+ );
+ await Future.delayed(const Duration(milliseconds: 300));
+ await device.connect(
+ timeout: const Duration(seconds: 15),
+ mtu: null,
+ license: License.free,
+ );
+ services = await device.discoverServices();
+ } else {
+ rethrow;
+ }
+ }
BluetoothService? uartService;
for (var service in services) {
@@ -855,18 +881,32 @@ class MeshCoreConnector extends ChangeNotifier {
throw Exception("MeshCore characteristics not found");
}
- // Retry setNotifyValue with increasing delays
- bool notifySet = false;
- for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
- try {
- if (attempt > 0) {
- await Future.delayed(Duration(milliseconds: 500 * attempt));
+ if (PlatformInfo.isWeb) {
+ debugPrint('Starting setNotifyValue(true)');
+ debugPrint('Web: Calling setNotifyValue(true) without awaiting');
+ unawaited(() async {
+ try {
+ await _txCharacteristic!.setNotifyValue(true);
+ } catch (error) {
+ debugPrint('[BLE Connect] notify failure (web, ignored): $error');
+ debugPrint('Web setNotifyValue error (ignoring): $error');
+ }
+ }());
+ debugPrint('setNotifyValue(true) configuration completed');
+ } else {
+ bool notifySet = false;
+ for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
+ try {
+ if (attempt > 0) {
+ await Future.delayed(Duration(milliseconds: 500 * attempt));
+ }
+ await _txCharacteristic!.setNotifyValue(true);
+ notifySet = true;
+ } catch (e) {
+ debugPrint('[BLE Connect] notify failure: $e');
+ debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ if (attempt == 2) rethrow;
}
- await _txCharacteristic!.setNotifyValue(true);
- notifySet = true;
- } catch (e) {
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
- if (attempt == 2) rethrow;
}
}
_notifySubscription = _txCharacteristic!.onValueReceived.listen(
@@ -881,19 +921,27 @@ class MeshCoreConnector extends ChangeNotifier {
await _requestDeviceInfo();
_startBatteryPolling();
- final gotSelfInfo = await _waitForSelfInfo(
- timeout: const Duration(seconds: 3),
- );
- if (!gotSelfInfo) {
- await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ // Chrome's Web Bluetooth stack commonly delays incoming notifications
+ // until the non-blocking notify setup settles. Avoid stacking extra
+ // startup writes while that is happening.
+ } else {
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ }
+
+ unawaited(syncTime());
}
- // Keep device clock aligned on every connection.
- await syncTime();
-
// Fetch channels so we can track unread counts for incoming messages
- unawaited(getChannels());
+ if (!_shouldGateInitialChannelSync) {
+ unawaited(getChannels());
+ }
} catch (e) {
debugPrint("Connection error: $e");
await disconnect(manual: false);
@@ -1012,6 +1060,7 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
}
bool get _shouldAutoReconnect =>
@@ -1020,7 +1069,9 @@ class MeshCoreConnector extends ChangeNotifier {
_activeTransport == MeshCoreTransportType.bluetooth;
bool get _shouldGateInitialChannelSync =>
- _activeTransport == MeshCoreTransportType.usb;
+ _activeTransport == MeshCoreTransportType.usb ||
+ (_activeTransport == MeshCoreTransportType.bluetooth &&
+ PlatformInfo.isWeb);
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@@ -1121,6 +1172,7 @@ class MeshCoreConnector extends ChangeNotifier {
_awaitingSelfInfo = false;
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -1226,6 +1278,28 @@ class MeshCoreConnector extends ChangeNotifier {
void _scheduleSelfInfoRetry() {
_selfInfoRetryTimer?.cancel();
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ var attempts = 0;
+ const maxAttempts = 3;
+ _selfInfoRetryTimer = Timer.periodic(const Duration(seconds: 10), (
+ timer,
+ ) {
+ if (!isConnected || !_awaitingSelfInfo) {
+ timer.cancel();
+ return;
+ }
+ if (_isLoadingContacts || _isSyncingChannels || _channelSyncInFlight) {
+ return;
+ }
+ attempts += 1;
+ unawaited(sendFrame(buildAppStartFrame()));
+ if (attempts >= maxAttempts) {
+ timer.cancel();
+ }
+ });
+ return;
+ }
_selfInfoRetryTimer = Timer.periodic(const Duration(milliseconds: 3500), (
timer,
) {
@@ -2009,6 +2083,12 @@ class MeshCoreConnector extends ChangeNotifier {
_preserveContactsOnRefresh = false;
notifyListeners();
unawaited(_persistContacts());
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _isSyncingChannels &&
+ !_channelSyncInFlight) {
+ unawaited(_requestNextChannel());
+ }
if (!_didInitialQueueSync || _pendingQueueSync) {
_didInitialQueueSync = true;
_pendingQueueSync = false;
@@ -2156,7 +2236,14 @@ class MeshCoreConnector extends ChangeNotifier {
_selfInfoRetryTimer = null;
notifyListeners();
- getContacts();
+ // Auto-fetch contacts after getting self info. On web BLE, defer this
+ // until after channel 0 so startup writes stay serialized.
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth) {
+ _pendingInitialContactsSync = true;
+ } else {
+ getContacts();
+ }
if (_shouldGateInitialChannelSync) {
_maybeStartInitialChannelSync();
}
@@ -3109,6 +3196,14 @@ class MeshCoreConnector extends ChangeNotifier {
// Move to next channel
_nextChannelIndexToRequest++;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ channel.index == 0 &&
+ _pendingInitialContactsSync) {
+ _pendingInitialContactsSync = false;
+ unawaited(getContacts());
+ return;
+ }
unawaited(_requestNextChannel());
return;
} else {
@@ -3772,6 +3867,7 @@ class MeshCoreConnector extends ChangeNotifier {
// They're only cleared on manual disconnect via disconnect() method
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
From 4c7ee3b3b0300d8200019f8930df15a1ce3634d0 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:54:12 -0500
Subject: [PATCH 26/53] Enhance USB serial services with debug logging and
reset functionality
- Introduced debug logging in USB serial services for better traceability.
- Added reset method to UsbSerialFrameDecoder to clear buffered data.
- Updated tests to verify the reset functionality of the decoder.
---
lib/connector/meshcore_connector.dart | 113 ++++++++++++++----
lib/services/usb_serial_frame_codec.dart | 5 +
lib/services/usb_serial_service_native.dart | 24 +++-
lib/services/usb_serial_service_web.dart | 23 +++-
.../services/usb_serial_frame_codec_test.dart | 18 +++
5 files changed, 153 insertions(+), 30 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index b3aaeab..a589849 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -350,11 +350,38 @@ class MeshCoreConnector extends ChangeNotifier {
? allMessages.sublist(allMessages.length - _messageWindowSize)
: allMessages;
- _conversations[contactKeyHex] = windowedMessages;
+ final currentMessages =
+ _conversations[contactKeyHex] ?? const [];
+ final mergedMessages = [...windowedMessages];
+ final existingKeys = {
+ for (final message in windowedMessages) _messageMergeKey(message),
+ };
+
+ for (final message in currentMessages) {
+ final key = _messageMergeKey(message);
+ if (existingKeys.add(key)) {
+ mergedMessages.add(message);
+ }
+ }
+
+ mergedMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
+ final windowedMergedMessages = mergedMessages.length > _messageWindowSize
+ ? mergedMessages.sublist(mergedMessages.length - _messageWindowSize)
+ : mergedMessages;
+
+ _conversations[contactKeyHex] = windowedMergedMessages;
notifyListeners();
}
}
+ String _messageMergeKey(Message message) {
+ final messageId = message.messageId;
+ if (messageId != null && messageId.isNotEmpty) {
+ return 'id:$messageId';
+ }
+ return 'fallback:${message.isOutgoing}:${message.timestamp.millisecondsSinceEpoch}:${message.text}';
+ }
+
/// Load older messages for a contact (pagination)
Future> loadOlderMessages(
String contactKeyHex, {
@@ -590,6 +617,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
+ _usbSerialService.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@@ -749,7 +777,7 @@ class MeshCoreConnector extends ChangeNotifier {
androidScanMode: AndroidScanMode.lowLatency,
);
} catch (error) {
- debugPrint('[BLE Scan] Scan/picker failure: $error');
+ _appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
_setState(MeshCoreConnectionState.disconnected);
rethrow;
}
@@ -806,7 +834,10 @@ class MeshCoreConnector extends ChangeNotifier {
try {
final connectLabel = _deviceDisplayName ?? _deviceId;
- debugPrint('[BLE Connect] Starting connect to $connectLabel');
+ _appDebugLogService?.info(
+ 'Starting connect to $connectLabel',
+ tag: 'BLE Connect',
+ );
_connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
@@ -820,7 +851,10 @@ class MeshCoreConnector extends ChangeNotifier {
license: License.free,
);
} catch (error) {
- debugPrint('[BLE Connect] device.connect() failure: $error');
+ _appDebugLogService?.error(
+ 'device.connect() failure: $error',
+ tag: 'BLE Connect',
+ );
rethrow;
}
@@ -828,9 +862,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (!PlatformInfo.isWeb) {
try {
final mtu = await device.requestMtu(185);
- debugPrint('MTU set to: $mtu');
+ _appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
} catch (e) {
- debugPrint('MTU request failed: $e, using default');
+ _appDebugLogService?.warn(
+ 'MTU request failed: $e, using default',
+ tag: 'BLE Connect',
+ );
}
}
@@ -838,11 +875,15 @@ class MeshCoreConnector extends ChangeNotifier {
try {
services = await device.discoverServices();
} catch (error) {
- debugPrint('[BLE Connect] service discovery failure: $error');
+ _appDebugLogService?.error(
+ 'service discovery failure: $error',
+ tag: 'BLE Connect',
+ );
if (PlatformInfo.isWeb &&
error.toString().contains('GATT Server is disconnected')) {
- debugPrint(
- '[BLE Connect] retrying service discovery after transient web disconnect',
+ _appDebugLogService?.warn(
+ 'retrying service discovery after transient web disconnect',
+ tag: 'BLE Connect',
);
await Future.delayed(const Duration(milliseconds: 300));
await device.connect(
@@ -882,17 +923,32 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (PlatformInfo.isWeb) {
- debugPrint('Starting setNotifyValue(true)');
- debugPrint('Web: Calling setNotifyValue(true) without awaiting');
+ _appDebugLogService?.info(
+ 'Starting setNotifyValue(true)',
+ tag: 'BLE Connect',
+ );
+ _appDebugLogService?.info(
+ 'Web: Calling setNotifyValue(true) without awaiting',
+ tag: 'BLE Connect',
+ );
unawaited(() async {
try {
await _txCharacteristic!.setNotifyValue(true);
} catch (error) {
- debugPrint('[BLE Connect] notify failure (web, ignored): $error');
- debugPrint('Web setNotifyValue error (ignoring): $error');
+ _appDebugLogService?.warn(
+ 'notify failure (web, ignored): $error',
+ tag: 'BLE Connect',
+ );
+ _appDebugLogService?.warn(
+ 'Web setNotifyValue error (ignoring): $error',
+ tag: 'BLE Connect',
+ );
}
}());
- debugPrint('setNotifyValue(true) configuration completed');
+ _appDebugLogService?.info(
+ 'setNotifyValue(true) configuration completed',
+ tag: 'BLE Connect',
+ );
} else {
bool notifySet = false;
for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
@@ -903,8 +959,11 @@ class MeshCoreConnector extends ChangeNotifier {
await _txCharacteristic!.setNotifyValue(true);
notifySet = true;
} catch (e) {
- debugPrint('[BLE Connect] notify failure: $e');
- debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
+ _appDebugLogService?.warn('notify failure: $e', tag: 'BLE Connect');
+ _appDebugLogService?.warn(
+ 'setNotifyValue attempt ${attempt + 1}/3 failed: $e',
+ tag: 'BLE Connect',
+ );
if (attempt == 2) rethrow;
}
}
@@ -925,7 +984,19 @@ class MeshCoreConnector extends ChangeNotifier {
_activeTransport == MeshCoreTransportType.bluetooth) {
// Chrome's Web Bluetooth stack commonly delays incoming notifications
// until the non-blocking notify setup settles. Avoid stacking extra
- // startup writes while that is happening.
+ // startup writes while that is happening. Defer the clock sync until
+ // the connection has had time to settle.
+ unawaited(
+ Future(() async {
+ await Future.delayed(const Duration(seconds: 5));
+ if (!isConnected ||
+ !PlatformInfo.isWeb ||
+ _activeTransport != MeshCoreTransportType.bluetooth) {
+ return;
+ }
+ await syncTime();
+ }),
+ );
} else {
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@@ -943,7 +1014,7 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(getChannels());
}
} catch (e) {
- debugPrint("Connection error: $e");
+ _appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
await disconnect(manual: false);
rethrow;
}
@@ -986,7 +1057,7 @@ class MeshCoreConnector extends ChangeNotifier {
_usbFrameSubscription = _usbSerialService.frameStream.listen(
_handleFrame,
onError: (error, stackTrace) {
- debugPrint('USB transport error: $error');
+ _appDebugLogService?.error('USB transport error: $error', tag: 'USB');
unawaited(disconnect(manual: false));
},
onDone: () {
@@ -1013,7 +1084,7 @@ class MeshCoreConnector extends ChangeNotifier {
await syncTime();
} catch (error) {
- debugPrint('USB connection error: $error');
+ _appDebugLogService?.error('USB connection error: $error', tag: 'USB');
await disconnect(manual: false);
rethrow;
}
@@ -1149,7 +1220,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
await _device?.disconnect(queue: false);
} catch (e) {
- debugPrint("Disconnect error: $e");
+ _appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
}
_device = null;
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
index ebe1733..59e4d4b 100644
--- a/lib/services/usb_serial_frame_codec.dart
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -37,6 +37,11 @@ class UsbSerialFrameDecoder {
final List _rxBuffer = [];
int _startIndex = 0;
+ void reset() {
+ _rxBuffer.clear();
+ _startIndex = 0;
+ }
+
List ingest(Uint8List bytes) {
if (bytes.isEmpty) {
return const [];
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
index f6b879b..d79205b 100644
--- a/lib/services/usb_serial_service_native.dart
+++ b/lib/services/usb_serial_service_native.dart
@@ -5,6 +5,7 @@ import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
+import 'app_debug_log_service.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@@ -29,6 +30,7 @@ class UsbSerialService {
String? _connectedPortKey;
String? _connectedPortLabel;
FlSerial? _serial;
+ AppDebugLogService? _debugLogService;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
@@ -66,6 +68,10 @@ class UsbSerialService {
return Future.value(FlSerial.listPorts());
}
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLogService = service;
+ }
+
Future connect({
required String portName,
int baudRate = 115200,
@@ -80,6 +86,7 @@ class UsbSerialService {
_status = UsbSerialStatus.connecting;
final normalizedPortName = normalizeUsbPortName(portName);
+ _frameDecoder.reset();
if (_useAndroidUsbHost) {
try {
@@ -87,8 +94,9 @@ class UsbSerialService {
'portName': normalizedPortName,
'baudRate': baudRate,
});
- debugPrint(
+ _debugLogService?.info(
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
+ tag: 'USB Serial',
);
} on PlatformException catch (error) {
_status = UsbSerialStatus.disconnected;
@@ -111,8 +119,9 @@ class UsbSerialService {
serial.setFlowControlNone();
serial.setRTS(false);
serial.setDTR(true);
- debugPrint(
+ _debugLogService?.info(
'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
+ tag: 'USB Serial',
);
} on FlSerialException catch (error) {
_serial?.free();
@@ -174,6 +183,7 @@ class UsbSerialService {
_status = UsbSerialStatus.disconnecting;
_connectedPortKey = null;
_connectedPortLabel = null;
+ _frameDecoder.reset();
await _androidDataSubscription?.cancel();
_androidDataSubscription = null;
await _dataSubscription?.cancel();
@@ -255,8 +265,9 @@ class UsbSerialService {
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
- debugPrint(
+ _debugLogService?.info(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ tag: 'USB Serial',
);
continue;
}
@@ -287,10 +298,13 @@ class UsbSerialService {
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
- debugPrint('$prefix len=0');
+ _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
return;
}
- debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
+ _debugLogService?.info(
+ '$prefix code=${bytes[0]} len=${bytes.length}',
+ tag: 'USB Serial',
+ );
}
}
diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart
index 87fe8e9..974928e 100644
--- a/lib/services/usb_serial_service_web.dart
+++ b/lib/services/usb_serial_service_web.dart
@@ -5,6 +5,7 @@ import 'dart:js_interop_unsafe';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
+import 'app_debug_log_service.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@@ -27,6 +28,7 @@ class UsbSerialService {
String? _connectedPortName;
String? _connectedPortKey;
String _requestPortLabel = 'Choose USB Device';
+ AppDebugLogService? _debugLogService;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
@@ -69,6 +71,7 @@ class UsbSerialService {
}
_status = UsbSerialStatus.connecting;
+ _frameDecoder.reset();
try {
final requestedPortName = normalizeUsbPortName(portName);
@@ -88,7 +91,10 @@ class UsbSerialService {
_status = UsbSerialStatus.connected;
unawaited(_pumpReads());
- debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
+ _debugLogService?.info(
+ 'USB serial opened port=$_connectedPortName via Web Serial',
+ tag: 'USB Serial',
+ );
} catch (error) {
await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected;
@@ -126,6 +132,7 @@ class UsbSerialService {
_port = null;
_connectedPortName = null;
_connectedPortKey = null;
+ _frameDecoder.reset();
if (reader != null) {
try {
@@ -169,6 +176,10 @@ class UsbSerialService {
_requestPortLabel = trimmed;
}
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLogService = service;
+ }
+
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
@@ -407,8 +418,9 @@ class UsbSerialService {
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
- debugPrint(
+ _debugLogService?.info(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ tag: 'USB Serial',
);
continue;
}
@@ -439,10 +451,13 @@ class UsbSerialService {
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
- debugPrint('$prefix len=0');
+ _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
return;
}
- debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
+ _debugLogService?.info(
+ '$prefix code=${bytes[0]} len=${bytes.length}',
+ tag: 'USB Serial',
+ );
}
}
diff --git a/test/services/usb_serial_frame_codec_test.dart b/test/services/usb_serial_frame_codec_test.dart
index be4497e..54165de 100644
--- a/test/services/usb_serial_frame_codec_test.dart
+++ b/test/services/usb_serial_frame_codec_test.dart
@@ -120,4 +120,22 @@ void main() {
expect(packets.single.payload, orderedEquals([0x44]));
},
);
+
+ test('UsbSerialFrameDecoder reset clears buffered partial data', () {
+ final decoder = UsbSerialFrameDecoder();
+
+ expect(
+ decoder.ingest(Uint8List.fromList([usbSerialRxFrameStart, 0x02])),
+ isEmpty,
+ );
+
+ decoder.reset();
+
+ final packets = decoder.ingest(
+ Uint8List.fromList([usbSerialRxFrameStart, 0x01, 0x00, 0x55]),
+ );
+
+ expect(packets, hasLength(1));
+ expect(packets.single.payload, orderedEquals([0x55]));
+ });
}
From f5154b003368bec7b35d12267505625456277782 Mon Sep 17 00:00:00 2001
From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com>
Date: Mon, 2 Mar 2026 19:33:09 -0500
Subject: [PATCH 27/53] Improve sender name resolution for room server messages
by handling missing room-contact keys
---
lib/connector/meshcore_connector.dart | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index a589849..3f69d0a 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -3506,14 +3506,17 @@ class MeshCoreConnector extends ChangeNotifier {
// For 1:1 chats, sender is implicit (null)
String? senderName;
if (isRoomServer && !msg.isOutgoing) {
- // Resolve sender from the message's fourByteRoomContactKey
- final senderContact = _contacts.cast().firstWhere(
- (c) =>
- c != null &&
- _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
- orElse: () => null,
- );
- senderName = senderContact?.name;
+ // Treat a missing room-contact key as unknown instead of matching every
+ // contact via an empty prefix.
+ if (msg.fourByteRoomContactKey.length == 4) {
+ final senderContact = _contacts.cast().firstWhere(
+ (c) =>
+ c != null &&
+ _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
+ orElse: () => null,
+ );
+ senderName = senderContact?.name;
+ }
} else if (isRoomServer && msg.isOutgoing) {
senderName = selfName;
}
From e6c9a3fea7802c57810747d1453f59267069dea4 Mon Sep 17 00:00:00 2001
From: Ben Allfree
Date: Mon, 2 Mar 2026 19:21:06 -0800
Subject: [PATCH 28/53] wip
---
lib/connector/meshcore_connector.dart | 24 +-
lib/screens/usb_screen.dart | 259 ++++++++++++--------
lib/services/usb_serial_service_native.dart | 248 +++++++++++++++----
lib/utils/macos_usb_device_names.dart | 92 +++++++
lib/utils/platform_info.dart | 3 +-
lib/utils/usb_port_labels.dart | 21 +-
macos/Podfile.lock | 6 +
macos/Runner/DebugProfile.entitlements | 6 +
macos/Runner/Release.entitlements | 6 +
test/utils/usb_port_labels_test.dart | 48 +++-
10 files changed, 533 insertions(+), 180 deletions(-)
create mode 100644 lib/utils/macos_usb_device_names.dart
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 3f69d0a..ecedb4e 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -737,8 +737,15 @@ class MeshCoreConnector extends ChangeNotifier {
_scanResults.clear();
_setState(MeshCoreConnectionState.scanning);
- // Ensure any previous scan is fully stopped
- await FlutterBluePlus.stopScan();
+ // Ensure any previous scan is fully stopped. Guard with isScanningNow to
+ // avoid triggering stale native callbacks when no scan is active.
+ if (FlutterBluePlus.isScanningNow) {
+ try {
+ await FlutterBluePlus.stopScan();
+ } catch (e) {
+ debugPrint('[FBP] stopScan error in startScan (ignored): $e');
+ }
+ }
await _scanSubscription?.cancel();
// On iOS/macOS, wait for Bluetooth to be powered on before scanning
@@ -787,7 +794,18 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future stopScan() async {
- await FlutterBluePlus.stopScan();
+ // Only call FlutterBluePlus.stopScan() when a scan is actually running.
+ // Calling it when idle triggers a native BLE completion callback even
+ // though no scan was started. After a hot restart Dart has already freed
+ // those callback handles, so the callback crashes with
+ // "Callback invoked after it has been deleted".
+ if (FlutterBluePlus.isScanningNow) {
+ try {
+ await FlutterBluePlus.stopScan();
+ } catch (e) {
+ debugPrint('[FBP] stopScan error (ignored): $e');
+ }
+ }
await _scanSubscription?.cancel();
_scanSubscription = null;
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
index 69e95c4..03d9446 100644
--- a/lib/screens/usb_screen.dart
+++ b/lib/screens/usb_screen.dart
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
+import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import 'contacts_screen.dart';
@@ -25,9 +26,16 @@ class _UsbScreenState extends State {
String? _selectedPort;
String? _connectedPortDisplayLabel;
String? _errorText;
+ Timer? _hotPlugTimer;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
+ /// Whether the current platform supports dynamic hot-plug polling.
+ /// On desktop (macOS, Windows, Linux) we poll continuously so the user
+ /// never needs to hit Refresh manually.
+ bool get _supportsHotPlug =>
+ PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
+
@override
void initState() {
super.initState();
@@ -58,6 +66,7 @@ class _UsbScreenState extends State {
}
};
_connector.addListener(_connectionListener);
+ _startHotPlugTimer();
}
@override
@@ -72,6 +81,8 @@ class _UsbScreenState extends State {
@override
void dispose() {
+ _hotPlugTimer?.cancel();
+ _hotPlugTimer = null;
_connector.removeListener(_connectionListener);
if (!_navigatedToContacts &&
_connector.activeTransport == MeshCoreTransportType.usb &&
@@ -124,101 +135,77 @@ class _UsbScreenState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Flexible(
- flex: 3,
- child: Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.usb,
- size: iconSize,
- color: theme.colorScheme.primary,
- ),
- SizedBox(height: gap),
- Flexible(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- l10n.usbScreenTitle,
- textAlign: TextAlign.center,
- style: theme.textTheme.headlineSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- SizedBox(height: math.max(4.0, gap * 0.5)),
- Flexible(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- l10n.usbScreenSubtitle,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ),
- ),
- SizedBox(height: gap),
- FittedBox(
- fit: BoxFit.scaleDown,
- child: Chip(
- label: Text(
- _connectedPortDisplayLabel != null &&
- _connectedPortDisplayLabel!.isNotEmpty
- ? _friendlyPortName(
- _connectedPortDisplayLabel!,
- )
- : _selectedPort == null
- ? l10n.usbScreenStatus
- : _friendlyPortName(_selectedPort!),
- overflow: TextOverflow.ellipsis,
- ),
- backgroundColor:
- theme.colorScheme.surfaceContainerHighest,
- ),
- ),
- ],
+ // ── Compact header ──────────────────────────────────────
+ Row(
+ children: [
+ Icon(
+ Icons.usb,
+ size: iconSize.clamp(24.0, 40.0),
+ color: theme.colorScheme.primary,
),
- ),
+ SizedBox(width: gap),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ l10n.usbScreenTitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Text(
+ l10n.usbScreenSubtitle,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
),
SizedBox(height: gap),
+ // ── Port list takes all remaining space ─────────────────
Expanded(child: _buildPortList(context)),
if (_errorText != null) ...[
- SizedBox(height: gap),
- Flexible(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- _errorText!,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.error,
- ),
- ),
+ SizedBox(height: gap * 0.5),
+ Text(
+ _errorText!,
+ textAlign: TextAlign.center,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.error,
),
),
],
SizedBox(height: gap),
+ // ── Action buttons ──────────────────────────────────────
if (isNarrow)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- OutlinedButton.icon(
- onPressed: _isLoadingPorts || _isConnecting
- ? null
- : () {
- debugPrint(
- 'UsbScreen: refresh ports pressed',
- );
- _loadPorts();
- },
- icon: const Icon(Icons.refresh),
- label: Text(l10n.repeater_refresh),
- ),
- SizedBox(height: gap),
+ if (!_supportsHotPlug) ...[
+ OutlinedButton.icon(
+ onPressed: _isLoadingPorts || _isConnecting
+ ? null
+ : () {
+ debugPrint(
+ 'UsbScreen: refresh ports pressed',
+ );
+ _loadPorts();
+ },
+ icon: const Icon(Icons.refresh),
+ label: Text(l10n.repeater_refresh),
+ ),
+ SizedBox(height: gap),
+ ],
FilledButton.icon(
onPressed: _canConnect
? () {
@@ -247,21 +234,23 @@ class _UsbScreenState extends State {
else
Row(
children: [
- Expanded(
- child: OutlinedButton.icon(
- onPressed: _isLoadingPorts || _isConnecting
- ? null
- : () {
- debugPrint(
- 'UsbScreen: refresh ports pressed',
- );
- _loadPorts();
- },
- icon: const Icon(Icons.refresh),
- label: Text(l10n.repeater_refresh),
+ if (!_supportsHotPlug) ...[
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _isLoadingPorts || _isConnecting
+ ? null
+ : () {
+ debugPrint(
+ 'UsbScreen: refresh ports pressed',
+ );
+ _loadPorts();
+ },
+ icon: const Icon(Icons.refresh),
+ label: Text(l10n.repeater_refresh),
+ ),
),
- ),
- SizedBox(width: gap),
+ SizedBox(width: gap),
+ ],
Expanded(
child: FilledButton.icon(
onPressed: _canConnect
@@ -289,17 +278,14 @@ class _UsbScreenState extends State {
),
],
),
- SizedBox(height: math.max(4.0, gap * 0.75)),
- Flexible(
- child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- l10n.usbScreenNote,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
+ SizedBox(height: math.max(4.0, gap * 0.5)),
+ Text(
+ l10n.usbScreenNote,
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
),
),
],
@@ -317,6 +303,43 @@ class _UsbScreenState extends State {
_selectedPort != null &&
_selectedPort!.isNotEmpty;
+ void _startHotPlugTimer() {
+ if (!_supportsHotPlug) return;
+ _hotPlugTimer?.cancel();
+ _hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) {
+ _pollHotPlug();
+ });
+ }
+
+ Future _pollHotPlug() async {
+ // Don't interfere with an active connection attempt or initial load.
+ if (_isConnecting || _isLoadingPorts) return;
+ if (!mounted) return;
+ try {
+ final ports = await _connector.listUsbPorts();
+ if (!mounted) return;
+ final added = ports.where((p) => !_ports.contains(p)).toList();
+ final removed = _ports.where((p) => !ports.contains(p)).toList();
+ if (added.isEmpty && removed.isEmpty) return;
+ setState(() {
+ _ports
+ ..clear()
+ ..addAll(ports);
+ if (_ports.isEmpty) {
+ _selectedPort = null;
+ } else if (added.isNotEmpty) {
+ // Auto-select the newly-connected device.
+ _selectedPort = added.first;
+ } else if (_selectedPort != null && !_ports.contains(_selectedPort)) {
+ // Previously-selected device was unplugged.
+ _selectedPort = _ports.isNotEmpty ? _ports.first : null;
+ }
+ });
+ } catch (_) {
+ // Silent — hot-plug failures are non-critical.
+ }
+ }
+
Widget _buildPortList(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
@@ -464,14 +487,32 @@ class _UsbScreenState extends State {
try {
await _connector.connectUsb(portName: rawPortName);
- } catch (error) {
+ } catch (error, stackTrace) {
+ debugPrint(
+ 'UsbScreen: connect failed for $rawPortName: $error\n$stackTrace',
+ );
if (!mounted) return;
setState(() {
_isConnecting = false;
- _errorText = error.toString();
+ _errorText = _friendlyErrorMessage(error);
});
+ // Re-scan so stale or renamed port entries are cleared from the list.
+ unawaited(_loadPorts());
}
}
+ /// Strips the Dart runtime prefix (e.g. "Bad state: ", "Exception: ")
+ /// from error messages before showing them in the UI.
+ String _friendlyErrorMessage(Object error) {
+ var msg = error.toString();
+ // StateError surfaces as "Bad state: "
+ if (msg.startsWith('Bad state: ')) {
+ msg = msg.substring('Bad state: '.length);
+ } else if (msg.startsWith('Exception: ')) {
+ msg = msg.substring('Exception: '.length);
+ }
+ return msg;
+ }
+
String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
}
diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart
index d79205b..9f442a1 100644
--- a/lib/services/usb_serial_service_native.dart
+++ b/lib/services/usb_serial_service_native.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:flserial/flserial.dart';
import 'package:flserial/flserial_exception.dart';
@@ -6,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'app_debug_log_service.dart';
+import '../utils/macos_usb_device_names.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@@ -32,6 +34,14 @@ class UsbSerialService {
FlSerial? _serial;
AppDebugLogService? _debugLogService;
+ /// Holds the last-opened native serial port across hot-restart boundaries.
+ /// On hot restart the Dart isolate is torn down without running [disconnect],
+ /// leaving the native SerialThread alive. The next [connect] call reads this
+ /// field and force-closes the orphaned port before creating a new one, which
+ /// causes the old native thread to unblock its blocking read and exit
+ /// naturally—before any new Dart FFI callbacks are registered.
+ static FlSerial? _lastSerial;
+
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel =>
@@ -40,19 +50,24 @@ class UsbSerialService {
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get _useDesktopFlSerial =>
- PlatformInfo.isWindows || PlatformInfo.isLinux;
+ PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
- FlSerial get _nativeSerial => _serial ??= FlSerial();
+ // Always-fresh: do NOT use ??= here – a cached FlSerial retains stale
+ // native handle state (flh) from a prior failed open, causing subsequent
+ // open attempts to fail with "port not exist" even when the device is present.
+ FlSerial _freshSerial() => FlSerial();
bool get isConnected {
if (!_isSupportedPlatform) {
return false;
}
- if (_useAndroidUsbHost) {
- return _status == UsbSerialStatus.connected;
- }
- return _status == UsbSerialStatus.connected &&
- _serial?.isOpen() == FlOpenStatus.open;
+ // Trust _status as the authoritative connection state. Polling
+ // _serial?.isOpen() via the native FL_CTRL_IS_PORT_OPEN query is
+ // unreliable during the brief USB re-enumeration window that many
+ // microcontrollers (e.g. NRF52) trigger in response to DTR assertion.
+ // Actual port drops are handled by the onDone / onError callbacks on the
+ // serial data stream subscription, which update _status correctly.
+ return _status == UsbSerialStatus.connected;
}
Future> listPorts() async {
@@ -65,7 +80,34 @@ class UsbSerialService {
);
return ports ??