From 8dfb1561a5956d2711dac26c2aaa183f8a42857e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20C=C3=B4t=C3=A9?= Date: Tue, 7 Jul 2026 00:22:44 -0400 Subject: [PATCH] Discover the Millennia eV port by its STM32 USB-CDC VID/PID Problem: MillenniaEv25Device could only be constructed with an explicit portPath; doInitializeDevice opened SerialPort(portPath=...) and never fell back to VID/PID discovery, so a caller (e.g. the MillenniaUI app) had to know the OS port name up front. The class did not set classIdVendor/classIdProduct, so nothing identified the device by its USB descriptor. Solution: Set classIdVendor = 0x0483 / classIdProduct = 0x5740 (the STM32 Virtual COM Port identity the lab eV25s enumerates as) and have doInitializeDevice discover the port by that identity when no portPath is given, mirroring FieldMasterDevice: it builds SerialPort(idVendor, idProduct, serialNumber) -> matchAnyPort over pyserial's comports(), and raises a clear UnableToInitialize naming the identity when nothing matches. An explicit portPath still wins; a serialNumber narrows discovery when several STM32 CDC ports are present. serialNumber is passed only when it actually narrows, so matchPorts stays on its vid/pid path and never regex-matches a possibly-None descriptor serial. The generic-STM32-VCP caveat is documented in the code. DebugMillenniaEv25Device is unaffected: it overrides doInitializeDevice and keeps its own fake identity. testMillennia.py passes (14 passed, 6 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- hardwarelibrary/sources/millennia.py | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/hardwarelibrary/sources/millennia.py b/hardwarelibrary/sources/millennia.py index 5f5b9cd..55cd351 100644 --- a/hardwarelibrary/sources/millennia.py +++ b/hardwarelibrary/sources/millennia.py @@ -31,20 +31,24 @@ class MillenniaEv25Device(LaserSourceDevice, OnOffControl, ShutterControl, Power # The lab eV25s enumerates its back-panel USB port as a native STM32 # USB-CDC device: VID 0x0483, PID 0x5740 (confirmed on the bench, firmware - # SW214-00.004.096). MillenniaEv25Device is still instantiated by portPath - # (like CoboltDevice) rather than discovered by VID/PID, because 0x0483:0x5740 - # is STMicroelectronics' *generic* STM32 Virtual COM Port identity, shared by - # many unrelated STM32-based USB-CDC boards; matching on it via - # SerialPort.matchAnyPort would risk binding to the wrong device. If a host - # only ever has this one STM32 CDC port, set classIdVendor = 0x0483 and - # classIdProduct = 0x5740 to enable discovery; otherwise pin the portPath. - # To re-derive on another unit: + # SW214-00.004.096). doInitializeDevice discovers the port by this identity + # when no portPath is given. + # + # Caveat: 0x0483:0x5740 is STMicroelectronics' *generic* STM32 Virtual COM + # Port identity, shared by many unrelated STM32-based USB-CDC boards. On a + # host that also has another STM32 CDC device, discovery could bind to the + # wrong port; pin it with an explicit portPath (or narrow with a + # serialNumber) in that case. A given portPath always wins over discovery. + # To re-derive the identity on another unit: # # system_profiler SPUSBDataType | grep -A 12 -i millennia # .venv/bin/python -c "from serial.tools.list_ports import comports; \ # [print(p.device, hex(p.vid or 0), hex(p.pid or 0), p.serial_number) \ # for p in comports()]" + classIdVendor = 0x0483 # STMicroelectronics + classIdProduct = 0x5740 # STM32 Virtual COM Port (generic STM32 CDC identity) + defaultBaudRate = 115200 commandTerminator = "\r" # the eV accepts , , or both minPower = 0.05 # documented eV/Pro-s lower bound @@ -76,8 +80,23 @@ def __init__(self, portPath=None, serialNumber=None, idProduct=None, idVendor=No self.laserSerialNumber = None def doInitializeDevice(self): - try: + if self.portPath is not None: self.port = SerialPort(portPath=self.portPath) + else: + # Discover by the STM32 USB-CDC identity. serialNumber is a regex + # (PhysicalDevice turns the default into ".*"); pass it only when it + # actually narrows, so SerialPort.matchPorts stays on its vid/pid + # path and never regex-matches against a possibly-None descriptor + # serial. + serialNumber = None if self.serialNumber in (None, ".*") else self.serialNumber + self.port = SerialPort(idVendor=self.idVendor, idProduct=self.idProduct, + serialNumber=serialNumber) + if self.port.portPath is None: + raise PhysicalDevice.UnableToInitialize( + "No Millennia eV found by USB identity {0:#06x}:{1:#06x}. " + "Pass an explicit portPath if several STM32 USB-CDC ports " + "are present.".format(self.idVendor, self.idProduct)) + try: self.port.open(baudRate=self.defaultBaudRate) self.readIdentity() except Exception as error: