Skip to content

Commit 9d57251

Browse files
puddlybramkragten
authored andcommitted
Fix non-unique ZHA serial port paths and migrate USB integration to always list unique paths (#155019)
1 parent f877614 commit 9d57251

File tree

6 files changed

+392
-123
lines changed

6 files changed

+392
-123
lines changed

homeassistant/components/usb/utils.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Sequence
6+
import dataclasses
67
import fnmatch
78
import os
89

@@ -29,15 +30,6 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice:
2930

3031
def scan_serial_ports() -> Sequence[USBDevice]:
3132
"""Scan serial ports for USB devices."""
32-
return [
33-
usb_device_from_port(port)
34-
for port in comports()
35-
if port.vid is not None or port.pid is not None
36-
]
37-
38-
39-
def usb_device_from_path(device_path: str) -> USBDevice | None:
40-
"""Get USB device info from a device path."""
4133

4234
# Scan all symlinks first
4335
by_id = "/dev/serial/by-id"
@@ -46,23 +38,30 @@ def usb_device_from_path(device_path: str) -> USBDevice | None:
4638
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
4739
realpath_to_by_id[os.path.realpath(path)] = path
4840

49-
# Then compare the actual path to each serial port's
41+
serial_ports = []
42+
43+
for port in comports():
44+
if port.vid is not None or port.pid is not None:
45+
usb_device = usb_device_from_port(port)
46+
device_path = realpath_to_by_id.get(port.device, port.device)
47+
48+
if device_path != port.device:
49+
# Prefer the unique /dev/serial/by-id/ path if it exists
50+
usb_device = dataclasses.replace(usb_device, device=device_path)
51+
52+
serial_ports.append(usb_device)
53+
54+
return serial_ports
55+
56+
57+
def usb_device_from_path(device_path: str) -> USBDevice | None:
58+
"""Get USB device info from a device path."""
59+
5060
device_path_real = os.path.realpath(device_path)
5161

5262
for device in scan_serial_ports():
53-
normalized_path = realpath_to_by_id.get(device.device, device.device)
54-
if (
55-
normalized_path == device_path
56-
or os.path.realpath(device.device) == device_path_real
57-
):
58-
return USBDevice(
59-
device=normalized_path,
60-
vid=device.vid,
61-
pid=device.pid,
62-
serial_number=device.serial_number,
63-
manufacturer=device.manufacturer,
64-
description=device.description,
65-
)
63+
if os.path.realpath(device.device) == device_path_real:
64+
return device
6665

6766
return None
6867

homeassistant/components/zha/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
async_notify_firmware_info,
1818
async_register_firmware_info_provider,
1919
)
20+
from homeassistant.components.usb import usb_device_from_path
2021
from homeassistant.config_entries import ConfigEntry
2122
from homeassistant.const import (
2223
CONF_TYPE,
@@ -134,6 +135,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
134135
135136
Will automatically load components to support devices found on the network.
136137
"""
138+
139+
# Try to perform an in-place migration if we detect that the device path can be made
140+
# unique
141+
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
142+
usb_device = await hass.async_add_executor_job(usb_device_from_path, device_path)
143+
144+
if usb_device is not None and device_path != usb_device.device:
145+
_LOGGER.info(
146+
"Migrating ZHA device path from %s to %s", device_path, usb_device.device
147+
)
148+
new_data = {**config_entry.data}
149+
new_data[CONF_DEVICE][CONF_DEVICE_PATH] = usb_device.device
150+
hass.config_entries.async_update_entry(config_entry, data=new_data)
151+
device_path = usb_device.device
152+
137153
ha_zha_data: HAZHAData = get_zha_data(hass)
138154
ha_zha_data.config_entry = config_entry
139155
zha_lib_data: ZHAData = create_zha_config(hass, ha_zha_data)
@@ -163,7 +179,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
163179
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
164180

165181
# Check if firmware update is in progress for this device
166-
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
167182
_raise_if_port_in_use(hass, device_path)
168183

169184
try:

homeassistant/components/zha/config_flow.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
from contextlib import suppress
88
from enum import StrEnum
99
import json
10+
import os
1011
from typing import Any
1112

12-
import serial.tools.list_ports
13-
from serial.tools.list_ports_common import ListPortInfo
1413
import voluptuous as vol
1514
from zha.application.const import RadioType
1615
import zigpy.backups
@@ -25,6 +24,7 @@
2524
ZigbeeFlowStrategy,
2625
)
2726
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
27+
from homeassistant.components.usb import USBDevice, scan_serial_ports
2828
from homeassistant.config_entries import (
2929
SOURCE_IGNORE,
3030
SOURCE_ZEROCONF,
@@ -124,10 +124,10 @@ def _format_backup_choice(
124124
return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})"
125125

126126

127-
async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
127+
async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
128128
"""List all serial ports, including the Yellow radio and the multi-PAN addon."""
129-
ports: list[ListPortInfo] = []
130-
ports.extend(await hass.async_add_executor_job(serial.tools.list_ports.comports))
129+
ports: list[USBDevice] = []
130+
ports.extend(await hass.async_add_executor_job(scan_serial_ports))
131131

132132
# Add useful info to the Yellow's serial port selection screen
133133
try:
@@ -137,9 +137,14 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
137137
else:
138138
# PySerial does not properly handle the Yellow's serial port with the CM5
139139
# so we manually include it
140-
port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True)
141-
port.description = "Yellow Zigbee module"
142-
port.manufacturer = "Nabu Casa"
140+
port = USBDevice(
141+
device="/dev/ttyAMA1",
142+
vid="ffff", # This is technically not a USB device
143+
pid="ffff",
144+
serial_number=None,
145+
manufacturer="Nabu Casa",
146+
description="Yellow Zigbee module",
147+
)
143148

144149
ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")]
145150
ports.insert(0, port)
@@ -156,13 +161,15 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
156161
addon_info = None
157162

158163
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
159-
addon_port = ListPortInfo(
164+
addon_port = USBDevice(
160165
device=silabs_multiprotocol_addon.get_zigbee_socket(),
161-
skip_link_detection=True,
166+
vid="ffff", # This is technically not a USB device
167+
pid="ffff",
168+
serial_number=None,
169+
manufacturer="Nabu Casa",
170+
description="Silicon Labs Multiprotocol add-on",
162171
)
163172

164-
addon_port.description = "Multiprotocol add-on"
165-
addon_port.manufacturer = "Nabu Casa"
166173
ports.append(addon_port)
167174

168175
return ports
@@ -218,8 +225,15 @@ async def async_step_choose_serial_port(
218225
) -> ConfigFlowResult:
219226
"""Choose a serial port."""
220227
ports = await list_serial_ports(self.hass)
228+
229+
# The full `/dev/serial/by-id/` path is too verbose to show
230+
resolved_paths = {
231+
p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device)
232+
for p in ports
233+
}
234+
221235
list_of_ports = [
222-
f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
236+
f"{resolved_paths[p.device]} - {p.description}{', s/n: ' + p.serial_number if p.serial_number else ''}"
223237
+ (f" - {p.manufacturer}" if p.manufacturer else "")
224238
for p in ports
225239
]

0 commit comments

Comments
 (0)