From 42f7f19be5486dc0a826008e9eb5b04bbbc98c2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Aug 2021 16:06:44 -0500 Subject: [PATCH] Switch periodic USB scanning to on-demand websocket when observer is not available (#54953) --- homeassistant/components/usb/__init__.py | 68 +++++++++++++++------- homeassistant/components/usb/manifest.json | 1 + tests/components/usb/test_init.py | 51 ++++++++++------ 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 115b0fc3de9..36a63a36642 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,29 +2,31 @@ from __future__ import annotations import dataclasses -import datetime import logging import os import sys from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_usb +from .const import DOMAIN from .flow import FlowDispatcher, USBFlow from .models import USBDevice from .utils import usb_device_from_port _LOGGER = logging.getLogger(__name__) -# Perodic scanning only happens on non-linux systems -SCAN_INTERVAL = datetime.timedelta(minutes=60) +REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown def human_readable_device_name( @@ -63,6 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: usb = await async_get_usb(hass) usb_discovery = USBDiscovery(hass, FlowDispatcher(hass), usb) await usb_discovery.async_setup() + hass.data[DOMAIN] = usb_discovery + websocket_api.async_register_command(hass, websocket_usb_scan) + return True @@ -80,31 +85,23 @@ class USBDiscovery: self.flow_dispatcher = flow_dispatcher self.usb = usb self.seen: set[tuple[str, ...]] = set() + self.observer_active = False + self._request_debouncer: Debouncer | None = None async def async_setup(self) -> None: """Set up USB Discovery.""" - if not await self._async_start_monitor(): - await self._async_start_scanner() + await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" self.flow_dispatcher.async_start() - await self.hass.async_add_executor_job(self.scan_serial) + await self._async_scan_serial() - async def _async_start_scanner(self) -> None: - """Perodic scan with pyserial when the observer is not available.""" - stop_track = async_track_time_interval( - self.hass, lambda now: self.scan_serial(), SCAN_INTERVAL - ) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, callback(lambda event: stop_track()) - ) - - async def _async_start_monitor(self) -> bool: + async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" if not sys.platform.startswith("linux"): - return False + return from pyudev import ( # pylint: disable=import-outside-toplevel Context, Monitor, @@ -114,7 +111,7 @@ class USBDiscovery: try: context = Context() except (ImportError, OSError): - return False + return monitor = Monitor.from_netlink(context) monitor.filter_by(subsystem="tty") @@ -125,7 +122,7 @@ class USBDiscovery: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, lambda event: observer.stop() ) - return True + self.observer_active = True def _device_discovered(self, device): """Call when the observer discovers a new usb tty device.""" @@ -168,3 +165,34 @@ class USBDiscovery: def scan_serial(self) -> None: """Scan serial ports.""" self.hass.add_job(self._async_process_ports, comports()) + + async def _async_scan_serial(self) -> None: + """Scan serial ports.""" + self._async_process_ports(await self.hass.async_add_executor_job(comports)) + + async def async_request_scan_serial(self) -> None: + """Request a serial scan.""" + if not self._request_debouncer: + self._request_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=REQUEST_SCAN_COOLDOWN, + immediate=True, + function=self._async_scan_serial, + ) + await self._request_debouncer.async_call() + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "usb/scan"}) +@websocket_api.async_response +async def websocket_usb_scan( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Scan for new usb devices.""" + usb_discovery: USBDiscovery = hass.data[DOMAIN] + if not usb_discovery.observer_active: + await usb_discovery.async_request_scan_serial() + connection.send_result(msg["id"]) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 274b9593f06..fd22882b8b3 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,6 +7,7 @@ "pyserial==3.5" ], "codeowners": ["@bdraco"], + "dependencies": ["websocket_api"], "quality_scale": "internal", "iot_class": "local_push" } \ No newline at end of file diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index f7b642c3390..a290ef9fa4c 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,5 +1,4 @@ """Tests for the USB Discovery integration.""" -import datetime import os import sys from unittest.mock import MagicMock, patch, sentinel @@ -9,12 +8,9 @@ import pytest from homeassistant.components import usb from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from . import slae_sh_device -from tests.common import async_fire_time_changed - @pytest.mark.skipif( not sys.platform.startswith("linux"), @@ -113,8 +109,8 @@ async def test_removal_by_observer_before_started(hass): assert len(mock_config_flow.mock_calls) == 0 -async def test_discovered_by_scanner_after_started(hass): - """Test a device is discovered by the scanner after the started event.""" +async def test_discovered_by_websocket_scan(hass, hass_ws_client): + """Test a device is discovered from websocket scan.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -139,15 +135,18 @@ async def test_discovered_by_scanner_after_started(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_discovered_by_scanner_after_started_match_vid_only(hass): - """Test a device is discovered by the scanner after the started event only matching vid.""" +async def test_discovered_by_websocket_scan_match_vid_only(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -172,15 +171,18 @@ async def test_discovered_by_scanner_after_started_match_vid_only(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_discovered_by_scanner_after_started_match_vid_wrong_pid(hass): - """Test a device is discovered by the scanner after the started event only matching vid but wrong pid.""" +async def test_discovered_by_websocket_scan_match_vid_wrong_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan only matching vid but wrong pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] mock_comports = [ @@ -205,14 +207,17 @@ async def test_discovered_by_scanner_after_started_match_vid_wrong_pid(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 -async def test_discovered_by_scanner_after_started_no_vid_pid(hass): - """Test a device is discovered by the scanner after the started event with no vid or pid.""" +async def test_discovered_by_websocket_no_vid_pid(hass, hass_ws_client): + """Test a device is discovered from websocket scan with no vid or pid.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] mock_comports = [ @@ -237,15 +242,20 @@ async def test_discovered_by_scanner_after_started_no_vid_pid(hass): await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 @pytest.mark.parametrize("exception_type", [ImportError, OSError]) -async def test_non_matching_discovered_by_scanner_after_started(hass, exception_type): - """Test a device is discovered by the scanner after the started event that does not match.""" +async def test_non_matching_discovered_by_scanner_after_started( + hass, exception_type, hass_ws_client +): + """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] mock_comports = [ @@ -270,7 +280,10 @@ async def test_non_matching_discovered_by_scanner_after_started(hass, exception_ await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0