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> queryMacOsUsbDeviceNames() async { + assert(Platform.isMacOS); + try { + final result = await Process.run('ioreg', [ + '-r', + '-c', + 'IOUSBHostDevice', + '-l', + ], stdoutEncoding: const SystemEncoding()); + if (result.exitCode != 0) return const {}; + return _parseIoregOutput(result.stdout as String); + } catch (_) { + return const {}; + } +} + +Map _parseIoregOutput(String output) { + final lines = output.split('\n'); + final result = {}; + + // We accumulate the current device block's properties. + // A new block starts at a line beginning with "+-o " which indicates a + // top-level IOUSBHostDevice entry in the ioreg tree. + String? currentVendor; + String? currentProduct; + final List currentPorts = []; + + void flushBlock() { + if (currentPorts.isNotEmpty && + (currentVendor != null || currentProduct != null)) { + final parts = [ + if (currentVendor != null && currentVendor!.isNotEmpty) currentVendor!, + if (currentProduct != null && currentProduct!.isNotEmpty) + currentProduct!, + ]; + final name = parts.join(' '); + for (final port in currentPorts) { + result[port] = name; + } + } + currentVendor = null; + currentProduct = null; + currentPorts.clear(); + } + + for (final line in lines) { + // A new top-level device block begins here. + if (line.startsWith('+-o ')) { + flushBlock(); + continue; + } + // USB Product Name (appears at multiple depths in the tree, first wins) + final productMatch = _kProductName.firstMatch(line); + if (productMatch != null && currentProduct == null) { + currentProduct = productMatch.group(1)?.trim(); + continue; + } + // USB Vendor Name + final vendorMatch = _kVendorName.firstMatch(line); + if (vendorMatch != null && currentVendor == null) { + currentVendor = vendorMatch.group(1)?.trim(); + continue; + } + // IOCalloutDevice — the /dev/cu.xxx path our app uses + final calloutMatch = _kCalloutDevice.firstMatch(line); + if (calloutMatch != null) { + final port = calloutMatch.group(1)?.trim(); + if (port != null && port.isNotEmpty) { + currentPorts.add(port); + } + } + } + flushBlock(); + return result; +} + +final RegExp _kProductName = RegExp(r'"USB Product Name" = "([^"]*)"'); +final RegExp _kVendorName = RegExp(r'"USB Vendor Name" = "([^"]*)"'); +final RegExp _kCalloutDevice = RegExp(r'"IOCalloutDevice" = "([^"]*)"'); diff --git a/lib/utils/platform_info.dart b/lib/utils/platform_info.dart index dc8e27e..a388932 100644 --- a/lib/utils/platform_info.dart +++ b/lib/utils/platform_info.dart @@ -33,4 +33,15 @@ class PlatformInfo { /// Whether the app is running on a desktop platform (macOS, Windows, or Linux). static bool get isDesktop => isMacOS || isWindows || isLinux; + + /// Whether the current platform supports a native USB serial backend. + static bool get supportsNativeUsbSerial => + isAndroid || isWindows || isLinux || isMacOS; + + /// Whether the current browser supports the Web Serial backend. + static bool get supportsWebSerial => isWeb && isChrome; + + /// Whether USB serial is expected to be available on the current platform. + static bool get supportsUsbSerial => + supportsNativeUsbSerial || supportsWebSerial; } diff --git a/lib/utils/usb_port_labels.dart b/lib/utils/usb_port_labels.dart new file mode 100644 index 0000000..05dfc85 --- /dev/null +++ b/lib/utils/usb_port_labels.dart @@ -0,0 +1,66 @@ +String normalizeUsbPortName(String portLabel) { + final separatorIndex = portLabel.indexOf(' - '); + final normalized = separatorIndex >= 0 + ? portLabel.substring(0, separatorIndex) + : portLabel; + return normalized.trim(); +} + +/// Returns a human-readable name for a serial port label. +/// +/// The native flserial library encodes port info as a ` - `-separated string: +/// `" - - "` +/// +/// This function extracts the *description* field (index 1) and discards the +/// raw hardware_id, which is not user-friendly. If the description is missing +/// or unhelpful (e.g. "n/a"), it falls back to the raw port name. +String friendlyUsbPortName(String portLabel) { + final parts = portLabel.split(' - '); + if (parts.length < 2) { + return portLabel.trim(); + } + // parts[0] = port name, parts[1] = description, parts[2+] = hardware id + final description = parts[1].trim(); + if (description.isEmpty || description.toLowerCase() == 'n/a') { + return parts[0].trim(); + } + return description; +} + +String describeWebUsbPort({ + required int? vendorId, + required int? productId, + String requestPortLabel = 'Choose USB Device', + String fallbackDeviceName = 'Web Serial Device', + Map knownUsbNames = const {}, +}) { + if (vendorId == null && productId == null) { + return requestPortLabel; + } + + final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase(); + final productHex = productId?.toRadixString(16).padLeft(4, '0').toUpperCase(); + final knownName = (vendorHex != null && productHex != null) + ? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}'] + : null; + + final parts = [knownName ?? fallbackDeviceName]; + if (vendorHex != null) { + parts.add('VID:$vendorHex'); + } + if (productHex != null) { + parts.add('PID:$productHex'); + } + return '${parts.first} (${parts.skip(1).join(' ')})'; +} + +String buildUsbDisplayLabel({ + required String basePortLabel, + String? deviceName, +}) { + final trimmedName = deviceName?.trim() ?? ''; + if (trimmedName.isEmpty) { + return basePortLabel; + } + return '$basePortLabel - $trimmedName'; +} diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index f3becea..1f592eb 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -78,40 +78,36 @@ class _SNRIndicatorState extends State { widget.connector.currentSf, ); - return InkWell( - onTap: () { - if (directRepeater != null) { - _showFullPathDialog(context, directBestRepeaters); - } - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(snrUi.icon, size: 18, color: snrUi.color), - Text( - snrUi.text, - style: TextStyle(fontSize: 12, color: snrUi.color), - ), - ], - ), - if (directRepeater != null) + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + child: InkWell( + onTap: directRepeater != null + ? () => _showFullPathDialog(context, directBestRepeaters) + : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(snrUi.icon, size: 18, color: snrUi.color), Text( - '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + snrUi.text, + style: TextStyle(fontSize: 12, color: snrUi.color), ), - ], + if (directRepeater != null) + Text( + '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), ); @@ -148,8 +144,10 @@ class _SNRIndicatorState extends State { builder: (context) => AlertDialog( title: Text(l10n.snrIndicator_nearByRepeaters), content: SizedBox( + width: double.maxFinite, child: Scrollbar( child: ListView.separated( + shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: directBestRepeaters.length, separatorBuilder: (_, _) => const Divider(height: 1), diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..379e36f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flserial ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4084d9b..d2ea57e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import flutter_blue_plus_darwin import flutter_local_notifications import mobile_scanner import package_info_plus -import path_provider_foundation import share_plus import shared_preferences_foundation import sqflite_darwin @@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 8224cfb..58b4d01 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - flserial (0.0.1): + - FlutterMacOS - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS @@ -24,6 +26,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - flserial (from `Flutter/ephemeral/.symlinks/plugins/flserial/macos`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -36,6 +39,8 @@ DEPENDENCIES: - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) EXTERNAL SOURCES: + flserial: + :path: Flutter/ephemeral/.symlinks/plugins/flserial/macos flutter_blue_plus_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin flutter_local_notifications: @@ -58,6 +63,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: + flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6 flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index f31e9af..2fcad1b 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,14 @@ com.apple.security.device.bluetooth + com.apple.security.device.usb + + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /dev/cu. + /dev/tty. + com.apple.security.device.camera diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 29ef507..2b1c694 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -8,6 +8,14 @@ com.apple.security.device.bluetooth + com.apple.security.device.usb + + + com.apple.security.temporary-exception.files.absolute-path.read-write + + /dev/cu. + /dev/tty. + com.apple.security.device.camera diff --git a/pubspec.yaml b/pubspec.yaml index f85530f..82e4d9c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,11 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_blue_plus: ^2.1.0 + # TODO: Switch to official flserial repo once changes are upstreamed + flserial: + git: + url: https://github.com/MeshEnvy/flserial.git + ref: 48216310061efc8d5d217cc18014fc2cb501646e provider: ^6.1.5+1 shared_preferences: ^2.2.2 uuid: ^4.3.3 @@ -130,6 +135,8 @@ flutter_launcher_icons: # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + + build_pipe: workflows: default: @@ -141,4 +148,4 @@ build_pipe: build_command: flutter build web --release --pwa-strategy=none # Strongly recommended: disables the default service worker which often causes more cache headaches add_version_query_param: true - # This is the key flag! It appends ?v= to bootstrap/JS files \ No newline at end of file + # This is the key flag! It appends ?v= to bootstrap/JS files diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart new file mode 100644 index 0000000..436d230 --- /dev/null +++ b/test/screens/usb_flow_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/screens/scanner_screen.dart'; +import 'package:meshcore_open/screens/usb_screen.dart'; +import 'package:meshcore_open/utils/platform_info.dart'; + +class _FakeMeshCoreConnector extends MeshCoreConnector { + _FakeMeshCoreConnector({ + this.initialState = MeshCoreConnectionState.disconnected, + List? ports, + }) : _ports = ports ?? []; + + final MeshCoreConnectionState initialState; + final List _ports; + + String? requestPortLabel; + String? fallbackDeviceName; + int connectUsbCalls = 0; + String? lastConnectPortName; + String? fakeActiveUsbPort; + String? fakeActiveUsbPortDisplayLabel; + bool fakeUsbTransportConnected = false; + Future> Function()? listUsbPortsImpl; + Future Function({required String portName})? connectUsbImpl; + + @override + MeshCoreConnectionState get state => initialState; + + @override + MeshCoreTransportType get activeTransport => MeshCoreTransportType.usb; + + @override + String? get activeUsbPort => fakeActiveUsbPort; + + @override + String? get activeUsbPortDisplayLabel => + fakeActiveUsbPortDisplayLabel ?? fakeActiveUsbPort; + + @override + bool get isUsbTransportConnected => fakeUsbTransportConnected; + + @override + Future> listUsbPorts() async { + if (listUsbPortsImpl != null) { + return listUsbPortsImpl!(); + } + return List.from(_ports); + } + + @override + Future connectUsb({ + required String portName, + int baudRate = 115200, + }) async { + if (connectUsbImpl != null) { + return connectUsbImpl!(portName: portName); + } + connectUsbCalls += 1; + lastConnectPortName = portName; + } + + @override + void setUsbRequestPortLabel(String label) { + requestPortLabel = label; + } + + @override + void setUsbFallbackDeviceName(String label) { + fallbackDeviceName = label; + } +} + +Widget _buildTestApp({ + required MeshCoreConnector connector, + required Widget child, +}) { + return ChangeNotifierProvider.value( + value: connector, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + testWidgets('UsbScreen passes localized chooser label to connector', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + expect(connector.requestPortLabel, 'Select a USB device'); + }); + + testWidgets( + 'UsbScreen does not call connectUsb when connector is not disconnected', + (tester) async { + final connector = _FakeMeshCoreConnector( + initialState: MeshCoreConnectionState.connected, + ports: ['COM6 - USB Serial Device (COM6)'], + ); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.ancestor( + of: find.text('Connect'), + matching: find.bySubtype(), + ), + ); + await tester.pump(); + + expect(connector.connectUsbCalls, 0); + + // UsbScreen.dispose() schedules disconnect work that debounces notify. + // Drain that debounce timer before test teardown. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }, + ); + + testWidgets('UsbScreen sends raw port name when tapping Connect', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector( + ports: ['COM6 - USB Serial Device (COM6)'], + ); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.ancestor( + of: find.text('Connect'), + matching: find.bySubtype(), + ), + ); + await tester.pump(); + + expect(connector.connectUsbCalls, 1); + expect(connector.lastConnectPortName, 'COM6'); + }); + + testWidgets('ScannerScreen USB action reflects platform support', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const ScannerScreen()), + ); + await tester.pumpAndSettle(); + + if (PlatformInfo.supportsUsbSerial) { + expect(find.widgetWithText(FloatingActionButton, 'USB'), findsOneWidget); + } else { + expect(find.widgetWithText(FloatingActionButton, 'USB'), findsNothing); + } + + // ScannerScreen.dispose() schedules disconnect work that debounces notify. + // Drain that debounce timer before test teardown. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + + group('Error Handling', () { + testWidgets('shows error SnackBar when listing ports fails', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + connector.listUsbPortsImpl = () async { + throw PlatformException( + code: 'usb_permission_denied', + message: 'Permission denied', + ); + }; + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + expect(find.text('USB permission was denied.'), findsOneWidget); + }); + + testWidgets('connection failure shows SnackBar error', (tester) async { + final connector = _FakeMeshCoreConnector(ports: ['COM1']); + var connectAttempted = false; + connector.connectUsbImpl = ({required String portName}) async { + connectAttempted = true; + throw PlatformException(code: 'usb_busy', message: 'Device is busy'); + }; + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.ancestor( + of: find.text('Connect'), + matching: find.bySubtype(), + ), + ); + await tester.pumpAndSettle(); + + expect(connectAttempted, isTrue); + expect( + find.text('Another USB connection request is already in progress.'), + findsOneWidget, + ); + }); + }); +} diff --git a/test/services/usb_serial_frame_codec_test.dart b/test/services/usb_serial_frame_codec_test.dart new file mode 100644 index 0000000..32242bd --- /dev/null +++ b/test/services/usb_serial_frame_codec_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/usb_serial_frame_codec.dart'; + +void main() { + test('wrapUsbSerialTxFrame prefixes tx header and payload length', () { + final packet = wrapUsbSerialTxFrame(Uint8List.fromList([0x16, 0x03])); + + expect( + packet, + orderedEquals([usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]), + ); + }); + + test('wrapUsbSerialTxFrame rejects payloads above protocol maximum', () { + final payload = Uint8List(usbSerialMaxPayloadLength + 1); + + expect( + () => wrapUsbSerialTxFrame(payload), + throwsA( + isA().having( + (error) => error.name, + 'name', + 'payload.length', + ), + ), + ); + }); + + test('UsbSerialFrameDecoder buffers partial frames until complete', () { + final decoder = UsbSerialFrameDecoder(); + + final firstChunk = decoder.ingest( + Uint8List.fromList([usbSerialRxFrameStart, 0x03]), + ); + final secondChunk = decoder.ingest( + Uint8List.fromList([0x00, 0x05, 0x06, 0x07]), + ); + + expect(firstChunk, isEmpty); + expect(secondChunk, hasLength(1)); + expect(secondChunk.single.isRxFrame, isTrue); + expect(secondChunk.single.payload, orderedEquals([0x05, 0x06, 0x07])); + }); + + test( + 'UsbSerialFrameDecoder drops leading noise and parses multiple frames', + () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + 0x00, + 0x01, + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x55, + usbSerialRxFrameStart, + 0x02, + 0x00, + 0x66, + 0x77, + ]), + ); + + expect(packets, hasLength(2)); + expect(packets[0].payload, orderedEquals([0x55])); + expect(packets[1].payload, orderedEquals([0x66, 0x77])); + }, + ); + + test( + 'UsbSerialFrameDecoder preserves tx packets so caller can ignore them', + () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + usbSerialTxFrameStart, + 0x01, + 0x00, + 0x22, + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x33, + ]), + ); + + expect(packets, hasLength(2)); + expect(packets[0].isRxFrame, isFalse); + expect(packets[0].payload, orderedEquals([0x22])); + expect(packets[1].isRxFrame, isTrue); + expect(packets[1].payload, orderedEquals([0x33])); + }, + ); + + test( + 'UsbSerialFrameDecoder drops oversized frames and resyncs on the next valid packet', + () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + usbSerialRxFrameStart, + 0xAD, + 0x00, + 0x99, + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x44, + ]), + ); + + expect(packets, hasLength(1)); + expect(packets.single.isRxFrame, isTrue); + expect(packets.single.payload, orderedEquals([0x44])); + }, + ); + + test('UsbSerialFrameDecoder reset clears buffered partial data', () { + final decoder = UsbSerialFrameDecoder(); + + expect( + decoder.ingest(Uint8List.fromList([usbSerialRxFrameStart, 0x02])), + isEmpty, + ); + + decoder.reset(); + + final packets = decoder.ingest( + Uint8List.fromList([usbSerialRxFrameStart, 0x01, 0x00, 0x55]), + ); + + expect(packets, hasLength(1)); + expect(packets.single.payload, orderedEquals([0x55])); + }); + + test('recovers from invalid frame header', () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + // First, a malformed frame (e.g. from a partial TX echo) + usbSerialRxFrameStart, + usbSerialTxFrameStart, + // Then, a valid frame + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x88, + ]), + ); + + expect(packets, hasLength(1)); + expect(packets.single.isRxFrame, isTrue); + expect(packets.single.payload, orderedEquals([0x88])); + }); +} diff --git a/test/utils/usb_port_labels_test.dart b/test/utils/usb_port_labels_test.dart new file mode 100644 index 0000000..1f81c78 --- /dev/null +++ b/test/utils/usb_port_labels_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/usb_port_labels.dart'; + +void main() { + test('normalizeUsbPortName strips friendly suffix from composite label', () { + expect( + normalizeUsbPortName( + 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667', + ), + 'COM6', + ); + }); + + test( + 'friendlyUsbPortName returns only description, not hardware_id (3-part label)', + () { + expect( + friendlyUsbPortName( + 'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667', + ), + 'USB Serial Device (COM6)', + ); + }, + ); + + test( + 'friendlyUsbPortName works for macOS-style 3-part label with USB product name', + () { + expect( + friendlyUsbPortName( + '/dev/cu.usbmodem1101 - Nordic Semiconductor nRF52 DK - USB VID:PID=1915:520f SNR=ABCDEF', + ), + 'Nordic Semiconductor nRF52 DK', + ); + }, + ); + + test('friendlyUsbPortName works for Linux-style label', () { + expect( + friendlyUsbPortName( + '/dev/ttyACM0 - RAK4631 - USB VID:PID=239A:8029 SER=xxxxxxxx', + ), + 'RAK4631', + ); + }); + + test('friendlyUsbPortName trims whitespace from label parts', () { + expect( + friendlyUsbPortName(' /dev/ttyS0 - My Serial Port - n/a '), + 'My Serial Port', + ); + }); + + test( + 'friendlyUsbPortName falls back to port name when description is n/a', + () { + expect( + friendlyUsbPortName('/dev/cu.Bluetooth-Incoming-Port - n/a - n/a'), + '/dev/cu.Bluetooth-Incoming-Port', + ); + }, + ); + + test( + 'friendlyUsbPortName handles 2-part label (no hardware_id) correctly', + () { + expect( + friendlyUsbPortName('COM6 - USB Serial Device (COM6)'), + 'USB Serial Device (COM6)', + ); + }, + ); + + test('describeWebUsbPort uses known VID/PID names when available', () { + expect( + describeWebUsbPort( + vendorId: 0x2886, + productId: 0x1667, + knownUsbNames: const { + '2886:1667': 'Seeed Wio Tracker L1', + }, + ), + 'Seeed Wio Tracker L1 (VID:2886 PID:1667)', + ); + }); + + test('describeWebUsbPort falls back to generic label for unknown device', () { + expect( + describeWebUsbPort(vendorId: 0x1234, productId: 0x5678), + 'Web Serial Device (VID:1234 PID:5678)', + ); + }); + + test('describeWebUsbPort returns chooser label when no usb ids exist', () { + expect( + describeWebUsbPort(vendorId: null, productId: null), + 'Choose USB Device', + ); + }); + + test('describeWebUsbPort uses caller-provided chooser label', () { + expect( + describeWebUsbPort( + vendorId: null, + productId: null, + requestPortLabel: 'Select a USB device', + ), + 'Select a USB device', + ); + }); + + test('buildUsbDisplayLabel appends device-reported name when available', () { + expect( + buildUsbDisplayLabel( + basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)', + deviceName: 'KD3CGK mesh-utility.org', + ), + 'Seeed Wio Tracker L1 (VID:2886 PID:1667) - KD3CGK mesh-utility.org', + ); + }); + + test('buildUsbDisplayLabel keeps base label when custom name is blank', () { + expect( + buildUsbDisplayLabel( + basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)', + deviceName: ' ', + ), + 'Seeed Wio Tracker L1 (VID:2886 PID:1667)', + ); + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index daf32c2..97c813c 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -89,9 +89,11 @@ endif() # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +if(EXISTS "${NATIVE_ASSETS_DIR}") + install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4c358e7..f02857f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flserial flutter_local_notifications_windows )