2026-03-02 00:27:49 -05:00
|
|
|
|
import 'dart:async';
|
2026-03-02 19:21:06 -08:00
|
|
|
|
import 'dart:io';
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
import 'package:flserial/flserial.dart';
|
|
|
|
|
|
import 'package:flserial/flserial_exception.dart';
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
|
2026-03-02 18:54:12 -05:00
|
|
|
|
import 'app_debug_log_service.dart';
|
2026-03-02 19:21:06 -08:00
|
|
|
|
import '../utils/macos_usb_device_names.dart';
|
2026-03-02 05:28:31 -05:00
|
|
|
|
import '../utils/platform_info.dart';
|
2026-03-02 00:27:49 -05:00
|
|
|
|
import '../utils/usb_port_labels.dart';
|
|
|
|
|
|
import 'usb_serial_frame_codec.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
|
|
|
|
|
|
/// MeshCore connector to consume.
|
|
|
|
|
|
class UsbSerialService {
|
|
|
|
|
|
UsbSerialService();
|
|
|
|
|
|
|
|
|
|
|
|
static const MethodChannel _androidMethodChannel = MethodChannel(
|
|
|
|
|
|
'meshcore_open/android_usb_serial',
|
|
|
|
|
|
);
|
|
|
|
|
|
static const EventChannel _androidEventChannel = EventChannel(
|
|
|
|
|
|
'meshcore_open/android_usb_serial_events',
|
|
|
|
|
|
);
|
|
|
|
|
|
final StreamController<Uint8List> _frameController =
|
|
|
|
|
|
StreamController<Uint8List>.broadcast();
|
|
|
|
|
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
|
|
|
|
|
StreamSubscription<dynamic>? _androidDataSubscription;
|
|
|
|
|
|
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
|
|
|
|
|
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
2026-03-02 15:54:39 -05:00
|
|
|
|
String? _connectedPortKey;
|
|
|
|
|
|
String? _connectedPortLabel;
|
2026-03-02 05:28:31 -05:00
|
|
|
|
FlSerial? _serial;
|
2026-03-02 18:54:12 -05:00
|
|
|
|
AppDebugLogService? _debugLogService;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
UsbSerialStatus get status => _status;
|
2026-03-02 15:54:39 -05:00
|
|
|
|
String? get activePortKey => _connectedPortKey;
|
|
|
|
|
|
String? get activePortDisplayLabel =>
|
|
|
|
|
|
_connectedPortLabel ?? _connectedPortKey;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
|
|
|
|
|
bool get _useAndroidUsbHost =>
|
|
|
|
|
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
2026-03-02 05:28:31 -05:00
|
|
|
|
bool get _useDesktopFlSerial =>
|
2026-03-02 19:21:06 -08:00
|
|
|
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
2026-03-02 05:28:31 -05:00
|
|
|
|
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
|
2026-03-02 19:21:06 -08:00
|
|
|
|
// 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();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
bool get isConnected {
|
2026-03-02 05:28:31 -05:00
|
|
|
|
if (!_isSupportedPlatform) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-03-02 19:21:06 -08:00
|
|
|
|
// 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;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<String>> listPorts() async {
|
2026-03-02 05:28:31 -05:00
|
|
|
|
if (!_isSupportedPlatform) {
|
|
|
|
|
|
return const <String>[];
|
|
|
|
|
|
}
|
2026-03-02 00:27:49 -05:00
|
|
|
|
if (_useAndroidUsbHost) {
|
|
|
|
|
|
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
|
|
|
|
|
'listPorts',
|
|
|
|
|
|
);
|
|
|
|
|
|
return ports ?? <String>[];
|
|
|
|
|
|
}
|
2026-03-02 19:21:06 -08:00
|
|
|
|
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();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 18:54:12 -05:00
|
|
|
|
void setDebugLogService(AppDebugLogService? service) {
|
|
|
|
|
|
_debugLogService = service;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 00:27:49 -05:00
|
|
|
|
Future<void> connect({
|
|
|
|
|
|
required String portName,
|
|
|
|
|
|
int baudRate = 115200,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
if (_status == UsbSerialStatus.connected ||
|
|
|
|
|
|
_status == UsbSerialStatus.connecting) {
|
|
|
|
|
|
throw StateError('USB serial transport is already active');
|
|
|
|
|
|
}
|
2026-03-02 05:28:31 -05:00
|
|
|
|
if (!_isSupportedPlatform) {
|
|
|
|
|
|
throw UnsupportedError('USB serial is not supported on this platform.');
|
|
|
|
|
|
}
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
_status = UsbSerialStatus.connecting;
|
2026-03-02 19:21:06 -08:00
|
|
|
|
var normalizedPortName = normalizeUsbPortName(portName);
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_frameDecoder.reset();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
if (_useAndroidUsbHost) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _androidMethodChannel.invokeMethod<void>('connect', {
|
|
|
|
|
|
'portName': normalizedPortName,
|
|
|
|
|
|
'baudRate': baudRate,
|
|
|
|
|
|
});
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_debugLogService?.info(
|
2026-03-02 00:27:49 -05:00
|
|
|
|
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
2026-03-02 18:54:12 -05:00
|
|
|
|
tag: 'USB Serial',
|
2026-03-02 00:27:49 -05:00
|
|
|
|
);
|
|
|
|
|
|
} on PlatformException catch (error) {
|
|
|
|
|
|
_status = UsbSerialStatus.disconnected;
|
2026-03-02 19:21:06 -08:00
|
|
|
|
final msg = error.message ?? error.code;
|
|
|
|
|
|
_debugLogService?.error(
|
|
|
|
|
|
'Android connect failed: $msg',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
2026-03-03 15:04:22 -05:00
|
|
|
|
rethrow;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-02 19:31:35 -08:00
|
|
|
|
// ── Hot-restart guard ─────────────────────────────────────────────────
|
|
|
|
|
|
// On hot restart Dart tears down the isolate without calling dispose().
|
|
|
|
|
|
// The NativeCallable registered by flserial's setCallback() is
|
|
|
|
|
|
// isolate-local and gets freed when the isolate dies, but the native
|
|
|
|
|
|
// SerialThread is still alive and will call it → crash.
|
|
|
|
|
|
//
|
|
|
|
|
|
// flserial uses process-global native state. Calling fl_free() kills ALL
|
|
|
|
|
|
// SerialThreads for every open port across all Dart isolates (there is
|
|
|
|
|
|
// only one in a Flutter app). Then fl_init() re-initialises the slot
|
|
|
|
|
|
// table so subsequent fl_open() calls work normally.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This must happen before we register any new NativeCallable, so it must
|
|
|
|
|
|
// be the very first thing we do in the desktop branch.
|
|
|
|
|
|
try {
|
|
|
|
|
|
bindings.fl_free();
|
|
|
|
|
|
bindings.fl_init(16);
|
|
|
|
|
|
} catch (_) {}
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
2026-03-02 19:21:06 -08:00
|
|
|
|
// 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)';
|
|
|
|
|
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
|
|
|
|
|
// Not a FlSerialException — treat as terminal failure
|
|
|
|
|
|
_status = UsbSerialStatus.disconnected;
|
|
|
|
|
|
throw StateError(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
serial.setByteSize8();
|
|
|
|
|
|
serial.setBitParityNone();
|
|
|
|
|
|
serial.setStopBits1();
|
|
|
|
|
|
serial.setFlowControlNone();
|
|
|
|
|
|
serial.setRTS(false);
|
2026-03-14 18:41:21 -07:00
|
|
|
|
// Toggle DTR low→high so the device sees a fresh connection even
|
|
|
|
|
|
// if the previous disconnect didn't cleanly signal DTR drop.
|
|
|
|
|
|
serial.setDTR(false);
|
|
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
2026-03-02 19:21:06 -08:00
|
|
|
|
serial.setDTR(true);
|
|
|
|
|
|
_serial = serial;
|
|
|
|
|
|
// Update the normalized port name to whichever candidate succeeded.
|
|
|
|
|
|
normalizedPortName = candidate;
|
|
|
|
|
|
_debugLogService?.info(
|
|
|
|
|
|
'USB serial opened port=$candidate cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
|
|
|
|
|
opened = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
} on FlSerialException catch (error) {
|
2026-03-02 19:31:35 -08:00
|
|
|
|
// The native fl_open() already called fl_close() on failure
|
|
|
|
|
|
// internally, so no extra cleanup is needed here for this candidate.
|
2026-03-02 19:21:06 -08:00
|
|
|
|
_debugLogService?.warn(
|
|
|
|
|
|
'Failed to open $candidate: ${error.msg} (code ${error.error})',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
|
|
|
|
|
lastError = error;
|
|
|
|
|
|
// Try next candidate
|
|
|
|
|
|
} catch (error, stackTrace) {
|
|
|
|
|
|
_status = UsbSerialStatus.disconnected;
|
|
|
|
|
|
_debugLogService?.error(
|
2026-03-03 16:44:13 -05:00
|
|
|
|
'Unexpected error opening $candidate: $error\n$stackTrace',
|
2026-03-02 19:21:06 -08:00
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
|
|
|
|
|
rethrow;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
2026-03-02 19:21:06 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!opened) {
|
2026-03-02 00:27:49 -05:00
|
|
|
|
_status = UsbSerialStatus.disconnected;
|
2026-03-02 19:21:06 -08:00
|
|
|
|
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';
|
|
|
|
|
|
_debugLogService?.error(msg, tag: 'USB Serial');
|
|
|
|
|
|
throw StateError(msg);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 15:54:39 -05:00
|
|
|
|
_connectedPortKey = normalizedPortName;
|
|
|
|
|
|
_connectedPortLabel = normalizedPortName;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
if (_useAndroidUsbHost) {
|
|
|
|
|
|
_androidDataSubscription = _androidEventChannel
|
|
|
|
|
|
.receiveBroadcastStream()
|
|
|
|
|
|
.listen(
|
|
|
|
|
|
_handleAndroidData,
|
|
|
|
|
|
onError: _handleSerialError,
|
|
|
|
|
|
onDone: _handleSerialDone,
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
2026-03-02 19:21:06 -08:00
|
|
|
|
_dataSubscription = _serial!.onSerialData.stream.listen(
|
2026-03-02 00:27:49 -05:00
|
|
|
|
_handleSerialData,
|
|
|
|
|
|
onError: _handleSerialError,
|
|
|
|
|
|
onDone: _handleSerialDone,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_status = UsbSerialStatus.connected;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 18:41:21 -07:00
|
|
|
|
Future<void> writeRaw(Uint8List data) async {
|
|
|
|
|
|
if (!isConnected) {
|
|
|
|
|
|
throw StateError('USB serial port is not open');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_useAndroidUsbHost) {
|
|
|
|
|
|
try {
|
2026-03-14 18:41:57 -07:00
|
|
|
|
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
|
2026-03-14 18:41:21 -07:00
|
|
|
|
} on PlatformException catch (error) {
|
|
|
|
|
|
throw StateError(error.message ?? error.code);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_serial!.write(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 00:27:49 -05:00
|
|
|
|
Future<void> write(Uint8List data) async {
|
|
|
|
|
|
if (!isConnected) {
|
|
|
|
|
|
throw StateError('USB serial port is not open');
|
|
|
|
|
|
}
|
|
|
|
|
|
final packet = wrapUsbSerialTxFrame(data);
|
|
|
|
|
|
_logFrameSummary('USB TX frame', data);
|
|
|
|
|
|
if (_useAndroidUsbHost) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _androidMethodChannel.invokeMethod<void>('write', {
|
|
|
|
|
|
'data': packet,
|
|
|
|
|
|
});
|
|
|
|
|
|
} on PlatformException catch (error) {
|
|
|
|
|
|
throw StateError(error.message ?? error.code);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-02 19:21:06 -08:00
|
|
|
|
_serial!.write(packet);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> disconnect() async {
|
|
|
|
|
|
if (_status == UsbSerialStatus.disconnected) return;
|
|
|
|
|
|
|
2026-03-02 22:48:19 -05:00
|
|
|
|
final portLabel = _connectedPortLabel ?? _connectedPortKey;
|
|
|
|
|
|
_debugLogService?.info(
|
|
|
|
|
|
'USB disconnect starting port=${portLabel ?? 'unknown'}',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
_status = UsbSerialStatus.disconnecting;
|
2026-03-02 15:54:39 -05:00
|
|
|
|
_connectedPortKey = null;
|
|
|
|
|
|
_connectedPortLabel = null;
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_frameDecoder.reset();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
|
|
|
|
|
if (_useAndroidUsbHost) {
|
2026-03-02 19:21:06 -08:00
|
|
|
|
await _androidDataSubscription?.cancel();
|
|
|
|
|
|
_androidDataSubscription = null;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
try {
|
|
|
|
|
|
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Ignore errors while closing.
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-02 19:21:06 -08:00
|
|
|
|
// 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;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
try {
|
2026-03-02 19:21:06 -08:00
|
|
|
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
2026-03-14 18:41:21 -07:00
|
|
|
|
serial?.setDTR(false);
|
2026-03-02 19:21:06 -08:00
|
|
|
|
serial?.closePort();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Ignore errors while closing.
|
|
|
|
|
|
}
|
2026-03-02 19:31:35 -08:00
|
|
|
|
// Note: we do NOT call free() here; that would globally reset native
|
|
|
|
|
|
// state for all ports. The global reset is done in connect() instead,
|
|
|
|
|
|
// before the next open, which is the safer place to do it.
|
2026-03-02 00:27:49 -05:00
|
|
|
|
|
2026-03-02 19:21:06 -08:00
|
|
|
|
// Now it is safe to cancel the Dart subscription — the native thread has
|
|
|
|
|
|
// already seen the port close and will not fire any more callbacks.
|
|
|
|
|
|
await _dataSubscription?.cancel();
|
|
|
|
|
|
_dataSubscription = null;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
_status = UsbSerialStatus.disconnected;
|
2026-03-02 22:48:19 -05:00
|
|
|
|
_debugLogService?.info(
|
|
|
|
|
|
'USB disconnect complete port=${portLabel ?? 'unknown'}',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 05:28:31 -05:00
|
|
|
|
void setRequestPortLabel(String label) {
|
|
|
|
|
|
// Native implementations do not use a synthetic chooser row.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 12:38:28 -07:00
|
|
|
|
void setFallbackDeviceName(String label) {
|
|
|
|
|
|
// Native implementations use OS-provided device names.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 00:27:49 -05:00
|
|
|
|
void updateConnectedLabel(String label) {
|
|
|
|
|
|
final trimmed = label.trim();
|
|
|
|
|
|
if (trimmed.isEmpty) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-02 15:54:39 -05:00
|
|
|
|
_connectedPortLabel = buildUsbDisplayLabel(
|
|
|
|
|
|
basePortLabel: _connectedPortKey ?? trimmed,
|
|
|
|
|
|
deviceName: trimmed,
|
|
|
|
|
|
);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void dispose() {
|
2026-03-02 19:31:35 -08:00
|
|
|
|
// Synchronously close the native port so the SerialThread exits before
|
|
|
|
|
|
// the Dart isolate is torn down (e.g. on hot restart). The async
|
|
|
|
|
|
// disconnect() path via unawaited() offers no ordering guarantee — the
|
|
|
|
|
|
// isolate may die before the Future resolves, leaving the thread alive
|
|
|
|
|
|
// with a dangling NativeCallable pointer.
|
|
|
|
|
|
if (_useDesktopFlSerial) {
|
|
|
|
|
|
final serial = _serial;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (serial?.isOpen() == FlOpenStatus.open) {
|
2026-03-14 18:41:21 -07:00
|
|
|
|
serial?.setDTR(false);
|
2026-03-02 19:31:35 -08:00
|
|
|
|
serial?.closePort(); // synchronous C call — kills the SerialThread
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Kick off the full async teardown for anything else (subscription cancel,
|
|
|
|
|
|
// stream controller close). These are best-effort at dispose time.
|
2026-03-02 04:11:52 -05:00
|
|
|
|
unawaited(disconnect().whenComplete(_closeFrameController));
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleSerialData(FlSerialEventArgs event) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final bytes = event.serial.readList();
|
|
|
|
|
|
if (bytes.isNotEmpty) {
|
|
|
|
|
|
_ingestRawBytes(Uint8List.fromList(bytes));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error, stack) {
|
2026-03-02 04:11:52 -05:00
|
|
|
|
_addFrameError(error, stack);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleAndroidData(dynamic data) {
|
|
|
|
|
|
if (data is Uint8List) {
|
|
|
|
|
|
_ingestRawBytes(data);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data is ByteData) {
|
|
|
|
|
|
_ingestRawBytes(data.buffer.asUint8List());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-02 04:11:52 -05:00
|
|
|
|
_addFrameError(
|
2026-03-02 00:27:49 -05:00
|
|
|
|
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
2026-03-02 04:11:52 -05:00
|
|
|
|
_addFrameError(error, stackTrace);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleSerialDone() {
|
|
|
|
|
|
unawaited(disconnect());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _ingestRawBytes(Uint8List bytes) {
|
|
|
|
|
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
|
|
|
|
|
if (!packet.isRxFrame) {
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_debugLogService?.info(
|
2026-03-02 00:27:49 -05:00
|
|
|
|
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
2026-03-02 18:54:12 -05:00
|
|
|
|
tag: 'USB Serial',
|
2026-03-02 00:27:49 -05:00
|
|
|
|
);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-02 04:11:52 -05:00
|
|
|
|
_addFrame(packet.payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _addFrame(Uint8List payload) {
|
|
|
|
|
|
if (_frameController.isClosed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
_frameController.add(payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
|
|
|
|
|
if (_frameController.isClosed) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
_frameController.addError(error, stackTrace);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _closeFrameController() async {
|
|
|
|
|
|
if (_frameController.isClosed) {
|
|
|
|
|
|
return;
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
2026-03-02 04:11:52 -05:00
|
|
|
|
await _frameController.close();
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _logFrameSummary(String prefix, Uint8List bytes) {
|
|
|
|
|
|
if (bytes.isEmpty) {
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
2026-03-02 00:27:49 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-02 18:54:12 -05:00
|
|
|
|
_debugLogService?.info(
|
|
|
|
|
|
'$prefix code=${bytes[0]} len=${bytes.length}',
|
|
|
|
|
|
tag: 'USB Serial',
|
|
|
|
|
|
);
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
2026-03-02 19:21:06 -08:00
|
|
|
|
|
|
|
|
|
|
/// 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];
|
|
|
|
|
|
}
|
2026-03-02 00:27:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|