mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
a8d1519a26
commit
e01cc5d447
13 changed files with 643 additions and 127 deletions
285
custom_components/tibber_prices/services/entity_resolver.py
Normal file
285
custom_components/tibber_prices/services/entity_resolver.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue