Add configurable EnergyZero electricity price interval (#181530)

This commit is contained in:
Klaas Schoute
2026-09-07 21:13:31 +02:00
committed by GitHub
parent c9cdb6b969
commit 0de08a70f1
10 changed files with 312 additions and 30 deletions
@@ -2,9 +2,23 @@
from typing import Any, override
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
import voluptuous as vol
from .const import DOMAIN
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import (
CONF_ELECTRICITY_PRICE_INTERVAL,
DEFAULT_ELECTRICITY_PRICE_INTERVAL,
DOMAIN,
ELECTRICITY_INTERVALS,
)
class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -12,6 +26,13 @@ class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
@staticmethod
@callback
@override
def async_get_options_flow(config_entry: ConfigEntry) -> EnergyZeroOptionsFlow:
"""Return the options flow."""
return EnergyZeroOptionsFlow()
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -28,3 +49,34 @@ class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN):
title="EnergyZero",
data={},
)
class EnergyZeroOptionsFlow(OptionsFlowWithReload):
"""Manage EnergyZero options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the electricity price interval."""
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(
CONF_ELECTRICITY_PRICE_INTERVAL,
default=DEFAULT_ELECTRICITY_PRICE_INTERVAL,
): SelectSelector(
SelectSelectorConfig(
options=list(ELECTRICITY_INTERVALS),
translation_key=CONF_ELECTRICITY_PRICE_INTERVAL,
)
),
}
),
self.config_entry.options,
),
)
@@ -4,6 +4,12 @@ from datetime import timedelta
import logging
from typing import Final
from energyzero import Interval
CONF_ELECTRICITY_PRICE_INTERVAL = "electricity_price_interval"
ELECTRICITY_INTERVALS = {"hourly": Interval.HOUR, "quarter_hourly": Interval.QUARTER}
DEFAULT_ELECTRICITY_PRICE_INTERVAL = "hourly"
DOMAIN: Final = "energyzero"
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(minutes=10)
@@ -9,7 +9,6 @@ from energyzero import (
EnergyZero,
EnergyZeroConnectionError,
EnergyZeroNoDataError,
Interval,
PriceType,
)
@@ -19,7 +18,15 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR
from .const import (
CONF_ELECTRICITY_PRICE_INTERVAL,
DEFAULT_ELECTRICITY_PRICE_INTERVAL,
DOMAIN,
ELECTRICITY_INTERVALS,
LOGGER,
SCAN_INTERVAL,
THRESHOLD_HOUR,
)
type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator]
@@ -30,6 +37,14 @@ class EnergyZeroData(NamedTuple):
energy_today: EnergyPrices
energy_tomorrow: EnergyPrices | None
gas_today: EnergyPrices | None
electricity_price_step: timedelta
@property
def next_energy_price(self) -> float | None:
"""Return the electricity price one market period from now."""
return self.energy_today.price_at_time(
self.energy_today.utcnow() + self.electricity_price_step
)
class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
@@ -47,6 +62,13 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
config_entry=entry,
)
interval = entry.options.get(
CONF_ELECTRICITY_PRICE_INTERVAL, DEFAULT_ELECTRICITY_PRICE_INTERVAL
)
self.electricity_interval = ELECTRICITY_INTERVALS[interval]
self.electricity_price_step = timedelta(
minutes=15 if interval == "quarter_hourly" else 60
)
self.energyzero = EnergyZero(session=async_get_clientsession(hass))
@override
@@ -61,7 +83,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
energy_today = await self.energyzero.get_electricity_prices(
start_date=today,
end_date=today,
interval=Interval.HOUR,
interval=self.electricity_interval,
price_type=PriceType.MARKET_WITH_VAT,
local_tz=local_tz,
)
@@ -81,7 +103,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
energy_tomorrow = await self.energyzero.get_electricity_prices(
start_date=tomorrow,
end_date=tomorrow,
interval=Interval.HOUR,
interval=self.electricity_interval,
price_type=PriceType.MARKET_WITH_VAT,
local_tz=local_tz,
)
@@ -95,4 +117,5 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]):
energy_today=energy_today,
energy_tomorrow=energy_tomorrow,
gas_today=gas_today,
electricity_price_step=self.electricity_price_step,
)
@@ -34,14 +34,9 @@ async def async_get_config_entry_diagnostics(
energy_today = coordinator_data.energy_today
return {
"entry": {
"title": entry.title,
},
"energy": {
"current_hour_price": energy_today.current_price,
"next_hour_price": energy_today.price_at_time(
energy_today.utcnow() + timedelta(hours=1)
),
"current_price": energy_today.current_price,
"next_price": coordinator_data.next_energy_price,
"average_price": energy_today.average_price,
"max_price": energy_today.extreme_prices[1],
"min_price": energy_today.extreme_prices[0],
@@ -67,9 +67,7 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = (
service_type="today_energy",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
suggested_display_precision=3,
value_fn=lambda data: data.energy_today.price_at_time(
data.energy_today.utcnow() + timedelta(hours=1)
),
value_fn=lambda data: data.next_energy_price,
),
EnergyZeroSensorEntityDescription(
key="average_price",
@@ -57,6 +57,23 @@
"message": "No price data available for {date}."
}
},
"options": {
"step": {
"init": {
"data": {
"electricity_price_interval": "Electricity price interval"
}
}
}
},
"selector": {
"electricity_price_interval": {
"options": {
"hourly": "Hourly",
"quarter_hourly": "Quarter-hourly"
}
}
},
"services": {
"get_energy_prices": {
"description": "Requests energy prices from EnergyZero.",
@@ -3,18 +3,15 @@
dict({
'energy': dict({
'average_price': 0.14609224895833334,
'current_hour_price': 0.17191075,
'current_price': 0.17191075,
'highest_price_time': '2026-04-10T18:00:00+00:00',
'hours_priced_equal_or_lower': 20,
'lowest_price_time': '2026-04-11T06:00:00+00:00',
'max_price': 0.288152425,
'min_price': 0.077503525,
'next_hour_price': 0.1521212,
'next_price': 0.1521212,
'percentage_of_max': 59.66,
}),
'entry': dict({
'title': 'energy',
}),
'gas': dict({
'current_hour_price': None,
'next_hour_price': None,
@@ -25,18 +22,15 @@
dict({
'energy': dict({
'average_price': 0.14609224895833334,
'current_hour_price': 0.17191075,
'current_price': 0.17191075,
'highest_price_time': '2026-04-10T18:00:00+00:00',
'hours_priced_equal_or_lower': 20,
'lowest_price_time': '2026-04-11T06:00:00+00:00',
'max_price': 0.288152425,
'min_price': 0.077503525,
'next_hour_price': 0.1521212,
'next_price': 0.1521212,
'percentage_of_max': 59.66,
}),
'entry': dict({
'title': 'energy',
}),
'gas': dict({
'current_hour_price': None,
'next_hour_price': None,
@@ -1,8 +1,14 @@
"""Test the EnergyZero config flow."""
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from homeassistant.components.energyzero.const import DOMAIN
import pytest
from homeassistant.components.energyzero.const import (
CONF_ELECTRICITY_PRICE_INTERVAL,
DOMAIN,
ELECTRICITY_INTERVALS,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -48,3 +54,67 @@ async def test_single_instance(
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "single_instance_allowed"
@pytest.mark.freeze_time("2026-04-10 20:32:59")
@pytest.mark.parametrize("initial", [None, "hourly", "quarter_hourly"])
@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"])
@pytest.mark.usefixtures("mock_energyzero")
async def test_options_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
initial: str | None,
selected: str,
) -> None:
"""Test defaults, saved options and automatic reload on changes."""
options = {} if initial is None else {CONF_ELECTRICITY_PRICE_INTERVAL: initial}
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(mock_config_entry, options=options)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
schema = result["data_schema"]
assert schema({}) == {CONF_ELECTRICITY_PRICE_INTERVAL: "hourly"}
key = next(iter(schema.schema))
assert (key.description or {}).get("suggested_value", "hourly") == (
initial or "hourly"
)
with patch.object(hass.config_entries, "async_reload", return_value=True) as reload:
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_ELECTRICITY_PRICE_INTERVAL: selected},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {CONF_ELECTRICITY_PRICE_INTERVAL: selected}
assert reload.call_count == (initial != selected)
@pytest.mark.freeze_time("2026-04-10 20:32:59")
@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"])
async def test_options_reload(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_energyzero: MagicMock,
selected: str,
) -> None:
"""Apply changed options to both requests without recreating entities."""
original_coordinator = init_integration.runtime_data
original_entities = set(hass.states.async_entity_ids("sensor"))
mock_energyzero.get_electricity_prices.reset_mock()
result = await hass.config_entries.options.async_init(init_integration.entry_id)
await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_ELECTRICITY_PRICE_INTERVAL: selected}
)
await hass.async_block_till_done()
assert init_integration.runtime_data is not original_coordinator
assert set(hass.states.async_entity_ids("sensor")) == original_entities
assert mock_energyzero.get_electricity_prices.await_count == 2
assert all(
request.kwargs["interval"] == ELECTRICITY_INTERVALS[selected]
for request in mock_energyzero.get_electricity_prices.await_args_list
)
+20 -2
View File
@@ -7,20 +7,38 @@ from zoneinfo import ZoneInfo
from energyzero import EnergyZeroConnectionError, Interval, PriceType
import pytest
from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("options", "interval"),
[
pytest.param({}, Interval.HOUR, id="existing"),
pytest.param(
{CONF_ELECTRICITY_PRICE_INTERVAL: "hourly"}, Interval.HOUR, id="hourly"
),
pytest.param(
{CONF_ELECTRICITY_PRICE_INTERVAL: "quarter_hourly"},
Interval.QUARTER,
id="quarter_hourly",
),
],
)
@pytest.mark.freeze_time("2026-04-10 20:32:59")
async def test_coordinator_requests_market_prices_with_vat(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_energyzero: MagicMock,
options: dict[str, str],
interval: Interval,
) -> None:
"""Test the coordinator requests the backwards-compatible price stream."""
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(mock_config_entry, options=options)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@@ -32,14 +50,14 @@ async def test_coordinator_requests_market_prices_with_vat(
call(
start_date=today,
end_date=today,
interval=Interval.HOUR,
interval=interval,
price_type=PriceType.MARKET_WITH_VAT,
local_tz=local_tz,
),
call(
start_date=tomorrow,
end_date=tomorrow,
interval=Interval.HOUR,
interval=interval,
price_type=PriceType.MARKET_WITH_VAT,
local_tz=local_tz,
),
@@ -0,0 +1,109 @@
"""Test electricity resolution with timezone-aware price data."""
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from zoneinfo import ZoneInfo
from energyzero import EnergyPrices, EnergyZeroNoDataError, Interval
from energyzero.models import TimeRange
import pytest
from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize(
("selected", "minutes", "interval"),
[("hourly", 60, Interval.HOUR), ("quarter_hourly", 15, Interval.QUARTER)],
)
@pytest.mark.parametrize("missing_tomorrow", [False, True])
@pytest.mark.parametrize(
("hours", "requests_tomorrow"),
[
pytest.param(
24, True, marks=pytest.mark.freeze_time("2026-04-10 20:32:59"), id="normal"
),
pytest.param(
23, False, marks=pytest.mark.freeze_time("2026-03-29 00:55:00"), id="spring"
),
pytest.param(
25, False, marks=pytest.mark.freeze_time("2026-10-25 00:55:00"), id="autumn"
),
],
)
async def test_electricity_interval(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_energyzero: MagicMock,
entity_registry: er.EntityRegistry,
selected: str,
minutes: int,
interval: Interval,
missing_tomorrow: bool,
hours: int,
requests_tomorrow: bool,
) -> None:
"""Keep all periods on DST days and use the selected next-price step."""
await hass.config.async_set_time_zone("Europe/Amsterdam")
today = dt_util.now().date()
start = datetime.combine(
today, datetime.min.time(), ZoneInfo("Europe/Amsterdam")
).astimezone(UTC)
step = timedelta(minutes=minutes)
prices = EnergyPrices(
prices={
TimeRange(start + index * step, start + (index + 1) * step): float(
index + 1
)
for index in range(hours * 60 // minutes)
},
average_price=(hours * 60 // minutes + 1) / 2,
)
mock_energyzero.get_electricity_prices.side_effect = [
prices,
EnergyZeroNoDataError() if missing_tomorrow else prices,
]
mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: selected}
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
expected = prices.price_at_time(dt_util.utcnow() + step)
assert expected != prices.current_price
assert (state := hass.states.get("sensor.energyzero_today_energy_next_hour_price"))
assert state.state == str(expected)
data = mock_config_entry.runtime_data.data
assert (data.energy_tomorrow is not None) == (
requests_tomorrow and not missing_tomorrow
)
assert len(data.energy_today.prices) == hours * 60 // minutes
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert diagnostics["energy"]["next_price"] == expected
assert diagnostics["energy"]["current_price"] == prices.current_price
assert diagnostics["energy"]["average_price"] == prices.average_price
assert (
diagnostics["energy"]["hours_priced_equal_or_lower"]
== prices.time_ranges_priced_equal_or_lower
)
assert (
mock_energyzero.get_electricity_prices.call_args.kwargs["interval"] == interval
)
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entries) == 11
assert all(
entry.unique_id == f"12345_{entry.entity_id.removeprefix('sensor.energyzero_')}"
for entry in entries
)