From f1884d34e9a68993d0461e99c612cbf1030a48f2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz <Shutgun@users.noreply.github.com> Date: Thu, 28 Oct 2021 22:42:10 +0200 Subject: [PATCH] Add devolo home network integration (#45866) Co-authored-by: Markus Bong <2Fake1987@gmail.com> Co-authored-by: Markus Bong <Markus.Bong@devolo.de> --- .strict-typing | 1 + CODEOWNERS | 1 + .../devolo_home_network/__init__.py | 122 +++++++++++++ .../devolo_home_network/config_flow.py | 108 +++++++++++ .../components/devolo_home_network/const.py | 17 ++ .../components/devolo_home_network/entity.py | 37 ++++ .../devolo_home_network/manifest.json | 11 ++ .../components/devolo_home_network/sensor.py | 122 +++++++++++++ .../devolo_home_network/strings.json | 25 +++ .../devolo_home_network/translations/en.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 3 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../devolo_home_network/__init__.py | 31 ++++ .../devolo_home_network/conftest.py | 41 +++++ tests/components/devolo_home_network/const.py | 64 +++++++ .../devolo_home_network/test_config_flow.py | 172 ++++++++++++++++++ .../devolo_home_network/test_init.py | 61 +++++++ .../devolo_home_network/test_sensor.py | 148 +++++++++++++++ 21 files changed, 1007 insertions(+) create mode 100644 homeassistant/components/devolo_home_network/__init__.py create mode 100644 homeassistant/components/devolo_home_network/config_flow.py create mode 100644 homeassistant/components/devolo_home_network/const.py create mode 100644 homeassistant/components/devolo_home_network/entity.py create mode 100644 homeassistant/components/devolo_home_network/manifest.json create mode 100644 homeassistant/components/devolo_home_network/sensor.py create mode 100644 homeassistant/components/devolo_home_network/strings.json create mode 100644 homeassistant/components/devolo_home_network/translations/en.json create mode 100644 tests/components/devolo_home_network/__init__.py create mode 100644 tests/components/devolo_home_network/conftest.py create mode 100644 tests/components/devolo_home_network/const.py create mode 100644 tests/components/devolo_home_network/test_config_flow.py create mode 100644 tests/components/devolo_home_network/test_init.py create mode 100644 tests/components/devolo_home_network/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 685c87aa094..8977c9f68c8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -32,6 +32,7 @@ homeassistant.components.crownstone.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* +homeassistant.components.devolo_home_network.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* diff --git a/CODEOWNERS b/CODEOWNERS index 2612cc3fd18..680424fbdea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -118,6 +118,7 @@ homeassistant/components/denonavr/* @ol-iver @starkillerOG homeassistant/components/derivative/* @afaucogney homeassistant/components/device_automation/* @home-assistant/core homeassistant/components/devolo_home_control/* @2Fake @Shutgun +homeassistant/components/devolo_home_network/* @2Fake @Shutgun homeassistant/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py new file mode 100644 index 00000000000..f427e5acbfc --- /dev/null +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -0,0 +1,122 @@ +"""The devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import async_timeout +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DeviceNotFound, DeviceUnavailable + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + LONG_UPDATE_INTERVAL, + NEIGHBORING_WIFI_NETWORKS, + PLATFORMS, + SHORT_UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up devolo Home Network from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + async_client = get_async_client(hass) + + try: + device = Device( + ip=entry.data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance + ) + await device.async_connect(session_instance=async_client) + except DeviceNotFound as err: + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}" + ) from err + + async def async_update_connected_plc_devices() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.plcnet.async_get_network_overview() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_connected_station() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(10): + return await device.device.async_get_wifi_connected_station() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def async_update_wifi_neighbor_access_points() -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(30): + return await device.device.async_get_wifi_neighbor_access_points() # type: ignore[no-any-return, union-attr] + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + + async def disconnect(event: Event) -> None: + """Disconnect from device.""" + await device.async_disconnect() + + coordinators: dict[str, DataUpdateCoordinator] = {} + if device.plcnet: + coordinators[CONNECTED_PLC_DEVICES] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_PLC_DEVICES, + update_method=async_update_connected_plc_devices, + update_interval=LONG_UPDATE_INTERVAL, + ) + if device.device and "wifi1" in device.device.features: + coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=CONNECTED_WIFI_CLIENTS, + update_method=async_update_wifi_connected_station, + update_interval=SHORT_UPDATE_INTERVAL, + ) + coordinators[NEIGHBORING_WIFI_NETWORKS] = DataUpdateCoordinator( + hass, + _LOGGER, + name=NEIGHBORING_WIFI_NETWORKS, + update_method=async_update_wifi_neighbor_access_points, + update_interval=LONG_UPDATE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id] = {"device": device, "coordinators": coordinators} + + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py new file mode 100644 index 00000000000..fa0ee983b69 --- /dev/null +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for devolo Home Network integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DeviceNotFound +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, TITLE + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + zeroconf_instance = await zeroconf.async_get_instance(hass) + async_client = get_async_client(hass) + + device = Device(data[CONF_IP_ADDRESS], zeroconf_instance=zeroconf_instance) + + await device.async_connect(session_instance=async_client) + await device.async_disconnect() + + return { + SERIAL_NUMBER: str(device.serial_number), + TITLE: device.hostname.split(".")[0], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for devolo Home Network.""" + + VERSION = 1 + + async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + info = await validate_input(self.hass, user_input) + except DeviceNotFound: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[SERIAL_NUMBER], raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[TITLE], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle zerooconf discovery.""" + if discovery_info["properties"]["MT"] in ["2600", "2601"]: + return self.async_abort(reason="home_control") + + await self.async_set_unique_id(discovery_info["properties"]["SN"]) + self._abort_if_unique_id_configured() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context[CONF_HOST] = discovery_info["host"] + self.context["title_placeholders"] = { + PRODUCT: discovery_info["properties"]["Product"], + CONF_NAME: discovery_info["hostname"].split(".")[0], + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: ConfigType | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + title = self.context["title_placeholders"][CONF_NAME] + if user_input is not None: + data = { + CONF_IP_ADDRESS: self.context[CONF_HOST], + } + return self.async_create_entry(title=title, data=data) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"host_name": title}, + ) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py new file mode 100644 index 00000000000..9276acebe41 --- /dev/null +++ b/homeassistant/components/devolo_home_network/const.py @@ -0,0 +1,17 @@ +"""Constants for the devolo Home Network integration.""" + +from datetime import timedelta + +DOMAIN = "devolo_home_network" +PLATFORMS = ["sensor"] + +PRODUCT = "product" +SERIAL_NUMBER = "serial_number" +TITLE = "title" + +LONG_UPDATE_INTERVAL = timedelta(minutes=5) +SHORT_UPDATE_INTERVAL = timedelta(seconds=15) + +CONNECTED_PLC_DEVICES = "connected_plc_devices" +CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py new file mode 100644 index 00000000000..dbfe0e4035a --- /dev/null +++ b/homeassistant/components/devolo_home_network/entity.py @@ -0,0 +1,37 @@ +"""Generic platform.""" +from __future__ import annotations + +from devolo_plc_api.device import Device + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class DevoloEntity(CoordinatorEntity): + """Representation of a devolo home network device.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, device: Device, device_name: str + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + + self._device = device + self._device_name = device_name + + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{self._device.ip}", + identifiers={(DOMAIN, str(self._device.serial_number))}, + manufacturer="devolo", + model=self._device.product, + name=self._device_name, + sw_version=self._device.firmware_version, + ) + self._attr_unique_id = ( + f"{self._device.serial_number}_{self.entity_description.key}" + ) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json new file mode 100644 index 00000000000..987211ca631 --- /dev/null +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "devolo_home_network", + "name": "devolo Home Network", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/devolo_home_network", + "requirements": ["devolo-plc-api==0.6.2"], + "zeroconf": ["_dvl-deviceapi._tcp.local."], + "codeowners": ["@2Fake", "@Shutgun"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py new file mode 100644 index 00000000000..3b0175d8c31 --- /dev/null +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -0,0 +1,122 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from devolo_plc_api.device import Device + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONNECTED_PLC_DEVICES, + CONNECTED_WIFI_CLIENTS, + DOMAIN, + NEIGHBORING_WIFI_NETWORKS, +) +from .entity import DevoloEntity + + +@dataclass +class DevoloSensorRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[dict[str, Any]], int] + + +@dataclass +class DevoloSensorEntityDescription( + SensorEntityDescription, DevoloSensorRequiredKeysMixin +): + """Describes devolo sensor entity.""" + + +SENSOR_TYPES: dict[str, DevoloSensorEntityDescription] = { + CONNECTED_PLC_DEVICES: DevoloSensorEntityDescription( + key=CONNECTED_PLC_DEVICES, + entity_registry_enabled_default=False, + icon="mdi:lan", + name="Connected PLC devices", + value_func=lambda data: len( + {device["mac_address_from"] for device in data["network"]["data_rates"]} + ), + ), + CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription( + key=CONNECTED_WIFI_CLIENTS, + entity_registry_enabled_default=True, + icon="mdi:wifi", + name="Connected Wifi clients", + value_func=lambda data: len(data["connected_stations"]), + ), + NEIGHBORING_WIFI_NETWORKS: DevoloSensorEntityDescription( + key=NEIGHBORING_WIFI_NETWORKS, + entity_registry_enabled_default=False, + icon="mdi:wifi-marker", + name="Neighboring Wifi networks", + value_func=lambda data: len(data["neighbor_aps"]), + ), +} + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id][ + "coordinators" + ] + + entities: list[DevoloSensorEntity] = [] + if device.plcnet: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_PLC_DEVICES], + device, + entry.title, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloSensorEntity( + coordinators[CONNECTED_WIFI_CLIENTS], + SENSOR_TYPES[CONNECTED_WIFI_CLIENTS], + device, + entry.title, + ) + ) + entities.append( + DevoloSensorEntity( + coordinators[NEIGHBORING_WIFI_NETWORKS], + SENSOR_TYPES[NEIGHBORING_WIFI_NETWORKS], + device, + entry.title, + ) + ) + async_add_entities(entities) + + +class DevoloSensorEntity(DevoloEntity, SensorEntity): + """Representation of a devolo sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: DevoloSensorEntityDescription, + device: Device, + device_name: str, + ) -> None: + """Initialize entity.""" + self.entity_description: DevoloSensorEntityDescription = description + super().__init__(coordinator, device, device_name) + + @property + def native_value(self) -> int: + """State of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json new file mode 100644 index 00000000000..685e139d2b8 --- /dev/null +++ b/homeassistant/components/devolo_home_network/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{product} ({name})", + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo home network device" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "home_control": "The devolo Home Control Central Unit does not work with this integration." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_network/translations/en.json b/homeassistant/components/devolo_home_network/translations/en.json new file mode 100644 index 00000000000..52e51d953c1 --- /dev/null +++ b/homeassistant/components/devolo_home_network/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "flow_title": "{product} ({name})", + "step": { + "user": { + "data": { + "ip_address": "IP Address" + }, + "description": "Do you want to start set up?" + }, + "zeroconf_confirm": { + "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo home network device" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aef37105170..b45c06b10b5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -60,6 +60,7 @@ FLOWS = [ "deconz", "denonavr", "devolo_home_control", + "devolo_home_network", "dexcom", "dialogflow", "directv", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8ab7a7f8e31..aec93dd36c9 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -58,6 +58,9 @@ ZEROCONF = { "_dvl-deviceapi._tcp.local.": [ { "domain": "devolo_home_control" + }, + { + "domain": "devolo_home_network" } ], "_easylink._tcp.local.": [ diff --git a/mypy.ini b/mypy.ini index 5e9501d5407..4192e1a10ea 100644 --- a/mypy.ini +++ b/mypy.ini @@ -363,6 +363,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.devolo_home_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dlna_dmr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index dbbd93deb1b..866ebdc8d29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -536,6 +536,9 @@ denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 +# homeassistant.components.devolo_home_network +devolo-plc-api==0.6.2 + # homeassistant.components.directv directv==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7dff0f66554..a11eb5c9fff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,6 +335,9 @@ denonavr==0.10.9 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 +# homeassistant.components.devolo_home_network +devolo-plc-api==0.6.2 + # homeassistant.components.directv directv==0.4.0 diff --git a/tests/components/devolo_home_network/__init__.py b/tests/components/devolo_home_network/__init__.py new file mode 100644 index 00000000000..b9026d8453b --- /dev/null +++ b/tests/components/devolo_home_network/__init__.py @@ -0,0 +1,31 @@ +"""Tests for the devolo Home Network integration.""" + +from typing import Any + +from devolo_plc_api.device_api.deviceapi import DeviceApi +from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi + +from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant + +from .const import DISCOVERY_INFO, IP + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + CONF_IP_ADDRESS: IP, + } + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + + return entry + + +async def async_connect(self, session_instance: Any = None): + """Give a mocked device the needed properties.""" + self.plcnet = PlcNetApi(IP, None, DISCOVERY_INFO) + self.device = DeviceApi(IP, None, DISCOVERY_INFO) diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py new file mode 100644 index 00000000000..ab798cd5cfd --- /dev/null +++ b/tests/components/devolo_home_network/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from . import async_connect +from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NEIGHBOR_ACCESS_POINTS, PLCNET + + +@pytest.fixture() +def mock_device(): + """Mock connecting to a devolo home network device.""" + with patch("devolo_plc_api.device.Device.async_connect", async_connect), patch( + "devolo_plc_api.device.Device.async_disconnect" + ), patch( + "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", + new=AsyncMock(return_value=CONNECTED_STATIONS), + ), patch( + "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", + new=AsyncMock(return_value=NEIGHBOR_ACCESS_POINTS), + ), patch( + "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", + new=AsyncMock(return_value=PLCNET), + ): + yield + + +@pytest.fixture(name="info") +def mock_validate_input(): + """Mock setup entry and user input.""" + info = { + "serial_number": DISCOVERY_INFO["properties"]["SN"], + "title": DISCOVERY_INFO["properties"]["Product"], + } + + with patch( + "homeassistant.components.devolo_home_network.config_flow.validate_input", + return_value=info, + ): + yield info diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py new file mode 100644 index 00000000000..e9b2113c4a6 --- /dev/null +++ b/tests/components/devolo_home_network/const.py @@ -0,0 +1,64 @@ +"""Constants used for mocking data.""" + +IP = "1.1.1.1" + +CONNECTED_STATIONS = { + "connected_stations": [ + { + "mac_address": "AA:BB:CC:DD:EE:FF", + "vap_type": "WIFI_VAP_MAIN_AP", + "band": "WIFI_BAND_5G", + "rx_rate": 87800, + "tx_rate": 87800, + } + ], +} + +DISCOVERY_INFO = { + "host": IP, + "port": 14791, + "hostname": "test.local.", + "type": "_dvl-deviceapi._tcp.local.", + "name": "dLAN pro 1200+ WiFi ac._dvl-deviceapi._tcp.local.", + "properties": { + "Path": "abcdefghijkl/deviceapi", + "Version": "v0", + "Product": "dLAN pro 1200+ WiFi ac", + "Features": "reset,update,led,intmtg,wifi1", + "MT": "2730", + "SN": "1234567890", + "FirmwareVersion": "5.6.1", + "FirmwareDate": "2020-10-23", + "PS": "", + "PlcMacAddress": "AA:BB:CC:DD:EE:FF", + }, +} + +DISCOVERY_INFO_WRONG_DEVICE = {"properties": {"MT": "2600"}} + +NEIGHBOR_ACCESS_POINTS = { + "neighbor_aps": [ + { + "mac_address": "AA:BB:CC:DD:EE:FF", + "ssid": "wifi", + "band": "WIFI_BAND_2G", + "channel": 1, + "signal": -73, + "signal_bars": 1, + } + ] +} + +PLCNET = { + "network": { + "data_rates": [ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 0.0, + "tx_rate": 0.0, + }, + ], + "devices": [], + } +} diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py new file mode 100644 index 00000000000..0be07be9a00 --- /dev/null +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -0,0 +1,172 @@ +"""Test the devolo Home Network config flow.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceNotFound +import pytest + +from homeassistant import config_entries +from homeassistant.components.devolo_home_network import config_flow +from homeassistant.components.devolo_home_network.const import ( + DOMAIN, + SERIAL_NUMBER, + TITLE, +) +from homeassistant.const import CONF_BASE, CONF_IP_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .const import DISCOVERY_INFO, DISCOVERY_INFO_WRONG_DEVICE, IP + + +async def test_form(hass: HomeAssistant, info: dict[str, Any]): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.devolo_home_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["result"].unique_id == info["serial_number"] + assert result2["title"] == info["title"] + assert result2["data"] == { + CONF_IP_ADDRESS: IP, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "exception_type, expected_error", + [[DeviceNotFound, "cannot_connect"], [Exception, "unknown"]], +) +async def test_form_error(hass: HomeAssistant, exception_type, expected_error): + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.devolo_home_network.config_flow.validate_input", + side_effect=exception_type, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {CONF_BASE: expected_error} + + +async def test_zeroconf(hass: HomeAssistant): + """Test that the zeroconf form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["description_placeholders"] == {"host_name": "test"} + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert ( + context["title_placeholders"][CONF_NAME] + == DISCOVERY_INFO["hostname"].split(".", maxsplit=1)[0] + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test" + assert result2["data"] == { + CONF_IP_ADDRESS: IP, + } + + +async def test_abort_zeroconf_wrong_device(hass: HomeAssistant): + """Test we abort zeroconf for wrong devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_WRONG_DEVICE, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "home_control" + + +@pytest.mark.usefixtures("info") +async def test_abort_if_configued(hass: HomeAssistant): + """Test we abort config flow if already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + + # Abort on concurrent user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: IP, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + # Abort on concurrent zeroconf discovery flow + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_validate_input(hass: HomeAssistant): + """Test input validaton.""" + info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) + assert SERIAL_NUMBER in info + assert TITLE in info diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py new file mode 100644 index 00000000000..66d32e8974d --- /dev/null +++ b/tests/components/devolo_home_network/test_init.py @@ -0,0 +1,61 @@ +"""Test the devolo Home Network integration setup.""" +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceNotFound +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_entry(hass: HomeAssistant): + """Test setup entry.""" + entry = configure_integration(hass) + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ), patch("homeassistant.core.EventBus.async_listen_once"): + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_device_not_found(hass: HomeAssistant): + """Test setup entry.""" + entry = configure_integration(hass) + with patch( + "homeassistant.components.devolo_home_network.Device.async_connect", + side_effect=DeviceNotFound, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_unload_entry(hass: HomeAssistant): + """Test unload entry.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_hass_stop(hass: HomeAssistant): + """Test homeassistant stop event.""" + entry = configure_integration(hass) + with patch( + "homeassistant.components.devolo_home_network.Device.async_disconnect" + ) as async_disconnect: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + assert async_disconnect.assert_called_once diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py new file mode 100644 index 00000000000..100db9005aa --- /dev/null +++ b/tests/components/devolo_home_network/test_sensor.py @@ -0,0 +1,148 @@ +"""Tests for the devolo Home Network sensors.""" +from unittest.mock import patch + +from devolo_plc_api.exceptions.device import DeviceUnavailable +import pytest + +from homeassistant.components.devolo_home_network.const import ( + LONG_UPDATE_INTERVAL, + SHORT_UPDATE_INTERVAL, +) +from homeassistant.components.sensor import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import configure_integration + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_sensor_setup(hass: HomeAssistant): + """Test default setup of the sensor component.""" + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(f"{DOMAIN}.connected_wifi_clients") is not None + assert hass.states.get(f"{DOMAIN}.connected_plc_devices") is None + assert hass.states.get(f"{DOMAIN}.neighboring_wifi_networks") is None + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_connected_wifi_clients(hass: HomeAssistant): + """Test state change of a connected_wifi_clients sensor device.""" + state_key = f"{DOMAIN}.connected_wifi_clients" + + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + # Emulate device failure + with patch( + "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_connected_station", + side_effect=DeviceUnavailable, + ): + async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_neighboring_wifi_networks(hass: HomeAssistant): + """Test state change of a neighboring_wifi_networks sensor device.""" + state_key = f"{DOMAIN}.neighboring_wifi_networks" + entry = configure_integration(hass) + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + # Emulate device failure + with patch( + "devolo_plc_api.device_api.deviceapi.DeviceApi.async_get_wifi_neighbor_access_points", + side_effect=DeviceUnavailable, + ): + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("mock_device") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_update_connected_plc_devices(hass: HomeAssistant): + """Test state change of a connected_plc_devices sensor device.""" + state_key = f"{DOMAIN}.connected_plc_devices" + entry = configure_integration(hass) + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + # Emulate device failure + with patch( + "devolo_plc_api.plcnet_api.plcnetapi.PlcNetApi.async_get_network_overview", + side_effect=DeviceUnavailable, + ): + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == "1" + + await hass.config_entries.async_unload(entry.entry_id) -- GitLab