fix(#248): VID-gate the USB DTR open pulse (don't wedge ESP32-C6)

The DTR low→high pulse in the desktop USB open path resets ESP32 USB-Serial/JTAG
chips (VID 303A: C6/H2/S3-HWCDC) into ROM download mode mid-handshake, wedging
the device until a physical reset. The pulse exists for nRF52 (VID 239A)
reconnect-after-unclean-disconnect, so it can't just be removed.

connect() now recovers the port's USB VID from the flserial enumeration
(hardware_id) and gates the pulse: 239A keeps the DTR low→high pulse; 303A and
any unknown/unparseable VID open with DTR asserted and no pulse. VID lookup is
enumeration-only (never opens the port). New usb_vid helper parses both flserial
hardware_id formats (Windows VID_XXXX and macOS/Linux VID:PID=xxxx:xxxx), unit
tested. Build of epic #245.
pull/255/head
Strycher 7 days ago
parent 39274488c3
commit ea4adb3faa

@ -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 lowhigh 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);
if (portVid == usbVidAdafruitNrf52) {
// nRF52 (Adafruit): keys "fresh connection" off a DTR lowhigh edge
// so a reconnect after an unclean disconnect is seen. Keep the pulse.
serial.setDTR(false);
await Future<void>.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;

@ -0,0 +1,29 @@
/// USB vendor-ID helpers for gating the desktop serial open sequence.
///
/// The DTR lowhigh 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;
}

@ -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);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.