From 4abc131cf92b4e7056ce62b2da35bbdb2bbd6711 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sun, 13 Sep 2026 14:07:50 +0200 Subject: [PATCH] Add price type and interval options to EnergyZero energy price action (#181683) --- .../components/energyzero/services.py | 20 +- .../components/energyzero/services.yaml | 16 + .../components/energyzero/strings.json | 20 ++ tests/components/energyzero/test_services.py | 308 +++++++++++++++++- 4 files changed, 357 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index bd191bbff908..ef1b90519e11 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -27,6 +27,10 @@ ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" ATTR_END: Final = "end" ATTR_INCL_VAT: Final = "incl_vat" +ATTR_PRICE_TYPE: Final = "price_type" +ATTR_INTERVAL: Final = "interval" + +ENERGY_INTERVALS = {"hour": Interval.HOUR, "quarter": Interval.QUARTER} GAS_SERVICE_NAME: Final = "get_gas_prices" ENERGY_SERVICE_NAME: Final = "get_energy_prices" @@ -43,6 +47,13 @@ SERVICE_SCHEMA: Final = vol.Schema( } ) +ENERGY_SERVICE_SCHEMA: Final = SERVICE_SCHEMA.extend( + { + vol.Optional(ATTR_PRICE_TYPE, default="market"): vol.In(("market", "all_in")), + vol.Optional(ATTR_INTERVAL, default="hour"): vol.In(ENERGY_INTERVALS), + } +) + class ServicePriceType(Enum): """Type of service.""" @@ -142,6 +153,11 @@ async def __get_prices( PriceType.MARKET_WITH_VAT if call.data[ATTR_INCL_VAT] else PriceType.MARKET ) + if price_type is ServicePriceType.ENERGY and call.data[ATTR_PRICE_TYPE] == "all_in": + selected_price_type = ( + PriceType.ALL_IN if call.data[ATTR_INCL_VAT] else PriceType.ALL_IN_EXCL_VAT + ) + price_data: list[EnergyPrices] = [] for day_offset in range((end_date - start_date).days + 1): request_date = start_date + timedelta(days=day_offset) @@ -156,7 +172,7 @@ async def __get_prices( prices = coordinator.energyzero.get_electricity_prices( start_date=request_date, end_date=request_date, - interval=Interval.HOUR, + interval=ENERGY_INTERVALS[call.data[ATTR_INTERVAL]], price_type=selected_price_type, local_tz=local_tz, ) @@ -190,6 +206,6 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, ENERGY_SERVICE_NAME, partial(__get_prices, price_type=ServicePriceType.ENERGY), - schema=SERVICE_SCHEMA, + schema=ENERGY_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml index dc8df9aa6d0c..0c57f0de8bf9 100644 --- a/homeassistant/components/energyzero/services.yaml +++ b/homeassistant/components/energyzero/services.yaml @@ -32,6 +32,22 @@ get_energy_prices: default: true selector: boolean: + price_type: + default: market + selector: + select: + translation_key: price_type + options: + - market + - all_in + interval: + default: hour + selector: + select: + translation_key: interval + options: + - hour + - quarter start: required: false example: "2023-01-01 00:00:00" diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 14047ff85780..47b7bf23735b 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -87,6 +87,18 @@ "hourly": "Hourly", "quarter_hourly": "Quarter-hourly" } + }, + "interval": { + "options": { + "hour": "[%key:component::energyzero::selector::electricity_price_interval::options::hourly%]", + "quarter": "[%key:component::energyzero::selector::electricity_price_interval::options::quarter_hourly%]" + } + }, + "price_type": { + "options": { + "all_in": "All-in", + "market": "Market" + } } }, "services": { @@ -105,6 +117,14 @@ "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]", "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]" }, + "interval": { + "description": "The interval of electricity prices to retrieve. Defaults to hourly, independently of the electricity price interval configured for entities.", + "name": "Interval" + }, + "price_type": { + "description": "The type of electricity prices to retrieve. Defaults to market prices.", + "name": "Price type" + }, "start": { "description": "[%key:component::energyzero::services::get_gas_prices::fields::start::description%]", "name": "[%key:component::energyzero::services::get_gas_prices::fields::start::name%]" diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index bf99e96d9d3a..04656d011bc2 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -1,16 +1,25 @@ """Tests for the services provided by the EnergyZero integration.""" -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta import re -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call from zoneinfo import ZoneInfo -from energyzero import EnergyPrices, EnergyZeroNoDataError, PriceType, TimeRange +from energyzero import ( + EnergyPrices, + EnergyZeroNoDataError, + Interval, + PriceType, + TimeRange, +) import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.components.energyzero.const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + DOMAIN, +) from homeassistant.components.energyzero.services import ( ATTR_CONFIG_ENTRY, ENERGY_SERVICE_NAME, @@ -514,12 +523,20 @@ async def test_service_called_with_unloaded_entry( @pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize( + ("service", "service_data"), + [ + (GAS_SERVICE_NAME, {}), + (ENERGY_SERVICE_NAME, {}), + (ENERGY_SERVICE_NAME, {"price_type": "all_in", "interval": "quarter"}), + ], +) async def test_service_no_data_returns_validation_error( hass: HomeAssistant, mock_energyzero: AsyncMock, mock_config_entry: MockConfigEntry, service: str, + service_data: dict[str, str], ) -> None: """Test backend no-data errors are surfaced as service validation errors.""" method = ( @@ -541,7 +558,288 @@ async def test_service_no_data_returns_validation_error( { ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, "incl_vat": True, + **service_data, }, blocking=True, return_response=True, ) + + +@pytest.mark.parametrize("entity_interval", ["hourly", "quarter_hourly"]) +@pytest.mark.parametrize( + ("interval_data", "expected_interval"), + [ + pytest.param({}, Interval.HOUR, id="default-hour"), + pytest.param({"interval": "hour"}, Interval.HOUR, id="hour"), + pytest.param({"interval": "quarter"}, Interval.QUARTER, id="quarter"), + ], +) +@pytest.mark.parametrize( + ("price_data", "incl_vat", "expected_price_type"), + [ + pytest.param({}, True, PriceType.MARKET_WITH_VAT, id="default-vat"), + pytest.param({}, False, PriceType.MARKET, id="default-no-vat"), + pytest.param( + {"price_type": "market"}, True, PriceType.MARKET_WITH_VAT, id="market-vat" + ), + pytest.param( + {"price_type": "market"}, False, PriceType.MARKET, id="market-no-vat" + ), + pytest.param({"price_type": "all_in"}, True, PriceType.ALL_IN, id="all-in-vat"), + pytest.param( + {"price_type": "all_in"}, + False, + PriceType.ALL_IN_EXCL_VAT, + id="all-in-no-vat", + ), + ], +) +async def test_energy_service_options( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_interval: str, + interval_data: dict[str, str], + expected_interval: Interval, + price_data: dict[str, str], + incl_vat: bool, + expected_price_type: PriceType, +) -> None: + """Action options and defaults are independent of entity configuration.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: entity_interval} + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + coordinator = mock_config_entry.runtime_data + coordinator_data = coordinator.data + entity_states = hass.states.async_all() + mock_energyzero.reset_mock() + + await hass.services.async_call( + DOMAIN, + ENERGY_SERVICE_NAME, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": incl_vat, + **price_data, + **interval_data, + }, + blocking=True, + return_response=True, + ) + + mock_energyzero.get_electricity_prices.assert_awaited_once_with( + start_date=date(2026, 4, 10), + end_date=date(2026, 4, 10), + interval=expected_interval, + price_type=expected_price_type, + local_tz=ZoneInfo(hass.config.time_zone), + ) + mock_energyzero.get_gas_prices.assert_not_awaited() + assert coordinator.data is coordinator_data + assert hass.states.async_all() == entity_states + assert mock_config_entry.options == { + CONF_ELECTRICITY_PRICE_INTERVAL: entity_interval + } + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("start", "end", "first_timestamp", "period_count"), + [ + pytest.param( + "2026-04-10", "2026-04-10", "2026-04-09T22:00:00+00:00", 96, id="date-only" + ), + pytest.param( + "2026-04-10 00:07:00", + "2026-04-10 00:38:00", + "2026-04-09T22:00:00+00:00", + 3, + id="partial-periods", + ), + pytest.param( + "2026-04-10 00:15:00", + "2026-04-10 00:15:00", + "2026-04-09T22:00:00+00:00", + 96, + id="equal-times-full-day", + ), + pytest.param( + "2026-04-10 00:15:00", + "2026-04-10 00:30:00", + "2026-04-09T22:15:00+00:00", + 1, + id="exact-boundaries", + ), + pytest.param( + "2026-04-10 23:53:00+02:00", + "2026-04-11 00:07:00+02:00", + "2026-04-10T21:45:00+00:00", + 2, + id="multiple-days", + ), + pytest.param( + "2026-03-29", "2026-03-29", "2026-03-28T23:00:00+00:00", 92, id="spring-dst" + ), + pytest.param( + "2026-10-25", + "2026-10-25", + "2026-10-24T22:00:00+00:00", + 100, + id="autumn-dst", + ), + pytest.param( + "2026-03-29 01:53:00+01:00", + "2026-03-29 03:07:00+02:00", + "2026-03-29T00:45:00+00:00", + 2, + id="spring-overlap", + ), + pytest.param( + "2026-10-25 02:53:00+02:00", + "2026-10-25 02:07:00+01:00", + "2026-10-25T00:45:00+00:00", + 2, + id="autumn-overlap", + ), + ], +) +async def test_energy_service_quarter_ranges( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, + start: str, + end: str, + first_timestamp: str, + period_count: int, +) -> None: + """Filter actual quarter-hour ranges, including partial periods and DST.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + local_tz = ZoneInfo(hass.config.time_zone) + first_day = date.fromisoformat(start[:10]) + last_day = date.fromisoformat(end[:10]) + days = [ + first_day + timedelta(days=index) + for index in range((last_day - first_day).days + 1) + ] + step = timedelta(minutes=15) + datasets = [] + for day in days: + day_start = datetime.combine(day, datetime.min.time(), local_tz).astimezone(UTC) + day_end = datetime.combine( + day + timedelta(days=1), datetime.min.time(), local_tz + ).astimezone(UTC) + datasets.append( + EnergyPrices( + prices={ + TimeRange( + day_start + index * step, day_start + (index + 1) * step + ): 0.25 + for index in range((day_end - day_start) // step) + }, + average_price=0.25, + ) + ) + mock_energyzero.reset_mock() + mock_energyzero.get_electricity_prices.side_effect = datasets + + response = await hass.services.async_call( + DOMAIN, + ENERGY_SERVICE_NAME, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": True, + "price_type": "all_in", + "interval": "quarter", + "start": start, + "end": end, + }, + blocking=True, + return_response=True, + ) + + first = datetime.fromisoformat(first_timestamp) + assert response == { + "prices": [ + { + "price": 0.25, + "timestamp": str(first + index * step), + "start": str(first + index * step), + "end": str(first + (index + 1) * step), + } + for index in range(period_count) + ] + } + assert mock_energyzero.get_electricity_prices.await_args_list == [ + call( + start_date=day, + end_date=day, + interval=Interval.QUARTER, + price_type=PriceType.ALL_IN, + local_tz=local_tz, + ) + for day in days + ] + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "service_data"), + [ + (ENERGY_SERVICE_NAME, {"price_type": "market_with_vat"}), + (ENERGY_SERVICE_NAME, {"interval": "day"}), + (GAS_SERVICE_NAME, {"price_type": "all_in"}), + (GAS_SERVICE_NAME, {"interval": "quarter"}), + ], +) +async def test_service_rejects_unsupported_options( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + service_data: dict[str, str], +) -> None: + """Only the electricity action accepts the supported new field values.""" + mock_energyzero.reset_mock() + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + service, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": True, + **service_data, + }, + blocking=True, + return_response=True, + ) + mock_energyzero.get_electricity_prices.assert_not_awaited() + mock_energyzero.get_gas_prices.assert_not_awaited() + + +@pytest.mark.usefixtures("init_integration") +async def test_energy_service_quarter_invalid_range( + hass: HomeAssistant, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Reject reversed quarter-hour ranges before calling the API.""" + mock_energyzero.reset_mock() + with pytest.raises(ServiceValidationError, match="Invalid date range provided"): + await hass.services.async_call( + DOMAIN, + ENERGY_SERVICE_NAME, + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + "incl_vat": True, + "price_type": "all_in", + "interval": "quarter", + "start": "2026-04-10 00:15:00", + "end": "2026-04-10 00:10:00", + }, + blocking=True, + return_response=True, + ) + mock_energyzero.get_electricity_prices.assert_not_awaited()