From ada4e3344894b17ae1a97cb752d9b75599207008 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 30 May 2026 14:46:32 +0000 Subject: [PATCH] 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. --- .../tibber_prices/binary_sensor/core.py | 19 ++++ .../tibber_prices/coordinator/core.py | 32 +++++++ .../tibber_prices/coordinator/repairs.py | 86 +++++++++++++++++++ .../tibber_prices/translations/de.json | 4 + .../tibber_prices/translations/en.json | 4 + .../tibber_prices/translations/nb.json | 4 + .../tibber_prices/translations/nl.json | 4 + .../tibber_prices/translations/sv.json | 4 + 8 files changed, 157 insertions(+) diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 4065b73..240b673 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -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.""" diff --git a/custom_components/tibber_prices/coordinator/core.py b/custom_components/tibber_prices/coordinator/core.py index 365b996..c48cb32 100644 --- a/custom_components/tibber_prices/coordinator/core.py +++ b/custom_components/tibber_prices/coordinator/core.py @@ -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() diff --git a/custom_components/tibber_prices/coordinator/repairs.py b/custom_components/tibber_prices/coordinator/repairs.py index 2be7e96..bb4f7a0 100644 --- a/custom_components/tibber_prices/coordinator/repairs.py +++ b/custom_components/tibber_prices/coordinator/repairs.py @@ -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 diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 9742660..4dffeb1 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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}." diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 616a8c9..95a8e42 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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." diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 7ed6b40..b060723 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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." diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 0cac27c..b78e932 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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." diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index a14af67..f8cfac3 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -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."