mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add configurable EnergyZero electricity price interval (#181530)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user