From dc74a52f580520d57ba164c1d0e2e9aca4ceb978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Aug 2021 14:04:18 -0500 Subject: [PATCH] Add support for USB discovery (#54904) Co-authored-by: Martin Hjelmare --- CODEOWNERS | 1 + .../components/default_config/manifest.json | 1 + homeassistant/components/usb/__init__.py | 138 +++++++++ homeassistant/components/usb/const.py | 3 + homeassistant/components/usb/flow.py | 48 +++ homeassistant/components/usb/manifest.json | 12 + homeassistant/components/usb/models.py | 16 + homeassistant/components/usb/utils.py | 18 ++ homeassistant/config_entries.py | 10 + homeassistant/generated/usb.py | 8 + homeassistant/loader.py | 21 ++ homeassistant/package_constraints.txt | 2 + requirements_all.txt | 4 + requirements_test_all.txt | 4 + script/hassfest/__main__.py | 2 + script/hassfest/config_flow.py | 1 + script/hassfest/manifest.py | 8 + script/hassfest/usb.py | 64 ++++ tests/components/usb/__init__.py | 29 ++ tests/components/usb/test_init.py | 273 ++++++++++++++++++ tests/test_config_entries.py | 1 + tests/test_loader.py | 54 ++++ 22 files changed, 718 insertions(+) create mode 100644 homeassistant/components/usb/__init__.py create mode 100644 homeassistant/components/usb/const.py create mode 100644 homeassistant/components/usb/flow.py create mode 100644 homeassistant/components/usb/manifest.json create mode 100644 homeassistant/components/usb/models.py create mode 100644 homeassistant/components/usb/utils.py create mode 100644 homeassistant/generated/usb.py create mode 100644 script/hassfest/usb.py create mode 100644 tests/components/usb/__init__.py create mode 100644 tests/components/usb/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 62b5b70648b..0b3d7500a21 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,6 +549,7 @@ homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core homeassistant/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/uptimerobot/* @ludeeus +homeassistant/components/usb/* @bdraco homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes homeassistant/components/velbus/* @Cereal2nd @brefra diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 834438f5a9f..274e53c2f38 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -29,6 +29,7 @@ "system_health", "tag", "timer", + "usb", "updater", "webhook", "zeroconf", diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py new file mode 100644 index 00000000000..ff8bb5fae88 --- /dev/null +++ b/homeassistant/components/usb/__init__.py @@ -0,0 +1,138 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +import dataclasses +import datetime +import logging +import sys + +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo + +from homeassistant import config_entries +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.typing import ConfigType +from homeassistant.loader import async_get_usb + +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) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the USB Discovery integration.""" + usb = await async_get_usb(hass) + usb_discovery = USBDiscovery(hass, FlowDispatcher(hass), usb) + await usb_discovery.async_setup() + return True + + +class USBDiscovery: + """Manage USB Discovery.""" + + def __init__( + self, + hass: HomeAssistant, + flow_dispatcher: FlowDispatcher, + usb: list[dict[str, str]], + ) -> None: + """Init USB Discovery.""" + self.hass = hass + self.flow_dispatcher = flow_dispatcher + self.usb = usb + self.seen: set[tuple[str, ...]] = set() + + async def async_setup(self) -> None: + """Set up USB Discovery.""" + if not await self._async_start_monitor(): + await self._async_start_scanner() + 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) + + 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: + """Start monitoring hardware with pyudev.""" + if not sys.platform.startswith("linux"): + return False + from pyudev import ( # pylint: disable=import-outside-toplevel + Context, + Monitor, + MonitorObserver, + ) + + try: + context = Context() + except ImportError: + return False + + monitor = Monitor.from_netlink(context) + monitor.filter_by(subsystem="tty") + observer = MonitorObserver( + monitor, callback=self._device_discovered, name="usb-observer" + ) + observer.start() + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: observer.stop() + ) + return True + + def _device_discovered(self, device): + """Call when the observer discovers a new usb tty device.""" + if device.action != "add": + return + _LOGGER.debug( + "Discovered Device at path: %s, triggering scan serial", + device.device_path, + ) + self.scan_serial() + + @callback + def _async_process_discovered_usb_device(self, device: USBDevice) -> None: + """Process a USB discovery.""" + _LOGGER.debug("Discovered USB Device: %s", device) + device_tuple = dataclasses.astuple(device) + if device_tuple in self.seen: + return + self.seen.add(device_tuple) + for matcher in self.usb: + if "vid" in matcher and device.vid != matcher["vid"]: + continue + if "pid" in matcher and device.pid != matcher["pid"]: + continue + flow: USBFlow = { + "domain": matcher["domain"], + "context": {"source": config_entries.SOURCE_USB}, + "data": dataclasses.asdict(device), + } + self.flow_dispatcher.async_create(flow) + + @callback + def _async_process_ports(self, ports: list[ListPortInfo]) -> None: + """Process each discovered port.""" + for port in ports: + if port.vid is None and port.pid is None: + continue + self._async_process_discovered_usb_device(usb_device_from_port(port)) + + def scan_serial(self) -> None: + """Scan serial ports.""" + self.hass.add_job(self._async_process_ports, comports()) diff --git a/homeassistant/components/usb/const.py b/homeassistant/components/usb/const.py new file mode 100644 index 00000000000..c31178bc323 --- /dev/null +++ b/homeassistant/components/usb/const.py @@ -0,0 +1,3 @@ +"""Constants for the USB Discovery integration.""" + +DOMAIN = "usb" diff --git a/homeassistant/components/usb/flow.py b/homeassistant/components/usb/flow.py new file mode 100644 index 00000000000..00c40add92a --- /dev/null +++ b/homeassistant/components/usb/flow.py @@ -0,0 +1,48 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from collections.abc import Coroutine +from typing import Any, TypedDict + +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult + + +class USBFlow(TypedDict): + """A queued usb discovery flow.""" + + domain: str + context: dict[str, Any] + data: dict + + +class FlowDispatcher: + """Dispatch discovery flows.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the discovery dispatcher.""" + self.hass = hass + self.pending_flows: list[USBFlow] = [] + self.started = False + + @callback + def async_start(self, *_: Any) -> None: + """Start processing pending flows.""" + self.started = True + for flow in self.pending_flows: + self.hass.async_create_task(self._init_flow(flow)) + self.pending_flows = [] + + @callback + def async_create(self, flow: USBFlow) -> None: + """Create and add or queue a flow.""" + if self.started: + self.hass.async_create_task(self._init_flow(flow)) + else: + self.pending_flows.append(flow) + + def _init_flow(self, flow: USBFlow) -> Coroutine[None, None, FlowResult]: + """Create a flow.""" + return self.hass.config_entries.flow.async_init( + flow["domain"], context=flow["context"], data=flow["data"] + ) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json new file mode 100644 index 00000000000..274b9593f06 --- /dev/null +++ b/homeassistant/components/usb/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "usb", + "name": "USB Discovery", + "documentation": "https://www.home-assistant.io/integrations/usb", + "requirements": [ + "pyudev==0.22.0", + "pyserial==3.5" + ], + "codeowners": ["@bdraco"], + "quality_scale": "internal", + "iot_class": "local_push" +} \ No newline at end of file diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py new file mode 100644 index 00000000000..bdc8bc71ced --- /dev/null +++ b/homeassistant/components/usb/models.py @@ -0,0 +1,16 @@ +"""Models helper class for the usb integration.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class USBDevice: + """A usb device.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py new file mode 100644 index 00000000000..d6bd96882b2 --- /dev/null +++ b/homeassistant/components/usb/utils.py @@ -0,0 +1,18 @@ +"""The USB Discovery integration.""" +from __future__ import annotations + +from serial.tools.list_ports_common import ListPortInfo + +from .models import USBDevice + + +def usb_device_from_port(port: ListPortInfo) -> USBDevice: + """Convert serial ListPortInfo to USBDevice.""" + return USBDevice( + device=port.device, + vid=f"{hex(port.vid)[2:]:0>4}".upper(), + pid=f"{hex(port.pid)[2:]:0>4}".upper(), + serial_number=port.serial_number, + manufacturer=port.manufacturer, + description=port.description, + ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b711802386a..67c718a497d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -40,6 +40,7 @@ SOURCE_IMPORT = "import" SOURCE_INTEGRATION_DISCOVERY = "integration_discovery" SOURCE_MQTT = "mqtt" SOURCE_SSDP = "ssdp" +SOURCE_USB = "usb" SOURCE_USER = "user" SOURCE_ZEROCONF = "zeroconf" SOURCE_DHCP = "dhcp" @@ -103,6 +104,9 @@ DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_SOURCES = ( SOURCE_SSDP, + SOURCE_USB, + SOURCE_DHCP, + SOURCE_HOMEKIT, SOURCE_ZEROCONF, SOURCE_HOMEKIT, SOURCE_DHCP, @@ -1372,6 +1376,12 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Handle a flow initialized by DHCP discovery.""" return await self.async_step_discovery(discovery_info) + async def async_step_usb( + self, discovery_info: DiscoveryInfoType + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by USB discovery.""" + return await self.async_step_discovery(discovery_info) + @callback def async_create_entry( # pylint: disable=arguments-differ self, diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py new file mode 100644 index 00000000000..d72cbc8c7a5 --- /dev/null +++ b/homeassistant/generated/usb.py @@ -0,0 +1,8 @@ +"""Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +""" + +# fmt: off + +USB = [] # type: ignore diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a535db4bde2..57244d9ec7b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -26,6 +26,7 @@ from awesomeversion import ( from homeassistant.generated.dhcp import DHCP from homeassistant.generated.mqtt import MQTT from homeassistant.generated.ssdp import SSDP +from homeassistant.generated.usb import USB from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF from homeassistant.util.async_ import gather_with_concurrency @@ -81,6 +82,7 @@ class Manifest(TypedDict, total=False): ssdp: list[dict[str, str]] zeroconf: list[str | dict[str, str]] dhcp: list[dict[str, str]] + usb: list[dict[str, str]] homekit: dict[str, list[str]] is_built_in: bool version: str @@ -219,6 +221,20 @@ async def async_get_dhcp(hass: HomeAssistant) -> list[dict[str, str]]: return dhcp +async def async_get_usb(hass: HomeAssistant) -> list[dict[str, str]]: + """Return cached list of usb types.""" + usb: list[dict[str, str]] = USB.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.usb: + continue + for entry in integration.usb: + usb.append({"domain": integration.domain, **entry}) + + return usb + + async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]: """Return cached list of homekit models.""" @@ -423,6 +439,11 @@ class Integration: """Return Integration dhcp entries.""" return self.manifest.get("dhcp") + @property + def usb(self) -> list[dict[str, str]] | None: + """Return Integration usb entries.""" + return self.manifest.get("usb") + @property def homekit(self) -> dict[str, list[str]] | None: """Return Integration homekit entries.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e4e57f608f..99e6fa77bbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,9 @@ jinja2==3.0.1 paho-mqtt==1.5.1 pillow==8.2.0 pip>=8.0.3,<20.3 +pyserial==3.5 python-slugify==4.0.1 +pyudev==0.22.0 pyyaml==5.4.1 requests==2.25.1 ruamel.yaml==0.15.100 diff --git a/requirements_all.txt b/requirements_all.txt index 76891354250..cf746042737 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,6 +1755,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1957,6 +1958,9 @@ pytradfri[async]==7.0.6 # homeassistant.components.trafikverket_weatherstation pytrafikverket==0.1.6.2 +# homeassistant.components.usb +pyudev==0.22.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50dbf55d42f..d803cb63fa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1011,6 +1011,7 @@ pyruckus==0.12 pyserial-asyncio==0.5 # homeassistant.components.acer_projector +# homeassistant.components.usb # homeassistant.components.zha pyserial==3.5 @@ -1095,6 +1096,9 @@ pytraccar==0.9.0 # homeassistant.components.tradfri pytradfri[async]==7.0.6 +# homeassistant.components.usb +pyudev==0.22.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.8.2 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index f9a1aa54c69..1bec328702e 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -18,6 +18,7 @@ from . import ( services, ssdp, translations, + usb, zeroconf, ) from .model import Config, Integration @@ -34,6 +35,7 @@ INTEGRATION_PLUGINS = [ translations, zeroconf, dhcp, + usb, ] HASS_PLUGINS = [ coverage, diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 8e0f53fd736..87e9bea6291 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -41,6 +41,7 @@ def validate_integration(config: Config, integration: Integration): or "async_step_ssdp" in config_flow or "async_step_zeroconf" in config_flow or "async_step_dhcp" in config_flow + or "async_step_usb" in config_flow ) if not needs_unique_id: diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 797729542f4..acb2a999fe3 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -205,6 +205,14 @@ MANIFEST_SCHEMA = vol.Schema( } ) ], + vol.Optional("usb"): [ + vol.Schema( + { + vol.Optional("vid"): vol.All(str, verify_uppercase), + vol.Optional("pid"): vol.All(str, verify_uppercase), + } + ) + ], vol.Required("documentation"): vol.All( vol.Url(), documentation_url # pylint: disable=no-value-for-parameter ), diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py new file mode 100644 index 00000000000..927b87def98 --- /dev/null +++ b/script/hassfest/usb.py @@ -0,0 +1,64 @@ +"""Generate usb file.""" +from __future__ import annotations + +import json + +from .model import Config, Integration + +BASE = """ +\"\"\"Automatically generated by hassfest. + +To update, run python3 -m script.hassfest +\"\"\" + +# fmt: off + +USB = {} # type: ignore +""".strip() + + +def generate_and_validate(integrations: list[dict[str, str]]) -> str: + """Validate and generate usb data.""" + match_list = [] + + for domain in sorted(integrations): + integration = integrations[domain] + + if not integration.manifest or not integration.config_flow: + continue + + match_types = integration.manifest.get("usb", []) + + if not match_types: + continue + + for entry in match_types: + match_list.append({"domain": domain, **entry}) + + return BASE.format(json.dumps(match_list, indent=4)) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + config.cache["usb"] = content = generate_and_validate(integrations) + + if config.specific_integrations: + return + + with open(str(usb_path)) as fp: + current = fp.read().strip() + if current != content: + config.add_error( + "usb", + "File usb.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + return + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate usb file.""" + usb_path = config.root / "homeassistant/generated/usb.py" + with open(str(usb_path), "w") as fp: + fp.write(f"{config.cache['usb']}\n") diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py new file mode 100644 index 00000000000..7dbfdfdcff6 --- /dev/null +++ b/tests/components/usb/__init__.py @@ -0,0 +1,29 @@ +"""Tests for the USB Discovery integration.""" + + +from homeassistant.components.usb.models import USBDevice + +conbee_device = USBDevice( + device="/dev/cu.usbmodemDE24338801", + vid="1CF1", + pid="0030", + serial_number="DE2433880", + manufacturer="dresden elektronik ingenieurtechnik GmbH", + description="ConBee II", +) +slae_sh_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="10C4", + pid="EA60", + serial_number="00_12_4B_00_22_98_88_7F", + manufacturer="Silicon Labs", + description="slae.sh cc2652rb stick - slaesh's iot stuff", +) +electro_lama_device = USBDevice( + device="/dev/cu.usbserial-110", + vid="1A86", + pid="7523", + serial_number=None, + manufacturer=None, + description="USB2.0-Serial", +) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py new file mode 100644 index 00000000000..cb547edc939 --- /dev/null +++ b/tests/components/usb/test_init.py @@ -0,0 +1,273 @@ +"""Tests for the USB Discovery integration.""" +import datetime +import sys +from unittest.mock import MagicMock, patch + +import pytest + +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"), + reason="Only works on linux", +) +async def test_discovered_by_observer_before_started(hass): + """Test a device is discovered by the observer before started.""" + + async def _mock_monitor_observer_callback(callback): + await hass.async_add_executor_job( + callback, MagicMock(action="add", device_path="/dev/new") + ) + + def _create_mock_monitor_observer(monitor, callback, name): + hass.async_create_task(_mock_monitor_observer_callback(callback)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "pyudev.MonitorObserver", new=_create_mock_monitor_observer + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + + with patch("homeassistant.components.usb.comports", return_value=[]), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + 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_removal_by_observer_before_started(hass): + """Test a device is removed by the observer before started.""" + + async def _mock_monitor_observer_callback(callback): + await hass.async_add_executor_job( + callback, MagicMock(action="remove", device_path="/dev/new") + ) + + def _create_mock_monitor_observer(monitor, callback, name): + hass.async_create_task(_mock_monitor_observer_callback(callback)) + return MagicMock() + + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=mock_comports + ), patch( + "pyudev.MonitorObserver", new=_create_mock_monitor_observer + ), 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() + + with patch("homeassistant.components.usb.comports", return_value=[]): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + 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.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] + + mock_comports = [ + 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("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + 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.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + + mock_comports = [ + 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("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + 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.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + mock_comports = [ + 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("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + 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.""" + new_usb = [{"domain": "test1", "vid": "3039", "pid": "9999"}] + + mock_comports = [ + MagicMock( + device=slae_sh_device.device, + vid=None, + pid=None, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with patch("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + +async def test_non_matching_discovered_by_scanner_after_started(hass): + """Test a device is discovered by the scanner after the started event that does not match.""" + new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] + + mock_comports = [ + 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("pyudev.Context", side_effect=ImportError), patch( + "homeassistant.components.usb.async_get_usb", return_value=new_usb + ), patch( + "homeassistant.components.usb.comports", return_value=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() + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=1)) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 391366683d5..4c002ad8228 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2359,6 +2359,7 @@ async def test_async_setup_update_entry(hass): ( config_entries.SOURCE_DISCOVERY, config_entries.SOURCE_SSDP, + config_entries.SOURCE_USB, config_entries.SOURCE_HOMEKIT, config_entries.SOURCE_DHCP, config_entries.SOURCE_ZEROCONF, diff --git a/tests/test_loader.py b/tests/test_loader.py index 20dcf90d90e..2c5eb91d0fb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -192,6 +192,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ], + "usb": [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ], "ssdp": [ { "manufacturer": "Royal Philips Electronics", @@ -216,6 +222,12 @@ def test_integration_properties(hass): {"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"}, ] + assert integration.usb == [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ] assert integration.ssdp == [ { "manufacturer": "Royal Philips Electronics", @@ -248,6 +260,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf is None assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None assert integration.mqtt is None assert integration.version is None @@ -268,6 +281,7 @@ def test_integration_properties(hass): assert integration.homekit is None assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] assert integration.dhcp is None + assert integration.usb is None assert integration.ssdp is None @@ -342,6 +356,28 @@ def _get_test_integration_with_dhcp_matcher(hass, name, config_flow): ) +def _get_test_integration_with_usb_matcher(hass, name, config_flow): + """Return a generated test integration with a usb matcher.""" + return loader.Integration( + hass, + f"homeassistant.components.{name}", + None, + { + "name": name, + "domain": name, + "config_flow": config_flow, + "dependencies": [], + "requirements": [], + "usb": [ + {"vid": "10C4", "pid": "EA60"}, + {"vid": "1CF1", "pid": "0030"}, + {"vid": "1A86", "pid": "7523"}, + {"vid": "10C4", "pid": "8A2A"}, + ], + }, + ) + + async def test_get_custom_components(hass, enable_custom_integrations): """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) @@ -411,6 +447,24 @@ async def test_get_dhcp(hass): ] +async def test_get_usb(hass): + """Verify that custom components with usb matchers are found.""" + test_1_integration = _get_test_integration_with_usb_matcher(hass, "test_1", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + } + usb = await loader.async_get_usb(hass) + usb_for_domain = [entry for entry in usb if entry["domain"] == "test_1"] + assert usb_for_domain == [ + {"domain": "test_1", "vid": "10C4", "pid": "EA60"}, + {"domain": "test_1", "vid": "1CF1", "pid": "0030"}, + {"domain": "test_1", "vid": "1A86", "pid": "7523"}, + {"domain": "test_1", "vid": "10C4", "pid": "8A2A"}, + ] + + async def test_get_homekit(hass): """Verify that custom components with homekit are found.""" test_1_integration = _get_test_integration(hass, "test_1", True)