mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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.
193 lines
5.9 KiB
Python
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
|