Add web serial support and USB tests

This commit is contained in:
just_stuff_tm
2026-03-02 00:27:49 -05:00
committed by just-stuff-tm
parent 22a53439b1
commit c23a1da430
10 changed files with 997 additions and 331 deletions
+14 -1
View File
@@ -31,6 +31,7 @@ import '../storage/message_store.dart';
import '../storage/unread_store.dart';
import '../utils/app_logger.dart';
import '../utils/battery_utils.dart';
import '../utils/platform_info.dart';
import 'meshcore_protocol.dart';
class MeshCoreUuids {
@@ -744,7 +745,9 @@ class MeshCoreConnector extends ChangeNotifier {
);
await Future.delayed(timeout);
await stopScan();
if (!PlatformInfo.isWeb) {
await stopScan();
}
}
Future<void> stopScan() async {
@@ -898,6 +901,9 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbSerialService.connect(portName: portName, baudRate: baudRate);
if (PlatformInfo.isWeb) {
await stopScan();
}
await Future<void>.delayed(const Duration(milliseconds: 200));
_usbFrameSubscription = _usbSerialService.frameStream.listen(
_handleFrame,
@@ -2087,6 +2093,13 @@ class MeshCoreConnector extends ChangeNotifier {
if (frame.length > 58) {
_selfName = readCString(frame, 58, frame.length - 58);
}
final selfName = _selfName?.trim();
if (_activeTransport == MeshCoreTransportType.usb &&
selfName != null &&
selfName.isNotEmpty) {
_usbSerialService.updateConnectedLabel(selfName);
_activeUsbPort = _usbSerialService.activePortName ?? _activeUsbPort;
}
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
_selfInfoRetryTimer = null;
+48 -23
View File
@@ -157,30 +157,55 @@ class _ConnectionMethodButton extends StatelessWidget {
? constraints.maxWidth
: 320.0;
final isCompact = availableHeight < 72.0 || availableWidth < 180.0;
final baseGap = isCompact ? 8.0 : 12.0;
final content = Flex(
direction: isCompact ? Axis.horizontal : Axis.vertical,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: isCompact ? 24.0 : 60.0, color: iconColor),
SizedBox(
width: isCompact ? baseGap : 0,
height: isCompact ? 0 : baseGap,
),
Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.visible,
style:
(isCompact
final useTightVertical = !isCompact && availableHeight < 120.0;
final baseGap = isCompact
? 8.0
: (useTightVertical
? math.max(4.0, math.min(8.0, availableHeight * 0.06))
: 12.0);
final labelStyle =
(isCompact
? theme.textTheme.titleMedium
: (useTightVertical
? theme.textTheme.titleMedium
: theme.textTheme.titleLarge)
?.copyWith(fontWeight: FontWeight.w600),
),
],
);
: theme.textTheme.titleLarge))
?.copyWith(fontWeight: FontWeight.w600);
final verticalIconSize = useTightVertical
? math.max(32.0, math.min(48.0, availableHeight * 0.42))
: 60.0;
final content = isCompact
? Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 24.0, color: iconColor),
SizedBox(width: baseGap),
Flexible(
child: Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: labelStyle,
),
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: verticalIconSize, color: iconColor),
SizedBox(height: baseGap),
Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.visible,
style: labelStyle,
),
],
);
return Center(
child: FittedBox(
+14 -23
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/usb_port_labels.dart';
import 'contacts_screen.dart';
class UsbScreen extends StatefulWidget {
@@ -31,6 +32,14 @@ class _UsbScreenState extends State<UsbScreen> {
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
if (!mounted) return;
final activeUsbPort = _connector.activeUsbPort;
if (activeUsbPort != null &&
activeUsbPort.isNotEmpty &&
activeUsbPort != _selectedPort) {
setState(() {
_selectedPort = activeUsbPort;
});
}
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
if (_isConnecting) {
@@ -192,7 +201,7 @@ class _UsbScreenState extends State<UsbScreen> {
FilledButton.icon(
onPressed: _canConnect
? () {
final rawPortName = _normalizedPortName(
final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
debugPrint(
@@ -236,7 +245,7 @@ class _UsbScreenState extends State<UsbScreen> {
child: FilledButton.icon(
onPressed: _canConnect
? () {
final rawPortName = _normalizedPortName(
final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
debugPrint(
@@ -322,7 +331,7 @@ class _UsbScreenState extends State<UsbScreen> {
final port = _ports[index];
final isSelected = port == _selectedPort;
final displayName = _friendlyPortName(port);
final rawName = _normalizedPortName(port);
final rawName = normalizeUsbPortName(port);
final showRawName = rawName != displayName;
return Material(
color: isSelected
@@ -416,7 +425,7 @@ class _UsbScreenState extends State<UsbScreen> {
if (selectedPort == null || selectedPort.isEmpty) {
return;
}
final rawPortName = _normalizedPortName(selectedPort);
final rawPortName = normalizeUsbPortName(selectedPort);
setState(() {
_isConnecting = true;
@@ -434,23 +443,5 @@ class _UsbScreenState extends State<UsbScreen> {
}
}
String _normalizedPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - ');
final normalized = separatorIndex >= 0
? portLabel.substring(0, separatorIndex)
: portLabel;
return normalized.trim();
}
String _friendlyPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - ');
if (separatorIndex < 0) {
return portLabel.trim();
}
final friendlyName = portLabel.substring(separatorIndex + 3).trim();
if (friendlyName.isEmpty) {
return _normalizedPortName(portLabel);
}
return friendlyName;
}
String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
}
+70
View File
@@ -0,0 +1,70 @@
import 'dart:typed_data';
const int usbSerialTxFrameStart = 0x3c;
const int usbSerialRxFrameStart = 0x3e;
const int usbSerialHeaderLength = 3;
Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
final packet = Uint8List(usbSerialHeaderLength + payload.length);
packet[0] = usbSerialTxFrameStart;
packet[1] = payload.length & 0xff;
packet[2] = (payload.length >> 8) & 0xff;
packet.setRange(usbSerialHeaderLength, packet.length, payload);
return packet;
}
class UsbSerialDecodedPacket {
const UsbSerialDecodedPacket({
required this.frameStart,
required this.payload,
});
final int frameStart;
final Uint8List payload;
bool get isRxFrame => frameStart == usbSerialRxFrameStart;
}
class UsbSerialFrameDecoder {
final List<int> _rxBuffer = <int>[];
List<UsbSerialDecodedPacket> ingest(Uint8List bytes) {
if (bytes.isEmpty) {
return const <UsbSerialDecodedPacket>[];
}
_rxBuffer.addAll(bytes);
final packets = <UsbSerialDecodedPacket>[];
while (true) {
if (_rxBuffer.isEmpty) {
return packets;
}
if (_rxBuffer.first != usbSerialRxFrameStart &&
_rxBuffer.first != usbSerialTxFrameStart) {
_rxBuffer.removeAt(0);
continue;
}
if (_rxBuffer.length < usbSerialHeaderLength) {
return packets;
}
final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
final packetLength = usbSerialHeaderLength + payloadLength;
if (_rxBuffer.length < packetLength) {
return packets;
}
final frameStart = _rxBuffer.first;
final payload = Uint8List.fromList(
_rxBuffer.sublist(usbSerialHeaderLength, packetLength),
);
_rxBuffer.removeRange(0, packetLength);
packets.add(
UsbSerialDecodedPacket(frameStart: frameStart, payload: payload),
);
}
}
}
+2 -284
View File
@@ -1,284 +1,2 @@
import 'dart:async';
import 'package:flserial/flserial.dart';
import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
/// MeshCore connector to consume.
class UsbSerialService {
UsbSerialService();
static const MethodChannel _androidMethodChannel = MethodChannel(
'meshcore_open/android_usb_serial',
);
static const EventChannel _androidEventChannel = EventChannel(
'meshcore_open/android_usb_serial_events',
);
static const int _serialTxFrameStart = 0x3c;
static const int _serialRxFrameStart = 0x3e;
static const int _serialHeaderLength = 3;
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final FlSerial _serial = FlSerial();
final List<int> _rxBuffer = <int>[];
StreamSubscription<dynamic>? _androidDataSubscription;
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
UsbSerialStatus _status = UsbSerialStatus.disconnected;
String? _connectedPortName;
UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get isConnected {
if (_useAndroidUsbHost) {
return _status == UsbSerialStatus.connected;
}
return _status == UsbSerialStatus.connected &&
_serial.isOpen() == FlOpenStatus.open;
}
Future<List<String>> listPorts() async {
if (_useAndroidUsbHost) {
final ports = await _androidMethodChannel.invokeListMethod<String>(
'listPorts',
);
return ports ?? <String>[];
}
return Future.value(FlSerial.listPorts());
}
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');
}
_status = UsbSerialStatus.connecting;
final normalizedPortName = _normalizePortName(portName);
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('connect', {
'portName': normalizedPortName,
'baudRate': baudRate,
});
debugPrint(
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
);
} on PlatformException catch (error) {
_status = UsbSerialStatus.disconnected;
throw StateError(error.message ?? error.code);
}
} else {
_serial.init();
try {
final status = _serial.openPort(normalizedPortName, baudRate);
if (status != FlOpenStatus.open) {
throw StateError(
'Failed to open USB port $normalizedPortName ($status)',
);
}
_serial.setByteSize8();
_serial.setBitParityNone();
_serial.setStopBits1();
_serial.setFlowControlNone();
_serial.setRTS(false);
_serial.setDTR(true);
debugPrint(
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
);
} on FlSerialException catch (error) {
_serial.free();
_status = UsbSerialStatus.disconnected;
throw StateError(
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
);
} catch (error) {
_serial.free();
_status = UsbSerialStatus.disconnected;
rethrow;
}
}
_connectedPortName = normalizedPortName;
if (_useAndroidUsbHost) {
_androidDataSubscription = _androidEventChannel
.receiveBroadcastStream()
.listen(
_handleAndroidData,
onError: _handleSerialError,
onDone: _handleSerialDone,
);
} else {
_dataSubscription = _serial.onSerialData.stream.listen(
_handleSerialData,
onError: _handleSerialError,
onDone: _handleSerialDone,
);
}
_status = UsbSerialStatus.connected;
}
Future<void> write(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
final packet = Uint8List(_serialHeaderLength + data.length);
packet[0] = _serialTxFrameStart;
packet[1] = data.length & 0xff;
packet[2] = (data.length >> 8) & 0xff;
packet.setRange(_serialHeaderLength, packet.length, data);
_logFrameSummary('USB TX frame', data);
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<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;
_status = UsbSerialStatus.disconnecting;
_connectedPortName = null;
await _androidDataSubscription?.cancel();
_androidDataSubscription = null;
await _dataSubscription?.cancel();
_dataSubscription = null;
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('disconnect');
} catch (_) {
// Ignore errors while closing.
}
} else {
try {
if (_serial.isOpen() == FlOpenStatus.open) {
_serial.closePort();
}
} catch (_) {
// Ignore errors while closing.
}
_serial.free();
}
_status = UsbSerialStatus.disconnected;
}
void dispose() {
unawaited(disconnect());
unawaited(_frameController.close());
}
void _handleSerialData(FlSerialEventArgs event) {
try {
final bytes = event.serial.readList();
if (bytes.isNotEmpty) {
_ingestRawBytes(Uint8List.fromList(bytes));
}
} catch (error, stack) {
_frameController.addError(error, stack);
}
}
void _handleAndroidData(dynamic data) {
if (data is Uint8List) {
_ingestRawBytes(data);
return;
}
if (data is ByteData) {
_ingestRawBytes(data.buffer.asUint8List());
return;
}
_frameController.addError(
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
);
}
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
_frameController.addError(error, stackTrace);
}
void _handleSerialDone() {
unawaited(disconnect());
}
String _normalizePortName(String portName) {
final separatorIndex = portName.indexOf(' - ');
final normalized = separatorIndex >= 0
? portName.substring(0, separatorIndex)
: portName;
return normalized.trim();
}
void _ingestRawBytes(Uint8List bytes) {
if (bytes.isEmpty) {
return;
}
_rxBuffer.addAll(bytes);
_drainRxBuffer();
}
void _drainRxBuffer() {
while (true) {
if (_rxBuffer.isEmpty) {
return;
}
if (_rxBuffer.first != _serialRxFrameStart &&
_rxBuffer.first != _serialTxFrameStart) {
_rxBuffer.removeAt(0);
continue;
}
if (_rxBuffer.length < _serialHeaderLength) {
return;
}
final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
final packetLength = _serialHeaderLength + payloadLength;
if (_rxBuffer.length < packetLength) {
return;
}
final frameStart = _rxBuffer.first;
final payload = Uint8List.fromList(
_rxBuffer.sublist(_serialHeaderLength, packetLength),
);
_rxBuffer.removeRange(0, packetLength);
if (frameStart != _serialRxFrameStart) {
debugPrint(
'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}',
);
}
_frameController.add(payload);
}
}
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
return;
}
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
}
}
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
export 'usb_serial_service_native.dart'
if (dart.library.js_interop) 'usb_serial_service_web.dart';
+247
View File
@@ -0,0 +1,247 @@
import 'dart:async';
import 'package:flserial/flserial.dart';
import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
/// MeshCore connector to consume.
class UsbSerialService {
UsbSerialService();
static const MethodChannel _androidMethodChannel = MethodChannel(
'meshcore_open/android_usb_serial',
);
static const EventChannel _androidEventChannel = EventChannel(
'meshcore_open/android_usb_serial_events',
);
final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final FlSerial _serial = FlSerial();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<dynamic>? _androidDataSubscription;
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
UsbSerialStatus _status = UsbSerialStatus.disconnected;
String? _connectedPortName;
UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get isConnected {
if (_useAndroidUsbHost) {
return _status == UsbSerialStatus.connected;
}
return _status == UsbSerialStatus.connected &&
_serial.isOpen() == FlOpenStatus.open;
}
Future<List<String>> listPorts() async {
if (_useAndroidUsbHost) {
final ports = await _androidMethodChannel.invokeListMethod<String>(
'listPorts',
);
return ports ?? <String>[];
}
return Future.value(FlSerial.listPorts());
}
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');
}
_status = UsbSerialStatus.connecting;
final normalizedPortName = normalizeUsbPortName(portName);
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('connect', {
'portName': normalizedPortName,
'baudRate': baudRate,
});
debugPrint(
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
);
} on PlatformException catch (error) {
_status = UsbSerialStatus.disconnected;
throw StateError(error.message ?? error.code);
}
} else {
_serial.init();
try {
final status = _serial.openPort(normalizedPortName, baudRate);
if (status != FlOpenStatus.open) {
throw StateError(
'Failed to open USB port $normalizedPortName ($status)',
);
}
_serial.setByteSize8();
_serial.setBitParityNone();
_serial.setStopBits1();
_serial.setFlowControlNone();
_serial.setRTS(false);
_serial.setDTR(true);
debugPrint(
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
);
} on FlSerialException catch (error) {
_serial.free();
_status = UsbSerialStatus.disconnected;
throw StateError(
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
);
} catch (error) {
_serial.free();
_status = UsbSerialStatus.disconnected;
rethrow;
}
}
_connectedPortName = normalizedPortName;
if (_useAndroidUsbHost) {
_androidDataSubscription = _androidEventChannel
.receiveBroadcastStream()
.listen(
_handleAndroidData,
onError: _handleSerialError,
onDone: _handleSerialDone,
);
} else {
_dataSubscription = _serial.onSerialData.stream.listen(
_handleSerialData,
onError: _handleSerialError,
onDone: _handleSerialDone,
);
}
_status = UsbSerialStatus.connected;
}
Future<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;
_status = UsbSerialStatus.disconnecting;
_connectedPortName = null;
await _androidDataSubscription?.cancel();
_androidDataSubscription = null;
await _dataSubscription?.cancel();
_dataSubscription = null;
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('disconnect');
} catch (_) {
// Ignore errors while closing.
}
} else {
try {
if (_serial.isOpen() == FlOpenStatus.open) {
_serial.closePort();
}
} catch (_) {
// Ignore errors while closing.
}
_serial.free();
}
_status = UsbSerialStatus.disconnected;
}
void updateConnectedLabel(String label) {
final trimmed = label.trim();
if (trimmed.isEmpty) {
return;
}
_connectedPortName = trimmed;
}
void dispose() {
unawaited(disconnect());
unawaited(_frameController.close());
}
void _handleSerialData(FlSerialEventArgs event) {
try {
final bytes = event.serial.readList();
if (bytes.isNotEmpty) {
_ingestRawBytes(Uint8List.fromList(bytes));
}
} catch (error, stack) {
_frameController.addError(error, stack);
}
}
void _handleAndroidData(dynamic data) {
if (data is Uint8List) {
_ingestRawBytes(data);
return;
}
if (data is ByteData) {
_ingestRawBytes(data.buffer.asUint8List());
return;
}
_frameController.addError(
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
);
}
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
_frameController.addError(error, stackTrace);
}
void _handleSerialDone() {
unawaited(disconnect());
}
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
debugPrint(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
);
continue;
}
_frameController.add(packet.payload);
}
}
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
return;
}
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
}
}
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
class UsbSerialService {
UsbSerialService();
static const Map<String, String> _knownUsbNames = <String, String>{
'2886:1667': 'Seeed Wio Tracker L1',
};
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
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;
UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName;
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>[];
}
final ports = await _getAuthorizedPorts();
if (ports.isEmpty) {
return const <String>[usbRequestPortLabel];
}
return ports.map(_displayLabelForPort).toList(growable: false);
}
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;
try {
final requestedPortName = normalizeUsbPortName(portName);
final authorizedPorts = await _getAuthorizedPorts();
_port = _selectPort(authorizedPorts, requestedPortName);
_port ??= await _requestPort();
if (_port == null) {
throw StateError('No USB serial device selected');
}
await _openPort(_port!, baudRate);
_connectedPortKey = _portKeyFor(_port!);
_connectedPortName = _buildDisplayLabel(_connectedPortKey!);
_writer = _getWriter(_port!);
_reader = _getReader(_port!);
_status = UsbSerialStatus.connected;
unawaited(_pumpReads());
debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
} catch (error) {
_status = UsbSerialStatus.disconnected;
_connectedPortName = null;
rethrow;
}
}
Future<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;
_status = UsbSerialStatus.disconnecting;
final reader = _reader;
final writer = _writer;
final port = _port;
_reader = null;
_writer = null;
_port = null;
_connectedPortName = null;
_connectedPortKey = null;
if (reader != null) {
try {
await reader.callMethod<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;
}
void updateConnectedLabel(String label) {
final trimmed = label.trim();
final portKey = _connectedPortKey;
if (trimmed.isEmpty || portKey == null) {
return;
}
_deviceNamesByPortKey[portKey] = trimmed;
_connectedPortName = _buildDisplayLabel(portKey);
}
void dispose() {
unawaited(disconnect());
unawaited(_frameController.close());
}
Future<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 == usbRequestPortLabel) {
return ports.first;
}
for (final port in ports) {
final description = _describePort(port);
if (description == requestedPortName) {
return port;
}
}
return null;
}
Future<void> _openPort(JSObject port, int baudRate) {
final options = JSObject()..['baudRate'] = baudRate.toJS;
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
}
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) return;
try {
while (_status == UsbSerialStatus.connected &&
identical(reader, _reader)) {
final result = await reader
.callMethod<JSPromise<JSAny?>>('read'.toJS)
.toDart;
if (result == null) {
break;
}
final resultObject = result as JSObject;
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
final done = doneValue != null && doneValue.dartify() == true;
if (done) {
break;
}
final value = resultObject.getProperty<JSAny?>('value'.toJS);
final bytes = _coerceBytes(value);
if (bytes != null && bytes.isNotEmpty) {
_ingestRawBytes(bytes);
}
}
} catch (error, stackTrace) {
if (_status == UsbSerialStatus.connected) {
_frameController.addError(error, stackTrace);
}
} finally {
_releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_frameController.addError(StateError('USB serial connection closed'));
}
}
}
Uint8List? _coerceBytes(JSAny? value) {
if (value == null) return null;
try {
return (value as JSUint8Array).toDart;
} catch (_) {
// Fall back to array-like coercion below.
}
final object = value as JSObject;
if (object.has('length')) {
final lengthValue = object.getProperty<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) {
try {
final info = port.callMethod<JSAny?>('getInfo'.toJS);
if (info == null) {
return usbRequestPortLabel;
}
final infoObject = info as JSObject;
final vendorId = infoObject
.getProperty<JSAny?>('usbVendorId'.toJS)
?.dartify();
final productId = infoObject
.getProperty<JSAny?>('usbProductId'.toJS)
?.dartify();
final hasVendor = vendorId is num;
final hasProduct = productId is num;
return describeWebUsbPort(
vendorId: hasVendor ? vendorId.toInt() : null,
productId: hasProduct ? productId.toInt() : null,
knownUsbNames: _knownUsbNames,
);
} catch (_) {
return usbRequestPortLabel;
}
}
String _portKeyFor(JSObject port) => _describePort(port);
String _displayLabelForPort(JSObject port) =>
_buildDisplayLabel(_portKeyFor(port));
String _buildDisplayLabel(String portKey) {
return buildUsbDisplayLabel(
basePortLabel: portKey,
deviceName: _deviceNamesByPortKey[portKey],
);
}
void _releaseLock(JSObject resource) {
try {
resource.callMethod<JSAny?>('releaseLock'.toJS);
} catch (_) {
// Ignore lock release failures.
}
}
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
debugPrint(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
);
continue;
}
_frameController.add(packet.payload);
}
}
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
return;
}
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
}
}
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
+57
View File
@@ -0,0 +1,57 @@
const String usbRequestPortLabel = 'Choose USB Device';
String normalizeUsbPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - ');
final normalized = separatorIndex >= 0
? portLabel.substring(0, separatorIndex)
: portLabel;
return normalized.trim();
}
String friendlyUsbPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - ');
if (separatorIndex < 0) {
return portLabel.trim();
}
final friendlyName = portLabel.substring(separatorIndex + 3).trim();
if (friendlyName.isEmpty) {
return normalizeUsbPortName(portLabel);
}
return friendlyName;
}
String describeWebUsbPort({
required int? vendorId,
required int? productId,
Map<String, String> knownUsbNames = const <String, String>{},
}) {
if (vendorId == null && productId == null) {
return usbRequestPortLabel;
}
final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();
final productHex = productId?.toRadixString(16).padLeft(4, '0').toUpperCase();
final knownName = (vendorHex != null && productHex != null)
? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}']
: null;
final parts = <String>[knownName ?? 'Web Serial Device'];
if (vendorHex != null) {
parts.add('VID:$vendorHex');
}
if (productHex != null) {
parts.add('PID:$productHex');
}
return '${parts.first} (${parts.skip(1).join(' ')})';
}
String buildUsbDisplayLabel({
required String basePortLabel,
String? deviceName,
}) {
final trimmedName = deviceName?.trim() ?? '';
if (trimmedName.isEmpty) {
return basePortLabel;
}
return '$basePortLabel - $trimmedName';
}
@@ -0,0 +1,84 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
void main() {
test('wrapUsbSerialTxFrame prefixes tx header and payload length', () {
final packet = wrapUsbSerialTxFrame(Uint8List.fromList(<int>[0x16, 0x03]));
expect(
packet,
orderedEquals(<int>[usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]),
);
});
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]));
},
);
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/utils/usb_port_labels.dart';
void main() {
test('normalizeUsbPortName strips friendly suffix from composite label', () {
expect(
normalizeUsbPortName(
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
),
'COM6',
);
});
test('friendlyUsbPortName prefers suffix when present', () {
expect(
friendlyUsbPortName(
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
),
'USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
);
});
test(
'friendlyUsbPortName falls back to normalized port when suffix is empty',
() {
expect(friendlyUsbPortName('COM6 - '), 'COM6');
},
);
test('describeWebUsbPort uses known VID/PID names when available', () {
expect(
describeWebUsbPort(
vendorId: 0x2886,
productId: 0x1667,
knownUsbNames: const <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),
usbRequestPortLabel,
);
});
test('buildUsbDisplayLabel appends device-reported name when available', () {
expect(
buildUsbDisplayLabel(
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
deviceName: 'KD3CGK mesh-utility.org',
),
'Seeed Wio Tracker L1 (VID:2886 PID:1667) - KD3CGK mesh-utility.org',
);
});
test('buildUsbDisplayLabel keeps base label when custom name is blank', () {
expect(
buildUsbDisplayLabel(
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
deviceName: ' ',
),
'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
);
});
}