diff --git a/homeassistant/components/energyzero/config_flow.py b/homeassistant/components/energyzero/config_flow.py index 437ecf74ac3c..0dd4368de969 100644 --- a/homeassistant/components/energyzero/config_flow.py +++ b/homeassistant/components/energyzero/config_flow.py @@ -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, + ), + ) diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index 84c114d6779c..5d8018ad3707 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -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) diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 783469c293fd..3110aa0c770d 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -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, ) diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index f45092dea437..0d57b8c81adf 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -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], diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 1d65a4340656..a9068378d93b 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -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", diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 8232cc62e01d..6ba6faf273f4 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -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.", diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 26d3533d84ca..60f79ec56351 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -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, diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index 09884ff4cf65..c8ed98b5082b 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -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 + ) diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index 03b23c634701..e97263cd4e6a 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -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, ), diff --git a/tests/components/energyzero/test_interval.py b/tests/components/energyzero/test_interval.py new file mode 100644 index 000000000000..a4f22e129482 --- /dev/null +++ b/tests/components/energyzero/test_interval.py @@ -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 + )