commit
4eecfc92dc
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export 'usb_serial_service_native.dart'
|
||||
if (dart.library.js_interop) 'usb_serial_service_web.dart';
|
||||
@ -0,0 +1,580 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'app_debug_log_service.dart';
|
||||
import '../utils/usb_port_labels.dart';
|
||||
import 'usb_serial_frame_codec.dart';
|
||||
|
||||
class UsbSerialService {
|
||||
UsbSerialService();
|
||||
|
||||
static const Map<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;
|
||||
}
|
||||
@ -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" = "([^"]*)"');
|
||||
@ -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';
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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]));
|
||||
});
|
||||
}
|
||||
@ -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)',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue