diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index 9c8af85..05655bb 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -10,6 +10,7 @@ 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 '../utils/usb_vid.dart'; import 'usb_serial_frame_codec.dart'; /// Wraps the native flserial plugin to expose a stream of raw bytes for the @@ -102,6 +103,25 @@ class UsbSerialService { }).toList(); } + /// Best-effort USB vendor ID for [portName] from the flserial port + /// enumeration, or null if it can't be determined. Enumeration only — it does + /// NOT open the port, so it can't trigger the reset the gate exists to avoid. + /// Used to gate the DTR open pulse. (#244/#245) + int? _lookupPortVid(String portName) { + try { + for (final entry in FlSerial.listPorts()) { + if (normalizeUsbPortName(entry) != portName) continue; + final segments = entry.split(' - '); + if (segments.length < 3) return null; + // hardware_id is the 3rd field onward (may itself contain ' - '). + return parseUsbVid(segments.sublist(2).join(' - ')); + } + } catch (e) { + _debugLogService?.warn('USB VID lookup failed: $e', tag: 'USB Serial'); + } + return null; + } + void setDebugLogService(AppDebugLogService? service) { _debugLogService = service; } @@ -164,6 +184,12 @@ class UsbSerialService { // 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); + + // Recover the USB vendor ID so we can gate the DTR open pulse below. The + // pulse resets ESP32 USB-Serial/JTAG chips (VID 303A) into ROM download + // mode; it must fire only for nRF52 (VID 239A). (#244/#245) + final int? portVid = _lookupPortVid(normalizedPortName); + FlSerialException? lastError; bool opened = false; @@ -189,11 +215,26 @@ class UsbSerialService { 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); + if (portVid == usbVidAdafruitNrf52) { + // nRF52 (Adafruit): keys "fresh connection" off a DTR low→high edge + // so a reconnect after an unclean disconnect is seen. Keep the pulse. + serial.setDTR(false); + await Future.delayed(const Duration(milliseconds: 50)); + serial.setDTR(true); + } else { + // ESP32 USB-Serial/JTAG (VID 303A) maps DTR/RTS onto EN/BOOT, so a + // DTR-low window resets the chip into ROM download mode mid-open and + // wedges it. Any non-nRF52 device — including an unknown/unparseable + // VID — opens with DTR asserted and no pulse (a stale DTR is safer + // than wedging the device). (#244/#245) + serial.setDTR(true); + } + _debugLogService?.info( + 'USB open pulse gate: vid=' + '${portVid == null ? 'unknown' : '0x${portVid.toRadixString(16)}'} ' + '${portVid == usbVidAdafruitNrf52 ? 'DTR pulse (nRF52)' : 'no pulse (DTR asserted)'}', + tag: 'USB Serial', + ); _serial = serial; // Update the normalized port name to whichever candidate succeeded. normalizedPortName = candidate; diff --git a/lib/utils/usb_vid.dart b/lib/utils/usb_vid.dart new file mode 100644 index 0000000..d43e350 --- /dev/null +++ b/lib/utils/usb_vid.dart @@ -0,0 +1,29 @@ +/// USB vendor-ID helpers for gating the desktop serial open sequence. +/// +/// The DTR low→high pulse in the USB open path is a "fresh connection" signal +/// nRF52 boards need to reconnect after an unclean disconnect. But on ESP32 +/// USB-Serial/JTAG chips (VID 303A: C6/H2/S3-HWCDC) the ROM maps DTR/RTS onto +/// EN/BOOT, so the pulse resets the chip into ROM download mode mid-open and +/// wedges it until a physical reset. We gate the pulse by vendor ID. (#244/#245) +library; + +/// Adafruit — nRF52 boards (RAK4631 etc.). Needs the DTR edge to reconnect. +const int usbVidAdafruitNrf52 = 0x239A; + +/// Espressif — ESP32 USB-Serial/JTAG. The DTR pulse resets it into ROM. +const int usbVidEspressif = 0x303A; + +final RegExp _vidWindows = RegExp(r'VID_([0-9A-Fa-f]{4})'); +final RegExp _vidUnix = RegExp(r'VID:PID=([0-9A-Fa-f]{4}):[0-9A-Fa-f]{4}'); + +/// Parses a USB vendor ID (0..0xFFFF) from a flserial `hardware_id` string, or +/// null if none is present. Handles both formats flserial emits: +/// - Windows (SetupAPI `SPDRP_HARDWAREID`): `USB\VID_303A&PID_1001&...` +/// - macOS / Linux (pyserial-style): `USB VID:PID=303a:1001 SNR=...` +int? parseUsbVid(String hardwareId) { + final win = _vidWindows.firstMatch(hardwareId); + if (win != null) return int.parse(win.group(1)!, radix: 16); + final unix = _vidUnix.firstMatch(hardwareId); + if (unix != null) return int.parse(unix.group(1)!, radix: 16); + return null; +} diff --git a/test/utils/usb_vid_test.dart b/test/utils/usb_vid_test.dart new file mode 100644 index 0000000..036eb2b --- /dev/null +++ b/test/utils/usb_vid_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/usb_vid.dart'; + +void main() { + group('parseUsbVid', () { + test('Windows SPDRP_HARDWAREID format', () { + expect( + parseUsbVid(r'USB\VID_303A&PID_1001&MI_00\8&25728F34&0&0000'), + 0x303A, + ); + expect( + parseUsbVid(r'USB\VID_239A&PID_8029&MI_00\8&6C3483E&0&0000'), + 0x239A, + ); + }); + + test('macOS / Linux pyserial format', () { + expect(parseUsbVid('USB VID:PID=303a:1001 SNR=1234'), 0x303A); + expect(parseUsbVid('USB VID:PID=239a:8029 LOCATION=1-1'), 0x239A); + }); + + test('hex is case-insensitive', () { + expect(parseUsbVid(r'USB\VID_303a&PID_1001'), 0x303A); + expect(parseUsbVid('USB VID:PID=239A:8029'), 0x239A); + }); + + test('no VID present returns null', () { + expect(parseUsbVid('n/a'), isNull); + expect(parseUsbVid('USB Serial Device'), isNull); + expect(parseUsbVid(''), isNull); + expect(parseUsbVid('COM23'), isNull); + }); + + test('known vendor-ID constants', () { + expect(usbVidAdafruitNrf52, 0x239A); + expect(usbVidEspressif, 0x303A); + }); + }); +}