mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
484 lines
17 KiB
Dart
484 lines
17 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:flserial/flserial.dart';
|
||
import 'package:flserial/flserial_exception.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import 'app_debug_log_service.dart';
|
||
import '../utils/macos_usb_device_names.dart';
|
||
import '../utils/platform_info.dart';
|
||
import '../utils/usb_port_labels.dart';
|
||
import 'usb_serial_frame_codec.dart';
|
||
|
||
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
|
||
/// MeshCore connector to consume.
|
||
class UsbSerialService {
|
||
UsbSerialService();
|
||
|
||
static const MethodChannel _androidMethodChannel = MethodChannel(
|
||
'meshcore_open/android_usb_serial',
|
||
);
|
||
static const EventChannel _androidEventChannel = EventChannel(
|
||
'meshcore_open/android_usb_serial_events',
|
||
);
|
||
final StreamController<Uint8List> _frameController =
|
||
StreamController<Uint8List>.broadcast();
|
||
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||
StreamSubscription<dynamic>? _androidDataSubscription;
|
||
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
||
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||
String? _connectedPortKey;
|
||
String? _connectedPortLabel;
|
||
FlSerial? _serial;
|
||
AppDebugLogService? _debugLogService;
|
||
|
||
UsbSerialStatus get status => _status;
|
||
String? get activePortKey => _connectedPortKey;
|
||
String? get activePortDisplayLabel =>
|
||
_connectedPortLabel ?? _connectedPortKey;
|
||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||
bool get _useAndroidUsbHost =>
|
||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||
bool get _useDesktopFlSerial =>
|
||
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
||
bool get _isSupportedPlatform => _useAndroidUsbHost || _useDesktopFlSerial;
|
||
// 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 {
|
||
if (!_isSupportedPlatform) {
|
||
return false;
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
Future<List<String>> listPorts() async {
|
||
if (!_isSupportedPlatform) {
|
||
return const <String>[];
|
||
}
|
||
if (_useAndroidUsbHost) {
|
||
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
||
'listPorts',
|
||
);
|
||
return ports ?? <String>[];
|
||
}
|
||
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) {
|
||
_debugLogService = service;
|
||
}
|
||
|
||
Future<void> connect({
|
||
required String portName,
|
||
int baudRate = 115200,
|
||
}) async {
|
||
if (_status == UsbSerialStatus.connected ||
|
||
_status == UsbSerialStatus.connecting) {
|
||
throw StateError('USB serial transport is already active');
|
||
}
|
||
if (!_isSupportedPlatform) {
|
||
throw UnsupportedError('USB serial is not supported on this platform.');
|
||
}
|
||
|
||
_status = UsbSerialStatus.connecting;
|
||
var normalizedPortName = normalizeUsbPortName(portName);
|
||
_frameDecoder.reset();
|
||
|
||
if (_useAndroidUsbHost) {
|
||
try {
|
||
await _androidMethodChannel.invokeMethod<void>('connect', {
|
||
'portName': normalizedPortName,
|
||
'baudRate': baudRate,
|
||
});
|
||
_debugLogService?.info(
|
||
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
||
tag: 'USB Serial',
|
||
);
|
||
} on PlatformException catch (error) {
|
||
_status = UsbSerialStatus.disconnected;
|
||
final msg = error.message ?? error.code;
|
||
_debugLogService?.error(
|
||
'Android connect failed: $msg',
|
||
tag: 'USB Serial',
|
||
);
|
||
rethrow;
|
||
}
|
||
} else {
|
||
// ── 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 (_) {}
|
||
|
||
// 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);
|
||
// 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));
|
||
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) {
|
||
// The native fl_open() already called fl_close() on failure
|
||
// internally, so no extra cleanup is needed here for this candidate.
|
||
_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(
|
||
'Unexpected error opening $candidate: $error\n$stackTrace',
|
||
tag: 'USB Serial',
|
||
);
|
||
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';
|
||
_debugLogService?.error(msg, tag: 'USB Serial');
|
||
throw StateError(msg);
|
||
}
|
||
}
|
||
|
||
_connectedPortKey = normalizedPortName;
|
||
_connectedPortLabel = normalizedPortName;
|
||
if (_useAndroidUsbHost) {
|
||
_androidDataSubscription = _androidEventChannel
|
||
.receiveBroadcastStream()
|
||
.listen(
|
||
_handleAndroidData,
|
||
onError: _handleSerialError,
|
||
onDone: _handleSerialDone,
|
||
);
|
||
} else {
|
||
_dataSubscription = _serial!.onSerialData.stream.listen(
|
||
_handleSerialData,
|
||
onError: _handleSerialError,
|
||
onDone: _handleSerialDone,
|
||
);
|
||
}
|
||
_status = UsbSerialStatus.connected;
|
||
}
|
||
|
||
Future<void> writeRaw(Uint8List data) async {
|
||
if (!isConnected) {
|
||
throw StateError('USB serial port is not open');
|
||
}
|
||
if (_useAndroidUsbHost) {
|
||
try {
|
||
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
|
||
} on PlatformException catch (error) {
|
||
throw StateError(error.message ?? error.code);
|
||
}
|
||
} else {
|
||
_serial!.write(data);
|
||
}
|
||
}
|
||
|
||
Future<void> write(Uint8List data) async {
|
||
if (!isConnected) {
|
||
throw StateError('USB serial port is not open');
|
||
}
|
||
final packet = wrapUsbSerialTxFrame(data);
|
||
_logFrameSummary('USB TX frame', data);
|
||
if (_useAndroidUsbHost) {
|
||
try {
|
||
await _androidMethodChannel.invokeMethod<void>('write', {
|
||
'data': packet,
|
||
});
|
||
} on PlatformException catch (error) {
|
||
throw StateError(error.message ?? error.code);
|
||
}
|
||
} else {
|
||
_serial!.write(packet);
|
||
}
|
||
}
|
||
|
||
Future<void> disconnect() async {
|
||
if (_status == UsbSerialStatus.disconnected) return;
|
||
|
||
final portLabel = _connectedPortLabel ?? _connectedPortKey;
|
||
_debugLogService?.info(
|
||
'USB disconnect starting port=${portLabel ?? 'unknown'}',
|
||
tag: 'USB Serial',
|
||
);
|
||
_status = UsbSerialStatus.disconnecting;
|
||
_connectedPortKey = null;
|
||
_connectedPortLabel = null;
|
||
_frameDecoder.reset();
|
||
|
||
if (_useAndroidUsbHost) {
|
||
await _androidDataSubscription?.cancel();
|
||
_androidDataSubscription = null;
|
||
try {
|
||
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
||
} catch (_) {
|
||
// Ignore errors while closing.
|
||
}
|
||
} 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;
|
||
try {
|
||
if (serial?.isOpen() == FlOpenStatus.open) {
|
||
serial?.setDTR(false);
|
||
serial?.closePort();
|
||
}
|
||
} catch (_) {
|
||
// Ignore errors while closing.
|
||
}
|
||
// 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.
|
||
|
||
// 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;
|
||
}
|
||
_status = UsbSerialStatus.disconnected;
|
||
_debugLogService?.info(
|
||
'USB disconnect complete port=${portLabel ?? 'unknown'}',
|
||
tag: 'USB Serial',
|
||
);
|
||
}
|
||
|
||
void setRequestPortLabel(String label) {
|
||
// Native implementations do not use a synthetic chooser row.
|
||
}
|
||
|
||
void setFallbackDeviceName(String label) {
|
||
// Native implementations use OS-provided device names.
|
||
}
|
||
|
||
void updateConnectedLabel(String label) {
|
||
final trimmed = label.trim();
|
||
if (trimmed.isEmpty) {
|
||
return;
|
||
}
|
||
_connectedPortLabel = buildUsbDisplayLabel(
|
||
basePortLabel: _connectedPortKey ?? trimmed,
|
||
deviceName: trimmed,
|
||
);
|
||
}
|
||
|
||
void dispose() {
|
||
// 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) {
|
||
serial?.setDTR(false);
|
||
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.
|
||
unawaited(disconnect().whenComplete(_closeFrameController));
|
||
}
|
||
|
||
void _handleSerialData(FlSerialEventArgs event) {
|
||
try {
|
||
final bytes = event.serial.readList();
|
||
if (bytes.isNotEmpty) {
|
||
_ingestRawBytes(Uint8List.fromList(bytes));
|
||
}
|
||
} catch (error, stack) {
|
||
_addFrameError(error, stack);
|
||
}
|
||
}
|
||
|
||
void _handleAndroidData(dynamic data) {
|
||
if (data is Uint8List) {
|
||
_ingestRawBytes(data);
|
||
return;
|
||
}
|
||
if (data is ByteData) {
|
||
_ingestRawBytes(data.buffer.asUint8List());
|
||
return;
|
||
}
|
||
_addFrameError(
|
||
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
||
);
|
||
}
|
||
|
||
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
||
_addFrameError(error, stackTrace);
|
||
}
|
||
|
||
void _handleSerialDone() {
|
||
unawaited(disconnect());
|
||
}
|
||
|
||
void _ingestRawBytes(Uint8List bytes) {
|
||
for (final packet in _frameDecoder.ingest(bytes)) {
|
||
if (!packet.isRxFrame) {
|
||
_debugLogService?.info(
|
||
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||
tag: 'USB Serial',
|
||
);
|
||
continue;
|
||
}
|
||
_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;
|
||
}
|
||
await _frameController.close();
|
||
}
|
||
|
||
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||
if (bytes.isEmpty) {
|
||
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
|
||
return;
|
||
}
|
||
_debugLogService?.info(
|
||
'$prefix code=${bytes[0]} len=${bytes.length}',
|
||
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 }
|