Switch periodic USB scanning to on-demand websocket when observer is not available (#54953)
This commit is contained in:
parent
a931e35a14
commit
42f7f19be5
3 changed files with 81 additions and 39 deletions
|
@ -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"])
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"pyserial==3.5"
|
||||
],
|
||||
"codeowners": ["@bdraco"],
|
||||
"dependencies": ["websocket_api"],
|
||||
"quality_scale": "internal",
|
||||
"iot_class": "local_push"
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue