Skip to content

Commit 2c00051

Browse files
dccoteclaude
andcommitted
Mark devices behind generic serial converters and skip auto-probing them
Problem: Many instruments sit behind a stock FTDI 0x0403:0x6001 cable (FieldMaster, oscilloscope, Echo, IntelliDrive), so they all share the same VID/PID. DeviceManager could not tell them apart and, on every FTDI cable plugged in, tried initializeDevice() on each candidate class -- sending arbitrary protocol bytes to an unknown instrument (the same kind of blind probing that can wedge a device). Solution: add PhysicalDevice.usesGenericSerialConverter (default False). When set, vidpids() expands to every generic converter vendor (FTDI/Prolific/ CP210x/CH34x) with the product id wildcarded, sourced from SerialPort.genericSerialConverterVendors (single source of truth), and isCompatibleWith treats a None product id in a pair as a wildcard. So such a device is discoverable behind any generic cable, not just FTDI, and is identified by serial number rather than VID/PID. DeviceManager gains candidateClassesForAutoDiscovery(), which drops generic-converter classes so they are never auto-probed; they are constructed explicitly instead. FieldMaster, oscilloscope, Echo and IntelliDrive are flagged generic; DebugFieldMasterDevice keeps its own fake identity (flag False), and Thorlabs (custom-EEPROM FTDI PID 0xfaf0) stays a specific, unique identity. connectedUSBDevices omits the product id from usb.core.find when it is None so VID-only matching works end to end. Adds testGenericSerialConverter.py (hardware-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f10fdd commit 2c00051

8 files changed

Lines changed: 114 additions & 4 deletions

File tree

hardwarelibrary/devicemanager.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,23 @@ def stopMonitoring(self):
207207
else:
208208
raise RuntimeError("No monitoring loop running")
209209

210+
@classmethod
211+
def candidateClassesForAutoDiscovery(cls, idVendor, idProduct):
212+
"""Device classes matching a plugged-in USB device that are safe to
213+
instantiate automatically. Classes behind a generic serial converter are
214+
excluded: their VID/PID identifies only the cable, so several instruments
215+
share it and auto-probing them would send arbitrary protocol bytes to an
216+
unknown device. Those must be constructed explicitly instead."""
217+
candidates = utils.getCandidateDeviceClasses(PhysicalDevice, idVendor, idProduct)
218+
return [aClass for aClass in candidates if not aClass.usesGenericSerialConverter]
219+
210220
def usbDeviceConnected(self, usbDevice):
211221
descriptor = USBDeviceDescriptor.fromUSBDevice(usbDevice)
212222
NotificationCenter().postNotification(DeviceManagerNotification.usbDeviceDidConnect, notifyingObject=self, userInfo=descriptor)
213223
if descriptor not in self.usbDeviceDescriptors:
214224
self.usbDeviceDescriptors.append(descriptor)
215225

216-
candidates = utils.getCandidateDeviceClasses(PhysicalDevice, descriptor.idVendor, descriptor.idProduct)
226+
candidates = self.candidateClassesForAutoDiscovery(descriptor.idVendor, descriptor.idProduct)
217227
for candidateClass in candidates:
218228
# This may throw if incompat:
219229
# deviceInstanceible

hardwarelibrary/echodevice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
class EchoDevice(PhysicalDevice):
1515
classIdProduct = 0x6001
1616
classIdVendor = 0x0403
17+
usesGenericSerialConverter = True
1718
commands = {
1819
"ECHO1": TextCommand(name="ECHO1", requestEncoder="someText", replyDecoder="someText"),
1920
"ECHO2": TextCommand(name="ECHO2", requestEncoder="someOtherText", replyDecoder="someOtherText"),

hardwarelibrary/motion/intellidrivedevice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class State(Enum):
2222
class IntellidriveDevice(RotationDevice):
2323
classIdVendor = 0x0403
2424
classIdProduct = 0x6001
25+
usesGenericSerialConverter = True
2526

2627
commands = {
2728
"SET_REGISTER": TextCommand(name="SET_REGISTER",

hardwarelibrary/oscilloscope/oscilloscopedevice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def __init__(self, code, msg, esr, stb, underlyingErrors=None):
2626
class OscilloscopeDevice(PhysicalDevice):
2727
classIdVendor = 0x0403
2828
classIdProduct = 0x6001
29+
usesGenericSerialConverter = True
2930

3031
def __init__(self, serialNumber:str = None, idProduct = 0x6001, idVendor = 0x0403):
3132
super().__init__(serialNumber, idProduct=self.classIdProduct, idVendor=self.classIdVendor)

hardwarelibrary/physicaldevice.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ class NotInitialized(Exception):
3838
classIdProduct = None
3939
commands = None
4040

41+
# True when classIdVendor/classIdProduct are those of a generic, off-the-shelf
42+
# USB/RS-232 converter (a stock FTDI/Prolific/CP210x/CH34x cable) rather than
43+
# the instrument's own identity. Such a device shares its VID/PID with every
44+
# other instrument behind the same cable model, so it cannot be identified by
45+
# VID/PID alone: it matches any generic converter (see vidpids) and must be
46+
# disambiguated by serial number, and DeviceManager will not auto-probe it.
47+
usesGenericSerialConverter = False
48+
4149
def __init__(self, serialNumber:str, idProduct:int, idVendor:int):
4250
if serialNumber == "*" or serialNumber is None:
4351
serialNumber = ".*"
@@ -65,13 +73,22 @@ def __init__(self, serialNumber:str, idProduct:int, idVendor:int):
6573

6674
@classmethod
6775
def vidpids(cls):
76+
if cls.usesGenericSerialConverter:
77+
# Identity is not in the VID/PID: match any generic converter chip,
78+
# PID wildcarded (None). Local import avoids a physicaldevice <->
79+
# communication import cycle.
80+
from hardwarelibrary.communication.serialport import SerialPort
81+
return [(idVendor, None) for idVendor in SerialPort.genericSerialConverterVendors]
6882
return [(cls.classIdVendor, cls.classIdProduct)]
6983

7084
@classmethod
7185
def isCompatibleWith(cls, serialNumber, idProduct, idVendor):
7286
for compatibleIdVendor, compatibleIdProduct in cls.vidpids():
73-
if idVendor == compatibleIdVendor and idProduct == compatibleIdProduct:
74-
return True
87+
if idVendor == compatibleIdVendor:
88+
# A None product id in the pair is a wildcard: it matches any
89+
# product behind that vendor (used for generic converters).
90+
if compatibleIdProduct is None or idProduct == compatibleIdProduct:
91+
return True
7592

7693
return False
7794

hardwarelibrary/powermeters/fieldmasterdevice.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class FieldMasterDevice(PowerMeterDevice):
4343

4444
classIdVendor = 0x0403
4545
classIdProduct = 0x6001
46+
usesGenericSerialConverter = True
4647

4748
def __init__(self, serialNumber: str = None, idProduct: int = 0x6001,
4849
idVendor: int = 0x0403, portPath: str = None,
@@ -152,6 +153,7 @@ class DebugFieldMasterDevice(FieldMasterDevice):
152153

153154
classIdVendor = 0xFFFF
154155
classIdProduct = 0xFFF1
156+
usesGenericSerialConverter = False # debug device has its own fake identity
155157

156158
def __init__(self, serialNumber='debug'):
157159
PhysicalDevice.__init__(self, serialNumber=serialNumber,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import env
2+
import unittest
3+
4+
from hardwarelibrary.physicaldevice import PhysicalDevice
5+
from hardwarelibrary.communication.serialport import SerialPort
6+
from hardwarelibrary.powermeters import (
7+
FieldMasterDevice, DebugFieldMasterDevice, IntegraDevice,
8+
)
9+
from hardwarelibrary.devicemanager import DeviceManager
10+
import hardwarelibrary.utils as utils
11+
12+
13+
class TestGenericConverterFlag(unittest.TestCase):
14+
def testFieldMasterIsGeneric(self):
15+
self.assertTrue(FieldMasterDevice.usesGenericSerialConverter)
16+
17+
def testDebugFieldMasterIsNotGeneric(self):
18+
self.assertFalse(DebugFieldMasterDevice.usesGenericSerialConverter)
19+
20+
def testIntegraIsNotGeneric(self):
21+
self.assertFalse(IntegraDevice.usesGenericSerialConverter)
22+
23+
def testBaseDefaultIsNotGeneric(self):
24+
self.assertFalse(PhysicalDevice.usesGenericSerialConverter)
25+
26+
27+
class TestGenericConverterVidpids(unittest.TestCase):
28+
def testGenericExpandsToAllConverterVendors(self):
29+
expected = [(idVendor, None) for idVendor in SerialPort.genericSerialConverterVendors]
30+
self.assertEqual(FieldMasterDevice.vidpids(), expected)
31+
32+
def testNonGenericKeepsSinglePair(self):
33+
self.assertEqual(IntegraDevice.vidpids(), [(0x1ad5, 0x0300)])
34+
35+
def testDebugKeepsItsOwnPair(self):
36+
self.assertEqual(DebugFieldMasterDevice.vidpids(), [(0xFFFF, 0xFFF1)])
37+
38+
39+
class TestGenericConverterCompatibility(unittest.TestCase):
40+
def testMatchesAnyGenericConverterVendorWithAnyProduct(self):
41+
# Same instrument behind FTDI, Prolific, or CP210x cables (different PIDs)
42+
self.assertTrue(FieldMasterDevice.isCompatibleWith("*", 0x6001, 0x0403))
43+
self.assertTrue(FieldMasterDevice.isCompatibleWith("*", 0x2303, 0x067b))
44+
self.assertTrue(FieldMasterDevice.isCompatibleWith("*", 0xEA60, 0x10c4))
45+
46+
def testRejectsUnknownVendor(self):
47+
self.assertFalse(FieldMasterDevice.isCompatibleWith("*", 0x0001, 0x9999))
48+
49+
def testNonGenericStillRequiresExactProduct(self):
50+
self.assertTrue(IntegraDevice.isCompatibleWith("*", 0x0300, 0x1ad5))
51+
self.assertFalse(IntegraDevice.isCompatibleWith("*", 0x9999, 0x1ad5))
52+
# No wildcard leak: a None product must not match a concrete pair.
53+
self.assertFalse(IntegraDevice.isCompatibleWith("*", None, 0x1ad5))
54+
55+
def testDebugFieldMasterStillConstructs(self):
56+
device = DebugFieldMasterDevice() # would raise if the flag broke its vidpids
57+
self.assertEqual(device.idVendor, 0xFFFF)
58+
self.assertEqual(device.idProduct, 0xFFF1)
59+
60+
61+
class TestAutoDiscoveryGuard(unittest.TestCase):
62+
def testGenericClassesMatchButAreExcludedFromAutoDiscovery(self):
63+
candidates = utils.getCandidateDeviceClasses(PhysicalDevice, 0x0403, 0x6001)
64+
self.assertIn(FieldMasterDevice, candidates) # it does match the FTDI cable
65+
66+
discoverable = DeviceManager.candidateClassesForAutoDiscovery(0x0403, 0x6001)
67+
self.assertNotIn(FieldMasterDevice, discoverable) # ...but is not auto-probed
68+
for aClass in discoverable:
69+
self.assertFalse(aClass.usesGenericSerialConverter)
70+
71+
72+
if __name__ == "__main__":
73+
unittest.main()

hardwarelibrary/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ def connectedUSBDevices(vidpids = None, serialNumberPattern=None):
7777
devices = []
7878
if vidpids is not None:
7979
for (idVendor, idProduct) in vidpids:
80-
devices.extend(list(usb.core.find(find_all=True, idVendor=idVendor, idProduct=idProduct)))
80+
if idProduct is None:
81+
# A None product id means "any product from this vendor". pyusb's
82+
# find matches on equality, so idProduct must be omitted, not None.
83+
devices.extend(list(usb.core.find(find_all=True, idVendor=idVendor)))
84+
else:
85+
devices.extend(list(usb.core.find(find_all=True, idVendor=idVendor, idProduct=idProduct)))
8186
else:
8287
devices.extend(list(usb.core.find(find_all=True)))
8388

0 commit comments

Comments
 (0)