Author SHA1 Message Date
Robin von Bülow 031830454a 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.
2026-10-02 08:37:50 +02:00
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
@@ -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
@@ -54,17 +54,33 @@ class NinlightLight(NinlightEntity, LightEntity):
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
super().__init__(coordinator, entry)
state = coordinator.data
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
# Both modes are advertised for RGB bulbs: the firmware keeps the last color and
# the last color temperature and picks between them with `mode`, which is exactly
# how HA models a color_temp + rgb light.
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {
ColorMode.COLOR_TEMP
}
self._attr_supported_features = LightEntityFeature.TRANSITION
self._attr_min_color_temp_kelvin = state.ct_min
self._attr_max_color_temp_kelvin = state.ct_max
# Capabilities come from the polled state, not from a one-time __init__ snapshot: an
# entry set up while the bulb was powered off starts from defaults and must pick up
# the real RGB/CT range as soon as the bulb answers again.
@property
def available(self) -> bool:
# An unreachable (powered-off) bulb is an expected state: the entity goes
# unavailable instead of the config entry erroring.
return self.coordinator.last_update_success and self.coordinator.data.available
@property
def supported_color_modes(self) -> set[ColorMode]:
state = self.coordinator.data
# Both modes for RGB bulbs: the firmware keeps the last color and the last color
# temperature and picks between them with `mode`, exactly how HA models a
# color_temp + rgb light.
return {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else {ColorMode.COLOR_TEMP}
@property
def min_color_temp_kelvin(self) -> int:
return self.coordinator.data.ct_min
@property
def max_color_temp_kelvin(self) -> int:
return self.coordinator.data.ct_max
@property
def is_on(self) -> bool: