mirror of
https://github.com/home-assistant/core.git
synced 2026-09-16 13:38:26 +01:00
Add price type and interval options to EnergyZero energy price action (#181683)
This commit is contained in:
@@ -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%]"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user