diff --git a/ha-integration/custom_components/ninlight/client.py b/ha-integration/custom_components/ninlight/client.py index aa9051f..1c90460 100644 --- a/ha-integration/custom_components/ninlight/client.py +++ b/ha-integration/custom_components/ninlight/client.py @@ -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 diff --git a/ha-integration/custom_components/ninlight/coordinator.py b/ha-integration/custom_components/ninlight/coordinator.py index 39bfcbf..3a6cada 100644 --- a/ha-integration/custom_components/ninlight/coordinator.py +++ b/ha-integration/custom_components/ninlight/coordinator.py @@ -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 diff --git a/ha-integration/custom_components/ninlight/light.py b/ha-integration/custom_components/ninlight/light.py index 74fa1f6..9ca0650 100644 --- a/ha-integration/custom_components/ninlight/light.py +++ b/ha-integration/custom_components/ninlight/light.py @@ -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: