diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f514f15a..d7731f15 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 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.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; diff --git a/lib/screens/connection_choice_screen.dart b/lib/screens/connection_choice_screen.dart index a2ea183b..e4abe7f6 100644 --- a/lib/screens/connection_choice_screen.dart +++ b/lib/screens/connection_choice_screen.dart @@ -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( diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index e542d616..c3c10669 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -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 { _connector = context.read(); _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 { FilledButton.icon( onPressed: _canConnect ? () { - final rawPortName = _normalizedPortName( + final rawPortName = normalizeUsbPortName( _selectedPort!, ); debugPrint( @@ -236,7 +245,7 @@ class _UsbScreenState extends State { child: FilledButton.icon( onPressed: _canConnect ? () { - final rawPortName = _normalizedPortName( + final rawPortName = normalizeUsbPortName( _selectedPort!, ); debugPrint( @@ -322,7 +331,7 @@ class _UsbScreenState extends State { 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 { 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 { } } - 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); } diff --git a/lib/services/usb_serial_frame_codec.dart b/lib/services/usb_serial_frame_codec.dart new file mode 100644 index 00000000..ee4a17c4 --- /dev/null +++ b/lib/services/usb_serial_frame_codec.dart @@ -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 _rxBuffer = []; + + List ingest(Uint8List bytes) { + if (bytes.isEmpty) { + return const []; + } + + _rxBuffer.addAll(bytes); + final packets = []; + + 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), + ); + } + } +} diff --git a/lib/services/usb_serial_service.dart b/lib/services/usb_serial_service.dart index 7e9027df..343d0ea2 100644 --- a/lib/services/usb_serial_service.dart +++ b/lib/services/usb_serial_service.dart @@ -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 _frameController = - StreamController.broadcast(); - final FlSerial _serial = FlSerial(); - final List _rxBuffer = []; - StreamSubscription? _androidDataSubscription; - StreamSubscription? _dataSubscription; - UsbSerialStatus _status = UsbSerialStatus.disconnected; - String? _connectedPortName; - - UsbSerialStatus get status => _status; - String? get activePortName => _connectedPortName; - Stream 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> listPorts() async { - if (_useAndroidUsbHost) { - final ports = await _androidMethodChannel.invokeListMethod( - 'listPorts', - ); - return ports ?? []; - } - return Future.value(FlSerial.listPorts()); - } - - Future 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('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 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('write', { - 'data': packet, - }); - } on PlatformException catch (error) { - throw StateError(error.message ?? error.code); - } - } else { - _serial.write(packet); - } - } - - Future disconnect() async { - if (_status == UsbSerialStatus.disconnected) return; - - _status = UsbSerialStatus.disconnecting; - _connectedPortName = null; - await _androidDataSubscription?.cancel(); - _androidDataSubscription = null; - await _dataSubscription?.cancel(); - _dataSubscription = null; - - if (_useAndroidUsbHost) { - try { - await _androidMethodChannel.invokeMethod('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'; diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart new file mode 100644 index 00000000..f69c6fc7 --- /dev/null +++ b/lib/services/usb_serial_service_native.dart @@ -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 _frameController = + StreamController.broadcast(); + final FlSerial _serial = FlSerial(); + final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder(); + StreamSubscription? _androidDataSubscription; + StreamSubscription? _dataSubscription; + UsbSerialStatus _status = UsbSerialStatus.disconnected; + String? _connectedPortName; + + UsbSerialStatus get status => _status; + String? get activePortName => _connectedPortName; + Stream 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> listPorts() async { + if (_useAndroidUsbHost) { + final ports = await _androidMethodChannel.invokeListMethod( + 'listPorts', + ); + return ports ?? []; + } + return Future.value(FlSerial.listPorts()); + } + + Future 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('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 write(Uint8List data) async { + if (!isConnected) { + throw StateError('USB serial port is not open'); + } + final packet = wrapUsbSerialTxFrame(data); + _logFrameSummary('USB TX frame', data); + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('write', { + 'data': packet, + }); + } on PlatformException catch (error) { + throw StateError(error.message ?? error.code); + } + } else { + _serial.write(packet); + } + } + + Future disconnect() async { + if (_status == UsbSerialStatus.disconnected) return; + + _status = UsbSerialStatus.disconnecting; + _connectedPortName = null; + await _androidDataSubscription?.cancel(); + _androidDataSubscription = null; + await _dataSubscription?.cancel(); + _dataSubscription = null; + + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('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 } diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart new file mode 100644 index 00000000..67844dfe --- /dev/null +++ b/lib/services/usb_serial_service_web.dart @@ -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 _knownUsbNames = { + '2886:1667': 'Seeed Wio Tracker L1', + }; + static final Map _deviceNamesByPortKey = {}; + + final StreamController _frameController = + StreamController.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 get frameStream => _frameController.stream; + bool get isConnected => _status == UsbSerialStatus.connected; + + JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator); + bool get _isSupported => _navigator.has('serial'); + JSObject? get _serial { + if (!_isSupported) { + return null; + } + final serial = _navigator['serial']; + return serial == null ? null : serial as JSObject; + } + + Future> listPorts() async { + if (!_isSupported) { + return const []; + } + + final ports = await _getAuthorizedPorts(); + if (ports.isEmpty) { + return const [usbRequestPortLabel]; + } + return ports.map(_displayLabelForPort).toList(growable: false); + } + + Future connect({ + required String portName, + int baudRate = 115200, + }) async { + if (_status == UsbSerialStatus.connected || + _status == UsbSerialStatus.connecting) { + throw StateError('USB serial transport is already active'); + } + if (!_isSupported) { + throw UnsupportedError('Web Serial is not supported by this browser.'); + } + + _status = UsbSerialStatus.connecting; + + 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 write(Uint8List data) async { + if (!isConnected || _writer == null) { + throw StateError('USB serial port is not open'); + } + + final packet = wrapUsbSerialTxFrame(data); + _logFrameSummary('USB TX frame', data); + + final promise = _writer!.callMethod>( + 'write'.toJS, + packet.toJS, + ); + await promise.toDart; + } + + Future disconnect() async { + if (_status == UsbSerialStatus.disconnected) return; + + _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>('cancel'.toJS).toDart; + } catch (_) { + // Ignore errors while closing. + } + _releaseLock(reader); + } + + if (writer != null) { + _releaseLock(writer); + } + + if (port != null) { + try { + await port.callMethod>('close'.toJS).toDart; + } catch (_) { + // Ignore errors while closing. + } + } + + _status = UsbSerialStatus.disconnected; + } + + 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> _getAuthorizedPorts() async { + final serial = _serial; + if (serial == null) { + return const []; + } + final result = await serial + .callMethod>('getPorts'.toJS) + .toDart; + return _toObjectList(result); + } + + Future _requestPort() async { + final serial = _serial; + if (serial == null) { + return null; + } + final result = await serial + .callMethod>('requestPort'.toJS) + .toDart; + return result == null ? null : result as JSObject; + } + + JSObject? _selectPort(List ports, String requestedPortName) { + if (ports.isEmpty) { + return null; + } + if (requestedPortName.isEmpty || requestedPortName == usbRequestPortLabel) { + return ports.first; + } + for (final port in ports) { + final description = _describePort(port); + if (description == requestedPortName) { + return port; + } + } + return null; + } + + Future _openPort(JSObject port, int baudRate) { + final options = JSObject()..['baudRate'] = baudRate.toJS; + return port.callMethod>('open'.toJS, options).toDart; + } + + JSObject? _getReader(JSObject port) { + final readable = port.getProperty('readable'.toJS); + if (readable == null) { + throw StateError('Web Serial port is not readable'); + } + final readableObject = readable as JSObject; + return readableObject.callMethod('getReader'.toJS) as JSObject; + } + + JSObject? _getWriter(JSObject port) { + final writable = port.getProperty('writable'.toJS); + if (writable == null) { + throw StateError('Web Serial port is not writable'); + } + final writableObject = writable as JSObject; + return writableObject.callMethod('getWriter'.toJS) as JSObject; + } + + Future _pumpReads() async { + final reader = _reader; + if (reader == null) return; + + try { + while (_status == UsbSerialStatus.connected && + identical(reader, _reader)) { + final result = await reader + .callMethod>('read'.toJS) + .toDart; + if (result == null) { + break; + } + final resultObject = result as JSObject; + + final doneValue = resultObject.getProperty('done'.toJS); + final done = doneValue != null && doneValue.dartify() == true; + if (done) { + break; + } + + final value = resultObject.getProperty('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('length'.toJS)?.dartify(); + if (lengthValue is num) { + final length = lengthValue.toInt(); + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + final item = object.getProperty(i.toString().toJS)?.dartify(); + if (item is num) { + bytes[i] = item.toInt(); + } + } + return bytes; + } + } + + return null; + } + + List _toObjectList(JSAny? value) { + if (value == null) { + return const []; + } + final object = value as JSObject; + if (!object.has('length')) { + return const []; + } + + final lengthValue = object.getProperty('length'.toJS)?.dartify(); + if (lengthValue is! num) { + return const []; + } + + final length = lengthValue.toInt(); + final items = []; + for (var i = 0; i < length; i++) { + final item = object.getProperty(i.toString().toJS); + if (item != null) { + items.add(item as JSObject); + } + } + return items; + } + + String _describePort(JSObject port) { + try { + final info = port.callMethod('getInfo'.toJS); + if (info == null) { + return usbRequestPortLabel; + } + final infoObject = info as JSObject; + + final vendorId = infoObject + .getProperty('usbVendorId'.toJS) + ?.dartify(); + final productId = infoObject + .getProperty('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('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 } diff --git a/lib/utils/usb_port_labels.dart b/lib/utils/usb_port_labels.dart new file mode 100644 index 00000000..6430f955 --- /dev/null +++ b/lib/utils/usb_port_labels.dart @@ -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 knownUsbNames = const {}, +}) { + 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 = [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'; +} diff --git a/test/services/usb_serial_frame_codec_test.dart b/test/services/usb_serial_frame_codec_test.dart new file mode 100644 index 00000000..f0ce1860 --- /dev/null +++ b/test/services/usb_serial_frame_codec_test.dart @@ -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([0x16, 0x03])); + + expect( + packet, + orderedEquals([usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]), + ); + }); + + test('UsbSerialFrameDecoder buffers partial frames until complete', () { + final decoder = UsbSerialFrameDecoder(); + + final firstChunk = decoder.ingest( + Uint8List.fromList([usbSerialRxFrameStart, 0x03]), + ); + final secondChunk = decoder.ingest( + Uint8List.fromList([0x00, 0x05, 0x06, 0x07]), + ); + + expect(firstChunk, isEmpty); + expect(secondChunk, hasLength(1)); + expect(secondChunk.single.isRxFrame, isTrue); + expect(secondChunk.single.payload, orderedEquals([0x05, 0x06, 0x07])); + }); + + test( + 'UsbSerialFrameDecoder drops leading noise and parses multiple frames', + () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + 0x00, + 0x01, + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x55, + usbSerialRxFrameStart, + 0x02, + 0x00, + 0x66, + 0x77, + ]), + ); + + expect(packets, hasLength(2)); + expect(packets[0].payload, orderedEquals([0x55])); + expect(packets[1].payload, orderedEquals([0x66, 0x77])); + }, + ); + + test( + 'UsbSerialFrameDecoder preserves tx packets so caller can ignore them', + () { + final decoder = UsbSerialFrameDecoder(); + + final packets = decoder.ingest( + Uint8List.fromList([ + usbSerialTxFrameStart, + 0x01, + 0x00, + 0x22, + usbSerialRxFrameStart, + 0x01, + 0x00, + 0x33, + ]), + ); + + expect(packets, hasLength(2)); + expect(packets[0].isRxFrame, isFalse); + expect(packets[0].payload, orderedEquals([0x22])); + expect(packets[1].isRxFrame, isTrue); + expect(packets[1].payload, orderedEquals([0x33])); + }, + ); +} diff --git a/test/utils/usb_port_labels_test.dart b/test/utils/usb_port_labels_test.dart new file mode 100644 index 00000000..4fef5093 --- /dev/null +++ b/test/utils/usb_port_labels_test.dart @@ -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 { + '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)', + ); + }); +}