mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add support for USB discovery (#54904)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
@ -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
|
||||
|
@ -29,6 +29,7 @@
|
||||
"system_health",
|
||||
"tag",
|
||||
"timer",
|
||||
"usb",
|
||||
"updater",
|
||||
"webhook",
|
||||
"zeroconf",
|
||||
|
138
homeassistant/components/usb/__init__.py
Normal file
138
homeassistant/components/usb/__init__.py
Normal 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())
|
3
homeassistant/components/usb/const.py
Normal file
3
homeassistant/components/usb/const.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Constants for the USB Discovery integration."""
|
||||
|
||||
DOMAIN = "usb"
|
48
homeassistant/components/usb/flow.py
Normal file
48
homeassistant/components/usb/flow.py
Normal 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"]
|
||||
)
|
12
homeassistant/components/usb/manifest.json
Normal file
12
homeassistant/components/usb/manifest.json
Normal 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"
|
||||
}
|
16
homeassistant/components/usb/models.py
Normal file
16
homeassistant/components/usb/models.py
Normal 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
|
18
homeassistant/components/usb/utils.py
Normal file
18
homeassistant/components/usb/utils.py
Normal 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,
|
||||
)
|
@ -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,
|
||||
|
8
homeassistant/generated/usb.py
Normal file
8
homeassistant/generated/usb.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Automatically generated by hassfest.
|
||||
|
||||
To update, run python3 -m script.hassfest
|
||||
"""
|
||||
|
||||
# fmt: off
|
||||
|
||||
USB = [] # type: ignore
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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
64
script/hassfest/usb.py
Normal 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")
|
29
tests/components/usb/__init__.py
Normal file
29
tests/components/usb/__init__.py
Normal 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",
|
||||
)
|
273
tests/components/usb/test_init.py
Normal file
273
tests/components/usb/test_init.py
Normal 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
|
@ -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,
|
||||
|
@ -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)
|
||||
|
Reference in New Issue
Block a user