Add get_prices service to Green Planet Energy integration (#166405)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Peter Grauvogel
2026-07-31 13:26:38 +02:00
committed by GitHub
co-authored by Copilot Joost Lekkerkerker
parent 9a220f283a
commit 1f4bfb655d
8 changed files with 500 additions and 220 deletions
@@ -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
@@ -2,6 +2,9 @@
"services": {
"get_cheapest_duration": {
"service": "mdi:clock-check"
},
"get_prices": {
"service": "mdi:lightning-bolt-circle"
}
}
}
@@ -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
@@ -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,
)
@@ -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
@@ -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"
}
}
}
@@ -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',
}),
]),
})
# ---
@@ -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