fix(ha-integration): ausgeschaltete Lampe -> "unavailable" statt Fehlerzustand

War eine Lampe stromlos, wurde der Verbindungsfehler als UpdateFailed
gemeldet; HA schob den Config-Entry in SETUP_RETRY und die Integration
fiel in den Fehlerzustand. Jetzt wird Connection-Refused/Timeout als
NinlightUnavailable gefuehrt: der Coordinator liefert den letzten
bekannten State mit available=False, der Entry bleibt geladen und die
Entity zeigt "unavailable". Protokollfehler (400, kaputtes JSON) und
Auth (401 -> Reauth) verhalten sich unveraendert.

Light-Faehigkeiten (RGB/CT, Kelvin-Bereich) sind deshalb dynamische
Properties — ein Entry, der mit stromloser Lampe angelegt wurde,
erkennt sie, sobald die Lampe antwortet.
This commit is contained in:
Robin von Bülow
2026-10-02 08:37:50 +02:00
parent 19a0d6539a
commit 031830454a
3 changed files with 63 additions and 10 deletions
@@ -17,6 +17,7 @@ from .client import (
NinlightClient,
NinlightError,
NinlightState,
NinlightUnavailable,
)
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
@@ -63,6 +64,18 @@ class NinlightCoordinator(DataUpdateCoordinator[NinlightState]):
# Only possible when the bulb has `auth_all` set; a wrong token will not fix
# itself, so surface a re-auth flow instead of retrying every interval.
raise ConfigEntryAuthFailed(str(err)) from err
except NinlightUnavailable as err:
# A bulb that is switched off or off the WLAN is an expected state, not a
# fault. Keep the last known data with `available=False` so the entity shows
# "unavailable" and the config entry stays loaded - no error log, no retry
# backoff, no reload. The next poll picks the bulb up when it answers again.
_LOGGER.debug("%s unreachable (assumed powered off): %s", self.name, err)
if isinstance(self.data, NinlightState):
return NinlightState.unavailable_copy(self.data)
# No successful poll yet (e.g. HA started while the bulb was off): a default
# state carries no identity, but the entry still loads cleanly and the
# identity fetch below reruns on the first poll that reaches the bulb.
return NinlightState.unavailable_copy(NinlightState.from_api({}))
except NinlightBadRequest as err:
# A poll sends no body, so this can only mean a firmware we do not match.
raise UpdateFailed(str(err)) from err