mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-04 15:03:00 +10:00
wip
This commit is contained in:
committed by
just-stuff-tm
parent
f5154b0033
commit
e6c9a3fea7
@@ -737,8 +737,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_scanResults.clear();
|
_scanResults.clear();
|
||||||
_setState(MeshCoreConnectionState.scanning);
|
_setState(MeshCoreConnectionState.scanning);
|
||||||
|
|
||||||
// Ensure any previous scan is fully stopped
|
// Ensure any previous scan is fully stopped. Guard with isScanningNow to
|
||||||
|
// avoid triggering stale native callbacks when no scan is active.
|
||||||
|
if (FlutterBluePlus.isScanningNow) {
|
||||||
|
try {
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FBP] stopScan error in startScan (ignored): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
await _scanSubscription?.cancel();
|
await _scanSubscription?.cancel();
|
||||||
|
|
||||||
// On iOS/macOS, wait for Bluetooth to be powered on before scanning
|
// On iOS/macOS, wait for Bluetooth to be powered on before scanning
|
||||||
@@ -787,7 +794,18 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopScan() async {
|
Future<void> stopScan() async {
|
||||||
|
// Only call FlutterBluePlus.stopScan() when a scan is actually running.
|
||||||
|
// Calling it when idle triggers a native BLE completion callback even
|
||||||
|
// though no scan was started. After a hot restart Dart has already freed
|
||||||
|
// those callback handles, so the callback crashes with
|
||||||
|
// "Callback invoked after it has been deleted".
|
||||||
|
if (FlutterBluePlus.isScanningNow) {
|
||||||
|
try {
|
||||||
await FlutterBluePlus.stopScan();
|
await FlutterBluePlus.stopScan();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[FBP] stopScan error (ignored): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
await _scanSubscription?.cancel();
|
await _scanSubscription?.cancel();
|
||||||
_scanSubscription = null;
|
_scanSubscription = null;
|
||||||
|
|
||||||
|
|||||||
+100
-59
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
import '../utils/usb_port_labels.dart';
|
import '../utils/usb_port_labels.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
|
|
||||||
@@ -25,9 +26,16 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
String? _selectedPort;
|
String? _selectedPort;
|
||||||
String? _connectedPortDisplayLabel;
|
String? _connectedPortDisplayLabel;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
Timer? _hotPlugTimer;
|
||||||
late final MeshCoreConnector _connector;
|
late final MeshCoreConnector _connector;
|
||||||
late final VoidCallback _connectionListener;
|
late final VoidCallback _connectionListener;
|
||||||
|
|
||||||
|
/// Whether the current platform supports dynamic hot-plug polling.
|
||||||
|
/// On desktop (macOS, Windows, Linux) we poll continuously so the user
|
||||||
|
/// never needs to hit Refresh manually.
|
||||||
|
bool get _supportsHotPlug =>
|
||||||
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -58,6 +66,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
_connector.addListener(_connectionListener);
|
_connector.addListener(_connectionListener);
|
||||||
|
_startHotPlugTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -72,6 +81,8 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_hotPlugTimer?.cancel();
|
||||||
|
_hotPlugTimer = null;
|
||||||
_connector.removeListener(_connectionListener);
|
_connector.removeListener(_connectionListener);
|
||||||
if (!_navigatedToContacts &&
|
if (!_navigatedToContacts &&
|
||||||
_connector.activeTransport == MeshCoreTransportType.usb &&
|
_connector.activeTransport == MeshCoreTransportType.usb &&
|
||||||
@@ -124,88 +135,63 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
// ── Compact header ──────────────────────────────────────
|
||||||
flex: 3,
|
Row(
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.usb,
|
Icons.usb,
|
||||||
size: iconSize,
|
size: iconSize.clamp(24.0, 40.0),
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
SizedBox(height: gap),
|
SizedBox(width: gap),
|
||||||
Flexible(
|
Expanded(
|
||||||
child: FittedBox(
|
child: Column(
|
||||||
fit: BoxFit.scaleDown,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
l10n.usbScreenTitle,
|
l10n.usbScreenTitle,
|
||||||
textAlign: TextAlign.center,
|
maxLines: 1,
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
),
|
|
||||||
SizedBox(height: math.max(4.0, gap * 0.5)),
|
|
||||||
Flexible(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
l10n.usbScreenSubtitle,
|
l10n.usbScreenSubtitle,
|
||||||
textAlign: TextAlign.center,
|
maxLines: 2,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: gap),
|
|
||||||
FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Chip(
|
|
||||||
label: Text(
|
|
||||||
_connectedPortDisplayLabel != null &&
|
|
||||||
_connectedPortDisplayLabel!.isNotEmpty
|
|
||||||
? _friendlyPortName(
|
|
||||||
_connectedPortDisplayLabel!,
|
|
||||||
)
|
|
||||||
: _selectedPort == null
|
|
||||||
? l10n.usbScreenStatus
|
|
||||||
: _friendlyPortName(_selectedPort!),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
backgroundColor:
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
theme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: gap),
|
SizedBox(height: gap),
|
||||||
|
// ── Port list takes all remaining space ─────────────────
|
||||||
Expanded(child: _buildPortList(context)),
|
Expanded(child: _buildPortList(context)),
|
||||||
if (_errorText != null) ...[
|
if (_errorText != null) ...[
|
||||||
SizedBox(height: gap),
|
SizedBox(height: gap * 0.5),
|
||||||
Flexible(
|
Text(
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
_errorText!,
|
_errorText!,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.error,
|
color: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
SizedBox(height: gap),
|
SizedBox(height: gap),
|
||||||
|
// ── Action buttons ──────────────────────────────────────
|
||||||
if (isNarrow)
|
if (isNarrow)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (!_supportsHotPlug) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _isLoadingPorts || _isConnecting
|
onPressed: _isLoadingPorts || _isConnecting
|
||||||
? null
|
? null
|
||||||
@@ -219,6 +205,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
label: Text(l10n.repeater_refresh),
|
label: Text(l10n.repeater_refresh),
|
||||||
),
|
),
|
||||||
SizedBox(height: gap),
|
SizedBox(height: gap),
|
||||||
|
],
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _canConnect
|
onPressed: _canConnect
|
||||||
? () {
|
? () {
|
||||||
@@ -247,6 +234,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
else
|
else
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (!_supportsHotPlug) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _isLoadingPorts || _isConnecting
|
onPressed: _isLoadingPorts || _isConnecting
|
||||||
@@ -262,6 +250,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: gap),
|
SizedBox(width: gap),
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _canConnect
|
onPressed: _canConnect
|
||||||
@@ -289,19 +278,16 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: math.max(4.0, gap * 0.75)),
|
SizedBox(height: math.max(4.0, gap * 0.5)),
|
||||||
Flexible(
|
Text(
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Text(
|
|
||||||
l10n.usbScreenNote,
|
l10n.usbScreenNote,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -317,6 +303,43 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
_selectedPort != null &&
|
_selectedPort != null &&
|
||||||
_selectedPort!.isNotEmpty;
|
_selectedPort!.isNotEmpty;
|
||||||
|
|
||||||
|
void _startHotPlugTimer() {
|
||||||
|
if (!_supportsHotPlug) return;
|
||||||
|
_hotPlugTimer?.cancel();
|
||||||
|
_hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
_pollHotPlug();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollHotPlug() async {
|
||||||
|
// Don't interfere with an active connection attempt or initial load.
|
||||||
|
if (_isConnecting || _isLoadingPorts) return;
|
||||||
|
if (!mounted) 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);
|
||||||
|
if (_ports.isEmpty) {
|
||||||
|
_selectedPort = null;
|
||||||
|
} else if (added.isNotEmpty) {
|
||||||
|
// Auto-select the newly-connected device.
|
||||||
|
_selectedPort = added.first;
|
||||||
|
} else if (_selectedPort != null && !_ports.contains(_selectedPort)) {
|
||||||
|
// Previously-selected device was unplugged.
|
||||||
|
_selectedPort = _ports.isNotEmpty ? _ports.first : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Silent — hot-plug failures are non-critical.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPortList(BuildContext context) {
|
Widget _buildPortList(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -464,14 +487,32 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _connector.connectUsb(portName: rawPortName);
|
await _connector.connectUsb(portName: rawPortName);
|
||||||
} catch (error) {
|
} catch (error, stackTrace) {
|
||||||
|
debugPrint(
|
||||||
|
'UsbScreen: connect failed for $rawPortName: $error\n$stackTrace',
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
_errorText = error.toString();
|
_errorText = _friendlyErrorMessage(error);
|
||||||
});
|
});
|
||||||
|
// Re-scan so stale or renamed port entries are cleared from the list.
|
||||||
|
unawaited(_loadPorts());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strips the Dart runtime prefix (e.g. "Bad state: ", "Exception: ")
|
||||||
|
/// from error messages before showing them in the UI.
|
||||||
|
String _friendlyErrorMessage(Object error) {
|
||||||
|
var msg = error.toString();
|
||||||
|
// StateError surfaces as "Bad state: <message>"
|
||||||
|
if (msg.startsWith('Bad state: ')) {
|
||||||
|
msg = msg.substring('Bad state: '.length);
|
||||||
|
} else if (msg.startsWith('Exception: ')) {
|
||||||
|
msg = msg.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
|
String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flserial/flserial.dart';
|
import 'package:flserial/flserial.dart';
|
||||||
import 'package:flserial/flserial_exception.dart';
|
import 'package:flserial/flserial_exception.dart';
|
||||||
@@ -6,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'app_debug_log_service.dart';
|
import 'app_debug_log_service.dart';
|
||||||
|
import '../utils/macos_usb_device_names.dart';
|
||||||
import '../utils/platform_info.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';
|
||||||
@@ -32,6 +34,14 @@ class UsbSerialService {
|
|||||||
FlSerial? _serial;
|
FlSerial? _serial;
|
||||||
AppDebugLogService? _debugLogService;
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
/// Holds the last-opened native serial port across hot-restart boundaries.
|
||||||
|
/// On hot restart the Dart isolate is torn down without running [disconnect],
|
||||||
|
/// leaving the native SerialThread alive. The next [connect] call reads this
|
||||||
|
/// field and force-closes the orphaned port before creating a new one, which
|
||||||
|
/// causes the old native thread to unblock its blocking read and exit
|
||||||
|
/// naturally—before any new Dart FFI callbacks are registered.
|
||||||
|
static FlSerial? _lastSerial;
|
||||||
|
|
||||||
UsbSerialStatus get status => _status;
|
UsbSerialStatus get status => _status;
|
||||||
String? get activePortKey => _connectedPortKey;
|
String? get activePortKey => _connectedPortKey;
|
||||||
String? get activePortDisplayLabel =>
|
String? get activePortDisplayLabel =>
|
||||||
@@ -40,20 +50,25 @@ class UsbSerialService {
|
|||||||
bool get _useAndroidUsbHost =>
|
bool get _useAndroidUsbHost =>
|
||||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||||
bool get _useDesktopFlSerial =>
|
bool get _useDesktopFlSerial =>
|
||||||
PlatformInfo.isWindows || PlatformInfo.isLinux;
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
||||||
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
|
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
|
||||||
FlSerial get _nativeSerial => _serial ??= FlSerial();
|
// Always-fresh: do NOT use ??= here – a cached FlSerial retains stale
|
||||||
|
// native handle state (flh) from a prior failed open, causing subsequent
|
||||||
|
// open attempts to fail with "port not exist" even when the device is present.
|
||||||
|
FlSerial _freshSerial() => FlSerial();
|
||||||
|
|
||||||
bool get isConnected {
|
bool get isConnected {
|
||||||
if (!_isSupportedPlatform) {
|
if (!_isSupportedPlatform) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (_useAndroidUsbHost) {
|
// Trust _status as the authoritative connection state. Polling
|
||||||
|
// _serial?.isOpen() via the native FL_CTRL_IS_PORT_OPEN query is
|
||||||
|
// unreliable during the brief USB re-enumeration window that many
|
||||||
|
// microcontrollers (e.g. NRF52) trigger in response to DTR assertion.
|
||||||
|
// Actual port drops are handled by the onDone / onError callbacks on the
|
||||||
|
// serial data stream subscription, which update _status correctly.
|
||||||
return _status == UsbSerialStatus.connected;
|
return _status == UsbSerialStatus.connected;
|
||||||
}
|
}
|
||||||
return _status == UsbSerialStatus.connected &&
|
|
||||||
_serial?.isOpen() == FlOpenStatus.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> listPorts() async {
|
Future<List<String>> listPorts() async {
|
||||||
if (!_isSupportedPlatform) {
|
if (!_isSupportedPlatform) {
|
||||||
@@ -65,7 +80,34 @@ class UsbSerialService {
|
|||||||
);
|
);
|
||||||
return ports ?? <String>[];
|
return ports ?? <String>[];
|
||||||
}
|
}
|
||||||
return Future.value(FlSerial.listPorts());
|
final rawPorts = FlSerial.listPorts();
|
||||||
|
// On macOS, flserial's native device-name lookup is broken on macOS
|
||||||
|
// 10.15+ because the IOKit class name changed from IOUSBDevice to
|
||||||
|
// IOUSBHostDevice. We resolve names ourselves via ioreg and rewrite any
|
||||||
|
// "port - n/a" entries with the real product name.
|
||||||
|
if (Platform.isMacOS && rawPorts.isNotEmpty) {
|
||||||
|
return _annotateMacOsPorts(rawPorts);
|
||||||
|
}
|
||||||
|
return Future.value(rawPorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites the flserial port list on macOS by substituting real USB device
|
||||||
|
/// names (obtained via [ioreg]) for the "n/a" placeholders that flserial
|
||||||
|
/// returns when it can't find the deprecated IOUSBDevice parent.
|
||||||
|
Future<List<String>> _annotateMacOsPorts(List<String> rawPorts) async {
|
||||||
|
final deviceNames = await queryMacOsUsbDeviceNames();
|
||||||
|
if (deviceNames.isEmpty) return rawPorts;
|
||||||
|
return rawPorts.map((entry) {
|
||||||
|
// entry format from fl_ports: "port - description - hardware_id"
|
||||||
|
final port = normalizeUsbPortName(entry); // e.g. /dev/cu.usbmodem1101
|
||||||
|
final knownName = deviceNames[port]; // e.g. "Nordic NRF52 DK"
|
||||||
|
if (knownName == null) return entry; // non-USB port, keep as-is
|
||||||
|
// Replace description field only; preserve hardware_id for device
|
||||||
|
// identity (used by normalizeUsbPortName).
|
||||||
|
final segments = entry.split(' - ');
|
||||||
|
final hardwareId = segments.length >= 3 ? segments.last : 'n/a';
|
||||||
|
return '$port - $knownName - $hardwareId';
|
||||||
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDebugLogService(AppDebugLogService? service) {
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
@@ -85,7 +127,7 @@ class UsbSerialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_status = UsbSerialStatus.connecting;
|
_status = UsbSerialStatus.connecting;
|
||||||
final normalizedPortName = normalizeUsbPortName(portName);
|
var normalizedPortName = normalizeUsbPortName(portName);
|
||||||
_frameDecoder.reset();
|
_frameDecoder.reset();
|
||||||
|
|
||||||
if (_useAndroidUsbHost) {
|
if (_useAndroidUsbHost) {
|
||||||
@@ -100,18 +142,59 @@ class UsbSerialService {
|
|||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_status = UsbSerialStatus.disconnected;
|
_status = UsbSerialStatus.disconnected;
|
||||||
throw StateError(error.message ?? error.code);
|
final msg = error.message ?? error.code;
|
||||||
|
debugPrint('[USB Serial] Android connect failed: $msg');
|
||||||
|
_debugLogService?.error(
|
||||||
|
'Android connect failed: $msg',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
throw StateError(msg);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final serial = _nativeSerial;
|
// Force-close any native serial port left open by the previous Dart
|
||||||
serial.init();
|
// isolate (hot-restart case). The old SerialThread blocks on read(); once
|
||||||
|
// the port is closed here it unblocks and exits before we register any
|
||||||
|
// new Dart FFI callbacks, preventing the "callback invoked after deletion"
|
||||||
|
// crash.
|
||||||
|
final orphan = _lastSerial;
|
||||||
|
if (orphan != null) {
|
||||||
|
_lastSerial = null;
|
||||||
try {
|
try {
|
||||||
final status = serial.openPort(normalizedPortName, baudRate);
|
if (orphan.isOpen() == FlOpenStatus.open) {
|
||||||
if (status != FlOpenStatus.open) {
|
orphan.closePort();
|
||||||
throw StateError(
|
}
|
||||||
'Failed to open USB port $normalizedPortName ($status)',
|
} catch (_) {}
|
||||||
);
|
try {
|
||||||
|
orphan.free();
|
||||||
|
} catch (_) {}
|
||||||
|
// Give the native thread a moment to observe the port closure and exit.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// On macOS, flserial lists both cu.* and tty.* device nodes.
|
||||||
|
// When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.*
|
||||||
|
// variant as a fallback (and vice-versa) before giving up.
|
||||||
|
final candidates = _buildPortCandidates(normalizedPortName);
|
||||||
|
FlSerialException? lastError;
|
||||||
|
bool opened = false;
|
||||||
|
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
// Always create a fresh FlSerial instance — a cached instance retains
|
||||||
|
// a stale flh handle from prior failed opens, which causes the native
|
||||||
|
// fl_open() to mis-route the request and report port-not-exist even
|
||||||
|
// when the device node is physically present.
|
||||||
|
final serial = _freshSerial();
|
||||||
|
serial.init();
|
||||||
|
try {
|
||||||
|
final openStatus = serial.openPort(candidate, baudRate);
|
||||||
|
if (openStatus != FlOpenStatus.open) {
|
||||||
|
final msg =
|
||||||
|
'Failed to open USB port $candidate (status: $openStatus)';
|
||||||
|
debugPrint('[USB Serial] $msg');
|
||||||
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
||||||
|
// Not a FlSerialException — treat as terminal failure
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
throw StateError(msg);
|
||||||
}
|
}
|
||||||
serial.setByteSize8();
|
serial.setByteSize8();
|
||||||
serial.setBitParityNone();
|
serial.setBitParityNone();
|
||||||
@@ -119,25 +202,54 @@ class UsbSerialService {
|
|||||||
serial.setFlowControlNone();
|
serial.setFlowControlNone();
|
||||||
serial.setRTS(false);
|
serial.setRTS(false);
|
||||||
serial.setDTR(true);
|
serial.setDTR(true);
|
||||||
|
_serial = serial;
|
||||||
|
_lastSerial = serial;
|
||||||
|
// Update the normalized port name to whichever candidate succeeded.
|
||||||
|
normalizedPortName = candidate;
|
||||||
_debugLogService?.info(
|
_debugLogService?.info(
|
||||||
'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
|
'USB serial opened port=$candidate cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
|
||||||
tag: 'USB Serial',
|
tag: 'USB Serial',
|
||||||
);
|
);
|
||||||
|
opened = true;
|
||||||
|
break;
|
||||||
} on FlSerialException catch (error) {
|
} on FlSerialException catch (error) {
|
||||||
_serial?.free();
|
// Do NOT call fl_free() here — it destroys global native library
|
||||||
_serial = null;
|
// state and makes subsequent fl_init() calls unreliable. The native
|
||||||
_status = UsbSerialStatus.disconnected;
|
// fl_open() already called fl_close() on failure internally.
|
||||||
throw StateError(
|
debugPrint(
|
||||||
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
|
'[USB Serial] Failed to open $candidate: ${error.msg} (code ${error.error})',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
_debugLogService?.warn(
|
||||||
_serial?.free();
|
'Failed to open $candidate: ${error.msg} (code ${error.error})',
|
||||||
_serial = null;
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
|
lastError = error;
|
||||||
|
// Try next candidate
|
||||||
|
} catch (error, stackTrace) {
|
||||||
_status = UsbSerialStatus.disconnected;
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
debugPrint(
|
||||||
|
'[USB Serial] Unexpected error opening $candidate: $error\n$stackTrace',
|
||||||
|
);
|
||||||
|
_debugLogService?.error(
|
||||||
|
'Unexpected error opening $candidate: $error',
|
||||||
|
tag: 'USB Serial',
|
||||||
|
);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
final primary = candidates.first;
|
||||||
|
final msg = lastError != null
|
||||||
|
? 'Failed to open USB port $primary: ${lastError.msg} (code ${lastError.error})'
|
||||||
|
: 'Failed to open USB port $primary';
|
||||||
|
debugPrint('[USB Serial] $msg');
|
||||||
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
||||||
|
throw StateError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_connectedPortKey = normalizedPortName;
|
_connectedPortKey = normalizedPortName;
|
||||||
_connectedPortLabel = normalizedPortName;
|
_connectedPortLabel = normalizedPortName;
|
||||||
if (_useAndroidUsbHost) {
|
if (_useAndroidUsbHost) {
|
||||||
@@ -149,7 +261,7 @@ class UsbSerialService {
|
|||||||
onDone: _handleSerialDone,
|
onDone: _handleSerialDone,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_dataSubscription = _nativeSerial.onSerialData.stream.listen(
|
_dataSubscription = _serial!.onSerialData.stream.listen(
|
||||||
_handleSerialData,
|
_handleSerialData,
|
||||||
onError: _handleSerialError,
|
onError: _handleSerialError,
|
||||||
onDone: _handleSerialDone,
|
onDone: _handleSerialDone,
|
||||||
@@ -173,7 +285,7 @@ class UsbSerialService {
|
|||||||
throw StateError(error.message ?? error.code);
|
throw StateError(error.message ?? error.code);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_nativeSerial.write(packet);
|
_serial!.write(packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,28 +296,40 @@ class UsbSerialService {
|
|||||||
_connectedPortKey = null;
|
_connectedPortKey = null;
|
||||||
_connectedPortLabel = null;
|
_connectedPortLabel = null;
|
||||||
_frameDecoder.reset();
|
_frameDecoder.reset();
|
||||||
await _androidDataSubscription?.cancel();
|
|
||||||
_androidDataSubscription = null;
|
|
||||||
await _dataSubscription?.cancel();
|
|
||||||
_dataSubscription = null;
|
|
||||||
|
|
||||||
if (_useAndroidUsbHost) {
|
if (_useAndroidUsbHost) {
|
||||||
|
await _androidDataSubscription?.cancel();
|
||||||
|
_androidDataSubscription = null;
|
||||||
try {
|
try {
|
||||||
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore errors while closing.
|
// Ignore errors while closing.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// IMPORTANT: Close and free the native port FIRST, before cancelling the
|
||||||
|
// Dart subscription. The native SerialThread is blocked on a read(); once
|
||||||
|
// closePort() is called it unblocks and the thread exits. If we cancel
|
||||||
|
// the Dart subscription first (freeing the FFI callback pointer) and the
|
||||||
|
// thread fires one final callback before noticing the port is gone, Dart
|
||||||
|
// crashes with "Callback invoked after it has been deleted".
|
||||||
|
final serial = _serial;
|
||||||
|
_serial = null;
|
||||||
|
_lastSerial = null;
|
||||||
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.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
serial?.free();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
_serial?.free();
|
// Now it is safe to cancel the Dart subscription — the native thread has
|
||||||
_serial = null;
|
// already seen the port close and will not fire any more callbacks.
|
||||||
|
await _dataSubscription?.cancel();
|
||||||
|
_dataSubscription = null;
|
||||||
}
|
}
|
||||||
_status = UsbSerialStatus.disconnected;
|
_status = UsbSerialStatus.disconnected;
|
||||||
}
|
}
|
||||||
@@ -306,6 +430,28 @@ class UsbSerialService {
|
|||||||
tag: 'USB Serial',
|
tag: 'USB Serial',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns an ordered list of port paths to try for [portName].
|
||||||
|
///
|
||||||
|
/// On macOS, USB serial devices appear as both `/dev/cu.*` (call-out, the
|
||||||
|
/// correct mode for outgoing serial connections) and `/dev/tty.*` (dial-in).
|
||||||
|
/// `flserial` may list one variant while only the other is actually openable
|
||||||
|
/// at a given moment. We prefer `cu.*` but automatically include the `tty.*`
|
||||||
|
/// sibling as a fallback, and vice-versa.
|
||||||
|
List<String> _buildPortCandidates(String normalizedPort) {
|
||||||
|
if (!Platform.isMacOS) return [normalizedPort];
|
||||||
|
const cuPrefix = '/dev/cu.';
|
||||||
|
const ttyPrefix = '/dev/tty.';
|
||||||
|
if (normalizedPort.startsWith(cuPrefix)) {
|
||||||
|
final suffix = normalizedPort.substring(cuPrefix.length);
|
||||||
|
return [normalizedPort, '$ttyPrefix$suffix'];
|
||||||
|
}
|
||||||
|
if (normalizedPort.startsWith(ttyPrefix)) {
|
||||||
|
final suffix = normalizedPort.substring(ttyPrefix.length);
|
||||||
|
return [normalizedPort, '$cuPrefix$suffix'];
|
||||||
|
}
|
||||||
|
return [normalizedPort];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||||
|
|||||||
@@ -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" = "([^"]*)"');
|
||||||
@@ -35,7 +35,8 @@ class PlatformInfo {
|
|||||||
static bool get isDesktop => isMacOS || isWindows || isLinux;
|
static bool get isDesktop => isMacOS || isWindows || isLinux;
|
||||||
|
|
||||||
/// Whether the current platform supports a native USB serial backend.
|
/// Whether the current platform supports a native USB serial backend.
|
||||||
static bool get supportsNativeUsbSerial => isAndroid || isWindows || isLinux;
|
static bool get supportsNativeUsbSerial =>
|
||||||
|
isAndroid || isWindows || isLinux || isMacOS;
|
||||||
|
|
||||||
/// Whether the current browser supports the Web Serial backend.
|
/// Whether the current browser supports the Web Serial backend.
|
||||||
static bool get supportsWebSerial => isWeb && isChrome;
|
static bool get supportsWebSerial => isWeb && isChrome;
|
||||||
|
|||||||
@@ -6,16 +6,25 @@ String normalizeUsbPortName(String portLabel) {
|
|||||||
return normalized.trim();
|
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) {
|
String friendlyUsbPortName(String portLabel) {
|
||||||
final separatorIndex = portLabel.indexOf(' - ');
|
final parts = portLabel.split(' - ');
|
||||||
if (separatorIndex < 0) {
|
if (parts.length < 2) {
|
||||||
return portLabel.trim();
|
return portLabel.trim();
|
||||||
}
|
}
|
||||||
final friendlyName = portLabel.substring(separatorIndex + 3).trim();
|
// parts[0] = port name, parts[1] = description, parts[2+] = hardware id
|
||||||
if (friendlyName.isEmpty) {
|
final description = parts[1].trim();
|
||||||
return normalizeUsbPortName(portLabel);
|
if (description.isEmpty || description.toLowerCase() == 'n/a') {
|
||||||
|
return parts[0].trim();
|
||||||
}
|
}
|
||||||
return friendlyName;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
String describeWebUsbPort({
|
String describeWebUsbPort({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- flserial (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- flutter_blue_plus_darwin (0.0.2):
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -24,6 +26,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- flserial (from `Flutter/ephemeral/.symlinks/plugins/flserial/macos`)
|
||||||
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
@@ -36,6 +39,8 @@ DEPENDENCIES:
|
|||||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
flserial:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flserial/macos
|
||||||
flutter_blue_plus_darwin:
|
flutter_blue_plus_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -58,6 +63,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
flserial: 3c161e076dfc73458ec5803e7a9a9d2bb85fadf6
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string>/dev/</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||||
|
<array>
|
||||||
|
<string>/dev/</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -11,19 +11,47 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('friendlyUsbPortName prefers suffix when present', () {
|
test(
|
||||||
|
'friendlyUsbPortName returns only description, not hardware_id (3-part label)',
|
||||||
|
() {
|
||||||
expect(
|
expect(
|
||||||
friendlyUsbPortName(
|
friendlyUsbPortName(
|
||||||
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
),
|
),
|
||||||
'USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
'USB Serial Device (COM6)',
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'friendlyUsbPortName falls back to normalized port when suffix is empty',
|
'friendlyUsbPortName works for macOS-style 3-part label with USB product name',
|
||||||
() {
|
() {
|
||||||
expect(friendlyUsbPortName('COM6 - '), 'COM6');
|
expect(
|
||||||
|
friendlyUsbPortName(
|
||||||
|
'/dev/cu.usbmodem1101 - Nordic Semiconductor nRF52 DK - USB VID:PID=1915:520f SNR=ABCDEF',
|
||||||
|
),
|
||||||
|
'Nordic Semiconductor nRF52 DK',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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)',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user