[usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373)

This commit is contained in:
J. Nick Koston
2026-10-09 16:38:05 -10:00
committed by GitHub
parent ba7d3bd770
commit dd45f8a6f8
2 changed files with 113 additions and 15 deletions
+39 -14
View File
@@ -1,4 +1,5 @@
from itertools import combinations
import itertools
import logging
import esphome.codegen as cg
from esphome.components.const import CONF_MANUFACTURER
@@ -22,6 +23,8 @@ import esphome.final_validate as fv
from esphome.helpers import cpp_u16string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["bytebuffer"]
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["esp32"]
@@ -71,15 +74,19 @@ def usb_device_schema(
)
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _clients_overlap(first: ConfigType, second: ConfigType) -> bool:
# Two entries overlap when no field they both constrain tells them apart
for first, second in combinations(configs, 2):
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
break
else:
for key, wildcard in _FILTER_WILDCARDS.items():
a = first.get(key)
b = second.get(key)
if wildcard not in (a, b) and a != b:
return False
return True
def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
for first, second in itertools.combinations(configs, 2):
if _clients_overlap(first, second):
raise cv.Invalid(
f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}"
)
@@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
def _final_validate(config: ConfigType) -> ConfigType:
# Every USB client on the bus, whichever component configured it: any two could
# otherwise open the same device
clients = list(config.get(CONF_DEVICES) or ())
clients.extend(fv.full_config.get().get("usb_uart") or ())
validate_usb_clients(clients)
# Any two overlapping clients could otherwise open the same device
devices = config.get(CONF_DEVICES) or []
uarts = fv.full_config.get().get("usb_uart") or []
validate_usb_clients(devices)
validate_usb_clients(uarts)
# Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call
for device, uart in itertools.product(devices, uarts):
if not _clients_overlap(device, uart):
continue
# The device matches nothing the usb_uart entry does not already claim
redundant = all(
uart.get(key) in (wildcard, device.get(key))
for key, wildcard in _FILTER_WILDCARDS.items()
)
_LOGGER.warning(
"usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. "
"This will be an error in 2027.10.0",
device[CONF_ID],
uart[CONF_ID],
"remove it from usb_host devices"
if redundant
else "narrow its filter so it no longer matches that device",
)
return config
+74 -1
View File
@@ -1,9 +1,12 @@
"""Tests for usb_host device matching validation."""
import logging
import pytest
from esphome.components.usb_host import validate_usb_clients
from esphome.components.usb_host import _final_validate, validate_usb_clients
import esphome.config_validation as cv
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -109,3 +112,73 @@ def test_every_pair_is_compared_not_just_neighbours() -> None:
]
with pytest.raises(cv.Invalid, match="a, c"):
validate_usb_clients(configs)
def _run_final_validate(
devices: list[ConfigType], uarts: list[ConfigType]
) -> ConfigType:
config = {"id": "usb_host", "devices": devices}
token = fv.full_config.set({"usb_host": config, "usb_uart": uarts})
try:
return _final_validate(config)
finally:
fv.full_config.reset(token)
def test_device_duplicating_usb_uart_warns(caplog: pytest.LogCaptureFixture) -> None:
devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}]
uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}]
with caplog.at_level(logging.WARNING):
_run_final_validate(devices, uarts)
assert "'device_0'" in caplog.text
assert "'uart_0'" in caplog.text
assert "remove it from usb_host devices" in caplog.text
def test_wildcard_device_covering_usb_uart_suggests_narrowing(
caplog: pytest.LogCaptureFixture,
) -> None:
devices = [{"id": "device_0", "vid": 0x303A, "pid": 0}]
uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}]
with caplog.at_level(logging.WARNING):
_run_final_validate(devices, uarts)
assert "narrow its filter" in caplog.text
assert "remove it" not in caplog.text
def test_disjoint_device_and_usb_uart_do_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4002}]
uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}]
with caplog.at_level(logging.WARNING):
_run_final_validate(devices, uarts)
assert caplog.text == ""
_DUPLICATE_CLIENTS = [
{"id": "a", "vid": 0x303A, "pid": 0x4001},
{"id": "b", "vid": 0x303A, "pid": 0x4001},
]
@pytest.mark.parametrize(
("devices", "uarts"),
[([*_DUPLICATE_CLIENTS], []), ([], [*_DUPLICATE_CLIENTS])],
ids=["devices", "uarts"],
)
def test_overlap_within_one_component_is_rejected(
devices: list[ConfigType], uarts: list[ConfigType]
) -> None:
with pytest.raises(cv.Invalid, match="a, b"):
_run_final_validate(devices, uarts)
def test_device_inside_broader_usb_uart_suggests_removing(
caplog: pytest.LogCaptureFixture,
) -> None:
devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}]
uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0}]
with caplog.at_level(logging.WARNING):
_run_final_validate(devices, uarts)
assert "remove it from usb_host devices" in caplog.text