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

View File

@ -29,6 +29,7 @@
"system_health",
"tag",
"timer",
"usb",
"updater",
"webhook",
"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_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,

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.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."""

View File

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

View File

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

View File

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

View File

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

View File

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

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.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_SSDP,
config_entries.SOURCE_USB,
config_entries.SOURCE_HOMEKIT,
config_entries.SOURCE_DHCP,
config_entries.SOURCE_ZEROCONF,

View File

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