mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
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:
parent
8da3083fb2
commit
feaf748cb6
7 changed files with 395 additions and 96 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,44 +150,54 @@ 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(
|
||||||
api_client=api_client,
|
pool,
|
||||||
user_data=user_data,
|
api_client=api_client,
|
||||||
start_time=start_time,
|
user_data=user_data,
|
||||||
end_time=end_time,
|
start_time=start_time,
|
||||||
)
|
end_time=end_time,
|
||||||
# Note: We ignore api_called flag here - service always returns requested data
|
service_label="get_price",
|
||||||
# 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",
|
|
||||||
) from error
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Add metadata to response
|
|
||||||
response = {
|
response = {
|
||||||
|
"success": False,
|
||||||
|
"reason": "price_data_unavailable",
|
||||||
"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(),
|
||||||
"interval_count": len(price_info),
|
"interval_count": 0,
|
||||||
"price_info": price_info,
|
"price_info": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
"get_price service completed: fetched %d intervals",
|
|
||||||
len(price_info),
|
|
||||||
)
|
|
||||||
|
|
||||||
if resolved_refs:
|
if resolved_refs:
|
||||||
response["_resolved"] = resolved_refs
|
response["_resolved"] = resolved_refs
|
||||||
|
_LOGGER.info("get_price service completed: price data unavailable")
|
||||||
return response
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
raise ServiceValidationError(
|
if not fetch_ok:
|
||||||
translation_domain=DOMAIN,
|
# Price data unavailable (API outage on uncached range). Return a well-formed
|
||||||
translation_key="price_fetch_failed",
|
# empty response with success=False so automations can detect this directly.
|
||||||
) from error
|
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(
|
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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue