Skip to content
Open
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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ jobs:
- {ref: "2026.7.4", api: "1.14"}
- {ref: "2026.8.2", api: "1.15"}
- {ref: "2026.9.0", api: "1.16"}
- {ref: "dcb943ded39dc1a7ec470dbfa7f461fc5041ab65", api: "1.19"}
- {ref: "2026.10.0", api: "1.19"}
- {ref: "29875a9e03051dab71fdf326681fa8656e410781", api: "1.20"}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -410,7 +411,8 @@ jobs:
- {ref: "2026.7.4", api: "1.14"}
- {ref: "2026.8.2", api: "1.15"}
- {ref: "2026.9.0", api: "1.16"}
- {ref: "dcb943ded39dc1a7ec470dbfa7f461fc5041ab65", api: "1.19"}
- {ref: "2026.10.0", api: "1.19"}
- {ref: "29875a9e03051dab71fdf326681fa8656e410781", api: "1.20"}
name: Run ESPHome tests (${{ matrix.esphome.ref }}, API ${{ matrix.esphome.api }})
steps:
- name: Check out code from GitHub
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repository = "https://github.com/puddly/serialx"
documentation = "https://puddly.github.io/serialx/"

[project.optional-dependencies]
esphome = ["aioesphomeapi>=46.21.0; python_version >= '3.11'"]
esphome = ["aioesphomeapi>=46.26.0; python_version >= '3.11'"]

[dependency-groups]
dev = [
Expand All @@ -49,7 +49,7 @@ dev = [
"types-pywin32>=311.0.0.20260508",
"types-setuptools>=82.0.0.20260508",
"setuptools-rust>=1.7.0",
"aioesphomeapi>=46.21.0 ; python_version >= '3.11'",
"aioesphomeapi>=46.26.0 ; python_version >= '3.11'",
]
docs = [
"sphinx>=7,<8.2.3; python_version < '3.11'",
Expand Down
2 changes: 2 additions & 0 deletions serialx/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ class AllConnectKwargs(_CommonConnectKwargs, total=False):
port_usb_pid: int | None
port_usb_bcd_device: int | None
port_usb_interface_num: int | None
port_usb_interface_description: str | None
mode: str
key: str | None
password: str | None
Expand All @@ -355,6 +356,7 @@ class AllConnectKwargs(_CommonConnectKwargs, total=False):
"port_usb_pid",
"port_usb_bcd_device",
"port_usb_interface_num",
"port_usb_interface_description",
"mode",
"key",
"password",
Expand Down
27 changes: 17 additions & 10 deletions serialx/platforms/serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,7 @@ class SerialProxyModeName(str, Enum):
}


def identity_port_info(
identity: SerialProxyIdentity, interface_description: str
) -> SerialPortInfo:
def identity_port_info(identity: SerialProxyIdentity) -> SerialPortInfo:
"""Describe the device behind a port from its identity, with no URL yet."""
# Any source can carry USB metadata, but a zero ID means it carries none
has_usb = bool(identity.usb.vendor_id and identity.usb.product_id)
Expand All @@ -166,7 +164,7 @@ def identity_port_info(
manufacturer=identity.manufacturer or None,
product=identity.product or None,
bcd_device=identity.usb.bcd_device if has_usb else None,
interface_description=interface_description,
interface_description=identity.usb.interface_description or None,
interface_num=identity.usb.interface_number if has_usb else None,
# A vendor-specific interface is bound through a chip driver, which numbers its
# ports; a class interface (CDC ACM) is bound by the class driver itself
Expand All @@ -187,8 +185,9 @@ def identity_port_info(
"port_usb_pid": attrgetter("usb.product_id"),
"port_usb_bcd_device": attrgetter("usb.bcd_device"),
"port_usb_interface_num": attrgetter("usb.interface_number"),
"port_usb_interface_description": attrgetter("usb.interface_description"),
"port_udev_id": lambda identity: udev_serial_by_id_stem(
identity_port_info(identity, interface_description="")
identity_port_info(identity)
),
}

Expand All @@ -198,6 +197,11 @@ def identity_port_info(
name for name in PORT_MATCHERS if name.startswith("port_usb_")
)

# Read from a URL as Python integer literals; every other matcher is a string
INT_PORT_MATCHERS = frozenset(
{"port_usb_vid", "port_usb_pid", "port_usb_bcd_device", "port_usb_interface_num"}
)


def parse_serial_proxy_mode(value: SerialProxyModeName | str) -> SerialProxyModeName:
"""Parse a serial proxy mode name, rejecting unknown values."""
Expand Down Expand Up @@ -292,6 +296,7 @@ def __init__(
port_usb_pid: int | None = None,
port_usb_bcd_device: int | None = None,
port_usb_interface_num: int | None = None,
port_usb_interface_description: str | None = None,
port_udev_id: str | None = None,
key: str | None = None,
password: str | None = None,
Expand Down Expand Up @@ -326,6 +331,9 @@ def __init__(
port_usb_bcd_device: USB device release number the device behind the port
must report.
port_usb_interface_num: USB interface number the port must be bound to.
port_usb_interface_description: USB interface string (iInterface) of the
interface the port is bound to, as the device reports it. A device
without one reports an empty string.
port_udev_id: The `/dev/serial/by-id/` link name udev would give the
device behind the port, without the directory or any `-portN` suffix.
See `udev_serial_by_id_stem`.
Expand Down Expand Up @@ -378,6 +386,7 @@ def __init__(
"port_usb_pid": port_usb_pid,
"port_usb_bcd_device": port_usb_bcd_device,
"port_usb_interface_num": port_usb_interface_num,
"port_usb_interface_description": port_usb_interface_description,
"port_udev_id": port_udev_id,
}.items()
if value is not None
Expand Down Expand Up @@ -608,7 +617,7 @@ async def _async_open(self) -> None:

value = params[name][0]
self._port_matchers[name] = (
int(value, 0) if name in USB_PORT_MATCHERS else value
int(value, 0) if name in INT_PORT_MATCHERS else value
)

# Only connect if the API was not passed in externally
Expand Down Expand Up @@ -710,7 +719,7 @@ async def _async_list_serial_ports(self) -> list[SerialPortInfo]:
manufacturer=None,
product=None,
bcd_device=None,
interface_description=proxy.name,
interface_description=None,
interface_num=None,
)

Expand All @@ -729,9 +738,7 @@ async def _async_list_serial_ports(self) -> list[SerialPortInfo]:
)

if usable:
info = identity_port_info(
identity, interface_description=proxy.name
)
info = identity_port_info(identity)

# Opening a listed port only succeeds with the same device still attached.
# A device with a unique serial number is found wherever it is plugged in,
Expand Down
23 changes: 13 additions & 10 deletions tests/test_serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ def _expected_esphome_ports(netloc: str) -> list[SerialPortInfo]:
manufacturer=None,
product=None,
bcd_device=None,
interface_description="Serial Proxy Left",
interface_description=None,
interface_num=None,
),
SerialPortInfo(
Expand All @@ -552,7 +552,7 @@ def _expected_esphome_ports(netloc: str) -> list[SerialPortInfo]:
manufacturer=None,
product=None,
bcd_device=None,
interface_description="Serial Proxy Right",
interface_description=None,
interface_num=None,
),
]
Expand Down Expand Up @@ -1110,6 +1110,7 @@ def _identity(**overrides: object) -> SerialProxyIdentity:
bcd_device=0x0101,
interface_number=0,
interface_class=0x02,
interface_description="Nabu Casa ZBT-2",
),
}
fields.update(overrides)
Expand Down Expand Up @@ -1145,7 +1146,7 @@ def test_identity_udev_id_follows_interface_class(
port_number=port_number,
)
)
port = identity_port_info(identity, interface_description="Zigbee")
port = identity_port_info(identity)

assert port.port_number == (port_number if interface_class == 0xFF else None)
assert udev_serial_by_id_stem(port) == udev_id
Expand Down Expand Up @@ -1280,6 +1281,7 @@ async def test_all_port_matchers_allow_connection() -> None:
"port_usb_pid": "0x4001",
"port_usb_bcd_device": "0x0101",
"port_usb_interface_num": "0",
"port_usb_interface_description": "Nabu Casa ZBT-2",
"port_udev_id": "usb-Nabu_Casa_ZBT-2_AABBCCDDEEFF-if00",
}
)
Expand All @@ -1303,6 +1305,7 @@ async def test_all_port_matchers_allow_connection() -> None:
("port_usb_pid", 0xEA60),
("port_usb_bcd_device", 0x0100),
("port_usb_interface_num", 1),
("port_usb_interface_description", "Thread"),
("port_udev_id", "usb-Nabu_Casa_ZBT-2_AABBCCDDEEFF-if00-port0"),
],
)
Expand Down Expand Up @@ -1422,7 +1425,7 @@ async def test_list_serial_ports_reports_identity() -> None:
manufacturer="Nabu Casa",
product="ZBT-2",
bcd_device=0x0101,
interface_description="USB",
interface_description="Nabu Casa ZBT-2",
interface_num=0,
),
SerialPortInfo(
Expand All @@ -1434,14 +1437,14 @@ async def test_list_serial_ports_reports_identity() -> None:
manufacturer="Nabu Casa",
product="ZBT-2",
bcd_device=None,
interface_description="Configured",
interface_description=None,
interface_num=None,
),
*(
SerialPortInfo(
device=f"{base}{name}",
resolved_device=f"{base}{name}",
interface_description=name,
interface_description=None,
**empty,
)
for name in ("Empty", "Unreadable", "None")
Expand All @@ -1455,7 +1458,7 @@ async def test_list_serial_ports_reports_identity() -> None:
manufacturer="Nabu Casa",
product="ZBT-2",
bcd_device=0x0101,
interface_description="Mimic",
interface_description="Nabu Casa ZBT-2",
interface_num=0,
),
SerialPortInfo(
Expand All @@ -1467,7 +1470,7 @@ async def test_list_serial_ports_reports_identity() -> None:
manufacturer="Nabu Casa",
product="ZBT-2",
bcd_device=None,
interface_description="Partial",
interface_description=None,
interface_num=None,
),
# A serial number shared by many devices is also tied to the port it is on
Expand All @@ -1480,7 +1483,7 @@ async def test_list_serial_ports_reports_identity() -> None:
manufacturer="Nabu Casa",
product="ZBT-2",
bcd_device=0x0101,
interface_description="Clone",
interface_description="Nabu Casa ZBT-2",
interface_num=0,
),
]
Expand Down Expand Up @@ -1646,7 +1649,7 @@ async def test_list_serial_ports_skips_identity_on_old_api() -> None:
manufacturer=None,
product=None,
bcd_device=None,
interface_description="Zigbee",
interface_description=None,
interface_num=None,
)
]
Expand Down
Loading
Loading