diff --git a/homeassistant/components/usb/consumers.py b/homeassistant/components/usb/consumers.py index 8dd32c97dda5..e58b4e363426 100644 --- a/homeassistant/components/usb/consumers.py +++ b/homeassistant/components/usb/consumers.py @@ -1,6 +1,6 @@ """Attribution of serial ports to the integrations and apps using them.""" -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence import os import re from typing import Any @@ -156,14 +156,29 @@ async def _async_get_config_entry_consumers( return consumers +def _iter_option_device_paths(value: Any) -> Iterator[str]: + """Yield device paths configured anywhere in the options of an app.""" + if isinstance(value, str): + if value.startswith("/dev/"): + yield value + elif isinstance(value, Mapping): + for item in value.values(): + yield from _iter_option_device_paths(item) + elif isinstance(value, list): + for item in value: + yield from _iter_option_device_paths(item) + + @callback def _async_get_app_consumers( hass: HomeAssistant, ) -> dict[str, list[SerialPortConsumer]]: - """Return devices mapped into apps, either statically or through options. + """Return devices configured in the options of apps. - Supervisor resolves `device(subsystem=tty)` options into real devices, so device - paths that no longer exist are missing and non-serial devices are included. + The `devices` field of an app also lists the static devices of its manifest, + which are mapped into the container whether the app uses them or not, so only + options are evidence of a device being used. Options can refer to devices + that no longer exist or are not serial ports. """ if not is_hassio(hass): return {} @@ -179,7 +194,7 @@ def _async_get_app_consumers( if info is None: continue - for device in info["devices"]: + for device in _iter_option_device_paths(info["options"]): consumers.setdefault(device, []).append( SerialPortConsumer( kind="app", @@ -224,7 +239,7 @@ async def async_get_serial_port_consumers( consumers.setdefault(device, []).extend(path_consumers) for path, path_consumers in app_consumers.items(): - # Apps also map non-serial devices, only scanned ports are of interest + # Options can name non-serial devices, only scanned ports are of interest resolved_path = resolved[path] if resolved_path not in aliases: diff --git a/tests/components/usb/test_consumers.py b/tests/components/usb/test_consumers.py index e130bb46edcd..d00993f11834 100644 --- a/tests/components/usb/test_consumers.py +++ b/tests/components/usb/test_consumers.py @@ -439,17 +439,26 @@ async def test_app_consumers( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: - """Test detecting serial ports mapped into apps.""" + """Test detecting serial ports configured in the options of apps.""" apps_info = { "core_zwave_js": { "name": "Z-Wave JS", "state": "started", - "devices": [TTY_USB0_BY_ID, "/dev/dri/card0"], + "devices": [TTY_USB0], + "options": {"device": TTY_USB0_BY_ID, "gpu": "/dev/dri/card0"}, }, "some_app": { "name": "Some App", "state": "stopped", "devices": [TTY_USB1], + "options": {"serial": [{"port": TTY_USB0}]}, + }, + # Static devices of the manifest are mapped regardless of being used + "wmbusmeters": { + "name": "Wmbusmeters", + "state": "started", + "devices": [TTY_USB0, TTY_USB1], + "options": {"reset_config": False}, }, "uninstalled_app": None, } @@ -478,7 +487,15 @@ async def test_app_consumers( "domain": None, "config_entry_id": None, "slug": "core_zwave_js", - } + }, + { + "kind": "app", + "title": "Some App", + "active": False, + "domain": None, + "config_entry_id": None, + "slug": "some_app", + }, ], ), (ESPHOME_PORT, []), @@ -531,7 +548,12 @@ async def test_multiple_consumers( entry.add_to_hass(hass) apps_info = { - "some_app": {"name": "Some App", "state": "started", "devices": [TTY_USB0]} + "some_app": { + "name": "Some App", + "state": "started", + "devices": [TTY_USB0], + "options": {"device": TTY_USB0}, + } } with (