"""Data coordinator for a single Ninlight bulb.""" from __future__ import annotations import logging from contextlib import suppress from typing import Any, TypeAlias from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .client import ( NinlightAuthError, NinlightBadRequest, NinlightClient, NinlightError, NinlightState, ) from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) class NinlightCoordinator(DataUpdateCoordinator[NinlightState]): """Polls one bulb. One coordinator, and one HTTP client, per config entry. `system/info` is fetched on the first successful refresh only. It carries identity (MAC, product name, firmware) that does not change while the bulb runs, and the bulb accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so skipping it on every poll keeps one of those slots free for the UI. """ config_entry: NinlightConfigEntry def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None: super().__init__( hass, _LOGGER, config_entry=entry, name=f"{DOMAIN} {entry.title}", update_interval=SCAN_INTERVAL, ) self.client = NinlightClient( entry.data["host"], entry.data.get(CONF_TOKEN, ""), port=entry.data.get("port", DEFAULT_PORT), ) self.info: dict[str, Any] | None = None self._identity_ok = False self._is_strip = False async def async_shutdown(self) -> None: """Close the HTTP client alongside the coordinator's own teardown.""" await self.client.close() await super().async_shutdown() async def _async_update_data(self) -> NinlightState: try: state = await self.client.get_state() except NinlightAuthError as err: # 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 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 except NinlightError as err: raise UpdateFailed(str(err)) from err if not self._identity_ok: # One extra pair of requests, then never again: the bulb accepts at most four # queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll # path to a single GET leaves slots free for the UI. with suppress(NinlightError): # system/info carries identity that only changes on a reboot or an OTA. self.info = await self.client.get_info() try: # system/settings always demands the token, so it is the only call that # proves ours is right. A failure here on a bulb whose state endpoint # answered happily means the token is wrong while `auth_all` is off - the # light would work and silently never be able to be renamed or updated. await self.client.get_settings() except NinlightAuthError as err: raise ConfigEntryAuthFailed(str(err)) from err # `product` is an ninstrip-only key in system/info (bulbs send # product_name); once known, the heavier segment poll follows every refresh. self._is_strip = "product" in (self.info or {}) self._identity_ok = True segments: list[dict[str, Any]] | None = None if self._is_strip: # The light lives in the segment list; the plain state GET above carries # only on/brightness for it. A firmware without the endpoint (misdetected # strip) must not fail the whole poll for that. try: segments = (await self.client.get_segments()).get("segments") except NinlightError as err: _LOGGER.debug("segment poll failed: %s", err) return NinlightState.from_api(state, self.info, segments) # Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the # project's 3.11 tooling and runs on HA's 3.14. NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator]