From 78158401940e3022e50a8af244e68fd2b2378f10 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Tue, 4 Jun 2024 10:45:53 +0200 Subject: [PATCH] Add ista EcoTrend integration (#118360) * Add ista EcoTrend integration * move code out of try * Use account owners name as entry title * update config flow tests * add tests for init * Add reauth flow * Add tests for sensors * add translations for reauth * trigger statistics import on first refresh * Move statistics and reauth flow to other PR * Fix tests * some changes * draft_final_final * remove unnecessary icons * changed tests * move device_registry test to init * add text selectors --- .coveragerc | 1 + CODEOWNERS | 2 + .../components/ista_ecotrend/__init__.py | 64 ++ .../components/ista_ecotrend/config_flow.py | 84 ++ .../components/ista_ecotrend/const.py | 3 + .../components/ista_ecotrend/coordinator.py | 103 ++ .../components/ista_ecotrend/icons.json | 15 + .../components/ista_ecotrend/manifest.json | 9 + .../components/ista_ecotrend/sensor.py | 183 ++++ .../components/ista_ecotrend/strings.json | 56 ++ .../components/ista_ecotrend/util.py | 129 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ista_ecotrend/__init__.py | 1 + tests/components/ista_ecotrend/conftest.py | 166 ++++ .../ista_ecotrend/snapshots/test_init.ambr | 61 ++ .../ista_ecotrend/snapshots/test_sensor.ambr | 915 ++++++++++++++++++ .../ista_ecotrend/snapshots/test_util.ambr | 175 ++++ .../ista_ecotrend/test_config_flow.py | 90 ++ tests/components/ista_ecotrend/test_init.py | 99 ++ tests/components/ista_ecotrend/test_sensor.py | 31 + tests/components/ista_ecotrend/test_util.py | 146 +++ 24 files changed, 2346 insertions(+) create mode 100644 homeassistant/components/ista_ecotrend/__init__.py create mode 100644 homeassistant/components/ista_ecotrend/config_flow.py create mode 100644 homeassistant/components/ista_ecotrend/const.py create mode 100644 homeassistant/components/ista_ecotrend/coordinator.py create mode 100644 homeassistant/components/ista_ecotrend/icons.json create mode 100644 homeassistant/components/ista_ecotrend/manifest.json create mode 100644 homeassistant/components/ista_ecotrend/sensor.py create mode 100644 homeassistant/components/ista_ecotrend/strings.json create mode 100644 homeassistant/components/ista_ecotrend/util.py create mode 100644 tests/components/ista_ecotrend/__init__.py create mode 100644 tests/components/ista_ecotrend/conftest.py create mode 100644 tests/components/ista_ecotrend/snapshots/test_init.ambr create mode 100644 tests/components/ista_ecotrend/snapshots/test_sensor.ambr create mode 100644 tests/components/ista_ecotrend/snapshots/test_util.ambr create mode 100644 tests/components/ista_ecotrend/test_config_flow.py create mode 100644 tests/components/ista_ecotrend/test_init.py create mode 100644 tests/components/ista_ecotrend/test_sensor.py create mode 100644 tests/components/ista_ecotrend/test_util.py diff --git a/.coveragerc b/.coveragerc index 40828381725..e556d0aab85 100644 --- a/.coveragerc +++ b/.coveragerc @@ -631,6 +631,7 @@ omit = homeassistant/components/irish_rail_transport/sensor.py homeassistant/components/iss/__init__.py homeassistant/components/iss/sensor.py + homeassistant/components/ista_ecotrend/coordinator.py homeassistant/components/isy994/__init__.py homeassistant/components/isy994/binary_sensor.py homeassistant/components/isy994/button.py diff --git a/CODEOWNERS b/CODEOWNERS index a72683c1737..90d482ce041 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -703,6 +703,8 @@ build.json @home-assistant/supervisor /tests/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol +/homeassistant/components/ista_ecotrend/ @tr4nt0r +/tests/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/isy994/ @bdraco @shbatm /tests/components/isy994/ @bdraco @shbatm /homeassistant/components/izone/ @Swamp-Ig diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py new file mode 100644 index 00000000000..2bb41dd6f8b --- /dev/null +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -0,0 +1,64 @@ +"""The ista Ecotrend integration.""" + +from __future__ import annotations + +import logging + +from pyecotrend_ista.exception_classes import ( + InternalServerError, + KeycloakError, + LoginError, + ServerError, +) +from pyecotrend_ista.pyecotrend_ista import PyEcotrendIsta +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import IstaCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type IstaConfigEntry = ConfigEntry[IstaCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: + """Set up ista Ecotrend from a config entry.""" + ista = PyEcotrendIsta( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + _LOGGER, + ) + try: + await hass.async_add_executor_job(ista.login) + except (ServerError, InternalServerError, RequestException, TimeoutError) as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_exception", + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, + ) from e + + coordinator = IstaCoordinator(hass, ista) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py new file mode 100644 index 00000000000..b58da0f3a56 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for ista Ecotrend integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyecotrend_ista.exception_classes import ( + InternalServerError, + KeycloakError, + LoginError, + ServerError, +) +from pyecotrend_ista.pyecotrend_ista import PyEcotrendIsta +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +class IstaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ista Ecotrend.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + ista = PyEcotrendIsta( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + _LOGGER, + ) + try: + await self.hass.async_add_executor_job(ista.login) + except (ServerError, InternalServerError): + errors["base"] = "cannot_connect" + except (LoginError, KeycloakError): + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + title = f"{ista._a_firstName} {ista._a_lastName}".strip() # noqa: SLF001 + await self.async_set_unique_id(ista._uuid) # noqa: SLF001 + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=title or "ista EcoTrend", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/ista_ecotrend/const.py b/homeassistant/components/ista_ecotrend/const.py new file mode 100644 index 00000000000..92c12b0f0e4 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/const.py @@ -0,0 +1,3 @@ +"""Constants for the ista Ecotrend integration.""" + +DOMAIN = "ista_ecotrend" diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py new file mode 100644 index 00000000000..78a31d560dd --- /dev/null +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -0,0 +1,103 @@ +"""DataUpdateCoordinator for Ista EcoTrend integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyecotrend_ista.exception_classes import ( + InternalServerError, + KeycloakError, + LoginError, + ServerError, +) +from pyecotrend_ista.pyecotrend_ista import PyEcotrendIsta +from requests.exceptions import RequestException + +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Ista EcoTrend data update coordinator.""" + + def __init__(self, hass: HomeAssistant, ista: PyEcotrendIsta) -> None: + """Initialize ista EcoTrend data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(days=1), + ) + self.ista = ista + self.details: dict[str, Any] = {} + + async def _async_update_data(self): + """Fetch ista EcoTrend data.""" + + if not self.details: + self.details = await self.async_get_details() + + try: + return await self.hass.async_add_executor_job(self.get_consumption_data) + except ( + ServerError, + InternalServerError, + RequestException, + TimeoutError, + ) as e: + raise UpdateFailed( + "Unable to connect and retrieve data from ista EcoTrend, try again later" + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: self.ista._email}, # noqa: SLF001 + ) from e + + def get_consumption_data(self) -> dict[str, Any]: + """Get raw json data for all consumption units.""" + + return { + consumption_unit: self.ista.get_raw(consumption_unit) + for consumption_unit in self.ista.getUUIDs() + } + + async def async_get_details(self) -> dict[str, Any]: + """Retrieve details of consumption units.""" + try: + result = await self.hass.async_add_executor_job( + self.ista.get_consumption_unit_details + ) + except ( + ServerError, + InternalServerError, + RequestException, + TimeoutError, + ) as e: + raise UpdateFailed( + "Unable to connect and retrieve data from ista EcoTrend, try again later" + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: self.ista._email}, # noqa: SLF001 + ) from e + else: + return { + consumption_unit: next( + details + for details in result["consumptionUnits"] + if details["id"] == consumption_unit + ) + for consumption_unit in self.ista.getUUIDs() + } diff --git a/homeassistant/components/ista_ecotrend/icons.json b/homeassistant/components/ista_ecotrend/icons.json new file mode 100644 index 00000000000..4223e8488ff --- /dev/null +++ b/homeassistant/components/ista_ecotrend/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "heating": { + "default": "mdi:radiator" + }, + "water": { + "default": "mdi:faucet" + }, + "hot_water": { + "default": "mdi:faucet" + } + } + } +} diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json new file mode 100644 index 00000000000..679825439e4 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ista_ecotrend", + "name": "ista Ecotrend", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ista_ecotrend", + "iot_class": "cloud_polling", + "requirements": ["pyecotrend-ista==3.1.1"] +} diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py new file mode 100644 index 00000000000..844b86e1689 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -0,0 +1,183 @@ +"""Sensor platform for Ista EcoTrend integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import IstaConfigEntry +from .const import DOMAIN +from .coordinator import IstaCoordinator +from .util import IstaConsumptionType, IstaValueType, get_native_value + + +@dataclass(kw_only=True, frozen=True) +class IstaSensorEntityDescription(SensorEntityDescription): + """Ista EcoTrend Sensor Description.""" + + consumption_type: IstaConsumptionType + value_type: IstaValueType | None = None + + +class IstaSensorEntity(StrEnum): + """Ista EcoTrend Entities.""" + + HEATING = "heating" + HEATING_ENERGY = "heating_energy" + HEATING_COST = "heating_cost" + + HOT_WATER = "hot_water" + HOT_WATER_ENERGY = "hot_water_energy" + HOT_WATER_COST = "hot_water_cost" + + WATER = "water" + WATER_COST = "water_cost" + + +SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = ( + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING, + translation_key=IstaSensorEntity.HEATING, + suggested_display_precision=0, + consumption_type=IstaConsumptionType.HEATING, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING_ENERGY, + translation_key=IstaSensorEntity.HEATING_ENERGY, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HEATING, + value_type=IstaValueType.ENERGY, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING_COST, + translation_key=IstaSensorEntity.HEATING_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.HEATING, + value_type=IstaValueType.COSTS, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER, + translation_key=IstaSensorEntity.HOT_WATER, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HOT_WATER, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER_ENERGY, + translation_key=IstaSensorEntity.HOT_WATER_ENERGY, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HOT_WATER, + value_type=IstaValueType.ENERGY, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER_COST, + translation_key=IstaSensorEntity.HOT_WATER_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.HOT_WATER, + value_type=IstaValueType.COSTS, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.WATER, + translation_key=IstaSensorEntity.WATER, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.WATER, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.WATER_COST, + translation_key=IstaSensorEntity.WATER_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.WATER, + value_type=IstaValueType.COSTS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IstaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ista EcoTrend sensors.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + IstaSensor(coordinator, description, consumption_unit) + for description in SENSOR_DESCRIPTIONS + for consumption_unit in coordinator.data + ) + + +class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): + """Ista EcoTrend sensor.""" + + entity_description: IstaSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IstaCoordinator, + entity_description: IstaSensorEntityDescription, + consumption_unit: str, + ) -> None: + """Initialize the ista EcoTrend sensor.""" + super().__init__(coordinator) + self.consumption_unit = consumption_unit + self.entity_description = entity_description + self._attr_unique_id = f"{consumption_unit}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ista SE", + model="ista EcoTrend", + name=f"{coordinator.details[consumption_unit]["address"]["street"]} " + f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(), + configuration_url="https://ecotrend.ista.de/", + identifiers={(DOMAIN, consumption_unit)}, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the device.""" + + return get_native_value( + data=self.coordinator.data[self.consumption_unit], + consumption_type=self.entity_description.consumption_type, + value_type=self.entity_description.value_type, + ) diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json new file mode 100644 index 00000000000..fa8fcc28c20 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "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%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + }, + "entity": { + "sensor": { + "heating": { + "name": "Heating" + }, + "heating_cost": { + "name": "Heating cost" + }, + "heating_energy": { + "name": "Heating energy" + }, + "hot_water": { + "name": "Hot water" + }, + "hot_water_cost": { + "name": "Hot water cost" + }, + "hot_water_energy": { + "name": "Hot water energy" + }, + "water": { + "name": "Water" + }, + "water_cost": { + "name": "Water cost" + } + } + }, + "exceptions": { + "authentication_exception": { + "message": "Authentication failed for {email}, check your login credentials" + }, + "connection_exception": { + "message": "Unable to connect and retrieve data from ista EcoTrends, try again later" + } + } +} diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py new file mode 100644 index 00000000000..db64dbf85db --- /dev/null +++ b/homeassistant/components/ista_ecotrend/util.py @@ -0,0 +1,129 @@ +"""Utility functions for Ista EcoTrend integration.""" + +from __future__ import annotations + +import datetime +from enum import StrEnum +from typing import Any + +from homeassistant.util import dt as dt_util + + +class IstaConsumptionType(StrEnum): + """Types of consumptions from ista.""" + + HEATING = "heating" + HOT_WATER = "warmwater" + WATER = "water" + + +class IstaValueType(StrEnum): + """Values type Costs or energy.""" + + COSTS = "costs" + ENERGY = "energy" + + +def get_consumptions( + data: dict[str, Any], value_type: IstaValueType | None = None +) -> list[dict[str, Any]]: + """Get consumption readings and sort in ascending order by date.""" + result: list = [] + if consumptions := data.get( + "costs" if value_type == IstaValueType.COSTS else "consumptions", [] + ): + result = [ + { + "readings": readings.get("costsByEnergyType") + if value_type == IstaValueType.COSTS + else readings.get("readings"), + "date": last_day_of_month(**readings["date"]), + } + for readings in consumptions + ] + result.sort(key=lambda d: d["date"]) + return result + + +def get_values_by_type( + consumptions: dict[str, Any], consumption_type: IstaConsumptionType +) -> dict[str, Any]: + """Get the readings of a certain type.""" + + readings: list = consumptions.get("readings", []) or consumptions.get( + "costsByEnergyType", [] + ) + + return next( + (values for values in readings if values.get("type") == consumption_type.value), + {}, + ) + + +def as_number(value: str | float | None) -> float | int | None: + """Convert readings to float or int. + + Readings in the json response are returned as strings, + float values have comma as decimal separator + """ + if isinstance(value, str): + return int(value) if value.isdigit() else float(value.replace(",", ".")) + + return value + + +def last_day_of_month(month: int, year: int) -> datetime.datetime: + """Get the last day of the month.""" + + return dt_util.as_local( + datetime.datetime( + month=month + 1 if month < 12 else 1, + year=year if month < 12 else year + 1, + day=1, + tzinfo=datetime.UTC, + ) + + datetime.timedelta(days=-1) + ) + + +def get_native_value( + data, + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None = None, +) -> int | float | None: + """Determine the latest value for the sensor.""" + + if last_value := get_statistics(data, consumption_type, value_type): + return last_value[-1].get("value") + return None + + +def get_statistics( + data, + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None = None, +) -> list[dict[str, Any]] | None: + """Determine the latest value for the sensor.""" + + if monthly_consumptions := get_consumptions(data, value_type): + return [ + { + "value": as_number( + get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + else "value" + ) + ), + "date": consumptions["date"], + } + for consumptions in monthly_consumptions + if get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + ] + return None diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e38513046f1..d6060a360b5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -267,6 +267,7 @@ FLOWS = { "iqvia", "islamic_prayer_times", "iss", + "ista_ecotrend", "isy994", "izone", "jellyfin", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 194ca540b3f..578f2631b25 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2922,6 +2922,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ista_ecotrend": { + "name": "ista Ecotrend", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "isy994": { "name": "Universal Devices ISY/IoX", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 54aee2cdafd..1bb6417f909 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1808,6 +1808,9 @@ pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 +# homeassistant.components.ista_ecotrend +pyecotrend-ista==3.1.1 + # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1198fee3cac..1308597ce9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1419,6 +1419,9 @@ pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 +# homeassistant.components.ista_ecotrend +pyecotrend-ista==3.1.1 + # homeassistant.components.efergy pyefergy==22.5.0 diff --git a/tests/components/ista_ecotrend/__init__.py b/tests/components/ista_ecotrend/__init__.py new file mode 100644 index 00000000000..d636c2a399c --- /dev/null +++ b/tests/components/ista_ecotrend/__init__.py @@ -0,0 +1 @@ +"""Tests for the ista Ecotrend integration.""" diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py new file mode 100644 index 00000000000..786be230c05 --- /dev/null +++ b/tests/components/ista_ecotrend/conftest.py @@ -0,0 +1,166 @@ +"""Common fixtures for the ista Ecotrend tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.ista_ecotrend.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="ista_config_entry") +def mock_ista_config_entry() -> MockConfigEntry: + """Mock ista EcoTrend configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="26e93f1a-c828-11ea-87d0-0242ac130003", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ista_ecotrend.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ista() -> Generator[MagicMock, None, None]: + """Mock Pyecotrend_ista client.""" + + with ( + patch( + "homeassistant.components.ista_ecotrend.PyEcotrendIsta", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.ista_ecotrend.config_flow.PyEcotrendIsta", + new=mock_client, + ), + patch( + "homeassistant.components.ista_ecotrend.coordinator.PyEcotrendIsta", + new=mock_client, + ), + ): + client = mock_client.return_value + client._uuid = "26e93f1a-c828-11ea-87d0-0242ac130003" + client._a_firstName = "Max" + client._a_lastName = "Istamann" + client.get_consumption_unit_details.return_value = { + "consumptionUnits": [ + { + "id": "26e93f1a-c828-11ea-87d0-0242ac130003", + "address": { + "street": "Luxemburger Str.", + "houseNumber": "1", + }, + }, + { + "id": "eaf5c5c8-889f-4a3c-b68c-e9a676505762", + "address": { + "street": "Bahnhofsstr.", + "houseNumber": "1A", + }, + }, + ] + } + client.getUUIDs.return_value = [ + "26e93f1a-c828-11ea-87d0-0242ac130003", + "eaf5c5c8-889f-4a3c-b68c-e9a676505762", + ] + client.get_raw = get_raw + + yield client + + +def get_raw(obj_uuid: str | None = None) -> dict[str, Any]: + """Mock function get_raw.""" + return { + "consumptionUnitId": obj_uuid, + "consumptions": [ + { + "date": {"month": 5, "year": 2024}, + "readings": [ + { + "type": "heating", + "value": "35", + "additionalValue": "38,0", + }, + { + "type": "warmwater", + "value": "1,0", + "additionalValue": "57,0", + }, + { + "type": "water", + "value": "5,0", + }, + ], + }, + { + "date": {"month": 4, "year": 2024}, + "readings": [ + { + "type": "heating", + "value": "104", + "additionalValue": "113,0", + }, + { + "type": "warmwater", + "value": "1,1", + "additionalValue": "61,1", + }, + { + "type": "water", + "value": "6,8", + }, + ], + }, + ], + "costs": [ + { + "date": {"month": 5, "year": 2024}, + "costsByEnergyType": [ + { + "type": "heating", + "value": 21, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 3, + }, + ], + }, + { + "date": {"month": 4, "year": 2024}, + "costsByEnergyType": [ + { + "type": "heating", + "value": 62, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 2, + }, + ], + }, + ], + } diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr new file mode 100644 index 00000000000..a9d13510b54 --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + '26e93f1a-c828-11ea-87d0-0242ac130003', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Luxemburger Str. 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Bahnhofsstr. 1A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c312f9b6350 --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr @@ -0,0 +1,915 @@ +# serializer version: 1 +# name: test_setup.32 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + '26e93f1a-c828-11ea-87d0-0242ac130003', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Luxemburger Str. 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_setup.33 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Bahnhofsstr. 1A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bahnhofsstr. 1A Heating', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Heating cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Bahnhofsstr. 1A Heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Bahnhofsstr. 1A Hot water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Hot water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Bahnhofsstr. 1A Hot water energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Bahnhofsstr. 1A Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Luxemburger Str. 1 Heating', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Heating cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Luxemburger Str. 1 Heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Luxemburger Str. 1 Hot water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Hot water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Luxemburger Str. 1 Hot water energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Luxemburger Str. 1 Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr new file mode 100644 index 00000000000..9536c5336db --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -0,0 +1,175 @@ +# serializer version: 1 +# name: test_get_statistics + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 104, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 35, + }), + ]) +# --- +# name: test_get_statistics.1 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 113.0, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 38.0, + }), + ]) +# --- +# name: test_get_statistics.2 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 62, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 21, + }), + ]) +# --- +# name: test_get_statistics.3 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 1.1, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 1.0, + }), + ]) +# --- +# name: test_get_statistics.4 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 61.1, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 57.0, + }), + ]) +# --- +# name: test_get_statistics.5 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + ]) +# --- +# name: test_get_statistics.6 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), + ]) +# --- +# name: test_get_statistics.7 + list([ + ]) +# --- +# name: test_get_statistics.8 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 2, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 3, + }), + ]) +# --- +# name: test_get_values_by_type + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }) +# --- +# name: test_get_values_by_type.1 + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }) +# --- +# name: test_get_values_by_type.2 + dict({ + 'type': 'water', + 'value': '5,0', + }) +# --- +# name: test_get_values_by_type.3 + dict({ + 'type': 'heating', + 'value': 21, + }) +# --- +# name: test_get_values_by_type.4 + dict({ + 'type': 'warmwater', + 'value': 7, + }) +# --- +# name: test_get_values_by_type.5 + dict({ + 'type': 'water', + 'value': 3, + }) +# --- +# name: test_last_day_of_month + datetime.datetime(2024, 1, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.1 + datetime.datetime(2024, 2, 29, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.10 + datetime.datetime(2024, 11, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.11 + datetime.datetime(2024, 12, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.2 + datetime.datetime(2024, 3, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.3 + datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.4 + datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.5 + datetime.datetime(2024, 6, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.6 + datetime.datetime(2024, 7, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.7 + datetime.datetime(2024, 8, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.8 + datetime.datetime(2024, 9, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.9 + datetime.datetime(2024, 10, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- diff --git a/tests/components/ista_ecotrend/test_config_flow.py b/tests/components/ista_ecotrend/test_config_flow.py new file mode 100644 index 00000000000..3ff192c85ac --- /dev/null +++ b/tests/components/ista_ecotrend/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the ista Ecotrend config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyecotrend_ista.exception_classes import LoginError, ServerError +import pytest + +from homeassistant.components.ista_ecotrend.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_ista: MagicMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = 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 result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Max Istamann" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (LoginError(None), "invalid_auth"), + (ServerError, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ista: MagicMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_ista.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_ista.login.side_effect = None + result = 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 result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Max Istamann" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ista_ecotrend/test_init.py b/tests/components/ista_ecotrend/test_init.py new file mode 100644 index 00000000000..11a770d9ec7 --- /dev/null +++ b/tests/components/ista_ecotrend/test_init.py @@ -0,0 +1,99 @@ +"""Test the ista Ecotrend init.""" + +from unittest.mock import MagicMock + +from pyecotrend_ista.exception_classes import ( + InternalServerError, + KeycloakError, + LoginError, + ServerError, +) +import pytest +from requests.exceptions import RequestException +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, ista_config_entry: MockConfigEntry, mock_ista: MagicMock +) -> None: + """Test integration setup and unload.""" + + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect"), + [ + ServerError, + InternalServerError(None), + RequestException, + TimeoutError, + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, +) -> None: + """Test config entry not ready.""" + mock_ista.login.side_effect = side_effect + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("side_effect"), + [LoginError(None), KeycloakError], +) +async def test_config_entry_error( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, +) -> None: + """Test config entry not ready.""" + mock_ista.login.side_effect = side_effect + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device_registry( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + for device in dr.async_entries_for_config_entry( + device_registry, ista_config_entry.entry_id + ): + assert device == snapshot diff --git a/tests/components/ista_ecotrend/test_sensor.py b/tests/components/ista_ecotrend/test_sensor.py new file mode 100644 index 00000000000..ca109455885 --- /dev/null +++ b/tests/components/ista_ecotrend/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests for the ista EcoTrend Sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of ista EcoTrend sensor platform.""" + + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, ista_config_entry.entry_id) diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py new file mode 100644 index 00000000000..e2e799aa78b --- /dev/null +++ b/tests/components/ista_ecotrend/test_util.py @@ -0,0 +1,146 @@ +"""Tests for the ista EcoTrend utility functions.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ista_ecotrend.util import ( + IstaConsumptionType, + IstaValueType, + as_number, + get_native_value, + get_statistics, + get_values_by_type, + last_day_of_month, +) + +from .conftest import get_raw + + +def test_as_number() -> None: + """Test as_number formatting function.""" + assert as_number("10") == 10 + assert isinstance(as_number("10"), int) + + assert as_number("9,5") == 9.5 + assert isinstance(as_number("9,5"), float) + + assert as_number(None) is None + assert isinstance(as_number(10.0), float) + + +def test_last_day_of_month(snapshot: SnapshotAssertion) -> None: + """Test determining last day of month.""" + + for month in range(12): + assert last_day_of_month(month=month + 1, year=2024) == snapshot + + +def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: + """Test get_values_by_type function.""" + consumptions = { + "readings": [ + { + "type": "heating", + "value": "35", + "additionalValue": "38,0", + }, + { + "type": "warmwater", + "value": "1,0", + "additionalValue": "57,0", + }, + { + "type": "water", + "value": "5,0", + }, + ], + } + + assert get_values_by_type(consumptions, IstaConsumptionType.HEATING) == snapshot + assert get_values_by_type(consumptions, IstaConsumptionType.HOT_WATER) == snapshot + assert get_values_by_type(consumptions, IstaConsumptionType.WATER) == snapshot + + costs = { + "costsByEnergyType": [ + { + "type": "heating", + "value": 21, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 3, + }, + ], + } + + assert get_values_by_type(costs, IstaConsumptionType.HEATING) == snapshot + assert get_values_by_type(costs, IstaConsumptionType.HOT_WATER) == snapshot + assert get_values_by_type(costs, IstaConsumptionType.WATER) == snapshot + + assert get_values_by_type({}, IstaConsumptionType.HEATING) == {} + assert get_values_by_type({"readings": []}, IstaConsumptionType.HEATING) == {} + + +def test_get_native_value() -> None: + """Test getting native value for sensor states.""" + test_data = get_raw("26e93f1a-c828-11ea-87d0-0242ac130003") + + assert get_native_value(test_data, IstaConsumptionType.HEATING) == 35 + assert get_native_value(test_data, IstaConsumptionType.HOT_WATER) == 1.0 + assert get_native_value(test_data, IstaConsumptionType.WATER) == 5.0 + + assert ( + get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) + == 21 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.COSTS) + == 7 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.WATER, IstaValueType.COSTS) == 3 + ) + + assert ( + get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.ENERGY) + == 38.0 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY) + == 57.0 + ) + + no_data = {"consumptions": None, "costs": None} + assert get_native_value(no_data, IstaConsumptionType.HEATING) is None + assert ( + get_native_value(no_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) + is None + ) + + +def test_get_statistics(snapshot: SnapshotAssertion) -> None: + """Test get_statistics function.""" + test_data = get_raw("26e93f1a-c828-11ea-87d0-0242ac130003") + for consumption_type in IstaConsumptionType: + assert get_statistics(test_data, consumption_type) == snapshot + assert get_statistics({"consumptions": None}, consumption_type) is None + assert ( + get_statistics(test_data, consumption_type, IstaValueType.ENERGY) + == snapshot + ) + assert ( + get_statistics( + {"consumptions": None}, consumption_type, IstaValueType.ENERGY + ) + is None + ) + assert ( + get_statistics(test_data, consumption_type, IstaValueType.COSTS) == snapshot + ) + assert ( + get_statistics({"costs": None}, consumption_type, IstaValueType.COSTS) + is None + )