From 8f6e24aa1ea7061e9b52085deb57c49e9ccf4a86 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 11 Jan 2022 01:23:31 +0100 Subject: [PATCH] Add HomeWizard Energy integration (#55812) Co-authored-by: Matthias Alphart Co-authored-by: Paulus Schoutsen --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/homewizard/__init__.py | 69 ++ .../components/homewizard/config_flow.py | 198 ++++++ homeassistant/components/homewizard/const.py | 30 + .../components/homewizard/coordinator.py | 88 +++ .../components/homewizard/manifest.json | 13 + homeassistant/components/homewizard/sensor.py | 201 ++++++ .../components/homewizard/strings.json | 24 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/homewizard/__init__.py | 1 + tests/components/homewizard/conftest.py | 30 + tests/components/homewizard/generator.py | 27 + .../components/homewizard/test_config_flow.py | 324 +++++++++ .../components/homewizard/test_coordinator.py | 131 ++++ tests/components/homewizard/test_init.py | 173 +++++ tests/components/homewizard/test_sensor.py | 639 ++++++++++++++++++ 21 files changed, 1974 insertions(+) create mode 100644 homeassistant/components/homewizard/__init__.py create mode 100644 homeassistant/components/homewizard/config_flow.py create mode 100644 homeassistant/components/homewizard/const.py create mode 100644 homeassistant/components/homewizard/coordinator.py create mode 100644 homeassistant/components/homewizard/manifest.json create mode 100644 homeassistant/components/homewizard/sensor.py create mode 100644 homeassistant/components/homewizard/strings.json create mode 100644 tests/components/homewizard/__init__.py create mode 100644 tests/components/homewizard/conftest.py create mode 100644 tests/components/homewizard/generator.py create mode 100644 tests/components/homewizard/test_config_flow.py create mode 100644 tests/components/homewizard/test_coordinator.py create mode 100644 tests/components/homewizard/test_init.py create mode 100644 tests/components/homewizard/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 9629f55c3ad..1a291f115f5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,6 +65,7 @@ homeassistant.components.group.* homeassistant.components.guardian.* homeassistant.components.history.* homeassistant.components.homeassistant.triggers.event +homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* diff --git a/CODEOWNERS b/CODEOWNERS index 10100d1c21c..4a8f121e5e6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -392,6 +392,8 @@ homeassistant/components/homekit_controller/* @Jc2k @bdraco tests/components/homekit_controller/* @Jc2k @bdraco homeassistant/components/homematic/* @pvizeli @danielperna84 tests/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/homewizard/* @DCSBL +tests/components/homewizard/* @DCSBL homeassistant/components/honeywell/* @rdfurman tests/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py new file mode 100644 index 00000000000..f17ca648ebb --- /dev/null +++ b/homeassistant/components/homewizard/__init__.py @@ -0,0 +1,69 @@ +"""The Homewizard integration.""" +import asyncio +import logging + +from aiohwenergy import DisabledError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import COORDINATOR, DOMAIN, PLATFORMS +from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Homewizard from a config entry.""" + + _LOGGER.debug("__init__ async_setup_entry") + + # Create coordinator + coordinator = Coordinator(hass, entry.data[CONF_IP_ADDRESS]) + try: + await coordinator.initialize_api() + + except DisabledError: + _LOGGER.error("API is disabled, enable API in HomeWizard Energy app") + return False + + except UpdateFailed as ex: + raise ConfigEntryNotReady from ex + + await coordinator.async_config_entry_first_refresh() + + # Finalize + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR: coordinator, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("__init__ async_unload_entry") + + unload_ok = all( + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ) + ) + ) + + if unload_ok: + config_data = hass.data[DOMAIN].pop(entry.entry_id) + await config_data[COORDINATOR].api.close() + + return unload_ok diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py new file mode 100644 index 00000000000..50a01980308 --- /dev/null +++ b/homeassistant/components/homewizard/config_flow.py @@ -0,0 +1,198 @@ +"""Config flow for Homewizard.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohwenergy +from aiohwenergy.hwenergy import SUPPORTED_DEVICES +import async_timeout +from voluptuous import Required, Schema + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for P1 meter.""" + + VERSION = 1 + config: dict[str, str | int] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + + _LOGGER.debug("config_flow async_step_user") + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=Schema( + { + Required(CONF_IP_ADDRESS): str, + } + ), + errors=None, + ) + + device_info = await self._async_try_connect_and_fetch( + user_input[CONF_IP_ADDRESS] + ) + + # Sets unique ID and aborts if it is already exists + await self._async_set_and_check_unique_id( + { + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PRODUCT_TYPE: device_info[CONF_PRODUCT_TYPE], + CONF_SERIAL: device_info[CONF_SERIAL], + } + ) + + # Add entry + return self.async_create_entry( + title=f"{device_info[CONF_PRODUCT_NAME]} ({device_info[CONF_SERIAL]})", + data={ + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + }, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + _LOGGER.debug("config_flow async_step_zeroconf") + + # Validate doscovery entry + if ( + "api_enabled" not in discovery_info.properties + or "path" not in discovery_info.properties + or "product_name" not in discovery_info.properties + or "product_type" not in discovery_info.properties + or "serial" not in discovery_info.properties + ): + return self.async_abort(reason="invalid_discovery_parameters") + + if (discovery_info.properties["path"]) != "/api/v1": + return self.async_abort(reason="unsupported_api_version") + + if (discovery_info.properties["api_enabled"]) != "1": + return self.async_abort(reason="api_not_enabled") + + # Sets unique ID and aborts if it is already exists + await self._async_set_and_check_unique_id( + { + CONF_IP_ADDRESS: discovery_info.host, + CONF_PRODUCT_TYPE: discovery_info.properties["product_type"], + CONF_SERIAL: discovery_info.properties["serial"], + } + ) + + # Check connection and fetch + device_info: dict[str, Any] = await self._async_try_connect_and_fetch( + discovery_info.host + ) + + # Pass parameters + self.config = { + CONF_IP_ADDRESS: discovery_info.host, + CONF_PRODUCT_TYPE: device_info[CONF_PRODUCT_TYPE], + CONF_PRODUCT_NAME: device_info[CONF_PRODUCT_NAME], + CONF_SERIAL: device_info[CONF_SERIAL], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=f"{self.config[CONF_PRODUCT_NAME]} ({self.config[CONF_SERIAL]})", + data={ + CONF_IP_ADDRESS: self.config[CONF_IP_ADDRESS], + }, + ) + + self._set_confirm_only() + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + CONF_PRODUCT_TYPE: self.config[CONF_PRODUCT_TYPE], + CONF_SERIAL: self.config[CONF_SERIAL], + CONF_IP_ADDRESS: self.config[CONF_IP_ADDRESS], + }, + ) + + @staticmethod + async def _async_try_connect_and_fetch(ip_address: str) -> dict[str, Any]: + """Try to connect.""" + + _LOGGER.debug("config_flow _async_try_connect_and_fetch") + + # Make connection with device + # This is to test the connection and to get info for unique_id + energy_api = aiohwenergy.HomeWizardEnergy(ip_address) + + initialized = False + try: + with async_timeout.timeout(10): + await energy_api.initialize() + if energy_api.device is not None: + initialized = True + + except aiohwenergy.DisabledError as ex: + _LOGGER.error("API disabled, API must be enabled in the app") + raise AbortFlow("api_not_enabled") from ex + + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "Error connecting with Energy Device at %s", + ip_address, + ) + raise AbortFlow("unknown_error") from ex + + finally: + await energy_api.close() + + if not initialized: + _LOGGER.error("Initialization failed") + raise AbortFlow("unknown_error") + + # Validate metadata + if energy_api.device.api_version != "v1": + raise AbortFlow("unsupported_api_version") + + if energy_api.device.product_type not in SUPPORTED_DEVICES: + _LOGGER.error( + "Device (%s) not supported by integration", + energy_api.device.product_type, + ) + raise AbortFlow("device_not_supported") + + return { + CONF_PRODUCT_NAME: energy_api.device.product_name, + CONF_PRODUCT_TYPE: energy_api.device.product_type, + CONF_SERIAL: energy_api.device.serial, + } + + async def _async_set_and_check_unique_id(self, entry_info: dict[str, Any]) -> None: + """Validate if entry exists.""" + + _LOGGER.debug("config_flow _async_set_and_check_unique_id") + + await self.async_set_unique_id( + f"{entry_info[CONF_PRODUCT_TYPE]}_{entry_info[CONF_SERIAL]}" + ) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: entry_info[CONF_IP_ADDRESS]} + ) diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py new file mode 100644 index 00000000000..ce66f7ed2e8 --- /dev/null +++ b/homeassistant/components/homewizard/const.py @@ -0,0 +1,30 @@ +"""Constants for the Homewizard integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import TypedDict + +# Set up. +from aiohwenergy.device import Device + +from homeassistant.helpers.typing import StateType + +DOMAIN = "homewizard" +COORDINATOR = "coordinator" +PLATFORMS = ["sensor"] + +# Platform config. +CONF_SERIAL = "serial" +CONF_PRODUCT_NAME = "product_name" +CONF_PRODUCT_TYPE = "product_type" +CONF_DEVICE = "device" +CONF_DATA = "data" + +UPDATE_INTERVAL = timedelta(seconds=5) + + +class DeviceResponseEntry(TypedDict): + """Dict describing a single response entry.""" + + device: Device + data: dict[str, StateType] diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py new file mode 100644 index 00000000000..bad24f11f1f --- /dev/null +++ b/homeassistant/components/homewizard/coordinator.py @@ -0,0 +1,88 @@ +"""Update coordinator for HomeWizard.""" +from __future__ import annotations + +import asyncio +import logging + +import aiohwenergy +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry + +_LOGGER = logging.getLogger(__name__) + + +class HWEnergyDeviceUpdateCoordinator( + DataUpdateCoordinator[aiohwenergy.HomeWizardEnergy] +): + """Gather data for the energy device.""" + + api: aiohwenergy + + def __init__( + self, + hass: HomeAssistant, + host: str, + ) -> None: + """Initialize Update Coordinator.""" + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.api = aiohwenergy.HomeWizardEnergy(host) + + async def _async_update_data(self) -> DeviceResponseEntry: + """Fetch all device and sensor data from api.""" + + async with async_timeout.timeout(10): + + if self.api.device is None: + await self.initialize_api() + + # Update all properties + try: + if not await self.api.update(): + raise UpdateFailed("Failed to communicate with device") + + except aiohwenergy.DisabledError as ex: + raise UpdateFailed( + "API disabled, API must be enabled in the app" + ) from ex + + except Exception as ex: # pylint: disable=broad-except + raise UpdateFailed( + f"Error connecting with Energy Device at {self.api.host}" + ) from ex + + data: DeviceResponseEntry = { + "device": self.api.device, + "data": {}, + } + + for datapoint in self.api.data.available_datapoints: + data["data"][datapoint] = getattr(self.api.data, datapoint) + + return data + + async def initialize_api(self) -> aiohwenergy: + """Initialize API and validate connection.""" + + try: + await self.api.initialize() + + except (asyncio.TimeoutError, aiohwenergy.RequestError) as ex: + raise UpdateFailed( + f"Error connecting to the Energy device at {self.api.host}" + ) from ex + + except aiohwenergy.DisabledError as ex: + raise ex + + except aiohwenergy.AiohwenergyException as ex: + raise UpdateFailed("Unknown Energy API error occurred") from ex + + except Exception as ex: # pylint: disable=broad-except + raise UpdateFailed( + f"Unknown error connecting with Energy Device at {self.api.host}" + ) from ex diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json new file mode 100644 index 00000000000..e1b6db1911f --- /dev/null +++ b/homeassistant/components/homewizard/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "homewizard", + "name": "HomeWizard Energy", + "documentation": "https://www.home-assistant.io/integrations/homewizard", + "codeowners": ["@DCSBL"], + "dependencies": [], + "requirements": [ + "aiohwenergy==0.6.0" + ], + "zeroconf": ["_hwenergy._tcp.local."], + "config_flow": true, + "iot_class": "local_polling" +} diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py new file mode 100644 index 00000000000..eda08536e9a --- /dev/null +++ b/homeassistant/components/homewizard/sensor.py @@ -0,0 +1,201 @@ +"""Creates Homewizard sensor entities.""" +from __future__ import annotations + +import logging +from typing import Final + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + ENTITY_CATEGORY_DIAGNOSTIC, + PERCENTAGE, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import COORDINATOR, DOMAIN, DeviceResponseEntry +from .coordinator import HWEnergyDeviceUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key="smr_version", + name="DSMR Version", + icon="mdi:counter", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="meter_model", + name="Smart Meter Model", + icon="mdi:gauge", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="wifi_ssid", + name="Wifi SSID", + icon="mdi:wifi", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="wifi_strength", + name="Wifi Strength", + icon="mdi:wifi", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="total_power_import_t1_kwh", + name="Total Power Import T1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="total_power_import_t2_kwh", + name="Total Power Import T2", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="total_power_export_t1_kwh", + name="Total Power Export T1", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="total_power_export_t2_kwh", + name="Total Power Export T2", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="active_power_w", + name="Active Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="active_power_l1_w", + name="Active Power L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="active_power_l2_w", + name="Active Power L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="active_power_l3_w", + name="Active Power L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="total_gas_m3", + name="Total Gas", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Initialize sensors.""" + coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + + entities = [] + if coordinator.api.data is not None: + for description in SENSORS: + if ( + description.key in coordinator.api.data.available_datapoints + and getattr(coordinator.api.data, description.key) is not None + ): + entities.append(HWEnergySensor(coordinator, entry, description)) + async_add_entities(entities) + + +class HWEnergySensor(CoordinatorEntity[DeviceResponseEntry], SensorEntity): + """Representation of a HomeWizard Sensor.""" + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + entry: ConfigEntry, + description: SensorEntityDescription, + ) -> None: + """Initialize Sensor Domain.""" + + super().__init__(coordinator) + self.entity_description = description + self.entry = entry + + # Config attributes. + self._attr_name = f"{entry.title} {description.name}" + self.data_type = description.key + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + + # Special case for export, not everyone has solarpanels + # The change that 'export' is non-zero when you have solar panels is nil + if self.data_type in [ + "total_power_export_t1_kwh", + "total_power_export_t2_kwh", + ]: + if self.data["data"][self.data_type] == 0: + self._attr_entity_registry_enabled_default = False + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return { + "name": self.entry.title, + "manufacturer": "HomeWizard", + "sw_version": self.data["device"].firmware_version, + "model": self.data["device"].product_type, + "identifiers": {(DOMAIN, self.data["device"].serial)}, + } + + @property + def data(self) -> DeviceResponseEntry: + """Return data object from DataUpdateCoordinator.""" + return self.coordinator.data + + @property + def native_value(self) -> StateType: + """Return state of meter.""" + return self.data["data"][self.data_type] + + @property + def available(self) -> bool: + """Return availability of meter.""" + return self.data_type in self.data["data"] diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json new file mode 100644 index 00000000000..c3798b35678 --- /dev/null +++ b/homeassistant/components/homewizard/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure device", + "description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "discovery_confirm": { + "title": "Confirm", + "description": "Do you want to setup {product_type} ({serial}) at {ip_address}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_discovery_parameters": "unsupported_api_version", + "api_not_enabled": "The API is not enabled. Enable API in the HomeWizard Energy App under settings", + "device_not_supported": "This device is not supported", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7e3a66a073a..9086b693eab 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -136,6 +136,7 @@ FLOWS = [ "homekit", "homekit_controller", "homematicip_cloud", + "homewizard", "honeywell", "huawei_lte", "hue", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index bc4a83f3261..22c968e6340 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -179,6 +179,11 @@ ZEROCONF = { "domain": "hue" } ], + "_hwenergy._tcp.local.": [ + { + "domain": "homewizard" + } + ], "_ipp._tcp.local.": [ { "domain": "ipp" diff --git a/mypy.ini b/mypy.ini index a91bbe59f40..349a2bc4af2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -718,6 +718,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homewizard.*] +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.http.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0e56ebd9816..597e4038050 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,6 +193,9 @@ aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==3.0.11 +# homeassistant.components.homewizard +aiohwenergy==0.6.0 + # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d71f8202093..3e853adaa39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,6 +140,9 @@ aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==3.0.11 +# homeassistant.components.homewizard +aiohwenergy==0.6.0 + # homeassistant.components.apache_kafka aiokafka==0.6.0 diff --git a/tests/components/homewizard/__init__.py b/tests/components/homewizard/__init__.py new file mode 100644 index 00000000000..bdd31419e12 --- /dev/null +++ b/tests/components/homewizard/__init__.py @@ -0,0 +1 @@ +"""Tests for the HomeWizard integration.""" diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py new file mode 100644 index 00000000000..15993aa35ed --- /dev/null +++ b/tests/components/homewizard/conftest.py @@ -0,0 +1,30 @@ +"""Fixtures for HomeWizard integration tests.""" +import pytest + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry_data(): + """Return the default mocked config entry data.""" + return { + "product_name": "Product Name", + "product_type": "product_type", + "serial": "aabbccddeeff", + "name": "Product Name", + CONF_IP_ADDRESS: "1.2.3.4", + } + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Product Name (aabbccddeeff)", + domain=DOMAIN, + data={}, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/homewizard/generator.py b/tests/components/homewizard/generator.py new file mode 100644 index 00000000000..74d33c9e609 --- /dev/null +++ b/tests/components/homewizard/generator.py @@ -0,0 +1,27 @@ +"""Helper files for unit tests.""" + +from unittest.mock import AsyncMock + + +def get_mock_device( + serial="aabbccddeeff", + host="1.2.3.4", + product_name="P1 meter", + product_type="HWE-P1", +): + """Return a mock bridge.""" + mock_device = AsyncMock() + mock_device.host = host + + mock_device.device.product_name = product_name + mock_device.device.product_type = product_type + mock_device.device.serial = serial + mock_device.device.api_version = "v1" + mock_device.device.firmware_version = "1.00" + + mock_device.state = None + + mock_device.initialize = AsyncMock() + mock_device.close = AsyncMock() + + return mock_device diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py new file mode 100644 index 00000000000..2d2cbd40b0f --- /dev/null +++ b/tests/components/homewizard/test_config_flow.py @@ -0,0 +1,324 @@ +"""Test the homewizard config flow.""" +import logging +from unittest.mock import patch + +from aiohwenergy import DisabledError + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from .generator import get_mock_device + +_LOGGER = logging.getLogger(__name__) + + +async def test_manual_flow_works(hass, aioclient_mock): + """Test config flow accepts user configuration.""" + + device = get_mock_device() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == "create_entry" + + assert result["title"] == f"{device.device.product_name} (aabbccddeeff)" + assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + entries = hass.config_entries.async_entries(DOMAIN) + + assert len(entries) == 1 + entry = entries[0] + assert entry.unique_id == f"{device.device.product_type}_{device.device.serial}" + + assert len(device.initialize.mock_calls) == 2 + assert len(device.close.mock_calls) == 1 + + +async def test_discovery_flow_works(hass, aioclient_mock): + """Test discovery setup flow works.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + with patch("aiohwenergy.HomeWizardEnergy", return_value=get_mock_device()), patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + with patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" + + assert result["result"] + assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + + +async def test_discovery_disabled_api(hass, aioclient_mock): + """Test discovery detecting disabled api.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + with patch("aiohwenergy.HomeWizardEnergy", return_value=get_mock_device()), patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "api_not_enabled" + + +async def test_discovery_missing_data_in_service_info(hass, aioclient_mock): + """Test discovery detecting missing discovery info.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + # "api_enabled": "1", --> removed + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + with patch("aiohwenergy.HomeWizardEnergy", return_value=get_mock_device()), patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_discovery_parameters" + + +async def test_discovery_invalid_api(hass, aioclient_mock): + """Test discovery detecting invalid_api.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/not_v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + with patch("aiohwenergy.HomeWizardEnergy", return_value=get_mock_device()), patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unsupported_api_version" + + +async def test_check_disabled_api(hass, aioclient_mock): + """Test check detecting disabled api.""" + + def MockInitialize(): + raise DisabledError + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "api_not_enabled" + + +async def test_check_error_handling_api(hass, aioclient_mock): + """Test check detecting error with api.""" + + def MockInitialize(): + raise Exception() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" + + +async def test_check_detects_unexpected_api_response(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device() + device.device = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" + + +async def test_check_detects_invalid_api(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device() + device.device.api_version = "not_v1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unsupported_api_version" + + +async def test_check_detects_unsuported_device(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device(product_type="not_an_energy_device") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "device_not_supported" diff --git a/tests/components/homewizard/test_coordinator.py b/tests/components/homewizard/test_coordinator.py new file mode 100644 index 00000000000..e026c6132ad --- /dev/null +++ b/tests/components/homewizard/test_coordinator.py @@ -0,0 +1,131 @@ +"""Test the update coordinator for HomeWizard.""" + +from datetime import timedelta +import json +from unittest.mock import AsyncMock, patch + +from aiohwenergy import errors +from pytest import raises + +from homeassistant.components.homewizard.const import CONF_DATA, CONF_DEVICE +from homeassistant.components.homewizard.coordinator import ( + HWEnergyDeviceUpdateCoordinator as Coordinator, +) +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .generator import get_mock_device + + +async def test_coordinator_sets_update_interval(aioclient_mock, hass): + """Test coordinator calculates correct update interval.""" + + # P1 meter + meter = get_mock_device(product_type="p1_meter") + + coordinator = Coordinator(hass, meter) + assert coordinator.update_interval == timedelta(seconds=5) + + +def mock_request_response( + status: int, data: str, content_type: str = "application/json" +): + """Return the default mocked config entry data.""" + + mock_response = AsyncMock() + mock_response.status = status + mock_response.content_type = content_type + + async def return_json(): + return json.loads(data) + + async def return_text(format: str): + return data + + mock_response.json = return_json + mock_response.text = return_text + + return mock_response + + +async def test_coordinator_fetches_data(aioclient_mock, hass): + """Test coordinator fetches data.""" + + # P1 meter and (very advanced kWh meter) + meter = get_mock_device(product_type="p1_meter") + meter.data.smr_version = 50 + meter.data.available_datapoints = [ + "active_power_l1_w", + "active_power_l2_w", + "active_power_l3_w", + "active_power_w", + "meter_model", + "smr_version", + "total_power_export_t1_kwh", + "total_power_export_t2_kwh", + "total_power_import_t1_kwh", + "total_power_import_t2_kwh", + "total_gas_m3", + "wifi_ssid", + "wifi_strength", + ] + + coordinator = Coordinator(hass, "1.2.3.4") + coordinator.api = meter + data = await coordinator._async_update_data() + + print(data[CONF_DEVICE]) + print(meter.device.product_type) + assert data[CONF_DEVICE] == meter.device + assert coordinator.api.host == "1.2.3.4" + assert coordinator.api == meter + + assert ( + len(coordinator.api.initialize.mock_calls) == 0 + ) # Already initialized by 'coordinator.api = meter' + assert len(coordinator.api.update.mock_calls) == 2 # Init and update + assert len(coordinator.api.close.mock_calls) == 0 + + for datapoint in meter.data.available_datapoints: + assert datapoint in data[CONF_DATA] + + +async def test_coordinator_failed_to_update(aioclient_mock, hass): + """Test coordinator handles failed update correctly.""" + + # Update failed by internal error + meter = get_mock_device(product_type="p1_meter") + + async def _failed_update() -> bool: + return False + + meter.update = _failed_update + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=meter, + ): + coordinator = Coordinator(hass, "1.2.3.4") + + with raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_coordinator_detected_disabled_api(aioclient_mock, hass): + """Test coordinator handles disabled api correctly.""" + + # Update failed by internal error + meter = get_mock_device(product_type="p1_meter") + + async def _failed_update() -> bool: + raise errors.DisabledError() + + meter.update = _failed_update + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=meter, + ): + coordinator = Coordinator(hass, "1.2.3.4") + + with raises(UpdateFailed): + await coordinator._async_update_data() diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py new file mode 100644 index 00000000000..f7aa4de7ade --- /dev/null +++ b/tests/components/homewizard/test_init.py @@ -0,0 +1,173 @@ +"""Tests for the homewizard component.""" +from asyncio import TimeoutError +from unittest.mock import patch + +from aiohwenergy import AiohwenergyException, DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS + +from .generator import get_mock_device + +from tests.common import MockConfigEntry + + +async def test_load_unload(aioclient_mock, hass): + """Test loading and unloading of integration.""" + + device = get_mock_device() + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_load_failed_host_unavailable(aioclient_mock, hass): + """Test setup handles unreachable host.""" + + def MockInitialize(): + raise TimeoutError() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_detect_api_disabled(aioclient_mock, hass): + """Test setup detects disabled API.""" + + def MockInitialize(): + raise DisabledError() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_aiohwenergy_exception(aioclient_mock, hass): + """Test setup handles exception from API.""" + + def MockInitialize(): + raise AiohwenergyException() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_generic_exception(aioclient_mock, hass): + """Test setup handles global exception.""" + + def MockInitialize(): + raise Exception() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_initialization_error(aioclient_mock, hass): + """Test handles non-exception error.""" + + device = get_mock_device() + device.device = None + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py new file mode 100644 index 00000000000..24facb02660 --- /dev/null +++ b/tests/components/homewizard/test_sensor.py @@ -0,0 +1,639 @@ +"""Test the update coordinator for HomeWizard.""" + +from unittest.mock import patch + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.helpers import entity_registry as er + +from .generator import get_mock_device + + +async def test_sensor_entity_smr_version( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads smr version.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "smr_version", + ] + api.data.smr_version = 50 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_dsmr_version") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_dsmr_version") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_smr_version" + assert not entry.disabled + assert state.state == "50" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) DSMR Version" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:counter" + + +async def test_sensor_entity_meter_model( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads meter model.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "meter_model", + ] + api.data.meter_model = "Model X" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_model") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_smart_meter_model" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_meter_model" + assert not entry.disabled + assert state.state == "Model X" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Smart Meter Model" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:gauge" + + +async def test_sensor_entity_wifi_ssid(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads wifi ssid.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "wifi_ssid", + ] + api.data.wifi_ssid = "My Wifi" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_wifi_ssid") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wifi_ssid") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_wifi_ssid" + assert not entry.disabled + assert state.state == "My Wifi" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Wifi SSID" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + + +async def test_sensor_entity_wifi_strength( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads wifi strength.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "wifi_strength", + ] + api.data.wifi_strength = 42 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wifi_strength") + assert entry + assert entry.unique_id == "aabbccddeeff_wifi_strength" + assert entry.disabled + + +async def test_sensor_entity_total_power_import_t1_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power import t1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t1_kwh", + ] + api.data.total_power_import_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_import_t1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_import_t1_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Import T1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_import_t2_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power import t2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t2_kwh", + ] + api.data.total_power_import_t2_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_import_t2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_import_t2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_import_t2_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Import T2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_export_t1_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power export t1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t1_kwh", + ] + api.data.total_power_export_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_export_t1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_export_t1_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Export T1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_export_t2_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power export t2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t2_kwh", + ] + api.data.total_power_export_t2_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_export_t2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_export_t2_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Export T2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_w", + ] + api.data.active_power_w = 123.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_power") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_w" + assert not entry.disabled + assert state.state == "123.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l1( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l1_w", + ] + api.data.active_power_l1_w = 123.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l1_w" + assert not entry.disabled + assert state.state == "123.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l2( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l2_w", + ] + api.data.active_power_l2_w = 456.456 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l2_w" + assert not entry.disabled + assert state.state == "456.456" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l3( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l3.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l3_w", + ] + api.data.active_power_l3_w = 789.789 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l3") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l3" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l3_w" + assert not entry.disabled + assert state.state == "789.789" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L3" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_gas(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads total gas.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_gas_m3", + ] + api.data.total_gas_m3 = 50 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_gas") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_gas_m3" + assert not entry.disabled + assert state.state == "50" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Gas" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_disabled_when_null( + hass, mock_config_entry_data, mock_config_entry +): + """Test sensor disables data with null by default.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l2_w", + "active_power_l3_w", + "total_gas_m3", + ] + api.data.active_power_l2_w = None + api.data.active_power_l3_w = None + api.data.total_gas_m3 = None + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l2" + ) + assert entry is None + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l3" + ) + assert entry is None + + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") + assert entry is None + + +async def test_sensor_entity_export_disabled_when_unused( + hass, mock_config_entry_data, mock_config_entry +): + """Test sensor disables export if value is 0.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t1_kwh", + "total_power_export_t2_kwh", + ] + api.data.total_power_export_t1_kwh = 0 + api.data.total_power_export_t2_kwh = 0 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t1" + ) + assert entry + assert entry.disabled + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t2" + ) + assert entry + assert entry.disabled