Add support for USB discovery (#54904)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2021-08-20 14:04:18 -05:00 committed by GitHub
parent 11c6a33594
commit dc74a52f58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 718 additions and 0 deletions

View File

@ -549,6 +549,7 @@ homeassistant/components/upcloud/* @scop
homeassistant/components/updater/* @home-assistant/core homeassistant/components/updater/* @home-assistant/core
homeassistant/components/upnp/* @StevenLooman @ehendrix23 homeassistant/components/upnp/* @StevenLooman @ehendrix23
homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/uptimerobot/* @ludeeus
homeassistant/components/usb/* @bdraco
homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/usgs_earthquakes_feed/* @exxamalte
homeassistant/components/utility_meter/* @dgomes homeassistant/components/utility_meter/* @dgomes
homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velbus/* @Cereal2nd @brefra

View File

@ -29,6 +29,7 @@
"system_health", "system_health",
"tag", "tag",
"timer", "timer",
"usb",
"updater", "updater",
"webhook", "webhook",
"zeroconf", "zeroconf",

View File

@ -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())

View File

@ -0,0 +1,3 @@
"""Constants for the USB Discovery integration."""
DOMAIN = "usb"

View File

@ -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"]
)

View File

@ -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"
}

View File

@ -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

View File

@ -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,
)

View File

@ -40,6 +40,7 @@ SOURCE_IMPORT = "import"
SOURCE_INTEGRATION_DISCOVERY = "integration_discovery" SOURCE_INTEGRATION_DISCOVERY = "integration_discovery"
SOURCE_MQTT = "mqtt" SOURCE_MQTT = "mqtt"
SOURCE_SSDP = "ssdp" SOURCE_SSDP = "ssdp"
SOURCE_USB = "usb"
SOURCE_USER = "user" SOURCE_USER = "user"
SOURCE_ZEROCONF = "zeroconf" SOURCE_ZEROCONF = "zeroconf"
SOURCE_DHCP = "dhcp" SOURCE_DHCP = "dhcp"
@ -103,6 +104,9 @@ DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id"
DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery"
DISCOVERY_SOURCES = ( DISCOVERY_SOURCES = (
SOURCE_SSDP, SOURCE_SSDP,
SOURCE_USB,
SOURCE_DHCP,
SOURCE_HOMEKIT,
SOURCE_ZEROCONF, SOURCE_ZEROCONF,
SOURCE_HOMEKIT, SOURCE_HOMEKIT,
SOURCE_DHCP, SOURCE_DHCP,
@ -1372,6 +1376,12 @@ class ConfigFlow(data_entry_flow.FlowHandler):
"""Handle a flow initialized by DHCP discovery.""" """Handle a flow initialized by DHCP discovery."""
return await self.async_step_discovery(discovery_info) 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 @callback
def async_create_entry( # pylint: disable=arguments-differ def async_create_entry( # pylint: disable=arguments-differ
self, self,

View File

@ -0,0 +1,8 @@
"""Automatically generated by hassfest.
To update, run python3 -m script.hassfest
"""
# fmt: off
USB = [] # type: ignore

View File

@ -26,6 +26,7 @@ from awesomeversion import (
from homeassistant.generated.dhcp import DHCP from homeassistant.generated.dhcp import DHCP
from homeassistant.generated.mqtt import MQTT from homeassistant.generated.mqtt import MQTT
from homeassistant.generated.ssdp import SSDP from homeassistant.generated.ssdp import SSDP
from homeassistant.generated.usb import USB
from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF
from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.async_ import gather_with_concurrency
@ -81,6 +82,7 @@ class Manifest(TypedDict, total=False):
ssdp: list[dict[str, str]] ssdp: list[dict[str, str]]
zeroconf: list[str | dict[str, str]] zeroconf: list[str | dict[str, str]]
dhcp: list[dict[str, str]] dhcp: list[dict[str, str]]
usb: list[dict[str, str]]
homekit: dict[str, list[str]] homekit: dict[str, list[str]]
is_built_in: bool is_built_in: bool
version: str version: str
@ -219,6 +221,20 @@ async def async_get_dhcp(hass: HomeAssistant) -> list[dict[str, str]]:
return dhcp 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]: async def async_get_homekit(hass: HomeAssistant) -> dict[str, str]:
"""Return cached list of homekit models.""" """Return cached list of homekit models."""
@ -423,6 +439,11 @@ class Integration:
"""Return Integration dhcp entries.""" """Return Integration dhcp entries."""
return self.manifest.get("dhcp") return self.manifest.get("dhcp")
@property
def usb(self) -> list[dict[str, str]] | None:
"""Return Integration usb entries."""
return self.manifest.get("usb")
@property @property
def homekit(self) -> dict[str, list[str]] | None: def homekit(self) -> dict[str, list[str]] | None:
"""Return Integration homekit entries.""" """Return Integration homekit entries."""

View File

@ -23,7 +23,9 @@ jinja2==3.0.1
paho-mqtt==1.5.1 paho-mqtt==1.5.1
pillow==8.2.0 pillow==8.2.0
pip>=8.0.3,<20.3 pip>=8.0.3,<20.3
pyserial==3.5
python-slugify==4.0.1 python-slugify==4.0.1
pyudev==0.22.0
pyyaml==5.4.1 pyyaml==5.4.1
requests==2.25.1 requests==2.25.1
ruamel.yaml==0.15.100 ruamel.yaml==0.15.100

View File

@ -1755,6 +1755,7 @@ pysensibo==1.0.3
pyserial-asyncio==0.5 pyserial-asyncio==0.5
# homeassistant.components.acer_projector # homeassistant.components.acer_projector
# homeassistant.components.usb
# homeassistant.components.zha # homeassistant.components.zha
pyserial==3.5 pyserial==3.5
@ -1957,6 +1958,9 @@ pytradfri[async]==7.0.6
# homeassistant.components.trafikverket_weatherstation # homeassistant.components.trafikverket_weatherstation
pytrafikverket==0.1.6.2 pytrafikverket==0.1.6.2
# homeassistant.components.usb
pyudev==0.22.0
# homeassistant.components.uptimerobot # homeassistant.components.uptimerobot
pyuptimerobot==21.8.2 pyuptimerobot==21.8.2

View File

@ -1011,6 +1011,7 @@ pyruckus==0.12
pyserial-asyncio==0.5 pyserial-asyncio==0.5
# homeassistant.components.acer_projector # homeassistant.components.acer_projector
# homeassistant.components.usb
# homeassistant.components.zha # homeassistant.components.zha
pyserial==3.5 pyserial==3.5
@ -1095,6 +1096,9 @@ pytraccar==0.9.0
# homeassistant.components.tradfri # homeassistant.components.tradfri
pytradfri[async]==7.0.6 pytradfri[async]==7.0.6
# homeassistant.components.usb
pyudev==0.22.0
# homeassistant.components.uptimerobot # homeassistant.components.uptimerobot
pyuptimerobot==21.8.2 pyuptimerobot==21.8.2

View File

@ -18,6 +18,7 @@ from . import (
services, services,
ssdp, ssdp,
translations, translations,
usb,
zeroconf, zeroconf,
) )
from .model import Config, Integration from .model import Config, Integration
@ -34,6 +35,7 @@ INTEGRATION_PLUGINS = [
translations, translations,
zeroconf, zeroconf,
dhcp, dhcp,
usb,
] ]
HASS_PLUGINS = [ HASS_PLUGINS = [
coverage, coverage,

View File

@ -41,6 +41,7 @@ def validate_integration(config: Config, integration: Integration):
or "async_step_ssdp" in config_flow or "async_step_ssdp" in config_flow
or "async_step_zeroconf" in config_flow or "async_step_zeroconf" in config_flow
or "async_step_dhcp" in config_flow or "async_step_dhcp" in config_flow
or "async_step_usb" in config_flow
) )
if not needs_unique_id: if not needs_unique_id:

View File

@ -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.Required("documentation"): vol.All(
vol.Url(), documentation_url # pylint: disable=no-value-for-parameter vol.Url(), documentation_url # pylint: disable=no-value-for-parameter
), ),

64
script/hassfest/usb.py Normal file
View File

@ -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")

View File

@ -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",
)

View File

@ -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

View File

@ -2359,6 +2359,7 @@ async def test_async_setup_update_entry(hass):
( (
config_entries.SOURCE_DISCOVERY, config_entries.SOURCE_DISCOVERY,
config_entries.SOURCE_SSDP, config_entries.SOURCE_SSDP,
config_entries.SOURCE_USB,
config_entries.SOURCE_HOMEKIT, config_entries.SOURCE_HOMEKIT,
config_entries.SOURCE_DHCP, config_entries.SOURCE_DHCP,
config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_ZEROCONF,

View File

@ -192,6 +192,12 @@ def test_integration_properties(hass):
{"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"}, {"hostname": "tesla_*", "macaddress": "98ED5C*"},
], ],
"usb": [
{"vid": "10C4", "pid": "EA60"},
{"vid": "1CF1", "pid": "0030"},
{"vid": "1A86", "pid": "7523"},
{"vid": "10C4", "pid": "8A2A"},
],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Royal Philips Electronics", "manufacturer": "Royal Philips Electronics",
@ -216,6 +222,12 @@ def test_integration_properties(hass):
{"hostname": "tesla_*", "macaddress": "044EAF*"}, {"hostname": "tesla_*", "macaddress": "044EAF*"},
{"hostname": "tesla_*", "macaddress": "98ED5C*"}, {"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 == [ assert integration.ssdp == [
{ {
"manufacturer": "Royal Philips Electronics", "manufacturer": "Royal Philips Electronics",
@ -248,6 +260,7 @@ def test_integration_properties(hass):
assert integration.homekit is None assert integration.homekit is None
assert integration.zeroconf is None assert integration.zeroconf is None
assert integration.dhcp is None assert integration.dhcp is None
assert integration.usb is None
assert integration.ssdp is None assert integration.ssdp is None
assert integration.mqtt is None assert integration.mqtt is None
assert integration.version is None assert integration.version is None
@ -268,6 +281,7 @@ def test_integration_properties(hass):
assert integration.homekit is None assert integration.homekit is None
assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}] assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}]
assert integration.dhcp is None assert integration.dhcp is None
assert integration.usb is None
assert integration.ssdp 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): async def test_get_custom_components(hass, enable_custom_integrations):
"""Verify that custom components are cached.""" """Verify that custom components are cached."""
test_1_integration = _get_test_integration(hass, "test_1", False) 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): async def test_get_homekit(hass):
"""Verify that custom components with homekit are found.""" """Verify that custom components with homekit are found."""
test_1_integration = _get_test_integration(hass, "test_1", True) test_1_integration = _get_test_integration(hass, "test_1", True)