Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement a polling fallback for USB monitor #130918

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions homeassistant/components/usb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Coroutine
import dataclasses
from datetime import datetime, timedelta
import fnmatch
import logging
import os
Expand All @@ -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

Expand All @@ -39,6 +41,7 @@

_LOGGER = logging.getLogger(__name__)

POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5)
REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown

__all__ = [
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions tests/components/usb/test_init.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down