mirror of
https://github.com/home-assistant/core.git
synced 2026-08-19 12:48:57 +01:00
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:
co-authored by
Copilot
Joost Lekkerkerker
parent
9a220f283a
commit
1f4bfb655d
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user