Add Zonneplan integration (#180722)

This commit is contained in:
Erwin Douna
2026-08-31 20:16:56 +02:00
committed by GitHub
parent 1bdf472ac8
commit 5373e31355
25 changed files with 3481 additions and 0 deletions
+1
View File
@@ -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
View File
@@ -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"
}
}
}
+1
View File
@@ -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": [
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Tests for the Zonneplan integration."""
+89
View File
@@ -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"
+64
View File
@@ -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()
+93
View File
@@ -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