Add support for USB dongles to the hardware integration (#76795)
* Add support for USB dongles to the hardware integration * Update hardware integrations * Adjust tests * Add USB discovery for SkyConnect 1.0 * Improve test coverage * Apply suggestions from code review Co-authored-by: Paulus Schoutsen <balloob@gmail.com> * Fix frozen dataclass shizzle * Adjust test Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
fb5a67fb1f
commit
bb74730e96
23 changed files with 581 additions and 28 deletions
|
@ -467,6 +467,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/homeassistant/ @home-assistant/core
|
/tests/components/homeassistant/ @home-assistant/core
|
||||||
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
||||||
/tests/components/homeassistant_alerts/ @home-assistant/core
|
/tests/components/homeassistant_alerts/ @home-assistant/core
|
||||||
|
/homeassistant/components/homeassistant_sky_connect/ @home-assistant/core
|
||||||
|
/tests/components/homeassistant_sky_connect/ @home-assistant/core
|
||||||
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
|
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
|
||||||
/tests/components/homeassistant_yellow/ @home-assistant/core
|
/tests/components/homeassistant_yellow/ @home-assistant/core
|
||||||
/homeassistant/components/homekit/ @bdraco
|
/homeassistant/components/homekit/ @bdraco
|
||||||
|
|
|
@ -34,6 +34,7 @@ def async_info(hass: HomeAssistant) -> HardwareInfo:
|
||||||
model=board,
|
model=board,
|
||||||
revision=None,
|
revision=None,
|
||||||
),
|
),
|
||||||
|
dongles=None,
|
||||||
name=BOARD_NAMES.get(board, f"Unknown hardkernel Odroid model '{board}'"),
|
name=BOARD_NAMES.get(board, f"Unknown hardkernel Odroid model '{board}'"),
|
||||||
url=None,
|
url=None,
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,12 +17,24 @@ class BoardInfo:
|
||||||
revision: str | None
|
revision: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
|
class USBInfo:
|
||||||
|
"""USB info type."""
|
||||||
|
|
||||||
|
vid: str
|
||||||
|
pid: str
|
||||||
|
serial_number: str | None
|
||||||
|
manufacturer: str | None
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
class HardwareInfo:
|
class HardwareInfo:
|
||||||
"""Hardware info type."""
|
"""Hardware info type."""
|
||||||
|
|
||||||
name: str | None
|
name: str | None
|
||||||
board: BoardInfo | None
|
board: BoardInfo | None
|
||||||
|
dongles: list[USBInfo] | None
|
||||||
url: str | None
|
url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""The Home Assistant Sky Connect integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components import usb
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up a Home Assistant Sky Connect config entry."""
|
||||||
|
usb_info = usb.UsbServiceInfo(
|
||||||
|
device=entry.data["device"],
|
||||||
|
vid=entry.data["vid"],
|
||||||
|
pid=entry.data["pid"],
|
||||||
|
serial_number=entry.data["serial_number"],
|
||||||
|
manufacturer=entry.data["manufacturer"],
|
||||||
|
description=entry.data["description"],
|
||||||
|
)
|
||||||
|
if not usb.async_is_plugged_in(hass, entry.data):
|
||||||
|
# The USB dongle is not plugged in
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
await hass.config_entries.flow.async_init(
|
||||||
|
"zha",
|
||||||
|
context={"source": "usb"},
|
||||||
|
data=usb_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return True
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Config flow for the Home Assistant Sky Connect integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components import usb
|
||||||
|
from homeassistant.config_entries import ConfigFlow
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantSkyConnectConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Home Assistant Sky Connect."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult:
|
||||||
|
"""Handle usb discovery."""
|
||||||
|
device = discovery_info.device
|
||||||
|
vid = discovery_info.vid
|
||||||
|
pid = discovery_info.pid
|
||||||
|
serial_number = discovery_info.serial_number
|
||||||
|
manufacturer = discovery_info.manufacturer
|
||||||
|
description = discovery_info.description
|
||||||
|
unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
|
||||||
|
if await self.async_set_unique_id(unique_id):
|
||||||
|
self._abort_if_unique_id_configured(updates={"device": device})
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
data={
|
||||||
|
"device": device,
|
||||||
|
"vid": vid,
|
||||||
|
"pid": pid,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"manufacturer": manufacturer,
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Constants for the Home Assistant Sky Connect integration."""
|
||||||
|
|
||||||
|
DOMAIN = "homeassistant_sky_connect"
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""The Home Assistant Sky Connect hardware platform."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.hardware.models import HardwareInfo, USBInfo
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
DONGLE_NAME = "Home Assistant Sky Connect"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_info(hass: HomeAssistant) -> HardwareInfo:
|
||||||
|
"""Return board info."""
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
|
||||||
|
dongles = [
|
||||||
|
USBInfo(
|
||||||
|
vid=entry.data["vid"],
|
||||||
|
pid=entry.data["pid"],
|
||||||
|
serial_number=entry.data["serial_number"],
|
||||||
|
manufacturer=entry.data["manufacturer"],
|
||||||
|
description=entry.data["description"],
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
return HardwareInfo(
|
||||||
|
board=None,
|
||||||
|
dongles=dongles,
|
||||||
|
name=DONGLE_NAME,
|
||||||
|
url=None,
|
||||||
|
)
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"domain": "homeassistant_sky_connect",
|
||||||
|
"name": "Home Assistant Sky Connect",
|
||||||
|
"config_flow": false,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
|
||||||
|
"dependencies": ["hardware", "usb"],
|
||||||
|
"codeowners": ["@home-assistant/core"],
|
||||||
|
"integration_type": "hardware",
|
||||||
|
"usb": [
|
||||||
|
{
|
||||||
|
"vid": "10C4",
|
||||||
|
"pid": "EA60",
|
||||||
|
"description": "*skyconnect v1.0*",
|
||||||
|
"known_devices": ["SkyConnect v1.0"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ def async_info(hass: HomeAssistant) -> HardwareInfo:
|
||||||
model=MODEL,
|
model=MODEL,
|
||||||
revision=None,
|
revision=None,
|
||||||
),
|
),
|
||||||
|
dongles=None,
|
||||||
name=BOARD_NAME,
|
name=BOARD_NAME,
|
||||||
url=None,
|
url=None,
|
||||||
)
|
)
|
||||||
|
|
|
@ -49,6 +49,7 @@ def async_info(hass: HomeAssistant) -> HardwareInfo:
|
||||||
model=MODELS.get(board),
|
model=MODELS.get(board),
|
||||||
revision=None,
|
revision=None,
|
||||||
),
|
),
|
||||||
|
dongles=None,
|
||||||
name=BOARD_NAMES.get(board, f"Unknown Raspberry Pi model '{board}'"),
|
name=BOARD_NAMES.get(board, f"Unknown Raspberry Pi model '{board}'"),
|
||||||
url=None,
|
url=None,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""The USB Discovery integration."""
|
"""The USB Discovery integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine, Mapping
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
|
@ -97,6 +97,27 @@ def _fnmatch_lower(name: str | None, pattern: str) -> bool:
|
||||||
return fnmatch.fnmatch(name.lower(), pattern)
|
return fnmatch.fnmatch(name.lower(), pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_matching(device: USBDevice, matcher: Mapping[str, str]) -> bool:
|
||||||
|
"""Return True if a device matches."""
|
||||||
|
if "vid" in matcher and device.vid != matcher["vid"]:
|
||||||
|
return False
|
||||||
|
if "pid" in matcher and device.pid != matcher["pid"]:
|
||||||
|
return False
|
||||||
|
if "serial_number" in matcher and not _fnmatch_lower(
|
||||||
|
device.serial_number, matcher["serial_number"]
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if "manufacturer" in matcher and not _fnmatch_lower(
|
||||||
|
device.manufacturer, matcher["manufacturer"]
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if "description" in matcher and not _fnmatch_lower(
|
||||||
|
device.description, matcher["description"]
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class USBDiscovery:
|
class USBDiscovery:
|
||||||
"""Manage USB Discovery."""
|
"""Manage USB Discovery."""
|
||||||
|
|
||||||
|
@ -179,23 +200,8 @@ class USBDiscovery:
|
||||||
self.seen.add(device_tuple)
|
self.seen.add(device_tuple)
|
||||||
matched = []
|
matched = []
|
||||||
for matcher in self.usb:
|
for matcher in self.usb:
|
||||||
if "vid" in matcher and device.vid != matcher["vid"]:
|
if _is_matching(device, matcher):
|
||||||
continue
|
matched.append(matcher)
|
||||||
if "pid" in matcher and device.pid != matcher["pid"]:
|
|
||||||
continue
|
|
||||||
if "serial_number" in matcher and not _fnmatch_lower(
|
|
||||||
device.serial_number, matcher["serial_number"]
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if "manufacturer" in matcher and not _fnmatch_lower(
|
|
||||||
device.manufacturer, matcher["manufacturer"]
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if "description" in matcher and not _fnmatch_lower(
|
|
||||||
device.description, matcher["description"]
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
matched.append(matcher)
|
|
||||||
|
|
||||||
if not matched:
|
if not matched:
|
||||||
return
|
return
|
||||||
|
@ -265,3 +271,14 @@ async def websocket_usb_scan(
|
||||||
if not usb_discovery.observer_active:
|
if not usb_discovery.observer_active:
|
||||||
await usb_discovery.async_request_scan_serial()
|
await usb_discovery.async_request_scan_serial()
|
||||||
connection.send_result(msg["id"])
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_is_plugged_in(hass: HomeAssistant, matcher: Mapping) -> bool:
|
||||||
|
"""Return True is a USB device is present."""
|
||||||
|
usb_discovery: USBDiscovery = hass.data[DOMAIN]
|
||||||
|
for device_tuple in usb_discovery.seen:
|
||||||
|
device = USBDevice(*device_tuple)
|
||||||
|
if _is_matching(device, matcher):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
|
@ -138,11 +138,11 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
self._set_confirm_only()
|
self._set_confirm_only()
|
||||||
self.context["title_placeholders"] = {CONF_NAME: self._title}
|
self.context["title_placeholders"] = {CONF_NAME: self._title}
|
||||||
return await self.async_step_confirm()
|
return await self.async_step_confirm_usb()
|
||||||
|
|
||||||
async def async_step_confirm(self, user_input=None):
|
async def async_step_confirm_usb(self, user_input=None):
|
||||||
"""Confirm a discovery."""
|
"""Confirm a USB discovery."""
|
||||||
if user_input is not None:
|
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
|
||||||
auto_detected_data = await detect_radios(self._device_path)
|
auto_detected_data = await detect_radios(self._device_path)
|
||||||
if auto_detected_data is None:
|
if auto_detected_data is None:
|
||||||
# This path probably will not happen now that we have
|
# This path probably will not happen now that we have
|
||||||
|
@ -155,7 +155,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="confirm",
|
step_id="confirm_usb",
|
||||||
description_placeholders={CONF_NAME: self._title},
|
description_placeholders={CONF_NAME: self._title},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -58,6 +58,7 @@ NO_IOT_CLASS = [
|
||||||
"history",
|
"history",
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
"homeassistant_alerts",
|
"homeassistant_alerts",
|
||||||
|
"homeassistant_sky_connect",
|
||||||
"homeassistant_yellow",
|
"homeassistant_yellow",
|
||||||
"image",
|
"image",
|
||||||
"input_boolean",
|
"input_boolean",
|
||||||
|
|
|
@ -48,6 +48,7 @@ async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None:
|
||||||
"model": "odroid-n2",
|
"model": "odroid-n2",
|
||||||
"revision": None,
|
"revision": None,
|
||||||
},
|
},
|
||||||
|
"dongles": None,
|
||||||
"name": "Home Assistant Blue / Hardkernel Odroid-N2",
|
"name": "Home Assistant Blue / Hardkernel Odroid-N2",
|
||||||
"url": None,
|
"url": None,
|
||||||
}
|
}
|
||||||
|
|
1
tests/components/homeassistant_sky_connect/__init__.py
Normal file
1
tests/components/homeassistant_sky_connect/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Home Assistant Sky Connect integration."""
|
14
tests/components/homeassistant_sky_connect/conftest.py
Normal file
14
tests/components/homeassistant_sky_connect/conftest.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
"""Test fixtures for the Home Assistant Sky Connect integration."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_zha():
|
||||||
|
"""Mock the zha integration."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.zha.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
yield
|
147
tests/components/homeassistant_sky_connect/test_config_flow.py
Normal file
147
tests/components/homeassistant_sky_connect/test_config_flow.py
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
"""Test the Home Assistant Sky Connect config flow."""
|
||||||
|
import copy
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components import homeassistant_sky_connect, usb
|
||||||
|
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
USB_DATA = usb.UsbServiceInfo(
|
||||||
|
device="bla_device",
|
||||||
|
vid="bla_vid",
|
||||||
|
pid="bla_pid",
|
||||||
|
serial_number="bla_serial_number",
|
||||||
|
manufacturer="bla_manufacturer",
|
||||||
|
description="bla_description",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the config flow."""
|
||||||
|
# mock_integration(hass, MockModule("hassio"))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "usb"}, data=USB_DATA
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_data = {
|
||||||
|
"device": USB_DATA.device,
|
||||||
|
"vid": USB_DATA.vid,
|
||||||
|
"pid": USB_DATA.pid,
|
||||||
|
"serial_number": USB_DATA.serial_number,
|
||||||
|
"manufacturer": USB_DATA.manufacturer,
|
||||||
|
"description": USB_DATA.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Home Assistant Sky Connect"
|
||||||
|
assert result["data"] == expected_data
|
||||||
|
assert result["options"] == {}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
|
assert config_entry.data == expected_data
|
||||||
|
assert config_entry.options == {}
|
||||||
|
assert config_entry.title == "Home Assistant Sky Connect"
|
||||||
|
assert (
|
||||||
|
config_entry.unique_id
|
||||||
|
== f"{USB_DATA.vid}:{USB_DATA.pid}_{USB_DATA.serial_number}_{USB_DATA.manufacturer}_{USB_DATA.description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_unique_id(hass: HomeAssistant) -> None:
|
||||||
|
"""Test only a single entry is allowed for a dongle."""
|
||||||
|
# Setup an existing config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={},
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
unique_id=f"{USB_DATA.vid}:{USB_DATA.pid}_{USB_DATA.serial_number}_{USB_DATA.manufacturer}_{USB_DATA.description}",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "usb"}, data=USB_DATA
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
mock_setup_entry.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_multiple_entries(hass: HomeAssistant) -> None:
|
||||||
|
"""Test multiple entries are allowed."""
|
||||||
|
# Setup an existing config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={},
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
unique_id=f"{USB_DATA.vid}:{USB_DATA.pid}_{USB_DATA.serial_number}_{USB_DATA.manufacturer}_{USB_DATA.description}",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
usb_data = copy.copy(USB_DATA)
|
||||||
|
usb_data.serial_number = "bla_serial_number_2"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "usb"}, data=usb_data
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_update_device(hass: HomeAssistant) -> None:
|
||||||
|
"""Test updating device path."""
|
||||||
|
# Setup an existing config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={},
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
unique_id=f"{USB_DATA.vid}:{USB_DATA.pid}_{USB_DATA.serial_number}_{USB_DATA.manufacturer}_{USB_DATA.description}",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
usb_data = copy.copy(USB_DATA)
|
||||||
|
usb_data.device = "bla_device_2"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry, patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.async_unload_entry",
|
||||||
|
wraps=homeassistant_sky_connect.async_unload_entry,
|
||||||
|
) as mock_unload_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "usb"}, data=usb_data
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
assert len(mock_unload_entry.mock_calls) == 1
|
85
tests/components/homeassistant_sky_connect/test_hardware.py
Normal file
85
tests/components/homeassistant_sky_connect/test_hardware.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
"""Test the Home Assistant Sky Connect hardware platform."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
CONFIG_ENTRY_DATA = {
|
||||||
|
"device": "bla_device",
|
||||||
|
"vid": "bla_vid",
|
||||||
|
"pid": "bla_pid",
|
||||||
|
"serial_number": "bla_serial_number",
|
||||||
|
"manufacturer": "bla_manufacturer",
|
||||||
|
"description": "bla_description",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_ENTRY_DATA_2 = {
|
||||||
|
"device": "bla_device_2",
|
||||||
|
"vid": "bla_vid_2",
|
||||||
|
"pid": "bla_pid_2",
|
||||||
|
"serial_number": "bla_serial_number_2",
|
||||||
|
"manufacturer": "bla_manufacturer_2",
|
||||||
|
"description": "bla_description_2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None:
|
||||||
|
"""Test we can get the board info."""
|
||||||
|
# Setup the config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
unique_id="unique_1",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
config_entry_2 = MockConfigEntry(
|
||||||
|
data=CONFIG_ENTRY_DATA_2,
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
unique_id="unique_2",
|
||||||
|
)
|
||||||
|
config_entry_2.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
await client.send_json({"id": 1, "type": "hardware/info"})
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
assert msg["id"] == 1
|
||||||
|
assert msg["success"]
|
||||||
|
assert msg["result"] == {
|
||||||
|
"hardware": [
|
||||||
|
{
|
||||||
|
"board": None,
|
||||||
|
"dongles": [
|
||||||
|
{
|
||||||
|
"vid": "bla_vid",
|
||||||
|
"pid": "bla_pid",
|
||||||
|
"serial_number": "bla_serial_number",
|
||||||
|
"manufacturer": "bla_manufacturer",
|
||||||
|
"description": "bla_description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vid": "bla_vid_2",
|
||||||
|
"pid": "bla_pid_2",
|
||||||
|
"serial_number": "bla_serial_number_2",
|
||||||
|
"manufacturer": "bla_manufacturer_2",
|
||||||
|
"description": "bla_description_2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"name": "Home Assistant Sky Connect",
|
||||||
|
"url": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
101
tests/components/homeassistant_sky_connect/test_init.py
Normal file
101
tests/components/homeassistant_sky_connect/test_init.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
"""Test the Home Assistant Sky Connect integration."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.homeassistant_sky_connect.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
CONFIG_ENTRY_DATA = {
|
||||||
|
"device": "bla_device",
|
||||||
|
"vid": "bla_vid",
|
||||||
|
"pid": "bla_pid",
|
||||||
|
"serial_number": "bla_serial_number",
|
||||||
|
"manufacturer": "bla_manufacturer",
|
||||||
|
"description": "bla_description",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"onboarded, num_entries, num_flows", ((False, 1, 0), (True, 0, 1))
|
||||||
|
)
|
||||||
|
async def test_setup_entry(
|
||||||
|
hass: HomeAssistant, onboarded, num_entries, num_flows
|
||||||
|
) -> None:
|
||||||
|
"""Test setup of a config entry, including setup of zha."""
|
||||||
|
# Setup the config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_is_plugged_in, patch(
|
||||||
|
"homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded
|
||||||
|
), patch(
|
||||||
|
"zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_is_plugged_in.mock_calls) == 1
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries("zha")) == num_entries
|
||||||
|
assert len(hass.config_entries.flow.async_progress_by_handler("zha")) == num_flows
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_zha(hass: HomeAssistant) -> None:
|
||||||
|
"""Test zha gets the right config."""
|
||||||
|
# Setup the config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_is_plugged_in, patch(
|
||||||
|
"homeassistant.components.onboarding.async_is_onboarded", return_value=False
|
||||||
|
), patch(
|
||||||
|
"zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True
|
||||||
|
):
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_is_plugged_in.mock_calls) == 1
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_entries("zha")[0]
|
||||||
|
assert config_entry.data == {
|
||||||
|
"device": {"baudrate": 115200, "flow_control": None, "path": "bla_device"},
|
||||||
|
"radio_type": "znp",
|
||||||
|
}
|
||||||
|
assert config_entry.options == {}
|
||||||
|
assert config_entry.title == "bla_description"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_wait_usb(hass: HomeAssistant) -> None:
|
||||||
|
"""Test setup of a config entry when the dongle is not plugged in."""
|
||||||
|
# Setup the config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data=CONFIG_ENTRY_DATA,
|
||||||
|
domain=DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Home Assistant Sky Connect",
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.homeassistant_sky_connect.usb.async_is_plugged_in",
|
||||||
|
return_value=False,
|
||||||
|
) as mock_is_plugged_in:
|
||||||
|
assert not await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_is_plugged_in.mock_calls) == 1
|
||||||
|
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
|
@ -48,6 +48,7 @@ async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None:
|
||||||
"model": "yellow",
|
"model": "yellow",
|
||||||
"revision": None,
|
"revision": None,
|
||||||
},
|
},
|
||||||
|
"dongles": None,
|
||||||
"name": "Home Assistant Yellow",
|
"name": "Home Assistant Yellow",
|
||||||
"url": None,
|
"url": None,
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None:
|
||||||
"model": "1",
|
"model": "1",
|
||||||
"revision": None,
|
"revision": None,
|
||||||
},
|
},
|
||||||
|
"dongles": None,
|
||||||
"name": "Raspberry Pi",
|
"name": "Raspberry Pi",
|
||||||
"url": None,
|
"url": None,
|
||||||
}
|
}
|
||||||
|
|
|
@ -833,3 +833,45 @@ def test_human_readable_device_name():
|
||||||
assert "Silicon Labs" in name
|
assert "Silicon Labs" in name
|
||||||
assert "10C4" in name
|
assert "10C4" in name
|
||||||
assert "8A2A" in name
|
assert "8A2A" in name
|
||||||
|
|
||||||
|
|
||||||
|
async def test_async_is_plugged_in(hass, hass_ws_client):
|
||||||
|
"""Test async_is_plugged_in."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
matcher = {
|
||||||
|
"vid": "3039",
|
||||||
|
"pid": "3039",
|
||||||
|
}
|
||||||
|
|
||||||
|
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=[]), patch.object(
|
||||||
|
hass.config_entries.flow, "async_init"
|
||||||
|
):
|
||||||
|
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()
|
||||||
|
assert not usb.async_is_plugged_in(hass, matcher)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.usb.comports", return_value=mock_comports
|
||||||
|
), patch.object(hass.config_entries.flow, "async_init"):
|
||||||
|
ws_client = await hass_ws_client(hass)
|
||||||
|
await ws_client.send_json({"id": 1, "type": "usb/scan"})
|
||||||
|
response = await ws_client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert usb.async_is_plugged_in(hass, matcher)
|
||||||
|
|
|
@ -228,7 +228,7 @@ async def test_discovery_via_usb(detect_mock, hass):
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
assert result["step_id"] == "confirm"
|
assert result["step_id"] == "confirm_usb"
|
||||||
|
|
||||||
with patch("homeassistant.components.zha.async_setup_entry"):
|
with patch("homeassistant.components.zha.async_setup_entry"):
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
@ -264,7 +264,7 @@ async def test_zigate_discovery_via_usb(detect_mock, hass):
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
assert result["step_id"] == "confirm"
|
assert result["step_id"] == "confirm_usb"
|
||||||
|
|
||||||
with patch("homeassistant.components.zha.async_setup_entry"):
|
with patch("homeassistant.components.zha.async_setup_entry"):
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
@ -298,7 +298,7 @@ async def test_discovery_via_usb_no_radio(detect_mock, hass):
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
assert result["step_id"] == "confirm"
|
assert result["step_id"] == "confirm_usb"
|
||||||
|
|
||||||
with patch("homeassistant.components.zha.async_setup_entry"):
|
with patch("homeassistant.components.zha.async_setup_entry"):
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
@ -451,7 +451,7 @@ async def test_discovery_via_usb_deconz_ignored(detect_mock, hass):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
assert result["step_id"] == "confirm"
|
assert result["step_id"] == "confirm_usb"
|
||||||
|
|
||||||
|
|
||||||
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
@patch("zigpy_znp.zigbee.application.ControllerApplication.probe", return_value=True)
|
||||||
|
|
Loading…
Add table
Reference in a new issue