Skip to content

Commit 2f10fdd

Browse files
dccoteclaude
andcommitted
Add SerialPort.genericSerialConverterPorts to find USB/RS-232 adaptors
Problem: Instruments behind a generic RS-232/USB converter (FTDI, Prolific, Silicon Labs CP210x, WCH CH34x) inherit the bridge's anonymous VID/PID and have no identity of their own. SerialPort could match a known VID/PID/serial (matchPorts) but had no way to list the generic-converter ports so a caller could discover the candidate adaptors. Solution: add genericSerialConverterVendors (VID -> chip name) plus two classmethods: isGenericSerialConverter(idVendor) and genericSerialConverterPorts(), which returns the connected ports whose vendor is a known converter chip as pyserial ListPortInfo objects (open via .device, disambiguate via .serial_number). Adds testSerialPortConverters.py, a hardware-free test that mocks comports(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1cf98a commit 2f10fdd

2 files changed

Lines changed: 84 additions & 1 deletion

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:
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()

0 commit comments

Comments
 (0)