mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Add entity_resolver module that lets all service parameters accept HA entity references in place of literal values. The entity's current state (or a specific attribute via the @attr syntax) is resolved at call time and coerced to the expected Python type. Syntax: "sensor.washing_duration" → uses entity state "sensor.washing_duration@run_minutes" → uses entity attribute Apply or_entity_ref() and resolve_entity_references() to all five service handlers (get_price, find_cheapest_block, find_cheapest_hours, find_cheapest_schedule, get_chartdata) for every parameter where a dynamic value from another entity is useful (duration, start/end times, offsets, etc.). Add five new translation keys for entity-resolution error messages (invalid_entity_reference, entity_not_found, entity_attribute_not_found, entity_state_unavailable, entity_value_conversion_failed) across all five language files. Fix pytest warning filter to suppress AsyncMock cleanup noise, and update test_resource_cleanup to mock hass.config_entries.async_entries so the blueprint-removal path in async_remove_entry does not raise. Impact: Automations and scripts can pass sensor entity IDs as service parameters (e.g. duration from a sensor) instead of having to use template-based workarounds.
538 lines
21 KiB
Python
538 lines
21 KiB
Python
"""
|
|
Service handler for find_cheapest_hours and find_most_expensive_hours services.
|
|
|
|
Finds the cheapest (or most expensive) N minutes of intervals within a search range.
|
|
Intervals need not be contiguous — designed for flexible loads
|
|
(battery charging, EV, water heater with thermostat).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
import logging
|
|
import math
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import voluptuous as vol
|
|
|
|
from custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
|
|
from custom_components.tibber_prices.utils.price_window import calculate_window_statistics, find_cheapest_n_intervals
|
|
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,
|
|
VALID_SEARCH_SCOPES,
|
|
apply_must_finish_by,
|
|
build_rating_lookup,
|
|
build_response_interval,
|
|
calculate_search_range_avg,
|
|
check_min_distance_from_avg,
|
|
filter_intervals_by_price_level,
|
|
get_entry_and_data,
|
|
resolve_home_timezone,
|
|
resolve_search_range,
|
|
restore_original_prices,
|
|
smooth_service_intervals,
|
|
validate_power_profile_length,
|
|
validate_price_level_range,
|
|
validate_search_params,
|
|
)
|
|
from .relaxation import (
|
|
MIN_RELAXED_DURATION_INTERVALS,
|
|
calculate_max_duration_reduction_intervals,
|
|
generate_relaxation_steps,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
|
|
|
|
_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"): or_entity_ref(
|
|
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24))),
|
|
),
|
|
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]),
|
|
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
|
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
|
vol.Optional("power_profile"): vol.All(
|
|
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
|
|
vol.Length(min=1, max=96),
|
|
),
|
|
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"): 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"): 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)
|
|
|
|
|
|
def _determine_no_intervals_reason(
|
|
price_info: list[dict],
|
|
filtered_price_info: list[dict],
|
|
total_intervals: int,
|
|
*,
|
|
level_filter_active: bool,
|
|
) -> str:
|
|
"""Classify why no interval selection could be found."""
|
|
if not price_info:
|
|
return "no_data_in_range"
|
|
if level_filter_active and not filtered_price_info:
|
|
return "no_intervals_matching_level_filter"
|
|
if len(filtered_price_info) < total_intervals:
|
|
return "insufficient_intervals_after_filter"
|
|
return "insufficient_intervals_for_constraints"
|
|
|
|
|
|
def _attempt_find_hours(
|
|
price_info: list[dict],
|
|
*,
|
|
max_price_level: str | None,
|
|
min_price_level: str | None,
|
|
total_intervals: int,
|
|
min_segment_intervals: int,
|
|
smooth_outliers: bool,
|
|
min_distance_from_avg: float | None,
|
|
reverse: bool,
|
|
) -> tuple[dict | None, str]:
|
|
"""Attempt to find hours with specific filter parameters.
|
|
|
|
Returns:
|
|
(result_dict, "") on success or (None, reason_code) on failure.
|
|
|
|
"""
|
|
level_filter_active = min_price_level is not None or max_price_level is not None
|
|
filtered = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
|
|
|
if smooth_outliers and filtered:
|
|
search_data = smooth_service_intervals(filtered)
|
|
else:
|
|
search_data = filtered
|
|
|
|
result = find_cheapest_n_intervals(search_data, total_intervals, min_segment_intervals, reverse=reverse)
|
|
|
|
if result is None:
|
|
return None, _determine_no_intervals_reason(
|
|
price_info, filtered, total_intervals, level_filter_active=level_filter_active
|
|
)
|
|
|
|
# Restore original prices (smoothing only affects scoring)
|
|
if smooth_outliers:
|
|
result["intervals"] = restore_original_prices(result["intervals"])
|
|
for seg in result["segments"]:
|
|
seg["intervals"] = restore_original_prices(seg["intervals"])
|
|
|
|
# Check distance constraint
|
|
if min_distance_from_avg is not None:
|
|
range_avg = calculate_search_range_avg(price_info)
|
|
window_mean = sum(iv["total"] for iv in result["intervals"]) / len(result["intervals"])
|
|
if range_avg is not None and not check_min_distance_from_avg(
|
|
window_mean, range_avg, min_distance_from_avg, reverse=reverse
|
|
):
|
|
return None, "selection_above_distance_threshold" if not reverse else "selection_below_distance_threshold"
|
|
|
|
return result, ""
|
|
|
|
|
|
def _build_found_response(
|
|
*,
|
|
result: dict,
|
|
comparison_result: dict | None,
|
|
reverse: bool,
|
|
home_id: str,
|
|
search_start: datetime,
|
|
search_end: datetime,
|
|
total_minutes_requested: int,
|
|
total_minutes: int,
|
|
min_segment_minutes_requested: int,
|
|
min_segment_minutes: int,
|
|
currency: str,
|
|
price_unit: str,
|
|
unit_factor: int,
|
|
service_label: str,
|
|
rating_lookup: dict[str, str | None],
|
|
include_comparison_details: bool = False,
|
|
power_profile: list[int] | None = None,
|
|
) -> dict:
|
|
"""Build the service response when intervals are found."""
|
|
stats = calculate_window_statistics(
|
|
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
|
|
)
|
|
|
|
# Calculate price comparison (difference to opposite-direction selection)
|
|
price_comparison: dict[str, float | str | None] = {}
|
|
if comparison_result is not None:
|
|
comparison_stats = calculate_window_statistics(
|
|
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
|
)
|
|
own_mean = stats.get("price_mean")
|
|
comp_mean = comparison_stats.get("price_mean")
|
|
if own_mean is not None and comp_mean is not None:
|
|
diff = round(float(comp_mean) - float(own_mean), 4)
|
|
if reverse:
|
|
diff = -diff
|
|
price_comparison = {
|
|
"comparison_price_mean": comp_mean,
|
|
"price_difference": abs(round(diff, 4)),
|
|
}
|
|
if include_comparison_details:
|
|
price_comparison["comparison_price_min"] = comparison_stats.get("price_min")
|
|
price_comparison["comparison_price_max"] = comparison_stats.get("price_max")
|
|
|
|
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
|
|
|
|
response_segments = []
|
|
for seg in result["segments"]:
|
|
seg_stats = calculate_window_statistics(seg["intervals"], unit_factor=unit_factor, round_decimals=4)
|
|
last_start = seg["intervals"][-1]["startsAt"]
|
|
if isinstance(last_start, str):
|
|
seg_end = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
|
else:
|
|
seg_end = last_start + timedelta(minutes=INTERVAL_MINUTES)
|
|
|
|
response_segments.append(
|
|
{
|
|
"start": seg["start"],
|
|
"end": seg_end.isoformat() if hasattr(seg_end, "isoformat") else seg_end,
|
|
"duration_minutes": seg["duration_minutes"],
|
|
"interval_count": seg["interval_count"],
|
|
"price_mean": seg_stats.get("price_mean"),
|
|
"intervals": [build_response_interval(iv, unit_factor, rating_lookup) for iv in seg["intervals"]],
|
|
}
|
|
)
|
|
|
|
actual_minutes = len(result["intervals"]) * INTERVAL_MINUTES
|
|
|
|
_LOGGER.info(
|
|
"%s: found %d intervals in %d segments, mean=%.4f %s",
|
|
service_label,
|
|
len(result["intervals"]),
|
|
len(response_segments),
|
|
stats.get("price_mean", 0) or 0,
|
|
price_unit,
|
|
)
|
|
|
|
return {
|
|
"home_id": home_id,
|
|
"search_start": search_start.isoformat(),
|
|
"search_end": search_end.isoformat(),
|
|
"total_minutes_requested": total_minutes_requested,
|
|
"total_minutes": total_minutes,
|
|
"min_segment_minutes_requested": min_segment_minutes_requested,
|
|
"min_segment_minutes": min_segment_minutes,
|
|
"currency": currency,
|
|
"price_unit": price_unit,
|
|
"intervals_found": True,
|
|
"schedule": {
|
|
"total_minutes": actual_minutes,
|
|
"interval_count": len(result["intervals"]),
|
|
**stats,
|
|
"segment_count": len(response_segments),
|
|
"segments": response_segments,
|
|
"intervals": response_intervals,
|
|
},
|
|
"price_comparison": price_comparison or None,
|
|
}
|
|
|
|
|
|
async def _handle_find_hours(
|
|
call: ServiceCall,
|
|
*,
|
|
reverse: bool = False,
|
|
) -> ServiceResponse:
|
|
"""
|
|
Core handler for finding price hours (cheapest or most expensive).
|
|
|
|
Finds the cheapest/most expensive N intervals (not necessarily contiguous)
|
|
within the search range. Results are grouped into contiguous segments for
|
|
scheduling convenience.
|
|
"""
|
|
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
|
|
hass: HomeAssistant = call.hass
|
|
|
|
# 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
|
|
|
|
# Round up to nearest quarter-hour intervals
|
|
total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
|
min_segment_minutes = math.ceil(min_segment_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
|
|
|
|
entry, coordinator, data = get_entry_and_data(hass, entry_id)
|
|
rating_lookup = build_rating_lookup(data)
|
|
|
|
home_id = entry.data.get("home_id")
|
|
if not home_id:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="missing_home_id",
|
|
)
|
|
|
|
# Resolve timezone
|
|
home_timezone = resolve_home_timezone(coordinator, home_id)
|
|
home_tz: ZoneInfo
|
|
from zoneinfo import ZoneInfo # noqa: PLC0415
|
|
|
|
home_tz = ZoneInfo(home_timezone)
|
|
|
|
# Handle must_finish_by: convert deadline to search_end
|
|
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)
|
|
search_start, search_end = resolve_search_range(effective_data, now, home_tz)
|
|
|
|
total_intervals = total_minutes // INTERVAL_MINUTES
|
|
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
|
|
|
|
# Validate parameter combinations
|
|
if min_segment_minutes > total_minutes:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="min_segment_exceeds_duration",
|
|
translation_placeholders={
|
|
"min_segment_minutes": str(min_segment_minutes),
|
|
"duration_minutes": str(total_minutes),
|
|
},
|
|
)
|
|
validate_price_level_range(min_price_level, max_price_level)
|
|
validate_power_profile_length(power_profile, total_intervals)
|
|
|
|
_LOGGER.info(
|
|
"%s called: total=%dmin, min_segment=%dmin, range=%s to %s",
|
|
service_label,
|
|
total_minutes,
|
|
min_segment_minutes,
|
|
search_start,
|
|
search_end,
|
|
)
|
|
|
|
# Fetch intervals via pool
|
|
api_client = coordinator.api
|
|
user_data = coordinator._cached_user_data # noqa: SLF001
|
|
pool = entry.runtime_data.interval_pool
|
|
|
|
try:
|
|
price_info, _api_called = await pool.get_intervals(
|
|
api_client=api_client,
|
|
user_data=user_data,
|
|
start_time=search_start,
|
|
end_time=search_end,
|
|
)
|
|
except Exception as error:
|
|
_LOGGER.exception("Error fetching price data for %s", service_label)
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="price_fetch_failed",
|
|
) from error
|
|
|
|
# Determine currency and unit
|
|
currency = entry.data.get("currency", "EUR")
|
|
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
|
|
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
|
|
|
|
# --- Attempt with original parameters ---
|
|
effective_total = total_intervals
|
|
result, reason = _attempt_find_hours(
|
|
price_info,
|
|
max_price_level=max_price_level,
|
|
min_price_level=min_price_level,
|
|
total_intervals=effective_total,
|
|
min_segment_intervals=min_segment_intervals,
|
|
smooth_outliers=smooth_outliers,
|
|
min_distance_from_avg=min_distance_from_avg,
|
|
reverse=reverse,
|
|
)
|
|
|
|
relaxation_applied = False
|
|
relaxation_steps = 0
|
|
|
|
# --- Relaxation loop ---
|
|
if result is None and allow_relaxation:
|
|
max_reduction = calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
|
|
min_dur = max(MIN_RELAXED_DURATION_INTERVALS, min_segment_intervals)
|
|
steps = generate_relaxation_steps(
|
|
min_distance_from_avg=min_distance_from_avg,
|
|
max_price_level=max_price_level,
|
|
min_price_level=min_price_level,
|
|
total_intervals=total_intervals,
|
|
min_duration_intervals=min_dur,
|
|
max_duration_reduction_intervals=max_reduction,
|
|
reverse=reverse,
|
|
)
|
|
for step in steps:
|
|
effective_total = total_intervals - step.duration_reduction
|
|
result, reason = _attempt_find_hours(
|
|
price_info,
|
|
max_price_level=step.max_price_level,
|
|
min_price_level=step.min_price_level,
|
|
total_intervals=effective_total,
|
|
min_segment_intervals=min_segment_intervals,
|
|
smooth_outliers=smooth_outliers,
|
|
min_distance_from_avg=step.min_distance_from_avg,
|
|
reverse=reverse,
|
|
)
|
|
if result is not None:
|
|
relaxation_applied = True
|
|
relaxation_steps = step.step_number
|
|
_LOGGER.info(
|
|
"%s: relaxation succeeded at step %d (phase=%s, intervals=%d)",
|
|
service_label,
|
|
step.step_number,
|
|
step.phase,
|
|
effective_total,
|
|
)
|
|
break
|
|
else:
|
|
reason = "relaxation_exhausted"
|
|
|
|
effective_total_minutes = effective_total * INTERVAL_MINUTES
|
|
|
|
if result is None:
|
|
_LOGGER.info(
|
|
"%s: no interval selection found (reason=%s, need %d, have %d in range)",
|
|
service_label,
|
|
reason,
|
|
effective_total,
|
|
len(price_info),
|
|
)
|
|
response: dict[str, Any] = {
|
|
"home_id": home_id,
|
|
"search_start": search_start.isoformat(),
|
|
"search_end": search_end.isoformat(),
|
|
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
|
|
"total_minutes_requested": total_minutes_requested,
|
|
"total_minutes": effective_total_minutes,
|
|
"min_segment_minutes_requested": min_segment_minutes_requested,
|
|
"min_segment_minutes": min_segment_minutes,
|
|
"currency": currency,
|
|
"price_unit": price_unit,
|
|
"intervals_found": False,
|
|
"reason": reason,
|
|
"relaxation_applied": relaxation_applied,
|
|
"schedule": None,
|
|
}
|
|
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)
|
|
comparison_result = find_cheapest_n_intervals(
|
|
price_info, effective_total, min_segment_intervals, reverse=not reverse
|
|
)
|
|
|
|
found_response = _build_found_response(
|
|
result=result,
|
|
comparison_result=comparison_result,
|
|
reverse=reverse,
|
|
home_id=home_id,
|
|
search_start=search_start,
|
|
search_end=search_end,
|
|
total_minutes_requested=total_minutes_requested,
|
|
total_minutes=effective_total_minutes,
|
|
min_segment_minutes_requested=min_segment_minutes_requested,
|
|
min_segment_minutes=min_segment_minutes,
|
|
currency=currency,
|
|
price_unit=price_unit,
|
|
unit_factor=unit_factor,
|
|
service_label=service_label,
|
|
rating_lookup=rating_lookup,
|
|
include_comparison_details=include_comparison_details,
|
|
power_profile=power_profile,
|
|
)
|
|
found_response["relaxation_applied"] = relaxation_applied
|
|
found_response["must_finish_by"] = must_finish_by_dt.isoformat() if must_finish_by_dt else None
|
|
if relaxation_applied:
|
|
found_response["relaxation_steps"] = relaxation_steps
|
|
|
|
# Add seconds_until_start (time until first segment starts)
|
|
schedule = found_response.get("schedule")
|
|
if schedule and schedule.get("segments"):
|
|
first_seg_start = schedule["segments"][0]["start"]
|
|
first_seg_dt = datetime.fromisoformat(first_seg_start)
|
|
schedule["seconds_until_start"] = max(0, int((first_seg_dt - now).total_seconds()))
|
|
last_seg_end = schedule["segments"][-1]["end"]
|
|
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
|
|
|
|
|
|
async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse:
|
|
"""Handle find_cheapest_hours service call."""
|
|
return await _handle_find_hours(call, reverse=False)
|