diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index f7f5a338c05a..bd191bbff908 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -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( diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr index 9dda7e607c23..b0b3351425aa 100644 --- a/tests/components/energyzero/snapshots/test_services.ambr +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -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', }), ]), diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index 9e094e024eff..bf99e96d9d3a 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -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"], )