hass.tibber_prices/custom_components/tibber_prices/services/entity_resolver.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

285 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Entity reference resolution for service parameters.
Allows service parameters to accept Home Assistant entity IDs instead of
literal values. The entity's current state (or a specific attribute) is
resolved at call time and converted to the expected parameter type.
Syntax:
"sensor.washing_duration" → uses entity state
"sensor.washing_duration@run_minutes" → uses entity attribute
Supported target types: int, float, datetime, timedelta, time.
Usage in schemas:
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(...))
),
Usage in handlers:
data, resolved = resolve_entity_references(hass, call.data, PARAM_TYPES)
# 'data' is a mutable dict with entity refs replaced by resolved values
# 'resolved' is a dict of resolution details for the response
"""
from __future__ import annotations
from datetime import datetime, time as dt_time, timedelta
import re
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import DOMAIN
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
# Entity ID pattern: domain.object_id with optional @attribute
# domain: lowercase letters + underscores, must start with letter
# object_id: lowercase letters, digits, underscores
# attribute: anything after @ (HA attributes can have varied names)
_ENTITY_REF_RE = re.compile(
r"^([a-z][a-z0-9_]*\.[a-z0-9_]+)" # entity_id
r"(?:@(.+))?$", # optional @attribute
)
def is_entity_reference(value: Any) -> bool:
"""Check if a value looks like an entity reference."""
return isinstance(value, str) and _ENTITY_REF_RE.match(value) is not None
def _validate_entity_ref(value: Any) -> str:
"""Voluptuous validator: accepts entity reference strings."""
if not isinstance(value, str):
raise vol.Invalid("Entity reference must be a string")
if not _ENTITY_REF_RE.match(value):
raise vol.Invalid(f"Not a valid entity reference: {value}")
return value
def or_entity_ref(validator: Any) -> vol.Any:
"""Wrap a voluptuous validator to also accept entity references.
The schema will first try the original validator (for literal values),
then fall back to accepting an entity reference string.
Example:
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1)))
),
"""
return vol.Any(validator, _validate_entity_ref)
def _resolve_raw_value(hass: HomeAssistant, ref: str) -> tuple[str, str, str | None]:
"""Resolve an entity reference to its raw string value.
Returns:
Tuple of (raw_value, entity_id, attribute_name_or_none).
Raises:
ServiceValidationError: If entity not found, attribute missing, or state unavailable.
"""
match = _ENTITY_REF_RE.match(ref)
if not match:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_entity_reference",
translation_placeholders={"reference": ref},
)
entity_id = match.group(1)
attribute = match.group(2)
state_obj = hass.states.get(entity_id)
if state_obj is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if attribute:
if attribute not in state_obj.attributes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_attribute_not_found",
translation_placeholders={"entity_id": entity_id, "attribute": attribute},
)
raw = state_obj.attributes[attribute]
else:
raw = state_obj.state
if raw in ("unknown", "unavailable"):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_state_unavailable",
translation_placeholders={"entity_id": entity_id, "state": raw},
)
return str(raw), entity_id, attribute
# ---------------------------------------------------------------------------
# Type converters convert raw string values to expected Python types
# ---------------------------------------------------------------------------
def _convert_to_timedelta(raw: str) -> timedelta:
"""Convert a raw string to timedelta.
Accepts:
- Numeric value → interpreted as minutes (e.g., "90" → 1h30m)
- "HH:MM" → hours and minutes
- "HH:MM:SS" → hours, minutes, seconds
"""
# Try numeric (minutes)
try:
minutes = float(raw)
return timedelta(minutes=minutes)
except ValueError:
pass
# Try HH:MM or HH:MM:SS
parts = raw.split(":")
if len(parts) == 2:
return timedelta(hours=int(parts[0]), minutes=int(parts[1]))
if len(parts) == 3:
return timedelta(hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2]))
msg = f"Cannot convert '{raw}' to duration (expected minutes as number or HH:MM:SS)"
raise ValueError(msg)
def _convert_to_datetime(raw: str) -> datetime:
"""Convert a raw string to datetime using HA's parser."""
dt = dt_util.parse_datetime(raw)
if dt is not None:
return dt
msg = f"Cannot convert '{raw}' to datetime"
raise ValueError(msg)
def _convert_to_time(raw: str) -> dt_time:
"""Convert a raw string to time-of-day using HA's parser."""
t = dt_util.parse_time(raw)
if t is not None:
return t
msg = f"Cannot convert '{raw}' to time"
raise ValueError(msg)
_CONVERTERS: dict[type, Any] = {
int: lambda raw: int(float(raw)),
float: float,
timedelta: _convert_to_timedelta,
datetime: _convert_to_datetime,
dt_time: _convert_to_time,
}
def resolve_entity_references(
hass: HomeAssistant,
data: dict[str, Any] | Any,
param_types: dict[str, type],
) -> tuple[dict[str, Any], dict[str, dict[str, str | None]]]:
"""Resolve entity references in service call data.
Creates a mutable copy of the data dict and replaces any entity reference
strings with their resolved and type-converted values.
Args:
hass: HomeAssistant instance.
data: Service call data (typically call.data, may be immutable).
param_types: Map of parameter name → expected Python type.
Only parameters listed here are checked for entity references.
Returns:
Tuple of (resolved_data_dict, resolved_info_dict).
resolved_data_dict: Mutable dict with entity refs replaced.
resolved_info_dict: Details of resolved references (empty if none).
Keys are parameter names; values contain entity_id, attribute,
raw_value, and resolved_value for the service response.
Raises:
ServiceValidationError: If entity not found, attribute missing,
state unavailable, or value cannot be converted.
"""
resolved_data = dict(data)
resolved_info: dict[str, dict[str, str | None]] = {}
for param_name, expected_type in param_types.items():
value = resolved_data.get(param_name)
if value is None or not is_entity_reference(value):
continue
raw_value, entity_id, attribute = _resolve_raw_value(hass, value)
converter = _CONVERTERS.get(expected_type)
if converter is None:
converted = raw_value
else:
try:
converted = converter(raw_value)
except (ValueError, TypeError) as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_value_conversion_failed",
translation_placeholders={
"entity_id": entity_id,
"attribute": attribute or "state",
"raw_value": raw_value,
"expected_type": expected_type.__name__,
},
) from err
resolved_data[param_name] = converted
resolved_info[param_name] = {
"entity_id": entity_id,
"attribute": attribute,
"raw_value": raw_value,
"resolved_value": str(converted),
}
return resolved_data, resolved_info
def resolve_task_entity_references(
hass: HomeAssistant,
tasks: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, str | None]]]:
"""Resolve entity references in schedule task list.
Handles entity references in task-level parameters (currently: duration).
Args:
hass: HomeAssistant instance.
tasks: List of task dicts from service call data.
Returns:
Tuple of (resolved_tasks, resolved_info).
resolved_tasks: New list with entity refs replaced in task dicts.
resolved_info: Details keyed as "tasks[i].param_name".
"""
task_param_types: dict[str, type] = {
"duration": timedelta,
}
resolved_tasks = []
all_resolved: dict[str, dict[str, str | None]] = {}
for i, task in enumerate(tasks):
resolved_task, task_resolved = resolve_entity_references(hass, task, task_param_types)
resolved_tasks.append(resolved_task)
for param_name, info in task_resolved.items():
all_resolved[f"tasks[{i}].{param_name}"] = info
return resolved_tasks, all_resolved