Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
2 changes: 2 additions & 0 deletions serialx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
UnsupportedSetting,
async_list_serial_ports,
get_serial_classes,
is_non_unique_serial,
list_serial_ports,
redact_uri,
register_uri_handler,
Expand Down Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions serialx/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from enum import Enum
import functools
import io
import itertools
import logging
import os.path
from pathlib import Path
Expand Down Expand Up @@ -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."""
Expand Down
7 changes: 4 additions & 3 deletions serialx/platforms/serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
PortSettingsUpdate,
SerialPortInfo,
StopBits,
is_non_unique_serial,
redact_uri,
register_uri_handler,
)
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Platform,
async_list_serial_ports,
get_serial_classes,
is_non_unique_serial,
list_serial_ports,
redact_uri,
register_uri_handler,
Expand Down Expand Up @@ -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."""
Expand Down
18 changes: 16 additions & 2 deletions tests/test_serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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]),
Expand Down Expand Up @@ -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)
]


Expand Down
Loading