From db5d172fb88478c7c0b04cab6001d3bc42d00f38 Mon Sep 17 00:00:00 2001 From: WouterGithb <166367768+WouterGithb@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:45:10 +0200 Subject: [PATCH] fix(services): preserve service call data through coordinator data fetch (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(services): preserve service call data through coordinator data fetch In `_handle_find_block` and `_handle_find_hours`, the local `data` variable holding the resolved service call data was rebound to the coordinator data dict returned by `get_entry_and_data()`. As a result, the subsequent calls to `validate_search_params(data)`, `apply_must_finish_by(data, ...)` and `resolve_search_range(...)` read search-range parameters from coordinator data instead of from the service call, silently ignoring: - must_finish_by - search_scope - search_start, search_end - search_start_time, search_end_time - search_start_day_offset, search_end_day_offset - search_start_offset_minutes, search_end_offset_minutes - include_current_interval The functions fell back to the default range ("now → end of tomorrow") for every call that depended on these parameters. Rename the third return value of `get_entry_and_data()` to `coordinator_data` so the service call `data` survives, restoring deadline and search-scope semantics. `find_cheapest_schedule.py` already uses `data_dict` for the same purpose and was not affected. Verified locally against v0.31.0: a call with `must_finish_by: 2026-06-01T20:00:00+02:00` now correctly produces `search_end: 2026-06-01T20:00:00+02:00` (was end-of-tomorrow before). * refactor(services): update data handling in find_cheapest_schedule service Refactor the data retrieval process to use coordinator data instead of entry data for improved clarity and consistency. Impact: Enhances maintainability of the service code without altering user-facing functionality. --------- Co-authored-by: “WouterK” Co-authored-by: Julian Pawlowski --- .../services/find_cheapest_block.py | 7 +- .../services/find_cheapest_hours.py | 7 +- .../services/find_cheapest_schedule.py | 4 +- tests/services/test_find_service_responses.py | 104 ++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/custom_components/tibber_prices/services/find_cheapest_block.py b/custom_components/tibber_prices/services/find_cheapest_block.py index fc33470..9995202 100644 --- a/custom_components/tibber_prices/services/find_cheapest_block.py +++ b/custom_components/tibber_prices/services/find_cheapest_block.py @@ -267,8 +267,11 @@ async def _handle_find_block( # Round up to nearest quarter-hour interval duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES - entry, coordinator, data = get_entry_and_data(hass, entry_id) - rating_lookup = build_rating_lookup(data) + # Note: rebind to coordinator_data — `data` (the resolved service call + # data) is still needed below for validate_search_params() and + # apply_must_finish_by(), which read search-range parameters from it. + entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id) + rating_lookup = build_rating_lookup(coordinator_data) home_id = entry.data.get("home_id") if not home_id: diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index b585a35..7c888ad 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -329,8 +329,11 @@ async def _handle_find_hours( 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) + # Note: rebind to coordinator_data — `data` (the resolved service call + # data) is still needed below for validate_search_params() and + # apply_must_finish_by(), which read search-range parameters from it. + entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id) + rating_lookup = build_rating_lookup(coordinator_data) home_id = entry.data.get("home_id") if not home_id: diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index 9cb975f..3e9998d 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -406,8 +406,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: # Round gap up to nearest quarter interval gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0 - entry, coordinator, data = get_entry_and_data(hass, entry_id) - rating_lookup = build_rating_lookup(data) + entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id) + rating_lookup = build_rating_lookup(coordinator_data) home_id = entry.data.get("home_id") if not home_id: diff --git a/tests/services/test_find_service_responses.py b/tests/services/test_find_service_responses.py index a8ed246..75adf50 100644 --- a/tests/services/test_find_service_responses.py +++ b/tests/services/test_find_service_responses.py @@ -297,3 +297,107 @@ async def test_schedule_handler_adds_per_task_comparison_details(monkeypatch: py assert "comparison_price_min" in comparison assert "comparison_price_max" in comparison assert "comparison_window_end" in comparison + + +@pytest.mark.asyncio +async def test_block_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None: + """Block handler must pass resolved call data (not coordinator data) into search helpers.""" + intervals = _make_intervals([10.0, 11.0, 12.0, 13.0]) + fake_tuple = _build_fake_entry_and_coordinator(intervals) + deadline = datetime(2026, 1, 1, 8, 0, tzinfo=UTC) + fixed_start = datetime(2026, 1, 1, 0, 0, tzinfo=UTC) + + monkeypatch.setattr(block_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple) + monkeypatch.setattr(block_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC") + + def _validate_search_params(call_data: dict[str, Any]) -> None: + assert call_data["must_finish_by"] == deadline + assert call_data["include_current_interval"] is False + + def _apply_must_finish_by(call_data: dict[str, Any], _home_tz: Any) -> tuple[dict[str, Any], datetime]: + assert call_data["must_finish_by"] == deadline + modified = dict(call_data) + modified["search_end"] = deadline + modified.pop("must_finish_by", None) + return modified, deadline + + def _resolve_search_range(call_data: dict[str, Any], _now: datetime, _home_tz: Any) -> tuple[datetime, datetime]: + assert call_data["include_current_interval"] is False + assert call_data["search_end"] == deadline + return fixed_start, deadline + + async def _fetch_intervals(*_args: Any, **_kwargs: Any) -> tuple[list[dict[str, Any]], bool]: + return [], False + + monkeypatch.setattr(block_module, "validate_search_params", _validate_search_params) + monkeypatch.setattr(block_module, "apply_must_finish_by", _apply_must_finish_by) + monkeypatch.setattr(block_module, "resolve_search_range", _resolve_search_range) + monkeypatch.setattr(block_module, "async_fetch_service_intervals", _fetch_intervals) + + call = SimpleNamespace( + hass=object(), + data={ + "duration": timedelta(hours=1), + "use_base_unit": True, + "must_finish_by": deadline, + "include_current_interval": False, + }, + ) + + response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call))) + assert response["success"] is False + assert response["search_start"] == fixed_start.isoformat() + assert response["search_end"] == deadline.isoformat() + assert response["must_finish_by"] == deadline.isoformat() + + +@pytest.mark.asyncio +async def test_hours_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None: + """Hours handler must pass resolved call data (not coordinator data) into search helpers.""" + intervals = _make_intervals([10.0, 11.0, 12.0, 13.0]) + fake_tuple = _build_fake_entry_and_coordinator(intervals) + deadline = datetime(2026, 1, 1, 8, 0, tzinfo=UTC) + fixed_start = datetime(2026, 1, 1, 0, 0, tzinfo=UTC) + + monkeypatch.setattr(hours_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple) + monkeypatch.setattr(hours_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC") + + def _validate_search_params(call_data: dict[str, Any]) -> None: + assert call_data["must_finish_by"] == deadline + assert call_data["include_current_interval"] is False + + def _apply_must_finish_by(call_data: dict[str, Any], _home_tz: Any) -> tuple[dict[str, Any], datetime]: + assert call_data["must_finish_by"] == deadline + modified = dict(call_data) + modified["search_end"] = deadline + modified.pop("must_finish_by", None) + return modified, deadline + + def _resolve_search_range(call_data: dict[str, Any], _now: datetime, _home_tz: Any) -> tuple[datetime, datetime]: + assert call_data["include_current_interval"] is False + assert call_data["search_end"] == deadline + return fixed_start, deadline + + async def _fetch_intervals(*_args: Any, **_kwargs: Any) -> tuple[list[dict[str, Any]], bool]: + return [], False + + monkeypatch.setattr(hours_module, "validate_search_params", _validate_search_params) + monkeypatch.setattr(hours_module, "apply_must_finish_by", _apply_must_finish_by) + monkeypatch.setattr(hours_module, "resolve_search_range", _resolve_search_range) + monkeypatch.setattr(hours_module, "async_fetch_service_intervals", _fetch_intervals) + + call = SimpleNamespace( + hass=object(), + data={ + "duration": timedelta(hours=1), + "use_base_unit": True, + "must_finish_by": deadline, + "include_current_interval": False, + }, + ) + + response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call))) + assert response["success"] is False + assert response["search_start"] == fixed_start.isoformat() + assert response["search_end"] == deadline.isoformat() + assert response["must_finish_by"] == deadline.isoformat()