diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index a4bbe2dcf78..b688d821db4 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine, Sequence import dataclasses +from datetime import datetime, timedelta import fnmatch from functools import partial import logging @@ -33,6 +34,7 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -46,6 +48,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown __all__ = [ @@ -229,7 +232,9 @@ class USBDiscovery: 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) @@ -243,26 +248,54 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" - if not sys.platform.startswith("linux"): - return + async def _async_supports_monitoring(self) -> bool: info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): - return + return not info.get("docker") + + async def _async_start_monitor(self) -> None: + """Start monitoring hardware.""" + if not await self._async_start_monitor_udev(): + _LOGGER.info( + "Falling back to periodic filesystem polling for development, libudev " + "is not present" + ) + self._async_start_monitor_polling() + + @hass_callback + def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling (for development only!).""" + + 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 + ) + + @hass_callback + 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 successful.""" + if not sys.platform.startswith("linux"): + return False if not ( observer := await self.hass.async_add_executor_job( self._get_monitor_observer ) ): - return + 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 True def _get_monitor_observer(self) -> MonitorObserver | None: """Get the monitor observer. diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 2f6dc72b4f8..f4002c81e40 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,7 +1,8 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta import os -import sys from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel @@ -59,10 +60,6 @@ def mock_venv(): yield -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_discovery( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -93,6 +90,7 @@ async def test_observer_discovery( return mock_observer with ( + patch("sys.platform", "linux"), patch("pyudev.Context"), patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch("pyudev.Monitor.filter_by"), @@ -115,10 +113,65 @@ async def test_observer_discovery( assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) +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("sys.platform", "linux"), + 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() + + async def test_removal_by_observer_before_started( hass: HomeAssistant, operating_system ) -> None: @@ -671,10 +724,6 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_observer_on_wsl_fallback_without_throwing_exception( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv ) -> None: @@ -713,10 +762,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) async def test_not_discovered_by_observer_before_started_on_docker( hass: HomeAssistant, docker ) -> None: