Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031830454a |
@@ -8,7 +8,7 @@ client must not keep a hot connection pool open.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field, replace as _dc_replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,6 +24,14 @@ class NinlightAuthError(NinlightError):
|
|||||||
"""The bulb rejected our token (HTTP 401)."""
|
"""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):
|
class NinlightBadRequest(NinlightError):
|
||||||
"""The bulb rejected the payload (HTTP 400) with the reason in its body."""
|
"""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
|
# device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the
|
||||||
# name is not a signal, but the keys are.
|
# name is not a signal, but the keys are.
|
||||||
strip: bool = False
|
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
|
@classmethod
|
||||||
def from_api(
|
def from_api(
|
||||||
@@ -149,6 +170,9 @@ class NinlightClient:
|
|||||||
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
|
resp = await self._session.request(method, f"{self._base}{path}", json=payload)
|
||||||
except httpx.InvalidURL as err: # programming error, not a transient one
|
except httpx.InvalidURL as err: # programming error, not a transient one
|
||||||
raise NinlightError(f"invalid bulb URL: {err}") from err
|
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:
|
except httpx.HTTPError as err:
|
||||||
raise NinlightError(f"request to bulb failed: {err}") from err
|
raise NinlightError(f"request to bulb failed: {err}") from err
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .client import (
|
|||||||
NinlightClient,
|
NinlightClient,
|
||||||
NinlightError,
|
NinlightError,
|
||||||
NinlightState,
|
NinlightState,
|
||||||
|
NinlightUnavailable,
|
||||||
)
|
)
|
||||||
from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL
|
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
|
# 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.
|
# itself, so surface a re-auth flow instead of retrying every interval.
|
||||||
raise ConfigEntryAuthFailed(str(err)) from err
|
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:
|
except NinlightBadRequest as err:
|
||||||
# A poll sends no body, so this can only mean a firmware we do not match.
|
# A poll sends no body, so this can only mean a firmware we do not match.
|
||||||
raise UpdateFailed(str(err)) from err
|
raise UpdateFailed(str(err)) from err
|
||||||
|
|||||||
@@ -54,17 +54,33 @@ class NinlightLight(NinlightEntity, LightEntity):
|
|||||||
|
|
||||||
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None:
|
||||||
super().__init__(coordinator, entry)
|
super().__init__(coordinator, entry)
|
||||||
state = coordinator.data
|
|
||||||
self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light"
|
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_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
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user