From 6a29bd3fe2360dfacc38c62c9bd2ed0d44e67c79 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:31:26 -0500 Subject: [PATCH 1/5] usb: Create a polling fallback for USB monitoring --- homeassistant/components/usb/__init__.py | 35 +++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 2da72d16ac6f1..cfb6b36f2a2fc 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -4,6 +4,7 @@ from collections.abc import Coroutine import dataclasses +from datetime import datetime, timedelta import fnmatch import logging import os @@ -27,6 +28,7 @@ from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -39,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown __all__ = [ @@ -221,25 +224,49 @@ def async_stop(self, event: Event) -> None: self._request_debouncer.async_shutdown() async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" - if not sys.platform.startswith("linux"): + fallback_to_polling = await self._async_start_monitor_udev() + + if not fallback_to_polling: return + + await self._async_start_monitor_polling() + + async def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling.""" + + async def _scan(event_time: datetime) -> None: + await self._async_scan_serial() + + stop_callback = async_track_time_interval( + self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD + ) + + def _stop_polling(event: Event) -> None: + stop_callback() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) + + async def _async_start_monitor_udev(self) -> bool: + """Start monitoring hardware with pyudev. Returns True if polling is needed.""" + if not sys.platform.startswith("linux"): + return True info = await system_info.async_get_system_info(self.hass) if info.get("docker"): - return + return False if not ( observer := await self.hass.async_add_executor_job( self._get_monitor_observer ) ): - return + return True def _stop_observer(event: Event) -> None: observer.stop() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) self.observer_active = True + return False def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. From e4bb1a76c3b9b8cab21ec668e6b50a281d427f11 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:06:03 -0500 Subject: [PATCH 2/5] usb: Unit test for polling fallback --- tests/components/usb/test_init.py | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index bbd802afc959d..b0a6714dc6059 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,5 +1,7 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta import os import sys from unittest.mock import MagicMock, Mock, call, patch, sentinel @@ -112,6 +114,64 @@ def _create_mock_monitor_observer(monitor, callback, name): assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] +async def test_polling_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +) -> None: + """Test that polling can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + mock_comports_found_device = asyncio.Event() + + def get_comports() -> list: + nonlocal mock_comports + + # Only "find" a device after a few invocations + if len(mock_comports.mock_calls) < 5: + return [] + + mock_comports_found_device.set() + return [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with ( + patch( + "homeassistant.components.usb.USBDiscovery._get_monitor_observer", + return_value=None, + ), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch( + "homeassistant.components.usb.comports", side_effect=get_comports + ) as mock_comports, + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Wait until a new device is discovered after a few polling attempts + assert len(mock_config_flow.mock_calls) == 0 + await mock_comports_found_device.wait() + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="Only works on linux", From 156dd4c3e4aff6ddc0060e0a6b5de4923c39e7d0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:15:51 -0500 Subject: [PATCH 3/5] usb: Re-add comment to `_async_start_monitor` --- homeassistant/components/usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index cfb6b36f2a2fc..a397030e68bf4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -224,6 +224,7 @@ def async_stop(self, event: Event) -> None: self._request_debouncer.async_shutdown() async def _async_start_monitor(self) -> None: + """Start monitoring hardware.""" fallback_to_polling = await self._async_start_monitor_udev() if not fallback_to_polling: From 3cffad92607ff7cec55eeed987bb295c33443ab8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 18 Nov 2024 18:22:18 -0500 Subject: [PATCH 4/5] usb: Create an explicit `_async_supports_monitoring` function --- homeassistant/components/usb/__init__.py | 28 +++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index a397030e68bf4..46b57f2a35c53 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -209,7 +209,9 @@ def __init__( async def async_setup(self) -> None: """Set up USB Discovery.""" - await self._async_start_monitor() + if await self._async_supports_monitoring(): + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -223,14 +225,17 @@ def async_stop(self, event: Event) -> None: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_start_monitor(self) -> None: - """Start monitoring hardware.""" - fallback_to_polling = await self._async_start_monitor_udev() + async def _async_supports_monitoring(self) -> bool: + info = await system_info.async_get_system_info(self.hass) + if info.get("docker"): + return False - if not fallback_to_polling: - return + return True - await self._async_start_monitor_polling() + async def _async_start_monitor(self) -> None: + """Start monitoring hardware.""" + if not await self._async_start_monitor_udev(): + await self._async_start_monitor_polling() async def _async_start_monitor_polling(self) -> None: """Start monitoring hardware with polling.""" @@ -248,11 +253,8 @@ def _stop_polling(event: Event) -> None: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) async def _async_start_monitor_udev(self) -> bool: - """Start monitoring hardware with pyudev. Returns True if polling is needed.""" + """Start monitoring hardware with pyudev. Returns True if successful.""" if not sys.platform.startswith("linux"): - return True - info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): return False if not ( @@ -260,14 +262,14 @@ async def _async_start_monitor_udev(self) -> bool: self._get_monitor_observer ) ): - return True + return False def _stop_observer(event: Event) -> None: observer.stop() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) self.observer_active = True - return False + return True def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. From 5b6a9680ec4a9929122fdd6aa6a73e64eaa63254 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:45:20 -0500 Subject: [PATCH 5/5] Clearly explain that USB polling is for development only --- homeassistant/components/usb/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 46b57f2a35c53..6b681f801f46c 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -235,10 +235,15 @@ async def _async_supports_monitoring(self) -> bool: async def _async_start_monitor(self) -> None: """Start monitoring hardware.""" if not await self._async_start_monitor_udev(): + # We fall back to polling to make development possible, this is not a proper + # way to run Home Assistant await self._async_start_monitor_polling() async def _async_start_monitor_polling(self) -> None: - """Start monitoring hardware with polling.""" + """Start monitoring hardware with polling (for development only!).""" + _LOGGER.info( + "Falling back to USB port polling for development, libudev is not preset" + ) async def _scan(event_time: datetime) -> None: await self._async_scan_serial()