feat(services): isolate price-fetch failures and add success flag to responses

Add a track_degraded parameter to IntervalPool.get_intervals so service calls
(track_degraded=False) never flip the coordinator's sensor-health flags or use
the covers-current-interval cache fallback; authentication errors still
propagate. All price-fetching services now go through
async_fetch_service_intervals and return a structured response with a
top-level success flag: success=false with reason="price_data_unavailable" on
a real API outage, and success=true (possibly empty) otherwise.

Impact: Action calls (get_price, find_cheapest_*, plan_charging) always return
the expected shape, never impair sensors during a Tibber outage, and let
automations distinguish an outage from "no data yet".
This commit is contained in:
Julian Pawlowski 2026-05-30 14:46:23 +00:00
parent 8da3083fb2
commit feaf748cb6
7 changed files with 395 additions and 96 deletions

View file

@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from custom_components.tibber_prices.api.exceptions import ( from custom_components.tibber_prices.api.exceptions import (
TibberPricesApiClientCommunicationError, TibberPricesApiClientAuthenticationError,
TibberPricesApiClientError, TibberPricesApiClientError,
) )
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -118,6 +118,13 @@ class TibberPricesIntervalPool:
self._save_debounce_task: asyncio.Task | None = None self._save_debounce_task: asyncio.Task | None = None
self._save_lock = asyncio.Lock() 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. # DST fall-back extra intervals.
# On DST fall-back nights (e.g. last Sunday October in EU), wall-clock # 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). # 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", ...}], ...} # Structure: {"2026-10-25T02:00:00": [{"startsAt": "...+01:00", ...}], ...}
self._dst_extras: dict[str, list[dict[str, Any]]] = {} 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( async def get_intervals(
self, self,
api_client: TibberPricesApiClient, api_client: TibberPricesApiClient,
user_data: dict[str, Any], user_data: dict[str, Any],
start_time: datetime, start_time: datetime,
end_time: datetime, end_time: datetime,
*,
track_degraded: bool = True,
) -> tuple[list[dict[str, Any]], bool]: ) -> tuple[list[dict[str, Any]], bool]:
""" """
Get price intervals for time range (cached + fetch missing). Get price intervals for time range (cached + fetch missing).
@ -152,6 +171,14 @@ class TibberPricesIntervalPool:
user_data: User data dict containing home metadata. user_data: User data dict containing home metadata.
start_time: Start of range (inclusive, timezone-aware). start_time: Start of range (inclusive, timezone-aware).
end_time: End of range (exclusive, 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: Returns:
Tuple of (intervals, api_called): Tuple of (intervals, api_called):
@ -203,6 +230,7 @@ class TibberPricesIntervalPool:
# Fetch missing ranges from API # Fetch missing ranges from API
api_fetch_failed = False api_fetch_failed = False
fallback_intervals: list[dict[str, Any]] | None = None
if missing_ranges: if missing_ranges:
fetch_time_iso = dt_util.now().isoformat() fetch_time_iso = dt_util.now().isoformat()
@ -214,25 +242,71 @@ class TibberPricesIntervalPool:
missing_ranges=missing_ranges, missing_ranges=missing_ranges,
on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso), on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso),
) )
except TibberPricesApiClientCommunicationError as err: except TibberPricesApiClientAuthenticationError:
if cached_intervals: # Authentication errors must always propagate so the coordinator can
# Transient API error (e.g. 503) but we have cached data - use it as # trigger the reauth flow. Cached data is never a valid fallback here -
# fallback so the coordinator can finish initializing. The next regular # the user must provide a new token.
# update cycle will retry the API automatically. 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( _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, self._home_id,
err, err,
len(cached_intervals), len(available_intervals),
) )
api_fetch_failed = True 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: 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 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 # 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 # This ensures we return exactly what user requested, filtering out extra intervals.
final_result = self._get_cached_intervals(start_time_iso, end_time_iso) # 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) # 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" # If fetch failed but we fell back to cache, treat as "no API call succeeded"
@ -551,6 +625,58 @@ class TibberPricesIntervalPool:
return None 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( def _get_cached_intervals(
self, self,
start_time_iso: str, start_time_iso: str,

View file

@ -30,6 +30,7 @@ from .helpers import (
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by, apply_must_finish_by,
async_fetch_service_intervals,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
calculate_search_range_avg, calculate_search_range_avg,
@ -310,25 +311,42 @@ async def _handle_find_block(
user_data = coordinator._cached_user_data # noqa: SLF001 user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool pool = entry.runtime_data.interval_pool
try: price_info, fetch_ok = await async_fetch_service_intervals(
price_info, _api_called = await pool.get_intervals( pool,
api_client=api_client, api_client=api_client,
user_data=user_data, user_data=user_data,
start_time=search_start, start_time=search_start,
end_time=search_end, end_time=search_end,
service_label=service_label,
) )
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 # Determine currency and unit
currency = entry.data.get("currency", "EUR") currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) 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 --- # --- Attempt with original parameters ---
effective_duration = duration_intervals effective_duration = duration_intervals
result, reason = _attempt_find_block( result, reason = _attempt_find_block(
@ -393,6 +411,7 @@ async def _handle_find_block(
len(price_info), len(price_info),
) )
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.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())) seconds_until_end = max(0, int((end_time_dt - now).total_seconds()))
response = { response = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),

View file

@ -27,6 +27,7 @@ from .helpers import (
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by, apply_must_finish_by,
async_fetch_service_intervals,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
calculate_search_range_avg, calculate_search_range_avg,
@ -267,6 +268,7 @@ def _build_found_response(
) )
return { return {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
@ -382,25 +384,44 @@ async def _handle_find_hours(
user_data = coordinator._cached_user_data # noqa: SLF001 user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool pool = entry.runtime_data.interval_pool
try: price_info, fetch_ok = await async_fetch_service_intervals(
price_info, _api_called = await pool.get_intervals( pool,
api_client=api_client, api_client=api_client,
user_data=user_data, user_data=user_data,
start_time=search_start, start_time=search_start,
end_time=search_end, end_time=search_end,
service_label=service_label,
) )
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 # Determine currency and unit
currency = entry.data.get("currency", "EUR") currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) 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 --- # --- Attempt with original parameters ---
effective_total = total_intervals effective_total = total_intervals
result, reason = _attempt_find_hours( result, reason = _attempt_find_hours(
@ -467,6 +488,7 @@ async def _handle_find_hours(
len(price_info), len(price_info),
) )
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),

View file

@ -30,6 +30,7 @@ from .helpers import (
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by, apply_must_finish_by,
async_fetch_service_intervals,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
filter_intervals_by_price_level, 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 user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool pool = entry.runtime_data.interval_pool
try: price_info, fetch_ok = await async_fetch_service_intervals(
price_info, _api_called = await pool.get_intervals( pool,
api_client=api_client, api_client=api_client,
user_data=user_data, user_data=user_data,
start_time=search_start, start_time=search_start,
end_time=search_end, end_time=search_end,
service_label=service_label,
) )
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
currency = entry.data.get("currency", "EUR") currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry) 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) 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 --- # --- Attempt with original parameters ---
raw_assignments, unscheduled, filtered = _attempt_schedule( raw_assignments, unscheduled, filtered = _attempt_schedule(
price_info, 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())) task_entry["seconds_until_end"] = max(0, int((task_end_dt - now).total_seconds()))
result: dict[str, Any] = { result: dict[str, Any] = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),

View file

@ -25,7 +25,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references from .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: if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
@ -150,31 +150,41 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
end_time, end_time,
) )
try:
# Get interval pool from entry runtime_data (one pool per config entry) # Get interval pool from entry runtime_data (one pool per config entry)
pool = entry.runtime_data.interval_pool pool = entry.runtime_data.interval_pool
# Call the interval pool to get intervals (with intelligent caching) # Resilient fetch: never impairs sensors, returns empty result on API failure
# Single-home architecture: pool knows its home_id, no parameter needed # instead of raising. Single-home architecture: pool knows its home_id.
price_info, _api_called = await pool.get_intervals( price_info, fetch_ok = await async_fetch_service_intervals(
pool,
api_client=api_client, api_client=api_client,
user_data=user_data, user_data=user_data,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
service_label="get_price",
) )
# 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
except Exception as error: if not fetch_ok:
_LOGGER.exception("Error fetching price data") # Price data could not be fetched (API outage on an uncached range). Return a
raise ServiceValidationError( # well-formed empty response with success=False so automations can detect this
translation_domain=DOMAIN, # without inspecting the data fields.
translation_key="price_fetch_failed", response = {
) from error "success": False,
"reason": "price_data_unavailable",
"home_id": home_id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"interval_count": 0,
"price_info": [],
}
if resolved_refs:
response["_resolved"] = resolved_refs
_LOGGER.info("get_price service completed: price data unavailable")
return response
else:
# Add metadata to response # Add metadata to response
response = { response = {
"success": True,
"home_id": home_id, "home_id": home_id,
"start_time": start_time.isoformat(), "start_time": start_time.isoformat(),
"end_time": end_time.isoformat(), "end_time": end_time.isoformat(),

View file

@ -31,8 +31,13 @@ Used by:
from __future__ import annotations from __future__ import annotations
from datetime import datetime, time as dt_time, timedelta from datetime import datetime, time as dt_time, timedelta
import logging
from typing import TYPE_CHECKING, Any 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.const import DOMAIN
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
@ -42,8 +47,11 @@ if TYPE_CHECKING:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from custom_components.tibber_prices.interval_pool import TibberPricesIntervalPool
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
# Interval duration in minutes (quarter-hourly resolution) # Interval duration in minutes (quarter-hourly resolution)
INTERVAL_MINUTES = 15 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 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: def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
""" """
Check if tomorrow's price data is available in coordinator. Check if tomorrow's price data is available in coordinator.

View file

@ -39,6 +39,7 @@ from .helpers import (
PRICE_LEVEL_ORDER, PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES, VALID_SEARCH_SCOPES,
apply_must_finish_by, apply_must_finish_by,
async_fetch_service_intervals,
build_rating_lookup, build_rating_lookup,
build_response_interval, build_response_interval,
calculate_search_range_avg, calculate_search_range_avg,
@ -660,6 +661,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse:
currency = entry.data.get("currency", "EUR") currency = entry.data.get("currency", "EUR")
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency) price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True,
"home_id": entry.data.get("home_id", ""), "home_id": entry.data.get("home_id", ""),
"intervals_found": False, "intervals_found": False,
"reason": "already_at_target", "reason": "already_at_target",
@ -742,19 +744,45 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse:
api_client = coordinator.api api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001 user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool pool = entry.runtime_data.interval_pool
try: price_info, fetch_ok = await async_fetch_service_intervals(
price_info, _api_called = await pool.get_intervals( pool,
api_client=api_client, api_client=api_client,
user_data=user_data, user_data=user_data,
start_time=search_start, start_time=search_start,
end_time=search_end, end_time=search_end,
service_label=PLAN_CHARGING_SERVICE_NAME,
) )
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", PLAN_CHARGING_SERVICE_NAME) if not fetch_ok:
raise ServiceValidationError( # Price data unavailable (API outage on uncached range). Return a well-formed
translation_domain=DOMAIN, # empty response with success=False so automations can detect this directly.
translation_key="price_fetch_failed", unavailable: dict[str, Any] = {
) from error "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( plan_ctx = _PlanContext(
price_info=price_info, price_info=price_info,
@ -824,6 +852,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse:
if planning_result is None: if planning_result is None:
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),
@ -930,6 +959,7 @@ async def handle_plan_charging(call: ServiceCall) -> ServiceResponse:
) )
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True,
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
"search_end": search_end.isoformat(), "search_end": search_end.isoformat(),