diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e69de29
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 @@
+
{
+ handleUsbDetached(intent)
+ return
+ }
+ usbPermissionAction -> Unit
+ else -> return
+ }
+
+ val result = pendingConnectResult
+ val portName = pendingConnectPortName
+ pendingConnectResult = null
+ pendingConnectPortName = null
+
+ if (result == null || portName == null) {
+ return
+ }
+
+ val device = findUsbDevice(portName)
+ if (device == null) {
+ result.error(
+ "usb_device_missing",
+ null,
+ null,
+ )
+ return
+ }
+
+ val granted =
+ intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
+ if (!granted || !usbManager.hasPermission(device)) {
+ result.error("usb_permission_denied", null, null)
+ return
+ }
+
+ openUsbDevice(device, pendingConnectBaudRate, result)
+ }
+ }
+
+ fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ registerUsbPermissionReceiver()
+
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "listPorts" -> result.success(listUsbPorts())
+ "connect" -> handleUsbConnect(call, result)
+ "write" -> handleUsbWrite(call, result)
+ "disconnect" -> {
+ scheduleCloseUsbConnection {
+ 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
+ }
+ },
+ )
+ }
+
+ fun dispose() {
+ closeUsbConnection()
+ usbIoExecutor.shutdownNow()
+ try {
+ activity.unregisterReceiver(permissionReceiver)
+ } catch (_: IllegalArgumentException) {
+ }
+ }
+
+ private fun registerUsbPermissionReceiver() {
+ val filter =
+ IntentFilter().apply {
+ addAction(usbPermissionAction)
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ @Suppress("DEPRECATION")
+ activity.registerReceiver(permissionReceiver, filter)
+ }
+ }
+
+ private fun listUsbPorts(): List {
+ return usbManager.deviceList.values.map { 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", null, null)
+ return
+ }
+
+ val device = findUsbDevice(portName)
+ if (device == null) {
+ result.error("usb_device_missing", null, null)
+ return
+ }
+
+ if (usbManager.hasPermission(device)) {
+ openUsbDevice(device, baudRate, result)
+ return
+ }
+
+ if (pendingConnectResult != null) {
+ result.error("usb_busy", null, null)
+ return
+ }
+
+ pendingConnectResult = result
+ pendingConnectPortName = portName
+ pendingConnectBaudRate = baudRate
+
+ val permissionIntent = PendingIntent.getBroadcast(
+ activity,
+ 0,
+ Intent(usbPermissionAction).setPackage(activity.packageName),
+ pendingIntentFlags(),
+ )
+ usbManager.requestPermission(device, permissionIntent)
+ }
+
+ private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
+ val data = call.argument("data")
+ val connection = usbConnection
+ val endpoint = usbOutEndpoint
+ if (data == null) {
+ result.error("usb_invalid_data", null, null)
+ return
+ }
+ if (connection == null || endpoint == null) {
+ result.error("usb_not_connected", null, null)
+ return
+ }
+
+ usbIoExecutor.execute {
+ try {
+ writeToDevice(data)
+ mainHandler.post { result.success(null) }
+ } catch (error: Exception) {
+ mainHandler.post {
+ result.error("usb_write_failed", error.message, null)
+ }
+ }
+ }
+ }
+
+ private fun findUsbDevice(portName: String): UsbDevice? {
+ val devices = usbManager.deviceList.values
+ val exactMatch = devices.firstOrNull { it.deviceName == portName }
+ if (exactMatch != null) {
+ return exactMatch
+ }
+
+ val normalizedName = portName.substringBefore(" - ").trim()
+ return devices.firstOrNull { it.deviceName == normalizedName }
+ }
+
+ private fun openUsbDevice(
+ device: UsbDevice,
+ baudRate: Int,
+ result: MethodChannel.Result,
+ ) {
+ usbIoExecutor.execute {
+ try {
+ closeUsbConnection()
+
+ val config = resolvePortConfig(device)
+ if (config == null) {
+ mainHandler.post {
+ result.error(
+ "usb_driver_missing",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ val connection = usbManager.openDevice(device)
+ if (connection == null) {
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ if (!connection.claimInterface(config.dataInterface, true)) {
+ connection.close()
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ if (config.controlInterface != null &&
+ config.controlInterface.id != config.dataInterface.id &&
+ !connection.claimInterface(config.controlInterface, true)
+ ) {
+ connection.releaseInterface(config.dataInterface)
+ connection.close()
+ mainHandler.post {
+ result.error(
+ "usb_open_failed",
+ null,
+ null,
+ )
+ }
+ return@execute
+ }
+
+ usbConnection = connection
+ usbInEndpoint = config.inEndpoint
+ usbOutEndpoint = config.outEndpoint
+ controlInterface = config.controlInterface
+ dataInterface = config.dataInterface
+
+ configureDevice(connection, config, baudRate)
+
+ connectedDeviceName = device.deviceName
+ startReadLoop()
+
+ mainHandler.post {
+ result.success(null)
+ }
+ } catch (error: Exception) {
+ closeUsbConnection()
+ mainHandler.post {
+ result.error("usb_connect_failed", error.message, null)
+ }
+ }
+ }
+ }
+
+ private fun resolvePortConfig(device: UsbDevice): PortConfig? {
+ var preferredDataInterface: UsbInterface? = null
+ var preferredInEndpoint: UsbEndpoint? = null
+ var preferredOutEndpoint: UsbEndpoint? = null
+ var fallbackDataInterface: UsbInterface? = null
+ var fallbackInEndpoint: UsbEndpoint? = null
+ var fallbackOutEndpoint: UsbEndpoint? = null
+ var preferredControlInterface: UsbInterface? = null
+
+ for (interfaceIndex in 0 until device.interfaceCount) {
+ val usbInterface = device.getInterface(interfaceIndex)
+ var inEndpoint: UsbEndpoint? = null
+ var outEndpoint: UsbEndpoint? = null
+
+ for (endpointIndex in 0 until usbInterface.endpointCount) {
+ val endpoint = usbInterface.getEndpoint(endpointIndex)
+ if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
+ continue
+ }
+ when (endpoint.direction) {
+ UsbConstants.USB_DIR_IN -> if (inEndpoint == null) inEndpoint = endpoint
+ UsbConstants.USB_DIR_OUT -> if (outEndpoint == null) outEndpoint = endpoint
+ }
+ }
+
+ val hasDataPair = inEndpoint != null && outEndpoint != null
+ when {
+ usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM &&
+ preferredControlInterface == null -> {
+ preferredControlInterface = usbInterface
+ }
+ hasDataPair &&
+ usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> {
+ preferredDataInterface = usbInterface
+ preferredInEndpoint = inEndpoint
+ preferredOutEndpoint = outEndpoint
+ }
+ hasDataPair && fallbackDataInterface == null -> {
+ fallbackDataInterface = usbInterface
+ fallbackInEndpoint = inEndpoint
+ fallbackOutEndpoint = outEndpoint
+ }
+ }
+ }
+
+ val dataInterface = preferredDataInterface ?: fallbackDataInterface ?: return null
+ val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null
+ val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null
+ return PortConfig(preferredControlInterface, dataInterface, inEndpoint, outEndpoint)
+ }
+
+ private fun configureDevice(
+ connection: UsbDeviceConnection,
+ config: PortConfig,
+ baudRate: Int,
+ ) {
+ val control = config.controlInterface ?: return
+ val lineCoding =
+ byteArrayOf(
+ (baudRate and 0xFF).toByte(),
+ ((baudRate shr 8) and 0xFF).toByte(),
+ ((baudRate shr 16) and 0xFF).toByte(),
+ ((baudRate shr 24) and 0xFF).toByte(),
+ 0, // stop bits: 1
+ 0, // parity: none
+ 8, // data bits
+ )
+
+ val lineCodingResult =
+ connection.controlTransfer(
+ UsbConstants.USB_DIR_OUT or
+ UsbConstants.USB_TYPE_CLASS or
+ usbRecipientInterface,
+ 0x20,
+ 0,
+ control.id,
+ lineCoding,
+ lineCoding.size,
+ 1000,
+ )
+ if (lineCodingResult < 0) {
+ throw IllegalStateException("Failed to configure USB line coding")
+ }
+
+ val controlLineResult =
+ connection.controlTransfer(
+ UsbConstants.USB_DIR_OUT or
+ UsbConstants.USB_TYPE_CLASS or
+ usbRecipientInterface,
+ 0x22,
+ 0x0001, // DTR on, RTS off
+ control.id,
+ null,
+ 0,
+ 1000,
+ )
+ if (controlLineResult < 0) {
+ throw IllegalStateException("Failed to configure USB control line state")
+ }
+ }
+
+ private fun startReadLoop() {
+ val connection = usbConnection ?: return
+ val endpoint = usbInEndpoint ?: return
+
+ isReading = true
+ readThread =
+ Thread({
+ val packetSize = endpoint.maxPacketSize.coerceAtLeast(64)
+ val buffer = ByteArray(packetSize * 4)
+ try {
+ while (isReading) {
+ val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
+ if (!isReading) {
+ break
+ }
+ if (bytesRead <= 0) {
+ continue
+ }
+ val packet = buffer.copyOf(bytesRead)
+ mainHandler.post {
+ eventSink?.success(packet)
+ }
+ }
+ } catch (error: Exception) {
+ if (isReading) {
+ mainHandler.post {
+ eventSink?.error(
+ "usb_io_error",
+ error.message ?: "USB serial I/O error",
+ null,
+ )
+ }
+ scheduleCloseUsbConnection()
+ }
+ }
+ }, "MeshCoreUsbRead").also { thread ->
+ thread.isDaemon = true
+ thread.start()
+ }
+ }
+
+ private fun writeToDevice(data: ByteArray) {
+ val connection = usbConnection ?: throw IllegalStateException("USB connection missing")
+ val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing")
+ var offset = 0
+ val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64)
+ while (offset < data.size) {
+ val chunkSize = minOf(maxPacketSize, data.size - offset)
+ val chunk = data.copyOfRange(offset, offset + chunkSize)
+ val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000)
+ if (bytesWritten != chunkSize) {
+ throw IllegalStateException("Short USB write: wrote $bytesWritten of $chunkSize bytes")
+ }
+ offset += chunkSize
+ }
+ }
+
+ private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
+ usbIoExecutor.execute {
+ closeUsbConnection()
+ if (onComplete != null) {
+ mainHandler.post(onComplete)
+ }
+ }
+ }
+
+ @Synchronized
+ private fun closeUsbConnection() {
+ isReading = false
+ readThread?.interrupt()
+ if (readThread != null && readThread !== Thread.currentThread()) {
+ try {
+ readThread?.join(300)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ readThread = null
+
+ val connection = usbConnection
+ val claimedControl = controlInterface
+ val claimedData = dataInterface
+
+ usbInEndpoint = null
+ usbOutEndpoint = null
+ controlInterface = null
+ dataInterface = null
+ usbConnection = null
+
+ if (connection != null) {
+ if (claimedControl != null) {
+ try {
+ connection.releaseInterface(claimedControl)
+ } catch (_: Exception) {
+ }
+ }
+ if (claimedData != null && claimedData.id != claimedControl?.id) {
+ try {
+ connection.releaseInterface(claimedData)
+ } catch (_: Exception) {
+ }
+ }
+ try {
+ connection.close()
+ } catch (_: Exception) {
+ }
+ }
+ 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) {
+ scheduleCloseUsbConnection {
+ eventSink?.error(
+ "usb_device_detached",
+ "USB device was disconnected",
+ 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/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 7aa0e5d..537312e 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -21,6 +21,7 @@ import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/notification_service.dart';
+import 'meshcore_connector_usb.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@@ -32,6 +33,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 {
@@ -84,6 +86,8 @@ enum MeshCoreConnectionState {
disconnecting,
}
+enum MeshCoreTransportType { bluetooth, usb }
+
class RepeaterBatterySnapshot {
final int millivolts;
final DateTime updatedAt;
@@ -110,6 +114,9 @@ class MeshCoreConnector extends ChangeNotifier {
String? _lastDeviceId;
String? _lastDeviceDisplayName;
bool _manualDisconnect = false;
+ final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
+ StreamSubscription? _usbFrameSubscription;
+ MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List _scanResults = [];
final List _contacts = [];
@@ -160,6 +167,12 @@ class MeshCoreConnector extends ChangeNotifier {
bool _hasLoadedChannels = false;
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
+ bool _hasReceivedDeviceInfo = false;
+ bool _pendingInitialChannelSync = false;
+ bool _pendingInitialContactsSync = false;
+ bool _bleInitialSyncStarted = false;
+ bool _pendingDeferredChannelSyncAfterContacts = false;
+ bool _webInitialHandshakeRequestSent = false;
bool _preserveContactsOnRefresh = false;
bool _autoAddUsers = false;
bool _autoAddRepeaters = false;
@@ -236,6 +249,13 @@ class MeshCoreConnector extends ChangeNotifier {
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
+ MeshCoreTransportType get activeTransport => _activeTransport;
+ String? get activeUsbPort => _usbManager.activePortKey;
+ String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
+ bool get isUsbTransportConnected =>
+ _state == MeshCoreConnectionState.connected &&
+ _activeTransport == MeshCoreTransportType.usb;
+
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
@@ -359,11 +379,50 @@ class MeshCoreConnector extends ChangeNotifier {
? allMessages.sublist(allMessages.length - _messageWindowSize)
: allMessages;
- _conversations[contactKeyHex] = windowedMessages;
+ final currentMessages =
+ _conversations[contactKeyHex] ?? const [];
+ final mergedMessages = [...windowedMessages];
+ final persistedKeyCounts = {};
+ for (final message in windowedMessages) {
+ final key = _messageMergeKey(message);
+ persistedKeyCounts[key] = (persistedKeyCounts[key] ?? 0) + 1;
+ }
+ final currentKeyCounts = {};
+
+ for (final message in currentMessages) {
+ final key = _messageMergeKey(message);
+ final currentCount = (currentKeyCounts[key] ?? 0) + 1;
+ currentKeyCounts[key] = currentCount;
+ final persistedCount = persistedKeyCounts[key] ?? 0;
+
+ // Preserve distinct duplicates without IDs (for example same text
+ // received multiple times in the same second) by only skipping the
+ // overlapping occurrences that already exist in persisted storage.
+ if (currentCount > persistedCount) {
+ mergedMessages.add(message);
+ }
+ }
+
+ // Re-sort after merging persisted and in-memory messages so the
+ // conversation window remains stable after optimistic inserts.
+ 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.senderKeyHex}:${message.isOutgoing}:${message.isCli}:${message.timestamp.millisecondsSinceEpoch}:${message.text}';
+ }
+
/// Load older messages for a contact (pagination)
Future> loadOlderMessages(
String contactKeyHex, {
@@ -599,6 +658,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
+ _usbManager.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@@ -725,8 +785,18 @@ 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) {
+ _appDebugLogService?.warn(
+ 'stopScan error in startScan (ignored): $e',
+ tag: 'BLE Scan',
+ );
+ }
+ }
await _scanSubscription?.cancel();
// On iOS/macOS, wait for Bluetooth to be powered on before scanning
@@ -757,19 +827,39 @@ 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) {
+ _appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
+ _setState(MeshCoreConnectionState.disconnected);
+ rethrow;
+ }
await Future.delayed(timeout);
await stopScan();
}
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) {
+ _appDebugLogService?.warn(
+ 'stopScan error (ignored): $e',
+ tag: 'BLE Scan',
+ );
+ }
+ }
await _scanSubscription?.cancel();
_scanSubscription = null;
@@ -778,12 +868,110 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
+ Future> listUsbPorts() => _usbManager.listPorts();
+
+ void setUsbRequestPortLabel(String label) {
+ _usbManager.setRequestPortLabel(label);
+ }
+
+ void setUsbFallbackDeviceName(String label) {
+ _usbManager.setFallbackDeviceName(label);
+ }
+
+ Future connectUsb({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ if (_state == MeshCoreConnectionState.connecting ||
+ _state == MeshCoreConnectionState.connected) {
+ _appDebugLogService?.warn(
+ 'connectUsb ignored: already $_state',
+ tag: 'USB',
+ );
+ return;
+ }
+
+ _appDebugLogService?.info(
+ 'connectUsb: port=$portName baud=$baudRate',
+ tag: 'USB',
+ );
+
+ await stopScan();
+ _cancelReconnectTimer();
+ _manualDisconnect = false;
+ _resetConnectionHandshakeState();
+ _activeTransport = MeshCoreTransportType.usb;
+ _setState(MeshCoreConnectionState.connecting);
+
+ try {
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ _appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
+ await _usbManager.connect(portName: portName, baudRate: baudRate);
+ _appDebugLogService?.info(
+ 'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
+ tag: 'USB',
+ );
+ notifyListeners();
+ if (PlatformInfo.isWeb) {
+ await stopScan();
+ }
+ await Future.delayed(const Duration(milliseconds: 200));
+ _usbFrameSubscription = _usbManager.frameStream.listen(
+ _handleFrame,
+ onError: (error, stackTrace) {
+ _appDebugLogService?.error('USB transport error: $error', tag: 'USB');
+ unawaited(disconnect(manual: false));
+ },
+ onDone: () {
+ _appDebugLogService?.warn('USB frame stream ended', tag: 'USB');
+ unawaited(disconnect(manual: false));
+ },
+ );
+
+ _setState(MeshCoreConnectionState.connected);
+ _pendingInitialChannelSync = true;
+ _appDebugLogService?.info(
+ 'connectUsb: requesting device info…',
+ tag: 'USB',
+ );
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ var gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ _appDebugLogService?.warn(
+ 'connectUsb: SELF_INFO timeout, retrying…',
+ tag: 'USB',
+ );
+ await refreshDeviceInfo();
+ gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ }
+ if (!gotSelfInfo) {
+ throw StateError('Timed out waiting for SELF_INFO during connect');
+ }
+
+ _appDebugLogService?.info('connectUsb: syncing time…', tag: 'USB');
+ await syncTime();
+ _appDebugLogService?.info('connectUsb: complete', tag: 'USB');
+ } catch (error) {
+ _appDebugLogService?.error('USB connection error: $error', tag: 'USB');
+ await disconnect(manual: false);
+ rethrow;
+ }
+ }
+
Future connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return;
}
+ _activeTransport = MeshCoreTransportType.bluetooth;
+
await stopScan();
_setState(MeshCoreConnectionState.connecting);
_device = device;
@@ -798,31 +986,83 @@ class MeshCoreConnector extends ChangeNotifier {
_lastDeviceDisplayName = _deviceDisplayName;
_manualDisconnect = false;
_cancelReconnectTimer();
+ _bleInitialSyncStarted = false;
+ if (PlatformInfo.isWeb) {
+ _resetConnectionHandshakeState();
+ }
unawaited(_backgroundService?.start());
notifyListeners();
try {
+ final connectLabel = _deviceDisplayName ?? _deviceId;
+ _appDebugLogService?.info(
+ 'Starting connect to $connectLabel',
+ tag: 'BLE Connect',
+ );
+ await _connectionSubscription?.cancel();
+ _connectionSubscription = null;
+ await _notifySubscription?.cancel();
+ _notifySubscription = null;
_connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
}
});
- await device.connect(
- timeout: const Duration(seconds: 15),
- mtu: null,
- 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');
+ await device.connect(
+ timeout: const Duration(seconds: 15),
+ mtu: null,
+ license: License.free,
+ );
+ } catch (error) {
+ _appDebugLogService?.error(
+ 'device.connect() failure: $error',
+ tag: 'BLE Connect',
+ );
+ rethrow;
}
- List services = await device.discoverServices();
+ // Request larger MTU only on native platforms; web does not support it.
+ if (!PlatformInfo.isWeb) {
+ try {
+ final mtu = await device.requestMtu(185);
+ _appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
+ } catch (e) {
+ _appDebugLogService?.warn(
+ 'MTU request failed: $e, using default',
+ tag: 'BLE Connect',
+ );
+ }
+ }
+
+ late final List services;
+ try {
+ services = await device.discoverServices();
+ } catch (error) {
+ _appDebugLogService?.error(
+ 'service discovery failure: $error',
+ tag: 'BLE Connect',
+ );
+ if (PlatformInfo.isWeb &&
+ error.toString().contains('GATT Server is disconnected')) {
+ // Chrome Web Bluetooth intermittently disconnects between connect()
+ // and service discovery; retry once to recover that transient state.
+ _appDebugLogService?.warn(
+ 'retrying service discovery after transient web disconnect',
+ tag: 'BLE Connect',
+ );
+ 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) {
@@ -849,18 +1089,50 @@ 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) {
+ _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) {
+ _appDebugLogService?.warn(
+ 'notify failure (web, ignored): $error',
+ tag: 'BLE Connect',
+ );
+ _appDebugLogService?.warn(
+ 'Web setNotifyValue error (ignoring): $error',
+ tag: 'BLE Connect',
+ );
+ }
+ }());
+ _appDebugLogService?.info(
+ 'setNotifyValue(true) configuration completed',
+ tag: 'BLE Connect',
+ );
+ } 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) {
+ _appDebugLogService?.warn('notify failure: $e', tag: 'BLE Connect');
+ _appDebugLogService?.warn(
+ 'setNotifyValue attempt ${attempt + 1}/3 failed: $e',
+ tag: 'BLE Connect',
+ );
+ 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(
@@ -868,27 +1140,13 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
-
- await _requestDeviceInfo();
- _startBatteryPolling();
- final gotSelfInfo = await _waitForSelfInfo(
- timeout: const Duration(seconds: 3),
- );
- if (!gotSelfInfo) {
- await refreshDeviceInfo();
- await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ if (_shouldGateInitialChannelSync) {
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = true;
}
-
- // Keep device clock aligned on every connection.
- await syncTime();
-
- // Fetch channels so we can track unread counts for incoming messages
- unawaited(getChannels());
-
- // Load discovered contacts from storage
- unawaited(loadDiscoveredContactCache());
+ await _startBleInitialSync();
} catch (e) {
- debugPrint("Connection error: $e");
+ _appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
await disconnect(manual: false);
rethrow;
}
@@ -925,7 +1183,55 @@ class MeshCoreConnector extends ChangeNotifier {
return result;
}
- bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null;
+ Future _startBleInitialSync() async {
+ if (_bleInitialSyncStarted ||
+ !isConnected ||
+ _activeTransport != MeshCoreTransportType.bluetooth) {
+ return;
+ }
+ _bleInitialSyncStarted = true;
+
+ await _requestDeviceInfo();
+ _startBatteryPolling();
+ unawaited(loadDiscoveredContactCache());
+
+ final gotSelfInfo = await _waitForSelfInfo(
+ timeout: const Duration(seconds: 3),
+ );
+ if (!gotSelfInfo) {
+ await refreshDeviceInfo();
+ await _waitForSelfInfo(timeout: const Duration(seconds: 3));
+ }
+
+ await syncTime();
+ unawaited(getChannels());
+ }
+
+ void _resetConnectionHandshakeState() {
+ _selfPublicKey = null;
+ _selfName = null;
+ _selfLatitude = null;
+ _selfLongitude = null;
+ _awaitingSelfInfo = false;
+ _webInitialHandshakeRequestSent = false;
+ _selfInfoRetryTimer?.cancel();
+ _selfInfoRetryTimer = null;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
+ _bleInitialSyncStarted = false;
+ _pendingDeferredChannelSyncAfterContacts = false;
+ }
+
+ bool get _shouldAutoReconnect =>
+ !_manualDisconnect &&
+ _lastDeviceId != null &&
+ _activeTransport == MeshCoreTransportType.bluetooth;
+
+ bool get _shouldGateInitialChannelSync =>
+ _activeTransport == MeshCoreTransportType.usb ||
+ (_activeTransport == MeshCoreTransportType.bluetooth &&
+ PlatformInfo.isWeb);
void _cancelReconnectTimer() {
_reconnectTimer?.cancel();
@@ -969,6 +1275,15 @@ class MeshCoreConnector extends ChangeNotifier {
Future disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
+ final transportAtDisconnect = _activeTransport;
+ final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb
+ ? 'USB'
+ : 'BLE';
+
+ _appDebugLogService?.info(
+ 'Starting disconnect transport=$transportLabel manual=$manual',
+ tag: 'Connection',
+ );
if (manual) {
_manualDisconnect = true;
@@ -980,6 +1295,10 @@ class MeshCoreConnector extends ChangeNotifier {
_setState(MeshCoreConnectionState.disconnecting);
_stopBatteryPolling();
+ await _usbFrameSubscription?.cancel();
+ _usbFrameSubscription = null;
+ await _usbManager.disconnect();
+
await _notifySubscription?.cancel();
_notifySubscription = null;
@@ -998,7 +1317,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;
@@ -1020,6 +1339,9 @@ class MeshCoreConnector extends ChangeNotifier {
_repeaterBatterySnapshots.clear();
_batteryRequested = false;
_awaitingSelfInfo = false;
+ _hasReceivedDeviceInfo = false;
+ _pendingInitialChannelSync = false;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -1033,8 +1355,14 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
+ _activeTransport = MeshCoreTransportType.bluetooth;
+
_setState(MeshCoreConnectionState.disconnected);
- if (!manual) {
+ _appDebugLogService?.info(
+ 'Disconnect complete transport=$transportLabel manual=$manual',
+ tag: 'Connection',
+ );
+ if (!manual && transportAtDisconnect == MeshCoreTransportType.bluetooth) {
_scheduleReconnect();
}
}
@@ -1044,24 +1372,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 _usbManager.write(data);
+ } else {
+ if (_rxCharacteristic == null) {
+ throw Exception("MeshCore RX characteristic not available");
+ }
+ // 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,
@@ -1094,7 +1427,18 @@ class MeshCoreConnector extends ChangeNotifier {
Future refreshDeviceInfo() async {
if (!isConnected) return;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _webInitialHandshakeRequestSent &&
+ _selfPublicKey == null) {
+ return;
+ }
_awaitingSelfInfo = true;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _selfPublicKey == null) {
+ _webInitialHandshakeRequestSent = true;
+ }
await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame());
await requestBatteryStatus(force: true);
@@ -1106,7 +1450,18 @@ class MeshCoreConnector extends ChangeNotifier {
Future _requestDeviceInfo() async {
if (!isConnected || _awaitingSelfInfo) return;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _webInitialHandshakeRequestSent &&
+ _selfPublicKey == null) {
+ return;
+ }
_awaitingSelfInfo = true;
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ _selfPublicKey == null) {
+ _webInitialHandshakeRequestSent = true;
+ }
await sendFrame(buildDeviceQueryFrame());
await sendFrame(buildAppStartFrame());
await sendFrame(buildGetCustomVarsFrame());
@@ -1117,6 +1472,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,
) {
@@ -1870,6 +2247,14 @@ class MeshCoreConnector extends ChangeNotifier {
_hasLoadedChannels = true;
_previousChannelsCache.clear();
}
+
+ // Fallback: if contact sync was deferred waiting for channel 0 but
+ // channel sync finished without triggering it, start contacts now.
+ if (_pendingInitialContactsSync && isConnected) {
+ _pendingInitialContactsSync = false;
+ unawaited(getContacts());
+ }
+
// Keep cache on failure/disconnection for future attempts
}
@@ -1938,11 +2323,24 @@ 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;
unawaited(syncQueuedMessages(force: true));
}
+ if (_pendingDeferredChannelSyncAfterContacts &&
+ (_activeTransport == MeshCoreTransportType.bluetooth ||
+ _activeTransport == MeshCoreTransportType.usb)) {
+ _pendingDeferredChannelSyncAfterContacts = false;
+ _pendingInitialChannelSync = false;
+ unawaited(getChannels());
+ }
break;
case respCodeContactMsgRecv:
case respCodeContactMsgRecvV3:
@@ -2053,6 +2451,7 @@ class MeshCoreConnector extends ChangeNotifier {
// [56] = sf
// [57] = cr
// [58+] = node_name
+ final wasAwaitingSelfInfo = _awaitingSelfInfo;
final reader = BufferReader(frame);
try {
reader.skipBytes(2);
@@ -2063,7 +2462,6 @@ class MeshCoreConnector extends ChangeNotifier {
_selfLongitude = reader.readInt32LE() / 1000000.0;
_multiAcks = reader.readByte();
_advertLocPolicy = reader.readByte();
-
final telemetryFlag = reader.readByte();
_telemetryModeBase = telemetryFlag & 0x03;
_telemetryModeEnv = telemetryFlag >> 2 & 0x03;
@@ -2083,17 +2481,45 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector',
);
}
+ final selfName = _selfName?.trim();
+ if (_activeTransport == MeshCoreTransportType.usb &&
+ selfName != null &&
+ selfName.isNotEmpty) {
+ _usbManager.updateConnectedLabel(selfName);
+ }
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
notifyListeners();
- // Auto-fetch contacts after getting self info
- getContacts();
+ if (PlatformInfo.isWeb &&
+ _activeTransport == MeshCoreTransportType.bluetooth &&
+ !wasAwaitingSelfInfo) {
+ return;
+ }
+
+ // 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 if (_activeTransport == MeshCoreTransportType.usb) {
+ _pendingDeferredChannelSyncAfterContacts = true;
+ getContacts();
+ } else {
+ getContacts();
+ }
+ if (_shouldGateInitialChannelSync &&
+ _activeTransport != MeshCoreTransportType.usb) {
+ _maybeStartInitialChannelSync();
+ }
}
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
+ if (_shouldGateInitialChannelSync) {
+ _hasReceivedDeviceInfo = true;
+ }
_firmwareVerCode = frame[1];
// Parse client_repeat from firmware v9+ (byte 80)
@@ -2117,12 +2543,29 @@ class MeshCoreConnector extends ChangeNotifier {
if (nextMaxChannels > previousMaxChannels) {
unawaited(loadChannelSettings(maxChannels: nextMaxChannels));
unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels));
- if (isConnected) {
+ if (isConnected &&
+ _selfPublicKey != null &&
+ (!_shouldGateInitialChannelSync || !_pendingInitialChannelSync)) {
unawaited(getChannels(maxChannels: nextMaxChannels));
}
}
}
notifyListeners();
+ if (_shouldGateInitialChannelSync) {
+ _maybeStartInitialChannelSync();
+ }
+ }
+
+ void _maybeStartInitialChannelSync() {
+ if (!_pendingInitialChannelSync || !isConnected) {
+ return;
+ }
+ if (_selfPublicKey == null || !_hasReceivedDeviceInfo) {
+ return;
+ }
+
+ _pendingInitialChannelSync = false;
+ unawaited(getChannels(maxChannels: _maxChannels));
}
void _handleNoMoreMessages() {
@@ -3043,6 +3486,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 {
@@ -3274,7 +3725,6 @@ 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 &&
@@ -3704,6 +4154,9 @@ 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;
+ _pendingInitialContactsSync = false;
_maxContacts = _defaultMaxContacts;
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
@@ -3818,11 +4271,13 @@ class MeshCoreConnector extends ChangeNotifier {
void dispose() {
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
+ _usbFrameSubscription?.cancel();
_notifySubscription?.cancel();
_notifyListenersTimer?.cancel();
_reconnectTimer?.cancel();
_batteryPollTimer?.cancel();
_receivedFramesController.close();
+ _usbManager.dispose();
// Flush pending unread writes before disposal
_unreadStore.flush();
diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart
new file mode 100644
index 0000000..376ae1a
--- /dev/null
+++ b/lib/connector/meshcore_connector_usb.dart
@@ -0,0 +1,73 @@
+import 'dart:typed_data';
+
+import '../services/app_debug_log_service.dart';
+import '../services/usb_serial_service.dart';
+
+/// Manages USB serial transport for MeshCore devices.
+///
+/// Owns the [UsbSerialService] and USB-specific connection state.
+/// The main [MeshCoreConnector] delegates all USB operations here.
+class MeshCoreUsbManager {
+ MeshCoreUsbManager();
+
+ final UsbSerialService _service = UsbSerialService();
+ AppDebugLogService? _debugLog;
+ String? _activePortKey;
+ String? _activePortLabel;
+
+ // --- Getters ---
+ String? get activePortKey => _activePortKey;
+ String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
+ bool get isConnected => _service.isConnected;
+ Stream get frameStream => _service.frameStream;
+
+ // --- Configuration ---
+ Future> listPorts() => _service.listPorts();
+
+ void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
+
+ void setFallbackDeviceName(String label) =>
+ _service.setFallbackDeviceName(label);
+
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLog = service;
+ _service.setDebugLogService(service);
+ }
+
+ // --- Connection lifecycle ---
+ Future connect({
+ required String portName,
+ int baudRate = 115200,
+ }) async {
+ _debugLog?.info(
+ 'UsbManager.connect: portName=$portName baud=$baudRate',
+ tag: 'USB',
+ );
+ await _service.connect(portName: portName, baudRate: baudRate);
+ _activePortKey = _service.activePortKey ?? portName;
+ _activePortLabel = _service.activePortDisplayLabel ?? portName;
+ _debugLog?.info(
+ 'UsbManager.connect: done, key=$_activePortKey label=$_activePortLabel',
+ tag: 'USB',
+ );
+ }
+
+ Future disconnect() async {
+ _debugLog?.info('UsbManager.disconnect', tag: 'USB');
+ await _service.disconnect();
+ _activePortKey = null;
+ _activePortLabel = null;
+ }
+
+ Future write(Uint8List data) => _service.write(data);
+
+ // --- Label management ---
+ void updateConnectedLabel(String selfName) {
+ _service.updateConnectedLabel(selfName);
+ _activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
+ }
+
+ void dispose() {
+ _service.dispose();
+ }
+}
diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb
index 9733738..94d8997 100644
--- a/lib/l10n/app_bg.arb
+++ b/lib/l10n/app_bg.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAllContent": "Сигурни ли сте, че искате да изтриете всички открити контакти?",
"common_deleteAll": "Изтрий всичко",
"map_guessedLocation": "Предполагано местоположение",
- "map_showGuessedLocations": "Покажете местоположенията на предположените възли."
+ "map_showGuessedLocations": "Покажете местоположенията на предположените възли.",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "Свържете се чрез USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenSubtitle": "Изберете открития сериен уред и свържете директно към вашия MeshCore възел.",
+ "usbScreenStatus": "Изберете USB устройство",
+ "usbScreenNote": "USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.",
+ "usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново.",
+ "usbErrorPermissionDenied": "Не беше разрешено достъпът през USB.",
+ "usbErrorDeviceMissing": "Избраното USB устройство вече не е налично.",
+ "usbErrorInvalidPort": "Изберете валитно USB устройство.",
+ "usbErrorBusy": "Друг мол за свързване през USB вече е в процес на изпълнение.",
+ "usbErrorNotConnected": "Няма свързано USB устройство.",
+ "usbErrorOpenFailed": "Не успях да отворя избраното USB устройство.",
+ "usbErrorConnectFailed": "Не успях да се свържа с избраното USB устройство.",
+ "usbErrorUnsupported": "USB серийната комуникация не се поддържа на тази платформа.",
+ "usbErrorAlreadyActive": "USB връзката вече е активирана.",
+ "usbErrorNoDeviceSelected": "Няма избран USB устройство.",
+ "usbErrorPortClosed": "USB връзката не е активна.",
+ "usbFallbackDeviceName": "Устройство за четене на уеб серийни данни",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_connecting": "Свързване към USB устройство...",
+ "usbConnectionFailed": "Неуспешно свързване през USB: {error}",
+ "usbStatus_notConnected": "Изберете USB устройство",
+ "usbStatus_searching": "Търсене на USB устройства...",
+ "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка."
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index dc4cbfc..9ba0f51 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -1856,5 +1856,36 @@
"discoveredContacts_deleteContactAllContent": "Sind Sie sicher, dass Sie alle gefundenen Kontakte löschen möchten?",
"discoveredContacts_deleteContactAll": "Alle entdeckten Kontakte löschen",
"map_showGuessedLocations": "Zeige die vermuteten Knotenpositionen",
- "map_guessedLocation": "Geschätzter Ort"
+ "map_guessedLocation": "Geschätzter Ort",
+ "usbScreenSubtitle": "Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "Verbinden über USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenStatus": "Wählen Sie ein USB-Gerät aus",
+ "usbScreenNote": "Die USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.",
+ "usbScreenEmptyState": "Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.",
+ "usbErrorPermissionDenied": "Die USB-Berechtigung wurde abgelehnt.",
+ "usbErrorDeviceMissing": "Das ausgewählte USB-Gerät ist nicht mehr verfügbar.",
+ "usbErrorInvalidPort": "Wählen Sie ein gültiges USB-Gerät aus.",
+ "usbErrorBusy": "Eine weitere Anfrage für eine USB-Verbindung ist bereits in Bearbeitung.",
+ "usbErrorNotConnected": "Es ist kein USB-Gerät angeschlossen.",
+ "usbErrorOpenFailed": "Fehlgeschlagen beim Öffnen des ausgewählten USB-Geräts.",
+ "usbErrorConnectFailed": "Keine Verbindung zum ausgewählten USB-Gerät hergestellt.",
+ "usbErrorUnsupported": "Die USB-Serielle Schnittstelle wird auf dieser Plattform nicht unterstützt.",
+ "usbErrorAlreadyActive": "Eine USB-Verbindung ist bereits hergestellt.",
+ "usbErrorNoDeviceSelected": "Kein USB-Gerät wurde ausgewählt.",
+ "usbErrorPortClosed": "Die USB-Verbindung ist nicht aktiv.",
+ "usbFallbackDeviceName": "Web-Serielle Geräte",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Suche nach USB-Geräten...",
+ "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
+ "usbStatus_connecting": "Verbindung zum USB-Gerät...",
+ "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
+ "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält."
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index eb03fac..2605628 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -47,6 +47,37 @@
}
},
"scanner_title": "MeshCore Open",
+ "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.",
+ "usbErrorPermissionDenied": "USB permission was denied.",
+ "usbErrorDeviceMissing": "The selected USB device is no longer available.",
+ "usbErrorInvalidPort": "Select a valid USB device.",
+ "usbErrorBusy": "Another USB connection request is already in progress.",
+ "usbErrorNotConnected": "No USB device is connected.",
+ "usbErrorOpenFailed": "Failed to open the selected USB device.",
+ "usbErrorConnectFailed": "Failed to connect to the selected USB device.",
+ "usbErrorUnsupported": "USB serial is not supported on this platform.",
+ "usbErrorAlreadyActive": "A USB connection is already active.",
+ "usbErrorNoDeviceSelected": "No USB device was selected.",
+ "usbErrorPortClosed": "The USB connection is not open.",
+ "usbErrorConnectTimedOut": "Connection timed out. Make sure the device has USB Companion firmware.",
+ "usbFallbackDeviceName": "Web Serial Device",
+ "usbStatus_notConnected": "Select a USB device",
+ "usbStatus_connecting": "Connecting to USB device...",
+ "usbStatus_searching": "Searching for USB devices...",
+ "usbConnectionFailed": "USB connection failed: {error}",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
"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 5020bef..9b791d3 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1856,5 +1856,36 @@
"discoveredContacts_deleteContactAll": "Eliminar Todos los Contactos Descubiertos",
"discoveredContacts_deleteContactAllContent": "¿Está seguro de que desea eliminar todos los contactos descubiertos!",
"map_guessedLocation": "Ubicación estimada",
- "map_showGuessedLocations": "Mostrar las ubicaciones estimadas de los nodos."
+ "map_showGuessedLocations": "Mostrar las ubicaciones estimadas de los nodos.",
+ "usbScreenTitle": "Conecte mediante USB",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenSubtitle": "Seleccione el dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.",
+ "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.",
+ "usbScreenEmptyState": "No se encontraron dispositivos USB. Conecte uno y vuelva a intentar.",
+ "usbErrorPermissionDenied": "Se denegó el permiso de acceso a través de USB.",
+ "usbErrorDeviceMissing": "El dispositivo USB seleccionado ya no está disponible.",
+ "usbErrorInvalidPort": "Seleccione un dispositivo USB válido.",
+ "usbErrorBusy": "Ya se ha iniciado una solicitud de conexión USB adicional.",
+ "usbErrorNotConnected": "No hay ningún dispositivo USB conectado.",
+ "usbErrorOpenFailed": "No se pudo abrir el dispositivo USB seleccionado.",
+ "usbErrorConnectFailed": "No se pudo conectar con el dispositivo USB seleccionado.",
+ "usbErrorUnsupported": "La comunicación serial a través de USB no está soportada en esta plataforma.",
+ "usbErrorAlreadyActive": "La conexión USB ya está activa.",
+ "usbErrorNoDeviceSelected": "No se ha seleccionado ningún dispositivo USB.",
+ "usbErrorPortClosed": "La conexión USB no está activa.",
+ "usbFallbackDeviceName": "Dispositivo de serie web",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_connecting": "Conectándose al dispositivo USB...",
+ "usbStatus_searching": "Buscando dispositivos USB...",
+ "usbStatus_notConnected": "Seleccione un dispositivo USB",
+ "usbConnectionFailed": "Error al conectar mediante USB: {error}",
+ "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion."
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 7207fe8..a7bedc9 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAll": "Supprimer tous les contacts découverts",
"discoveredContacts_deleteContactAllContent": "Êtes-vous sûr de vouloir supprimer tous les contacts découverts ?",
"map_showGuessedLocations": "Afficher les emplacements des nœuds estimés",
- "map_guessedLocation": "Lieu deviné"
+ "map_guessedLocation": "Lieu deviné",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "Connectez via USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenSubtitle": "Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.",
+ "usbScreenStatus": "Sélectionnez un périphérique USB",
+ "usbScreenNote": "La communication série USB est active sur les appareils Android et les plateformes de bureau compatibles.",
+ "usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Veuillez en brancher un et rafraîchir la page.",
+ "usbErrorPermissionDenied": "L'accès via USB a été refusé.",
+ "usbErrorDeviceMissing": "Le périphérique USB sélectionné n'est plus disponible.",
+ "usbErrorInvalidPort": "Sélectionnez un périphérique USB valide.",
+ "usbErrorBusy": "Une autre demande de connexion USB est déjà en cours.",
+ "usbErrorNotConnected": "Aucun appareil USB n'est connecté.",
+ "usbErrorOpenFailed": "Impossible d'ouvrir l'appareil USB sélectionné.",
+ "usbErrorConnectFailed": "Impossible de se connecter à l'appareil USB sélectionné.",
+ "usbErrorUnsupported": "La communication série USB n'est pas prise en charge sur cette plateforme.",
+ "usbErrorAlreadyActive": "Une connexion USB est déjà établie.",
+ "usbErrorNoDeviceSelected": "Aucun appareil USB n'a été sélectionné.",
+ "usbErrorPortClosed": "La connexion USB n'est pas établie.",
+ "usbFallbackDeviceName": "Dispositif de communication série sur le Web",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_notConnected": "Sélectionnez un périphérique USB",
+ "usbConnectionFailed": "Échec de la connexion USB : {error}",
+ "usbStatus_connecting": "Connexion au périphérique USB...",
+ "usbStatus_searching": "Recherche de périphériques USB...",
+ "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion."
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index f3f3f53..423ff40 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAllContent": "Sei sicuro di voler eliminare tutti i contatti scoperti?",
"discoveredContacts_deleteContactAll": "Eliminare tutti i contatti scoperti",
"map_guessedLocation": "Località indovinata",
- "map_showGuessedLocations": "Mostra le posizioni stimate dei nodi"
+ "map_showGuessedLocations": "Mostra le posizioni stimate dei nodi",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenSubtitle": "Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "Connessione tramite USB",
+ "usbScreenStatus": "Seleziona un dispositivo USB",
+ "usbScreenNote": "La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.",
+ "usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e aggiornare.",
+ "usbErrorPermissionDenied": "È stato negato l'accesso tramite USB.",
+ "usbErrorDeviceMissing": "Il dispositivo USB selezionato non è più disponibile.",
+ "usbErrorInvalidPort": "Seleziona un dispositivo USB valido.",
+ "usbErrorBusy": "Un'altra richiesta di connessione tramite USB è già in corso.",
+ "usbErrorNotConnected": "Non è collegato alcun dispositivo USB.",
+ "usbErrorOpenFailed": "Impossibile aprire il dispositivo USB selezionato.",
+ "usbErrorConnectFailed": "Impossibile connettersi al dispositivo USB selezionato.",
+ "usbErrorUnsupported": "La comunicazione seriale tramite USB non è supportata su questa piattaforma.",
+ "usbErrorAlreadyActive": "La connessione USB è già attiva.",
+ "usbErrorNoDeviceSelected": "Non è stato selezionato alcun dispositivo USB.",
+ "usbErrorPortClosed": "La connessione USB non è attiva.",
+ "usbFallbackDeviceName": "Dispositivo per comunicazione seriale su rete",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Ricerca di dispositivi USB...",
+ "usbConnectionFailed": "Errore nella connessione USB: {error}",
+ "usbStatus_notConnected": "Seleziona un dispositivo USB",
+ "usbStatus_connecting": "Connessione al dispositivo USB...",
+ "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion."
}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 0f43fe2..8d3f86b 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -322,6 +322,150 @@ abstract class AppLocalizations {
/// **'MeshCore Open'**
String get scanner_title;
+ /// 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 @usbErrorPermissionDenied.
+ ///
+ /// In en, this message translates to:
+ /// **'USB permission was denied.'**
+ String get usbErrorPermissionDenied;
+
+ /// No description provided for @usbErrorDeviceMissing.
+ ///
+ /// In en, this message translates to:
+ /// **'The selected USB device is no longer available.'**
+ String get usbErrorDeviceMissing;
+
+ /// No description provided for @usbErrorInvalidPort.
+ ///
+ /// In en, this message translates to:
+ /// **'Select a valid USB device.'**
+ String get usbErrorInvalidPort;
+
+ /// No description provided for @usbErrorBusy.
+ ///
+ /// In en, this message translates to:
+ /// **'Another USB connection request is already in progress.'**
+ String get usbErrorBusy;
+
+ /// No description provided for @usbErrorNotConnected.
+ ///
+ /// In en, this message translates to:
+ /// **'No USB device is connected.'**
+ String get usbErrorNotConnected;
+
+ /// No description provided for @usbErrorOpenFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to open the selected USB device.'**
+ String get usbErrorOpenFailed;
+
+ /// No description provided for @usbErrorConnectFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to connect to the selected USB device.'**
+ String get usbErrorConnectFailed;
+
+ /// No description provided for @usbErrorUnsupported.
+ ///
+ /// In en, this message translates to:
+ /// **'USB serial is not supported on this platform.'**
+ String get usbErrorUnsupported;
+
+ /// No description provided for @usbErrorAlreadyActive.
+ ///
+ /// In en, this message translates to:
+ /// **'A USB connection is already active.'**
+ String get usbErrorAlreadyActive;
+
+ /// No description provided for @usbErrorNoDeviceSelected.
+ ///
+ /// In en, this message translates to:
+ /// **'No USB device was selected.'**
+ String get usbErrorNoDeviceSelected;
+
+ /// No description provided for @usbErrorPortClosed.
+ ///
+ /// In en, this message translates to:
+ /// **'The USB connection is not open.'**
+ String get usbErrorPortClosed;
+
+ /// No description provided for @usbErrorConnectTimedOut.
+ ///
+ /// In en, this message translates to:
+ /// **'Connection timed out. Make sure the device has USB Companion firmware.'**
+ String get usbErrorConnectTimedOut;
+
+ /// No description provided for @usbFallbackDeviceName.
+ ///
+ /// In en, this message translates to:
+ /// **'Web Serial Device'**
+ String get usbFallbackDeviceName;
+
+ /// No description provided for @usbStatus_notConnected.
+ ///
+ /// In en, this message translates to:
+ /// **'Select a USB device'**
+ String get usbStatus_notConnected;
+
+ /// No description provided for @usbStatus_connecting.
+ ///
+ /// In en, this message translates to:
+ /// **'Connecting to USB device...'**
+ String get usbStatus_connecting;
+
+ /// No description provided for @usbStatus_searching.
+ ///
+ /// In en, this message translates to:
+ /// **'Searching for USB devices...'**
+ String get usbStatus_searching;
+
+ /// No description provided for @usbConnectionFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'USB connection failed: {error}'**
+ String usbConnectionFailed(String error);
+
/// 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 85dcf42..356106e 100644
--- a/lib/l10n/app_localizations_bg.dart
+++ b/lib/l10n/app_localizations_bg.dart
@@ -111,6 +111,90 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Свържете се чрез USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Изберете открития сериен уред и свържете директно към вашия MeshCore възел.';
+
+ @override
+ String get usbScreenStatus => 'Изберете USB устройство';
+
+ @override
+ String get usbScreenNote =>
+ 'USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Няма открити USB устройства. Включете едно и опитайте отново.';
+
+ @override
+ String get usbErrorPermissionDenied => 'Не беше разрешено достъпът през USB.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Избраното USB устройство вече не е налично.';
+
+ @override
+ String get usbErrorInvalidPort => 'Изберете валитно USB устройство.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Друг мол за свързване през USB вече е в процес на изпълнение.';
+
+ @override
+ String get usbErrorNotConnected => 'Няма свързано USB устройство.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Не успях да отворя избраното USB устройство.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Не успях да се свържа с избраното USB устройство.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'USB серийната комуникация не се поддържа на тази платформа.';
+
+ @override
+ String get usbErrorAlreadyActive => 'USB връзката вече е активирана.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'Няма избран USB устройство.';
+
+ @override
+ String get usbErrorPortClosed => 'USB връзката не е активна.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Устройство за четене на уеб серийни данни';
+
+ @override
+ String get usbStatus_notConnected => 'Изберете USB устройство';
+
+ @override
+ String get usbStatus_connecting => 'Свързване към USB устройство...';
+
+ @override
+ String get usbStatus_searching => 'Търсене на USB устройства...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Неуспешно свързване през USB: $error';
+ }
+
@override
String get scanner_scanning => 'Сканиране за устройства...';
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index 6bfb4be..6353f35 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -111,6 +111,91 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Verbinden über USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Wählen Sie ein erkannten serielles Gerät aus und verbinden Sie es direkt mit Ihrem MeshCore-Knoten.';
+
+ @override
+ String get usbScreenStatus => 'Wählen Sie ein USB-Gerät aus';
+
+ @override
+ String get usbScreenNote =>
+ 'Die USB-Serielle Schnittstelle ist auf unterstützten Android-Geräten und Desktop-Plattformen aktiv.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Die USB-Berechtigung wurde abgelehnt.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Das ausgewählte USB-Gerät ist nicht mehr verfügbar.';
+
+ @override
+ String get usbErrorInvalidPort => 'Wählen Sie ein gültiges USB-Gerät aus.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Eine weitere Anfrage für eine USB-Verbindung ist bereits in Bearbeitung.';
+
+ @override
+ String get usbErrorNotConnected => 'Es ist kein USB-Gerät angeschlossen.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Fehlgeschlagen beim Öffnen des ausgewählten USB-Geräts.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Keine Verbindung zum ausgewählten USB-Gerät hergestellt.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'Die USB-Serielle Schnittstelle wird auf dieser Plattform nicht unterstützt.';
+
+ @override
+ String get usbErrorAlreadyActive =>
+ 'Eine USB-Verbindung ist bereits hergestellt.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'Kein USB-Gerät wurde ausgewählt.';
+
+ @override
+ String get usbErrorPortClosed => 'Die USB-Verbindung ist nicht aktiv.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.';
+
+ @override
+ String get usbFallbackDeviceName => 'Web-Serielle Geräte';
+
+ @override
+ String get usbStatus_notConnected => 'Wählen Sie ein USB-Gerät aus';
+
+ @override
+ String get usbStatus_connecting => 'Verbindung zum USB-Gerät...';
+
+ @override
+ String get usbStatus_searching => 'Suche nach USB-Geräten...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Fehler beim USB-Verbindungsaufbau: $error';
+ }
+
@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 b2e1a0b..9c20df7 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -111,6 +111,88 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @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 usbErrorPermissionDenied => 'USB permission was denied.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'The selected USB device is no longer available.';
+
+ @override
+ String get usbErrorInvalidPort => 'Select a valid USB device.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Another USB connection request is already in progress.';
+
+ @override
+ String get usbErrorNotConnected => 'No USB device is connected.';
+
+ @override
+ String get usbErrorOpenFailed => 'Failed to open the selected USB device.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Failed to connect to the selected USB device.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'USB serial is not supported on this platform.';
+
+ @override
+ String get usbErrorAlreadyActive => 'A USB connection is already active.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'No USB device was selected.';
+
+ @override
+ String get usbErrorPortClosed => 'The USB connection is not open.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Connection timed out. Make sure the device has USB Companion firmware.';
+
+ @override
+ String get usbFallbackDeviceName => 'Web Serial Device';
+
+ @override
+ String get usbStatus_notConnected => 'Select a USB device';
+
+ @override
+ String get usbStatus_connecting => 'Connecting to USB device...';
+
+ @override
+ String get usbStatus_searching => 'Searching for USB devices...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'USB connection failed: $error';
+ }
+
@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 62d7a09..eecbd48 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -111,6 +111,91 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Conecte mediante USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Seleccione el dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Seleccione un dispositivo USB';
+
+ @override
+ String get usbScreenNote =>
+ '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 encontraron dispositivos USB. Conecte uno y vuelva a intentar.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Se denegó el permiso de acceso a través de USB.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'El dispositivo USB seleccionado ya no está disponible.';
+
+ @override
+ String get usbErrorInvalidPort => 'Seleccione un dispositivo USB válido.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Ya se ha iniciado una solicitud de conexión USB adicional.';
+
+ @override
+ String get usbErrorNotConnected => 'No hay ningún dispositivo USB conectado.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'No se pudo abrir el dispositivo USB seleccionado.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'No se pudo conectar con el dispositivo USB seleccionado.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'La comunicación serial a través de USB no está soportada en esta plataforma.';
+
+ @override
+ String get usbErrorAlreadyActive => 'La conexión USB ya está activa.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'No se ha seleccionado ningún dispositivo USB.';
+
+ @override
+ String get usbErrorPortClosed => 'La conexión USB no está activa.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName => 'Dispositivo de serie web';
+
+ @override
+ String get usbStatus_notConnected => 'Seleccione un dispositivo USB';
+
+ @override
+ String get usbStatus_connecting => 'Conectándose al dispositivo USB...';
+
+ @override
+ String get usbStatus_searching => 'Buscando dispositivos USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Error al conectar mediante USB: $error';
+ }
+
@override
String get scanner_scanning => 'Escaneando dispositivos...';
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 7a60519..5cabc86 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -111,6 +111,91 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Connectez via USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Sélectionnez un périphérique série détecté et connectez-vous directement à votre nœud MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Sélectionnez un périphérique USB';
+
+ @override
+ String get usbScreenNote =>
+ 'La communication série USB est active sur les appareils Android et les plateformes de bureau compatibles.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Aucun périphérique USB n\'a été trouvé. Veuillez en brancher un et rafraîchir la page.';
+
+ @override
+ String get usbErrorPermissionDenied => 'L\'accès via USB a été refusé.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Le périphérique USB sélectionné n\'est plus disponible.';
+
+ @override
+ String get usbErrorInvalidPort => 'Sélectionnez un périphérique USB valide.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Une autre demande de connexion USB est déjà en cours.';
+
+ @override
+ String get usbErrorNotConnected => 'Aucun appareil USB n\'est connecté.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Impossible d\'ouvrir l\'appareil USB sélectionné.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Impossible de se connecter à l\'appareil USB sélectionné.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'La communication série USB n\'est pas prise en charge sur cette plateforme.';
+
+ @override
+ String get usbErrorAlreadyActive => 'Une connexion USB est déjà établie.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Aucun appareil USB n\'a été sélectionné.';
+
+ @override
+ String get usbErrorPortClosed => 'La connexion USB n\'est pas établie.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'La connexion a expiré. Assurez-vous que l\'appareil dispose du firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Dispositif de communication série sur le Web';
+
+ @override
+ String get usbStatus_notConnected => 'Sélectionnez un périphérique USB';
+
+ @override
+ String get usbStatus_connecting => 'Connexion au périphérique USB...';
+
+ @override
+ String get usbStatus_searching => 'Recherche de périphériques USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Échec de la connexion USB : $error';
+ }
+
@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 f84f461..d170540 100644
--- a/lib/l10n/app_localizations_it.dart
+++ b/lib/l10n/app_localizations_it.dart
@@ -111,6 +111,92 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Connessione tramite USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Seleziona un dispositivo USB';
+
+ @override
+ String get usbScreenNote =>
+ 'La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nessun dispositivo USB rilevato. Collegare uno e aggiornare.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'È stato negato l\'accesso tramite USB.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Il dispositivo USB selezionato non è più disponibile.';
+
+ @override
+ String get usbErrorInvalidPort => 'Seleziona un dispositivo USB valido.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Un\'altra richiesta di connessione tramite USB è già in corso.';
+
+ @override
+ String get usbErrorNotConnected => 'Non è collegato alcun dispositivo USB.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Impossibile aprire il dispositivo USB selezionato.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Impossibile connettersi al dispositivo USB selezionato.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'La comunicazione seriale tramite USB non è supportata su questa piattaforma.';
+
+ @override
+ String get usbErrorAlreadyActive => 'La connessione USB è già attiva.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Non è stato selezionato alcun dispositivo USB.';
+
+ @override
+ String get usbErrorPortClosed => 'La connessione USB non è attiva.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Dispositivo per comunicazione seriale su rete';
+
+ @override
+ String get usbStatus_notConnected => 'Seleziona un dispositivo USB';
+
+ @override
+ String get usbStatus_connecting => 'Connessione al dispositivo USB...';
+
+ @override
+ String get usbStatus_searching => 'Ricerca di dispositivi USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Errore nella connessione USB: $error';
+ }
+
@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 5ed036d..323ba34 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -111,6 +111,89 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Verbind via USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Selecteer een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.';
+
+ @override
+ String get usbScreenStatus => 'Selecteer een USB-apparaat';
+
+ @override
+ String get usbScreenNote =>
+ 'USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Geen USB-apparaten gevonden. Sluit er een aan en herlaad.';
+
+ @override
+ String get usbErrorPermissionDenied => 'Toegang via USB is geweigerd.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Het geselecteerde USB-apparaat is niet meer beschikbaar.';
+
+ @override
+ String get usbErrorInvalidPort => 'Selecteer een geldig USB-apparaat.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Een andere verzoek om een USB-verbinding is al in behandeling.';
+
+ @override
+ String get usbErrorNotConnected => 'Er is geen USB-apparaat aangesloten.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Kon het geselecteerde USB-apparaat niet openen.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Kon niet verbinding maken met het geselecteerde USB-apparaat.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'USB-serieel is niet ondersteund op deze platform.';
+
+ @override
+ String get usbErrorAlreadyActive => 'Een USB-verbinding is al actief.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'Geen USB-apparaat is geselecteerd.';
+
+ @override
+ String get usbErrorPortClosed => 'De USB-verbinding is niet actief.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.';
+
+ @override
+ String get usbFallbackDeviceName => 'Web-serieapparaat';
+
+ @override
+ String get usbStatus_notConnected => 'Selecteer een USB-apparaat';
+
+ @override
+ String get usbStatus_connecting => 'Verbinding maken met USB-apparaat...';
+
+ @override
+ String get usbStatus_searching => 'Zoeken naar USB-apparaten...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Fout bij de USB-verbinding: $error';
+ }
+
@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 70c8061..9175c3e 100644
--- a/lib/l10n/app_localizations_pl.dart
+++ b/lib/l10n/app_localizations_pl.dart
@@ -111,6 +111,92 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Połącz przez USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Wybierz urządzenie USB';
+
+ @override
+ String get usbScreenNote =>
+ 'Port szeregowy USB jest aktywny na urządzeniach z systemem Android i platformach stacjonarnych, które go obsługują.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Zostało odrzucone żądanie dostępu przez USB.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Wybór urządzenia USB już nie jest dostępny.';
+
+ @override
+ String get usbErrorInvalidPort => 'Wybierz prawidłowe urządzenie USB.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Kolejne żądanie połączenia przez USB jest już w trakcie realizacji.';
+
+ @override
+ String get usbErrorNotConnected => 'Brak podłączonego urządzenia USB.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Nie udało się otworzyć wybranego urządzenia USB.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Nie udało się połączyć z wybranym urządzeniem USB.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'Port szeregowy USB nie jest obsługiwany na tym urządzeniu.';
+
+ @override
+ String get usbErrorAlreadyActive => 'Połączenie USB jest już aktywne.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Nie został wybrany żaden urządzenie USB.';
+
+ @override
+ String get usbErrorPortClosed => 'Połączenie USB nie jest aktywne.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Urządzenie do komunikacji przez sieć (seria)';
+
+ @override
+ String get usbStatus_notConnected => 'Wybierz urządzenie USB';
+
+ @override
+ String get usbStatus_connecting => 'Połączenie z urządzeniem USB...';
+
+ @override
+ String get usbStatus_searching => 'Wyszukiwanie urządzeń USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Błąd połączenia USB: $error';
+ }
+
@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 aa37f8a..ff09213 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -111,6 +111,91 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Conecte via USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Selecione um dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Selecione um dispositivo USB';
+
+ @override
+ String get usbScreenNote =>
+ 'A comunicação serial via USB está ativa em dispositivos Android e plataformas de desktop compatíveis.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nenhum dispositivo USB encontrado. Conecte um e atualize.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'A permissão para acesso via USB foi negada.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'O dispositivo USB selecionado não está mais disponível.';
+
+ @override
+ String get usbErrorInvalidPort => 'Selecione um dispositivo USB válido.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Já existe uma solicitação de conexão USB em andamento.';
+
+ @override
+ String get usbErrorNotConnected => 'Não há nenhum dispositivo USB conectado.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Não foi possível abrir o dispositivo USB selecionado.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Não foi possível conectar ao dispositivo USB selecionado.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'A comunicação serial via USB não é suportada nesta plataforma.';
+
+ @override
+ String get usbErrorAlreadyActive => 'A conexão USB já está ativa.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Nenhum dispositivo USB foi selecionado.';
+
+ @override
+ String get usbErrorPortClosed => 'A conexão USB não está ativa.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName => 'Dispositivo de Serial para a Web';
+
+ @override
+ String get usbStatus_notConnected => 'Selecione um dispositivo USB';
+
+ @override
+ String get usbStatus_connecting => 'Conectando ao dispositivo USB...';
+
+ @override
+ String get usbStatus_searching => 'Procurando por dispositivos USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Falha na conexão USB: $error';
+ }
+
@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 affb256..69a5891 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -111,6 +111,92 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Подключение через USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Выберите USB-устройство';
+
+ @override
+ String get usbScreenNote =>
+ 'USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Не обнаружено устройств USB. Подключите одно из них и обновите список.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Запрос на доступ через USB был отклонен.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Выбранное USB-устройство больше недоступно.';
+
+ @override
+ String get usbErrorInvalidPort => 'Выберите действительное USB-устройство.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Еще одно запрошенное соединение через USB уже находится в процессе.';
+
+ @override
+ String get usbErrorNotConnected => 'Ни одно USB-устройство не подключено.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Не удалось открыть выбранное USB-устройство.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Не удалось установить соединение с выбранным USB-устройством.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'Поддержка последовательного USB отсутствует на данной платформе.';
+
+ @override
+ String get usbErrorAlreadyActive => 'USB-соединение уже установлено.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Не было выбрано ни одно устройство USB.';
+
+ @override
+ String get usbErrorPortClosed => 'USB-соединение не установлено.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Устройство для последовательного подключения к сети';
+
+ @override
+ String get usbStatus_notConnected => 'Выберите USB-устройство';
+
+ @override
+ String get usbStatus_connecting => 'Подключение к USB-устройству...';
+
+ @override
+ String get usbStatus_searching => 'Поиск USB-устройств...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Не удалось установить соединение через USB: $error';
+ }
+
@override
String get scanner_scanning => 'Поиск устройств...';
diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart
index 0861ca8..d0e75b0 100644
--- a/lib/l10n/app_localizations_sk.dart
+++ b/lib/l10n/app_localizations_sk.dart
@@ -111,6 +111,91 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Pripojte cez USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.';
+
+ @override
+ String get usbScreenStatus => 'Vyberte USB zariadenie';
+
+ @override
+ String get usbScreenNote =>
+ 'USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Žiadosť o prístup cez USB bola zamietnutá.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Vybrané USB zariadenie už nie je dostupné.';
+
+ @override
+ String get usbErrorInvalidPort => 'Vyberte platné USB zariadenie.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Ďalšia požiadavka na pripojenie cez USB je aktuálne v procese.';
+
+ @override
+ String get usbErrorNotConnected => 'Nie je pripojené žiadne USB zariadenie.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Nepodarilo sa otvoriť vybrané USB zariadenie.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Nepodarilo sa sa sa pripojiť k vybranému USB zariadeniu.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'Podpora USB sériového rozhrania nie je na tejto platforme dostupná.';
+
+ @override
+ String get usbErrorAlreadyActive => 'Pripojenie cez USB je už aktivované.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Nebolo vybrané žiadne USB zariadenie.';
+
+ @override
+ String get usbErrorPortClosed => 'Pripojenie cez USB nie je aktivované.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName => 'Webový sériový zariadenie';
+
+ @override
+ String get usbStatus_notConnected => 'Vyberte USB zariadenie';
+
+ @override
+ String get usbStatus_connecting => 'Pripojenie k USB zariadeniu...';
+
+ @override
+ String get usbStatus_searching => 'Hľadanie USB zariadení...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Neúspešné pripojenie cez USB: $error';
+ }
+
@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 0b4bd0f..21e3d9d 100644
--- a/lib/l10n/app_localizations_sl.dart
+++ b/lib/l10n/app_localizations_sl.dart
@@ -111,6 +111,89 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Povežite preko USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Izberite zaznano serijsko napravo in se neposredno povežite z vašo MeshCore napravo.';
+
+ @override
+ String get usbScreenStatus => 'Izberite USB naprave';
+
+ @override
+ String get usbScreenNote =>
+ 'USB serijska povezava je aktivna na podprtih napravah Android in na desktop platformah.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Niti en USB naprave niso najdeni. Povežite eno in posodobite.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Dovoljenje za dostop preko USB-ja je bilo zavrnjeno.';
+
+ @override
+ String get usbErrorDeviceMissing => 'Izbrani USB napravej je več ne.';
+
+ @override
+ String get usbErrorInvalidPort => 'Izberite veljavno USB naprave.';
+
+ @override
+ String get usbErrorBusy => 'Že je v teku zahteva za povezavo preko USB.';
+
+ @override
+ String get usbErrorNotConnected => 'Ni priklopljenih USB naprave.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Uspešno ni bilo mogo, da se odpre izbran naprave USB.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Niso bilo mogoče uskladiti povezave z izbranim USB napom.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'USB serijska komunikacija ni podprta na tej platformi.';
+
+ @override
+ String get usbErrorAlreadyActive => 'USB povezava je že aktivirana.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'Ni bilo izbranega USB naprave.';
+
+ @override
+ String get usbErrorPortClosed => 'USB povezava ni aktivirana.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Naprave za serijsko komunikacijo preko spleta';
+
+ @override
+ String get usbStatus_notConnected => 'Izberite USB naprave.';
+
+ @override
+ String get usbStatus_connecting => 'Povezava z USB napravo...';
+
+ @override
+ String get usbStatus_searching => 'Iskanje USB naprav...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Napaka pri povezavi preko USB: $error';
+ }
+
@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 7281f10..5951fae 100644
--- a/lib/l10n/app_localizations_sv.dart
+++ b/lib/l10n/app_localizations_sv.dart
@@ -111,6 +111,89 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Anslut via USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.';
+
+ @override
+ String get usbScreenStatus => 'Välj en USB-enhet';
+
+ @override
+ String get usbScreenNote =>
+ 'USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Inga USB-enheter hittades. Anslut en och uppdatera.';
+
+ @override
+ String get usbErrorPermissionDenied => 'Tillgången via USB nekas.';
+
+ @override
+ String get usbErrorDeviceMissing =>
+ 'Den valda USB-enheten är inte längre tillgänglig.';
+
+ @override
+ String get usbErrorInvalidPort => 'Välj en giltig USB-enhet.';
+
+ @override
+ String get usbErrorBusy =>
+ 'En annan förfrågan om USB-anslutning är redan pågående.';
+
+ @override
+ String get usbErrorNotConnected => 'Ingen USB-enhet är ansluten.';
+
+ @override
+ String get usbErrorOpenFailed =>
+ 'Misslyckades med att öppna det valda USB-enheten.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Kunde inte ansluta till det valda USB-enheten.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'USB-seriell kommunikation stöds inte på denna plattform.';
+
+ @override
+ String get usbErrorAlreadyActive => 'En USB-anslutning är redan aktiv.';
+
+ @override
+ String get usbErrorNoDeviceSelected => 'Ingen USB-enhet valdes.';
+
+ @override
+ String get usbErrorPortClosed => 'USB-anslutningen är inte aktiv.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.';
+
+ @override
+ String get usbFallbackDeviceName => 'Web-serieenhet';
+
+ @override
+ String get usbStatus_notConnected => 'Välj en USB-enhet';
+
+ @override
+ String get usbStatus_connecting => 'Anslutning till USB-enhet...';
+
+ @override
+ String get usbStatus_searching => 'Söker efter USB-enheter...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Fel vid USB-anslutning: $error';
+ }
+
@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 5bf6da2..b8fd60a 100644
--- a/lib/l10n/app_localizations_uk.dart
+++ b/lib/l10n/app_localizations_uk.dart
@@ -111,6 +111,90 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get scanner_title => 'MeshCore Open';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => 'Bluetooth';
+
+ @override
+ String get usbScreenTitle => 'Підключити через USB';
+
+ @override
+ String get usbScreenSubtitle =>
+ 'Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.';
+
+ @override
+ String get usbScreenStatus => 'Виберіть пристрій USB';
+
+ @override
+ String get usbScreenNote =>
+ 'USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.';
+
+ @override
+ String get usbScreenEmptyState =>
+ 'Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.';
+
+ @override
+ String get usbErrorPermissionDenied =>
+ 'Було відмовлено у наданні дозволу на використання USB.';
+
+ @override
+ String get usbErrorDeviceMissing => 'Вибране USB-пристрій більше недоступне.';
+
+ @override
+ String get usbErrorInvalidPort => 'Виберіть дійсний USB-пристрій.';
+
+ @override
+ String get usbErrorBusy =>
+ 'Ще один запит на підключення через USB вже обробляється.';
+
+ @override
+ String get usbErrorNotConnected => 'Немає підключених пристроїв USB.';
+
+ @override
+ String get usbErrorOpenFailed => 'Не вдалося відкрити вибране USB-пристрій.';
+
+ @override
+ String get usbErrorConnectFailed =>
+ 'Не вдалося підключитися до вибраного USB-пристрою.';
+
+ @override
+ String get usbErrorUnsupported =>
+ 'Підтримка USB-серіального інтерфейсу не реалізована на цій платформі.';
+
+ @override
+ String get usbErrorAlreadyActive => 'USB-з\'єднання вже встановлено.';
+
+ @override
+ String get usbErrorNoDeviceSelected =>
+ 'Не було вибрано жодного пристрою USB.';
+
+ @override
+ String get usbErrorPortClosed => 'З\'єднання USB не встановлено.';
+
+ @override
+ String get usbErrorConnectTimedOut =>
+ 'З\'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.';
+
+ @override
+ String get usbFallbackDeviceName =>
+ 'Пристрій для передачі даних по веб-серіалах';
+
+ @override
+ String get usbStatus_notConnected => 'Виберіть пристрій USB';
+
+ @override
+ String get usbStatus_connecting => 'Підключення до USB-пристрою...';
+
+ @override
+ String get usbStatus_searching => 'Пошук пристроїв USB...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'Не вдалося встановити з\'єднання через USB: $error';
+ }
+
@override
String get scanner_scanning => 'Пошук пристроїв...';
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index c3ee3e6..27e6c21 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -111,6 +111,80 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get scanner_title => '连接设备';
+ @override
+ String get connectionChoiceUsbLabel => 'USB';
+
+ @override
+ String get connectionChoiceBluetoothLabel => '蓝牙';
+
+ @override
+ String get usbScreenTitle => '通过USB连接';
+
+ @override
+ String get usbScreenSubtitle => '选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。';
+
+ @override
+ String get usbScreenStatus => '选择一个 USB 设备';
+
+ @override
+ String get usbScreenNote => 'USB 串行接口在支持的 Android 设备和桌面平台上处于活动状态。';
+
+ @override
+ String get usbScreenEmptyState => '未找到任何 USB 设备。请插入一个,然后刷新。';
+
+ @override
+ String get usbErrorPermissionDenied => '拒绝了USB权限。';
+
+ @override
+ String get usbErrorDeviceMissing => '所选的USB设备已不再可用。';
+
+ @override
+ String get usbErrorInvalidPort => '选择一个有效的USB设备。';
+
+ @override
+ String get usbErrorBusy => '还有一个 USB 连接请求正在进行中。';
+
+ @override
+ String get usbErrorNotConnected => '没有连接任何USB设备。';
+
+ @override
+ String get usbErrorOpenFailed => '未能打开所选的USB设备。';
+
+ @override
+ String get usbErrorConnectFailed => '未能连接到所选的USB设备。';
+
+ @override
+ String get usbErrorUnsupported => '此平台不支持USB串行通信。';
+
+ @override
+ String get usbErrorAlreadyActive => 'USB 连接已建立。';
+
+ @override
+ String get usbErrorNoDeviceSelected => '未选择任何 USB 设备。';
+
+ @override
+ String get usbErrorPortClosed => 'USB 连接未建立。';
+
+ @override
+ String get usbErrorConnectTimedOut => '连接超时。请确保设备已安装 USB 伴侣固件。';
+
+ @override
+ String get usbFallbackDeviceName => 'Web 串流设备';
+
+ @override
+ String get usbStatus_notConnected => '选择一个 USB 设备';
+
+ @override
+ String get usbStatus_connecting => '连接USB设备...';
+
+ @override
+ String get usbStatus_searching => '正在搜索 USB 设备...';
+
+ @override
+ String usbConnectionFailed(String error) {
+ return 'USB 连接失败:$error';
+ }
+
@override
String get scanner_scanning => '正在搜索设备...';
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
index cde7457..94df130 100644
--- a/lib/l10n/app_nl.arb
+++ b/lib/l10n/app_nl.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAll": "Verwijder alle ontdekte contacten",
"discoveredContacts_deleteContactAllContent": "Weet u zeker dat u alle ontdekte contacten wilt verwijderen?",
"map_guessedLocation": "Geroerde locatie",
- "map_showGuessedLocations": "Toon de voorspelde locaties van de knopen"
+ "map_showGuessedLocations": "Toon de voorspelde locaties van de knopen",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenSubtitle": "Selecteer een gedetecteerd seriële apparaat en verbind deze direct met uw MeshCore-node.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "Verbind via USB",
+ "usbScreenStatus": "Selecteer een USB-apparaat",
+ "usbScreenNote": "USB-serieel is actief op ondersteunde Android-apparaten en desktop-platforms.",
+ "usbScreenEmptyState": "Geen USB-apparaten gevonden. Sluit er een aan en herlaad.",
+ "usbErrorPermissionDenied": "Toegang via USB is geweigerd.",
+ "usbErrorDeviceMissing": "Het geselecteerde USB-apparaat is niet meer beschikbaar.",
+ "usbErrorInvalidPort": "Selecteer een geldig USB-apparaat.",
+ "usbErrorBusy": "Een andere verzoek om een USB-verbinding is al in behandeling.",
+ "usbErrorNotConnected": "Er is geen USB-apparaat aangesloten.",
+ "usbErrorOpenFailed": "Kon het geselecteerde USB-apparaat niet openen.",
+ "usbErrorConnectFailed": "Kon niet verbinding maken met het geselecteerde USB-apparaat.",
+ "usbErrorUnsupported": "USB-serieel is niet ondersteund op deze platform.",
+ "usbErrorAlreadyActive": "Een USB-verbinding is al actief.",
+ "usbErrorNoDeviceSelected": "Geen USB-apparaat is geselecteerd.",
+ "usbErrorPortClosed": "De USB-verbinding is niet actief.",
+ "usbFallbackDeviceName": "Web-serieapparaat",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbConnectionFailed": "Fout bij de USB-verbinding: {error}",
+ "usbStatus_notConnected": "Selecteer een USB-apparaat",
+ "usbStatus_connecting": "Verbinding maken met USB-apparaat...",
+ "usbStatus_searching": "Zoeken naar USB-apparaten...",
+ "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft."
}
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 41152a5..d020e0e 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAllContent": "Czy na pewno chcesz usunąć wszystkie znalezione kontakty?",
"discoveredContacts_deleteContactAll": "Usuń wszystkie odkryte kontakty",
"map_guessedLocation": "Wydana lokalizacja",
- "map_showGuessedLocations": "Wyświetl lokalizacje zgadanych węzłów"
+ "map_showGuessedLocations": "Wyświetl lokalizacje zgadanych węzłów",
+ "usbScreenSubtitle": "Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.",
+ "usbScreenTitle": "Połącz przez USB",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenStatus": "Wybierz urządzenie USB",
+ "usbScreenNote": "Port szeregowy USB jest aktywny na urządzeniach z systemem Android i platformach stacjonarnych, które go obsługują.",
+ "usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.",
+ "usbErrorPermissionDenied": "Zostało odrzucone żądanie dostępu przez USB.",
+ "usbErrorDeviceMissing": "Wybór urządzenia USB już nie jest dostępny.",
+ "usbErrorInvalidPort": "Wybierz prawidłowe urządzenie USB.",
+ "usbErrorBusy": "Kolejne żądanie połączenia przez USB jest już w trakcie realizacji.",
+ "usbErrorNotConnected": "Brak podłączonego urządzenia USB.",
+ "usbErrorOpenFailed": "Nie udało się otworzyć wybranego urządzenia USB.",
+ "usbErrorConnectFailed": "Nie udało się połączyć z wybranym urządzeniem USB.",
+ "usbErrorUnsupported": "Port szeregowy USB nie jest obsługiwany na tym urządzeniu.",
+ "usbErrorAlreadyActive": "Połączenie USB jest już aktywne.",
+ "usbErrorNoDeviceSelected": "Nie został wybrany żaden urządzenie USB.",
+ "usbErrorPortClosed": "Połączenie USB nie jest aktywne.",
+ "usbFallbackDeviceName": "Urządzenie do komunikacji przez sieć (seria)",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Wyszukiwanie urządzeń USB...",
+ "usbStatus_connecting": "Połączenie z urządzeniem USB...",
+ "usbStatus_notConnected": "Wybierz urządzenie USB",
+ "usbConnectionFailed": "Błąd połączenia USB: {error}",
+ "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"."
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index f843119..d52cb41 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAll": "Excluir Todos os Contatos Descobertos",
"discoveredContacts_deleteContactAllContent": "Tem certeza de que deseja excluir todos os contatos descobertos?",
"map_guessedLocation": "Localização estimada",
- "map_showGuessedLocations": "Mostrar as localizações dos nós estimados"
+ "map_showGuessedLocations": "Mostrar as localizações dos nós estimados",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenTitle": "Conecte via USB",
+ "usbScreenSubtitle": "Selecione um dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.",
+ "usbScreenStatus": "Selecione um dispositivo USB",
+ "usbScreenNote": "A comunicação serial via USB está ativa em dispositivos Android e plataformas de desktop compatíveis.",
+ "usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize.",
+ "usbErrorPermissionDenied": "A permissão para acesso via USB foi negada.",
+ "usbErrorDeviceMissing": "O dispositivo USB selecionado não está mais disponível.",
+ "usbErrorInvalidPort": "Selecione um dispositivo USB válido.",
+ "usbErrorBusy": "Já existe uma solicitação de conexão USB em andamento.",
+ "usbErrorNotConnected": "Não há nenhum dispositivo USB conectado.",
+ "usbErrorOpenFailed": "Não foi possível abrir o dispositivo USB selecionado.",
+ "usbErrorConnectFailed": "Não foi possível conectar ao dispositivo USB selecionado.",
+ "usbErrorUnsupported": "A comunicação serial via USB não é suportada nesta plataforma.",
+ "usbErrorAlreadyActive": "A conexão USB já está ativa.",
+ "usbErrorNoDeviceSelected": "Nenhum dispositivo USB foi selecionado.",
+ "usbErrorPortClosed": "A conexão USB não está ativa.",
+ "usbFallbackDeviceName": "Dispositivo de Serial para a Web",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Procurando por dispositivos USB...",
+ "usbStatus_notConnected": "Selecione um dispositivo USB",
+ "usbConnectionFailed": "Falha na conexão USB: {error}",
+ "usbStatus_connecting": "Conectando ao dispositivo USB...",
+ "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion."
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 0897cba..92fd55e 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1068,5 +1068,36 @@
"discoveredContacts_deleteContactAllContent": "Вы уверены, что хотите удалить все обнаруженные контакты?",
"discoveredContacts_deleteContactAll": "Удалить Все Обнаруженные Контакты",
"map_guessedLocation": "Угаданное место",
- "map_showGuessedLocations": "Отобразить предполагаемые места расположения узлов"
+ "map_showGuessedLocations": "Отобразить предполагаемые места расположения узлов",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenSubtitle": "Выберите обнаруженное устройство с последовательным интерфейсом и подключите его напрямую к вашему узлу MeshCore.",
+ "usbScreenTitle": "Подключение через USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenStatus": "Выберите USB-устройство",
+ "usbScreenNote": "USB-серийный порт активен на поддерживаемых устройствах Android и на настольных платформах.",
+ "usbScreenEmptyState": "Не обнаружено устройств USB. Подключите одно из них и обновите список.",
+ "usbErrorPermissionDenied": "Запрос на доступ через USB был отклонен.",
+ "usbErrorDeviceMissing": "Выбранное USB-устройство больше недоступно.",
+ "usbErrorInvalidPort": "Выберите действительное USB-устройство.",
+ "usbErrorBusy": "Еще одно запрошенное соединение через USB уже находится в процессе.",
+ "usbErrorNotConnected": "Ни одно USB-устройство не подключено.",
+ "usbErrorOpenFailed": "Не удалось открыть выбранное USB-устройство.",
+ "usbErrorConnectFailed": "Не удалось установить соединение с выбранным USB-устройством.",
+ "usbErrorUnsupported": "Поддержка последовательного USB отсутствует на данной платформе.",
+ "usbErrorAlreadyActive": "USB-соединение уже установлено.",
+ "usbErrorNoDeviceSelected": "Не было выбрано ни одно устройство USB.",
+ "usbErrorPortClosed": "USB-соединение не установлено.",
+ "usbFallbackDeviceName": "Устройство для последовательного подключения к сети",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Поиск USB-устройств...",
+ "usbStatus_connecting": "Подключение к USB-устройству...",
+ "usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
+ "usbStatus_notConnected": "Выберите USB-устройство",
+ "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion."
}
diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb
index 16aae9b..141147c 100644
--- a/lib/l10n/app_sk.arb
+++ b/lib/l10n/app_sk.arb
@@ -1828,5 +1828,36 @@
"common_deleteAll": "Zmazať všetko",
"discoveredContacts_deleteContactAllContent": "Ste si istí, že chcete zmazať všetky objavené kontakty?",
"map_showGuessedLocations": "Zobraziť umiestnenia odhadnutých uzlov",
- "map_guessedLocation": "Odhadnutá lokalita"
+ "map_guessedLocation": "Odhadnutá lokalita",
+ "usbScreenTitle": "Pripojte cez USB",
+ "usbScreenSubtitle": "Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.",
+ "connectionChoiceUsbLabel": "USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "usbScreenStatus": "Vyberte USB zariadenie",
+ "usbScreenNote": "USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.",
+ "usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.",
+ "usbErrorPermissionDenied": "Žiadosť o prístup cez USB bola zamietnutá.",
+ "usbErrorDeviceMissing": "Vybrané USB zariadenie už nie je dostupné.",
+ "usbErrorInvalidPort": "Vyberte platné USB zariadenie.",
+ "usbErrorBusy": "Ďalšia požiadavka na pripojenie cez USB je aktuálne v procese.",
+ "usbErrorNotConnected": "Nie je pripojené žiadne USB zariadenie.",
+ "usbErrorOpenFailed": "Nepodarilo sa otvoriť vybrané USB zariadenie.",
+ "usbErrorConnectFailed": "Nepodarilo sa sa sa pripojiť k vybranému USB zariadeniu.",
+ "usbErrorUnsupported": "Podpora USB sériového rozhrania nie je na tejto platforme dostupná.",
+ "usbErrorAlreadyActive": "Pripojenie cez USB je už aktivované.",
+ "usbErrorNoDeviceSelected": "Nebolo vybrané žiadne USB zariadenie.",
+ "usbErrorPortClosed": "Pripojenie cez USB nie je aktivované.",
+ "usbFallbackDeviceName": "Webový sériový zariadenie",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Hľadanie USB zariadení...",
+ "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
+ "usbStatus_notConnected": "Vyberte USB zariadenie",
+ "usbStatus_connecting": "Pripojenie k USB zariadeniu...",
+ "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion."
}
diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb
index 97d913f..12529d6 100644
--- a/lib/l10n/app_sl.arb
+++ b/lib/l10n/app_sl.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAllContent": "Ste prepričani, da želite izbrisati vse odkrite kontakte?",
"discoveredContacts_deleteContactAll": "Izbriši vse odkrite kontakte",
"map_guessedLocation": "Predpostavljena lokacija",
- "map_showGuessedLocations": "Pokaži lokacije domnevnih not."
+ "map_showGuessedLocations": "Pokaži lokacije domnevnih not.",
+ "usbScreenTitle": "Povežite preko USB",
+ "usbScreenSubtitle": "Izberite zaznano serijsko napravo in se neposredno povežite z vašo MeshCore napravo.",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "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.",
+ "usbErrorPermissionDenied": "Dovoljenje za dostop preko USB-ja je bilo zavrnjeno.",
+ "usbErrorDeviceMissing": "Izbrani USB napravej je več ne.",
+ "usbErrorInvalidPort": "Izberite veljavno USB naprave.",
+ "usbErrorBusy": "Že je v teku zahteva za povezavo preko USB.",
+ "usbErrorNotConnected": "Ni priklopljenih USB naprave.",
+ "usbErrorOpenFailed": "Uspešno ni bilo mogo, da se odpre izbran naprave USB.",
+ "usbErrorConnectFailed": "Niso bilo mogoče uskladiti povezave z izbranim USB napom.",
+ "usbErrorUnsupported": "USB serijska komunikacija ni podprta na tej platformi.",
+ "usbErrorAlreadyActive": "USB povezava je že aktivirana.",
+ "usbErrorNoDeviceSelected": "Ni bilo izbranega USB naprave.",
+ "usbErrorPortClosed": "USB povezava ni aktivirana.",
+ "usbFallbackDeviceName": "Naprave za serijsko komunikacijo preko spleta",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_notConnected": "Izberite USB naprave.",
+ "usbStatus_connecting": "Povezava z USB napravo...",
+ "usbStatus_searching": "Iskanje USB naprav...",
+ "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
+ "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion."
}
diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb
index b451082..f7615df 100644
--- a/lib/l10n/app_sv.arb
+++ b/lib/l10n/app_sv.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAllContent": "Är du säker på att du vill ta bort alla upptäckta kontakter?",
"discoveredContacts_deleteContactAll": "Ta bort alla upptäckta kontakter",
"map_guessedLocation": "Gissad plats",
- "map_showGuessedLocations": "Visa upp de antagna nodernas placeringar"
+ "map_showGuessedLocations": "Visa upp de antagna nodernas placeringar",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
+ "usbScreenTitle": "Anslut via USB",
+ "usbScreenStatus": "Välj en USB-enhet",
+ "usbScreenNote": "USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.",
+ "usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera.",
+ "usbErrorPermissionDenied": "Tillgången via USB nekas.",
+ "usbErrorDeviceMissing": "Den valda USB-enheten är inte längre tillgänglig.",
+ "usbErrorInvalidPort": "Välj en giltig USB-enhet.",
+ "usbErrorBusy": "En annan förfrågan om USB-anslutning är redan pågående.",
+ "usbErrorNotConnected": "Ingen USB-enhet är ansluten.",
+ "usbErrorOpenFailed": "Misslyckades med att öppna det valda USB-enheten.",
+ "usbErrorConnectFailed": "Kunde inte ansluta till det valda USB-enheten.",
+ "usbErrorUnsupported": "USB-seriell kommunikation stöds inte på denna plattform.",
+ "usbErrorAlreadyActive": "En USB-anslutning är redan aktiv.",
+ "usbErrorNoDeviceSelected": "Ingen USB-enhet valdes.",
+ "usbErrorPortClosed": "USB-anslutningen är inte aktiv.",
+ "usbFallbackDeviceName": "Web-serieenhet",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_connecting": "Anslutning till USB-enhet...",
+ "usbStatus_notConnected": "Välj en USB-enhet",
+ "usbConnectionFailed": "Fel vid USB-anslutning: {error}",
+ "usbStatus_searching": "Söker efter USB-enheter...",
+ "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware."
}
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index fb60b81..7794098 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -1828,5 +1828,36 @@
"discoveredContacts_deleteContactAll": "Видалити всі виявлені контакти",
"discoveredContacts_deleteContactAllContent": "Ви впевнені, що хочете видалити всі виявлені контакти?",
"map_showGuessedLocations": "Показати місцезнаходження передбачених вузлів",
- "map_guessedLocation": "Визначено місцезнаходження"
+ "map_guessedLocation": "Визначено місцезнаходження",
+ "usbScreenSubtitle": "Виберіть виявлене серійне пристрій і підключіть його безпосередньо до вашого вузла MeshCore.",
+ "usbScreenTitle": "Підключити через USB",
+ "connectionChoiceBluetoothLabel": "Bluetooth",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenStatus": "Виберіть пристрій USB",
+ "usbScreenNote": "USB-серіальний інтерфейс активний на підтримуваних пристроях на базі Android та на десктопних платформах.",
+ "usbScreenEmptyState": "Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.",
+ "usbErrorPermissionDenied": "Було відмовлено у наданні дозволу на використання USB.",
+ "usbErrorDeviceMissing": "Вибране USB-пристрій більше недоступне.",
+ "usbErrorInvalidPort": "Виберіть дійсний USB-пристрій.",
+ "usbErrorBusy": "Ще один запит на підключення через USB вже обробляється.",
+ "usbErrorNotConnected": "Немає підключених пристроїв USB.",
+ "usbErrorOpenFailed": "Не вдалося відкрити вибране USB-пристрій.",
+ "usbErrorConnectFailed": "Не вдалося підключитися до вибраного USB-пристрою.",
+ "usbErrorUnsupported": "Підтримка USB-серіального інтерфейсу не реалізована на цій платформі.",
+ "usbErrorAlreadyActive": "USB-з'єднання вже встановлено.",
+ "usbErrorNoDeviceSelected": "Не було вибрано жодного пристрою USB.",
+ "usbErrorPortClosed": "З'єднання USB не встановлено.",
+ "usbFallbackDeviceName": "Пристрій для передачі даних по веб-серіалах",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "Пошук пристроїв USB...",
+ "usbStatus_notConnected": "Виберіть пристрій USB",
+ "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
+ "usbStatus_connecting": "Підключення до USB-пристрою...",
+ "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion."
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 51bd60c..dfc8e64 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -1833,5 +1833,36 @@
"discoveredContacts_deleteContactAllContent": "您确定要删除所有发现的联系人吗?",
"discoveredContacts_deleteContactAll": "删除所有发现的联系人",
"map_showGuessedLocations": "显示猜测的节点位置",
- "map_guessedLocation": "猜测的位置"
+ "map_guessedLocation": "猜测的位置",
+ "connectionChoiceUsbLabel": "USB",
+ "usbScreenTitle": "通过USB连接",
+ "usbScreenSubtitle": "选择已检测到的串行设备,并直接连接到您的 MeshCore 节点。",
+ "connectionChoiceBluetoothLabel": "蓝牙",
+ "usbScreenStatus": "选择一个 USB 设备",
+ "usbScreenNote": "USB 串行接口在支持的 Android 设备和桌面平台上处于活动状态。",
+ "usbScreenEmptyState": "未找到任何 USB 设备。请插入一个,然后刷新。",
+ "usbErrorPermissionDenied": "拒绝了USB权限。",
+ "usbErrorDeviceMissing": "所选的USB设备已不再可用。",
+ "usbErrorInvalidPort": "选择一个有效的USB设备。",
+ "usbErrorBusy": "还有一个 USB 连接请求正在进行中。",
+ "usbErrorNotConnected": "没有连接任何USB设备。",
+ "usbErrorOpenFailed": "未能打开所选的USB设备。",
+ "usbErrorConnectFailed": "未能连接到所选的USB设备。",
+ "usbErrorUnsupported": "此平台不支持USB串行通信。",
+ "usbErrorAlreadyActive": "USB 连接已建立。",
+ "usbErrorNoDeviceSelected": "未选择任何 USB 设备。",
+ "usbErrorPortClosed": "USB 连接未建立。",
+ "usbFallbackDeviceName": "Web 串流设备",
+ "@usbConnectionFailed": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "usbStatus_searching": "正在搜索 USB 设备...",
+ "usbStatus_connecting": "连接USB设备...",
+ "usbStatus_notConnected": "选择一个 USB 设备",
+ "usbConnectionFailed": "USB 连接失败:{error}",
+ "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。"
}
diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart
index e5cc785..3fef9ec 100644
--- a/lib/screens/contacts_screen.dart
+++ b/lib/screens/contacts_screen.dart
@@ -401,8 +401,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());
}
diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart
index 4017408..45fd3fb 100644
--- a/lib/screens/scanner_screen.dart
+++ b/lib/screens/scanner_screen.dart
@@ -6,9 +6,11 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
+import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import 'contacts_screen.dart';
+import 'usb_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatefulWidget {
@@ -20,6 +22,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 +30,15 @@ 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) {
+ final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
+ if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
- } else if (connector.state == MeshCoreConnectionState.connected &&
+ } else if (_connector.state == MeshCoreConnectionState.connected &&
+ _connector.activeTransport == MeshCoreTransportType.bluetooth &&
+ isCurrentRoute &&
!_changedNavigation) {
_changedNavigation = true;
if (mounted) {
@@ -43,7 +49,7 @@ class _ScannerScreenState extends State {
}
};
- connector.addListener(_connectionListener);
+ _connector.addListener(_connectionListener);
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
(state) {
@@ -53,28 +59,42 @@ class _ScannerScreenState extends State {
});
// Cancel scan if Bluetooth turns off while scanning
if (state != BluetoothAdapterState.on) {
- unawaited(connector.stopScan());
+ unawaited(_connector.stopScan());
}
}
},
onError: (Object e) {
- debugPrint("Scanner adapterState stream error: $e");
+ appLogger.warn('Adapter state stream error: $e', tag: 'ScannerScreen');
},
);
}
@override
void dispose() {
- final connector = Provider.of(context, listen: false);
- connector.removeListener(_connectionListener);
+ _connector.removeListener(_connectionListener);
unawaited(_bluetoothStateSubscription.cancel());
+ if (!_changedNavigation) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_connector.disconnect(manual: true));
+ });
+ }
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: () {
+ appLogger.info('Back button pressed', tag: 'ScannerScreen');
+ Navigator.of(context).maybePop();
+ },
+ )
+ : null,
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
@@ -99,40 +119,67 @@ class _ScannerScreenState extends State {
},
),
),
- floatingActionButton: Consumer(
+ bottomNavigationBar: Consumer(
builder: (context, connector, child) {
final isScanning =
connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
+ final usbSupported = PlatformInfo.supportsUsbSerial;
- return FloatingActionButton.extended(
- onPressed: isBluetoothOff
- ? null
- : () {
- if (isScanning) {
- connector.stopScan();
- } else {
- unawaited(
- connector.startScan().catchError((e) {
- debugPrint("Scanner screen startScan error: $e");
- }),
+ return SafeArea(
+ top: false,
+ minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ if (usbSupported)
+ FloatingActionButton.extended(
+ onPressed: () {
+ appLogger.info(
+ 'USB selected, opening UsbScreen',
+ tag: 'ScannerScreen',
);
- }
- },
- icon: isScanning
- ? const SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: Colors.white,
- ),
- )
- : const Icon(Icons.bluetooth_searching),
- label: Text(
- isScanning
- ? context.l10n.scanner_stop
- : context.l10n.scanner_scan,
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const UsbScreen()),
+ );
+ },
+ heroTag: 'scanner_usb_action',
+ icon: const Icon(Icons.usb),
+ label: Text(context.l10n.connectionChoiceUsbLabel),
+ ),
+ if (usbSupported) const SizedBox(width: 12),
+ FloatingActionButton.extended(
+ heroTag: 'scanner_ble_action',
+ onPressed: isBluetoothOff
+ ? null
+ : () {
+ if (isScanning) {
+ connector.stopScan();
+ } else {
+ unawaited(
+ connector.startScan().catchError((e) {
+ appLogger.warn(
+ 'startScan error: $e',
+ tag: 'ScannerScreen',
+ );
+ }),
+ );
+ }
+ },
+ icon: isScanning
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.bluetooth_searching),
+ label: Text(
+ isScanning
+ ? context.l10n.scanner_stop
+ : context.l10n.scanner_scan,
+ ),
+ ),
+ ],
),
);
},
diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart
new file mode 100644
index 0000000..e230a54
--- /dev/null
+++ b/lib/screens/usb_screen.dart
@@ -0,0 +1,407 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
+import '../connector/meshcore_connector.dart';
+import '../l10n/l10n.dart';
+import '../utils/app_logger.dart';
+import '../utils/platform_info.dart';
+import '../utils/usb_port_labels.dart';
+import '../widgets/adaptive_app_bar_title.dart';
+import 'contacts_screen.dart';
+import 'scanner_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 _navigatedToContacts = false;
+ bool _didScheduleInitialLoad = false;
+ Timer? _hotPlugTimer;
+ late final MeshCoreConnector _connector;
+ late final VoidCallback _connectionListener;
+
+ bool get _supportsHotPlug =>
+ PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
+
+ @override
+ void initState() {
+ super.initState();
+ _connector = context.read();
+ _connectionListener = () {
+ if (!mounted) return;
+ if (_connector.state == MeshCoreConnectionState.disconnected) {
+ _navigatedToContacts = false;
+ }
+ if (_connector.state == MeshCoreConnectionState.connected &&
+ _connector.isUsbTransportConnected &&
+ !_navigatedToContacts) {
+ _navigatedToContacts = true;
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(builder: (_) => const ContactsScreen()),
+ );
+ }
+ };
+ _connector.addListener(_connectionListener);
+ _startHotPlugTimer();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ _connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
+ _connector.setUsbFallbackDeviceName(context.l10n.usbFallbackDeviceName);
+ if (!_didScheduleInitialLoad) {
+ _didScheduleInitialLoad = true;
+ unawaited(_loadPorts());
+ }
+ }
+
+ @override
+ void dispose() {
+ _hotPlugTimer?.cancel();
+ _hotPlugTimer = null;
+ _connector.removeListener(_connectionListener);
+ if (!_navigatedToContacts &&
+ _connector.activeTransport == MeshCoreTransportType.usb &&
+ _connector.state != MeshCoreConnectionState.disconnected) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_connector.disconnect(manual: true));
+ });
+ }
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => Navigator.of(context).maybePop(),
+ ),
+ title: AdaptiveAppBarTitle(context.l10n.usbScreenTitle),
+ centerTitle: true,
+ ),
+ body: SafeArea(
+ top: false,
+ child: Consumer(
+ builder: (context, connector, child) {
+ return Column(
+ children: [
+ _buildStatusBar(context, connector),
+ Expanded(child: _buildPortList(context, connector)),
+ ],
+ );
+ },
+ ),
+ ),
+ bottomNavigationBar: Consumer(
+ builder: (context, connector, child) {
+ final isLoading = _isLoadingPorts;
+ final showBle =
+ PlatformInfo.isWeb ||
+ PlatformInfo.isAndroid ||
+ PlatformInfo.isIOS;
+
+ return SafeArea(
+ top: false,
+ minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ if (showBle)
+ FloatingActionButton.extended(
+ onPressed: () {
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(
+ builder: (_) => const ScannerScreen(),
+ ),
+ );
+ },
+ heroTag: 'usb_ble_action',
+ icon: const Icon(Icons.bluetooth),
+ label: Text(context.l10n.connectionChoiceBluetoothLabel),
+ ),
+ if (showBle) const SizedBox(width: 12),
+ if (!_supportsHotPlug)
+ FloatingActionButton.extended(
+ onPressed: isLoading ? null : _loadPorts,
+ heroTag: 'usb_refresh_action',
+ icon: isLoading
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.refresh),
+ label: Text(context.l10n.repeater_refresh),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
+ final l10n = context.l10n;
+ String statusText;
+ Color statusColor;
+
+ if (_isLoadingPorts) {
+ statusText = l10n.usbStatus_searching;
+ statusColor = Colors.blue;
+ } else if (connector.isUsbTransportConnected) {
+ switch (connector.state) {
+ case MeshCoreConnectionState.connected:
+ statusText = l10n.scanner_connectedTo(
+ connector.activeUsbPortDisplayLabel ?? 'USB',
+ );
+ statusColor = Colors.green;
+ case MeshCoreConnectionState.disconnecting:
+ statusText = l10n.scanner_disconnecting;
+ statusColor = Colors.orange;
+ default:
+ statusText = l10n.usbStatus_notConnected;
+ statusColor = Colors.grey;
+ }
+ } else if (connector.state == MeshCoreConnectionState.connecting &&
+ connector.activeTransport == MeshCoreTransportType.usb) {
+ statusText = l10n.usbStatus_connecting;
+ statusColor = Colors.orange;
+ } else {
+ statusText = l10n.usbStatus_notConnected;
+ statusColor = Colors.grey;
+ }
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+ color: statusColor.withValues(alpha: 0.1),
+ child: Row(
+ children: [
+ Icon(Icons.circle, size: 12, color: statusColor),
+ const SizedBox(width: 8),
+ Text(
+ statusText,
+ style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
+ final l10n = context.l10n;
+
+ if (_isLoadingPorts) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.usb, size: 64, color: Colors.grey[400]),
+ const SizedBox(height: 16),
+ Text(
+ l10n.usbStatus_searching,
+ style: TextStyle(fontSize: 16, color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ );
+ }
+
+ if (_ports.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.usb, size: 64, color: Colors.grey[400]),
+ const SizedBox(height: 16),
+ Text(
+ l10n.usbScreenEmptyState,
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 16, color: Colors.grey[600]),
+ ),
+ ],
+ ),
+ );
+ }
+
+ final isConnecting =
+ connector.state == MeshCoreConnectionState.connecting &&
+ connector.activeTransport == MeshCoreTransportType.usb;
+
+ return ListView.separated(
+ padding: const EdgeInsets.all(8),
+ itemCount: _ports.length,
+ separatorBuilder: (context, index) => const Divider(),
+ itemBuilder: (context, index) {
+ final port = _ports[index];
+ final displayName = friendlyUsbPortName(port);
+ final rawName = normalizeUsbPortName(port);
+ final showRawName =
+ rawName != displayName && !rawName.startsWith('web:');
+
+ return ListTile(
+ leading: const Icon(Icons.usb),
+ title: Text(
+ displayName,
+ style: const TextStyle(fontWeight: FontWeight.w500),
+ ),
+ subtitle: showRawName ? Text(rawName) : null,
+ trailing: ElevatedButton(
+ onPressed: isConnecting ? null : () => _connectPort(port),
+ child: Text(l10n.common_connect),
+ ),
+ onTap: isConnecting ? null : () => _connectPort(port),
+ );
+ },
+ );
+ }
+
+ void _startHotPlugTimer() {
+ if (!_supportsHotPlug) return;
+ _hotPlugTimer?.cancel();
+ _hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) {
+ _pollHotPlug();
+ });
+ }
+
+ Future _pollHotPlug() async {
+ if (_isLoadingPorts) return;
+ if (!mounted) return;
+ // Don't poll while connecting or connected.
+ if (_connector.state != MeshCoreConnectionState.disconnected) 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);
+ });
+ } catch (_) {
+ // Silent — hot-plug failures are non-critical.
+ }
+ }
+
+ Future _loadPorts() async {
+ if (!mounted) return;
+ _connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
+
+ setState(() {
+ _isLoadingPorts = true;
+ });
+
+ try {
+ final ports = await _connector.listUsbPorts();
+ if (!mounted) return;
+ setState(() {
+ _ports
+ ..clear()
+ ..addAll(ports);
+ _isLoadingPorts = false;
+ });
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _ports.clear();
+ _isLoadingPorts = false;
+ });
+ _showError(error);
+ }
+ }
+
+ Future _connectPort(String port) async {
+ if (_connector.state != MeshCoreConnectionState.disconnected) return;
+
+ final rawPortName = normalizeUsbPortName(port);
+ appLogger.info(
+ 'Connect tapped for $port (raw: $rawPortName)',
+ tag: 'UsbScreen',
+ );
+
+ try {
+ await _connector.connectUsb(portName: rawPortName);
+ } catch (error, stackTrace) {
+ appLogger.error(
+ 'Connect failed for $rawPortName: $error\n$stackTrace',
+ tag: 'UsbScreen',
+ );
+ if (!mounted) return;
+ _showError(error);
+ unawaited(_loadPorts());
+ }
+ }
+
+ void _showError(Object error) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(_friendlyErrorMessage(error)),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+
+ String _friendlyErrorMessage(Object error) {
+ final l10n = context.l10n;
+
+ if (error is PlatformException) {
+ switch (error.code) {
+ case 'usb_permission_denied':
+ return l10n.usbErrorPermissionDenied;
+ case 'usb_device_missing':
+ case 'usb_device_detached':
+ return l10n.usbErrorDeviceMissing;
+ case 'usb_invalid_port':
+ return l10n.usbErrorInvalidPort;
+ case 'usb_busy':
+ return l10n.usbErrorBusy;
+ case 'usb_not_connected':
+ return l10n.usbErrorNotConnected;
+ case 'usb_open_failed':
+ case 'usb_driver_missing':
+ return l10n.usbErrorOpenFailed;
+ case 'usb_connect_failed':
+ return l10n.usbErrorConnectFailed;
+ }
+ }
+
+ if (error is UnsupportedError) {
+ return l10n.usbErrorUnsupported;
+ }
+
+ if (error is StateError) {
+ final msg = error.message;
+ if (msg.contains('already active')) return l10n.usbErrorAlreadyActive;
+ if (msg.contains('No USB serial device selected')) {
+ return l10n.usbErrorNoDeviceSelected;
+ }
+ if (msg.contains('not open') || msg.contains('closed')) {
+ return l10n.usbErrorPortClosed;
+ }
+ if (msg.contains('Timed out')) return l10n.usbErrorConnectTimedOut;
+ if (msg.contains('Failed to open')) return l10n.usbErrorOpenFailed;
+ }
+
+ if (error is TimeoutException) {
+ return l10n.usbErrorConnectTimedOut;
+ }
+
+ return error.toString();
+ }
+}
diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart
index 6a35b17..c63e625 100644
--- a/lib/services/app_debug_log_service.dart
+++ b/lib/services/app_debug_log_service.dart
@@ -52,7 +52,12 @@ class AppDebugLogService extends ChangeNotifier {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
- if (!_enabled) return;
+ if (!_enabled && !kDebugMode) return;
+ if (!_enabled) {
+ // In debug mode, still print to console but don't store entries.
+ debugPrint('[$tag] $message');
+ return;
+ }
_entries.add(
AppDebugLogEntry(
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
index 0b59bbc..95326d2 100644
--- a/lib/services/notification_service.dart
+++ b/lib/services/notification_service.dart
@@ -1,9 +1,11 @@
+import 'dart:io' show Platform, File;
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../l10n/app_localizations.dart';
+import '../utils/platform_info.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
@@ -63,14 +65,27 @@ class NotificationService {
appUserModelId: 'org.meshcore.open.app',
guid: 'e7ea8f85-72f5-4f36-91f6-038f740ccf86',
);
+ const linuxSettings = LinuxInitializationSettings(
+ defaultActionName: 'Open notification',
+ );
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
macOS: macSettings,
windows: windowsSettings,
+ linux: linuxSettings,
);
+ // On Linux, the notifications plugin opens a D-Bus session bus
+ // connection whose async subscription can throw an unhandled
+ // SocketException when the bus socket is missing (e.g. running as
+ // root or inside a container without a session bus).
+ if (PlatformInfo.isLinux && !_isDbusSessionAvailable()) {
+ debugPrint('Skipping notification init: D-Bus session bus unavailable');
+ return;
+ }
+
try {
await _notifications.initialize(
settings: initSettings,
@@ -82,6 +97,15 @@ class NotificationService {
}
}
+ static bool _isDbusSessionAvailable() {
+ final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
+ if (addr != null && addr.isNotEmpty) return true;
+ // Fallback: check the default socket for the current user.
+ final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
+ final path = '/run/user/${uid ?? '1000'}/bus';
+ return File(path).existsSync();
+ }
+
Future _ensureInitialized() async {
if (!_isInitialized) {
await initialize();
diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart
new file mode 100644
index 0000000..59e4d4b
--- /dev/null
+++ b/lib/services/usb_serial_frame_codec.dart
@@ -0,0 +1,111 @@
+import 'dart:typed_data';
+
+const int usbSerialTxFrameStart = 0x3c;
+const int usbSerialRxFrameStart = 0x3e;
+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;
+ 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 = [];
+ int _startIndex = 0;
+
+ void reset() {
+ _rxBuffer.clear();
+ _startIndex = 0;
+ }
+
+ List ingest(Uint8List bytes) {
+ if (bytes.isEmpty) {
+ return const [];
+ }
+
+ _rxBuffer.addAll(bytes);
+ final packets = [];
+
+ while (true) {
+ if (_startIndex >= _rxBuffer.length) {
+ _rxBuffer.clear();
+ _startIndex = 0;
+ return packets;
+ }
+
+ if (_rxBuffer[_startIndex] != usbSerialRxFrameStart &&
+ _rxBuffer[_startIndex] != usbSerialTxFrameStart) {
+ _startIndex++;
+ _compactBufferIfNeeded();
+ continue;
+ }
+
+ final availableLength = _rxBuffer.length - _startIndex;
+ if (availableLength < usbSerialHeaderLength) {
+ _compactBufferIfNeeded(force: true);
+ return packets;
+ }
+
+ 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);
+ return packets;
+ }
+
+ final frameStart = _rxBuffer[_startIndex];
+ final payload = Uint8List.fromList(
+ _rxBuffer.sublist(
+ _startIndex + usbSerialHeaderLength,
+ _startIndex + 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.dart b/lib/services/usb_serial_service.dart
new file mode 100644
index 0000000..343d0ea
--- /dev/null
+++ b/lib/services/usb_serial_service.dart
@@ -0,0 +1,2 @@
+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..fca3d19
--- /dev/null
+++ b/lib/services/usb_serial_service_native.dart
@@ -0,0 +1,463 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flserial/flserial.dart';
+import 'package:flserial/flserial_exception.dart';
+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';
+
+/// 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 UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
+ StreamSubscription? _androidDataSubscription;
+ StreamSubscription? _dataSubscription;
+ UsbSerialStatus _status = UsbSerialStatus.disconnected;
+ String? _connectedPortKey;
+ String? _connectedPortLabel;
+ FlSerial? _serial;
+ AppDebugLogService? _debugLogService;
+
+ UsbSerialStatus get status => _status;
+ String? get activePortKey => _connectedPortKey;
+ String? get activePortDisplayLabel =>
+ _connectedPortLabel ?? _connectedPortKey;
+ Stream get frameStream => _frameController.stream;
+ bool get _useAndroidUsbHost =>
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+ bool get _useDesktopFlSerial =>
+ PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
+ bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
+ // 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;
+ }
+ // 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 {
+ if (!_isSupportedPlatform) {
+ return const [];
+ }
+ if (_useAndroidUsbHost) {
+ final ports = await _androidMethodChannel.invokeListMethod(
+ 'listPorts',
+ );
+ return ports ?? [];
+ }
+ final rawPorts = FlSerial.listPorts();
+ // On macOS, flserial's native device-name lookup is broken on macOS
+ // 10.15+ because the IOKit class name changed from IOUSBDevice to
+ // IOUSBHostDevice. We resolve names ourselves via ioreg and rewrite any
+ // "port - n/a" entries with the real product name.
+ if (Platform.isMacOS && rawPorts.isNotEmpty) {
+ return _annotateMacOsPorts(rawPorts);
+ }
+ return Future.value(rawPorts);
+ }
+
+ /// Rewrites the flserial port list on macOS by substituting real USB device
+ /// names (obtained via [ioreg]) for the "n/a" placeholders that flserial
+ /// returns when it can't find the deprecated IOUSBDevice parent.
+ Future> _annotateMacOsPorts(List rawPorts) async {
+ final deviceNames = await queryMacOsUsbDeviceNames();
+ if (deviceNames.isEmpty) return rawPorts;
+ return rawPorts.map((entry) {
+ // entry format from fl_ports: "port - description - hardware_id"
+ final port = normalizeUsbPortName(entry); // e.g. /dev/cu.usbmodem1101
+ final knownName = deviceNames[port]; // e.g. "Nordic NRF52 DK"
+ if (knownName == null) return entry; // non-USB port, keep as-is
+ // Replace description field only; preserve hardware_id for device
+ // identity (used by normalizeUsbPortName).
+ final segments = entry.split(' - ');
+ final hardwareId = segments.length >= 3 ? segments.last : 'n/a';
+ return '$port - $knownName - $hardwareId';
+ }).toList();
+ }
+
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLogService = service;
+ }
+
+ 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 (!_isSupportedPlatform) {
+ throw UnsupportedError('USB serial is not supported on this platform.');
+ }
+
+ _status = UsbSerialStatus.connecting;
+ var normalizedPortName = normalizeUsbPortName(portName);
+ _frameDecoder.reset();
+
+ if (_useAndroidUsbHost) {
+ try {
+ await _androidMethodChannel.invokeMethod('connect', {
+ 'portName': normalizedPortName,
+ 'baudRate': baudRate,
+ });
+ _debugLogService?.info(
+ 'USB serial opened port=$normalizedPortName on Android via USB host bridge',
+ tag: 'USB Serial',
+ );
+ } on PlatformException catch (error) {
+ _status = UsbSerialStatus.disconnected;
+ final msg = error.message ?? error.code;
+ _debugLogService?.error(
+ 'Android connect failed: $msg',
+ tag: 'USB Serial',
+ );
+ rethrow;
+ }
+ } else {
+ // ── Hot-restart guard ─────────────────────────────────────────────────
+ // On hot restart Dart tears down the isolate without calling dispose().
+ // The NativeCallable registered by flserial's setCallback() is
+ // isolate-local and gets freed when the isolate dies, but the native
+ // SerialThread is still alive and will call it → crash.
+ //
+ // flserial uses process-global native state. Calling fl_free() kills ALL
+ // SerialThreads for every open port across all Dart isolates (there is
+ // only one in a Flutter app). Then fl_init() re-initialises the slot
+ // table so subsequent fl_open() calls work normally.
+ //
+ // This must happen before we register any new NativeCallable, so it must
+ // be the very first thing we do in the desktop branch.
+ try {
+ bindings.fl_free();
+ bindings.fl_init(16);
+ } catch (_) {}
+
+ // On macOS, flserial lists both cu.* and tty.* device nodes.
+ // When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.*
+ // variant as a fallback (and vice-versa) before giving up.
+ final candidates = _buildPortCandidates(normalizedPortName);
+ FlSerialException? lastError;
+ bool opened = false;
+
+ for (final candidate in candidates) {
+ // Always create a fresh FlSerial instance — a cached instance retains
+ // a stale flh handle from prior failed opens, which causes the native
+ // fl_open() to mis-route the request and report port-not-exist even
+ // when the device node is physically present.
+ final serial = _freshSerial();
+ serial.init();
+ try {
+ final openStatus = serial.openPort(candidate, baudRate);
+ if (openStatus != FlOpenStatus.open) {
+ final msg =
+ 'Failed to open USB port $candidate (status: $openStatus)';
+ _debugLogService?.error(msg, tag: 'USB Serial');
+ // Not a FlSerialException — treat as terminal failure
+ _status = UsbSerialStatus.disconnected;
+ throw StateError(msg);
+ }
+ serial.setByteSize8();
+ serial.setBitParityNone();
+ serial.setStopBits1();
+ serial.setFlowControlNone();
+ serial.setRTS(false);
+ serial.setDTR(true);
+ _serial = serial;
+ // Update the normalized port name to whichever candidate succeeded.
+ normalizedPortName = candidate;
+ _debugLogService?.info(
+ 'USB serial opened port=$candidate cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
+ tag: 'USB Serial',
+ );
+ opened = true;
+ break;
+ } on FlSerialException catch (error) {
+ // The native fl_open() already called fl_close() on failure
+ // internally, so no extra cleanup is needed here for this candidate.
+ _debugLogService?.warn(
+ 'Failed to open $candidate: ${error.msg} (code ${error.error})',
+ tag: 'USB Serial',
+ );
+ lastError = error;
+ // Try next candidate
+ } catch (error, stackTrace) {
+ _status = UsbSerialStatus.disconnected;
+ _debugLogService?.error(
+ 'Unexpected error opening $candidate: $error\n$stackTrace',
+ tag: 'USB Serial',
+ );
+ rethrow;
+ }
+ }
+
+ if (!opened) {
+ _status = UsbSerialStatus.disconnected;
+ final primary = candidates.first;
+ final msg = lastError != null
+ ? 'Failed to open USB port $primary: ${lastError.msg} (code ${lastError.error})'
+ : 'Failed to open USB port $primary';
+ _debugLogService?.error(msg, tag: 'USB Serial');
+ throw StateError(msg);
+ }
+ }
+
+ _connectedPortKey = normalizedPortName;
+ _connectedPortLabel = 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;
+
+ final portLabel = _connectedPortLabel ?? _connectedPortKey;
+ _debugLogService?.info(
+ 'USB disconnect starting port=${portLabel ?? 'unknown'}',
+ tag: 'USB Serial',
+ );
+ _status = UsbSerialStatus.disconnecting;
+ _connectedPortKey = null;
+ _connectedPortLabel = null;
+ _frameDecoder.reset();
+
+ if (_useAndroidUsbHost) {
+ await _androidDataSubscription?.cancel();
+ _androidDataSubscription = null;
+ try {
+ await _androidMethodChannel.invokeMethod('disconnect');
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ } else {
+ // IMPORTANT: Close and free the native port FIRST, before cancelling the
+ // Dart subscription. The native SerialThread is blocked on a read(); once
+ // closePort() is called it unblocks and the thread exits. If we cancel
+ // the Dart subscription first (freeing the FFI callback pointer) and the
+ // thread fires one final callback before noticing the port is gone, Dart
+ // crashes with "Callback invoked after it has been deleted".
+ final serial = _serial;
+ _serial = null;
+ try {
+ if (serial?.isOpen() == FlOpenStatus.open) {
+ serial?.closePort();
+ }
+ } catch (_) {
+ // Ignore errors while closing.
+ }
+ // Note: we do NOT call free() here; that would globally reset native
+ // state for all ports. The global reset is done in connect() instead,
+ // before the next open, which is the safer place to do it.
+
+ // Now it is safe to cancel the Dart subscription — the native thread has
+ // already seen the port close and will not fire any more callbacks.
+ await _dataSubscription?.cancel();
+ _dataSubscription = null;
+ }
+ _status = UsbSerialStatus.disconnected;
+ _debugLogService?.info(
+ 'USB disconnect complete port=${portLabel ?? 'unknown'}',
+ tag: 'USB Serial',
+ );
+ }
+
+ void setRequestPortLabel(String label) {
+ // Native implementations do not use a synthetic chooser row.
+ }
+
+ void setFallbackDeviceName(String label) {
+ // Native implementations use OS-provided device names.
+ }
+
+ void updateConnectedLabel(String label) {
+ final trimmed = label.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ _connectedPortLabel = buildUsbDisplayLabel(
+ basePortLabel: _connectedPortKey ?? trimmed,
+ deviceName: trimmed,
+ );
+ }
+
+ void dispose() {
+ // Synchronously close the native port so the SerialThread exits before
+ // the Dart isolate is torn down (e.g. on hot restart). The async
+ // disconnect() path via unawaited() offers no ordering guarantee — the
+ // isolate may die before the Future resolves, leaving the thread alive
+ // with a dangling NativeCallable pointer.
+ if (_useDesktopFlSerial) {
+ final serial = _serial;
+ try {
+ if (serial?.isOpen() == FlOpenStatus.open) {
+ serial?.closePort(); // synchronous C call — kills the SerialThread
+ }
+ } catch (_) {}
+ }
+ // Kick off the full async teardown for anything else (subscription cancel,
+ // stream controller close). These are best-effort at dispose time.
+ unawaited(disconnect().whenComplete(_closeFrameController));
+ }
+
+ void _handleSerialData(FlSerialEventArgs event) {
+ try {
+ final bytes = event.serial.readList();
+ if (bytes.isNotEmpty) {
+ _ingestRawBytes(Uint8List.fromList(bytes));
+ }
+ } catch (error, stack) {
+ _addFrameError(error, stack);
+ }
+ }
+
+ void _handleAndroidData(dynamic data) {
+ if (data is Uint8List) {
+ _ingestRawBytes(data);
+ return;
+ }
+ if (data is ByteData) {
+ _ingestRawBytes(data.buffer.asUint8List());
+ return;
+ }
+ _addFrameError(
+ StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
+ );
+ }
+
+ void _handleSerialError(Object error, [StackTrace? stackTrace]) {
+ _addFrameError(error, stackTrace);
+ }
+
+ void _handleSerialDone() {
+ unawaited(disconnect());
+ }
+
+ void _ingestRawBytes(Uint8List bytes) {
+ for (final packet in _frameDecoder.ingest(bytes)) {
+ if (!packet.isRxFrame) {
+ _debugLogService?.info(
+ 'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ tag: 'USB Serial',
+ );
+ continue;
+ }
+ _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) {
+ _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
+ return;
+ }
+ _debugLogService?.info(
+ '$prefix code=${bytes[0]} len=${bytes.length}',
+ tag: 'USB Serial',
+ );
+ }
+
+ /// Returns an ordered list of port paths to try for [portName].
+ ///
+ /// On macOS, USB serial devices appear as both `/dev/cu.*` (call-out, the
+ /// correct mode for outgoing serial connections) and `/dev/tty.*` (dial-in).
+ /// `flserial` may list one variant while only the other is actually openable
+ /// at a given moment. We prefer `cu.*` but automatically include the `tty.*`
+ /// sibling as a fallback, and vice-versa.
+ List _buildPortCandidates(String normalizedPort) {
+ if (!Platform.isMacOS) return [normalizedPort];
+ const cuPrefix = '/dev/cu.';
+ const ttyPrefix = '/dev/tty.';
+ if (normalizedPort.startsWith(cuPrefix)) {
+ final suffix = normalizedPort.substring(cuPrefix.length);
+ return [normalizedPort, '$ttyPrefix$suffix'];
+ }
+ if (normalizedPort.startsWith(ttyPrefix)) {
+ final suffix = normalizedPort.substring(ttyPrefix.length);
+ return [normalizedPort, '$cuPrefix$suffix'];
+ }
+ return [normalizedPort];
+ }
+}
+
+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..4c83d7d
--- /dev/null
+++ b/lib/services/usb_serial_service_web.dart
@@ -0,0 +1,580 @@
+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 'app_debug_log_service.dart';
+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 = {};
+ static final Map _baseLabelsByPortKey = {};
+ static final Map _authorizedPortsByKey =
+ {};
+ static int _nextAuthorizedPortId = 1;
+
+ final StreamController _frameController =
+ StreamController.broadcast();
+ final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
+
+ UsbSerialStatus _status = UsbSerialStatus.disconnected;
+ JSObject? _port;
+ JSObject? _reader;
+ JSObject? _writer;
+ String? _connectedPortName;
+ String? _connectedPortKey;
+ String _requestPortLabel = 'Choose USB Device';
+ String _fallbackDeviceName = 'Web Serial Device';
+ AppDebugLogService? _debugLogService;
+
+ UsbSerialStatus get status => _status;
+ String? get activePortKey => _connectedPortKey;
+ String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
+ 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 [];
+ }
+
+ _resetPortCache();
+ final ports = await _getAuthorizedPorts();
+ return [_requestPortListEntry, ...ports.map(_listEntryForPort)];
+ }
+
+ 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;
+ _frameDecoder.reset();
+
+ try {
+ final requestedPortName = normalizeUsbPortName(portName);
+ _debugLogService?.info(
+ 'Web connect: requested=$requestedPortName baud=$baudRate',
+ tag: 'USB Serial',
+ );
+ final selectedPortKey = requestedPortName.startsWith('web:port:')
+ ? requestedPortName
+ : null;
+ _port = _authorizedPortsByKey[requestedPortName];
+ final authorizedPorts = await _getAuthorizedPorts();
+ _debugLogService?.info(
+ 'Web connect: ${authorizedPorts.length} authorized port(s), cached=${_port != null}',
+ tag: 'USB Serial',
+ );
+ _port ??= _selectPort(authorizedPorts, requestedPortName);
+
+ _port ??= await _requestPort();
+ if (_port == null) {
+ throw StateError('No USB serial device selected');
+ }
+
+ _debugLogService?.info(
+ 'Web connect: opening port at $baudRate baud…',
+ tag: 'USB Serial',
+ );
+ await _openPort(_port!, baudRate);
+ _connectedPortKey = _cachePort(_port!, preferredKey: selectedPortKey);
+ _connectedPortName = _displayLabelForPort(
+ _port!,
+ portKey: _connectedPortKey,
+ );
+ _writer = _getWriter(_port!);
+ _reader = _getReader(_port!);
+ _status = UsbSerialStatus.connected;
+ unawaited(_pumpReads());
+
+ _debugLogService?.info(
+ 'USB serial opened port=$_connectedPortName via Web Serial',
+ tag: 'USB Serial',
+ );
+ } catch (error) {
+ _debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
+ await _cleanupFailedConnect();
+ _status = UsbSerialStatus.disconnected;
+ _connectedPortName = null;
+ _connectedPortKey = 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;
+
+ final portLabel = _connectedPortName ?? _connectedPortKey;
+ _debugLogService?.info(
+ 'USB disconnect starting port=${portLabel ?? 'unknown'}',
+ tag: 'USB Serial',
+ );
+ _status = UsbSerialStatus.disconnecting;
+ final reader = _reader;
+ final writer = _writer;
+ final port = _port;
+
+ _reader = null;
+ _writer = null;
+ _port = null;
+ _connectedPortName = null;
+ _connectedPortKey = null;
+ _frameDecoder.reset();
+
+ 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;
+ _debugLogService?.info(
+ 'USB disconnect complete port=${portLabel ?? 'unknown'}',
+ tag: 'USB Serial',
+ );
+ }
+
+ void updateConnectedLabel(String label) {
+ final trimmed = label.trim();
+ final portKey = _connectedPortKey;
+ if (trimmed.isEmpty || portKey == null) {
+ return;
+ }
+ _deviceNamesByPortKey[portKey] = trimmed;
+ _connectedPortName = _buildDisplayLabel(portKey);
+ }
+
+ void setRequestPortLabel(String label) {
+ final trimmed = label.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ _requestPortLabel = trimmed;
+ }
+
+ void setFallbackDeviceName(String label) {
+ final trimmed = label.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ _fallbackDeviceName = trimmed;
+ }
+
+ void setDebugLogService(AppDebugLogService? service) {
+ _debugLogService = service;
+ }
+
+ void dispose() {
+ unawaited(disconnect().whenComplete(_closeFrameController));
+ }
+
+ 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 == _requestPortKey) {
+ return ports.first;
+ }
+ if (requestedPortName.startsWith('web:port:')) {
+ return null;
+ }
+ for (final port in ports) {
+ final description = _describePort(port);
+ if (description == requestedPortName) {
+ return port;
+ }
+ }
+ return null;
+ }
+
+ Future _openPort(JSObject port, int baudRate) async {
+ final options = JSObject()
+ ..['baudRate'] = baudRate.toJS
+ ..['flowControl'] = 'none'.toJS;
+ await port.callMethod>('open'.toJS, options).toDart;
+
+ // Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
+ try {
+ final signals = JSObject()
+ ..['dataTerminalReady'] = true.toJS
+ ..['requestToSend'] = false.toJS;
+ await port
+ .callMethod>('setSignals'.toJS, signals)
+ .toDart;
+ } catch (_) {
+ // setSignals may not be supported on all browsers/devices.
+ }
+ }
+
+ 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) {
+ 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) {
+ _debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
+ return;
+ }
+
+ _debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
+ try {
+ while (_status == UsbSerialStatus.connected &&
+ identical(reader, _reader)) {
+ final result = await reader
+ .callMethod>('read'.toJS)
+ .toDart;
+ if (result == null) {
+ _debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
+ break;
+ }
+ final resultObject = result as JSObject;
+
+ final doneValue = resultObject.getProperty('done'.toJS);
+ final done = doneValue != null && doneValue.dartify() == true;
+ if (done) {
+ _debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
+ break;
+ }
+
+ final value = resultObject.getProperty('value'.toJS);
+ final bytes = _coerceBytes(value);
+ if (bytes != null && bytes.isNotEmpty) {
+ _debugLogService?.info(
+ 'USB RX raw: ${bytes.length} byte(s)',
+ tag: 'USB Serial',
+ );
+ _ingestRawBytes(bytes);
+ }
+ }
+ } catch (error, stackTrace) {
+ _debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
+ if (_status == UsbSerialStatus.connected) {
+ _addFrameError(error, stackTrace);
+ }
+ } finally {
+ _debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
+ _releaseLock(reader);
+ if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
+ _addFrameError(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) {
+ final info = _portInfo(port);
+ if (info == null) {
+ return _requestPortLabel;
+ }
+
+ final vendorId = info.usbVendorId;
+ final productId = info.usbProductId;
+ final hasVendor = vendorId != null;
+ final hasProduct = productId != null;
+
+ return describeWebUsbPort(
+ vendorId: hasVendor ? vendorId : null,
+ productId: hasProduct ? productId : null,
+ requestPortLabel: _requestPortLabel,
+ fallbackDeviceName: _fallbackDeviceName,
+ knownUsbNames: _knownUsbNames,
+ );
+ }
+
+ _WebPortInfo? _portInfo(JSObject port) {
+ try {
+ final info = port.callMethod('getInfo'.toJS);
+ if (info == null) {
+ return null;
+ }
+ final infoObject = info as JSObject;
+
+ final vendorId = infoObject
+ .getProperty('usbVendorId'.toJS)
+ ?.dartify();
+ final productId = infoObject
+ .getProperty('usbProductId'.toJS)
+ ?.dartify();
+ return _WebPortInfo(
+ usbVendorId: vendorId is num ? vendorId.toInt() : null,
+ usbProductId: productId is num ? productId.toInt() : null,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ String _portKeyFor(JSObject port) {
+ return _cachePort(port);
+ }
+
+ String _cachePort(JSObject port, {String? preferredKey}) {
+ final portKey = preferredKey ?? 'web:port:${_nextAuthorizedPortId++}';
+ _baseLabelsByPortKey[portKey] = _describePort(port);
+ _authorizedPortsByKey[portKey] = port;
+ return portKey;
+ }
+
+ String _displayLabelForPort(JSObject port, {String? portKey}) =>
+ _buildDisplayLabel(portKey ?? _portKeyFor(port));
+
+ String _buildDisplayLabel(String portKey) {
+ return buildUsbDisplayLabel(
+ basePortLabel: _baseLabelsByPortKey[portKey] ?? portKey,
+ deviceName: _deviceNamesByPortKey[portKey],
+ );
+ }
+
+ String _listEntryForPort(JSObject port) {
+ final portKey = _portKeyFor(port);
+ return '$portKey - ${_displayLabelForPort(port, portKey: portKey)}';
+ }
+
+ String get _requestPortKey => 'web:request';
+
+ String get _requestPortListEntry => '$_requestPortKey - $_requestPortLabel';
+
+ void _resetPortCache() {
+ _authorizedPortsByKey.clear();
+ _baseLabelsByPortKey.clear();
+ _deviceNamesByPortKey.clear();
+ _nextAuthorizedPortId = 1;
+ }
+
+ 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) {
+ _debugLogService?.info(
+ 'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
+ tag: 'USB Serial',
+ );
+ continue;
+ }
+ _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) {
+ _debugLogService?.info('$prefix len=0', tag: 'USB Serial');
+ return;
+ }
+ _debugLogService?.info(
+ '$prefix code=${bytes[0]} len=${bytes.length}',
+ tag: 'USB Serial',
+ );
+ }
+}
+
+enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
+
+final class _WebPortInfo {
+ const _WebPortInfo({required this.usbVendorId, required this.usbProductId});
+
+ final int? usbVendorId;
+ final int? usbProductId;
+}
diff --git a/lib/utils/dialog_utils.dart b/lib/utils/dialog_utils.dart
index 510eca7..8a6ad9b 100644
--- a/lib/utils/dialog_utils.dart
+++ b/lib/utils/dialog_utils.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
+import 'app_logger.dart';
/// Shows a confirmation dialog before disconnecting from the device.
/// Returns true if user confirmed and disconnect completed, false otherwise.
@@ -28,6 +29,7 @@ Future showDisconnectDialog(
);
if (confirmed == true) {
+ appLogger.info('Disconnect confirmed from popup', tag: 'Connection');
await connector.disconnect();
return true;
}
diff --git a/lib/utils/macos_usb_device_names.dart b/lib/utils/macos_usb_device_names.dart
new file mode 100644
index 0000000..ad521f8
--- /dev/null
+++ b/lib/utils/macos_usb_device_names.dart
@@ -0,0 +1,92 @@
+import 'dart:io';
+
+/// Queries the macOS IOKit registry via [ioreg] to build a map of serial port
+/// callout device paths to human-readable USB device names.
+///
+/// The [flserial] native library uses the deprecated [IOUSBDevice] IOKit class
+/// to resolve device names, but macOS 10.15+ renamed it to [IOUSBHostDevice].
+/// As a result flserial always returns "n/a" for USB product/vendor info on
+/// modern macOS. This utility bypasses that limitation by invoking ioreg
+/// directly and parsing its output.
+///
+/// Returns a Map of e.g. `"/dev/cu.usbmodem1101"` → `"Nordic NRF52 DK"`.
+/// Devices without a USB product name are not included in the map.
+Future