Add price type and interval options to EnergyZero energy price action (#181683)

This commit is contained in:
Klaas Schoute
2026-09-13 14:07:50 +02:00
committed by GitHub
parent e333e978d4
commit 4abc131cf9
4 changed files with 357 additions and 7 deletions
@@ -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,
)
@@ -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"
@@ -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%]"
+303 -5
View File
@@ -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()