Expose full price validity range in EnergyZero service response (#181851)

This commit is contained in:
Klaas Schoute
2026-09-11 12:56:32 +02:00
committed by GitHub
parent 3151ca888f
commit d907b6aea7
3 changed files with 270 additions and 8 deletions
@@ -86,6 +86,8 @@ def __serialize_prices(
{
"price": price,
"timestamp": str(time_range.start_including),
"start": str(time_range.start_including),
"end": str(time_range.end_excluding),
}
for price_data in prices
for time_range, price in price_data.prices.items()
@@ -111,12 +113,20 @@ async def __get_prices(
local_tz = ZoneInfo(call.hass.config.time_zone)
start_date, start_datetime = __get_date(call.data.get(ATTR_START), local_tz)
end_date, end_datetime = __get_date(call.data.get(ATTR_END), local_tz)
filter_start = start_datetime or dt_util.as_utc(
dt_util.start_of_local_day(start_date)
)
filter_end = end_datetime or dt_util.as_utc(
dt_util.start_of_local_day(end_date + timedelta(days=1))
)
end_start = end_datetime or dt_util.as_utc(dt_util.start_of_local_day(end_date))
if filter_start == end_start:
day = filter_start.astimezone(local_tz).date()
filter_start = dt_util.as_utc(dt_util.start_of_local_day(day))
filter_end = dt_util.as_utc(dt_util.start_of_local_day(day + timedelta(days=1)))
else:
filter_end = end_datetime or dt_util.as_utc(
dt_util.start_of_local_day(end_date + timedelta(days=1))
)
if filter_end <= filter_start:
raise ServiceValidationError(
@@ -3,99 +3,147 @@
dict({
'prices': list([
dict({
'end': '2026-04-10 08:00:00+00:00',
'price': 0.158361775,
'start': '2026-04-10 07:00:00+00:00',
'timestamp': '2026-04-10 07:00:00+00:00',
}),
dict({
'end': '2026-04-10 09:00:00+00:00',
'price': 0.15199415,
'start': '2026-04-10 08:00:00+00:00',
'timestamp': '2026-04-10 08:00:00+00:00',
}),
dict({
'end': '2026-04-10 10:00:00+00:00',
'price': 0.129712,
'start': '2026-04-10 09:00:00+00:00',
'timestamp': '2026-04-10 09:00:00+00:00',
}),
dict({
'end': '2026-04-10 11:00:00+00:00',
'price': 0.12542255,
'start': '2026-04-10 10:00:00+00:00',
'timestamp': '2026-04-10 10:00:00+00:00',
}),
dict({
'end': '2026-04-10 12:00:00+00:00',
'price': 0.1105819,
'start': '2026-04-10 11:00:00+00:00',
'timestamp': '2026-04-10 11:00:00+00:00',
}),
dict({
'end': '2026-04-10 13:00:00+00:00',
'price': 0.11546425,
'start': '2026-04-10 12:00:00+00:00',
'timestamp': '2026-04-10 12:00:00+00:00',
}),
dict({
'end': '2026-04-10 14:00:00+00:00',
'price': 0.10231155,
'start': '2026-04-10 13:00:00+00:00',
'timestamp': '2026-04-10 13:00:00+00:00',
}),
dict({
'end': '2026-04-10 15:00:00+00:00',
'price': 0.122527625,
'start': '2026-04-10 14:00:00+00:00',
'timestamp': '2026-04-10 14:00:00+00:00',
}),
dict({
'end': '2026-04-10 16:00:00+00:00',
'price': 0.146068175,
'start': '2026-04-10 15:00:00+00:00',
'timestamp': '2026-04-10 15:00:00+00:00',
}),
dict({
'end': '2026-04-10 17:00:00+00:00',
'price': 0.181984,
'start': '2026-04-10 16:00:00+00:00',
'timestamp': '2026-04-10 16:00:00+00:00',
}),
dict({
'end': '2026-04-10 18:00:00+00:00',
'price': 0.272253025,
'start': '2026-04-10 17:00:00+00:00',
'timestamp': '2026-04-10 17:00:00+00:00',
}),
dict({
'end': '2026-04-10 19:00:00+00:00',
'price': 0.288152425,
'start': '2026-04-10 18:00:00+00:00',
'timestamp': '2026-04-10 18:00:00+00:00',
}),
dict({
'end': '2026-04-10 20:00:00+00:00',
'price': 0.2064623,
'start': '2026-04-10 19:00:00+00:00',
'timestamp': '2026-04-10 19:00:00+00:00',
}),
dict({
'end': '2026-04-10 21:00:00+00:00',
'price': 0.17191075,
'start': '2026-04-10 20:00:00+00:00',
'timestamp': '2026-04-10 20:00:00+00:00',
}),
dict({
'end': '2026-04-10 22:00:00+00:00',
'price': 0.1521212,
'start': '2026-04-10 21:00:00+00:00',
'timestamp': '2026-04-10 21:00:00+00:00',
}),
dict({
'end': '2026-04-10 23:00:00+00:00',
'price': 0.15236925,
'start': '2026-04-10 22:00:00+00:00',
'timestamp': '2026-04-10 22:00:00+00:00',
}),
dict({
'end': '2026-04-11 00:00:00+00:00',
'price': 0.135583525,
'start': '2026-04-10 23:00:00+00:00',
'timestamp': '2026-04-10 23:00:00+00:00',
}),
dict({
'end': '2026-04-11 01:00:00+00:00',
'price': 0.12652365,
'start': '2026-04-11 00:00:00+00:00',
'timestamp': '2026-04-11 00:00:00+00:00',
}),
dict({
'end': '2026-04-11 02:00:00+00:00',
'price': 0.122267475,
'start': '2026-04-11 01:00:00+00:00',
'timestamp': '2026-04-11 01:00:00+00:00',
}),
dict({
'end': '2026-04-11 03:00:00+00:00',
'price': 0.120640025,
'start': '2026-04-11 02:00:00+00:00',
'timestamp': '2026-04-11 02:00:00+00:00',
}),
dict({
'end': '2026-04-11 04:00:00+00:00',
'price': 0.117239925,
'start': '2026-04-11 03:00:00+00:00',
'timestamp': '2026-04-11 03:00:00+00:00',
}),
dict({
'end': '2026-04-11 05:00:00+00:00',
'price': 0.116616775,
'start': '2026-04-11 04:00:00+00:00',
'timestamp': '2026-04-11 04:00:00+00:00',
}),
dict({
'end': '2026-04-11 06:00:00+00:00',
'price': 0.10214215,
'start': '2026-04-11 05:00:00+00:00',
'timestamp': '2026-04-11 05:00:00+00:00',
}),
dict({
'end': '2026-04-11 07:00:00+00:00',
'price': 0.077503525,
'start': '2026-04-11 06:00:00+00:00',
'timestamp': '2026-04-11 06:00:00+00:00',
}),
]),
@@ -105,7 +153,9 @@
dict({
'prices': list([
dict({
'end': '2026-04-12 04:00:00+00:00',
'price': 0.430304930424,
'start': '2026-04-11 04:00:00+00:00',
'timestamp': '2026-04-11 04:00:00+00:00',
}),
]),
+207 -5
View File
@@ -1,11 +1,11 @@
"""Tests for the services provided by the EnergyZero integration."""
from datetime import date
from datetime import UTC, date, datetime
import re
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
from energyzero import EnergyZeroNoDataError, PriceType
from energyzero import EnergyPrices, EnergyZeroNoDataError, PriceType, TimeRange
import pytest
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
@@ -139,15 +139,32 @@ async def test_service_dates_normalized_to_hass_timezone(
{
"price": 0.45193447944,
"timestamp": "2026-04-10 04:00:00+00:00",
"start": "2026-04-10 04:00:00+00:00",
"end": "2026-04-11 04:00:00+00:00",
}
],
),
(
ENERGY_SERVICE_NAME,
[
{"price": 0.12572, "timestamp": "2026-04-10 21:00:00+00:00"},
{"price": 0.125925, "timestamp": "2026-04-10 22:00:00+00:00"},
{"price": 0.1120525, "timestamp": "2026-04-10 23:00:00+00:00"},
{
"price": 0.12572,
"timestamp": "2026-04-10 21:00:00+00:00",
"start": "2026-04-10 21:00:00+00:00",
"end": "2026-04-10 22:00:00+00:00",
},
{
"price": 0.125925,
"timestamp": "2026-04-10 22:00:00+00:00",
"start": "2026-04-10 22:00:00+00:00",
"end": "2026-04-10 23:00:00+00:00",
},
{
"price": 0.1120525,
"timestamp": "2026-04-10 23:00:00+00:00",
"start": "2026-04-10 23:00:00+00:00",
"end": "2026-04-11 00:00:00+00:00",
},
],
),
],
@@ -177,6 +194,8 @@ async def test_service_filters_datetime_range(
)
assert response == {"prices": expected_prices}
assert response["prices"]
assert all(item["timestamp"] == item["start"] for item in response["prices"])
method = (
mock_energyzero.get_gas_prices
@@ -193,6 +212,178 @@ async def test_service_filters_datetime_range(
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"equal_time", ["00:00:00", "14:30:00"], ids=["midnight", "non-midnight"]
)
@pytest.mark.parametrize(
("service", "expected_count", "expected_first", "expected_last"),
[
(
GAS_SERVICE_NAME,
1,
{
"price": 0.430304930424,
"timestamp": "2026-04-11 04:00:00+00:00",
"start": "2026-04-11 04:00:00+00:00",
"end": "2026-04-12 04:00:00+00:00",
},
{
"price": 0.430304930424,
"timestamp": "2026-04-11 04:00:00+00:00",
"start": "2026-04-11 04:00:00+00:00",
"end": "2026-04-12 04:00:00+00:00",
},
),
(
ENERGY_SERVICE_NAME,
24,
{
"price": 0.125925,
"timestamp": "2026-04-10 22:00:00+00:00",
"start": "2026-04-10 22:00:00+00:00",
"end": "2026-04-10 23:00:00+00:00",
},
{
"price": 0.082385,
"timestamp": "2026-04-11 21:00:00+00:00",
"start": "2026-04-11 21:00:00+00:00",
"end": "2026-04-11 22:00:00+00:00",
},
),
],
)
async def test_service_equal_start_end_selects_full_local_day(
hass: HomeAssistant,
mock_energyzero: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
equal_time: str,
expected_count: int,
expected_first: dict[str, str | float],
expected_last: dict[str, str | float],
) -> None:
"""Equal start/end datetimes select the full local calendar day."""
await hass.config.async_set_time_zone("Europe/Amsterdam")
mock_energyzero.reset_mock()
equal_datetime = f"2026-04-11 {equal_time}"
response = await hass.services.async_call(
DOMAIN,
service,
{
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
"incl_vat": False,
"start": equal_datetime,
"end": equal_datetime,
},
blocking=True,
return_response=True,
)
assert len(response["prices"]) == expected_count
assert response["prices"][0] == expected_first
assert response["prices"][-1] == expected_last
assert all(item["timestamp"] == item["start"] for item in response["prices"])
method = (
mock_energyzero.get_gas_prices
if service == GAS_SERVICE_NAME
else mock_energyzero.get_electricity_prices
)
assert len(method.await_args_list) == 1
assert method.await_args.kwargs["start_date"] == date(2026, 4, 11)
assert method.await_args.kwargs["end_date"] == date(2026, 4, 11)
@pytest.mark.usefixtures("init_integration")
async def test_service_equal_start_end_full_day_respects_dst(
hass: HomeAssistant,
mock_energyzero: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Equal start/end selects a local calendar day, not a fixed 24h window."""
await hass.config.async_set_time_zone("Europe/Amsterdam")
in_range = TimeRange(
datetime(2026, 3, 28, 23, 0, tzinfo=UTC),
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
)
out_of_range = TimeRange(
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
datetime(2026, 3, 29, 23, 0, tzinfo=UTC),
)
mock_energyzero.get_electricity_prices.side_effect = None
mock_energyzero.get_electricity_prices.return_value = EnergyPrices(
prices={in_range: 0.1, out_of_range: 0.2}, average_price=None
)
response = await hass.services.async_call(
DOMAIN,
ENERGY_SERVICE_NAME,
{
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
"incl_vat": False,
"start": "2026-03-29 00:00:00",
"end": "2026-03-29 00:00:00",
},
blocking=True,
return_response=True,
)
assert response == {
"prices": [
{
"price": 0.1,
"timestamp": "2026-03-28 23:00:00+00:00",
"start": "2026-03-28 23:00:00+00:00",
"end": "2026-03-29 22:00:00+00:00",
}
]
}
assert mock_energyzero.get_electricity_prices.await_args.kwargs[
"start_date"
] == date(2026, 3, 29)
@pytest.mark.usefixtures("init_integration")
async def test_service_date_and_equal_datetime_selects_full_local_day(
hass: HomeAssistant,
mock_energyzero: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A date-only start and a same-day midnight datetime end are equal."""
await hass.config.async_set_time_zone("Europe/Amsterdam")
mock_energyzero.reset_mock()
response = await hass.services.async_call(
DOMAIN,
GAS_SERVICE_NAME,
{
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
"incl_vat": False,
"start": "2026-04-11",
"end": "2026-04-11 00:00:00",
},
blocking=True,
return_response=True,
)
assert response == {
"prices": [
{
"price": 0.430304930424,
"timestamp": "2026-04-11 04:00:00+00:00",
"start": "2026-04-11 04:00:00+00:00",
"end": "2026-04-12 04:00:00+00:00",
}
]
}
assert mock_energyzero.get_gas_prices.await_args.kwargs["start_date"] == date(
2026, 4, 11
)
@pytest.fixture
def config_entry_data(
mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest
@@ -262,6 +453,17 @@ def config_entry_data(
ServiceValidationError,
"Invalid date range provided. End 2023-01-01 must be after start 2023-01-02",
),
(
{"config_entry": True},
{
"incl_vat": True,
"start": "2026-04-11 12:00:00",
"end": "2026-04-11 08:00:00",
},
ServiceValidationError,
"Invalid date range provided. End 2026-04-11 08:00:00 must be "
"after start 2026-04-11 12:00:00",
),
],
indirect=["config_entry_data"],
)