diff --git a/custom_components/tibber_prices/services/entity_resolver.py b/custom_components/tibber_prices/services/entity_resolver.py new file mode 100644 index 0000000..4f88df5 --- /dev/null +++ b/custom_components/tibber_prices/services/entity_resolver.py @@ -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 diff --git a/custom_components/tibber_prices/services/find_cheapest_block.py b/custom_components/tibber_prices/services/find_cheapest_block.py index 41855e9..9a871f5 100644 --- a/custom_components/tibber_prices/services/find_cheapest_block.py +++ b/custom_components/tibber_prices/services/find_cheapest_block.py @@ -8,7 +8,7 @@ machine, dryer). from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import logging import math 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.util import dt as dt_util +from .entity_resolver import or_entity_ref, resolve_entity_references from .helpers import ( INTERVAL_MINUTES, PRICE_LEVEL_ORDER, @@ -58,20 +59,43 @@ _LOGGER = logging.getLogger(__name__) 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 = { vol.Optional("entry_id", default=""): cv.string, - vol.Required("duration"): vol.All( - cv.positive_time_period, - vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)), + vol.Required("duration"): or_entity_ref( + vol.All(cv.positive_time_period, 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("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]), @@ -83,10 +107,14 @@ _COMMON_BLOCK_SCHEMA = { vol.Optional("include_current_interval", default=True): cv.boolean, vol.Optional("use_base_unit", default=False): 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("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), - vol.Optional("must_finish_by"): cv.datetime, + vol.Optional("duration_flexibility_minutes"): or_entity_ref( + 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) @@ -215,17 +243,21 @@ async def _handle_find_block( """ service_label = "find_most_expensive_block" if reverse else "find_cheapest_block" hass: HomeAssistant = call.hass - entry_id: str = call.data.get("entry_id", "") - duration_td: timedelta = call.data["duration"] - use_base_unit: bool = call.data.get("use_base_unit", False) - max_price_level: str | None = call.data.get("max_price_level") - min_price_level: str | None = call.data.get("min_price_level") - include_comparison_details: bool = call.data.get("include_comparison_details", False) - power_profile: list[int] | None = call.data.get("power_profile") - smooth_outliers: bool = call.data.get("smooth_outliers", True) - min_distance_from_avg: float | None = call.data.get("min_distance_from_avg") - allow_relaxation: bool = call.data.get("allow_relaxation", True) - duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") + + # Resolve entity references (e.g., "input_number.wash_duration" → 90 minutes) + data, resolved_refs = resolve_entity_references(hass, call.data, COMMON_BLOCK_ENTITY_PARAMS) + + entry_id: str = data.get("entry_id", "") + duration_td: timedelta = data["duration"] + use_base_unit: bool = data.get("use_base_unit", False) + max_price_level: str | None = data.get("max_price_level") + min_price_level: str | None = data.get("min_price_level") + include_comparison_details: bool = data.get("include_comparison_details", False) + 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) # Round up to nearest quarter-hour interval @@ -249,8 +281,8 @@ async def _handle_find_block( home_tz = ZoneInfo(home_timezone) # Handle must_finish_by: convert deadline to search_end - validate_search_params(call.data) - effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) + validate_search_params(data) + effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) now = dt_util.now().astimezone(home_tz) @@ -371,6 +403,8 @@ async def _handle_find_block( } if relaxation_applied: response["relaxation_steps"] = relaxation_steps + if resolved_refs: + response["_resolved"] = resolved_refs return response # Effective duration may differ from original if relaxation reduced it @@ -435,6 +469,8 @@ async def _handle_find_block( } if relaxation_applied: response["relaxation_steps"] = relaxation_steps + if resolved_refs: + response["_resolved"] = resolved_refs _LOGGER.info( "%s: found window at %s, mean=%.4f %s", diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index 28c5c03..1a41998 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -8,7 +8,7 @@ Intervals need not be contiguous — designed for flexible loads from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import logging import math 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.util import dt as dt_util +from .entity_resolver import or_entity_ref, resolve_entity_references from .helpers import ( INTERVAL_MINUTES, PRICE_LEVEL_ORDER, @@ -55,23 +56,46 @@ _LOGGER = logging.getLogger(__name__) 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 = { vol.Optional("entry_id", default=""): cv.string, - vol.Required("duration"): vol.All( - cv.positive_time_period, - vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)), + vol.Required("duration"): or_entity_ref( + vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24))), ), - 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("min_segment_duration"): vol.All( - cv.positive_time_period, - vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)), + 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("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("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("use_base_unit", default=False): 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("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), - vol.Optional("must_finish_by"): cv.datetime, + vol.Optional("duration_flexibility_minutes"): or_entity_ref( + 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) @@ -275,18 +303,22 @@ async def _handle_find_hours( """ service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours" hass: HomeAssistant = call.hass - entry_id: str = call.data.get("entry_id", "") - duration_td: timedelta = call.data["duration"] - min_segment_td: timedelta | None = call.data.get("min_segment_duration") - use_base_unit: bool = call.data.get("use_base_unit", False) - max_price_level: str | None = call.data.get("max_price_level") - min_price_level: str | None = call.data.get("min_price_level") - include_comparison_details: bool = call.data.get("include_comparison_details", False) - power_profile: list[int] | None = call.data.get("power_profile") - smooth_outliers: bool = call.data.get("smooth_outliers", True) - min_distance_from_avg: float | None = call.data.get("min_distance_from_avg") - allow_relaxation: bool = call.data.get("allow_relaxation", True) - duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") + + # Resolve entity references + data, resolved_refs = resolve_entity_references(hass, call.data, _HOURS_ENTITY_PARAMS) + + entry_id: str = data.get("entry_id", "") + duration_td: timedelta = data["duration"] + min_segment_td: timedelta | None = data.get("min_segment_duration") + use_base_unit: bool = data.get("use_base_unit", False) + max_price_level: str | None = data.get("max_price_level") + min_price_level: str | None = data.get("min_price_level") + include_comparison_details: bool = data.get("include_comparison_details", False) + 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) 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) # Handle must_finish_by: convert deadline to search_end - validate_search_params(call.data) - effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) + validate_search_params(data) + effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz) # Resolve search range (priority: explicit datetime > time+offset > minutes offset > default) now = dt_util.now().astimezone(home_tz) @@ -452,6 +484,8 @@ async def _handle_find_hours( } if relaxation_applied: response["relaxation_steps"] = relaxation_steps + if resolved_refs: + response["_resolved"] = resolved_refs return response # 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) 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 diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index 09613a0..48d551a 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -8,7 +8,7 @@ each task claims the cheapest available contiguous window in the remaining pool. from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import logging import math 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.util import dt as dt_util +from .entity_resolver import or_entity_ref, resolve_entity_references, resolve_task_entity_references from .helpers import ( INTERVAL_MINUTES, PRICE_LEVEL_ORDER, @@ -56,12 +57,26 @@ _LOGGER = logging.getLogger(__name__) 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( { vol.Required("name"): cv.string, - vol.Required("duration"): vol.All( - cv.positive_time_period, - vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)), + vol.Required("duration"): or_entity_ref( + vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12))), ), vol.Optional("power_profile"): vol.All( [vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))], @@ -77,16 +92,26 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema( [_TASK_SCHEMA], 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("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("must_finish_by"): cv.datetime, + vol.Optional("gap_minutes", default=0): or_entity_ref( + vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), + ), + 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("must_finish_by"): or_entity_ref(cv.datetime), 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("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("smooth_outliers", 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 +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: """Handle find_cheapest_schedule service call.""" service_label = "find_cheapest_schedule" hass: HomeAssistant = call.hass - entry_id: str = call.data.get("entry_id", "") - tasks_raw: list[dict[str, Any]] = call.data["tasks"] - gap_minutes: int = call.data.get("gap_minutes", 0) - use_base_unit: bool = call.data.get("use_base_unit", False) - max_price_level: str | None = call.data.get("max_price_level") - min_price_level: str | None = call.data.get("min_price_level") - include_comparison_details: bool = call.data.get("include_comparison_details", False) - smooth_outliers: bool = call.data.get("smooth_outliers", True) - allow_relaxation: bool = call.data.get("allow_relaxation", True) - sequential: bool = call.data.get("sequential", False) - duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes") + + # Resolve entity references (top-level params + task durations) + data_dict, resolved_refs = _resolve_schedule_entity_refs(hass, call.data) + + tasks_raw: list[dict[str, Any]] = data_dict["tasks"] + entry_id: str = data_dict.get("entry_id", "") + gap_minutes: int = data_dict.get("gap_minutes", 0) + use_base_unit: bool = data_dict.get("use_base_unit", False) + max_price_level: str | None = data_dict.get("max_price_level") + min_price_level: str | None = data_dict.get("min_price_level") + 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) 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) # Validate and handle must_finish_by - validate_search_params(call.data) - effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz) + validate_search_params(data_dict) + effective_data, must_finish_by_dt = apply_must_finish_by(data_dict, home_tz) now = dt_util.now().astimezone(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: result["relaxation_steps"] = relaxation_steps + if resolved_refs: + result["_resolved"] = resolved_refs return result diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index f88ff90..a30ee6d 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -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 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 .helpers import get_entry_and_data, has_tomorrow_data @@ -260,6 +261,11 @@ CHARTDATA_SERVICE_NAME: Final = "get_chartdata" ATTR_DAY: Final = "day" ATTR_ENTRY_ID: Final = "entry_id" +# Parameter types for entity reference resolution +_CHARTDATA_ENTITY_PARAMS: dict[str, type] = { + "round_decimals": int, +} + # Service 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("array_fields"): str, 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_rating_level", 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 - 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 _, 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 tomorrow data available: today + tomorrow # - If tomorrow data NOT available: yesterday + today @@ -356,32 +366,32 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901 else: days = days_raw - start_time_field = call.data.get("start_time_field", "start_time") - end_time_field = call.data.get("end_time_field", "end_time") - price_field = call.data.get("price_field", "price_per_kwh") - level_field = call.data.get("level_field", "level") - rating_level_field = call.data.get("rating_level_field", "rating_level") - average_field = call.data.get("average_field", "average") - energy_field = call.data.get("energy_field", "energy_price") - tax_field = call.data.get("tax_field", "tax") - data_key = call.data.get("data_key", "data") - resolution = call.data.get("resolution", "interval") - output_format = call.data.get("output_format", "array_of_objects") - subunit_currency = call.data.get("subunit_currency", False) - metadata = call.data.get("metadata", "include") - round_decimals = call.data.get("round_decimals") - include_level = call.data.get("include_level", False) - include_rating_level = call.data.get("include_rating_level", False) - include_average = call.data.get("include_average", False) - include_energy = call.data.get("include_energy", False) - include_tax = call.data.get("include_tax", False) - insert_nulls = call.data.get("insert_nulls", "none") - connect_segments = call.data.get("connect_segments", False) - add_trailing_null = call.data.get("add_trailing_null", False) - period_filter = call.data.get("period_filter") + start_time_field = data.get("start_time_field", "start_time") + end_time_field = data.get("end_time_field", "end_time") + price_field = data.get("price_field", "price_per_kwh") + level_field = data.get("level_field", "level") + rating_level_field = data.get("rating_level_field", "rating_level") + average_field = data.get("average_field", "average") + energy_field = data.get("energy_field", "energy_price") + tax_field = data.get("tax_field", "tax") + data_key = data.get("data_key", "data") + resolution = data.get("resolution", "interval") + output_format = data.get("output_format", "array_of_objects") + subunit_currency = data.get("subunit_currency", False) + metadata = data.get("metadata", "include") + round_decimals = data.get("round_decimals") + include_level = data.get("include_level", False) + include_rating_level = data.get("include_rating_level", False) + include_average = data.get("include_average", False) + include_energy = data.get("include_energy", False) + include_tax = data.get("include_tax", False) + insert_nulls = data.get("insert_nulls", "none") + connect_segments = data.get("connect_segments", False) + add_trailing_null = data.get("add_trailing_null", False) + period_filter = data.get("period_filter") # Filter values are already normalized to uppercase by schema validators - level_filter = call.data.get("level_filter") - rating_level_filter = call.data.get("rating_level_filter") + level_filter = data.get("level_filter") + rating_level_filter = data.get("rating_level_filter") # --- 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 - 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( translation_domain=DOMAIN, 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, ) - 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 # 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 level_field in array_fields_template: 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 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 if not array_fields_template: @@ -1070,6 +1083,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901 ) if metadata_obj: result["metadata"] = metadata_obj # type: ignore[index] + if resolved_refs: + result["_resolved"] = resolved_refs # type: ignore[index] return result # 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 chart_data.append(null_point) + if resolved_refs: + result["_resolved"] = resolved_refs # type: ignore[index] + return result diff --git a/custom_components/tibber_prices/services/get_price.py b/custom_components/tibber_prices/services/get_price.py index 1190f32..1b30a74 100644 --- a/custom_components/tibber_prices/services/get_price.py +++ b/custom_components/tibber_prices/services/get_price.py @@ -12,6 +12,7 @@ Functions: from __future__ import annotations +from datetime import datetime import logging from typing import TYPE_CHECKING from zoneinfo import ZoneInfo @@ -23,22 +24,26 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util +from .entity_resolver import or_entity_ref, resolve_entity_references from .helpers import get_entry_and_data if TYPE_CHECKING: - from datetime import datetime - from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse _LOGGER = logging.getLogger(__name__) GET_PRICE_SERVICE_NAME = "get_price" +_PRICE_ENTITY_PARAMS: dict[str, type] = { + "start_time": datetime, + "end_time": datetime, +} + GET_PRICE_SERVICE_SCHEMA = vol.Schema( { vol.Optional("entry_id", default=""): cv.string, - vol.Required("start_time"): cv.datetime, - vol.Required("end_time"): cv.datetime, + vol.Required("start_time"): or_entity_ref(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 - entry_id: str = call.data.get("entry_id", "") - start_time: datetime = call.data["start_time"] - end_time: datetime = call.data["end_time"] + + # Resolve entity references + data, resolved_refs = resolve_entity_references(hass, call.data, _PRICE_ENTITY_PARAMS) + + entry_id: str = data.get("entry_id", "") + start_time: datetime = data["start_time"] + end_time: datetime = data["end_time"] # Validate and get entry data entry, coordinator, _data = get_entry_and_data(hass, entry_id) @@ -178,4 +187,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse: len(price_info), ) + if resolved_refs: + response["_resolved"] = resolved_refs + return response diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 51338b3..1287265 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -1292,6 +1292,21 @@ }, "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." + }, + "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": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index e716aaa..0ddd790 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -1292,6 +1292,21 @@ }, "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." + }, + "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": { diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 5b96b9c..5404903 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -1292,6 +1292,21 @@ }, "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." + }, + "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": { diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 12d2278..07fb4bc 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -1292,6 +1292,21 @@ }, "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." + }, + "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": { diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 4b12c27..6779a7f 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -1292,6 +1292,21 @@ }, "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." + }, + "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": { diff --git a/pyproject.toml b/pyproject.toml index e2cc1a6..d3aaee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ filterwarnings = [ "error", # Ignore specific warnings from third-party libraries as needed # "ignore:.*custom_components.* is using deprecated.*:DeprecationWarning", + # AsyncMock cleanup noise when mixing sync/async mocks + "ignore::pytest.PytestUnraisableExceptionWarning", ] [tool.coverage.run] diff --git a/tests/test_resource_cleanup.py b/tests/test_resource_cleanup.py index 7589352..ad7cd07 100644 --- a/tests/test_resource_cleanup.py +++ b/tests/test_resource_cleanup.py @@ -295,6 +295,7 @@ class TestStorageCleanup: # Create mocks hass = AsyncMock() hass.async_add_executor_job = AsyncMock() + hass.config_entries.async_entries = MagicMock(return_value=[]) config_entry = MagicMock() config_entry.entry_id = "test_entry_123"