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.
This commit is contained in:
Julian Pawlowski 2026-04-20 18:44:24 +00:00
parent a8d1519a26
commit e01cc5d447
13 changed files with 643 additions and 127 deletions

View file

@ -0,0 +1,285 @@
"""
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

View file

@ -8,7 +8,7 @@ machine, dryer).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, time as dt_time, timedelta
import logging import logging
import math import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -24,6 +24,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import ( from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
@ -58,20 +59,43 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block" FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block"
# Parameter types for entity reference resolution (param_name → expected Python type)
COMMON_BLOCK_ENTITY_PARAMS: dict[str, type] = {
"duration": timedelta,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"min_distance_from_avg": float,
"duration_flexibility_minutes": int,
"must_finish_by": datetime,
}
_COMMON_BLOCK_SCHEMA = { _COMMON_BLOCK_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string, vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All( vol.Required("duration"): or_entity_ref(
cv.positive_time_period, vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12))),
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)), ),
vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
), ),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
@ -83,10 +107,14 @@ _COMMON_BLOCK_SCHEMA = {
vol.Optional("include_current_interval", default=True): cv.boolean, vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean, vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)), vol.Optional("min_distance_from_avg"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
),
vol.Optional("allow_relaxation", default=True): cv.boolean, vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.Optional("must_finish_by"): cv.datetime, vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
} }
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA) FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
@ -215,17 +243,21 @@ async def _handle_find_block(
""" """
service_label = "find_most_expensive_block" if reverse else "find_cheapest_block" service_label = "find_most_expensive_block" if reverse else "find_cheapest_block"
hass: HomeAssistant = call.hass hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"] # Resolve entity references (e.g., "input_number.wash_duration" → 90 minutes)
use_base_unit: bool = call.data.get("use_base_unit", False) data, resolved_refs = resolve_entity_references(hass, call.data, COMMON_BLOCK_ENTITY_PARAMS)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level") entry_id: str = data.get("entry_id", "")
include_comparison_details: bool = call.data.get("include_comparison_details", False) duration_td: timedelta = data["duration"]
power_profile: list[int] | None = call.data.get("power_profile") use_base_unit: bool = data.get("use_base_unit", False)
smooth_outliers: bool = call.data.get("smooth_outliers", True) max_price_level: str | None = data.get("max_price_level")
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg") min_price_level: str | None = data.get("min_price_level")
allow_relaxation: bool = call.data.get("allow_relaxation", True) include_comparison_details: bool = data.get("include_comparison_details", False)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") power_profile: list[int] | None = data.get("power_profile")
smooth_outliers: bool = data.get("smooth_outliers", True)
min_distance_from_avg: float | None = data.get("min_distance_from_avg")
allow_relaxation: bool = data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = data.get("duration_flexibility_minutes")
duration_minutes_requested = int(duration_td.total_seconds() / 60) duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval # Round up to nearest quarter-hour interval
@ -249,8 +281,8 @@ async def _handle_find_block(
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end # Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data) validate_search_params(data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
@ -371,6 +403,8 @@ async def _handle_find_block(
} }
if relaxation_applied: if relaxation_applied:
response["relaxation_steps"] = relaxation_steps response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response return response
# Effective duration may differ from original if relaxation reduced it # Effective duration may differ from original if relaxation reduced it
@ -435,6 +469,8 @@ async def _handle_find_block(
} }
if relaxation_applied: if relaxation_applied:
response["relaxation_steps"] = relaxation_steps response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
_LOGGER.info( _LOGGER.info(
"%s: found window at %s, mean=%.4f %s", "%s: found window at %s, mean=%.4f %s",

View file

@ -8,7 +8,7 @@ Intervals need not be contiguous — designed for flexible loads
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, time as dt_time, timedelta
import logging import logging
import math import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -21,6 +21,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import ( from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
@ -55,23 +56,46 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours" FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours"
# Parameter types for entity reference resolution
_HOURS_ENTITY_PARAMS: dict[str, type] = {
"duration": timedelta,
"min_segment_duration": timedelta,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"min_distance_from_avg": float,
"duration_flexibility_minutes": int,
"must_finish_by": datetime,
}
_COMMON_HOURS_SCHEMA = { _COMMON_HOURS_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string, vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All( vol.Required("duration"): or_entity_ref(
cv.positive_time_period, vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24))),
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)),
), ),
vol.Optional("search_start"): cv.datetime, vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): cv.datetime, vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): cv.time, vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.Optional("search_end_time"): cv.time, vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), ),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.Optional("min_segment_duration"): vol.All( vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
cv.positive_time_period, ),
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)), vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("min_segment_duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4))),
), ),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
@ -84,10 +108,14 @@ _COMMON_HOURS_SCHEMA = {
vol.Optional("include_current_interval", default=True): cv.boolean, vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean, vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)), vol.Optional("min_distance_from_avg"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
),
vol.Optional("allow_relaxation", default=True): cv.boolean, vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.Optional("must_finish_by"): cv.datetime, vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
} }
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA) FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
@ -275,18 +303,22 @@ async def _handle_find_hours(
""" """
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours" service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
hass: HomeAssistant = call.hass hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"] # Resolve entity references
min_segment_td: timedelta | None = call.data.get("min_segment_duration") data, resolved_refs = resolve_entity_references(hass, call.data, _HOURS_ENTITY_PARAMS)
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level") entry_id: str = data.get("entry_id", "")
min_price_level: str | None = call.data.get("min_price_level") duration_td: timedelta = data["duration"]
include_comparison_details: bool = call.data.get("include_comparison_details", False) min_segment_td: timedelta | None = data.get("min_segment_duration")
power_profile: list[int] | None = call.data.get("power_profile") use_base_unit: bool = data.get("use_base_unit", False)
smooth_outliers: bool = call.data.get("smooth_outliers", True) max_price_level: str | None = data.get("max_price_level")
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg") min_price_level: str | None = data.get("min_price_level")
allow_relaxation: bool = call.data.get("allow_relaxation", True) include_comparison_details: bool = data.get("include_comparison_details", False)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") power_profile: list[int] | None = data.get("power_profile")
smooth_outliers: bool = data.get("smooth_outliers", True)
min_distance_from_avg: float | None = data.get("min_distance_from_avg")
allow_relaxation: bool = data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = data.get("duration_flexibility_minutes")
total_minutes_requested = int(duration_td.total_seconds() / 60) total_minutes_requested = int(duration_td.total_seconds() / 60)
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
@ -313,8 +345,8 @@ async def _handle_find_hours(
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end # Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data) validate_search_params(data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
@ -452,6 +484,8 @@ async def _handle_find_hours(
} }
if relaxation_applied: if relaxation_applied:
response["relaxation_steps"] = relaxation_steps response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response return response
# Find opposite-direction selection for price comparison (from full unfiltered list) # Find opposite-direction selection for price comparison (from full unfiltered list)
@ -493,6 +527,9 @@ async def _handle_find_hours(
last_seg_end_dt = datetime.fromisoformat(last_seg_end) last_seg_end_dt = datetime.fromisoformat(last_seg_end)
schedule["seconds_until_end"] = max(0, int((last_seg_end_dt - now).total_seconds())) schedule["seconds_until_end"] = max(0, int((last_seg_end_dt - now).total_seconds()))
if resolved_refs:
found_response["_resolved"] = resolved_refs
return found_response return found_response

View file

@ -8,7 +8,7 @@ each task claims the cheapest available contiguous window in the remaining pool.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, time as dt_time, timedelta
import logging import logging
import math import math
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -24,6 +24,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references, resolve_task_entity_references
from .helpers import ( from .helpers import (
INTERVAL_MINUTES, INTERVAL_MINUTES,
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
@ -56,12 +57,26 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule" FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
# Parameter types for entity reference resolution
_SCHEDULE_ENTITY_PARAMS: dict[str, type] = {
"gap_minutes": int,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"must_finish_by": datetime,
"duration_flexibility_minutes": int,
}
_TASK_SCHEMA = vol.Schema( _TASK_SCHEMA = vol.Schema(
{ {
vol.Required("name"): cv.string, vol.Required("name"): cv.string,
vol.Required("duration"): vol.All( vol.Required("duration"): or_entity_ref(
cv.positive_time_period, vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12))),
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
), ),
vol.Optional("power_profile"): vol.All( vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))], [vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
@ -77,16 +92,26 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
[_TASK_SCHEMA], [_TASK_SCHEMA],
vol.Length(min=1, max=4), vol.Length(min=1, max=4),
), ),
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), vol.Optional("gap_minutes", default=0): or_entity_ref(
vol.Optional("search_start"): cv.datetime, vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("search_end"): cv.datetime, ),
vol.Optional("search_start_time"): cv.time, vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_end_time"): cv.time, vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), ),
vol.Optional("must_finish_by"): cv.datetime, vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
@ -95,7 +120,9 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
vol.Optional("sequential", default=False): cv.boolean, vol.Optional("sequential", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean, vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("allow_relaxation", default=True): cv.boolean, vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
} }
) )
@ -314,21 +341,42 @@ def _attempt_schedule(
return assignments, unscheduled, filtered return assignments, unscheduled, filtered
def _resolve_schedule_entity_refs(
hass: HomeAssistant,
call_data: dict[str, Any] | Any,
) -> tuple[dict[str, Any], dict[str, dict[str, str | None]]]:
"""Resolve entity references in schedule service call data (top-level + tasks)."""
data_dict, resolved_refs = resolve_entity_references(hass, call_data, _SCHEDULE_ENTITY_PARAMS)
tasks_raw: list[dict[str, Any]] = data_dict["tasks"]
resolved_tasks, task_resolved_refs = resolve_task_entity_references(hass, tasks_raw)
if resolved_tasks:
data_dict["tasks"] = resolved_tasks
if task_resolved_refs:
resolved_refs.update(task_resolved_refs)
return data_dict, resolved_refs
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_schedule service call.""" """Handle find_cheapest_schedule service call."""
service_label = "find_cheapest_schedule" service_label = "find_cheapest_schedule"
hass: HomeAssistant = call.hass hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
tasks_raw: list[dict[str, Any]] = call.data["tasks"] # Resolve entity references (top-level params + task durations)
gap_minutes: int = call.data.get("gap_minutes", 0) data_dict, resolved_refs = _resolve_schedule_entity_refs(hass, call.data)
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level") tasks_raw: list[dict[str, Any]] = data_dict["tasks"]
min_price_level: str | None = call.data.get("min_price_level") entry_id: str = data_dict.get("entry_id", "")
include_comparison_details: bool = call.data.get("include_comparison_details", False) gap_minutes: int = data_dict.get("gap_minutes", 0)
smooth_outliers: bool = call.data.get("smooth_outliers", True) use_base_unit: bool = data_dict.get("use_base_unit", False)
allow_relaxation: bool = call.data.get("allow_relaxation", True) max_price_level: str | None = data_dict.get("max_price_level")
sequential: bool = call.data.get("sequential", False) min_price_level: str | None = data_dict.get("min_price_level")
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") include_comparison_details: bool = data_dict.get("include_comparison_details", False)
smooth_outliers: bool = data_dict.get("smooth_outliers", True)
allow_relaxation: bool = data_dict.get("allow_relaxation", True)
sequential: bool = data_dict.get("sequential", False)
duration_flexibility_minutes: int | None = data_dict.get("duration_flexibility_minutes")
# Validate task names are unique (before any expensive operations) # Validate task names are unique (before any expensive operations)
task_names = [t["name"] for t in tasks_raw] task_names = [t["name"] for t in tasks_raw]
@ -360,8 +408,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
home_tz = ZoneInfo(home_timezone) home_tz = ZoneInfo(home_timezone)
# Validate and handle must_finish_by # Validate and handle must_finish_by
validate_search_params(call.data) validate_search_params(data_dict)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) effective_data, must_finish_by_dt = apply_must_finish_by(data_dict, home_tz)
now = dt_util.now().astimezone(home_tz) now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(effective_data, now, home_tz) search_start, search_end = resolve_search_range(effective_data, now, home_tz)
@ -629,4 +677,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
} }
if relaxation_applied: if relaxation_applied:
result["relaxation_steps"] = relaxation_steps result["relaxation_steps"] = relaxation_steps
if resolved_refs:
result["_resolved"] = resolved_refs
return result return result

View file

@ -52,6 +52,7 @@ from custom_components.tibber_prices.const import (
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from .entity_resolver import or_entity_ref, resolve_entity_references
from .formatters import aggregate_to_hourly, get_period_data, normalize_level_filter, normalize_rating_level_filter from .formatters import aggregate_to_hourly, get_period_data, normalize_level_filter, normalize_rating_level_filter
from .helpers import get_entry_and_data, has_tomorrow_data from .helpers import get_entry_and_data, has_tomorrow_data
@ -260,6 +261,11 @@ CHARTDATA_SERVICE_NAME: Final = "get_chartdata"
ATTR_DAY: Final = "day" ATTR_DAY: Final = "day"
ATTR_ENTRY_ID: Final = "entry_id" ATTR_ENTRY_ID: Final = "entry_id"
# Parameter types for entity reference resolution
_CHARTDATA_ENTITY_PARAMS: dict[str, type] = {
"round_decimals": int,
}
# Service schema # Service schema
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema( CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
{ {
@ -269,7 +275,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]), vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
vol.Optional("array_fields"): str, vol.Optional("array_fields"): str,
vol.Optional("subunit_currency", default=False): bool, vol.Optional("subunit_currency", default=False): bool,
vol.Optional("round_decimals"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)), vol.Optional("round_decimals"): or_entity_ref(vol.All(vol.Coerce(int), vol.Range(min=0, max=10))),
vol.Optional("include_level", default=False): bool, vol.Optional("include_level", default=False): bool,
vol.Optional("include_rating_level", default=False): bool, vol.Optional("include_rating_level", default=False): bool,
vol.Optional("include_average", default=False): bool, vol.Optional("include_average", default=False): bool,
@ -339,12 +345,16 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
""" """
hass = call.hass hass = call.hass
entry_id: str = call.data.get(ATTR_ENTRY_ID, "")
# Resolve entity references
data, resolved_refs = resolve_entity_references(hass, call.data, _CHARTDATA_ENTITY_PARAMS)
entry_id: str = data.get(ATTR_ENTRY_ID, "")
# Get coordinator to check data availability # Get coordinator to check data availability
_, coordinator, _ = get_entry_and_data(hass, entry_id) _, coordinator, _ = get_entry_and_data(hass, entry_id)
days_raw = call.data.get(ATTR_DAY) days_raw = data.get(ATTR_DAY)
# If no day specified, use rolling 2-day window: # If no day specified, use rolling 2-day window:
# - If tomorrow data available: today + tomorrow # - If tomorrow data available: today + tomorrow
# - If tomorrow data NOT available: yesterday + today # - If tomorrow data NOT available: yesterday + today
@ -356,32 +366,32 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
else: else:
days = days_raw days = days_raw
start_time_field = call.data.get("start_time_field", "start_time") start_time_field = data.get("start_time_field", "start_time")
end_time_field = call.data.get("end_time_field", "end_time") end_time_field = data.get("end_time_field", "end_time")
price_field = call.data.get("price_field", "price_per_kwh") price_field = data.get("price_field", "price_per_kwh")
level_field = call.data.get("level_field", "level") level_field = data.get("level_field", "level")
rating_level_field = call.data.get("rating_level_field", "rating_level") rating_level_field = data.get("rating_level_field", "rating_level")
average_field = call.data.get("average_field", "average") average_field = data.get("average_field", "average")
energy_field = call.data.get("energy_field", "energy_price") energy_field = data.get("energy_field", "energy_price")
tax_field = call.data.get("tax_field", "tax") tax_field = data.get("tax_field", "tax")
data_key = call.data.get("data_key", "data") data_key = data.get("data_key", "data")
resolution = call.data.get("resolution", "interval") resolution = data.get("resolution", "interval")
output_format = call.data.get("output_format", "array_of_objects") output_format = data.get("output_format", "array_of_objects")
subunit_currency = call.data.get("subunit_currency", False) subunit_currency = data.get("subunit_currency", False)
metadata = call.data.get("metadata", "include") metadata = data.get("metadata", "include")
round_decimals = call.data.get("round_decimals") round_decimals = data.get("round_decimals")
include_level = call.data.get("include_level", False) include_level = data.get("include_level", False)
include_rating_level = call.data.get("include_rating_level", False) include_rating_level = data.get("include_rating_level", False)
include_average = call.data.get("include_average", False) include_average = data.get("include_average", False)
include_energy = call.data.get("include_energy", False) include_energy = data.get("include_energy", False)
include_tax = call.data.get("include_tax", False) include_tax = data.get("include_tax", False)
insert_nulls = call.data.get("insert_nulls", "none") insert_nulls = data.get("insert_nulls", "none")
connect_segments = call.data.get("connect_segments", False) connect_segments = data.get("connect_segments", False)
add_trailing_null = call.data.get("add_trailing_null", False) add_trailing_null = data.get("add_trailing_null", False)
period_filter = call.data.get("period_filter") period_filter = data.get("period_filter")
# Filter values are already normalized to uppercase by schema validators # Filter values are already normalized to uppercase by schema validators
level_filter = call.data.get("level_filter") level_filter = data.get("level_filter")
rating_level_filter = call.data.get("rating_level_filter") rating_level_filter = data.get("rating_level_filter")
# --- Parameter dependency validation --- # --- Parameter dependency validation ---
@ -424,7 +434,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
) )
# array_fields is only meaningful with array_of_arrays format # array_fields is only meaningful with array_of_arrays format
if call.data.get("array_fields") and output_format != "array_of_arrays": if data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="array_fields_requires_array_format", translation_key="array_fields_requires_array_format",
@ -464,12 +474,15 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
subunit_currency=subunit_currency, subunit_currency=subunit_currency,
) )
return {"metadata": metadata} result_meta: dict[str, Any] = {"metadata": metadata}
if resolved_refs:
result_meta["_resolved"] = resolved_refs
return result_meta
# Filter values are already normalized to uppercase by schema validators # Filter values are already normalized to uppercase by schema validators
# If array_fields is specified, implicitly enable fields that are used # If array_fields is specified, implicitly enable fields that are used
array_fields_template = call.data.get("array_fields") array_fields_template = data.get("array_fields")
if array_fields_template and output_format == "array_of_arrays": if array_fields_template and output_format == "array_of_arrays":
if level_field in array_fields_template: if level_field in array_fields_template:
include_level = True include_level = True
@ -1024,7 +1037,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
# Convert to array of arrays format if requested # Convert to array of arrays format if requested
if output_format == "array_of_arrays": if output_format == "array_of_arrays":
array_fields_template = call.data.get("array_fields") array_fields_template = data.get("array_fields")
# Default: nur timestamp und price # Default: nur timestamp und price
if not array_fields_template: if not array_fields_template:
@ -1070,6 +1083,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
) )
if metadata_obj: if metadata_obj:
result["metadata"] = metadata_obj # type: ignore[index] result["metadata"] = metadata_obj # type: ignore[index]
if resolved_refs:
result["_resolved"] = resolved_refs # type: ignore[index]
return result return result
# Calculate metadata (before adding trailing null) # Calculate metadata (before adding trailing null)
@ -1097,4 +1112,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
null_point[field] = None null_point[field] = None
chart_data.append(null_point) chart_data.append(null_point)
if resolved_refs:
result["_resolved"] = resolved_refs # type: ignore[index]
return result return result

View file

@ -12,6 +12,7 @@ Functions:
from __future__ import annotations from __future__ import annotations
from datetime import datetime
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -23,22 +24,26 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util 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 from .helpers import get_entry_and_data
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
GET_PRICE_SERVICE_NAME = "get_price" GET_PRICE_SERVICE_NAME = "get_price"
_PRICE_ENTITY_PARAMS: dict[str, type] = {
"start_time": datetime,
"end_time": datetime,
}
GET_PRICE_SERVICE_SCHEMA = vol.Schema( GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{ {
vol.Optional("entry_id", default=""): cv.string, vol.Optional("entry_id", default=""): cv.string,
vol.Required("start_time"): cv.datetime, vol.Required("start_time"): or_entity_ref(cv.datetime),
vol.Required("end_time"): cv.datetime, vol.Required("end_time"): or_entity_ref(cv.datetime),
} }
) )
@ -70,9 +75,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
""" """
hass: HomeAssistant = call.hass hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
start_time: datetime = call.data["start_time"] # Resolve entity references
end_time: datetime = call.data["end_time"] 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 # Validate and get entry data
entry, coordinator, _data = get_entry_and_data(hass, entry_id) entry, coordinator, _data = get_entry_and_data(hass, entry_id)
@ -178,4 +187,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
len(price_info), len(price_info),
) )
if resolved_refs:
response["_resolved"] = resolved_refs
return response return response

View file

@ -1292,6 +1292,21 @@
}, },
"must_finish_by_conflicts_with_end": { "must_finish_by_conflicts_with_end": {
"message": "must_finish_by kann nicht mit Endgrenz-Parametern ({params}) kombiniert werden. Verwende must_finish_by allein — es setzt das Suchende automatisch auf die Deadline." "message": "must_finish_by kann nicht mit Endgrenz-Parametern ({params}) kombiniert werden. Verwende must_finish_by allein — es setzt das Suchende automatisch auf die Deadline."
},
"invalid_entity_reference": {
"message": "'{reference}' ist keine gültige Entity-Referenz. Verwende das Format 'domain.entity_id' oder 'domain.entity_id@attribut'."
},
"entity_not_found": {
"message": "Entity '{entity_id}' nicht gefunden. Überprüfe, ob die Entity existiert und verfügbar ist."
},
"entity_attribute_not_found": {
"message": "Entity '{entity_id}' hat kein Attribut '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entity '{entity_id}' hat den Status '{state}'. Die Entity muss einen gültigen Statuswert haben."
},
"entity_value_conversion_failed": {
"message": "Wert '{raw_value}' von '{entity_id}' ({attribute}) kann nicht in {expected_type} konvertiert werden. Überprüfe, ob die Entity einen kompatiblen Wert liefert."
} }
}, },
"services": { "services": {

View file

@ -1292,6 +1292,21 @@
}, },
"must_finish_by_conflicts_with_end": { "must_finish_by_conflicts_with_end": {
"message": "must_finish_by cannot be combined with end-boundary parameters ({params}). Use must_finish_by alone — it sets the search end to the deadline automatically." "message": "must_finish_by cannot be combined with end-boundary parameters ({params}). Use must_finish_by alone — it sets the search end to the deadline automatically."
},
"invalid_entity_reference": {
"message": "'{reference}' is not a valid entity reference. Use the format 'domain.entity_id' or 'domain.entity_id@attribute'."
},
"entity_not_found": {
"message": "Entity '{entity_id}' not found. Verify the entity exists and is available."
},
"entity_attribute_not_found": {
"message": "Entity '{entity_id}' does not have attribute '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entity '{entity_id}' state is '{state}'. The entity must have a valid state value."
},
"entity_value_conversion_failed": {
"message": "Cannot convert value '{raw_value}' from '{entity_id}' ({attribute}) to {expected_type}. Verify the entity provides a compatible value."
} }
}, },
"services": { "services": {

View file

@ -1292,6 +1292,21 @@
}, },
"must_finish_by_conflicts_with_end": { "must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan ikke kombineres med sluttgrenseparametere ({params}). Bruk must_finish_by alene — det setter søkeslutt til fristen automatisk." "message": "must_finish_by kan ikke kombineres med sluttgrenseparametere ({params}). Bruk must_finish_by alene — det setter søkeslutt til fristen automatisk."
},
"invalid_entity_reference": {
"message": "'{reference}' er ikke en gyldig entitetsreferanse. Bruk formatet 'domain.entity_id' eller 'domain.entity_id@attributt'."
},
"entity_not_found": {
"message": "Entitet '{entity_id}' ble ikke funnet. Kontroller at entiteten finnes og er tilgjengelig."
},
"entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har ikke attributtet '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tilstanden '{state}'. Entiteten må ha en gyldig tilstandsverdi."
},
"entity_value_conversion_failed": {
"message": "Kan ikke konvertere verdi '{raw_value}' fra '{entity_id}' ({attribute}) til {expected_type}. Kontroller at entiteten gir en kompatibel verdi."
} }
}, },
"services": { "services": {

View file

@ -1292,6 +1292,21 @@
}, },
"must_finish_by_conflicts_with_end": { "must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan niet worden gecombineerd met eindgrensparameters ({params}). Gebruik must_finish_by alleen — het stelt het zoekeinde automatisch in op de deadline." "message": "must_finish_by kan niet worden gecombineerd met eindgrensparameters ({params}). Gebruik must_finish_by alleen — het stelt het zoekeinde automatisch in op de deadline."
},
"invalid_entity_reference": {
"message": "'{reference}' is geen geldige entiteitsreferentie. Gebruik het formaat 'domain.entity_id' of 'domain.entity_id@attribuut'."
},
"entity_not_found": {
"message": "Entiteit '{entity_id}' niet gevonden. Controleer of de entiteit bestaat en beschikbaar is."
},
"entity_attribute_not_found": {
"message": "Entiteit '{entity_id}' heeft geen attribuut '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entiteit '{entity_id}' heeft status '{state}'. De entiteit moet een geldige statuswaarde hebben."
},
"entity_value_conversion_failed": {
"message": "Kan waarde '{raw_value}' van '{entity_id}' ({attribute}) niet converteren naar {expected_type}. Controleer of de entiteit een compatibele waarde biedt."
} }
}, },
"services": { "services": {

View file

@ -1292,6 +1292,21 @@
}, },
"must_finish_by_conflicts_with_end": { "must_finish_by_conflicts_with_end": {
"message": "must_finish_by kan inte kombineras med slutgränsparametrar ({params}). Använd must_finish_by ensamt — det sätter sökslutet till deadline automatiskt." "message": "must_finish_by kan inte kombineras med slutgränsparametrar ({params}). Använd must_finish_by ensamt — det sätter sökslutet till deadline automatiskt."
},
"invalid_entity_reference": {
"message": "'{reference}' är inte en giltig entitetsreferens. Använd formatet 'domain.entity_id' eller 'domain.entity_id@attribut'."
},
"entity_not_found": {
"message": "Entitet '{entity_id}' hittades inte. Kontrollera att entiteten finns och är tillgänglig."
},
"entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har inte attributet '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tillståndet '{state}'. Entiteten måste ha ett giltigt tillståndsvärde."
},
"entity_value_conversion_failed": {
"message": "Kan inte konvertera värdet '{raw_value}' från '{entity_id}' ({attribute}) till {expected_type}. Kontrollera att entiteten ger ett kompatibelt värde."
} }
}, },
"services": { "services": {

View file

@ -49,6 +49,8 @@ filterwarnings = [
"error", "error",
# Ignore specific warnings from third-party libraries as needed # Ignore specific warnings from third-party libraries as needed
# "ignore:.*custom_components.* is using deprecated.*:DeprecationWarning", # "ignore:.*custom_components.* is using deprecated.*:DeprecationWarning",
# AsyncMock cleanup noise when mixing sync/async mocks
"ignore::pytest.PytestUnraisableExceptionWarning",
] ]
[tool.coverage.run] [tool.coverage.run]

View file

@ -295,6 +295,7 @@ class TestStorageCleanup:
# Create mocks # Create mocks
hass = AsyncMock() hass = AsyncMock()
hass.async_add_executor_job = AsyncMock() hass.async_add_executor_job = AsyncMock()
hass.config_entries.async_entries = MagicMock(return_value=[])
config_entry = MagicMock() config_entry = MagicMock()
config_entry.entry_id = "test_entry_123" config_entry.entry_id = "test_entry_123"