hass.tibber_prices/custom_components/tibber_prices/services/get_price.py
Julian Pawlowski e01cc5d447 feat(services): allow entity IDs as service parameter values
Add entity_resolver module that lets all service parameters accept
HA entity references in place of literal values. The entity's current
state (or a specific attribute via the @attr syntax) is resolved at
call time and coerced to the expected Python type.

Syntax:
  "sensor.washing_duration"           → uses entity state
  "sensor.washing_duration@run_minutes" → uses entity attribute

Apply or_entity_ref() and resolve_entity_references() to all five
service handlers (get_price, find_cheapest_block, find_cheapest_hours,
find_cheapest_schedule, get_chartdata) for every parameter where a
dynamic value from another entity is useful (duration, start/end times,
offsets, etc.).

Add five new translation keys for entity-resolution error messages
(invalid_entity_reference, entity_not_found, entity_attribute_not_found,
entity_state_unavailable, entity_value_conversion_failed) across all
five language files.

Fix pytest warning filter to suppress AsyncMock cleanup noise, and
update test_resource_cleanup to mock hass.config_entries.async_entries
so the blueprint-removal path in async_remove_entry does not raise.

Impact: Automations and scripts can pass sensor entity IDs as service
parameters (e.g. duration from a sensor) instead of having to use
template-based workarounds.
2026-04-20 18:44:24 +00:00

193 lines
5.9 KiB
Python

"""
Service handler for get_price service.
This service fetches raw price interval data for any time range using the
interval pool's intelligent caching. Only intervals not already cached are
fetched from the Tibber API.
Functions:
handle_get_price: Service handler for fetching price data
"""
from __future__ import annotations
from datetime import datetime
import logging
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
import voluptuous as vol
from custom_components.tibber_prices.const import DOMAIN
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import get_entry_and_data
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
GET_PRICE_SERVICE_NAME = "get_price"
_PRICE_ENTITY_PARAMS: dict[str, type] = {
"start_time": datetime,
"end_time": datetime,
}
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Required("start_time"): or_entity_ref(cv.datetime),
vol.Required("end_time"): or_entity_ref(cv.datetime),
}
)
def _raise_user_data_error() -> None:
"""Raise user data not available error."""
msg = "User data not available"
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="user_data_not_available",
) from ValueError(msg)
async def handle_get_price(call: ServiceCall) -> ServiceResponse:
"""
Handle get_price service call.
Fetches price data for a specified time range using the interval pool.
The pool intelligently caches intervals and only fetches missing data from the API.
Args:
call: Service call with entry_id, start_time, and end_time
Returns:
Dict with price data and metadata
Raises:
ServiceValidationError: If arguments invalid or request fails
"""
hass: HomeAssistant = call.hass
# Resolve entity references
data, resolved_refs = resolve_entity_references(hass, call.data, _PRICE_ENTITY_PARAMS)
entry_id: str = data.get("entry_id", "")
start_time: datetime = data["start_time"]
end_time: datetime = data["end_time"]
# Validate and get entry data
entry, coordinator, _data = get_entry_and_data(hass, entry_id)
# Get home_id from entry
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
# Get API client from coordinator
api_client = coordinator.api
# Get user data (needed for timezone) - coordinator doesn't expose this publicly yet
user_data = coordinator._cached_user_data # noqa: SLF001
if not user_data:
_raise_user_data_error()
# Extract home timezone from user_data
home_timezone = None
if user_data and "viewer" in user_data:
for home in user_data["viewer"].get("homes", []):
if home.get("id") == home_id:
home_timezone = home.get("timeZone")
break
if not home_timezone:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="timezone_not_found",
)
# Ensure times are timezone-aware using HOME timezone (not HA server timezone!)
# CRITICAL TWO-STEP PROCESS:
# 1. GUI gives us naive datetime in HA SERVER timezone → localize to HA timezone
# 2. Convert from HA timezone to HOME timezone (Tibber home location)
home_tz = ZoneInfo(home_timezone)
if start_time.tzinfo is None:
# Step 1: Localize to HA server timezone
start_time = dt_util.as_local(start_time)
# Step 2: Convert to home timezone
start_time = start_time.astimezone(home_tz)
if end_time.tzinfo is None:
# Step 1: Localize to HA server timezone
end_time = dt_util.as_local(end_time)
# Step 2: Convert to home timezone
end_time = end_time.astimezone(home_tz)
# Validate: end must be after start
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_before_start",
)
_LOGGER.info(
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
entry_id,
home_id,
start_time,
end_time,
)
try:
# Get interval pool from entry runtime_data (one pool per config entry)
pool = entry.runtime_data.interval_pool
# Call the interval pool to get intervals (with intelligent caching)
# Single-home architecture: pool knows its home_id, no parameter needed
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=start_time,
end_time=end_time,
)
# Note: We ignore api_called flag here - service always returns requested data
# regardless of whether it came from cache or was fetched fresh from API
except Exception as error:
_LOGGER.exception("Error fetching price data")
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
else:
# Add metadata to response
response = {
"home_id": home_id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interval_count": len(price_info),
"price_info": price_info,
}
_LOGGER.info(
"get_price service completed: fetched %d intervals",
len(price_info),
)
if resolved_refs:
response["_resolved"] = resolved_refs
return response