diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index 5d8018ad3707..2c6ca533107b 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -16,6 +16,6 @@ SCAN_INTERVAL = timedelta(minutes=10) THRESHOLD_HOUR: Final = 14 SERVICE_TYPE_DEVICE_NAMES = { - "today_energy": "Energy market price", + "today_energy": "Electricity price", "today_gas": "Gas market price", } diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 3110aa0c770d..66f376746dc9 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -1,6 +1,6 @@ """The Coordinator for EnergyZero.""" -from datetime import timedelta +from datetime import date, timedelta from typing import NamedTuple, override from zoneinfo import ZoneInfo @@ -34,17 +34,22 @@ type EnergyZeroConfigEntry = ConfigEntry[EnergyZeroDataUpdateCoordinator] class EnergyZeroData(NamedTuple): """Class for defining data in dict.""" - energy_today: EnergyPrices - energy_tomorrow: EnergyPrices | None + electricity_market_today: EnergyPrices + electricity_market_tomorrow: EnergyPrices | None + electricity_all_in_today: EnergyPrices + electricity_all_in_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 - ) + def next_price( + self, prices: EnergyPrices, tomorrow: EnergyPrices | None + ) -> float | None: + """Return the next period's price, including across midnight.""" + moment = prices.utcnow() + self.electricity_price_step + price = prices.price_at_time(moment) + if price is not None: + return price + return tomorrow.price_at_time(moment) if tomorrow is not None else None class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): @@ -71,21 +76,29 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): ) self.energyzero = EnergyZero(session=async_get_clientsession(hass)) + async def _async_get_electricity_prices( + self, day: date, local_tz: ZoneInfo + ) -> dict[PriceType, EnergyPrices]: + """Fetch both electricity price streams in a single request.""" + return await self.energyzero.get_electricity_prices( + start_date=day, + end_date=day, + interval=self.electricity_interval, + price_type=(PriceType.MARKET_WITH_VAT, PriceType.ALL_IN), + local_tz=local_tz, + ) + @override async def _async_update_data(self) -> EnergyZeroData: """Fetch data from EnergyZero.""" today = dt_util.now().date() gas_today = None - energy_tomorrow = None + electricity_tomorrow: dict[PriceType, EnergyPrices] = {} local_tz = ZoneInfo(self.hass.config.time_zone) try: - energy_today = await self.energyzero.get_electricity_prices( - start_date=today, - end_date=today, - interval=self.electricity_interval, - price_type=PriceType.MARKET_WITH_VAT, - local_tz=local_tz, + electricity_today = await self._async_get_electricity_prices( + today, local_tz ) try: gas_today = await self.energyzero.get_gas_prices( @@ -98,24 +111,22 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): LOGGER.debug("No data for gas prices for EnergyZero integration") # Energy for tomorrow only after 14:00 UTC if dt_util.utcnow().hour >= THRESHOLD_HOUR: - tomorrow = today + timedelta(days=1) try: - energy_tomorrow = await self.energyzero.get_electricity_prices( - start_date=tomorrow, - end_date=tomorrow, - interval=self.electricity_interval, - price_type=PriceType.MARKET_WITH_VAT, - local_tz=local_tz, + electricity_tomorrow = await self._async_get_electricity_prices( + today + timedelta(days=1), local_tz ) except EnergyZeroNoDataError: - LOGGER.debug("No data for tomorrow for EnergyZero integration") - + LOGGER.debug("No electricity prices for tomorrow") except EnergyZeroConnectionError as err: raise UpdateFailed("Error communicating with EnergyZero API") from err return EnergyZeroData( - energy_today=energy_today, - energy_tomorrow=energy_tomorrow, + electricity_market_today=electricity_today[PriceType.MARKET_WITH_VAT], + electricity_market_tomorrow=electricity_tomorrow.get( + PriceType.MARKET_WITH_VAT + ), + electricity_all_in_today=electricity_today[PriceType.ALL_IN], + electricity_all_in_tomorrow=electricity_tomorrow.get(PriceType.ALL_IN), 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 0d57b8c81adf..f2173b5b3838 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -31,19 +31,35 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator_data = entry.runtime_data.data - energy_today = coordinator_data.energy_today + energy_today = coordinator_data.electricity_market_today + all_in_today = coordinator_data.electricity_all_in_today return { - "energy": { + "electricity_market": { "current_price": energy_today.current_price, - "next_price": coordinator_data.next_energy_price, + "next_price": coordinator_data.next_price( + energy_today, coordinator_data.electricity_market_tomorrow + ), "average_price": energy_today.average_price, "max_price": energy_today.extreme_prices[1], "min_price": energy_today.extreme_prices[0], "highest_price_time": energy_today.highest_price_time_range.start_including, "lowest_price_time": energy_today.lowest_price_time_range.start_including, "percentage_of_max": energy_today.pct_of_max_price, - "hours_priced_equal_or_lower": energy_today.time_ranges_priced_equal_or_lower, + "periods_priced_equal_or_lower": energy_today.time_ranges_priced_equal_or_lower, + }, + "electricity_all_in": { + "current_price": all_in_today.current_price, + "next_price": coordinator_data.next_price( + all_in_today, coordinator_data.electricity_all_in_tomorrow + ), + "average_price": all_in_today.average_price, + "max_price": all_in_today.extreme_prices[1], + "min_price": all_in_today.extreme_prices[0], + "highest_price_time": all_in_today.highest_price_time_range.start_including, + "lowest_price_time": all_in_today.lowest_price_time_range.start_including, + "percentage_of_max": all_in_today.pct_of_max_price, + "periods_priced_equal_or_lower": all_in_today.time_ranges_priced_equal_or_lower, }, "gas": { "current_hour_price": get_gas_price(coordinator_data, 0), diff --git a/homeassistant/components/energyzero/icons.json b/homeassistant/components/energyzero/icons.json index 3c90f7ea5c96..abe530541eaf 100644 --- a/homeassistant/components/energyzero/icons.json +++ b/homeassistant/components/energyzero/icons.json @@ -1,9 +1,39 @@ { "entity": { "sensor": { + "all_in_average_price": { + "default": "mdi:cash-multiple" + }, + "all_in_current_price": { + "default": "mdi:cash" + }, + "all_in_max_price": { + "default": "mdi:cash-plus" + }, + "all_in_min_price": { + "default": "mdi:cash-minus" + }, + "all_in_next_price": { + "default": "mdi:cash" + }, + "average_price": { + "default": "mdi:cash-multiple" + }, + "current_price": { + "default": "mdi:cash" + }, "hours_priced_equal_or_lower": { "default": "mdi:clock" }, + "max_price": { + "default": "mdi:cash-plus" + }, + "min_price": { + "default": "mdi:cash-minus" + }, + "next_price": { + "default": "mdi:cash" + }, "percentage_of_max": { "default": "mdi:percent" } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index a9068378d93b..76800ed15334 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -59,7 +59,7 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", suggested_display_precision=3, - value_fn=lambda data: data.energy_today.current_price, + value_fn=lambda data: data.electricity_market_today.current_price, ), EnergyZeroSensorEntityDescription( key="next_hour_price", @@ -67,7 +67,9 @@ 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.next_energy_price, + value_fn=lambda data: data.next_price( + data.electricity_market_today, data.electricity_market_tomorrow + ), ), EnergyZeroSensorEntityDescription( key="average_price", @@ -75,7 +77,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.average_price, + value_fn=lambda data: data.electricity_market_today.average_price, ), EnergyZeroSensorEntityDescription( key="max_price", @@ -83,7 +85,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.extreme_prices[1], + value_fn=lambda data: data.electricity_market_today.extreme_prices[1], ), EnergyZeroSensorEntityDescription( key="min_price", @@ -91,7 +93,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.extreme_prices[0], + value_fn=lambda data: data.electricity_market_today.extreme_prices[0], ), EnergyZeroSensorEntityDescription( key="highest_price_time", @@ -99,7 +101,7 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: ( - data.energy_today.highest_price_time_range.start_including + data.electricity_market_today.highest_price_time_range.start_including ), ), EnergyZeroSensorEntityDescription( @@ -107,20 +109,67 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( translation_key="lowest_price_time", service_type="today_energy", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.energy_today.lowest_price_time_range.start_including, + value_fn=lambda data: ( + data.electricity_market_today.lowest_price_time_range.start_including + ), ), EnergyZeroSensorEntityDescription( key="percentage_of_max", translation_key="percentage_of_max", service_type="today_energy", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: data.energy_today.pct_of_max_price, + value_fn=lambda data: data.electricity_market_today.pct_of_max_price, ), EnergyZeroSensorEntityDescription( key="hours_priced_equal_or_lower", translation_key="hours_priced_equal_or_lower", service_type="today_energy", - value_fn=lambda data: data.energy_today.time_ranges_priced_equal_or_lower, + value_fn=lambda data: ( + data.electricity_market_today.time_ranges_priced_equal_or_lower + ), + ), + EnergyZeroSensorEntityDescription( + key="all_in_current_price", + translation_key="all_in_current_price", + service_type="today_energy", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=3, + value_fn=lambda data: data.electricity_all_in_today.current_price, + ), + EnergyZeroSensorEntityDescription( + key="all_in_next_price", + translation_key="all_in_next_price", + service_type="today_energy", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=3, + value_fn=lambda data: data.next_price( + data.electricity_all_in_today, data.electricity_all_in_tomorrow + ), + ), + EnergyZeroSensorEntityDescription( + key="all_in_average_price", + translation_key="all_in_average_price", + service_type="today_energy", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=3, + value_fn=lambda data: data.electricity_all_in_today.average_price, + ), + EnergyZeroSensorEntityDescription( + key="all_in_max_price", + translation_key="all_in_max_price", + service_type="today_energy", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=3, + value_fn=lambda data: data.electricity_all_in_today.extreme_prices[1], + ), + EnergyZeroSensorEntityDescription( + key="all_in_min_price", + translation_key="all_in_min_price", + service_type="today_energy", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + suggested_display_precision=3, + value_fn=lambda data: data.electricity_all_in_today.extreme_prices[0], ), ) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 6ba6faf273f4..14047ff85780 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -11,38 +11,53 @@ }, "entity": { "sensor": { + "all_in_average_price": { + "name": "Average all-in price" + }, + "all_in_current_price": { + "name": "Current all-in price" + }, + "all_in_max_price": { + "name": "Maximum all-in price" + }, + "all_in_min_price": { + "name": "Minimum all-in price" + }, + "all_in_next_price": { + "name": "Next all-in price" + }, "average_price": { - "name": "Average - today" + "name": "Average market price" }, "current_hour_price": { "name": "Current hour" }, "current_price": { - "name": "Current price" + "name": "Current market price" }, "highest_price_time": { - "name": "Time of highest price - today" + "name": "Highest market price time" }, "hours_priced_equal_or_lower": { - "name": "Periods priced equal or lower" + "name": "Market periods priced equal or lower" }, "lowest_price_time": { - "name": "Time of lowest price - today" + "name": "Lowest market price time" }, "max_price": { - "name": "Highest price - today" + "name": "Maximum market price" }, "min_price": { - "name": "Lowest price - today" + "name": "Minimum market price" }, "next_hour_price": { "name": "Next hour" }, "next_price": { - "name": "Next price" + "name": "Next market price" }, "percentage_of_max": { - "name": "Current percentage of highest price - today" + "name": "Market percentage of maximum" } } }, diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index 4bd0d90e1895..696d92ddb90e 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -1,17 +1,17 @@ """Fixtures for EnergyZero integration tests.""" from collections.abc import AsyncGenerator, Generator -from datetime import UTC, datetime +from datetime import UTC, date, datetime, tzinfo +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from zoneinfo import ZoneInfo -from energyzero import EnergyPrices, PriceType +from energyzero import EnergyPrices, Interval, PriceType from energyzero.models import REST_PRICE_STREAMS import pytest from homeassistant.components.energyzero.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -49,24 +49,38 @@ async def mock_energyzero(hass: HomeAssistant) -> AsyncGenerator[MagicMock]: ) gas_data = await async_load_json_object_fixture(hass, "today_gas.json", DOMAIN) - def _get_prices(data: dict, *args, **kwargs) -> EnergyPrices: - price_type = kwargs.get("price_type", args[0] if args else PriceType.ALL_IN) - filter_date = kwargs.get("start_date", dt_util.now().date()) - local_tz = kwargs.get("local_tz", ZoneInfo(hass.config.time_zone)) - stream = REST_PRICE_STREAMS[price_type] - filtered_data = { - **data, - stream: [ - item - for item in data[stream] - if datetime.strptime(item["start"], "%Y-%m-%dT%H:%M:%SZ") - .replace(tzinfo=UTC) - .astimezone(local_tz) - .date() - == filter_date - ], - } - return EnergyPrices.from_rest_dict(filtered_data, price_type) + def _get_prices( + data: dict[str, Any], + *, + start_date: date, + end_date: date | None = None, + interval: Interval = Interval.QUARTER, + price_type: PriceType | tuple[PriceType, ...] = PriceType.ALL_IN, + local_tz: tzinfo | None = None, + ) -> EnergyPrices | dict[PriceType, EnergyPrices]: + local_tz = local_tz or ZoneInfo(hass.config.time_zone) + requested_types = ( + (price_type,) if isinstance(price_type, PriceType) else price_type + ) + results = {} + for requested_type in requested_types: + stream = REST_PRICE_STREAMS[requested_type] + filtered_data = { + **data, + stream: [ + item + for item in data[stream] + if datetime.strptime(item["start"], "%Y-%m-%dT%H:%M:%SZ") + .replace(tzinfo=UTC) + .astimezone(local_tz) + .date() + == start_date + ], + } + results[requested_type] = EnergyPrices.from_rest_dict( + filtered_data, requested_type + ) + return results[price_type] if isinstance(price_type, PriceType) else results client.get_electricity_prices.side_effect = lambda *a, **kw: _get_prices( energy_data, *a, **kw diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 60f79ec56351..8fa202b589bd 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -1,16 +1,27 @@ # serializer version: 1 # name: test_diagnostics_no_gas_today dict({ - 'energy': dict({ + 'electricity_all_in': dict({ + 'average_price': 0.25694034895833334, + 'current_price': 0.28275885, + 'highest_price_time': '2026-04-10T18:00:00+00:00', + 'lowest_price_time': '2026-04-11T06:00:00+00:00', + 'max_price': 0.399000525, + 'min_price': 0.188351625, + 'next_price': 0.2629693, + 'percentage_of_max': 70.87, + 'periods_priced_equal_or_lower': 20, + }), + 'electricity_market': dict({ 'average_price': 0.14609224895833334, '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_price': 0.1521212, 'percentage_of_max': 59.66, + 'periods_priced_equal_or_lower': 20, }), 'gas': dict({ 'current_hour_price': None, @@ -20,16 +31,27 @@ # --- # name: test_entry_diagnostics dict({ - 'energy': dict({ + 'electricity_all_in': dict({ + 'average_price': 0.25694034895833334, + 'current_price': 0.28275885, + 'highest_price_time': '2026-04-10T18:00:00+00:00', + 'lowest_price_time': '2026-04-11T06:00:00+00:00', + 'max_price': 0.399000525, + 'min_price': 0.188351625, + 'next_price': 0.2629693, + 'percentage_of_max': 70.87, + 'periods_priced_equal_or_lower': 20, + }), + 'electricity_market': dict({ 'average_price': 0.14609224895833334, '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_price': 0.1521212, 'percentage_of_max': 59.66, + 'periods_priced_equal_or_lower': 20, }), 'gas': dict({ 'current_hour_price': None, diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 9b5389ed5437..792ec9c41061 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,4 +1,282 @@ # serializer version: 1 +# name: test_sensor[sensor.energyzero_today_energy_all_in_average_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_all_in_average_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average all-in price', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_all_in_average_price', + 'supported_features': 0, + 'translation_key': 'all_in_average_price', + 'unique_id': '12345_today_energy_all_in_average_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_average_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by EnergyZero', + : 'Electricity price Average all-in price', + : '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_all_in_average_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.252829121875', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_current_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_all_in_current_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current all-in price', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_all_in_current_price', + 'supported_features': 0, + 'translation_key': 'all_in_current_price', + 'unique_id': '12345_today_energy_all_in_current_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_current_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by EnergyZero', + : 'Electricity price Current all-in price', + : , + : '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_all_in_current_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.28275885', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_max_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_all_in_max_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum all-in price', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_all_in_max_price', + 'supported_features': 0, + 'translation_key': 'all_in_max_price', + 'unique_id': '12345_today_energy_all_in_max_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_max_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by EnergyZero', + : 'Electricity price Maximum all-in price', + : '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_all_in_max_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.399000525', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_min_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_all_in_min_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum all-in price', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_all_in_min_price', + 'supported_features': 0, + 'translation_key': 'all_in_min_price', + 'unique_id': '12345_today_energy_all_in_min_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_min_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by EnergyZero', + : 'Electricity price Minimum all-in price', + : '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_all_in_min_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.19798625', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_next_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_all_in_next_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next all-in price', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'suggested_object_id': 'energyzero_today_energy_all_in_next_price', + 'supported_features': 0, + 'translation_key': 'all_in_next_price', + 'unique_id': '12345_today_energy_all_in_next_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_all_in_next_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by EnergyZero', + : 'Electricity price Next all-in price', + : '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_all_in_next_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2629693', + }) +# --- # name: test_sensor[sensor.energyzero_today_energy_average_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -29,7 +307,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Average - today', + 'original_name': 'Average market price', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_average_price', @@ -43,7 +321,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Average - today', + : 'Electricity price Average market price', : '€/kWh', }), 'context': , @@ -86,7 +364,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Current price', + 'original_name': 'Current market price', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_current_hour_price', @@ -100,7 +378,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Current price', + : 'Electricity price Current market price', : , : '€/kWh', }), @@ -139,7 +417,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Time of highest price - today', + 'original_name': 'Highest market price time', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_highest_price_time', @@ -154,7 +432,7 @@ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', : 'timestamp', - : 'Energy market price Time of highest price - today', + : 'Electricity price Highest market price time', }), 'context': , 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', @@ -191,7 +469,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Periods priced equal or lower', + 'original_name': 'Market periods priced equal or lower', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_hours_priced_equal_or_lower', @@ -205,7 +483,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Periods priced equal or lower', + : 'Electricity price Market periods priced equal or lower', }), 'context': , 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', @@ -242,7 +520,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Time of lowest price - today', + 'original_name': 'Lowest market price time', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_lowest_price_time', @@ -257,7 +535,7 @@ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', : 'timestamp', - : 'Energy market price Time of lowest price - today', + : 'Electricity price Lowest market price time', }), 'context': , 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', @@ -297,7 +575,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Highest price - today', + 'original_name': 'Maximum market price', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_max_price', @@ -311,7 +589,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Highest price - today', + : 'Electricity price Maximum market price', : '€/kWh', }), 'context': , @@ -352,7 +630,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Lowest price - today', + 'original_name': 'Minimum market price', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_min_price', @@ -366,7 +644,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Lowest price - today', + : 'Electricity price Minimum market price', : '€/kWh', }), 'context': , @@ -407,7 +685,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Next price', + 'original_name': 'Next market price', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_next_hour_price', @@ -421,7 +699,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Next price', + : 'Electricity price Next market price', : '€/kWh', }), 'context': , @@ -459,7 +737,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Current percentage of highest price - today', + 'original_name': 'Market percentage of maximum', 'platform': 'energyzero', 'previous_unique_id': None, 'suggested_object_id': 'energyzero_today_energy_percentage_of_max', @@ -473,7 +751,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by EnergyZero', - : 'Energy market price Current percentage of highest price - today', + : 'Electricity price Market percentage of maximum', : '%', }), 'context': , diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index c8ed98b5082b..174ea076fe4e 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -103,7 +103,6 @@ async def test_options_reload( 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) @@ -111,7 +110,6 @@ async def test_options_reload( 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( diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index 44df0bc3a0cd..6f76ecf5a2f6 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -49,7 +49,7 @@ async def test_diagnostics_no_gas_today( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( await get_diagnostics_for_config_entry(hass, hass_client, init_integration) diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index e97263cd4e6a..6181f3363368 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -5,13 +5,23 @@ from unittest.mock import MagicMock, call, patch from zoneinfo import ZoneInfo from energyzero import EnergyZeroConnectionError, Interval, PriceType +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL +from homeassistant.components.energyzero.const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + DOMAIN, + ELECTRICITY_INTERVALS, + SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, +) @pytest.mark.parametrize( @@ -29,14 +39,14 @@ from tests.common import MockConfigEntry ], ) @pytest.mark.freeze_time("2026-04-10 20:32:59") -async def test_coordinator_requests_market_prices_with_vat( +async def test_coordinator_requests_both_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.""" + """Test both VAT-inclusive streams share a request and the configured interval.""" 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) @@ -51,18 +61,19 @@ async def test_coordinator_requests_market_prices_with_vat( start_date=today, end_date=today, interval=interval, - price_type=PriceType.MARKET_WITH_VAT, + price_type=(PriceType.MARKET_WITH_VAT, PriceType.ALL_IN), local_tz=local_tz, ), call( start_date=tomorrow, end_date=tomorrow, interval=interval, - price_type=PriceType.MARKET_WITH_VAT, + price_type=(PriceType.MARKET_WITH_VAT, PriceType.ALL_IN), local_tz=local_tz, ), ] ) + assert mock_energyzero.get_electricity_prices.await_count == 2 mock_energyzero.get_gas_prices.assert_awaited_once_with( start_date=today, end_date=today, @@ -101,3 +112,131 @@ async def test_config_flow_entry_not_ready( assert mock_request.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.freeze_time("2026-04-10 20:32:59") +@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"]) +@pytest.mark.parametrize( + "missing_streams", + [ + pytest.param(("base_with_vat",), id="market"), + pytest.param(("all_in_with_vat",), id="all_in"), + pytest.param(("base_with_vat", "all_in_with_vat"), id="both"), + pytest.param( + ("base", "base_with_vat", "all_in", "all_in_with_vat"), + id="unpublished", + ), + ], +) +async def test_missing_tomorrow_prices_do_not_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + selected: str, + missing_streams: tuple[str, ...], + freezer: FrozenDateTimeFactory, +) -> None: + """Missing tomorrow streams do not cause extra requests or fail today's sensors.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + electricity = await async_load_json_object_fixture( + hass, "today_energy.json", DOMAIN + ) + gas = await async_load_json_object_fixture(hass, "today_gas.json", DOMAIN) + tomorrow = {**electricity, **{stream: [] for stream in missing_streams}} + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: selected} + ) + with patch( + "energyzero.api.rest.RESTClient._request", + side_effect=[electricity, gas, tomorrow] * 2, + ) as request: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert request.await_count == 3 + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert request.await_count == 6 + assert ( + request.await_args_list[2] + == request.await_args_list[5] + == call( + "public/v1/prices", + params={ + "energyType": "ENERGY_TYPE_ELECTRICITY", + "date": "11-04-2026", + "interval": ELECTRICITY_INTERVALS[selected].value, + }, + ) + ) + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + state := hass.states.get("sensor.energyzero_today_energy_current_hour_price") + ) + assert state.state == "0.17191075" + assert ( + state := hass.states.get("sensor.energyzero_today_energy_all_in_current_price") + ) + assert state.state == "0.28275885" + + +@pytest.mark.freeze_time("2026-04-10 20:32:59") +@pytest.mark.parametrize("selected", ["hourly", "quarter_hourly"]) +async def test_rest_requests_share_price_streams( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + selected: str, +) -> None: + """The actual library extracts both streams with one REST request per day.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + electricity = await async_load_json_object_fixture( + hass, "today_energy.json", DOMAIN + ) + gas = await async_load_json_object_fixture(hass, "today_gas.json", DOMAIN) + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: selected} + ) + with patch( + "energyzero.api.rest.RESTClient._request", + side_effect=[electricity, gas, electricity], + ) as request: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert request.await_args_list == [ + call( + "public/v1/prices", + params={ + "energyType": "ENERGY_TYPE_ELECTRICITY", + "date": "10-04-2026", + "interval": ELECTRICITY_INTERVALS[selected].value, + }, + ), + call( + "public/v1/prices", + params={ + "energyType": "ENERGY_TYPE_GAS", + "date": "10-04-2026", + "interval": "INTERVAL_DAY", + }, + ), + call( + "public/v1/prices", + params={ + "energyType": "ENERGY_TYPE_ELECTRICITY", + "date": "11-04-2026", + "interval": ELECTRICITY_INTERVALS[selected].value, + }, + ), + ] + assert ( + state := hass.states.get("sensor.energyzero_today_energy_current_hour_price") + ) + assert state.state == "0.17191075" + assert ( + state := hass.states.get("sensor.energyzero_today_energy_all_in_current_price") + ) + assert state.state == "0.28275885" diff --git a/tests/components/energyzero/test_interval.py b/tests/components/energyzero/test_interval.py index a4f22e129482..e2bf226aa407 100644 --- a/tests/components/energyzero/test_interval.py +++ b/tests/components/energyzero/test_interval.py @@ -4,16 +4,18 @@ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock from zoneinfo import ZoneInfo -from energyzero import EnergyPrices, EnergyZeroNoDataError, Interval +from energyzero import EnergyPrices, EnergyZeroNoDataError, Interval, PriceType from energyzero.models import TimeRange +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.energyzero.const import CONF_ELECTRICITY_PRICE_INTERVAL +from homeassistant.const import STATE_UNKNOWN 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.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -66,9 +68,17 @@ async def test_electricity_interval( }, average_price=(hours * 60 // minutes + 1) / 2, ) + all_in_prices = EnergyPrices( + prices={period: price + 100 for period, price in prices.prices.items()}, + average_price=prices.average_price + 100, + ) + electricity = { + PriceType.MARKET_WITH_VAT: prices, + PriceType.ALL_IN: all_in_prices, + } mock_energyzero.get_electricity_prices.side_effect = [ - prices, - EnergyZeroNoDataError() if missing_tomorrow else prices, + electricity, + EnergyZeroNoDataError() if missing_tomorrow else electricity, ] mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( @@ -81,29 +91,161 @@ async def test_electricity_interval( 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 ( + all_in_state := hass.states.get( + "sensor.energyzero_today_energy_all_in_next_price" + ) ) - assert len(data.energy_today.prices) == hours * 60 // minutes + assert all_in_state.state == str(expected + 100) + for suffix, market_value, all_in_value in ( + ("current_hour_price", prices.current_price, all_in_prices.current_price), + ("average_price", prices.average_price, all_in_prices.average_price), + ("min_price", prices.extreme_prices[0], all_in_prices.extreme_prices[0]), + ("max_price", prices.extreme_prices[1], all_in_prices.extreme_prices[1]), + ): + assert (state := hass.states.get(f"sensor.energyzero_today_energy_{suffix}")) + assert state.state == str(market_value) + all_in_suffix = suffix.replace("current_hour", "current") + assert ( + state := hass.states.get( + f"sensor.energyzero_today_energy_all_in_{all_in_suffix}" + ) + ) + assert state.state == str(all_in_value) 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["electricity_market"]["next_price"] == expected + assert diagnostics["electricity_market"]["current_price"] == prices.current_price + assert diagnostics["electricity_market"]["average_price"] == prices.average_price assert ( - diagnostics["energy"]["hours_priced_equal_or_lower"] + diagnostics["electricity_market"]["periods_priced_equal_or_lower"] == prices.time_ranges_priced_equal_or_lower ) + assert diagnostics["electricity_all_in"]["next_price"] == expected + 100 assert ( - mock_energyzero.get_electricity_prices.call_args.kwargs["interval"] == interval + diagnostics["electricity_all_in"]["current_price"] + == all_in_prices.current_price ) + assert ( + diagnostics["electricity_all_in"]["average_price"] + == all_in_prices.average_price + ) + assert ( + diagnostics["electricity_all_in"]["min_price"] + == all_in_prices.extreme_prices[0] + ) + assert ( + diagnostics["electricity_all_in"]["max_price"] + == all_in_prices.extreme_prices[1] + ) + assert all( + request.kwargs["interval"] == interval + for request in mock_energyzero.get_electricity_prices.await_args_list + ) + assert mock_energyzero.get_electricity_prices.await_count == 1 + requests_tomorrow entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entries) == 11 + assert len(entries) == 16 assert all( entry.unique_id == f"12345_{entry.entity_id.removeprefix('sensor.energyzero_')}" for entry in entries ) + + +@pytest.mark.freeze_time("2026-04-10 21:42:00") +@pytest.mark.parametrize( + ("selected", "minutes"), [("hourly", 60), ("quarter_hourly", 15)] +) +@pytest.mark.parametrize("missing_tomorrow", [False, True]) +async def test_next_price_across_midnight( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_energyzero: MagicMock, + freezer: FrozenDateTimeFactory, + selected: str, + minutes: int, + missing_tomorrow: bool, +) -> None: + """Use tomorrow prices until the first scheduled refresh after midnight.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + start = dt_util.start_of_local_day().astimezone(UTC) + step = timedelta(minutes=minutes) + today_prices = EnergyPrices( + prices={TimeRange(start, start + timedelta(days=1)): -0.1}, + average_price=-0.1, + ) + tomorrow_start = start + timedelta(days=1) + tomorrow_prices = EnergyPrices( + prices={ + TimeRange( + tomorrow_start + index * step, tomorrow_start + (index + 1) * step + ): index / 100 + for index in range(24 * 60 // minutes) + }, + average_price=0.1, + ) + all_in_tomorrow = EnergyPrices( + prices={ + period: price + 0.11 for period, price in tomorrow_prices.prices.items() + }, + average_price=0.21, + ) + today = {PriceType.MARKET_WITH_VAT: today_prices, PriceType.ALL_IN: today_prices} + tomorrow = { + PriceType.MARKET_WITH_VAT: tomorrow_prices, + PriceType.ALL_IN: all_in_tomorrow, + } + tomorrow_result = EnergyZeroNoDataError() if missing_tomorrow else tomorrow + mock_energyzero.get_electricity_prices.side_effect = [ + today, + tomorrow_result, + today, + tomorrow_result, + tomorrow, + EnergyZeroNoDataError(), + ] + 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() + + freezer.move_to("2026-04-10 21:52:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_energyzero.get_electricity_prices.await_count == 4 + for entity_id, value in ( + ("sensor.energyzero_today_energy_next_hour_price", 0.0), + ("sensor.energyzero_today_energy_all_in_next_price", 0.11), + ): + assert (state := hass.states.get(entity_id)) + assert state.state == (STATE_UNKNOWN if missing_tomorrow else str(value)) + + freezer.move_to("2026-04-10 22:00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics["electricity_market"]["next_price"] == ( + None if missing_tomorrow else 0.01 + ) + assert diagnostics["electricity_all_in"]["next_price"] == ( + None if missing_tomorrow else 0.12 + ) + assert mock_energyzero.get_electricity_prices.await_count == 4 + + freezer.move_to("2026-04-10 22:02:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for entity_id, value in ( + ("sensor.energyzero_today_energy_next_hour_price", 0.01), + ("sensor.energyzero_today_energy_all_in_next_price", 0.12), + ): + assert (state := hass.states.get(entity_id)) + assert state.state == str(value) + assert mock_energyzero.get_electricity_prices.await_count == 6 diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 7222870aa06c..cd3f5fc9708d 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -8,7 +8,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.energyzero.const import SCAN_INTERVAL +from homeassistant.components.energyzero.const import ( + CONF_ELECTRICITY_PRICE_INTERVAL, + SCAN_INTERVAL, +) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -95,7 +98,62 @@ async def test_no_data( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert (state := hass.states.get(entity_id)) assert state.state == expected_state + + +@pytest.mark.usefixtures("mock_energyzero") +@pytest.mark.parametrize("disabled_by", [None, er.RegistryEntryDisabler.USER]) +async def test_existing_market_registry_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + disabled_by: er.RegistryEntryDisabler | None, +) -> None: + """Keep existing identities and user customizations through setup and reload.""" + mock_config_entry.add_to_hass(hass) + entries = [ + entity_registry.async_get_or_create( + "sensor", + "energyzero", + f"12345_today_energy_{key}", + suggested_object_id=f"custom_{key}", + config_entry=mock_config_entry, + disabled_by=disabled_by, + ) + for key in ( + "current_hour_price", + "next_hour_price", + "average_price", + "min_price", + "max_price", + "highest_price_time", + "lowest_price_time", + "percentage_of_max", + "hours_priced_equal_or_lower", + ) + ] + entries = [ + entity_registry.async_update_entity( + entry.entity_id, name=f"Custom {entry.entity_id}" + ) + for entry in entries + ] + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: "quarter_hourly"} + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for original in entries: + assert (entry := entity_registry.async_get(original.entity_id)) + assert entry.id == original.id + assert entry.entity_id == original.entity_id + assert entry.unique_id == original.unique_id + assert entry.name == original.name + assert entry.disabled_by == original.disabled_by + assert len(er.async_entries_for_config_entry(entity_registry, "12345")) == 16