diff --git a/.strict-typing b/.strict-typing index 1360aefca339..77f85ae03e7d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -674,4 +674,5 @@ homeassistant.components.zeroconf.* homeassistant.components.zinvolt.* homeassistant.components.zodiac.* homeassistant.components.zone.* +homeassistant.components.zonneplan.* homeassistant.components.zwave_js.* diff --git a/CODEOWNERS b/CODEOWNERS index a677b5ac081c..f25b6c4f2726 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2188,6 +2188,8 @@ CLAUDE.md @home-assistant/core /tests/components/zone/ @home-assistant/core /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi /tests/components/zoneminder/ @rohankapoorcom @nabbi +/homeassistant/components/zonneplan/ @erwindouna +/tests/components/zonneplan/ @erwindouna /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS diff --git a/homeassistant/components/zonneplan/__init__.py b/homeassistant/components/zonneplan/__init__.py new file mode 100644 index 000000000000..661bede950e9 --- /dev/null +++ b/homeassistant/components/zonneplan/__init__.py @@ -0,0 +1,35 @@ +"""The Zonneplan integration.""" + +from pyzonneplan import Token, Zonneplan + +from homeassistant.const import CONF_EMAIL, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import ZonneplanConfigEntry, ZonneplanCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZonneplanConfigEntry) -> bool: + """Set up Zonneplan from a config entry.""" + coordinator = ZonneplanCoordinator( + hass, + entry, + Zonneplan( + email=entry.data[CONF_EMAIL], + session=async_get_clientsession(hass), + token=Token.from_dict(entry.data[CONF_TOKEN]), + ), + ) + 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: ZonneplanConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/zonneplan/config_flow.py b/homeassistant/components/zonneplan/config_flow.py new file mode 100644 index 000000000000..69a3f320f28e --- /dev/null +++ b/homeassistant/components/zonneplan/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for the Zonneplan integration.""" + +import logging +from typing import Any, override + +from pyzonneplan import ( + OtpChallenge, + Zonneplan, + ZonneplanConnectionError, + ZonneplanInvalidOtpError, + ZonneplanTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +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="username") + ), + } +) +STEP_OTP_DATA_SCHEMA = vol.Schema( + { + vol.Required("otp"): TextSelector( + TextSelectorConfig(type=TextSelectorType.NUMBER) + ) + } +) + + +class ZonneplanConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Zonneplan.""" + + _client: Zonneplan + _challenge: OtpChallenge + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step: request an OTP for the given email.""" + errors: dict[str, str] = {} + if user_input is not None: + self._client = Zonneplan( + email=user_input[CONF_EMAIL], + session=async_get_clientsession(self.hass), + ) + try: + self._challenge = await self._client.async_request_otp( + source_name=self.hass.config.location_name + ) + except ZonneplanConnectionError: + errors["base"] = "cannot_connect" + except ZonneplanTimeoutError: + errors["base"] = "timeout_connect" + except Exception: + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self.async_step_otp() + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_otp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle submission of the mailed one-time password.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + token = await self._client.async_submit_otp( + self._challenge, user_input["otp"] + ) + account = await self._client.async_get_account() + except ZonneplanInvalidOtpError: + errors["base"] = "invalid_auth" + except ZonneplanConnectionError: + errors["base"] = "cannot_connect" + except ZonneplanTimeoutError: + errors["base"] = "timeout_connect" + except Exception: + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(account.user_account.uuid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=account.user_account.full_name, + data={ + CONF_EMAIL: account.user_account.email, + CONF_TOKEN: token.as_dict(), + }, + ) + + return self.async_show_form( + step_id="otp", + data_schema=STEP_OTP_DATA_SCHEMA, + errors=errors, + description_placeholders={CONF_EMAIL: self._challenge.email}, + ) diff --git a/homeassistant/components/zonneplan/const.py b/homeassistant/components/zonneplan/const.py new file mode 100644 index 000000000000..5676e35a7d9b --- /dev/null +++ b/homeassistant/components/zonneplan/const.py @@ -0,0 +1,3 @@ +"""Constants for the Zonneplan integration.""" + +DOMAIN = "zonneplan" diff --git a/homeassistant/components/zonneplan/coordinator.py b/homeassistant/components/zonneplan/coordinator.py new file mode 100644 index 000000000000..9611bf367677 --- /dev/null +++ b/homeassistant/components/zonneplan/coordinator.py @@ -0,0 +1,115 @@ +"""Coordinator for Zonneplan.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, override + +from pyzonneplan import ( + Account, + ConsumerPrices, + Zonneplan, + ZonneplanAuthenticationError, + ZonneplanConnectionError, + ZonneplanTimeoutError, +) +from pyzonneplan.const import PriceChart + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(minutes=15) + +type ZonneplanConfigEntry = ConfigEntry[ZonneplanCoordinator] + + +@dataclass(frozen=True, kw_only=True) +class ZonneplanData: + """Data fetched by the Zonneplan coordinator.""" + + account: Account + electricity_prices: ConsumerPrices | None = None + gas_prices: ConsumerPrices | None = None + + +class ZonneplanCoordinator(DataUpdateCoordinator[ZonneplanData]): + """Coordinator to manage fetching Zonneplan account data.""" + + config_entry: ZonneplanConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ZonneplanConfigEntry, zonneplan: Zonneplan + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.zonneplan = zonneplan + + @override + async def _async_update_data(self) -> ZonneplanData: + """Fetch data from the Zonneplan API.""" + try: + account = await self.zonneplan.async_get_account() + electricity_prices: ConsumerPrices | None = None + gas_prices: ConsumerPrices | None = None + + # Depending per contract, fetch the associated consumer prices + for connection in account.connections: + if ( + "electricity" in connection.market_segment + if connection.market_segment is not None + else False + ): + electricity_prices = await self.zonneplan.async_get_consumer_prices( + PriceChart.ELECTRICITY_HOURLY + ) + if ( + "gas" in connection.market_segment + if connection.market_segment is not None + else False + ): + gas_prices = await self.zonneplan.async_get_consumer_prices( + PriceChart.GAS_DAILY + ) + except ZonneplanAuthenticationError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ZonneplanTimeoutError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + except ZonneplanConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + if TYPE_CHECKING: + assert self.zonneplan.token is not None + + token = self.zonneplan.token.as_dict() + if self.config_entry.data.get(CONF_TOKEN) != token: + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_TOKEN: token}, + ) + + return ZonneplanData( + account=account, + electricity_prices=electricity_prices, + gas_prices=gas_prices, + ) diff --git a/homeassistant/components/zonneplan/entity.py b/homeassistant/components/zonneplan/entity.py new file mode 100644 index 000000000000..1e575aee0064 --- /dev/null +++ b/homeassistant/components/zonneplan/entity.py @@ -0,0 +1,29 @@ +"""Base entity for Zonneplan.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ZonneplanCoordinator + + +class ZonneplanEntity(CoordinatorEntity[ZonneplanCoordinator]): + """Base entity for Zonneplan.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: ZonneplanCoordinator, entity_description: EntityDescription + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.account.user_account.uuid}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Zonneplan", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/zonneplan/icons.json b/homeassistant/components/zonneplan/icons.json new file mode 100644 index 000000000000..f029aa3fd913 --- /dev/null +++ b/homeassistant/components/zonneplan/icons.json @@ -0,0 +1,43 @@ +{ + "entity": { + "sensor": { + "current_electricity_price": { + "default": "mdi:cash-multiple" + }, + "electricity_price_low_today_end_time": { + "default": "mdi:clock-end" + }, + "electricity_price_low_today_start_time": { + "default": "mdi:clock-start" + }, + "electricity_price_low_tomorrow_end_time": { + "default": "mdi:clock-end" + }, + "electricity_price_low_tomorrow_start_time": { + "default": "mdi:clock-start" + }, + "electricity_prices_tomorrow_status": { + "default": "mdi:calendar-clock", + "state": { + "available": "mdi:calendar-check", + "incoming": "mdi:calendar-clock" + } + }, + "gas_price_today": { + "default": "mdi:cash-multiple" + }, + "highest_electricity_price_today": { + "default": "mdi:cash-multiple" + }, + "highest_electricity_price_tomorrow": { + "default": "mdi:cash-multiple" + }, + "lowest_electricity_price_today": { + "default": "mdi:cash-multiple" + }, + "lowest_electricity_price_tomorrow": { + "default": "mdi:cash-multiple" + } + } + } +} diff --git a/homeassistant/components/zonneplan/manifest.json b/homeassistant/components/zonneplan/manifest.json new file mode 100644 index 000000000000..1b2520fb0458 --- /dev/null +++ b/homeassistant/components/zonneplan/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "zonneplan", + "name": "Zonneplan", + "codeowners": ["@erwindouna"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zonneplan", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyzonneplan"], + "quality_scale": "bronze", + "requirements": ["pyzonneplan==0.1.2"] +} diff --git a/homeassistant/components/zonneplan/quality_scale.yaml b/homeassistant/components/zonneplan/quality_scale.yaml new file mode 100644 index 000000000000..bb26093c8211 --- /dev/null +++ b/homeassistant/components/zonneplan/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: | + Entities read cached state from the coordinator via CoordinatorEntity + and subscribe to no other events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register any service actions or entity actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/zonneplan/sensor.py b/homeassistant/components/zonneplan/sensor.py new file mode 100644 index 000000000000..90b455746dab --- /dev/null +++ b/homeassistant/components/zonneplan/sensor.py @@ -0,0 +1,281 @@ +"""Sensor platform for Zonneplan.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import override + +from aiozoneinfo import get_time_zone + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ZonneplanConfigEntry, ZonneplanCoordinator +from .entity import ZonneplanEntity + +PARALLEL_UPDATES = 0 + +# It's for Dutchies, so ya... +ZONNEPLAN_TIMEZONE = get_time_zone("Europe/Amsterdam") + + +@dataclass(frozen=True, kw_only=True) +class ZonneplanPriceSensorEntityDescription(SensorEntityDescription): + """Describes a Zonneplan price sensor.""" + + value_fn: Callable[[ZonneplanCoordinator], float | str | datetime | None] + supported_fn: Callable[[ZonneplanCoordinator], bool] | None = None + + +ZONNEPLAN_SENSORS: tuple[ZonneplanPriceSensorEntityDescription, ...] = ( + ZonneplanPriceSensorEntityDescription( + key="current_electricity_price", + translation_key="current_electricity_price", + native_unit_of_measurement=f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if ( + point := next( + ( + point + for electricity_prices in (coordinator.data.electricity_prices,) + if electricity_prices is not None + for point in electricity_prices.prices + if point.start_date <= dt_util.utcnow() < point.end_date + ), + None, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="lowest_electricity_price_today", + translation_key="lowest_electricity_price_today", + native_unit_of_measurement=f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if coordinator.data.electricity_prices is not None + and ( + point := coordinator.data.electricity_prices.extreme_price( + dt_util.now(ZONNEPLAN_TIMEZONE).date(), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="highest_electricity_price_today", + translation_key="highest_electricity_price_today", + native_unit_of_measurement=f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if coordinator.data.electricity_prices is not None + and ( + point := coordinator.data.electricity_prices.extreme_price( + dt_util.now(ZONNEPLAN_TIMEZONE).date(), + ZONNEPLAN_TIMEZONE, + lowest=False, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="lowest_electricity_price_tomorrow", + translation_key="lowest_electricity_price_tomorrow", + native_unit_of_measurement=f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if coordinator.data.electricity_prices is not None + and ( + point := coordinator.data.electricity_prices.extreme_price( + dt_util.now(ZONNEPLAN_TIMEZONE).date() + timedelta(days=1), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="highest_electricity_price_tomorrow", + translation_key="highest_electricity_price_tomorrow", + native_unit_of_measurement=f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if coordinator.data.electricity_prices is not None + and ( + point := coordinator.data.electricity_prices.extreme_price( + dt_util.now(ZONNEPLAN_TIMEZONE).date() + timedelta(days=1), + ZONNEPLAN_TIMEZONE, + lowest=False, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="electricity_prices_tomorrow_status", + translation_key="electricity_prices_tomorrow_status", + device_class=SensorDeviceClass.ENUM, + options=["incoming", "available"], + value_fn=lambda coordinator: ( + "available" + if coordinator.data.electricity_prices is not None + and coordinator.data.electricity_prices.prices_for_day( + dt_util.now(ZONNEPLAN_TIMEZONE).date() + timedelta(days=1), + ZONNEPLAN_TIMEZONE, + ) + else "incoming" + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="gas_price_today", + translation_key="gas_price_today", + native_unit_of_measurement=f"EUR/{UnitOfVolume.CUBIC_METERS}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda coordinator: ( + float(point.price_tax_included.euro) + if coordinator.data.gas_prices is not None + and ( + point := next( + iter( + coordinator.data.gas_prices.prices_for_day( + dt_util.now(ZONNEPLAN_TIMEZONE).date(), ZONNEPLAN_TIMEZONE + ) + ), + None, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.gas_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="electricity_price_low_today_start_time", + translation_key="electricity_price_low_today_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda coordinator: ( + dt_util.as_local(block[0].start_date) + if coordinator.data.electricity_prices is not None + and ( + block := coordinator.data.electricity_prices.price_block( + dt_util.now(ZONNEPLAN_TIMEZONE).date(), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="electricity_price_low_today_end_time", + translation_key="electricity_price_low_today_end_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda coordinator: ( + dt_util.as_local(block[1].end_date) + if coordinator.data.electricity_prices is not None + and ( + block := coordinator.data.electricity_prices.price_block( + dt_util.now(ZONNEPLAN_TIMEZONE).date(), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="electricity_price_low_tomorrow_start_time", + translation_key="electricity_price_low_tomorrow_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda coordinator: ( + dt_util.as_local(block[0].start_date) + if coordinator.data.electricity_prices is not None + and ( + block := coordinator.data.electricity_prices.price_block( + dt_util.now(ZONNEPLAN_TIMEZONE).date() + timedelta(days=1), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), + ZonneplanPriceSensorEntityDescription( + key="electricity_price_low_tomorrow_end_time", + translation_key="electricity_price_low_tomorrow_end_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda coordinator: ( + dt_util.as_local(block[1].end_date) + if coordinator.data.electricity_prices is not None + and ( + block := coordinator.data.electricity_prices.price_block( + dt_util.now(ZONNEPLAN_TIMEZONE).date() + timedelta(days=1), + ZONNEPLAN_TIMEZONE, + lowest=True, + ) + ) + else None + ), + supported_fn=lambda coordinator: bool(coordinator.data.electricity_prices), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZonneplanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Zonneplan sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + ZonneplanPriceSensor(coordinator, description) + for description in ZONNEPLAN_SENSORS + ) + + +class ZonneplanPriceSensor(ZonneplanEntity, SensorEntity): + """Representation of a Zonneplan electricity price sensor.""" + + entity_description: ZonneplanPriceSensorEntityDescription + + @property + @override + def native_value(self) -> StateType | datetime: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/zonneplan/strings.json b/homeassistant/components/zonneplan/strings.json new file mode 100644 index 000000000000..53f2d57d03d6 --- /dev/null +++ b/homeassistant/components/zonneplan/strings.json @@ -0,0 +1,85 @@ +{ + "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%]", + "timeout_connect": "Timeout while communicating with the Zonneplan API", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "otp": { + "data": { + "otp": "One-time password" + }, + "data_description": { + "otp": "Enter the one-time password mailed to {email}." + }, + "description": "Within a few minutes, you will receive an email with a one-time password. Enter the password below to complete the login process." + }, + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "email": "Email address for your Zonneplan account." + }, + "description": "Log in to Zonneplan with the email address associated with your account. You will be receiving a one-time password via email to complete the login process." + } + } + }, + "entity": { + "sensor": { + "current_electricity_price": { + "name": "Current electricity price" + }, + "electricity_price_low_today_end_time": { + "name": "Electricity price low today end time" + }, + "electricity_price_low_today_start_time": { + "name": "Electricity price low today start time" + }, + "electricity_price_low_tomorrow_end_time": { + "name": "Electricity price low tomorrow end time" + }, + "electricity_price_low_tomorrow_start_time": { + "name": "Electricity price low tomorrow start time" + }, + "electricity_prices_tomorrow_status": { + "name": "Electricity prices tomorrow status", + "state": { + "available": "Available", + "incoming": "Incoming" + } + }, + "gas_price_today": { + "name": "Gas price daily" + }, + "highest_electricity_price_today": { + "name": "Highest electricity price today" + }, + "highest_electricity_price_tomorrow": { + "name": "Highest electricity price tomorrow" + }, + "lowest_electricity_price_today": { + "name": "Lowest electricity price today" + }, + "lowest_electricity_price_tomorrow": { + "name": "Lowest electricity price tomorrow" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Error communicating with the Zonneplan API" + }, + "invalid_auth": { + "message": "An error occurred while trying to authenticate" + }, + "timeout_connect": { + "message": "Timeout while communicating with the Zonneplan API" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 72322aeb918d..022efbd91edf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -926,6 +926,7 @@ FLOWS = { "zimi", "zinvolt", "zodiac", + "zonneplan", "zwave_js", "zwave_me", ], diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3ce24b731a4c..7f190a8098fb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8552,6 +8552,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "zonneplan": { + "name": "Zonneplan", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "zooz": { "name": "Zooz", "iot_standards": [ diff --git a/mypy.ini b/mypy.ini index 423e23ab1420..45920e6fcb78 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6501,6 +6501,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.zonneplan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.zwave_js.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 699f603bd4db..30a7c90d03d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2916,6 +2916,9 @@ pyzbar==0.1.9 # homeassistant.components.zerproc pyzerproc==0.4.8 +# homeassistant.components.zonneplan +pyzonneplan==0.1.2 + # homeassistant.components.qbittorrent qbittorrent-api==2026.5.1 diff --git a/tests/components/zonneplan/__init__.py b/tests/components/zonneplan/__init__.py new file mode 100644 index 000000000000..1ba8c59ebd31 --- /dev/null +++ b/tests/components/zonneplan/__init__.py @@ -0,0 +1 @@ +"""Tests for the Zonneplan integration.""" diff --git a/tests/components/zonneplan/conftest.py b/tests/components/zonneplan/conftest.py new file mode 100644 index 000000000000..5257a88f415f --- /dev/null +++ b/tests/components/zonneplan/conftest.py @@ -0,0 +1,89 @@ +"""Common fixtures for the Zonneplan tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest +from pyzonneplan import Account, ConsumerPrices, OtpChallenge, Token, Zonneplan +from pyzonneplan.const import PriceChart + +from homeassistant.components.zonneplan.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + +MOCK_EMAIL = "user@example.com" +MOCK_USER_INPUT = {CONF_EMAIL: MOCK_EMAIL} +MOCK_ACCOUNT = Account.from_dict(load_json_object_fixture("get_account.json", DOMAIN)) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.zonneplan.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_zonneplan_client() -> Generator[AsyncMock]: + """Mock the Zonneplan client.""" + client = AsyncMock(Zonneplan) + client.token = Token( + access_token="mock-access-token", + refresh_token="mock-refresh-token", + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + ) + client.async_request_otp.return_value = OtpChallenge( + auth_session="mock-auth-session", + code_verifier="mock-code-verifier", + email=MOCK_EMAIL, + ) + client.async_submit_otp.return_value = Token( + access_token="mock-access-token", + refresh_token="mock-refresh-token", + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + ) + client.async_get_account.return_value = MOCK_ACCOUNT + + prices_by_chart = { + PriceChart.ELECTRICITY_HOURLY: ConsumerPrices.from_dict( + load_json_object_fixture( + "get_consumer_prices_electricity_hourly.json", DOMAIN + ) + ), + PriceChart.GAS_DAILY: ConsumerPrices.from_dict( + load_json_object_fixture("get_consumer_prices_gas_daily.json", DOMAIN) + ), + } + client.async_get_consumer_prices.side_effect = lambda chart: prices_by_chart[chart] + + with ( + patch("homeassistant.components.zonneplan.Zonneplan", return_value=client), + patch( + "homeassistant.components.zonneplan.config_flow.Zonneplan", + return_value=client, + ), + ): + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_EMAIL, + unique_id=MOCK_ACCOUNT.user_account.uuid, + entry_id="01JXZKYZ00XZKYZ00XZKYZ00XZ", + data={ + CONF_EMAIL: MOCK_EMAIL, + CONF_TOKEN: Token( + access_token="mock-access-token", + refresh_token="mock-refresh-token", + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + ).as_dict(), + }, + ) diff --git a/tests/components/zonneplan/fixtures/get_account.json b/tests/components/zonneplan/fixtures/get_account.json new file mode 100644 index 000000000000..497e893a8fdb --- /dev/null +++ b/tests/components/zonneplan/fixtures/get_account.json @@ -0,0 +1,207 @@ +{ + "user_account": { + "initials": "", + "uuid": "00000000-0000-4000-8000-000000000001", + "email": "user@example.com", + "first_name": null, + "full_name": "No one special", + "is_representative": false + }, + "address_groups": [ + { + "uuid": "00000000-0000-4000-8000-000000000002", + "connections": [ + { + "uuid": "00000000-0000-4000-8000-000000000002", + "ean": "871000000000000001", + "market_segment": "electricity", + "contracts": [ + { + "uuid": "00000000-0000-4000-8000-000000000004", + "label": "Stroom tegen uurprijzen", + "type": "electricity", + "start_date": "2026-09-17T22:00:00.000000Z", + "end_date": null, + "meta": { + "external_contract_id": "00000000-0000-4000-8000-000000000005", + "agreement_date": "2026-08-29", + "original_end_date": null, + "contract_type": "smart", + "proposition_reference": "vast-voorschot-e-2025-12-kwart", + "start_reason": null, + "end_reason": null, + "show_in_contract_screen": true, + "expected_delivery": 4470, + "expected_production": 2520, + "gas_price_ceiling_contract_start_date": null, + "gas_price_ceiling_contract_end_date": null, + "deposit_type": "fixed", + "monthly_advanced_deposit_amount": 930000000 + } + } + ], + "features": [ + { + "code": "E004", + "start_date": "2026-08-29", + "label": "Verbruikshistorie" + } + ], + "buttons": [] + }, + { + "uuid": "00000000-0000-4000-8000-000000000003", + "ean": "871000000000000002", + "market_segment": "gas", + "contracts": [ + { + "uuid": "00000000-0000-4000-8000-000000000006", + "label": "Gas tegen dagprijzen", + "type": "gas", + "start_date": "2026-09-17T22:00:00.000000Z", + "end_date": null, + "meta": { + "external_contract_id": "00000000-0000-4000-8000-000000000007", + "agreement_date": "2026-08-29", + "original_end_date": null, + "contract_type": "smart", + "proposition_reference": "vast-voorschot-g-2024-11", + "start_reason": null, + "end_reason": null, + "show_in_contract_screen": true, + "expected_delivery": 2171, + "expected_production": null, + "gas_price_ceiling_contract_start_date": null, + "gas_price_ceiling_contract_end_date": null, + "deposit_type": "fixed", + "monthly_advanced_deposit_amount": 3500000000 + } + } + ], + "features": [], + "buttons": [] + } + ], + "address": { + "id": "1234AB1", + "zipcode": "1234 AB", + "street": "Teststraat", + "number": "1", + "addition": "", + "city": "Voorbeeldstad", + "sunrise": "2026-08-29T04:39:24.000000Z", + "sunset": "2026-08-29T18:30:23.000000Z" + }, + "is_representative": true, + "organization": { + "number": 12345678, + "name": "No one special", + "phone_numbers": ["+31600000000"], + "emails": ["user@example.com"], + "privacy_service_code": "ABCDEF", + "address": { + "id": "1234AB1", + "zipcode": "1234 AB", + "street": "Teststraat", + "number": "1", + "addition": "", + "city": "Voorbeeldstad", + "sunrise": "2026-08-29T04:39:24.000000Z", + "sunset": "2026-08-29T18:30:23.000000Z" + }, + "debtor": { + "name": "J DOE", + "bank_account_number": "NL00BANK0123456789", + "mandate_reference": "01-01-2026", + "mandate_date": "01-01-2026", + "payment_method": "Automatische Incasso" + } + }, + "organization_uuid": "00000000-0000-4000-8000-000000000008" + } + ], + "buttons": [], + "iar_enabled": false, + "unread_notification_count": 0, + "chat": { + "user": { + "identifier": "00000000-0000-4000-8000-000000000001", + "email": "user@example.com", + "name": " No one special" + }, + "custom_attributes": { + "source": "app" + } + }, + "notification_setting_groups": [ + { + "slug": "general", + "name": "Algemeen", + "description": "Algemene notificaties over Zonneplan van onderwerpen die relevant zijn voor jou", + "settings": [ + { + "slug": "new-updates", + "name": "Algemeen", + "description": "Altijd als eerste op de hoogte", + "push_enabled": true + }, + { + "slug": "conversation-updates", + "name": "Chatberichten", + "description": "Krijg een melding bij een ongelezen bericht", + "push_enabled": true + }, + { + "slug": "p1-sgn-updates", + "name": "Connect P1-meter berichten", + "description": "Updates over de dongel in je slimme meter", + "push_enabled": true + } + ] + }, + { + "slug": "prices", + "name": "Prijsupdates", + "description": "Notificaties bij belangrijke prijsupdates wanneer je klant bent bij Zonneplan Energie", + "settings": [ + { + "slug": "high-energy-price", + "name": "Hoge stroomprijs", + "description": "Melding bij een bijzonder hoge stroomprijs", + "push_enabled": false + }, + { + "slug": "low-energy-price", + "name": "Negatieve stroomprijs", + "description": "Melding als de stroomprijs negatief is", + "push_enabled": false + } + ] + }, + { + "slug": "malfunctions", + "name": "Proactieve monitoring", + "description": "Notificaties bij storingen", + "settings": [ + { + "slug": "battery-malfunction", + "name": "Thuisbatterij", + "description": "Belangrijke updates over je thuisbatterij", + "push_enabled": true + }, + { + "slug": "chargepoint-malfunction", + "name": "Laadpaal", + "description": "Belangrijke updates over je laadpaal", + "push_enabled": true + }, + { + "slug": "solar-malfunction", + "name": "Zonnepanelen", + "description": "Belangrijke updates over je zonnepanelen", + "push_enabled": true + } + ] + } + ] +} diff --git a/tests/components/zonneplan/fixtures/get_consumer_prices_electricity_hourly.json b/tests/components/zonneplan/fixtures/get_consumer_prices_electricity_hourly.json new file mode 100644 index 000000000000..c22137474956 --- /dev/null +++ b/tests/components/zonneplan/fixtures/get_consumer_prices_electricity_hourly.json @@ -0,0 +1,810 @@ +{ + "chart": { + "range": { + "start_date": "2026-08-28T15:00:00+02:00", + "end_date": "2026-08-30T23:59:59+02:00" + }, + "series": { + "prices": [ + { + "start_date": "2026-08-28T13:00:00+00:00", + "end_date": "2026-08-28T14:00:00+00:00", + "price_tax_included": { + "amount": 2604541 + }, + "price_tax_excluded": { + "amount": 1496060 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-28T14:00:00+00:00", + "end_date": "2026-08-28T15:00:00+00:00", + "price_tax_included": { + "amount": 2653304 + }, + "price_tax_excluded": { + "amount": 1544823 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-28T15:00:00+00:00", + "end_date": "2026-08-28T16:00:00+00:00", + "price_tax_included": { + "amount": 2958497 + }, + "price_tax_excluded": { + "amount": 1850016 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-28T16:00:00+00:00", + "end_date": "2026-08-28T17:00:00+00:00", + "price_tax_included": { + "amount": 3231866 + }, + "price_tax_excluded": { + "amount": 2123385 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-28T17:00:00+00:00", + "end_date": "2026-08-28T18:00:00+00:00", + "price_tax_included": { + "amount": 3389680 + }, + "price_tax_excluded": { + "amount": 2281199 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 896 + } + }, + { + "start_date": "2026-08-28T18:00:00+00:00", + "end_date": "2026-08-28T19:00:00+00:00", + "price_tax_included": { + "amount": 3444160 + }, + "price_tax_excluded": { + "amount": 2335679 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 747 + } + }, + { + "start_date": "2026-08-28T19:00:00+00:00", + "end_date": "2026-08-28T20:00:00+00:00", + "price_tax_included": { + "amount": 3319348 + }, + "price_tax_excluded": { + "amount": 2210867 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 633 + } + }, + { + "start_date": "2026-08-28T20:00:00+00:00", + "end_date": "2026-08-28T21:00:00+00:00", + "price_tax_included": { + "amount": 3264990 + }, + "price_tax_excluded": { + "amount": 2156509 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 689 + } + }, + { + "start_date": "2026-08-28T21:00:00+00:00", + "end_date": "2026-08-28T22:00:00+00:00", + "price_tax_included": { + "amount": 3030734 + }, + "price_tax_excluded": { + "amount": 1922253 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 712 + } + }, + { + "start_date": "2026-08-28T22:00:00+00:00", + "end_date": "2026-08-28T23:00:00+00:00", + "price_tax_included": { + "amount": 2862300 + }, + "price_tax_excluded": { + "amount": 1753819 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 729 + } + }, + { + "start_date": "2026-08-28T23:00:00+00:00", + "end_date": "2026-08-29T00:00:00+00:00", + "price_tax_included": { + "amount": 2659657 + }, + "price_tax_excluded": { + "amount": 1551176 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 781 + } + }, + { + "start_date": "2026-08-29T00:00:00+00:00", + "end_date": "2026-08-29T01:00:00+00:00", + "price_tax_included": { + "amount": 2517542 + }, + "price_tax_excluded": { + "amount": 1409061 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 776 + } + }, + { + "start_date": "2026-08-29T01:00:00+00:00", + "end_date": "2026-08-29T02:00:00+00:00", + "price_tax_included": { + "amount": 2429031 + }, + "price_tax_excluded": { + "amount": 1320550 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 776 + } + }, + { + "start_date": "2026-08-29T02:00:00+00:00", + "end_date": "2026-08-29T03:00:00+00:00", + "price_tax_included": { + "amount": 2423344 + }, + "price_tax_excluded": { + "amount": 1314863 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 672 + } + }, + { + "start_date": "2026-08-29T03:00:00+00:00", + "end_date": "2026-08-29T04:00:00+00:00", + "price_tax_included": { + "amount": 2467116 + }, + "price_tax_excluded": { + "amount": 1358635 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 675 + } + }, + { + "start_date": "2026-08-29T04:00:00+00:00", + "end_date": "2026-08-29T05:00:00+00:00", + "price_tax_included": { + "amount": 2489380 + }, + "price_tax_excluded": { + "amount": 1380899 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 678 + } + }, + { + "start_date": "2026-08-29T05:00:00+00:00", + "end_date": "2026-08-29T06:00:00+00:00", + "price_tax_included": { + "amount": 2533696 + }, + "price_tax_excluded": { + "amount": 1425215 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 674 + } + }, + { + "start_date": "2026-08-29T06:00:00+00:00", + "end_date": "2026-08-29T07:00:00+00:00", + "price_tax_included": { + "amount": 2043011 + }, + "price_tax_excluded": { + "amount": 934530 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 748 + } + }, + { + "start_date": "2026-08-29T07:00:00+00:00", + "end_date": "2026-08-29T08:00:00+00:00", + "price_tax_included": { + "amount": 1371491 + }, + "price_tax_excluded": { + "amount": 263010 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 993 + } + }, + { + "start_date": "2026-08-29T08:00:00+00:00", + "end_date": "2026-08-29T09:00:00+00:00", + "price_tax_included": { + "amount": 1310052 + }, + "price_tax_excluded": { + "amount": 201571 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T09:00:00+00:00", + "end_date": "2026-08-29T10:00:00+00:00", + "price_tax_included": { + "amount": 1301402 + }, + "price_tax_excluded": { + "amount": 192921 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T10:00:00+00:00", + "end_date": "2026-08-29T11:00:00+00:00", + "price_tax_included": { + "amount": 1292962 + }, + "price_tax_excluded": { + "amount": 184481 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T11:00:00+00:00", + "end_date": "2026-08-29T12:00:00+00:00", + "price_tax_included": { + "amount": 1289241 + }, + "price_tax_excluded": { + "amount": 180760 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T12:00:00+00:00", + "end_date": "2026-08-29T13:00:00+00:00", + "price_tax_included": { + "amount": 1302218 + }, + "price_tax_excluded": { + "amount": 193737 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T13:00:00+00:00", + "end_date": "2026-08-29T14:00:00+00:00", + "price_tax_included": { + "amount": 1344145 + }, + "price_tax_excluded": { + "amount": 235664 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T14:00:00+00:00", + "end_date": "2026-08-29T15:00:00+00:00", + "price_tax_included": { + "amount": 1591923 + }, + "price_tax_excluded": { + "amount": 483442 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T15:00:00+00:00", + "end_date": "2026-08-29T16:00:00+00:00", + "price_tax_included": { + "amount": 2542771 + }, + "price_tax_excluded": { + "amount": 1434290 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T16:00:00+00:00", + "end_date": "2026-08-29T17:00:00+00:00", + "price_tax_included": { + "amount": 3139815 + }, + "price_tax_excluded": { + "amount": 2031334 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-29T17:00:00+00:00", + "end_date": "2026-08-29T18:00:00+00:00", + "price_tax_included": { + "amount": 3558867 + }, + "price_tax_excluded": { + "amount": 2450386 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 815 + } + }, + { + "start_date": "2026-08-29T18:00:00+00:00", + "end_date": "2026-08-29T19:00:00+00:00", + "price_tax_included": { + "amount": 3581586 + }, + "price_tax_excluded": { + "amount": 2473105 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 681 + } + }, + { + "start_date": "2026-08-29T19:00:00+00:00", + "end_date": "2026-08-29T20:00:00+00:00", + "price_tax_included": { + "amount": 3388047 + }, + "price_tax_excluded": { + "amount": 2279566 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 612 + } + }, + { + "start_date": "2026-08-29T20:00:00+00:00", + "end_date": "2026-08-29T21:00:00+00:00", + "price_tax_included": { + "amount": 3227177 + }, + "price_tax_excluded": { + "amount": 2118696 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 614 + } + }, + { + "start_date": "2026-08-29T21:00:00+00:00", + "end_date": "2026-08-29T22:00:00+00:00", + "price_tax_included": { + "amount": 3043166 + }, + "price_tax_excluded": { + "amount": 1934685 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 648 + } + }, + { + "start_date": "2026-08-29T22:00:00+00:00", + "end_date": "2026-08-29T23:00:00+00:00", + "price_tax_included": { + "amount": 2725662 + }, + "price_tax_excluded": { + "amount": 1617181 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 677 + } + }, + { + "start_date": "2026-08-29T23:00:00+00:00", + "end_date": "2026-08-30T00:00:00+00:00", + "price_tax_included": { + "amount": 2550545 + }, + "price_tax_excluded": { + "amount": 1442064 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 756 + } + }, + { + "start_date": "2026-08-30T00:00:00+00:00", + "end_date": "2026-08-30T01:00:00+00:00", + "price_tax_included": { + "amount": 2351863 + }, + "price_tax_excluded": { + "amount": 1243382 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 780 + } + }, + { + "start_date": "2026-08-30T01:00:00+00:00", + "end_date": "2026-08-30T02:00:00+00:00", + "price_tax_included": { + "amount": 2233858 + }, + "price_tax_excluded": { + "amount": 1125377 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 824 + } + }, + { + "start_date": "2026-08-30T02:00:00+00:00", + "end_date": "2026-08-30T03:00:00+00:00", + "price_tax_included": { + "amount": 2131008 + }, + "price_tax_excluded": { + "amount": 1022527 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 835 + } + }, + { + "start_date": "2026-08-30T03:00:00+00:00", + "end_date": "2026-08-30T04:00:00+00:00", + "price_tax_included": { + "amount": 2172844 + }, + "price_tax_excluded": { + "amount": 1064363 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 846 + } + }, + { + "start_date": "2026-08-30T04:00:00+00:00", + "end_date": "2026-08-30T05:00:00+00:00", + "price_tax_included": { + "amount": 2173176 + }, + "price_tax_excluded": { + "amount": 1064695 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 860 + } + }, + { + "start_date": "2026-08-30T05:00:00+00:00", + "end_date": "2026-08-30T06:00:00+00:00", + "price_tax_included": { + "amount": 2024256 + }, + "price_tax_excluded": { + "amount": 915775 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 853 + } + }, + { + "start_date": "2026-08-30T06:00:00+00:00", + "end_date": "2026-08-30T07:00:00+00:00", + "price_tax_included": { + "amount": 1503924 + }, + "price_tax_excluded": { + "amount": 395443 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 889 + } + }, + { + "start_date": "2026-08-30T07:00:00+00:00", + "end_date": "2026-08-30T08:00:00+00:00", + "price_tax_included": { + "amount": 1311777 + }, + "price_tax_excluded": { + "amount": 203296 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T08:00:00+00:00", + "end_date": "2026-08-30T09:00:00+00:00", + "price_tax_included": { + "amount": 1307391 + }, + "price_tax_excluded": { + "amount": 198910 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T09:00:00+00:00", + "end_date": "2026-08-30T10:00:00+00:00", + "price_tax_included": { + "amount": 1306666 + }, + "price_tax_excluded": { + "amount": 198185 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T10:00:00+00:00", + "end_date": "2026-08-30T11:00:00+00:00", + "price_tax_included": { + "amount": 1299103 + }, + "price_tax_excluded": { + "amount": 190622 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T11:00:00+00:00", + "end_date": "2026-08-30T12:00:00+00:00", + "price_tax_included": { + "amount": 1295896 + }, + "price_tax_excluded": { + "amount": 187415 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T12:00:00+00:00", + "end_date": "2026-08-30T13:00:00+00:00", + "price_tax_included": { + "amount": 1296108 + }, + "price_tax_excluded": { + "amount": 187627 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T13:00:00+00:00", + "end_date": "2026-08-30T14:00:00+00:00", + "price_tax_included": { + "amount": 1307028 + }, + "price_tax_excluded": { + "amount": 198547 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T14:00:00+00:00", + "end_date": "2026-08-30T15:00:00+00:00", + "price_tax_included": { + "amount": 1314258 + }, + "price_tax_excluded": { + "amount": 205777 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T15:00:00+00:00", + "end_date": "2026-08-30T16:00:00+00:00", + "price_tax_included": { + "amount": 1994732 + }, + "price_tax_excluded": { + "amount": 886251 + }, + "tariff_group": "low", + "sustainability_score": { + "permille": 1000 + } + }, + { + "start_date": "2026-08-30T16:00:00+00:00", + "end_date": "2026-08-30T17:00:00+00:00", + "price_tax_included": { + "amount": 3208694 + }, + "price_tax_excluded": { + "amount": 2100213 + }, + "tariff_group": "normal", + "sustainability_score": { + "permille": 898 + } + }, + { + "start_date": "2026-08-30T17:00:00+00:00", + "end_date": "2026-08-30T18:00:00+00:00", + "price_tax_included": { + "amount": 3535576 + }, + "price_tax_excluded": { + "amount": 2427095 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 584 + } + }, + { + "start_date": "2026-08-30T18:00:00+00:00", + "end_date": "2026-08-30T19:00:00+00:00", + "price_tax_included": { + "amount": 3647561 + }, + "price_tax_excluded": { + "amount": 2539080 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 409 + } + }, + { + "start_date": "2026-08-30T19:00:00+00:00", + "end_date": "2026-08-30T20:00:00+00:00", + "price_tax_included": { + "amount": 3644536 + }, + "price_tax_excluded": { + "amount": 2536055 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 314 + } + }, + { + "start_date": "2026-08-30T20:00:00+00:00", + "end_date": "2026-08-30T21:00:00+00:00", + "price_tax_included": { + "amount": 3504570 + }, + "price_tax_excluded": { + "amount": 2396089 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 348 + } + }, + { + "start_date": "2026-08-30T21:00:00+00:00", + "end_date": "2026-08-30T22:00:00+00:00", + "price_tax_included": { + "amount": 3341552 + }, + "price_tax_excluded": { + "amount": 2233071 + }, + "tariff_group": "high", + "sustainability_score": { + "permille": 374 + } + } + ] + } + } +} diff --git a/tests/components/zonneplan/fixtures/get_consumer_prices_gas_daily.json b/tests/components/zonneplan/fixtures/get_consumer_prices_gas_daily.json new file mode 100644 index 000000000000..c93bb3745e50 --- /dev/null +++ b/tests/components/zonneplan/fixtures/get_consumer_prices_gas_daily.json @@ -0,0 +1,22 @@ +{ + "chart": { + "range": { + "start_date": "2026-08-28T19:00:00+02:00", + "end_date": "2026-08-30T23:59:59+02:00" + }, + "series": { + "prices": [ + { + "start_date": "2026-08-29T04:00:00+00:00", + "end_date": "2026-08-30T04:00:00+00:00", + "price_tax_included": { + "amount": 16029802 + }, + "price_tax_excluded": { + "amount": 8761816 + } + } + ] + } + } +} diff --git a/tests/components/zonneplan/snapshots/test_sensor.ambr b/tests/components/zonneplan/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..61461bb493e0 --- /dev/null +++ b/tests/components/zonneplan/snapshots/test_sensor.ambr @@ -0,0 +1,1213 @@ +# serializer version: 1 +# name: test_sensor[prices_incoming][sensor.zonneplan_current_electricity_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_current_electricity_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current electricity price', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current electricity price', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_electricity_price', + 'unique_id': '00000000-0000-4000-8000-000000000001_current_electricity_price', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_current_electricity_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Current electricity price', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_current_electricity_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2351863', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_today_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low today end time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low today end time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_today_end_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_today_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_today_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low today end time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-30T15:00:00+00:00', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_today_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low today start time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low today start time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_today_start_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_today_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_today_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low today start time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-30T07:00:00+00:00', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_tomorrow_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low tomorrow end time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low tomorrow end time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_tomorrow_end_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_tomorrow_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_tomorrow_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low tomorrow end time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_tomorrow_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low tomorrow start time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low tomorrow start time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_tomorrow_start_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_tomorrow_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_price_low_tomorrow_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low tomorrow start time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_prices_tomorrow_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'incoming', + 'available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_prices_tomorrow_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity prices tomorrow status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity prices tomorrow status', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_prices_tomorrow_status', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_prices_tomorrow_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_electricity_prices_tomorrow_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Zonneplan Electricity prices tomorrow status', + : list([ + 'incoming', + 'available', + ]), + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_prices_tomorrow_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'incoming', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_gas_price_daily-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_gas_price_daily', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas price daily', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas price daily', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_gas_price_today', + 'unit_of_measurement': 'EUR/m³', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_gas_price_daily-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Gas price daily', + : , + : 'EUR/m³', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_gas_price_daily', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_highest_electricity_price_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_highest_electricity_price_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highest electricity price today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest electricity price today', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highest_electricity_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_highest_electricity_price_today', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_highest_electricity_price_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Highest electricity price today', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_highest_electricity_price_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3647561', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_highest_electricity_price_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_highest_electricity_price_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highest electricity price tomorrow', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest electricity price tomorrow', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highest_electricity_price_tomorrow', + 'unique_id': '00000000-0000-4000-8000-000000000001_highest_electricity_price_tomorrow', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_highest_electricity_price_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Highest electricity price tomorrow', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_highest_electricity_price_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_lowest_electricity_price_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lowest electricity price today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest electricity price today', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_electricity_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_lowest_electricity_price_today', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_lowest_electricity_price_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Lowest electricity price today', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1295896', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_lowest_electricity_price_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lowest electricity price tomorrow', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest electricity price tomorrow', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_electricity_price_tomorrow', + 'unique_id': '00000000-0000-4000-8000-000000000001_lowest_electricity_price_tomorrow', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_incoming][sensor.zonneplan_lowest_electricity_price_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Lowest electricity price tomorrow', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_current_electricity_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_current_electricity_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current electricity price', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current electricity price', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_electricity_price', + 'unique_id': '00000000-0000-4000-8000-000000000001_current_electricity_price', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_current_electricity_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Current electricity price', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_current_electricity_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1310052', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_today_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low today end time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low today end time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_today_end_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_today_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_today_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low today end time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-29T14:00:00+00:00', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_today_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low today start time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low today start time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_today_start_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_today_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_today_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low today start time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_today_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-29T08:00:00+00:00', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_tomorrow_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low tomorrow end time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low tomorrow end time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_tomorrow_end_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_tomorrow_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_tomorrow_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low tomorrow end time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-30T15:00:00+00:00', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_tomorrow_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity price low tomorrow start time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity price low tomorrow start time', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_price_low_tomorrow_start_time', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_price_low_tomorrow_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_price_low_tomorrow_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Zonneplan Electricity price low tomorrow start time', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_price_low_tomorrow_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-08-30T07:00:00+00:00', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_prices_tomorrow_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'incoming', + 'available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_electricity_prices_tomorrow_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity prices tomorrow status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity prices tomorrow status', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_prices_tomorrow_status', + 'unique_id': '00000000-0000-4000-8000-000000000001_electricity_prices_tomorrow_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_electricity_prices_tomorrow_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Zonneplan Electricity prices tomorrow status', + : list([ + 'incoming', + 'available', + ]), + }), + 'context': , + 'entity_id': 'sensor.zonneplan_electricity_prices_tomorrow_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_gas_price_daily-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_gas_price_daily', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gas price daily', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gas price daily', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_gas_price_today', + 'unit_of_measurement': 'EUR/m³', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_gas_price_daily-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Gas price daily', + : , + : 'EUR/m³', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_gas_price_daily', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.6029802', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_highest_electricity_price_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_highest_electricity_price_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highest electricity price today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest electricity price today', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highest_electricity_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_highest_electricity_price_today', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_highest_electricity_price_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Highest electricity price today', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_highest_electricity_price_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3581586', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_highest_electricity_price_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_highest_electricity_price_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highest electricity price tomorrow', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest electricity price tomorrow', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highest_electricity_price_tomorrow', + 'unique_id': '00000000-0000-4000-8000-000000000001_highest_electricity_price_tomorrow', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_highest_electricity_price_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Highest electricity price tomorrow', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_highest_electricity_price_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3647561', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_lowest_electricity_price_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lowest electricity price today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest electricity price today', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_electricity_price_today', + 'unique_id': '00000000-0000-4000-8000-000000000001_lowest_electricity_price_today', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_lowest_electricity_price_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Lowest electricity price today', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1289241', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_lowest_electricity_price_tomorrow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_tomorrow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lowest electricity price tomorrow', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest electricity price tomorrow', + 'platform': 'zonneplan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_electricity_price_tomorrow', + 'unique_id': '00000000-0000-4000-8000-000000000001_lowest_electricity_price_tomorrow', + 'unit_of_measurement': 'EUR/kWh', + }) +# --- +# name: test_sensor[prices_published][sensor.zonneplan_lowest_electricity_price_tomorrow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Zonneplan Lowest electricity price tomorrow', + : , + : 'EUR/kWh', + }), + 'context': , + 'entity_id': 'sensor.zonneplan_lowest_electricity_price_tomorrow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1295896', + }) +# --- diff --git a/tests/components/zonneplan/test_config_flow.py b/tests/components/zonneplan/test_config_flow.py new file mode 100644 index 000000000000..f0e3b3e73084 --- /dev/null +++ b/tests/components/zonneplan/test_config_flow.py @@ -0,0 +1,167 @@ +"""Test the Zonneplan config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pyzonneplan import ( + ZonneplanConnectionError, + ZonneplanInvalidOtpError, + ZonneplanTimeoutError, +) + +from homeassistant import config_entries +from homeassistant.components.zonneplan.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_ACCOUNT, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_full_flow(hass: HomeAssistant, mock_zonneplan_client: AsyncMock) -> None: + """Test the full OTP config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + mock_zonneplan_client.async_request_otp.assert_called_once() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_ACCOUNT.user_account.full_name + assert result["data"][CONF_EMAIL] == MOCK_ACCOUNT.user_account.email + assert result["data"][CONF_TOKEN] == mock_zonneplan_client.token.as_dict() + assert result["result"].unique_id == MOCK_ACCOUNT.user_account.uuid + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), + pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_user_exceptions( + hass: HomeAssistant, + mock_zonneplan_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all user step exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_zonneplan_client.async_request_otp.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": reason} + + mock_zonneplan_client.async_request_otp.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_ACCOUNT.user_account.full_name + assert result["data"][CONF_EMAIL] == MOCK_ACCOUNT.user_account.email + assert result["data"][CONF_TOKEN] == mock_zonneplan_client.token.as_dict() + assert result["result"].unique_id == MOCK_ACCOUNT.user_account.uuid + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), + pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param(ZonneplanInvalidOtpError("bad otp"), "invalid_auth"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_otp_exceptions( + hass: HomeAssistant, + mock_zonneplan_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all OTP step exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + assert result["step_id"] == "otp" + + mock_zonneplan_client.async_submit_otp.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {"base": reason} + + mock_zonneplan_client.async_submit_otp.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_ACCOUNT.user_account.full_name + assert result["data"][CONF_EMAIL] == MOCK_ACCOUNT.user_account.email + assert result["data"][CONF_TOKEN] == mock_zonneplan_client.token.as_dict() + assert result["result"].unique_id == MOCK_ACCOUNT.user_account.uuid + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting when the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/zonneplan/test_init.py b/tests/components/zonneplan/test_init.py new file mode 100644 index 000000000000..da4cbbfae721 --- /dev/null +++ b/tests/components/zonneplan/test_init.py @@ -0,0 +1,64 @@ +"""Test the Zonneplan integration setup.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest +from pyzonneplan import ( + Token, + ZonneplanAuthenticationError, + ZonneplanConnectionError, + ZonneplanTimeoutError, +) + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "exception", + [ + ZonneplanAuthenticationError("bad token"), + ZonneplanTimeoutError("timed out"), + ZonneplanConnectionError("boom"), + ], +) +async def test_setup_entry_update_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, + exception: Exception, +) -> None: + """Test errors while fetching data mark the entry for retry.""" + mock_zonneplan_client.async_get_account.side_effect = exception + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_persists_rotated_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, +) -> None: + """Test a rotated refresh token is persisted to the config entry.""" + rotated_token = Token( + access_token="rotated-access-token", + refresh_token="rotated-refresh-token", + expires_at=dt_util.utcnow() + timedelta(hours=1), + ) + mock_zonneplan_client.token = rotated_token + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_TOKEN] == rotated_token.as_dict() diff --git a/tests/components/zonneplan/test_sensor.py b/tests/components/zonneplan/test_sensor.py new file mode 100644 index 000000000000..a731fb9497c4 --- /dev/null +++ b/tests/components/zonneplan/test_sensor.py @@ -0,0 +1,93 @@ +"""Tests for the Zonneplan sensor platform.""" + +import dataclasses +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.zonneplan import Platform +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_ACCOUNT + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.parametrize( + "frozen_time", + [ + pytest.param("2026-08-29T08:30:00+00:00", id="prices_published"), + pytest.param("2026-08-30T00:30:00+00:00", id="prices_incoming"), + ], +) +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, + frozen_time: str, +) -> None: + """Test the sensor entities.""" + with patch( + "homeassistant.components.zonneplan.PLATFORMS", + [Platform.SENSOR], + ): + freezer.move_to(frozen_time) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("missing_market_segment", "entity_id"), + [ + pytest.param( + "electricity", + "sensor.zonneplan_current_electricity_price", + id="missing_electricity", + ), + pytest.param("gas", "sensor.zonneplan_gas_price_daily", id="missing_gas"), + ], +) +async def test_sensor_unknown_for_missing_market_segment( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, + missing_market_segment: str, + entity_id: str, +) -> None: + """Test a sensor is unknown when its market segment isn't on the account.""" + mock_zonneplan_client.async_get_account.return_value = dataclasses.replace( + MOCK_ACCOUNT, + address_groups=[ + dataclasses.replace( + address_group, + connections=[ + connection + for connection in address_group.connections + if connection.market_segment != missing_market_segment + ], + ) + for address_group in MOCK_ACCOUNT.address_groups + ], + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN