Skip to content

Commit 0e1e996

Browse files
authored
Merge pull request #102 from DCC-Lab/find-generic-ports
Discover generic USB/RS-232 adaptors and stop auto-probing devices behind them
2 parents b1cf98a + 2c00051 commit 0e1e996

10 files changed

Lines changed: 198 additions & 5 deletions

File tree

hardwarelibrary/communication/serialport.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class MoreThanOneMatch(serial.SerialException):
1717
class SerialPort(CommunicationPort):
1818
"""
1919
An implementation of CommunicationPort using BSD-style serial port
20-
20+
2121
Two strategies to initialize the SerialPort:
2222
1. with a portPath/port name (i.e. "COM1" or "/dev/cu.serial")
2323
2. with an instance of pyserial.Serial() that will support the same
@@ -26,6 +26,17 @@ class SerialPort(CommunicationPort):
2626
or the find_url.py script from the distribution. More info: https://eblot.github.io/pyftdi/api/usbtools.html
2727
You have to add any custom VID/PID when using tools (but they are added here in SerialPort) @line 30.
2828
"""
29+
30+
# USB idVendor of the common, generic RS-232/USB serial-converter chips.
31+
# A device behind one of these bridges inherits the bridge's anonymous
32+
# VID/PID and carries no identity of its own, so it can only be told apart
33+
# from other adaptors by its serial number. Extend as needed.
34+
genericSerialConverterVendors = {
35+
0x0403: "FTDI",
36+
0x067b: "Prolific",
37+
0x10c4: "Silicon Labs (CP210x)",
38+
0x1a86: "WCH (CH340/CH341)",
39+
}
2940
def __init__(self, idVendor=None, idProduct=None, serialNumber=None, portPath=None, port=None, delay=0):
3041
CommunicationPort.__init__(self)
3142

@@ -138,6 +149,27 @@ def ftdiPorts(cls):
138149

139150
return urls
140151

152+
@classmethod
153+
def isGenericSerialConverter(cls, idVendor) -> bool:
154+
"""Whether idVendor belongs to a known generic USB/RS-232 converter
155+
chip (FTDI, Prolific, Silicon Labs, WCH, ...). A None idVendor (a
156+
non-USB port such as a Bluetooth channel) is not a converter."""
157+
return idVendor in cls.genericSerialConverterVendors
158+
159+
@classmethod
160+
def genericSerialConverterPorts(cls):
161+
"""Return the connected serial ports that come from a generic USB/RS-232
162+
converter chip, as pyserial ListPortInfo objects.
163+
164+
Useful when the instrument at the other end has no USB identity of its
165+
own (it sits behind an FTDI/Prolific/CP210x/CH34x cable): this lists the
166+
candidate adaptors so the caller can pick one, typically by its
167+
`serial_number`. Open a match with its `.device` path. The recognized
168+
chips are in `genericSerialConverterVendors`; read a port's chip name
169+
with `genericSerialConverterVendors[port.vid]`.
170+
"""
171+
return [port for port in comports() if cls.isGenericSerialConverter(port.vid)]
172+
141173
@property
142174
def isOpen(self):
143175
if self.port is None:

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()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import env
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from hardwarelibrary.communication.serialport import SerialPort
6+
7+
8+
class FakePort:
9+
"""Minimal stand-in for pyserial's ListPortInfo."""
10+
11+
def __init__(self, device, vid, pid=0x0001, serial_number="SN"):
12+
self.device = device
13+
self.vid = vid
14+
self.pid = pid
15+
self.serial_number = serial_number
16+
17+
18+
class TestIsGenericSerialConverter(unittest.TestCase):
19+
def testKnownVendorsAreConverters(self):
20+
for idVendor in (0x0403, 0x067b, 0x10c4, 0x1a86):
21+
self.assertTrue(SerialPort.isGenericSerialConverter(idVendor))
22+
23+
def testUnknownVendorIsNotConverter(self):
24+
self.assertFalse(SerialPort.isGenericSerialConverter(0x0483)) # STM32 CDC
25+
26+
def testNoneVendorIsNotConverter(self):
27+
self.assertFalse(SerialPort.isGenericSerialConverter(None))
28+
29+
30+
class TestGenericSerialConverterPorts(unittest.TestCase):
31+
def testFiltersToConvertersOnly(self):
32+
fakePorts = [
33+
FakePort("/dev/cu.usbserial-FTDI", 0x0403, 0x6001, "FTFDLOTS"),
34+
FakePort("/dev/cu.usbserial-CP210", 0x10c4, 0xea60, "CP01"),
35+
FakePort("/dev/cu.usbmodem-STM32", 0x0483, 0x5740, "STM"),
36+
FakePort("/dev/cu.Bluetooth", None, None, None),
37+
]
38+
with patch("hardwarelibrary.communication.serialport.comports", return_value=fakePorts):
39+
found = SerialPort.genericSerialConverterPorts()
40+
41+
devices = [port.device for port in found]
42+
self.assertEqual(devices, ["/dev/cu.usbserial-FTDI", "/dev/cu.usbserial-CP210"])
43+
44+
def testReturnsEmptyWhenNoConverters(self):
45+
fakePorts = [FakePort("/dev/cu.usbmodem-STM32", 0x0483)]
46+
with patch("hardwarelibrary.communication.serialport.comports", return_value=fakePorts):
47+
self.assertEqual(SerialPort.genericSerialConverterPorts(), [])
48+
49+
50+
if __name__ == "__main__":
51+
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)