mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Add Zonneplan integration (#180722)
This commit is contained in:
@@ -674,4 +674,5 @@ homeassistant.components.zeroconf.*
|
||||
homeassistant.components.zinvolt.*
|
||||
homeassistant.components.zodiac.*
|
||||
homeassistant.components.zone.*
|
||||
homeassistant.components.zonneplan.*
|
||||
homeassistant.components.zwave_js.*
|
||||
|
||||
Generated
+2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Zonneplan integration."""
|
||||
|
||||
DOMAIN = "zonneplan"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -926,6 +926,7 @@ FLOWS = {
|
||||
"zimi",
|
||||
"zinvolt",
|
||||
"zodiac",
|
||||
"zonneplan",
|
||||
"zwave_js",
|
||||
"zwave_me",
|
||||
],
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Zonneplan integration."""
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user