Enhance USB functionality by adding request port label management and platform support checks

This commit is contained in:
just_stuff_tm
2026-03-02 05:28:31 -05:00
committed by just-stuff-tm
parent ca5784f3f8
commit 781090243c
7 changed files with 93 additions and 34 deletions
+4
View File
@@ -767,6 +767,10 @@ class MeshCoreConnector extends ChangeNotifier {
Future<List<String>> listUsbPorts() => _usbSerialService.listPorts(); Future<List<String>> listUsbPorts() => _usbSerialService.listPorts();
void setUsbRequestPortLabel(String label) {
_usbSerialService.setRequestPortLabel(label);
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async { Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting || if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) { _state == MeshCoreConnectionState.connected) {
+15 -9
View File
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../utils/platform_info.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -14,6 +15,7 @@ class ConnectionChoiceScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final theme = Theme.of(context); final theme = Theme.of(context);
final usbSupported = PlatformInfo.supportsUsbSerial;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: FittedBox( title: FittedBox(
@@ -82,14 +84,18 @@ class ConnectionChoiceScreen extends StatelessWidget {
label: l10n.connectionChoiceUsbLabel, label: l10n.connectionChoiceUsbLabel,
color: theme.colorScheme.primaryContainer, color: theme.colorScheme.primaryContainer,
iconColor: theme.colorScheme.onPrimaryContainer, iconColor: theme.colorScheme.onPrimaryContainer,
onPressed: () { onPressed: usbSupported
debugPrint( ? () {
'ConnectionChoiceScreen: USB selected, opening UsbScreen', debugPrint(
); 'ConnectionChoiceScreen: USB selected, opening UsbScreen',
Navigator.of(context).push( );
MaterialPageRoute(builder: (_) => const UsbScreen()), Navigator.of(context).push(
); MaterialPageRoute(
}, builder: (_) => const UsbScreen(),
),
);
}
: null,
), ),
), ),
SizedBox(height: gap), SizedBox(height: gap),
@@ -133,7 +139,7 @@ class _ConnectionMethodButton extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;
final VoidCallback onPressed; final VoidCallback? onPressed;
final Color color; final Color color;
final Color iconColor; final Color iconColor;
+8
View File
@@ -61,6 +61,12 @@ class _UsbScreenState extends State<UsbScreen> {
unawaited(_loadPorts()); unawaited(_loadPorts());
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
}
@override @override
void dispose() { void dispose() {
_connector.removeListener(_connectionListener); _connector.removeListener(_connectionListener);
@@ -389,6 +395,7 @@ class _UsbScreenState extends State<UsbScreen> {
Future<void> _loadPorts() async { Future<void> _loadPorts() async {
if (!mounted) return; if (!mounted) return;
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
setState(() { setState(() {
_isLoadingPorts = true; _isLoadingPorts = true;
@@ -425,6 +432,7 @@ class _UsbScreenState extends State<UsbScreen> {
if (selectedPort == null || selectedPort.isEmpty) { if (selectedPort == null || selectedPort.isEmpty) {
return; return;
} }
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
if (_connector.state != MeshCoreConnectionState.disconnected) { if (_connector.state != MeshCoreConnectionState.disconnected) {
setState(() { setState(() {
_isConnecting = false; _isConnecting = false;
+40 -18
View File
@@ -5,6 +5,7 @@ import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart'; import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart'; import 'usb_serial_frame_codec.dart';
@@ -21,28 +22,38 @@ class UsbSerialService {
); );
final StreamController<Uint8List> _frameController = final StreamController<Uint8List> _frameController =
StreamController<Uint8List>.broadcast(); StreamController<Uint8List>.broadcast();
final FlSerial _serial = FlSerial();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder(); final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<dynamic>? _androidDataSubscription; StreamSubscription<dynamic>? _androidDataSubscription;
StreamSubscription<FlSerialEventArgs>? _dataSubscription; StreamSubscription<FlSerialEventArgs>? _dataSubscription;
UsbSerialStatus _status = UsbSerialStatus.disconnected; UsbSerialStatus _status = UsbSerialStatus.disconnected;
String? _connectedPortName; String? _connectedPortName;
FlSerial? _serial;
UsbSerialStatus get status => _status; UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName; String? get activePortName => _connectedPortName;
Stream<Uint8List> get frameStream => _frameController.stream; Stream<Uint8List> get frameStream => _frameController.stream;
bool get _useAndroidUsbHost => bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android; !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get _useDesktopFlSerial =>
PlatformInfo.isWindows || PlatformInfo.isLinux;
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
FlSerial get _nativeSerial => _serial ??= FlSerial();
bool get isConnected { bool get isConnected {
if (!_isSupportedPlatform) {
return false;
}
if (_useAndroidUsbHost) { if (_useAndroidUsbHost) {
return _status == UsbSerialStatus.connected; return _status == UsbSerialStatus.connected;
} }
return _status == UsbSerialStatus.connected && return _status == UsbSerialStatus.connected &&
_serial.isOpen() == FlOpenStatus.open; _serial?.isOpen() == FlOpenStatus.open;
} }
Future<List<String>> listPorts() async { Future<List<String>> listPorts() async {
if (!_isSupportedPlatform) {
return const <String>[];
}
if (_useAndroidUsbHost) { if (_useAndroidUsbHost) {
final ports = await _androidMethodChannel.invokeListMethod<String>( final ports = await _androidMethodChannel.invokeListMethod<String>(
'listPorts', 'listPorts',
@@ -60,6 +71,9 @@ class UsbSerialService {
_status == UsbSerialStatus.connecting) { _status == UsbSerialStatus.connecting) {
throw StateError('USB serial transport is already active'); throw StateError('USB serial transport is already active');
} }
if (!_isSupportedPlatform) {
throw UnsupportedError('USB serial is not supported on this platform.');
}
_status = UsbSerialStatus.connecting; _status = UsbSerialStatus.connecting;
final normalizedPortName = normalizeUsbPortName(portName); final normalizedPortName = normalizeUsbPortName(portName);
@@ -78,32 +92,35 @@ class UsbSerialService {
throw StateError(error.message ?? error.code); throw StateError(error.message ?? error.code);
} }
} else { } else {
_serial.init(); final serial = _nativeSerial;
serial.init();
try { try {
final status = _serial.openPort(normalizedPortName, baudRate); final status = serial.openPort(normalizedPortName, baudRate);
if (status != FlOpenStatus.open) { if (status != FlOpenStatus.open) {
throw StateError( throw StateError(
'Failed to open USB port $normalizedPortName ($status)', 'Failed to open USB port $normalizedPortName ($status)',
); );
} }
_serial.setByteSize8(); serial.setByteSize8();
_serial.setBitParityNone(); serial.setBitParityNone();
_serial.setStopBits1(); serial.setStopBits1();
_serial.setFlowControlNone(); serial.setFlowControlNone();
_serial.setRTS(false); serial.setRTS(false);
_serial.setDTR(true); serial.setDTR(true);
debugPrint( debugPrint(
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false', 'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
); );
} on FlSerialException catch (error) { } on FlSerialException catch (error) {
_serial.free(); _serial?.free();
_serial = null;
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
throw StateError( throw StateError(
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})', 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
); );
} catch (error) { } catch (error) {
_serial.free(); _serial?.free();
_serial = null;
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
rethrow; rethrow;
} }
@@ -119,7 +136,7 @@ class UsbSerialService {
onDone: _handleSerialDone, onDone: _handleSerialDone,
); );
} else { } else {
_dataSubscription = _serial.onSerialData.stream.listen( _dataSubscription = _nativeSerial.onSerialData.stream.listen(
_handleSerialData, _handleSerialData,
onError: _handleSerialError, onError: _handleSerialError,
onDone: _handleSerialDone, onDone: _handleSerialDone,
@@ -143,7 +160,7 @@ class UsbSerialService {
throw StateError(error.message ?? error.code); throw StateError(error.message ?? error.code);
} }
} else { } else {
_serial.write(packet); _nativeSerial.write(packet);
} }
} }
@@ -165,18 +182,23 @@ class UsbSerialService {
} }
} else { } else {
try { try {
if (_serial.isOpen() == FlOpenStatus.open) { if (_serial?.isOpen() == FlOpenStatus.open) {
_serial.closePort(); _serial?.closePort();
} }
} catch (_) { } catch (_) {
// Ignore errors while closing. // Ignore errors while closing.
} }
_serial.free(); _serial?.free();
_serial = null;
} }
_status = UsbSerialStatus.disconnected; _status = UsbSerialStatus.disconnected;
} }
void setRequestPortLabel(String label) {
// Native implementations do not use a synthetic chooser row.
}
void updateConnectedLabel(String label) { void updateConnectedLabel(String label) {
final trimmed = label.trim(); final trimmed = label.trim();
if (trimmed.isEmpty) { if (trimmed.isEmpty) {
+14 -4
View File
@@ -26,6 +26,7 @@ class UsbSerialService {
JSObject? _writer; JSObject? _writer;
String? _connectedPortName; String? _connectedPortName;
String? _connectedPortKey; String? _connectedPortKey;
String _requestPortLabel = 'Choose USB Device';
UsbSerialStatus get status => _status; UsbSerialStatus get status => _status;
String? get activePortName => _connectedPortName; String? get activePortName => _connectedPortName;
@@ -49,7 +50,7 @@ class UsbSerialService {
final ports = await _getAuthorizedPorts(); final ports = await _getAuthorizedPorts();
if (ports.isEmpty) { if (ports.isEmpty) {
return const <String>[usbRequestPortLabel]; return <String>[_requestPortLabel];
} }
return ports.map(_displayLabelForPort).toList(growable: false); return ports.map(_displayLabelForPort).toList(growable: false);
} }
@@ -159,6 +160,14 @@ class UsbSerialService {
_connectedPortName = _buildDisplayLabel(portKey); _connectedPortName = _buildDisplayLabel(portKey);
} }
void setRequestPortLabel(String label) {
final trimmed = label.trim();
if (trimmed.isEmpty) {
return;
}
_requestPortLabel = trimmed;
}
void dispose() { void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController)); unawaited(disconnect().whenComplete(_closeFrameController));
} }
@@ -189,7 +198,7 @@ class UsbSerialService {
if (ports.isEmpty) { if (ports.isEmpty) {
return null; return null;
} }
if (requestedPortName.isEmpty || requestedPortName == usbRequestPortLabel) { if (requestedPortName.isEmpty || requestedPortName == _requestPortLabel) {
return ports.first; return ports.first;
} }
for (final port in ports) { for (final port in ports) {
@@ -350,7 +359,7 @@ class UsbSerialService {
try { try {
final info = port.callMethod<JSAny?>('getInfo'.toJS); final info = port.callMethod<JSAny?>('getInfo'.toJS);
if (info == null) { if (info == null) {
return usbRequestPortLabel; return _requestPortLabel;
} }
final infoObject = info as JSObject; final infoObject = info as JSObject;
@@ -366,10 +375,11 @@ class UsbSerialService {
return describeWebUsbPort( return describeWebUsbPort(
vendorId: hasVendor ? vendorId.toInt() : null, vendorId: hasVendor ? vendorId.toInt() : null,
productId: hasProduct ? productId.toInt() : null, productId: hasProduct ? productId.toInt() : null,
requestPortLabel: _requestPortLabel,
knownUsbNames: _knownUsbNames, knownUsbNames: _knownUsbNames,
); );
} catch (_) { } catch (_) {
return usbRequestPortLabel; return _requestPortLabel;
} }
} }
+10
View File
@@ -33,4 +33,14 @@ class PlatformInfo {
/// Whether the app is running on a desktop platform (macOS, Windows, or Linux). /// Whether the app is running on a desktop platform (macOS, Windows, or Linux).
static bool get isDesktop => isMacOS || isWindows || isLinux; static bool get isDesktop => isMacOS || isWindows || isLinux;
/// Whether the current platform supports a native USB serial backend.
static bool get supportsNativeUsbSerial => isAndroid || isWindows || isLinux;
/// Whether the current browser supports the Web Serial backend.
static bool get supportsWebSerial => isWeb && isChrome;
/// Whether USB serial is expected to be available on the current platform.
static bool get supportsUsbSerial =>
supportsNativeUsbSerial || supportsWebSerial;
} }
+2 -3
View File
@@ -1,5 +1,3 @@
const String usbRequestPortLabel = 'Choose USB Device';
String normalizeUsbPortName(String portLabel) { String normalizeUsbPortName(String portLabel) {
final separatorIndex = portLabel.indexOf(' - '); final separatorIndex = portLabel.indexOf(' - ');
final normalized = separatorIndex >= 0 final normalized = separatorIndex >= 0
@@ -23,10 +21,11 @@ String friendlyUsbPortName(String portLabel) {
String describeWebUsbPort({ String describeWebUsbPort({
required int? vendorId, required int? vendorId,
required int? productId, required int? productId,
String requestPortLabel = 'Choose USB Device',
Map<String, String> knownUsbNames = const <String, String>{}, Map<String, String> knownUsbNames = const <String, String>{},
}) { }) {
if (vendorId == null && productId == null) { if (vendorId == null && productId == null) {
return usbRequestPortLabel; return requestPortLabel;
} }
final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase(); final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();