diff --git a/custom_components/tibber_prices/interval_pool/manager.py b/custom_components/tibber_prices/interval_pool/manager.py index c13a7a5..0eac164 100644 --- a/custom_components/tibber_prices/interval_pool/manager.py +++ b/custom_components/tibber_prices/interval_pool/manager.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any from zoneinfo import ZoneInfo from custom_components.tibber_prices.api.exceptions import ( - TibberPricesApiClientCommunicationError, + TibberPricesApiClientAuthenticationError, TibberPricesApiClientError, ) from homeassistant.util import dt as dt_util @@ -118,6 +118,13 @@ class TibberPricesIntervalPool: self._save_debounce_task: asyncio.Task | None = None self._save_lock = asyncio.Lock() + # Degraded-mode tracking (cache fallback during API outage). + # When a fetch fails but cached data still covers the current interval, + # the pool keeps serving cached data and flags itself as degraded so the + # coordinator can expose this on the connection sensor. Reset on success. + self._last_fetch_degraded = False + self._last_fetch_error: str | None = None + # DST fall-back extra intervals. # On DST fall-back nights (e.g. last Sunday October in EU), wall-clock # 02:00-02:45 occurs twice: once in CEST (+02:00) and once in CET (+01:00). @@ -127,12 +134,24 @@ class TibberPricesIntervalPool: # Structure: {"2026-10-25T02:00:00": [{"startsAt": "...+01:00", ...}], ...} self._dst_extras: dict[str, list[dict[str, Any]]] = {} + @property + def last_fetch_degraded(self) -> bool: + """Return True if the most recent API fetch fell back to cached data.""" + return self._last_fetch_degraded + + @property + def last_fetch_error(self) -> str | None: + """Return the error message of the most recent degraded fetch, if any.""" + return self._last_fetch_error + async def get_intervals( self, api_client: TibberPricesApiClient, user_data: dict[str, Any], start_time: datetime, end_time: datetime, + *, + track_degraded: bool = True, ) -> tuple[list[dict[str, Any]], bool]: """ Get price intervals for time range (cached + fetch missing). @@ -152,6 +171,14 @@ class TibberPricesIntervalPool: user_data: User data dict containing home metadata. start_time: Start of range (inclusive, timezone-aware). end_time: End of range (exclusive, timezone-aware). + track_degraded: When True (default), API failures update the pool's + degraded-state flags (last_fetch_degraded/last_fetch_error) which + govern sensor availability, and the cache-fallback path is used to + keep sensors alive. This is the SENSOR path (get_sensor_data). + When False, the call is a SERVICE request: it must never touch + sensor-health state. Any API error is propagated unchanged so the + service handler can return a structured "data unavailable" response + without impairing sensors. Returns: Tuple of (intervals, api_called): @@ -203,6 +230,7 @@ class TibberPricesIntervalPool: # Fetch missing ranges from API api_fetch_failed = False + fallback_intervals: list[dict[str, Any]] | None = None if missing_ranges: fetch_time_iso = dt_util.now().isoformat() @@ -214,25 +242,71 @@ class TibberPricesIntervalPool: missing_ranges=missing_ranges, on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso), ) - except TibberPricesApiClientCommunicationError as err: - if cached_intervals: - # Transient API error (e.g. 503) but we have cached data - use it as - # fallback so the coordinator can finish initializing. The next regular - # update cycle will retry the API automatically. + except TibberPricesApiClientAuthenticationError: + # Authentication errors must always propagate so the coordinator can + # trigger the reauth flow. Cached data is never a valid fallback here - + # the user must provide a new token. + raise + except TibberPricesApiClientError as err: + # Any other API error (transient communication failure like 503/timeout, + # rate limiting, or an "empty data" response during a Tibber outage). + if not track_degraded: + # Service request: never mutate sensor-health degraded state and never + # silently serve a partial cache. Propagate so the service handler can + # return a structured "data unavailable" response. Sensor availability + # is governed solely by the regular coordinator update (get_sensor_data). + raise + # + # Resilience strategy: As long as the cache still covers the CURRENT + # interval, keep serving locally cached data so the integration keeps + # working benevolently through temporary outages. The next regular update + # cycle will retry the API automatically. + # + # Re-read the cache because the fetcher may have partially cached some + # ranges before failing. + available_intervals = self._get_cached_intervals(start_time_iso, end_time_iso) + if self._covers_current_interval(available_intervals): _LOGGER.warning( - "API temporarily unavailable for home %s (%s) - using %d cached intervals as fallback", + "Tibber API temporarily unavailable for home %s (%s) - continuing with " + "%d cached intervals that still cover the current interval. " + "Will retry on next update cycle", self._home_id, err, - len(cached_intervals), + len(available_intervals), ) api_fetch_failed = True + fallback_intervals = available_intervals + # Flag degraded mode so the coordinator/connection sensor can surface it. + self._last_fetch_degraded = True + self._last_fetch_error = str(err) else: - # No cached data at all - re-raise so the caller can decide + # No cached data for the current interval - we cannot serve valid + # prices. Re-raise so the caller can mark the entry as not-ready + # (fresh setup) or raise UpdateFailed (running integration). + _LOGGER.error( + "Tibber API unavailable for home %s (%s) and no cached data covers the " + "current interval - cannot provide price data", + self._home_id, + err, + ) raise + else: + # Fetch succeeded - clear any previous degraded state (sensor path only). + # Service requests (track_degraded=False) must not clear a real sensor + # degradation, otherwise a successful service fetch could mask it. + if track_degraded: + self._last_fetch_degraded = False + self._last_fetch_error = None - # After caching all API responses, read from cache again to get final result - # This ensures we return exactly what user requested, filtering out extra intervals - final_result = self._get_cached_intervals(start_time_iso, end_time_iso) + # After caching all API responses, read from cache again to get final result. + # This ensures we return exactly what user requested, filtering out extra intervals. + # When we fell back to cache during an outage, reuse the already-read intervals + # instead of querying the cache a second time. + final_result = ( + fallback_intervals + if fallback_intervals is not None + else self._get_cached_intervals(start_time_iso, end_time_iso) + ) # Track if API was called (True if any missing ranges were attempted) # If fetch failed but we fell back to cache, treat as "no API call succeeded" @@ -551,6 +625,58 @@ class TibberPricesIntervalPool: return None + def _covers_current_interval(self, intervals: list[dict[str, Any]]) -> bool: + """ + Return True if the cached intervals cover the current moment. + + This is the resilience threshold used during Tibber API outages: as long + as we still have a price interval for "now", the integration can keep + serving sensors from locally cached data instead of failing to load. + Only when the current interval is missing do we treat the cache as + unusable and let the caller raise (alarm). + + Args: + intervals: Cached interval dicts (each with a "startsAt" field). + + Returns: + True if an interval covering the current time exists, False otherwise. + + """ + if not intervals: + return False + + now = self._time_service.now() if self._time_service else dt_util.now() + + # Collect interval start datetimes (startsAt may be ISO string or datetime) + starts: list[datetime] = [] + for interval in intervals: + starts_at = interval.get("startsAt") + if starts_at is None: + continue + start_dt = starts_at if isinstance(starts_at, datetime) else dt_util.parse_datetime(starts_at) + if start_dt is not None: + starts.append(start_dt) + + if not starts: + return False + + starts.sort() + + # An interval covers "now" if start <= now < start + length. The length is + # derived from the gap to the next interval, capped at one hour so a large + # gap (missing data) cannot falsely report coverage. This handles both + # quarter-hourly (15 min) and hourly (60 min) resolutions. + max_length = timedelta(minutes=INTERVAL_HOURLY) + for index, start_dt in enumerate(starts): + if index + 1 < len(starts): + length = min(starts[index + 1] - start_dt, max_length) + else: + length = max_length + if start_dt <= now < start_dt + length: + return True + + return False + def _get_cached_intervals( self, start_time_iso: str, diff --git a/custom_components/tibber_prices/services/find_cheapest_block.py b/custom_components/tibber_prices/services/find_cheapest_block.py index 42d4a00..fc33470 100644 --- a/custom_components/tibber_prices/services/find_cheapest_block.py +++ b/custom_components/tibber_prices/services/find_cheapest_block.py @@ -30,6 +30,7 @@ from .helpers import ( PRICE_LEVEL_ORDER, VALID_SEARCH_SCOPES, apply_must_finish_by, + async_fetch_service_intervals, build_rating_lookup, build_response_interval, calculate_search_range_avg, @@ -310,25 +311,42 @@ async def _handle_find_block( 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 + price_info, fetch_ok = await async_fetch_service_intervals( + pool, + api_client=api_client, + user_data=user_data, + start_time=search_start, + end_time=search_end, + service_label=service_label, + ) # 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) + if not fetch_ok: + # Price data unavailable (API outage on uncached range). Return a well-formed + # empty response with success=False so automations can detect this directly. + unavailable: dict[str, Any] = { + "success": False, + "reason": "price_data_unavailable", + "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, + "duration_minutes_requested": duration_minutes_requested, + "duration_minutes": duration_minutes_requested, + "currency": currency, + "price_unit": price_unit, + "window_found": False, + "relaxation_applied": False, + "window": None, + } + if resolved_refs: + unavailable["_resolved"] = resolved_refs + return unavailable + # --- Attempt with original parameters --- effective_duration = duration_intervals result, reason = _attempt_find_block( @@ -393,6 +411,7 @@ async def _handle_find_block( len(price_info), ) response: dict[str, Any] = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), @@ -452,6 +471,7 @@ async def _handle_find_block( seconds_until_end = max(0, int((end_time_dt - now).total_seconds())) response = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index 1a41998..b585a35 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -27,6 +27,7 @@ from .helpers import ( PRICE_LEVEL_ORDER, VALID_SEARCH_SCOPES, apply_must_finish_by, + async_fetch_service_intervals, build_rating_lookup, build_response_interval, calculate_search_range_avg, @@ -267,6 +268,7 @@ def _build_found_response( ) return { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), @@ -382,25 +384,44 @@ async def _handle_find_hours( 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 + price_info, fetch_ok = await async_fetch_service_intervals( + pool, + api_client=api_client, + user_data=user_data, + start_time=search_start, + end_time=search_end, + service_label=service_label, + ) # 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) + if not fetch_ok: + # Price data unavailable (API outage on uncached range). Return a well-formed + # empty response with success=False so automations can detect this directly. + unavailable: dict[str, Any] = { + "success": False, + "reason": "price_data_unavailable", + "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": total_minutes_requested, + "min_segment_minutes_requested": min_segment_minutes_requested, + "min_segment_minutes": min_segment_minutes, + "currency": currency, + "price_unit": price_unit, + "intervals_found": False, + "relaxation_applied": False, + "schedule": None, + } + if resolved_refs: + unavailable["_resolved"] = resolved_refs + return unavailable + # --- Attempt with original parameters --- effective_total = total_intervals result, reason = _attempt_find_hours( @@ -467,6 +488,7 @@ async def _handle_find_hours( len(price_info), ) response: dict[str, Any] = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index d105d16..9cb975f 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -30,6 +30,7 @@ from .helpers import ( PRICE_LEVEL_ORDER, VALID_SEARCH_SCOPES, apply_must_finish_by, + async_fetch_service_intervals, build_rating_lookup, build_response_interval, filter_intervals_by_price_level, @@ -479,24 +480,42 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: 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 + price_info, fetch_ok = await async_fetch_service_intervals( + pool, + api_client=api_client, + user_data=user_data, + start_time=search_start, + end_time=search_end, + service_label=service_label, + ) 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) + if not fetch_ok: + # Price data unavailable (API outage on uncached range). Return a well-formed + # empty response with success=False so automations can detect this directly. + unavailable: dict[str, Any] = { + "success": False, + "reason": "price_data_unavailable", + "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, + "currency": currency, + "price_unit": price_unit, + "sequential": sequential, + "all_tasks_scheduled": False, + "relaxation_applied": False, + "unscheduled_tasks": None, + "tasks": [], + "total_estimated_cost": None, + } + if resolved_refs: + unavailable["_resolved"] = resolved_refs + return unavailable + # --- Attempt with original parameters --- raw_assignments, unscheduled, filtered = _attempt_schedule( price_info, @@ -676,6 +695,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: task_entry["seconds_until_end"] = max(0, int((task_end_dt - now).total_seconds())) result: dict[str, Any] = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), diff --git a/custom_components/tibber_prices/services/get_price.py b/custom_components/tibber_prices/services/get_price.py index 1b30a74..1d2aad1 100644 --- a/custom_components/tibber_prices/services/get_price.py +++ b/custom_components/tibber_prices/services/get_price.py @@ -25,7 +25,7 @@ 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 +from .helpers import async_fetch_service_intervals, get_entry_and_data if TYPE_CHECKING: from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse @@ -150,44 +150,54 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse: end_time, ) - try: - # Get interval pool from entry runtime_data (one pool per config entry) - pool = entry.runtime_data.interval_pool + # Get interval pool from entry runtime_data (one pool per config entry) + pool = entry.runtime_data.interval_pool - # Call the interval pool to get intervals (with intelligent caching) - # Single-home architecture: pool knows its home_id, no parameter needed - price_info, _api_called = await pool.get_intervals( - api_client=api_client, - user_data=user_data, - start_time=start_time, - end_time=end_time, - ) - # Note: We ignore api_called flag here - service always returns requested data - # regardless of whether it came from cache or was fetched fresh from API + # Resilient fetch: never impairs sensors, returns empty result on API failure + # instead of raising. Single-home architecture: pool knows its home_id. + price_info, fetch_ok = await async_fetch_service_intervals( + pool, + api_client=api_client, + user_data=user_data, + start_time=start_time, + end_time=end_time, + service_label="get_price", + ) - except Exception as error: - _LOGGER.exception("Error fetching price data") - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="price_fetch_failed", - ) from error - - else: - # Add metadata to response + if not fetch_ok: + # Price data could not be fetched (API outage on an uncached range). Return a + # well-formed empty response with success=False so automations can detect this + # without inspecting the data fields. response = { + "success": False, + "reason": "price_data_unavailable", "home_id": home_id, "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), - "interval_count": len(price_info), - "price_info": price_info, + "interval_count": 0, + "price_info": [], } - - _LOGGER.info( - "get_price service completed: fetched %d intervals", - len(price_info), - ) - if resolved_refs: response["_resolved"] = resolved_refs - + _LOGGER.info("get_price service completed: price data unavailable") return response + + # Add metadata to response + response = { + "success": True, + "home_id": home_id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "interval_count": len(price_info), + "price_info": price_info, + } + + _LOGGER.info( + "get_price service completed: fetched %d intervals", + len(price_info), + ) + + if resolved_refs: + response["_resolved"] = resolved_refs + + return response diff --git a/custom_components/tibber_prices/services/helpers.py b/custom_components/tibber_prices/services/helpers.py index c49e546..44f0c9c 100644 --- a/custom_components/tibber_prices/services/helpers.py +++ b/custom_components/tibber_prices/services/helpers.py @@ -31,8 +31,13 @@ Used by: from __future__ import annotations from datetime import datetime, time as dt_time, timedelta +import logging from typing import TYPE_CHECKING, Any +from custom_components.tibber_prices.api.exceptions import ( + TibberPricesApiClientAuthenticationError, + TibberPricesApiClientError, +) from custom_components.tibber_prices.const import DOMAIN from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from homeassistant.exceptions import ServiceValidationError @@ -42,8 +47,11 @@ if TYPE_CHECKING: from zoneinfo import ZoneInfo from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator + from custom_components.tibber_prices.interval_pool import TibberPricesIntervalPool from homeassistant.core import HomeAssistant +_LOGGER = logging.getLogger(__name__) + # Interval duration in minutes (quarter-hourly resolution) INTERVAL_MINUTES = 15 @@ -259,6 +267,69 @@ def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, di return entry, coordinator, data +async def async_fetch_service_intervals( + pool: TibberPricesIntervalPool, + *, + api_client: Any, + user_data: dict[str, Any] | None, + start_time: datetime, + end_time: datetime, + service_label: str, +) -> tuple[list[dict[str, Any]], bool]: + """ + Fetch price intervals for a service request resiliently. + + Shared, resilient wrapper around the interval pool used by every price-query + service. It guarantees two things the raw pool call does not: + + 1. Sensor isolation: passes track_degraded=False so a service request can never + flip the pool's degraded-state flags (which govern sensor availability). A + failing or succeeding service fetch therefore has zero effect on sensor health. + 2. Graceful failure: on a transient API/data error the helper returns + ([], False) instead of raising, so the service can still return a well-formed + response (possibly with empty contents) for automations to consume. + + Authentication errors are re-raised unchanged so the coordinator can trigger the + reauth flow. + + Args: + pool: The config entry's interval pool. + api_client: TibberPricesApiClient instance. + user_data: Cached user data dict (may be None during early setup). + start_time: Start of range (inclusive, timezone-aware). + end_time: End of range (exclusive, timezone-aware). + service_label: Human-readable service name for logging. + + Returns: + Tuple of (intervals, fetch_ok): + - intervals: Price intervals for the range. Empty list on failure. + - fetch_ok: True if the data was available, False if an API error + prevented fetching the requested (uncached) range. + + Raises: + TibberPricesApiClientAuthenticationError: If authentication failed. + + """ + try: + intervals, _api_called = await pool.get_intervals( + api_client=api_client, + user_data=user_data or {}, + start_time=start_time, + end_time=end_time, + track_degraded=False, + ) + except TibberPricesApiClientAuthenticationError: + raise + except TibberPricesApiClientError as err: + _LOGGER.warning( + "%s: price data unavailable (%s) - returning empty result so sensors stay unaffected", + service_label, + err, + ) + return [], False + return intervals, True + + def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool: """ Check if tomorrow's price data is available in coordinator. diff --git a/custom_components/tibber_prices/services/plan_charging.py b/custom_components/tibber_prices/services/plan_charging.py index e9fecd2..39112ba 100644 --- a/custom_components/tibber_prices/services/plan_charging.py +++ b/custom_components/tibber_prices/services/plan_charging.py @@ -39,6 +39,7 @@ from .helpers import ( PRICE_LEVEL_ORDER, VALID_SEARCH_SCOPES, apply_must_finish_by, + async_fetch_service_intervals, build_rating_lookup, build_response_interval, calculate_search_range_avg, @@ -660,6 +661,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse: currency = entry.data.get("currency", "EUR") price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency) response: dict[str, Any] = { + "success": True, "home_id": entry.data.get("home_id", ""), "intervals_found": False, "reason": "already_at_target", @@ -742,19 +744,45 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse: 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", PLAN_CHARGING_SERVICE_NAME) - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="price_fetch_failed", - ) from error + price_info, fetch_ok = await async_fetch_service_intervals( + pool, + api_client=api_client, + user_data=user_data, + start_time=search_start, + end_time=search_end, + service_label=PLAN_CHARGING_SERVICE_NAME, + ) + + if not fetch_ok: + # Price data unavailable (API outage on uncached range). Return a well-formed + # empty response with success=False so automations can detect this directly. + unavailable: dict[str, Any] = { + "success": False, + "reason": "price_data_unavailable", + "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, + "intervals_found": False, + "battery": _build_battery_info( + current_soc_kwh=current_soc_kwh, + target_soc_kwh=target_soc_kwh, + capacity_kwh=capacity_kwh, + requested_energy_needed_kwh=requested_energy_needed_grid_kwh, + charging_efficiency=charging_efficiency, + achieved_soc_kwh=current_soc_kwh, + must_reach_soc_kwh=must_reach_soc_kwh, + ), + "charging": None, + "deadline": {"must_reach_by": deadline.isoformat(), "source": deadline_source} if deadline else None, + "economics": None, + "currency": currency, + "price_unit": price_unit, + "relaxation_applied": False, + } + if resolved_refs: + unavailable["_resolved"] = resolved_refs + return unavailable plan_ctx = _PlanContext( price_info=price_info, @@ -824,6 +852,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse: if planning_result is None: response: dict[str, Any] = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(), @@ -930,6 +959,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse: ) response: dict[str, Any] = { + "success": True, "home_id": home_id, "search_start": search_start.isoformat(), "search_end": search_end.isoformat(),