From dc709b936bbb4218fedf0ab26f491ae4d41e54c6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:02:39 +0000 Subject: [PATCH 1/8] Add `is_unique` to `SerialPortInfo` --- serialx/common.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_common.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/serialx/common.py b/serialx/common.py index 8c653b4..1ac59c0 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -1530,6 +1530,47 @@ def get_serial_classes( return handler.sync_cls, handler.async_transport_cls +# USB serial numbers known to be shared by many devices +NON_UNIQUE_SERIAL_NUMBERS = frozenset( + { + # Counterfeit FT232R (0403:6001) chips have no writable EEPROM. Windows appends + # an `A` to FTDI serial numbers, so both spellings are seen. + "A50285BI", + "A50285BIA", + "A5XK3RJT", + "A5XK3RJTA", + "A5XK3RJJ", + "A5069RR4", + "FTB6SPL3", + "A601CKQK", + "A400DE3U", + "00000000", + "0000000", + # FT232RL reels sold at a discount, likely factory rejects + "DUT", + # Unprogrammed Silicon Labs CP210x default, kept by some vendors (early Sonoff + # ZBDongle-P batches, Zooz ZST10-700, SiK telemetry radios) and by CP2102 clones + "0001", + # CP2102 programmed identically by NTREX for every STELLA AHRS + "1234", + # STM32 CubeMX CDC VCP default descriptor + "00000000001A", + # ESP32-S2 ROM download mode + "0", + # Older esp_tinyusb default descriptor + "123456", + # Microchip MCP2221 with serial number enumeration enabled but unset + "0000000000", + # Raspberry Pi Pico SDK `PICO_NO_FLASH` builds, with no flash ID to read + "EEEEEEEEEEEEEEEE", + # Arduino ATmega32U4 PluggableUSB, built from the interface letters + "HIDFG", + # Android boards (HiKey960) with an unset serial, seen by adb + "0123456789ABCDEF", + } +) + + @dataclasses.dataclass class SerialPortInfo: """A serial port.""" @@ -1576,6 +1617,14 @@ def description(self) -> str: return os.path.basename(self.resolved_device) + @property + def is_unique(self) -> bool: + """Whether the serial number tells this device apart from identical ones.""" + if self.serial_number is None or not self.serial_number.strip(): + return False + + return self.serial_number not in NON_UNIQUE_SERIAL_NUMBERS + def list_serial_ports( platform: Platform | str = Platform.DEVICE, **kwargs: Any diff --git a/tests/test_common.py b/tests/test_common.py index 436f79f..9900423 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -401,6 +401,37 @@ def test_serial_port_info_description(port: SerialPortInfo, expected: str) -> No assert port.description == expected +@pytest.mark.parametrize( + ("serial_number", "expected"), + [ + pytest.param("A6008isP", True, id="unique"), + pytest.param(None, False, id="missing"), + pytest.param("", False, id="empty"), + pytest.param(" ", False, id="whitespace"), + pytest.param("A50285BI", False, id="ftdi_clone"), + pytest.param("A50285BIA", False, id="ftdi_clone_windows"), + pytest.param("0001", False, id="cp210x_default"), + ], +) +def test_serial_port_info_is_unique(serial_number: str | None, expected: bool) -> None: + """Test the `is_unique` property on `SerialPortInfo`.""" + port = SerialPortInfo( + device="/dev/ttyUSB0", + resolved_device="/dev/ttyUSB0", + vid=0x0403, + pid=0x6001, + serial_number=serial_number, + manufacturer="FTDI", + product="FT232R USB UART", + bcd_device=0x0600, + interface_description=None, + interface_num=0, + port_number=0, + ) + + assert port.is_unique is expected + + @pytest.mark.parametrize("platform", list(Platform)) def test_list_serial_ports_all_platforms(platform: Platform) -> None: """Sync listing returns a list of `SerialPortInfo` for every platform.""" From 6aa69bcdb950e69aa33575c54aabb7961dad031a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:03:34 +0000 Subject: [PATCH 2/8] Rely on `is_unique` for ESPHome serial port listing --- serialx/platforms/serial_esphome.py | 6 +++--- tests/test_serial_esphome.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/serialx/platforms/serial_esphome.py b/serialx/platforms/serial_esphome.py index cb7fda5..6cdc07e 100644 --- a/serialx/platforms/serial_esphome.py +++ b/serialx/platforms/serial_esphome.py @@ -708,10 +708,10 @@ async def _async_list_serial_ports(self) -> list[SerialPortInfo]: ) # Opening a listed port only succeeds with the same device still attached. - # A device with a serial number is found wherever it is plugged in, one - # without is only told apart from an identical one by the port it is on. + # A device with a unique serial number is found wherever it is plugged in, + # one without is only told apart from an identical one by the port it is on. query = {} - if info.serial_number is None: + if not info.is_unique: query["port_name"] = proxy.name udev_id = udev_serial_by_id_stem(info) diff --git a/tests/test_serial_esphome.py b/tests/test_serial_esphome.py index 79f5caf..45b2dae 100644 --- a/tests/test_serial_esphome.py +++ b/tests/test_serial_esphome.py @@ -1196,7 +1196,7 @@ async def test_port_usb_matcher_accepts_configured_identity() -> None: async def test_list_serial_ports_reports_identity() -> None: """Each port lists what its identity says is attached, and nothing it cannot know.""" api = mock_api_client( - "USB", "Configured", "Empty", "Unreadable", "None", "Mimic", "Partial" + "USB", "Configured", "Empty", "Unreadable", "None", "Mimic", "Partial", "Clone" ) api.api_version = APIVersion(1, 18) identities = [ @@ -1219,6 +1219,7 @@ async def test_list_serial_ports_reports_identity() -> None: serial_number="", usb=UsbDeviceDescriptor(vendor_id=0x303A), # type:ignore[call-arg] ), + _identity(instance=7, serial_number="0001"), ] api.attach_mock( AsyncMock(side_effect=lambda instance, timeout: identities[instance]), @@ -1299,9 +1300,22 @@ async def test_list_serial_ports_reports_identity() -> None: interface_description="Partial", interface_num=None, ), + # A serial number shared by many devices is also tied to the port it is on + SerialPortInfo( + device=f"{base}Clone&port_udev_id=usb-Nabu_Casa_ZBT-2_0001-if00", + resolved_device=f"{base}Clone&port_udev_id=usb-Nabu_Casa_ZBT-2_0001-if00", + vid=0x303A, + pid=0x4001, + serial_number="0001", + manufacturer="Nabu Casa", + product="ZBT-2", + bcd_device=0x0101, + interface_description="Clone", + interface_num=0, + ), ] assert api.serial_proxy_get_identity.mock_calls == [ - call(instance, timeout=10.0) for instance in range(7) + call(instance, timeout=10.0) for instance in range(8) ] From 2de457a22e6b070950d45746623c7202e8a2419d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:28:11 +0000 Subject: [PATCH 3/8] A few more variants --- serialx/common.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/serialx/common.py b/serialx/common.py index 1ac59c0..8b923ce 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -1567,6 +1567,27 @@ def get_serial_classes( "HIDFG", # Android boards (HiKey960) with an unset serial, seen by adb "0123456789ABCDEF", + # Z-Wave.Me UZB1 firmware + "12345678-9012-3456-7890-123456789012", + # TI CC25xx USB stack (CC2531, CC2540), also seen on FT232R clones + "001", + # VESC on ChibiOS, which reports the kernel version as the serial number + "301", + "304", + # Linux USB gadget scripts (Milk-V Duo, Librem 5, Zynq boards, Ubiquiti) + "0123456789", + # Some RP2040 firmwares (PicoMite, MicroPython builds) + "000000000000", + # Dangerous Prototypes PIC firmware (USB IR Toy, LCD Backpack) + "00000001", + # TivaWare CDC example, Solo motor controllers, an FT232R batch + "12345678", + # WQ9201S and WQ9101 Wi-Fi modules + "123456789", + # Generic placeholders seen across unrelated USB devices + "000000000001", + "1234567890", + "000000000000000000000000", } ) From 85012286669ec6f19320806ee320091a7e66179f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:38:57 +0000 Subject: [PATCH 4/8] Add `ZIGATE+` --- serialx/common.py | 2 ++ tests/test_list_serial_ports_linux.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/serialx/common.py b/serialx/common.py index 8b923ce..78b7903 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -1569,6 +1569,8 @@ def get_serial_classes( "0123456789ABCDEF", # Z-Wave.Me UZB1 firmware "12345678-9012-3456-7890-123456789012", + # ZiGate+ (FT230X, 0403:6015), programmed with the product name + "ZIGATE+", # TI CC25xx USB stack (CC2531, CC2540), also seen on FT232R clones "001", # VESC on ChibiOS, which reports the kernel version as the serial number diff --git a/tests/test_list_serial_ports_linux.py b/tests/test_list_serial_ports_linux.py index 31628e4..d3394e1 100644 --- a/tests/test_list_serial_ports_linux.py +++ b/tests/test_list_serial_ports_linux.py @@ -254,8 +254,9 @@ def test_replay_debian_13_kernel_7_0( interface_num=1, port_number=0, ), + # Every ZiGate+ has the same serial number SerialPortInfo( - device=str(by_id / "usb-FTDI_ZiGate_ZIGATE+-if00-port0"), + device=str(by_path / "pci-0000:00:14.0-usbv2-0:6.4:1.0-port0"), resolved_device=str(dev_root / "ttyUSB2"), vid=0x0403, pid=0x6015, From 96a71bb7df94a058d09065a1e77b30918100a4b0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:41:14 +0000 Subject: [PATCH 5/8] Revert `by-path` change --- tests/test_list_serial_ports_linux.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_list_serial_ports_linux.py b/tests/test_list_serial_ports_linux.py index d3394e1..31628e4 100644 --- a/tests/test_list_serial_ports_linux.py +++ b/tests/test_list_serial_ports_linux.py @@ -254,9 +254,8 @@ def test_replay_debian_13_kernel_7_0( interface_num=1, port_number=0, ), - # Every ZiGate+ has the same serial number SerialPortInfo( - device=str(by_path / "pci-0000:00:14.0-usbv2-0:6.4:1.0-port0"), + device=str(by_id / "usb-FTDI_ZiGate_ZIGATE+-if00-port0"), resolved_device=str(dev_root / "ttyUSB2"), vid=0x0403, pid=0x6015, From 560bc29acbc9e8072b7b53dfcda9fbab66e8dadf Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:14:20 +0000 Subject: [PATCH 6/8] Account for Windows `A` suffix for FTDI --- serialx/common.py | 50 ++++++++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/serialx/common.py b/serialx/common.py index 78b7903..4d97223 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -14,6 +14,7 @@ from enum import Enum import functools import io +import itertools import logging import os.path from pathlib import Path @@ -1532,22 +1533,33 @@ def get_serial_classes( # USB serial numbers known to be shared by many devices NON_UNIQUE_SERIAL_NUMBERS = frozenset( - { - # Counterfeit FT232R (0403:6001) chips have no writable EEPROM. Windows appends - # an `A` to FTDI serial numbers, so both spellings are seen. - "A50285BI", - "A50285BIA", - "A5XK3RJT", - "A5XK3RJTA", - "A5XK3RJJ", - "A5069RR4", - "FTB6SPL3", - "A601CKQK", - "A400DE3U", - "00000000", - "0000000", - # FT232RL reels sold at a discount, likely factory rejects - "DUT", + # Windows appends an `A` to FTDI serial numbers so both spellings are seen + set( + itertools.chain.from_iterable( + (serial + "", serial + "A") + for serial in { + # Counterfeit FT232R (0403:6001) chips have no writable EEPROM + "A50285BI", + "A5XK3RJT", + "A5XK3RJJ", + "A5069RR4", + "FTB6SPL3", + "A601CKQK", + "A400DE3U", + "00000000", + "0000000", + # FT232RL reels sold at a discount, likely factory rejects + "DUT", + # TivaWare CDC example, Solo motor controllers, an FT232R batch + "12345678", + # ZiGate+ (FT230X, 0403:6015), programmed with the product name + "ZIGATE+", + # TI CC25xx USB stack (CC2531, CC2540), also seen on FT232R clones + "001", + } + ) + ) + | { # Unprogrammed Silicon Labs CP210x default, kept by some vendors (early Sonoff # ZBDongle-P batches, Zooz ZST10-700, SiK telemetry radios) and by CP2102 clones "0001", @@ -1569,10 +1581,6 @@ def get_serial_classes( "0123456789ABCDEF", # Z-Wave.Me UZB1 firmware "12345678-9012-3456-7890-123456789012", - # ZiGate+ (FT230X, 0403:6015), programmed with the product name - "ZIGATE+", - # TI CC25xx USB stack (CC2531, CC2540), also seen on FT232R clones - "001", # VESC on ChibiOS, which reports the kernel version as the serial number "301", "304", @@ -1582,8 +1590,6 @@ def get_serial_classes( "000000000000", # Dangerous Prototypes PIC firmware (USB IR Toy, LCD Backpack) "00000001", - # TivaWare CDC example, Solo motor controllers, an FT232R batch - "12345678", # WQ9201S and WQ9101 Wi-Fi modules "123456789", # Generic placeholders seen across unrelated USB devices From 4b5b57d31dc0750b748219356759be57385a29a5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:09:35 +0000 Subject: [PATCH 7/8] Replace `is_unique` with `is_non_unique_serial` --- serialx/__init__.py | 2 ++ serialx/common.py | 16 ++++++------- serialx/platforms/serial_esphome.py | 3 ++- tests/test_common.py | 35 +++++++++-------------------- 4 files changed, 23 insertions(+), 33 deletions(-) diff --git a/serialx/__init__.py b/serialx/__init__.py index 5a10786..33b8544 100644 --- a/serialx/__init__.py +++ b/serialx/__init__.py @@ -22,6 +22,7 @@ UnsupportedSetting, async_list_serial_ports, get_serial_classes, + is_non_unique_serial, list_serial_ports, redact_uri, register_uri_handler, @@ -52,6 +53,7 @@ "async_serial_for_url", "create_serial_connection", "get_serial_classes", + "is_non_unique_serial", "list_serial_ports", "async_list_serial_ports", "open_serial_connection", diff --git a/serialx/common.py b/serialx/common.py index 4d97223..a0b6e69 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -1600,6 +1600,14 @@ def get_serial_classes( ) +def is_non_unique_serial(serial_number: str | None) -> bool: + """Whether a serial number fails to tell a device apart from identical ones.""" + if serial_number is None or not serial_number.strip(): + return True + + return serial_number in NON_UNIQUE_SERIAL_NUMBERS + + @dataclasses.dataclass class SerialPortInfo: """A serial port.""" @@ -1646,14 +1654,6 @@ def description(self) -> str: return os.path.basename(self.resolved_device) - @property - def is_unique(self) -> bool: - """Whether the serial number tells this device apart from identical ones.""" - if self.serial_number is None or not self.serial_number.strip(): - return False - - return self.serial_number not in NON_UNIQUE_SERIAL_NUMBERS - def list_serial_ports( platform: Platform | str = Platform.DEVICE, **kwargs: Any diff --git a/serialx/platforms/serial_esphome.py b/serialx/platforms/serial_esphome.py index 6cdc07e..f13c6cb 100644 --- a/serialx/platforms/serial_esphome.py +++ b/serialx/platforms/serial_esphome.py @@ -70,6 +70,7 @@ PortSettingsUpdate, SerialPortInfo, StopBits, + is_non_unique_serial, redact_uri, register_uri_handler, ) @@ -711,7 +712,7 @@ async def _async_list_serial_ports(self) -> list[SerialPortInfo]: # A device with a unique serial number is found wherever it is plugged in, # one without is only told apart from an identical one by the port it is on. query = {} - if not info.is_unique: + if is_non_unique_serial(info.serial_number): query["port_name"] = proxy.name udev_id = udev_serial_by_id_stem(info) diff --git a/tests/test_common.py b/tests/test_common.py index 9900423..6f147e8 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -15,6 +15,7 @@ Platform, async_list_serial_ports, get_serial_classes, + is_non_unique_serial, list_serial_ports, redact_uri, register_uri_handler, @@ -404,32 +405,18 @@ def test_serial_port_info_description(port: SerialPortInfo, expected: str) -> No @pytest.mark.parametrize( ("serial_number", "expected"), [ - pytest.param("A6008isP", True, id="unique"), - pytest.param(None, False, id="missing"), - pytest.param("", False, id="empty"), - pytest.param(" ", False, id="whitespace"), - pytest.param("A50285BI", False, id="ftdi_clone"), - pytest.param("A50285BIA", False, id="ftdi_clone_windows"), - pytest.param("0001", False, id="cp210x_default"), + pytest.param("A6008isP", False, id="unique"), + pytest.param(None, True, id="missing"), + pytest.param("", True, id="empty"), + pytest.param(" ", True, id="whitespace"), + pytest.param("A50285BI", True, id="ftdi_clone"), + pytest.param("A50285BIA", True, id="ftdi_clone_windows"), + pytest.param("0001", True, id="cp210x_default"), ], ) -def test_serial_port_info_is_unique(serial_number: str | None, expected: bool) -> None: - """Test the `is_unique` property on `SerialPortInfo`.""" - port = SerialPortInfo( - device="/dev/ttyUSB0", - resolved_device="/dev/ttyUSB0", - vid=0x0403, - pid=0x6001, - serial_number=serial_number, - manufacturer="FTDI", - product="FT232R USB UART", - bcd_device=0x0600, - interface_description=None, - interface_num=0, - port_number=0, - ) - - assert port.is_unique is expected +def test_is_non_unique_serial(serial_number: str | None, expected: bool) -> None: + """Test `is_non_unique_serial`.""" + assert is_non_unique_serial(serial_number) is expected @pytest.mark.parametrize("platform", list(Platform)) From 9cbd112a6524a250d14a1145806ae2bb63f96e3d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:10:36 +0000 Subject: [PATCH 8/8] Add some docs --- docs/usage.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 0b3477e..705cf27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -79,3 +79,15 @@ The async APIs expose the same method as a coroutine: await serial.reconfigure_port(baudrate=9600) await transport.reconfigure_port(baudrate=9600) ``` + +## Listing ports +`list_serial_ports` returns a `SerialPortInfo` per port. A USB serial number is +only an identifier when the vendor programmed a distinct one: clones, unprogrammed +chips, and some firmwares ship a shared default. `is_non_unique_serial` reports +whether a serial number is missing, blank, or one of these known placeholders. + +```python +for port in serialx.list_serial_ports(): + if serialx.is_non_unique_serial(port.serial_number): + print(f"{port.device} must be identified by its path") +```