feat(coordinator): surface prolonged API outages and cache-fallback state

Track API failures over time in the repair manager and raise an api_outage
repair issue once fresh data has been missing for longer than two hours,
independent of the update interval. A genuine successful fetch clears it;
serving cached data as a fallback keeps the outage active. Expose the degraded
cache-fallback state via the connection binary sensor (using_cached_data,
last_successful_update) so users can tell when sensors run on locally cached
prices. Add the api_outage repair strings to all supported languages.

Impact: Users get a clear repair notice during prolonged Tibber outages and
can see when the integration is running on cached data.
This commit is contained in:
Julian Pawlowski 2026-05-30 14:46:32 +00:00
parent feaf748cb6
commit ada4e33448
8 changed files with 157 additions and 0 deletions

View file

@ -50,6 +50,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
# Frequently Changing Diagnostics
"icon_color",
"data_status",
# Connection diagnostics (time-bound, not useful in long-term history)
"last_successful_update",
# Static/Rarely Changing
"level_value",
"rating_value",
@ -317,11 +319,28 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
if key == "tomorrow_data_available":
return self._get_tomorrow_data_available_attributes()
if key == "connection":
return self._get_connection_attributes()
if key in ("in_rising_price_phase", "in_falling_price_phase", "in_flat_price_phase"):
return get_phase_attributes(self.coordinator.data, time=self.coordinator.time)
return None
def _get_connection_attributes(self) -> dict | None:
"""
Build attributes for the connection sensor.
Distinguishes a healthy connection from degraded cache-fallback operation,
so users can tell when the integration is running on locally cached data
during a temporary Tibber API outage.
"""
last_update = self.coordinator.last_successful_update
return {
"using_cached_data": self.coordinator.using_cached_fallback,
"last_successful_update": last_update.isoformat() if last_update else None,
}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""

View file

@ -246,6 +246,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._is_fetching: bool = False # Flag to track active API fetch (read by lifecycle sensor)
self._last_coordinator_update: datetime | None = None # When Timer #1 last ran (_async_update_data)
# Degraded-mode tracking (cache fallback during API outage).
# True when the last update served cached data because the API fetch failed
# but cached data still covered the current interval. Exposed via connection sensor.
self._using_cached_fallback: bool = False
# Runtime config overrides from config entities (number/switch)
# Structure: {"section_name": {"config_key": value, ...}, ...}
# When set, these override the corresponding options from config_entry.options
@ -260,6 +265,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
prefixed_message = f"{self._log_prefix} {message}"
getattr(_LOGGER, level)(prefixed_message, *args, **kwargs)
@property
def using_cached_fallback(self) -> bool:
"""Return True if the last update served cached data due to an API failure."""
return self._using_cached_fallback
@property
def last_successful_update(self) -> datetime | None:
"""Return when price data was last successfully fetched from the API."""
return self._last_price_update
async def _handle_options_update(self, _hass: HomeAssistant, _config_entry: ConfigEntry) -> None:
"""Handle options update by invalidating config caches and re-transforming data."""
self._log("debug", "Options update triggered, re-transforming data")
@ -774,6 +789,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Track rate limit errors for repair system
await self._track_rate_limit_error(err)
# Track the API outage for the time-based outage repair.
# This branch means the IntervalPool could NOT serve cached data either
# (no fallback possible), so this is a genuine, user-impacting failure.
await self._repair_manager.track_api_failure(current_time)
# Handle API error - will re-raise as ConfigEntryAuthFailed or UpdateFailed
# Note: With IntervalPool, there's no local cache fallback here.
# The Pool has its own persistence for offline recovery.
@ -830,6 +850,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 3. Clear rate limit tracking on successful API call
await self._repair_manager.clear_rate_limit_tracking()
# 4. Update degraded-mode flag and outage tracking.
# The IntervalPool flags itself as degraded when it served cached data because
# a fetch failed. In that case the outage is still ongoing (we just cushioned
# it with cache), so we keep tracking it for the time-based outage repair.
# Only a genuine fresh fetch clears the outage.
degraded = self.interval_pool.last_fetch_degraded
self._using_cached_fallback = degraded
if degraded:
await self._repair_manager.track_api_failure(current_time)
else:
await self._repair_manager.clear_api_failure_tracking()
async def load_cache(self) -> None:
"""Load cached user data from storage (price data is in IntervalPool)."""
await self._price_data_manager.load_cache()

View file

@ -8,10 +8,12 @@ Repair Types:
1. Tomorrow Data Missing - Warns when tomorrow's price data is unavailable after 18:00
2. Persistent Rate Limits - Warns when API rate limiting persists after multiple errors
3. Home Not Found - Warns when a home no longer exists in the Tibber account
4. API Outage - Warns when the API has been unreachable for a prolonged period (time-based)
"""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
@ -29,6 +31,14 @@ _LOGGER = logging.getLogger(__name__)
TOMORROW_DATA_WARNING_HOUR = 18 # Warn after 18:00 if tomorrow data missing
RATE_LIMIT_WARNING_THRESHOLD = 3 # Warn after 3 consecutive rate limit errors
# How long the integration silently cushions an API outage (serving cached data
# or hard-failing) before surfacing a repair issue. The cushioning itself is
# unbounded: as long as cached data covers the current interval, sensors keep
# working across every update cycle. This delay only governs WHEN we inform the
# user about a prolonged outage, independent of the update interval or how often
# the API was retried.
OUTAGE_REPAIR_DELAY = timedelta(hours=2)
class TibberPricesRepairManager:
"""Manage repair issues for Tibber Prices integration."""
@ -50,10 +60,15 @@ class TibberPricesRepairManager:
# Track consecutive rate limit errors
self._rate_limit_error_count = 0
# Track when an ongoing API outage started (None = no active outage).
# Set on the first failed/degraded update, cleared on a genuine success.
self._outage_since: datetime | None = None
# Track if repairs are currently active
self._tomorrow_data_repair_active = False
self._rate_limit_repair_active = False
self._home_not_found_repair_active = False
self._outage_repair_active = False
async def check_tomorrow_data_availability(
self,
@ -105,6 +120,41 @@ class TibberPricesRepairManager:
if self._rate_limit_repair_active:
await self._clear_rate_limit_repair()
async def track_api_failure(self, current_time: datetime) -> None:
"""
Track an ongoing API outage and surface a repair after a prolonged period.
Call this on every update cycle where the integration could NOT fetch fresh
data - whether it served cached data as a fallback (degraded) or failed
outright (no cache). The first such call records the outage start time; once
the outage has lasted longer than ``OUTAGE_REPAIR_DELAY`` a repair issue is
created. This is time-based (not retry/cycle-count based) so it reflects the
real outage duration regardless of the update interval.
Args:
current_time: Current time of this update cycle.
"""
if self._outage_since is None:
self._outage_since = current_time
outage_duration = current_time - self._outage_since
if outage_duration >= OUTAGE_REPAIR_DELAY and not self._outage_repair_active:
await self._create_outage_repair()
async def clear_api_failure_tracking(self) -> None:
"""
Clear outage tracking after a genuinely successful API fetch.
Resets the outage start time and clears any active outage repair. Call this
only when fresh data was actually received (NOT when serving cached data as
a fallback, which still counts as an ongoing outage).
"""
self._outage_since = None
if self._outage_repair_active:
await self._clear_outage_repair()
async def create_home_not_found_repair(self) -> None:
"""
Create repair for home no longer found in Tibber account.
@ -160,6 +210,8 @@ class TibberPricesRepairManager:
await self._clear_rate_limit_repair()
if self._home_not_found_repair_active:
await self.clear_home_not_found_repair()
if self._outage_repair_active:
await self._clear_outage_repair()
async def _create_tomorrow_data_repair(self) -> None:
"""Create repair issue for missing tomorrow data."""
@ -226,3 +278,37 @@ class TibberPricesRepairManager:
f"rate_limit_exceeded_{self._entry_id}",
)
self._rate_limit_repair_active = False
async def _create_outage_repair(self) -> None:
"""Create repair issue for a prolonged API outage."""
since = self._outage_since.isoformat(timespec="minutes") if self._outage_since else "unknown"
_LOGGER.warning(
"Prolonged Tibber API outage for home '%s' (no fresh data since %s) - creating repair issue",
self._home_name,
since,
)
ir.async_create_issue(
self._hass,
DOMAIN,
f"api_outage_{self._entry_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="api_outage",
translation_placeholders={
"home_name": self._home_name,
"since": since,
},
)
self._outage_repair_active = True
async def _clear_outage_repair(self) -> None:
"""Clear API outage repair issue."""
_LOGGER.debug("Tibber API reachable again for '%s' - clearing outage repair issue", self._home_name)
ir.async_delete_issue(
self._hass,
DOMAIN,
f"api_outage_{self._entry_id}",
)
self._outage_repair_active = False

View file

@ -1201,6 +1201,10 @@
"title": "API-Ratenlimit erreicht für {home_name}",
"description": "Die Tibber-API hat diese Integration nach {error_count} aufeinanderfolgenden Fehlern ratenlimitiert. Das bedeutet, dass Anfragen zu häufig gestellt werden.\n\nDie Integration wird automatisch mit zunehmenden Verzögerungen erneut versuchen. Dieses Problem löst sich, sobald das Ratenlimit abläuft.\n\nFalls dies mehrere Stunden anhält, überprüfe:\n- Ob mehrere Home Assistant Instanzen denselben API-Token verwenden\n- Ob andere Anwendungen deinen Tibber-API-Token stark nutzen\n- Die Update-Frequenz reduzieren, falls du sie angepasst hast"
},
"api_outage": {
"title": "Tibber-API nicht erreichbar für {home_name}",
"description": "Seit {since} wurden keine aktuellen Preisdaten mehr von Tibber empfangen. Die Integration verwendet weiterhin lokal zwischengespeicherte Preise, sofern verfügbar, sodass deine Sensoren möglicherweise weiterarbeiten - sie können aber unverfügbar werden, wenn der Ausfall andauert (z. B. könnten die Preise für morgen fehlen).\n\nUrsache ist meist ein vorübergehender Tibber-Ausfall oder ein Netzwerkproblem. Die Integration versucht es bei jedem Update-Zyklus automatisch erneut und löst dieses Problem, sobald wieder frische Daten eintreffen.\n\nFalls dies anhält, überprüfe den Tibber-Dienststatus, deine Internetverbindung und ob dein API-Token noch gültig ist (ggf. neu anmelden)."
},
"home_not_found": {
"title": "Zuhause {home_name} nicht im Tibber-Konto gefunden",
"description": "Das in dieser Integration konfigurierte Zuhause (Eintrag-ID: {entry_id}) ist nicht mehr in deinem Tibber-Konto verfügbar. Dies passiert normalerweise, wenn:\n- Das Zuhause aus deinem Tibber-Konto gelöscht wurde\n- Das Zuhause zu einem anderen Tibber-Konto verschoben wurde\n- Der Zugriff auf dieses Zuhause widerrufen wurde\n\nBitte entferne diesen Integrationseintrag und füge ihn erneut hinzu, falls das Zuhause weiterhin überwacht werden soll. Um diesen Eintrag zu entfernen, gehe zu Einstellungen → Geräte & Dienste → Tibber Prices und lösche die Konfiguration {home_name}."

View file

@ -1201,6 +1201,10 @@
"title": "API rate limit exceeded for {home_name}",
"description": "The Tibber API has rate-limited this integration after {error_count} consecutive errors. This means requests are being made too frequently.\n\nThe integration will automatically retry with increasing delays. This issue will resolve once the rate limit expires.\n\nIf this persists for several hours, consider:\n- Checking if multiple Home Assistant instances are using the same API token\n- Verifying no other applications are heavily using your Tibber API token\n- Reducing the update frequency if you've customized it"
},
"api_outage": {
"title": "Tibber API unavailable for {home_name}",
"description": "No fresh price data has been received from Tibber since {since}. The integration keeps using locally cached prices where available, so your sensors may continue to work, but they can become unavailable if the outage continues (for example, tomorrow's prices may be missing).\n\nThis is usually caused by a temporary Tibber outage or a network problem. The integration retries automatically on every update cycle and clears this issue as soon as fresh data arrives.\n\nIf this persists, check the Tibber service status, your internet connection, and that your API token is still valid (re-authenticate if prompted)."
},
"home_not_found": {
"title": "Home {home_name} not found in Tibber account",
"description": "The home configured in this integration (entry ID: {entry_id}) is no longer available in your Tibber account. This typically happens when:\n- The home was deleted from your Tibber account\n- The home was moved to a different Tibber account\n- Access to this home was revoked\n\nPlease remove this integration entry and re-add it if the home should still be monitored. To remove this entry, go to Settings → Devices & Services → Tibber Prices and delete the {home_name} configuration."

View file

@ -1201,6 +1201,10 @@
"title": "API-hastighetsbegrensning overskredet for {home_name}",
"description": "Tibber-APIet har hastighetsbegrenset denne integrasjonen etter {error_count} påfølgende feil. Dette betyr at forespørsler blir gjort for hyppig.\n\nIntegrasjonen vil automatisk prøve på nytt med økende forsinkelser. Dette problemet vil løse seg når hastighetsbegrensningen utløper.\n\nHvis dette vedvarer i flere timer, vurder:\n- Å sjekke om flere Home Assistant-instanser bruker samme API-token\n- Å verifisere at ingen andre applikasjoner bruker Tibber-API-tokenet ditt mye\n- Å redusere oppdateringsfrekvensen hvis du har tilpasset den"
},
"api_outage": {
"title": "Tibber-API utilgjengelig for {home_name}",
"description": "Ingen ferske prisdata har blitt mottatt fra Tibber siden {since}. Integrasjonen fortsetter å bruke lokalt mellomlagrede priser der det er mulig, så sensorene dine kan fortsette å fungere - men de kan bli utilgjengelige hvis avbruddet vedvarer (for eksempel kan morgendagens priser mangle).\n\nDette skyldes vanligvis et midlertidig Tibber-avbrudd eller et nettverksproblem. Integrasjonen prøver automatisk på nytt ved hver oppdateringssyklus og fjerner dette problemet så snart ferske data ankommer.\n\nHvis dette vedvarer, sjekk Tibber-tjenestestatusen, internettforbindelsen din og at API-tokenet ditt fortsatt er gyldig (autentiser på nytt hvis du blir bedt om det)."
},
"home_not_found": {
"title": "Hjemmet {home_name} ble ikke funnet i Tibber-kontoen",
"description": "Hjemmet konfigurert i denne integrasjonen (oppførings-ID: {entry_id}) er ikke lenger tilgjengelig i Tibber-kontoen din. Dette skjer vanligvis når:\n- Hjemmet ble slettet fra Tibber-kontoen din\n- Hjemmet ble flyttet til en annen Tibber-konto\n- Tilgang til dette hjemmet ble tilbakekalt\n\nVennligst fjern denne integrasjonsoppføringen og legg den til på nytt hvis hjemmet fortsatt skal overvåkes. For å fjerne denne oppføringen, gå til Innstillinger → Enheter og tjenester → Tibber Prices og slett {home_name}-konfigurasjonen."

View file

@ -1201,6 +1201,10 @@
"title": "API rate limiet overschreden voor {home_name}",
"description": "De Tibber API heeft deze integratie beperkt na {error_count} opeenvolgende fouten. Dit betekent dat verzoeken te frequent worden gedaan.\n\nDe integratie zal automatisch opnieuw proberen met toenemende vertragingen. Dit probleem lost zich op zodra de rate limiet verloopt.\n\nAls dit meerdere uren aanhoudt, overweeg dan:\n- Te controleren of meerdere Home Assistant instanties hetzelfde API-token gebruiken\n- Te verifiëren dat geen andere applicaties intensief je Tibber API-token gebruiken\n- De update frequentie te verlagen als je deze hebt aangepast"
},
"api_outage": {
"title": "Tibber API niet beschikbaar voor {home_name}",
"description": "Sinds {since} zijn er geen verse prijsgegevens meer van Tibber ontvangen. De integratie blijft lokaal gecachete prijzen gebruiken waar beschikbaar, dus je sensoren kunnen blijven werken - maar ze kunnen onbeschikbaar worden als de storing aanhoudt (de prijzen van morgen kunnen bijvoorbeeld ontbreken).\n\nDit wordt meestal veroorzaakt door een tijdelijke Tibber-storing of een netwerkprobleem. De integratie probeert bij elke updatecyclus automatisch opnieuw en lost dit probleem op zodra er weer verse gegevens binnenkomen.\n\nAls dit aanhoudt, controleer de Tibber servicestatus, je internetverbinding en of je API-token nog geldig is (authenticeer opnieuw indien gevraagd)."
},
"home_not_found": {
"title": "Huis {home_name} niet gevonden in Tibber-account",
"description": "Het huis geconfigureerd in deze integratie (entry ID: {entry_id}) is niet langer beschikbaar in je Tibber-account. Dit gebeurt meestal wanneer:\n- Het huis is verwijderd uit je Tibber-account\n- Het huis is verplaatst naar een ander Tibber-account\n- Toegang tot dit huis is ingetrokken\n\nVerwijder dit integratie-item en voeg het opnieuw toe als het huis nog steeds gemonitord moet worden. Om dit item te verwijderen, ga naar Instellingen → Apparaten & Services → Tibber Prices en verwijder de {home_name} configuratie."

View file

@ -1201,6 +1201,10 @@
"title": "API-hastighetsgräns överskriden för {home_name}",
"description": "Tibber API har hastighetsbegränsat denna integration efter {error_count} konsekutiva fel. Detta betyder att förfrågningar görs för ofta.\n\nIntegrationen kommer automatiskt att försöka igen med ökande fördröjningar. Detta problem löser sig när hastighetsgränsen löper ut.\n\nOm detta kvarstår i flera timmar, överväg:\n- Kontrollera om flera Home Assistant-instanser använder samma API-token\n- Verifiera att inga andra applikationer använder din Tibber API-token kraftigt\n- Minska uppdateringsfrekvensen om du har anpassat den"
},
"api_outage": {
"title": "Tibber API otillgänglig för {home_name}",
"description": "Inga färska prisdata har tagits emot från Tibber sedan {since}. Integrationen fortsätter att använda lokalt cachade priser där det är möjligt, så dina sensorer kan fortsätta fungera - men de kan bli otillgängliga om avbrottet fortsätter (till exempel kan morgondagens priser saknas).\n\nDetta orsakas vanligtvis av ett tillfälligt Tibber-avbrott eller ett nätverksproblem. Integrationen försöker automatiskt igen vid varje uppdateringscykel och rensar detta problem så snart färska data anländer.\n\nOm detta kvarstår, kontrollera Tibbers tjänststatus, din internetanslutning och att din API-token fortfarande är giltig (autentisera igen om du uppmanas)."
},
"home_not_found": {
"title": "Hem {home_name} hittades inte i Tibber-konto",
"description": "Hemmet som konfigurerats i denna integration (post-ID: {entry_id}) är inte längre tillgängligt i ditt Tibber-konto. Detta händer vanligtvis när:\n- Hemmet togs bort från ditt Tibber-konto\n- Hemmet flyttades till ett annat Tibber-konto\n- Åtkomst till detta hem återkallades\n\nTa bort denna integrationspost och lägg till den igen om hemmet fortfarande ska övervakas. För att ta bort denna post, gå till Inställningar → Enheter & Tjänster → Tibber-priser och radera {home_name}-konfigurationen."