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") +``` 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 8c653b4..a0b6e69 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 @@ -1530,6 +1531,83 @@ 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( + # 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", + # 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", + # Z-Wave.Me UZB1 firmware + "12345678-9012-3456-7890-123456789012", + # 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", + # WQ9201S and WQ9101 Wi-Fi modules + "123456789", + # Generic placeholders seen across unrelated USB devices + "000000000001", + "1234567890", + "000000000000000000000000", + } +) + + +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.""" diff --git a/serialx/platforms/serial_esphome.py b/serialx/platforms/serial_esphome.py index cb7fda5..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, ) @@ -708,10 +709,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 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 436f79f..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, @@ -401,6 +402,23 @@ def test_serial_port_info_description(port: SerialPortInfo, expected: str) -> No assert port.description == expected +@pytest.mark.parametrize( + ("serial_number", "expected"), + [ + 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_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)) def test_list_serial_ports_all_platforms(platform: Platform) -> None: """Sync listing returns a list of `SerialPortInfo` for every platform.""" 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) ]