diff --git a/.coveragerc b/.coveragerc index e653b35b62d..445be38ab05 100644 --- a/.coveragerc +++ b/.coveragerc @@ -202,6 +202,8 @@ omit = homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py homeassistant/components/discord/notify.py + homeassistant/components/discovergy/__init__.py + homeassistant/components/discovergy/sensor.py homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/data.py diff --git a/CODEOWNERS b/CODEOWNERS index 669294af12f..63833d6c1fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -275,6 +275,8 @@ build.json @home-assistant/supervisor /homeassistant/components/discogs/ @thibmaek /homeassistant/components/discord/ @tkdrob /tests/components/discord/ @tkdrob +/homeassistant/components/discovergy/ @jpbede +/tests/components/discovergy/ @jpbede /homeassistant/components/discovery/ @home-assistant/core /tests/components/discovery/ @home-assistant/core /homeassistant/components/dlink/ @tkdrob diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py new file mode 100644 index 00000000000..d36100f611d --- /dev/null +++ b/homeassistant/components/discovergy/__init__.py @@ -0,0 +1,84 @@ +"""The Discovergy integration.""" +from __future__ import annotations + +from dataclasses import dataclass, field +import logging + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +from pydiscovergy.models import Meter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import APP_NAME, DOMAIN + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DiscovergyData: + """Discovergy data class to share meters and api client.""" + + api_client: pydiscovergy.Discovergy = field(default_factory=lambda: None) + meters: list[Meter] = field(default_factory=lambda: []) + coordinators: dict[str, DataUpdateCoordinator] = field(default_factory=lambda: {}) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Discovergy from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # init discovergy data class + discovergy_data = DiscovergyData( + api_client=pydiscovergy.Discovergy( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(hass), + authentication=BasicAuth(), + ), + meters=[], + coordinators={}, + ) + + try: + # try to get meters from api to check if access token is still valid and later use + # if no exception is raised everything is fine to go + discovergy_data.meters = await discovergy_data.api_client.get_meters() + except discovergyError.InvalidLogin as err: + _LOGGER.debug("Invalid email or password: %s", err) + raise ConfigEntryAuthFailed("Invalid email or password") from err + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected error while communicating with API: %s", err) + raise ConfigEntryNotReady( + "Unexpected error while communicating with API" + ) from err + + hass.data[DOMAIN][entry.entry_id] = discovergy_data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + 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: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py new file mode 100644 index 00000000000..1f685f3e23a --- /dev/null +++ b/homeassistant/components/discovergy/config_flow.py @@ -0,0 +1,167 @@ +"""Config flow for Discovergy integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import pydiscovergy +from pydiscovergy.authentication import BasicAuth +import pydiscovergy.error as discovergyError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.httpx_client import get_async_client + +from .const import ( + APP_NAME, + CONF_TIME_BETWEEN_UPDATE, + DEFAULT_TIME_BETWEEN_UPDATE, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def make_schema(email: str = "", password: str = "") -> vol.Schema: + """Create schema for config flow.""" + return vol.Schema( + { + vol.Required( + CONF_EMAIL, + default=email, + ): str, + vol.Required( + CONF_PASSWORD, + default=password, + ): str, + } + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Discovergy.""" + + VERSION = 1 + + existing_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=make_schema(), + ) + + return await self._validate_and_save(user_input) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle the initial step.""" + self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) + + if entry_data is None: + return self.async_show_form( + step_id="reauth", + data_schema=make_schema( + self.existing_entry.data[CONF_EMAIL] or "", + self.existing_entry.data[CONF_PASSWORD] or "", + ), + ) + + return await self._validate_and_save(dict(entry_data), step_id="reauth") + + async def _validate_and_save( + self, user_input: dict[str, Any] | None = None, step_id: str = "user" + ) -> FlowResult: + """Validate user input and create config entry.""" + errors = {} + + if user_input: + try: + await pydiscovergy.Discovergy( + email=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + app_name=APP_NAME, + httpx_client=get_async_client(self.hass), + authentication=BasicAuth(), + ).get_meters() + + result = {"title": user_input[CONF_EMAIL], "data": user_input} + except discovergyError.HTTPError: + errors["base"] = "cannot_connect" + except discovergyError.InvalidLogin: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.existing_entry: + self.hass.config_entries.async_update_entry( + self.existing_entry, + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + await self.hass.config_entries.async_reload( + self.existing_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + # set unique id to title which is the account email + await self.async_set_unique_id(result["title"].lower()) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=result["title"], data=result["data"] + ) + + return self.async_show_form( + step_id=step_id, + data_schema=make_schema(), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return DiscovergyOptionsFlowHandler(config_entry) + + +class DiscovergyOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Discovergy options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_TIME_BETWEEN_UPDATE, + default=self.config_entry.options.get( + CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + ) diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py new file mode 100644 index 00000000000..31d834156d4 --- /dev/null +++ b/homeassistant/components/discovergy/const.py @@ -0,0 +1,8 @@ +"""Constants for the Discovergy integration.""" +from __future__ import annotations + +DOMAIN = "discovergy" +MANUFACTURER = "Discovergy" +APP_NAME = "homeassistant" +CONF_TIME_BETWEEN_UPDATE = "time_between_update" +DEFAULT_TIME_BETWEEN_UPDATE = 30 diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py new file mode 100644 index 00000000000..a6ce3aea40a --- /dev/null +++ b/homeassistant/components/discovergy/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for discovergy.""" +from __future__ import annotations + +from typing import Any + +from pydiscovergy.models import Meter + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import DiscovergyData +from .const import DOMAIN + +TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"} + +TO_REDACT_METER = { + "serial_number", + "full_serial_number", + "location", + "fullSerialNumber", + "printedFullSerialNumber", + "administrationNumber", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + flattened_meter: list[dict] = [] + last_readings: dict[str, dict] = {} + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + meters: list[Meter] = data.meters # always returns a list + + for meter in meters: + # make a dict of meter data and redact some data + flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) + + # get last reading for meter and make a dict of it + coordinator: DataUpdateCoordinator = data.coordinators[meter.get_meter_id()] + last_readings[meter.get_meter_id()] = coordinator.data.__dict__ + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), + "meters": flattened_meter, + "readings": last_readings, + } diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json new file mode 100644 index 00000000000..c929386e8e8 --- /dev/null +++ b/homeassistant/components/discovergy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "discovergy", + "name": "Discovergy", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/discovergy", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["pydiscovergy==1.2.1"] +} diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py new file mode 100644 index 00000000000..3084e578525 --- /dev/null +++ b/homeassistant/components/discovergy/sensor.py @@ -0,0 +1,274 @@ +"""Discovergy sensor entity.""" +from dataclasses import dataclass, field +from datetime import timedelta +import logging + +from pydiscovergy import Discovergy +from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.models import Meter + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from . import DiscovergyData +from .const import ( + CONF_TIME_BETWEEN_UPDATE, + DEFAULT_TIME_BETWEEN_UPDATE, + DOMAIN, + MANUFACTURER, +) + +PARALLEL_UPDATES = 1 +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DiscovergyMixin: + """Mixin for alternative keys.""" + + alternative_keys: list = field(default_factory=lambda: []) + scale: int = field(default_factory=lambda: 1000) + + +@dataclass +class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription): + """Define Sensor entity description class.""" + + +GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + DiscovergySensorEntityDescription( + key="volume", + translation_key="total_gas_consumption", + suggested_display_precision=4, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + +ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + # power sensors + DiscovergySensorEntityDescription( + key="power", + translation_key="total_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + DiscovergySensorEntityDescription( + key="power1", + translation_key="phase_1_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase1Power"], + ), + DiscovergySensorEntityDescription( + key="power2", + translation_key="phase_2_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase2Power"], + ), + DiscovergySensorEntityDescription( + key="power3", + translation_key="phase_3_power", + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=3, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + alternative_keys=["phase3Power"], + ), + # voltage sensors + DiscovergySensorEntityDescription( + key="phase1Voltage", + translation_key="phase_1_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase2Voltage", + translation_key="phase_2_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + DiscovergySensorEntityDescription( + key="phase3Voltage", + translation_key="phase_3_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + # energy sensors + DiscovergySensorEntityDescription( + key="energy", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), + DiscovergySensorEntityDescription( + key="energyOut", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=4, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + scale=10000000000, + ), +) + + +def get_coordinator_for_meter( + hass: HomeAssistant, + meter: Meter, + discovergy_instance: Discovergy, + update_interval: timedelta, +) -> DataUpdateCoordinator: + """Create a new DataUpdateCoordinator for given meter.""" + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await discovergy_instance.get_last_reading(meter.get_meter_id()) + except AccessTokenExpired as err: + raise ConfigEntryAuthFailed( + "Got token expired while communicating with API" + ) from err + except HTTPError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + except Exception as err: # pylint: disable=broad-except + raise UpdateFailed( + f"Unexpected error while communicating with API: {err}" + ) from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="sensor", + update_method=async_update_data, + update_interval=update_interval, + ) + return coordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Discovergy sensors.""" + data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + discovergy_instance: Discovergy = data.api_client + meters: list[Meter] = data.meters # always returns a list + + min_time_between_updates = timedelta( + seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) + ) + + entities = [] + for meter in meters: + # Get coordinator for meter, set config entry and fetch initial data + # so we have data when entities are added + coordinator = get_coordinator_for_meter( + hass, meter, discovergy_instance, min_time_between_updates + ) + coordinator.config_entry = entry + await coordinator.async_config_entry_first_refresh() + + # add coordinator to data for diagnostics + data.coordinators[meter.get_meter_id()] = coordinator + + sensors = None + if meter.measurement_type == "ELECTRICITY": + sensors = ELECTRICITY_SENSORS + elif meter.measurement_type == "GAS": + sensors = GAS_SENSORS + + if sensors is not None: + for description in sensors: + keys = [description.key] + description.alternative_keys + + # check if this meter has this data, then add this sensor + for key in keys: + if key in coordinator.data.values: + entities.append( + DiscovergySensor(key, description, meter, coordinator) + ) + + async_add_entities(entities, False) + + +class DiscovergySensor(CoordinatorEntity, SensorEntity): + """Represents a discovergy smart meter sensor.""" + + entity_description: DiscovergySensorEntityDescription + data_key: str + _attr_has_entity_name = True + + def __init__( + self, + data_key: str, + description: DiscovergySensorEntityDescription, + meter: Meter, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.data_key = data_key + + self.entity_description = description + self._attr_unique_id = f"{meter.full_serial_number}-{description.key}" + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())}, + ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + ATTR_MODEL: f"{meter.type} {meter.full_serial_number}", + ATTR_MANUFACTURER: MANUFACTURER, + } + + @property + def native_value(self) -> StateType: + """Return the sensor state.""" + return float( + self.coordinator.data.values[self.data_key] / self.entity_description.scale + ) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json new file mode 100644 index 00000000000..11d6b74a822 --- /dev/null +++ b/homeassistant/components/discovergy/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "Discovergy API endpoint reachable" + } + }, + "options": { + "step": { + "init": { + "data": { + "time_between_update": "Minimum time between entity updates [s]" + } + } + } + }, + "entity": { + "sensor": { + "total_gas_consumption": { + "name": "Total gas consumption" + }, + "total_power": { + "name": "Total power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "phase_1_voltage": { + "name": "Phase 1 voltage" + }, + "phase_2_voltage": { + "name": "Phase 2 voltage" + }, + "phase_3_voltage": { + "name": "Phase 3 voltage" + }, + "phase_1_power": { + "name": "Phase 1 power" + }, + "phase_2_power": { + "name": "Phase 2 power" + }, + "phase_3_power": { + "name": "Phase 3 power" + } + } + } +} diff --git a/homeassistant/components/discovergy/system_health.py b/homeassistant/components/discovergy/system_health.py new file mode 100644 index 00000000000..2baeb0e5f6e --- /dev/null +++ b/homeassistant/components/discovergy/system_health.py @@ -0,0 +1,22 @@ +"""Provide info to system health.""" +from pydiscovergy.const import API_BASE + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass): + """Get info for the info page.""" + return { + "api_endpoint_reachable": system_health.async_check_can_reach_url( + hass, API_BASE + ) + } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d98aacbe77..f938bdfd8d1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -95,6 +95,7 @@ FLOWS = { "dialogflow", "directv", "discord", + "discovergy", "dlink", "dlna_dmr", "dlna_dms", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f1b8d0f1ca6..b9c2ac57553 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1091,6 +1091,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "discovergy": { + "name": "Discovergy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "dlib_face_detect": { "name": "Dlib Face Detect", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6fd8f259923..b32104a5826 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1633,6 +1633,9 @@ pydelijn==1.0.0 # homeassistant.components.dexcom pydexcom==0.2.3 +# homeassistant.components.discovergy +pydiscovergy==1.2.1 + # homeassistant.components.doods pydoods==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17ae48d7355..dadad78574d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1203,6 +1203,9 @@ pydeconz==113 # homeassistant.components.dexcom pydexcom==0.2.3 +# homeassistant.components.discovergy +pydiscovergy==1.2.1 + # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/tests/components/discovergy/__init__.py b/tests/components/discovergy/__init__.py new file mode 100644 index 00000000000..f721b5842c9 --- /dev/null +++ b/tests/components/discovergy/__init__.py @@ -0,0 +1,75 @@ +"""Tests for the Discovergy integration.""" +import datetime +from unittest.mock import patch + +from pydiscovergy.models import Meter, Reading + +from homeassistant.components.discovergy import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +GET_METERS = [ + Meter( + meterId="f8d610b7a8cc4e73939fa33b990ded54", + serialNumber="abc123", + fullSerialNumber="abc123", + type="TST", + measurementType="ELECTRICITY", + loadProfileType="SLP", + location={ + "city": "Testhause", + "street": "Teststraße", + "streetNumber": "1", + "country": "Germany", + }, + manufacturerId="TST", + printedFullSerialNumber="abc123", + administrationNumber="12345", + scalingFactor=1, + currentScalingFactor=1, + voltageScalingFactor=1, + internalMeters=1, + firstMeasurementTime=1517569090926, + lastMeasurementTime=1678430543742, + ), +] + +LAST_READING = Reading( + time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000), + values={ + "energy": 119348699715000.0, + "energy1": 2254180000.0, + "energy2": 119346445534000.0, + "energyOut": 55048723044000.0, + "energyOut1": 0.0, + "energyOut2": 0.0, + "power": 531750.0, + "power1": 142680.0, + "power2": 138010.0, + "power3": 251060.0, + "voltage1": 239800.0, + "voltage2": 239700.0, + "voltage3": 239000.0, + }, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Discovergy integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="user@example.org", + unique_id="user@example.org", + data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"}, + ) + + with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch( + "pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py new file mode 100644 index 00000000000..40bd0bb8aa1 --- /dev/null +++ b/tests/components/discovergy/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Discovergy integration tests.""" +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from tests.components.discovergy import GET_METERS + + +@pytest.fixture +def mock_meters() -> Mock: + """Patch libraries.""" + with patch("pydiscovergy.Discovergy.get_meters") as discovergy: + discovergy.side_effect = AsyncMock(return_value=GET_METERS) + yield discovergy diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py new file mode 100644 index 00000000000..312828a7997 --- /dev/null +++ b/tests/components/discovergy/test_config_flow.py @@ -0,0 +1,145 @@ +"""Test the Discovergy config flow.""" +from unittest.mock import patch + +from pydiscovergy.error import HTTPError, InvalidLogin + +from homeassistant import data_entry_flow, setup +from homeassistant.components.discovergy.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.components.discovergy import init_integration + + +async def test_form(hass: HomeAssistant, mock_meters) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.discovergy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == "test@example.com" + assert result2["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant, mock_meters) -> None: + """Test reauth flow.""" + entry = await init_integration(hass) + + init_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": entry.unique_id}, + data=None, + ) + + assert init_result["type"] == data_entry_flow.FlowResultType.FORM + assert init_result["step_id"] == "reauth" + + configure_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert configure_result["type"] == data_entry_flow.FlowResultType.ABORT + assert configure_result["reason"] == "reauth_successful" + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "pydiscovergy.Discovergy.get_meters", + side_effect=InvalidLogin, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.get_meters", side_effect=HTTPError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.get_meters", side_effect=Exception): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_options_flow_init(hass: HomeAssistant) -> None: + """Test the options flow.""" + entry = await init_integration(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + create_result = await hass.config_entries.options.async_configure( + result["flow_id"], {"time_between_update": 2} + ) + + assert create_result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert create_result["data"] == {"time_between_update": 2} diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py new file mode 100644 index 00000000000..7d7b3508f95 --- /dev/null +++ b/tests/components/discovergy/test_diagnostics.py @@ -0,0 +1,78 @@ +"""Test Discovergy diagnostics.""" +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + entry = await init_integration(hass) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["entry"] == { + "entry_id": entry.entry_id, + "version": 1, + "domain": "discovergy", + "title": REDACTED, + "data": {"email": REDACTED, "password": REDACTED}, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + } + + assert result["meters"] == [ + { + "additional": { + "administrationNumber": REDACTED, + "currentScalingFactor": 1, + "firstMeasurementTime": 1517569090926, + "fullSerialNumber": REDACTED, + "internalMeters": 1, + "lastMeasurementTime": 1678430543742, + "loadProfileType": "SLP", + "manufacturerId": "TST", + "printedFullSerialNumber": REDACTED, + "scalingFactor": 1, + "type": "TST", + "voltageScalingFactor": 1, + }, + "full_serial_number": REDACTED, + "load_profile_type": "SLP", + "location": REDACTED, + "measurement_type": "ELECTRICITY", + "meter_id": "f8d610b7a8cc4e73939fa33b990ded54", + "serial_number": REDACTED, + "type": "TST", + } + ] + + assert result["readings"] == { + "f8d610b7a8cc4e73939fa33b990ded54": { + "time": "2023-03-10T07:32:06.702000", + "values": { + "energy": 119348699715000.0, + "energy1": 2254180000.0, + "energy2": 119346445534000.0, + "energyOut": 55048723044000.0, + "energyOut1": 0.0, + "energyOut2": 0.0, + "power": 531750.0, + "power1": 142680.0, + "power2": 138010.0, + "power3": 251060.0, + "voltage1": 239800.0, + "voltage2": 239700.0, + "voltage3": 239000.0, + }, + } + } diff --git a/tests/components/discovergy/test_system_health.py b/tests/components/discovergy/test_system_health.py new file mode 100644 index 00000000000..91025b06dd7 --- /dev/null +++ b/tests/components/discovergy/test_system_health.py @@ -0,0 +1,48 @@ +"""Test Discovergy system health.""" +import asyncio + +from aiohttp import ClientError +from pydiscovergy.const import API_BASE + +from homeassistant.components.discovergy.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import get_system_health_info +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_discovergy_system_health( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Discovergy system health.""" + aioclient_mock.get(API_BASE, text="") + hass.config.components.add(DOMAIN) + assert await async_setup_component(hass, "system_health", {}) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == {"api_endpoint_reachable": "ok"} + + +async def test_discovergy_system_health_fail( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Discovergy system health.""" + aioclient_mock.get(API_BASE, exc=ClientError) + hass.config.components.add(DOMAIN) + assert await async_setup_component(hass, "system_health", {}) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "api_endpoint_reachable": {"type": "failed", "error": "unreachable"} + }