Files
core/tests/components/usb/test_consumers.py
T
2026-08-21 16:30:11 -04:00

587 lines
19 KiB
Python

"""Tests for serial port consumer attribution."""
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.components.usb import DOMAIN
from homeassistant.components.usb.models import SerialDevice, USBDevice
from homeassistant.components.usb.utils import usb_service_info_from_device
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_USB,
SOURCE_USER,
ConfigEntryDisabler,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.setup import async_setup_component
from . import patch_scanned_serial_ports
from tests.common import (
MockConfigEntry,
MockModule,
mock_config_flow,
mock_integration,
mock_platform,
)
from tests.typing import WebSocketGenerator
TTY_USB0 = "/dev/ttyUSB0"
TTY_USB0_BY_ID = "/dev/serial/by-id/usb-Silicon_Labs_CP2102-if00-port0"
TTY_USB1 = "/dev/ttyUSB1"
ESPHOME_PORT = "esphome-hass://01JZ/uart0"
USB0_PORT = USBDevice(
device=TTY_USB0,
vid="10C4",
pid="EA60",
serial_number="001234",
manufacturer="Silicon Labs",
description="CP2102 USB to UART",
)
@pytest.fixture(name="setup_ports")
async def setup_ports_fixture(
hass: HomeAssistant, force_usb_polling_watcher: None
) -> AsyncGenerator[None]:
"""Set up the USB integration with a local and a remote serial port."""
with (
patch("homeassistant.components.usb.async_get_usb", return_value=[]),
patch_scanned_serial_ports(
return_value=[
USB0_PORT,
SerialDevice(
device=ESPHOME_PORT,
serial_number="01JZ-uart0",
manufacturer="ESPHome",
description="Serial proxy",
),
]
),
):
assert await async_setup_component(hass, DOMAIN, {"usb": {}})
await hass.async_block_till_done()
yield
async def _async_get_serial_ports(
hass_ws_client: WebSocketGenerator, hass: HomeAssistant
) -> list[dict[str, Any]]:
"""Return the result of the `usb/list_serial_ports` command with usage."""
ws_client = await hass_ws_client(hass)
await ws_client.send_json(
{"id": 1, "type": "usb/list_serial_ports", "include_usage": True}
)
response = await ws_client.receive_json()
assert response["success"]
return response["result"]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
("data", "options"),
[
pytest.param({"device": TTY_USB0}, {}, id="device"),
pytest.param({"device": {"path": TTY_USB0}}, {}, id="nested_device_path"),
pytest.param({"port": TTY_USB0}, {}, id="port"),
pytest.param({"usb_path": TTY_USB0}, {}, id="usb_path"),
pytest.param({}, {"usb_path": TTY_USB0}, id="usb_path_in_options"),
pytest.param({"serial_port": TTY_USB0}, {}, id="serial_port"),
pytest.param({"device": TTY_USB0_BY_ID}, {}, id="by_id_symlink"),
pytest.param({"device": TTY_USB0}, {"device": TTY_USB0}, id="data_and_options"),
],
)
async def test_config_entry_consumers(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test detecting serial ports configured in config entries."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
entry = MockConfigEntry(
domain="test_usb", title="Test USB", data=data, options=options
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.usb.consumers.os.path.realpath",
side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path,
):
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], port["consumers"]) for port in result] == [
(
TTY_USB0,
[
{
"kind": "config_entry",
"title": "Test USB",
"active": False,
"domain": "test_usb",
"config_entry_id": entry.entry_id,
"slug": None,
}
],
),
(ESPHOME_PORT, []),
]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
"device",
[
pytest.param(f"serial://{TTY_USB0}", id="serial_url"),
pytest.param(f"serial://{TTY_USB0}:4800", id="serial_url_with_baud"),
pytest.param(f"device://{TTY_USB0}:4800", id="device_url_with_baud"),
pytest.param(f"{TTY_USB0}:4800", id="bare_path_with_baud"),
],
)
async def test_config_entry_upb_url(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device: str,
) -> None:
"""Test upb's URL forms with an optional baud rate suffix."""
mock_integration(hass, MockModule("upb", dependencies=["usb"]))
MockConfigEntry(domain="upb", title="UPB", data={"device": device}).add_to_hass(
hass
)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], len(port["consumers"])) for port in result] == [
(TTY_USB0, 1),
(ESPHOME_PORT, 0),
]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
("state", "active"),
[
pytest.param(ConfigEntryState.LOADED, True, id="loaded"),
pytest.param(ConfigEntryState.SETUP_RETRY, True, id="setup_retry"),
pytest.param(ConfigEntryState.SETUP_IN_PROGRESS, True, id="setup_in_progress"),
pytest.param(
ConfigEntryState.UNLOAD_IN_PROGRESS, True, id="unload_in_progress"
),
pytest.param(ConfigEntryState.FAILED_UNLOAD, True, id="failed_unload"),
pytest.param(ConfigEntryState.NOT_LOADED, False, id="not_loaded"),
pytest.param(ConfigEntryState.SETUP_ERROR, False, id="setup_error"),
pytest.param(ConfigEntryState.MIGRATION_ERROR, False, id="migration_error"),
],
)
async def test_config_entry_active_states(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
state: ConfigEntryState,
active: bool,
) -> None:
"""Test which config entry states mark the consumer as active."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
MockConfigEntry(
domain="test_usb", title="Test USB", data={"device": TTY_USB0}, state=state
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [consumer["active"] for consumer in result[0]["consumers"]] == [active]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
"data",
[
pytest.param({"port": 8080}, id="tcp_port"),
pytest.param({"port": "192.0.2.1:1234"}, id="host_and_port"),
pytest.param({"device": {"other": TTY_USB0}}, id="unknown_nested_key"),
pytest.param({"other": TTY_USB0}, id="unknown_key"),
],
)
async def test_config_entry_non_serial_values(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
data: dict[str, Any],
) -> None:
"""Test values that do not refer to a serial port are ignored."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
MockConfigEntry(domain="test_usb", title="Test USB", data=data).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [port["consumers"] for port in result] == [[], []]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
("source", "disabled_by", "num_consumers"),
[
pytest.param(SOURCE_IGNORE, None, 0, id="ignored_entry_hidden"),
pytest.param(
SOURCE_USER, ConfigEntryDisabler.USER, 1, id="disabled_entry_shown"
),
],
)
async def test_config_entry_ignored_and_disabled(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
source: str,
disabled_by: ConfigEntryDisabler | None,
num_consumers: int,
) -> None:
"""Test ignored entries are hidden while disabled entries are shown."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
MockConfigEntry(
domain="test_usb",
title="Test USB",
data={"device": TTY_USB0},
source=source,
disabled_by=disabled_by,
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [len(port["consumers"]) for port in result] == [num_consumers, 0]
@pytest.mark.usefixtures("setup_ports")
async def test_socket_path_psk_not_exposed(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the noise PSK in zwave_js's esphome socket path is stripped."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
MockConfigEntry(
domain="test_usb",
title="Test USB",
data={"socket_path": "esphome://192.0.2.5:6053/?key=secret-psk"},
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [
(port["device"], port["present"], len(port["consumers"])) for port in result
] == [
(TTY_USB0, True, 0),
(ESPHOME_PORT, True, 0),
("esphome://192.0.2.5:6053/", True, 1),
]
assert "secret-psk" not in str(result)
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
("domain", "data"),
[
pytest.param("alarmdecoder", {"device_path": TTY_USB0}, id="alarmdecoder"),
pytest.param("bryant_evolution", {"filename": TTY_USB0}, id="bryant_evolution"),
pytest.param("elkm1", {"host": f"serial://{TTY_USB0}:115200"}, id="elkm1"),
pytest.param("mysensors", {"device": TTY_USB0}, id="mysensors"),
],
)
async def test_non_usb_serial_domains(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
domain: str,
data: dict[str, Any],
) -> None:
"""Test integrations holding a serial port without a `usb` dependency."""
mock_integration(hass, MockModule(domain))
MockConfigEntry(domain=domain, title="Test", data=data).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [len(port["consumers"]) for port in result] == [1, 0]
@pytest.mark.usefixtures("setup_ports")
async def test_config_entry_unknown_integration(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test config entries of integrations that fail to resolve are ignored."""
MockConfigEntry(
domain="removed_custom_component", title="Test", data={"device": TTY_USB0}
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [port["consumers"] for port in result] == [[], []]
@pytest.mark.usefixtures("setup_ports")
async def test_config_entry_without_usb_dependency(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test config entries of integrations not depending on `usb` are ignored."""
mock_integration(hass, MockModule("test_no_usb"))
MockConfigEntry(
domain="test_no_usb", title="Test", data={"device": TTY_USB0}
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [port["consumers"] for port in result] == [[], []]
@pytest.mark.usefixtures("setup_ports")
async def test_config_entry_after_dependency(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test config entries of integrations depending on `usb` after setup."""
mock_integration(
hass,
MockModule("test_after_usb", partial_manifest={"after_dependencies": ["usb"]}),
)
MockConfigEntry(
domain="test_after_usb", title="Test", data={"device": TTY_USB0}
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [len(port["consumers"]) for port in result] == [1, 0]
@pytest.mark.usefixtures("setup_ports")
async def test_remote_port_consumer(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test a config entry using a remote serial port."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
MockConfigEntry(
domain="test_usb", title="Test USB", data={"device": ESPHOME_PORT}
).add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], len(port["consumers"])) for port in result] == [
(TTY_USB0, 0),
(ESPHOME_PORT, 1),
]
@pytest.mark.usefixtures("setup_ports")
@pytest.mark.parametrize(
("device", "present"),
[
pytest.param(TTY_USB1, False, id="local"),
pytest.param("esphome-hass://02AB/uart0", False, id="esphome_proxy"),
pytest.param("esphome://ttl-to-serial.local/uart1", True, id="esphome"),
pytest.param("socket://192.0.2.1:1234", True, id="socket"),
pytest.param("tcp://192.0.2.1:1234", True, id="tcp"),
pytest.param("rfc2217://192.0.2.1:1234", True, id="rfc2217"),
],
)
async def test_configured_port_not_scanned(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device: str,
present: bool,
) -> None:
"""Test a configured port that is not in the scan.
Scannable ports are absent, unscannable URLs are assumed present.
"""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
entry = MockConfigEntry(
domain="test_usb", title="Test USB", data={"device": device}
)
entry.add_to_hass(hass)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], port["present"]) for port in result] == [
(TTY_USB0, True),
(ESPHOME_PORT, True),
(device, present),
]
assert result[2] == {
"device": device,
"resolved_device": None,
"serial_number": None,
"manufacturer": None,
"description": None,
"interface_description": None,
"interface_num": None,
"matching_integrations": [],
"present": present,
"discovery_flows": [],
"consumers": [
{
"kind": "config_entry",
"title": "Test USB",
"active": False,
"domain": "test_usb",
"config_entry_id": entry.entry_id,
"slug": None,
}
],
}
@pytest.mark.usefixtures("setup_ports")
async def test_app_consumers(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test detecting serial ports mapped into apps."""
apps_info = {
"core_zwave_js": {
"name": "Z-Wave JS",
"state": "started",
"devices": [TTY_USB0_BY_ID, "/dev/dri/card0"],
},
"some_app": {
"name": "Some App",
"state": "stopped",
"devices": [TTY_USB1],
},
"uninstalled_app": None,
}
with (
patch("homeassistant.components.usb.consumers.is_hassio", return_value=True),
patch(
"homeassistant.components.usb.consumers.get_addons_info",
return_value=apps_info,
),
patch(
"homeassistant.components.usb.consumers.os.path.realpath",
side_effect=lambda path: TTY_USB0 if path == TTY_USB0_BY_ID else path,
),
):
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], port["consumers"]) for port in result] == [
(
TTY_USB0,
[
{
"kind": "app",
"title": "Z-Wave JS",
"active": True,
"domain": None,
"config_entry_id": None,
"slug": "core_zwave_js",
}
],
),
(ESPHOME_PORT, []),
]
@pytest.mark.usefixtures("setup_ports")
async def test_app_consumers_without_supervisor(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test apps are not considered without a supervisor."""
with patch(
"homeassistant.components.usb.consumers.get_addons_info"
) as mock_apps_info:
result = await _async_get_serial_ports(hass_ws_client, hass)
assert len(mock_apps_info.mock_calls) == 0
assert [port["consumers"] for port in result] == [[], []]
@pytest.mark.usefixtures("setup_ports")
async def test_app_consumers_supervisor_not_ready(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test apps are not considered when the supervisor is not ready yet."""
with (
patch("homeassistant.components.usb.consumers.is_hassio", return_value=True),
patch(
"homeassistant.components.usb.consumers.get_addons_info",
side_effect=HassioNotReadyError("Not ready"),
),
):
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [port["consumers"] for port in result] == [[], []]
@pytest.mark.usefixtures("setup_ports")
async def test_multiple_consumers(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test a port used by both an integration and an app."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
entry = MockConfigEntry(
domain="test_usb", title="Test USB", data={"device": TTY_USB0}
)
entry.add_to_hass(hass)
apps_info = {
"some_app": {"name": "Some App", "state": "started", "devices": [TTY_USB0]}
}
with (
patch("homeassistant.components.usb.consumers.is_hassio", return_value=True),
patch(
"homeassistant.components.usb.consumers.get_addons_info",
return_value=apps_info,
),
):
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [
(consumer["kind"], consumer["title"]) for consumer in result[0]["consumers"]
] == [("config_entry", "Test USB"), ("app", "Some App")]
class MockUsbFlow(ConfigFlow):
"""Config flow that keeps USB discoveries in progress."""
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Show a form so the discovery flow stays in progress."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show a form so the discovery flow stays in progress."""
return self.async_show_form(step_id="confirm")
@pytest.mark.usefixtures("setup_ports")
async def test_discovery_flows(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that in-progress discovery flows are listed for their serial port."""
mock_integration(hass, MockModule("test_usb", dependencies=["usb"]))
mock_platform(hass, "test_usb.config_flow", None)
with mock_config_flow("test_usb", MockUsbFlow):
flow = await hass.config_entries.flow.async_init(
"test_usb",
context={"source": SOURCE_USB},
data=usb_service_info_from_device(USB0_PORT),
)
result = await _async_get_serial_ports(hass_ws_client, hass)
assert [(port["device"], port["discovery_flows"]) for port in result] == [
(TTY_USB0, [{"flow_id": flow["flow_id"], "domain": "test_usb"}]),
(ESPHOME_PORT, []),
]