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 _frameController = StreamController.broadcast(); final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder(); StreamSubscription? _androidDataSubscription; StreamSubscription? _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 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> listPorts() async { if (!_isSupportedPlatform) { return const []; } if (_useAndroidUsbHost) { final ports = await _androidMethodChannel.invokeListMethod( 'listPorts', ); return ports ?? []; } 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> _annotateMacOsPorts(List 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 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('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.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 writeRaw(Uint8List data) async { if (!isConnected) { throw StateError('USB serial port is not open'); } if (_useAndroidUsbHost) { try { await _androidMethodChannel.invokeMethod('write', {'data': data}); } on PlatformException catch (error) { throw StateError(error.message ?? error.code); } } else { _serial!.write(data); } } Future write(Uint8List data) async { if (!isConnected) { throw StateError('USB serial port is not open'); } final packet = wrapUsbSerialTxFrame(data); // _logFrameSummary('USB TX frame', data); if (_useAndroidUsbHost) { try { await _androidMethodChannel.invokeMethod('write', { 'data': packet, }); } on PlatformException catch (error) { throw StateError(error.message ?? error.code); } } else { _serial!.write(packet); } } Future disconnect() async { if (_status == UsbSerialStatus.disconnected) return; 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('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 _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 _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 }