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
@@ -8,7 +8,7 @@ client must not keep a hot connection pool open.
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace as _dc_replace
from typing import Any
import httpx
@@ -24,6 +24,14 @@ class NinlightAuthError(NinlightError):
"""The bulb rejected our token (HTTP 401)."""
class NinlightUnavailable(NinlightError):
"""The bulb could not be reached at all (connection refused or timeout).
A bulb that is switched off or has lost Wi-Fi is an expected state, not a fault,
so the coordinator treats this differently from protocol errors.
"""
class NinlightBadRequest(NinlightError):
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
@@ -69,6 +77,19 @@ class NinlightState:
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
# name is not a signal, but the keys are.
strip: bool = False
# False when the last poll could not reach the bulb (powered off / offline).
# Kept separate from `on` so the entity can show "unavailable" instead of an
# error, while the light keeps its last known appearance.
available: bool = True
@classmethod
def unavailable_copy(cls, last: "NinlightState") -> "NinlightState":
"""The last known state, flagged unavailable.
Keeps the light's appearance (on/off, color) at its last real value while the
entity shows as unavailable, instead of falling back to defaults.
"""
return _dc_replace(last, available=False)
@classmethod
def from_api(
@@ -149,6 +170,9 @@ class NinlightClient:
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
except httpx.InvalidURL as err: # programming error, not a transient one
raise NinlightError(f"invalid bulb URL: {err}") from err
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as err:
# No answer at all: bulb powered off, rebooting, or gone from the WLAN.
raise NinlightUnavailable(f"bulb unreachable: {err}") from err
except httpx.HTTPError as err:
raise NinlightError(f"request to bulb failed: {err}") from err