Merge pull request #252 from just-stuff-tm/feature/usb

Feature/usb
This commit is contained in:
zjs81 2026-03-07 13:16:39 -07:00 committed by GitHub
commit 4eecfc92dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 5522 additions and 164 deletions

0
.gitmodules vendored Normal file
View file

View file

@ -19,6 +19,7 @@
<!-- Camera permission for QR code scanning -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="meshcore_open"

View file

@ -1,5 +1,18 @@
package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
usbFunctions.configureFlutterEngine(flutterEngine)
}
override fun onDestroy() {
usbFunctions.dispose()
super.onDestroy()
}
}

View file

@ -0,0 +1,582 @@
package com.meshcore.meshcore_open
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class MeshcoreUsbFunctions(
private val activity: FlutterActivity,
) {
private companion object {
const val usbRecipientInterface = 0x01
}
private val usbMethodChannelName = "meshcore_open/android_usb_serial"
private val usbEventChannelName = "meshcore_open/android_usb_serial_events"
private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION"
private val usbManager by lazy {
activity.getSystemService(Context.USB_SERVICE) as UsbManager
}
private val mainHandler = Handler(Looper.getMainLooper())
private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
@Volatile private var eventSink: EventChannel.EventSink? = null
@Volatile private var usbConnection: UsbDeviceConnection? = null
@Volatile private var usbInEndpoint: UsbEndpoint? = null
@Volatile private var usbOutEndpoint: UsbEndpoint? = null
@Volatile private var controlInterface: UsbInterface? = null
@Volatile private var dataInterface: UsbInterface? = null
private var readThread: Thread? = null
@Volatile private var isReading = false
@Volatile private var connectedDeviceName: String? = null
private var pendingConnectResult: MethodChannel.Result? = null
private var pendingConnectPortName: String? = null
private var pendingConnectBaudRate: Int = 115200
private data class PortConfig(
val controlInterface: UsbInterface?,
val dataInterface: UsbInterface,
val inEndpoint: UsbEndpoint,
val outEndpoint: UsbEndpoint,
)
private val permissionReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
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<String> {
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<String>("portName")
val baudRate = call.argument<Int>("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<ByteArray>("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
}
}

View file

@ -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<Uint8List>? _usbFrameSubscription;
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = [];
final List<Contact> _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 <Message>[];
final mergedMessages = <Message>[...windowedMessages];
final persistedKeyCounts = <String, int>{};
for (final message in windowedMessages) {
final key = _messageMergeKey(message);
persistedKeyCounts[key] = (persistedKeyCounts[key] ?? 0) + 1;
}
final currentKeyCounts = <String, int>{};
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<List<Message>> 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<void> 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<List<String>> listUsbPorts() => _usbManager.listPorts();
void setUsbRequestPortLabel(String label) {
_usbManager.setRequestPortLabel(label);
}
void setUsbFallbackDeviceName(String label) {
_usbManager.setFallbackDeviceName(label);
}
Future<void> 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<void>.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<void> 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<BluetoothService> 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<BluetoothService> 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<void>.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<void> _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<void> 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<void> 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<void> _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<Contact?>().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();

View file

@ -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<Uint8List> get frameStream => _service.frameStream;
// --- Configuration ---
Future<List<String>> 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<void> 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<void> disconnect() async {
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect();
_activePortKey = null;
_activePortLabel = null;
}
Future<void> write(Uint8List data) => _service.write(data);
// --- Label management ---
void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName);
_activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
}
void dispose() {
_service.dispose();
}
}

View file

@ -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 връзка."
}

View file

@ -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."
}

View file

@ -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...",

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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:

View file

@ -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 => 'Сканиране за устройства...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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ń...';

View file

@ -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...';

View file

@ -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 => 'Поиск устройств...';

View file

@ -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í...';

View file

@ -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...';

View file

@ -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...';

View file

@ -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 => 'Пошук пристроїв...';

View file

@ -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 => '正在搜索设备...';

View file

@ -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."
}

View file

@ -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\"."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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 伴侣固件。"
}

View file

@ -401,8 +401,15 @@ class _ContactsScreenState extends State<ContactsScreen>
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());
}

View file

@ -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<ScannerScreen> {
bool _changedNavigation = false;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
late StreamSubscription<BluetoothAdapterState> _bluetoothStateSubscription;
@ -27,12 +30,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_connector = Provider.of<MeshCoreConnector>(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<ScannerScreen> {
}
};
connector.addListener(_connectionListener);
_connector.addListener(_connectionListener);
_bluetoothStateSubscription = FlutterBluePlus.adapterState.listen(
(state) {
@ -53,28 +59,42 @@ class _ScannerScreenState extends State<ScannerScreen> {
});
// 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<MeshCoreConnector>(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<ScannerScreen> {
},
),
),
floatingActionButton: Consumer<MeshCoreConnector>(
bottomNavigationBar: Consumer<MeshCoreConnector>(
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,
),
),
],
),
);
},

407
lib/screens/usb_screen.dart Normal file
View file

@ -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<UsbScreen> createState() => _UsbScreenState();
}
class _UsbScreenState extends State<UsbScreen> {
final List<String> _ports = <String>[];
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<MeshCoreConnector>();
_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<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
children: [
_buildStatusBar(context, connector),
Expanded(child: _buildPortList(context, connector)),
],
);
},
),
),
bottomNavigationBar: Consumer<MeshCoreConnector>(
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<void> _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<void> _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<void> _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();
}
}

View file

@ -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(

View file

@ -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<bool> _ensureInitialized() async {
if (!_isInitialized) {
await initialize();

View file

@ -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<int> _rxBuffer = <int>[];
int _startIndex = 0;
void reset() {
_rxBuffer.clear();
_startIndex = 0;
}
List<UsbSerialDecodedPacket> ingest(Uint8List bytes) {
if (bytes.isEmpty) {
return const <UsbSerialDecodedPacket>[];
}
_rxBuffer.addAll(bytes);
final packets = <UsbSerialDecodedPacket>[];
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;
}
}

View file

@ -0,0 +1,2 @@
export 'usb_serial_service_native.dart'
if (dart.library.js_interop) 'usb_serial_service_web.dart';

View file

@ -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<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<dynamic>? _androidDataSubscription;
StreamSubscription<FlSerialEventArgs>? _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<Uint8List> 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<List<String>> listPorts() async {
if (!_isSupportedPlatform) {
return const <String>[];
}
if (_useAndroidUsbHost) {
final ports = await _androidMethodChannel.invokeListMethod<String>(
'listPorts',
);
return ports ?? <String>[];
}
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<List<String>> _annotateMacOsPorts(List<String> 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<void> 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<void>('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<void> 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<void>('write', {
'data': packet,
});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(packet);
}
}
Future<void> 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<void>('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<void> _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<String> _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 }

View file

@ -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<String, String> _knownUsbNames = <String, String>{
'2886:1667': 'Seeed Wio Tracker L1',
};
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
static final Map<String, JSObject> _authorizedPortsByKey =
<String, JSObject>{};
static int _nextAuthorizedPortId = 1;
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.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<Uint8List> 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<List<String>> listPorts() async {
if (!_isSupported) {
return const <String>[];
}
_resetPortCache();
final ports = await _getAuthorizedPorts();
return <String>[_requestPortListEntry, ...ports.map(_listEntryForPort)];
}
Future<void> 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<void> 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<JSPromise<JSAny?>>(
'write'.toJS,
packet.toJS,
);
await promise.toDart;
}
Future<void> 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<JSPromise<JSAny?>>('cancel'.toJS).toDart;
} catch (_) {
// Ignore errors while closing.
}
_releaseLock(reader);
}
if (writer != null) {
_releaseLock(writer);
}
if (port != null) {
try {
await port.callMethod<JSPromise<JSAny?>>('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<List<JSObject>> _getAuthorizedPorts() async {
final serial = _serial;
if (serial == null) {
return const <JSObject>[];
}
final result = await serial
.callMethod<JSPromise<JSAny?>>('getPorts'.toJS)
.toDart;
return _toObjectList(result);
}
Future<JSObject?> _requestPort() async {
final serial = _serial;
if (serial == null) {
return null;
}
final result = await serial
.callMethod<JSPromise<JSAny?>>('requestPort'.toJS)
.toDart;
return result == null ? null : result as JSObject;
}
JSObject? _selectPort(List<JSObject> 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<void> _openPort(JSObject port, int baudRate) async {
final options = JSObject()
..['baudRate'] = baudRate.toJS
..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('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<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
}
Future<void> _cleanupFailedConnect() async {
final reader = _reader;
final writer = _writer;
final port = _port;
_reader = null;
_writer = null;
_port = null;
if (reader != null) {
try {
await reader.callMethod<JSPromise<JSAny?>>('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<JSPromise<JSAny?>>('close'.toJS).toDart;
} catch (_) {
// Ignore cleanup errors after a failed connect.
}
}
}
JSObject? _getReader(JSObject port) {
final readable = port.getProperty<JSAny?>('readable'.toJS);
if (readable == null) {
throw StateError('Web Serial port is not readable');
}
final readableObject = readable as JSObject;
return readableObject.callMethod<JSAny?>('getReader'.toJS) as JSObject;
}
JSObject? _getWriter(JSObject port) {
final writable = port.getProperty<JSAny?>('writable'.toJS);
if (writable == null) {
throw StateError('Web Serial port is not writable');
}
final writableObject = writable as JSObject;
return writableObject.callMethod<JSAny?>('getWriter'.toJS) as JSObject;
}
Future<void> _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<JSPromise<JSAny?>>('read'.toJS)
.toDart;
if (result == null) {
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
break;
}
final resultObject = result as JSObject;
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
final done = doneValue != null && doneValue.dartify() == true;
if (done) {
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
break;
}
final value = resultObject.getProperty<JSAny?>('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<JSAny?>('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<JSAny?>(i.toString().toJS)?.dartify();
if (item is num) {
bytes[i] = item.toInt();
}
}
return bytes;
}
}
return null;
}
List<JSObject> _toObjectList(JSAny? value) {
if (value == null) {
return const <JSObject>[];
}
final object = value as JSObject;
if (!object.has('length')) {
return const <JSObject>[];
}
final lengthValue = object.getProperty<JSAny?>('length'.toJS)?.dartify();
if (lengthValue is! num) {
return const <JSObject>[];
}
final length = lengthValue.toInt();
final items = <JSObject>[];
for (var i = 0; i < length; i++) {
final item = object.getProperty<JSAny?>(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<JSAny?>('getInfo'.toJS);
if (info == null) {
return null;
}
final infoObject = info as JSObject;
final vendorId = infoObject
.getProperty<JSAny?>('usbVendorId'.toJS)
?.dartify();
final productId = infoObject
.getProperty<JSAny?>('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<JSAny?>('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<void> _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;
}

View file

@ -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<bool> showDisconnectDialog(
);
if (confirmed == true) {
appLogger.info('Disconnect confirmed from popup', tag: 'Connection');
await connector.disconnect();
return true;
}

View file

@ -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<Map<String, String>> 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 <String, String>{};
return _parseIoregOutput(result.stdout as String);
} catch (_) {
return const <String, String>{};
}
}
Map<String, String> _parseIoregOutput(String output) {
final lines = output.split('\n');
final result = <String, String>{};
// 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<String> currentPorts = <String>[];
void flushBlock() {
if (currentPorts.isNotEmpty &&
(currentVendor != null || currentProduct != null)) {
final parts = <String>[
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" = "([^"]*)"');

View file

@ -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;
}

View file

@ -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:
/// `"<port> - <description> - <hardware_id>"`
///
/// 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<String, String> knownUsbNames = const <String, String>{},
}) {
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 = <String>[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';
}

View file

@ -78,40 +78,36 @@ class _SNRIndicatorState extends State<SNRIndicator> {
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<SNRIndicator> {
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),

View file

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial
)
set(PLUGIN_BUNDLED_LIBRARIES)

View file

@ -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"))

View file

@ -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

View file

@ -12,6 +12,14 @@
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array>
<string>/dev/cu.</string>
<string>/dev/tty.</string>
</array>
<key>com.apple.security.device.camera</key>
<true/>
</dict>

View file

@ -8,6 +8,14 @@
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
<array>
<string>/dev/cu.</string>
<string>/dev/tty.</string>
</array>
<key>com.apple.security.device.camera</key>
<true/>
</dict>

View file

@ -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=<your pubspec version> to bootstrap/JS files
# This is the key flag! It appends ?v=<your pubspec version> to bootstrap/JS files

View file

@ -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<String>? ports,
}) : _ports = ports ?? <String>[];
final MeshCoreConnectionState initialState;
final List<String> _ports;
String? requestPortLabel;
String? fallbackDeviceName;
int connectUsbCalls = 0;
String? lastConnectPortName;
String? fakeActiveUsbPort;
String? fakeActiveUsbPortDisplayLabel;
bool fakeUsbTransportConnected = false;
Future<List<String>> Function()? listUsbPortsImpl;
Future<void> 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<List<String>> listUsbPorts() async {
if (listUsbPortsImpl != null) {
return listUsbPortsImpl!();
}
return List<String>.from(_ports);
}
@override
Future<void> 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<MeshCoreConnector>.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: <String>['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<ElevatedButton>(),
),
);
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: <String>['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<ElevatedButton>(),
),
);
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: <String>['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<ElevatedButton>(),
),
);
await tester.pumpAndSettle();
expect(connectAttempted, isTrue);
expect(
find.text('Another USB connection request is already in progress.'),
findsOneWidget,
);
});
});
}

View file

@ -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(<int>[0x16, 0x03]));
expect(
packet,
orderedEquals(<int>[usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]),
);
});
test('wrapUsbSerialTxFrame rejects payloads above protocol maximum', () {
final payload = Uint8List(usbSerialMaxPayloadLength + 1);
expect(
() => wrapUsbSerialTxFrame(payload),
throwsA(
isA<ArgumentError>().having(
(error) => error.name,
'name',
'payload.length',
),
),
);
});
test('UsbSerialFrameDecoder buffers partial frames until complete', () {
final decoder = UsbSerialFrameDecoder();
final firstChunk = decoder.ingest(
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x03]),
);
final secondChunk = decoder.ingest(
Uint8List.fromList(<int>[0x00, 0x05, 0x06, 0x07]),
);
expect(firstChunk, isEmpty);
expect(secondChunk, hasLength(1));
expect(secondChunk.single.isRxFrame, isTrue);
expect(secondChunk.single.payload, orderedEquals(<int>[0x05, 0x06, 0x07]));
});
test(
'UsbSerialFrameDecoder drops leading noise and parses multiple frames',
() {
final decoder = UsbSerialFrameDecoder();
final packets = decoder.ingest(
Uint8List.fromList(<int>[
0x00,
0x01,
usbSerialRxFrameStart,
0x01,
0x00,
0x55,
usbSerialRxFrameStart,
0x02,
0x00,
0x66,
0x77,
]),
);
expect(packets, hasLength(2));
expect(packets[0].payload, orderedEquals(<int>[0x55]));
expect(packets[1].payload, orderedEquals(<int>[0x66, 0x77]));
},
);
test(
'UsbSerialFrameDecoder preserves tx packets so caller can ignore them',
() {
final decoder = UsbSerialFrameDecoder();
final packets = decoder.ingest(
Uint8List.fromList(<int>[
usbSerialTxFrameStart,
0x01,
0x00,
0x22,
usbSerialRxFrameStart,
0x01,
0x00,
0x33,
]),
);
expect(packets, hasLength(2));
expect(packets[0].isRxFrame, isFalse);
expect(packets[0].payload, orderedEquals(<int>[0x22]));
expect(packets[1].isRxFrame, isTrue);
expect(packets[1].payload, orderedEquals(<int>[0x33]));
},
);
test(
'UsbSerialFrameDecoder drops oversized frames and resyncs on the next valid packet',
() {
final decoder = UsbSerialFrameDecoder();
final packets = decoder.ingest(
Uint8List.fromList(<int>[
usbSerialRxFrameStart,
0xAD,
0x00,
0x99,
usbSerialRxFrameStart,
0x01,
0x00,
0x44,
]),
);
expect(packets, hasLength(1));
expect(packets.single.isRxFrame, isTrue);
expect(packets.single.payload, orderedEquals(<int>[0x44]));
},
);
test('UsbSerialFrameDecoder reset clears buffered partial data', () {
final decoder = UsbSerialFrameDecoder();
expect(
decoder.ingest(Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x02])),
isEmpty,
);
decoder.reset();
final packets = decoder.ingest(
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x01, 0x00, 0x55]),
);
expect(packets, hasLength(1));
expect(packets.single.payload, orderedEquals(<int>[0x55]));
});
test('recovers from invalid frame header', () {
final decoder = UsbSerialFrameDecoder();
final packets = decoder.ingest(
Uint8List.fromList(<int>[
// 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(<int>[0x88]));
});
}

View file

@ -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 <String, String>{
'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)',
);
});
}

View file

@ -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.

View file

@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial
flutter_local_notifications_windows
)