diff --git a/homeassistant/components/green_planet_energy/__init__.py b/homeassistant/components/green_planet_energy/__init__.py index 182407c23cdd..d3763862148d 100644 --- a/homeassistant/components/green_planet_energy/__init__.py +++ b/homeassistant/components/green_planet_energy/__init__.py @@ -1,24 +1,14 @@ """Green Planet Energy integration for Home Assistant.""" -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import GreenPlanetEnergyUpdateCoordinator +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,105 +16,10 @@ type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinat PLATFORMS: list[Platform] = [Platform.SENSOR] -# Service constants -SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration" -ATTR_DURATION = "duration" -ATTR_TIME_RANGE = "time_range" - -# Time range options -TIME_RANGE_DAY = "day" -TIME_RANGE_NIGHT = "night" -TIME_RANGE_FULL_DAY = "full_day" - -SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DURATION): vol.All( - vol.Coerce(float), vol.Range(min=0.5, max=24) - ), - vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In( - [TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY] - ), - } -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Green Planet Energy component.""" - - async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse: - """Handle the get_cheapest_duration service call.""" - # This integration has single_config_entry, so get the first entry - entries = hass.config_entries.async_entries(DOMAIN) - - if not entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_config_entry", - ) - - entry = entries[0] - - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_loaded", - ) - - coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data - - duration = call.data[ATTR_DURATION] - time_range = call.data[ATTR_TIME_RANGE] - data = coordinator.data - api = coordinator.api - now = dt_util.now() - current_hour = now.hour - - result: tuple[float | None, int | None] - - if time_range == TIME_RANGE_DAY: - result = api.get_cheapest_duration_day(data, duration, current_hour) - elif time_range == TIME_RANGE_NIGHT: - result = api.get_cheapest_duration_night(data, duration, current_hour) - else: # TIME_RANGE_FULL_DAY - result = api.get_cheapest_duration(data, duration, current_hour) - - avg_price, start_hour_result = result - - if avg_price is None or start_hour_result is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_data_available", - ) - - start_time = dt_util.start_of_local_day(now).replace( - hour=start_hour_result, minute=0, second=0, microsecond=0 - ) - - # If the calculated start time is in the past, shift to tomorrow - if start_time < now: - start_time = start_time + timedelta(days=1) - - end_time = start_time + timedelta(hours=duration) - - hours_until_start = (start_time - now).total_seconds() / 3600 - - return { - "duration": duration, - "average_price": round(avg_price / 100, 4), - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "hours_until_start": round(hours_until_start, 1), - "time_range": time_range, - } - - hass.services.async_register( - DOMAIN, - SERVICE_GET_CHEAPEST_DURATION, - get_cheapest_duration, - schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - + async_setup_services(hass) return True diff --git a/homeassistant/components/green_planet_energy/icons.json b/homeassistant/components/green_planet_energy/icons.json index a3b326305416..4778d583320a 100644 --- a/homeassistant/components/green_planet_energy/icons.json +++ b/homeassistant/components/green_planet_energy/icons.json @@ -2,6 +2,9 @@ "services": { "get_cheapest_duration": { "service": "mdi:clock-check" + }, + "get_prices": { + "service": "mdi:lightning-bolt-circle" } } } diff --git a/homeassistant/components/green_planet_energy/quality_scale.yaml b/homeassistant/components/green_planet_energy/quality_scale.yaml index bb38b1831bda..09f9f6a76112 100644 --- a/homeassistant/components/green_planet_energy/quality_scale.yaml +++ b/homeassistant/components/green_planet_energy/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: The integration registers no actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: The integration registers no actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -32,9 +28,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: The integration registers no actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/green_planet_energy/services.py b/homeassistant/components/green_planet_energy/services.py new file mode 100644 index 000000000000..25f43f1cbb73 --- /dev/null +++ b/homeassistant/components/green_planet_energy/services.py @@ -0,0 +1,197 @@ +"""Services for Green Planet Energy integration.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.helpers.service import async_get_config_entry +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonValueType + +from .const import DOMAIN + +SERVICE_GET_PRICES = "get_prices" +ATTR_HOURS = "hours" +SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration" + +ATTR_DURATION = "duration" +ATTR_TIME_RANGE = "time_range" + +TIME_RANGE_DAY = "day" +TIME_RANGE_NIGHT = "night" +TIME_RANGE_FULL_DAY = "full_day" + + +def _validate_hours(v: float) -> float: + """Validate that hours is a multiple of 0.25 (15 minutes).""" + if abs(v * 4 - round(v * 4)) >= 1e-9: + raise vol.Invalid("hours must be a multiple of 0.25 (15 minutes)") + return v + + +SERVICE_GET_PRICES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): ConfigEntrySelector( + {"integration": DOMAIN} + ), + vol.Required(ATTR_HOURS): vol.All( + vol.Coerce(float), + vol.Range(min=0.25, max=24), + _validate_hours, + ), + } +) + +SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DURATION): vol.All( + vol.Coerce(float), vol.Range(min=0.5, max=24) + ), + vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In( + [TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY] + ), + } +) + + +async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse: + """Find the cheapest consecutive time window for a given duration.""" + entries = call.hass.config_entries.async_entries(DOMAIN) + + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry", + ) + + entry = entries[0] + + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + coordinator = entry.runtime_data + duration = call.data[ATTR_DURATION] + time_range = call.data[ATTR_TIME_RANGE] + data = coordinator.data + api = coordinator.api + now = dt_util.now() + current_hour = now.hour + + result: tuple[float | None, int | None] + + if time_range == TIME_RANGE_DAY: + result = api.get_cheapest_duration_day(data, duration, current_hour) + elif time_range == TIME_RANGE_NIGHT: + result = api.get_cheapest_duration_night(data, duration, current_hour) + else: + result = api.get_cheapest_duration(data, duration, current_hour) + + avg_price, start_hour_result = result + + if avg_price is None or start_hour_result is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_data_available", + ) + + start_time = dt_util.start_of_local_day(now).replace( + hour=start_hour_result, minute=0, second=0, microsecond=0 + ) + if start_time < now: + start_time = start_time + timedelta(days=1) + + end_time = start_time + timedelta(hours=duration) + hours_until_start = (start_time - now).total_seconds() / 3600 + + return { + "duration": duration, + "average_price": round(avg_price / 100, 4), + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "hours_until_start": round(hours_until_start, 1), + "time_range": time_range, + } + + +async def get_prices(call: ServiceCall) -> ServiceResponse: + """Return raw 15-minute-slot electricity prices for the next N hours. + + Prices are in EUR/kWh. Slots for which the API has no data yet (e.g. + tomorrow's prices have not been published yet) are silently omitted + from the result. + """ + entry = async_get_config_entry(call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]) + data = entry.runtime_data.data + hours: float = call.data[ATTR_HOURS] + + now = dt_util.now() + slot_timestamp = int(dt_util.as_timestamp(now) // 900 * 900) + slot_start = dt_util.as_local(dt_util.utc_from_timestamp(slot_timestamp)) + end_time = slot_start + timedelta(hours=hours) + today = slot_start.date() + tomorrow = today + timedelta(days=1) + + slots: list[JsonValueType] = [] + current = slot_start + while current < end_time: + slot_end = current + timedelta(minutes=15) + h = current.hour + m = current.minute + current_date = current.date() + + if current_date == today: + key = f"gpe_price_{h:02d}_{m:02d}" + elif current_date == tomorrow: + key = f"gpe_price_{h:02d}_{m:02d}_tomorrow" + else: + current = slot_end + continue + + if key in data: + slots.append( + { + "start": current.isoformat(), + "end": slot_end.isoformat(), + "price": round(data[key] / 100, 6), + } + ) + + current = slot_end + + return { + "prices": slots, + "hours_requested": hours, + } + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Green Planet Energy.""" + + hass.services.async_register( + DOMAIN, + SERVICE_GET_CHEAPEST_DURATION, + get_cheapest_duration, + schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICES, + get_prices, + schema=SERVICE_GET_PRICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/green_planet_energy/services.yaml b/homeassistant/components/green_planet_energy/services.yaml index 652a8414a699..7f32b785a810 100644 --- a/homeassistant/components/green_planet_energy/services.yaml +++ b/homeassistant/components/green_planet_energy/services.yaml @@ -23,3 +23,19 @@ get_cheapest_duration: value: "day" - label: Night (18:00-06:00) value: "night" + +get_prices: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: green_planet_energy + hours: + required: true + selector: + number: + min: 0.25 + max: 24 + step: 0.25 + unit_of_measurement: hours diff --git a/homeassistant/components/green_planet_energy/strings.json b/homeassistant/components/green_planet_energy/strings.json index ab7fb2cedca4..853ab3280e31 100644 --- a/homeassistant/components/green_planet_energy/strings.json +++ b/homeassistant/components/green_planet_energy/strings.json @@ -70,6 +70,20 @@ } }, "name": "Get cheapest time window" + }, + "get_prices": { + "description": "Returns raw 15-minute electricity price slots for the next N hours. Slots beyond the API horizon are omitted.", + "fields": { + "config_entry_id": { + "description": "The Green Planet Energy integration instance to use.", + "name": "Config entry" + }, + "hours": { + "description": "How many hours of price data to return, starting from the current 15-minute slot. Minimum 0.25, maximum 24.", + "name": "Hours" + } + }, + "name": "Get energy prices" } } } diff --git a/tests/components/green_planet_energy/snapshots/test_services.ambr b/tests/components/green_planet_energy/snapshots/test_services.ambr new file mode 100644 index 000000000000..0a639939d8eb --- /dev/null +++ b/tests/components/green_planet_energy/snapshots/test_services.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_get_prices_basic + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-24T14:15:00-07:00', + 'price': 0.34, + 'start': '2026-03-24T14:00:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:30:00-07:00', + 'price': 0.3415, + 'start': '2026-03-24T14:15:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:45:00-07:00', + 'price': 0.343, + 'start': '2026-03-24T14:30:00-07:00', + }), + dict({ + 'end': '2026-03-24T15:00:00-07:00', + 'price': 0.3445, + 'start': '2026-03-24T14:45:00-07:00', + }), + ]), + }) +# --- +# name: test_get_prices_crosses_midnight + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-25T00:00:00-07:00', + 'price': 0.4345, + 'start': '2026-03-24T23:45:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:15:00-07:00', + 'price': 0.25, + 'start': '2026-03-25T00:00:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:30:00-07:00', + 'price': 0.2515, + 'start': '2026-03-25T00:15:00-07:00', + }), + dict({ + 'end': '2026-03-25T00:45:00-07:00', + 'price': 0.253, + 'start': '2026-03-25T00:30:00-07:00', + }), + ]), + }) +# --- +# name: test_get_prices_missing_slots_omitted + dict({ + 'hours_requested': 1.0, + 'prices': list([ + dict({ + 'end': '2026-03-24T14:15:00-07:00', + 'price': 0.34, + 'start': '2026-03-24T14:00:00-07:00', + }), + dict({ + 'end': '2026-03-24T14:45:00-07:00', + 'price': 0.343, + 'start': '2026-03-24T14:30:00-07:00', + }), + dict({ + 'end': '2026-03-24T15:00:00-07:00', + 'price': 0.3445, + 'start': '2026-03-24T14:45:00-07:00', + }), + ]), + }) +# --- diff --git a/tests/components/green_planet_energy/test_services.py b/tests/components/green_planet_energy/test_services.py index 153c07e4a707..49c4988573e3 100644 --- a/tests/components/green_planet_energy/test_services.py +++ b/tests/components/green_planet_energy/test_services.py @@ -1,11 +1,18 @@ -"""Test Green Planet Energy services.""" +"""Tests for Green Planet Energy services.""" from unittest.mock import MagicMock from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.green_planet_energy.const import DOMAIN +from homeassistant.components.green_planet_energy.services import ( + ATTR_HOURS, + SERVICE_GET_PRICES, +) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component @@ -13,28 +20,172 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +async def _call_get_prices(hass: HomeAssistant, hours: float, entry_id: str) -> dict: + """Call get_prices and return the service response.""" + return await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES, + {ATTR_CONFIG_ENTRY_ID: entry_id, ATTR_HOURS: hours}, + blocking=True, + return_response=True, + ) + + +async def _call_get_cheapest_duration( + hass: HomeAssistant, duration: float, time_range: str | None = None +) -> dict: + """Call get_cheapest_duration and return the service response.""" + data: dict[str, float | str] = {"duration": duration} + if time_range is not None: + data["time_range"] = time_range + + return await hass.services.async_call( + DOMAIN, + "get_cheapest_duration", + data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_basic( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Requesting 1 hour returns the expected response.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_slot_start_snapped( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Slot start is snapped to the current 15-minute boundary.""" + result = await _call_get_prices(hass, 0.25, init_integration.entry_id) + + prices = result["prices"] + assert len(prices) == 1 + assert prices[0]["start"].startswith("2026-03-24T14:00:00") + assert prices[0]["end"].startswith("2026-03-24T14:15:00") + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_correct_values( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Prices match the expected 15-minute mock data values.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + prices = result["prices"] + + expected = [ + (14, 0), + (14, 15), + (14, 30), + (14, 45), + ] + for slot, (hour, minute) in zip(prices, expected, strict=True): + expected_price = round((20.0 + hour + minute / 100) / 100, 6) + assert slot["price"] == pytest.approx(expected_price) + + +@pytest.mark.freeze_time("2026-03-24 23:45:00-07:00") +async def test_get_prices_crosses_midnight( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Slots that cross midnight use the expected response data.""" + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_missing_slots_omitted( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Missing data keys are omitted from the returned slots.""" + coordinator = init_integration.runtime_data + del coordinator.data["gpe_price_14_15"] + + result = await _call_get_prices(hass, 1, init_integration.entry_id) + + assert result == snapshot + + +async def test_get_prices_entry_not_found(hass: HomeAssistant) -> None: + """Service raises when the config entry does not exist.""" + await async_setup_component(hass, DOMAIN, {}) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_prices(hass, 1, "non_existent_entry_id") + assert exc_info.value.translation_key == "service_config_entry_not_found" + + +async def test_get_prices_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Service raises when the config entry exists but is not loaded.""" + await async_setup_component(hass, DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_prices(hass, 1, mock_config_entry.entry_id) + assert exc_info.value.translation_key == "service_config_entry_not_loaded" + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_quarter_hour( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Requesting 0.25 h returns exactly one slot.""" + result = await _call_get_prices(hass, 0.25, init_integration.entry_id) + assert len(result["prices"]) == 1 + assert result["hours_requested"] == 0.25 + + +@pytest.mark.freeze_time("2026-03-24 14:07:00-07:00") +async def test_get_prices_non_quarter_hour_rejected( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Hours must be a multiple of 0.25 according to schema validation.""" + with pytest.raises(vol.Invalid): + await _call_get_prices(hass, 0.3, init_integration.entry_id) + + +@pytest.mark.freeze_time("2026-03-24 00:00:00-07:00") +async def test_get_prices_max_hours( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Requesting 24 h from midnight returns one day of quarter-hour slots.""" + result = await _call_get_prices(hass, 24, init_integration.entry_id) + assert result["hours_requested"] == 24.0 + assert len(result["prices"]) == 96 + + @freeze_time("2024-01-01 08:00:00+00:00") async def test_get_cheapest_duration_day( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with day time range.""" + """get_cheapest_duration returns expected result for day range.""" await hass.config.async_set_time_zone("UTC") mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "day") assert response["duration"] == 2.5 assert response["average_price"] == 0.266 @@ -51,22 +202,13 @@ async def test_get_cheapest_duration_night( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with night time range.""" + """get_cheapest_duration returns expected result for night range.""" await hass.config.async_set_time_zone("UTC") mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "night", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "night") assert response["duration"] == 2.5 assert response["average_price"] == 0.258 @@ -83,7 +225,7 @@ async def test_get_cheapest_duration_full_day( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with full_day time range.""" + """get_cheapest_duration returns expected result for full day range.""" await hass.config.async_set_time_zone("UTC") mock_api.get_cheapest_duration.return_value = (25.0, 12) @@ -91,16 +233,7 @@ async def test_get_cheapest_duration_full_day( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 3.0, - "time_range": "full_day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 3.0, "full_day") assert response["duration"] == 3.0 assert response["average_price"] == 0.25 @@ -117,7 +250,7 @@ async def test_get_cheapest_duration_default_time_range( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test get_cheapest_duration service with default time range.""" + """get_cheapest_duration uses full_day as default time range.""" await hass.config.async_set_time_zone("UTC") mock_api.get_cheapest_duration.return_value = (25.0, 10) @@ -125,15 +258,7 @@ async def test_get_cheapest_duration_default_time_range( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 1.5, - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 1.5) assert response["time_range"] == "full_day" assert response["duration"] == 1.5 @@ -143,45 +268,27 @@ async def test_get_cheapest_duration_default_time_range( assert response["hours_until_start"] == 2.0 -async def test_get_cheapest_duration_no_config_entry( - hass: HomeAssistant, -) -> None: - """Test service error when no config entry exists.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) +async def test_get_cheapest_duration_no_config_entry(hass: HomeAssistant) -> None: + """Service raises when no integration config entry exists.""" + await async_setup_component(hass, DOMAIN, {}) - with pytest.raises( - ServiceValidationError, - match="No matching integration instance was found", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - {"duration": 2.5}, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5) + assert exc_info.value.translation_key == "no_config_entry" async def test_get_cheapest_duration_config_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: - """Test service error when config entry is not loaded.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + """Service raises when config entry exists but is not loaded.""" + await async_setup_component(hass, DOMAIN, {}) mock_config_entry.add_to_hass(hass) - with pytest.raises( - ServiceValidationError, - match="This integration instance is not currently loaded", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - {"duration": 2.5}, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5) + assert exc_info.value.translation_key == "config_entry_not_loaded" @freeze_time("2024-01-01 08:00:00+00:00") @@ -190,27 +297,16 @@ async def test_get_cheapest_duration_no_data_available( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test service fails when no price data is available.""" + """Service raises when cheapest-duration calculation has no data.""" mock_api.get_cheapest_duration_day.return_value = (None, None) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with pytest.raises( - ServiceValidationError, - match="No price data available for the requested duration and time range", - ): - await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + with pytest.raises(ServiceValidationError) as exc_info: + await _call_get_cheapest_duration(hass, 2.5, "day") + assert exc_info.value.translation_key == "no_data_available" @freeze_time("2024-01-01 20:00:00+00:00") @@ -219,27 +315,15 @@ async def test_get_cheapest_duration_past_start_time( mock_config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Test service handles start times that are in the past (tomorrow).""" - # Mock returns hour 6, but we're at hour 20, so result should be tomorrow + """Service shifts start time to tomorrow when computed start is in the past.""" mock_api.get_cheapest_duration_day.return_value = (26.6, 6) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - response = await hass.services.async_call( - DOMAIN, - "get_cheapest_duration", - { - "duration": 2.5, - "time_range": "day", - }, - blocking=True, - return_response=True, - ) + response = await _call_get_cheapest_duration(hass, 2.5, "day") - # Start time should be tomorrow since we're past 6:00 today - # hours_until_start should be positive (sometime in the future) assert response["duration"] == 2.5 assert response["hours_until_start"] > 0 assert "start_time" in response