diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 2da72d16ac6f1..6b681f801f46c 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__ = [ @@ -206,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) @@ -220,26 +225,56 @@ 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 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 False + + return True + + 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 (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() + + 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 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 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",