"""Light platform for LED Screen Controller (api_input CSS sources).""" from __future__ import annotations import asyncio import logging from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, DATA_COORDINATOR from .coordinator import LedGrabCoordinator _LOGGER = logging.getLogger(__name__) # Lower bound on the keep-alive interval (seconds). Refreshing faster than # this is wasteful and can flood the server for very short timeouts. MIN_KEEPALIVE_INTERVAL = 0.5 # Upper bound — even with very long timeouts, refresh occasionally so the # strip recovers within reasonable time after a server-side restart. MAX_KEEPALIVE_INTERVAL = 30.0 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up LED Screen Controller api_input lights.""" data = hass.data[DOMAIN][entry.entry_id] coordinator: LedGrabCoordinator = data[DATA_COORDINATOR] entities = [] if coordinator.data: for source in coordinator.data.get("css_sources", []): if source.get("source_type") == "api_input": entities.append( ApiInputLight(coordinator, source, entry.entry_id) ) async_add_entities(entities) class ApiInputLight(CoordinatorEntity, LightEntity, RestoreEntity): """Representation of an api_input CSS source as a light entity. The light's RGB color doubles as the source's ``fallback_color`` (the resting color shown when no API input arrives within ``timeout``). Turning the light off only pushes black segments — it does *not* zero ``fallback_color`` — so the chosen default color persists across off/on cycles. The on/off state itself is restored across HA restarts via ``RestoreEntity``. While the light is on, a background task re-pushes the current color at roughly half the source's ``timeout`` so the server's idle timer never expires and the strip stays at the picked color rather than reverting to ``fallback_color``. """ _attr_has_entity_name = True _attr_color_mode = ColorMode.RGB _attr_supported_color_modes = {ColorMode.RGB} _attr_translation_key = "api_input_light" _attr_icon = "mdi:led-strip-variant" def __init__( self, coordinator: LedGrabCoordinator, source: dict[str, Any], entry_id: str, ) -> None: """Initialize the light.""" super().__init__(coordinator) self._source_id: str = source["id"] self._source_name: str = source.get("name", self._source_id) self._entry_id = entry_id self._attr_unique_id = f"{self._source_id}_light" # Seed color from the source's current fallback_color; default to # white if it's unset/black so the picker has a sensible starting # value. is_on is provisional here and refined in async_added_to_hass # from RestoreEntity, since fallback_color alone no longer indicates # on/off. fallback = self._get_fallback_color() has_color = fallback != [0, 0, 0] self._rgb_color: tuple[int, int, int] = ( tuple(fallback) if has_color else (255, 255, 255) # type: ignore[arg-type] ) self._is_on: bool = has_color self._brightness: int = 255 self._keepalive_task: asyncio.Task[None] | None = None async def async_added_to_hass(self) -> None: """Restore on/off state and last color from the previous HA session.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() if last_state is not None: self._is_on = last_state.state == "on" last_rgb = last_state.attributes.get("rgb_color") if ( isinstance(last_rgb, (list, tuple)) and len(last_rgb) == 3 and all(isinstance(c, (int, float)) for c in last_rgb) ): self._rgb_color = tuple(int(c) for c in last_rgb) # type: ignore[assignment] last_brightness = last_state.attributes.get("brightness") if isinstance(last_brightness, (int, float)): self._brightness = int(last_brightness) # If we restored to "on", resume the keep-alive so the server stays # at the picked color rather than drifting to fallback_color. if self._is_on: self._start_keepalive() async def async_will_remove_from_hass(self) -> None: """Cancel the keep-alive task when the entity is removed/unloaded.""" self._stop_keepalive() await super().async_will_remove_from_hass() @property def device_info(self) -> dict[str, Any]: """Return device information — one virtual device per api_input source.""" return { "identifiers": {(DOMAIN, self._source_id)}, "name": self._source_name, "manufacturer": "LedGrab", "model": "API Input CSS Source", } @property def is_on(self) -> bool: """Return true if the light is on.""" return self._is_on @property def rgb_color(self) -> tuple[int, int, int]: """Return the current RGB color.""" return self._rgb_color @property def brightness(self) -> int: """Return the current brightness (0-255).""" return self._brightness async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light, optionally setting color and brightness.""" if ATTR_RGB_COLOR in kwargs: self._rgb_color = kwargs[ATTR_RGB_COLOR] if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] scaled = self._scaled_color() await self.coordinator.push_segments( self._source_id, [{"mode": "solid", "color": scaled}], ) # Update fallback_color so the color persists if the keep-alive ever # misses a beat (server restart, integration unload, etc.). await self.coordinator.update_source( self._source_id, fallback_color=scaled, ) self._is_on = True self._start_keepalive() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light by pushing black segments. ``fallback_color`` is intentionally left untouched so the chosen default color survives off/on cycles. """ self._stop_keepalive() await self.coordinator.push_segments( self._source_id, [{"mode": "solid", "color": [0, 0, 0]}], ) self._is_on = False self.async_write_ha_state() def _scaled_color(self) -> list[int]: """Return the current RGB color scaled by brightness.""" scale = self._brightness / 255 r, g, b = self._rgb_color return [round(r * scale), round(g * scale), round(b * scale)] def _get_fallback_color(self) -> list[int]: """Read fallback_color from the source config in coordinator data.""" source = self._get_source() if source is None: return [0, 0, 0] fallback = source.get("fallback_color") if fallback and len(fallback) >= 3: return list(fallback[:3]) return [0, 0, 0] def _get_source(self) -> dict[str, Any] | None: """Return the cached source config dict, or None if unavailable.""" if not self.coordinator.data: return None for source in self.coordinator.data.get("css_sources", []): if source.get("id") == self._source_id: return source return None def _get_keepalive_interval(self) -> float: """Pick a refresh interval based on the source's ``timeout`` field. ``timeout`` is stored as a ``BindableFloat`` — either a plain number or ``{"value": float, "source_id": str}`` when bound. A timeout of 0 means "never expire" on the server, but we still refresh periodically so the strip recovers from server-side restarts. """ source = self._get_source() timeout: float | None = None if source is not None: raw = source.get("timeout") if isinstance(raw, dict): value = raw.get("value") if isinstance(value, (int, float)): timeout = float(value) elif isinstance(raw, (int, float)): timeout = float(raw) if timeout is None or timeout <= 0: return MAX_KEEPALIVE_INTERVAL # Refresh at half the timeout so we have a full timeout-window of # slack for retry / network jitter before the server gives up. interval = timeout / 2.0 return max(MIN_KEEPALIVE_INTERVAL, min(MAX_KEEPALIVE_INTERVAL, interval)) def _start_keepalive(self) -> None: """(Re)start the background re-push task.""" self._stop_keepalive() self._keepalive_task = self.hass.loop.create_task(self._keepalive_loop()) def _stop_keepalive(self) -> None: """Cancel the background re-push task if running.""" task = self._keepalive_task self._keepalive_task = None if task is not None and not task.done(): task.cancel() async def _keepalive_loop(self) -> None: """Periodically re-push the current color while the light is on.""" try: while self._is_on: await asyncio.sleep(self._get_keepalive_interval()) if not self._is_on: return try: await self.coordinator.push_segments( self._source_id, [{"mode": "solid", "color": self._scaled_color()}], ) except Exception as err: # pragma: no cover — transient API errors _LOGGER.debug( "Keep-alive push failed for %s: %s", self._source_id, err, ) except asyncio.CancelledError: pass