Files
ledgrab-haos-integration/custom_components/ledgrab/light.py
T
alexei.dolgolyov ca5b0a8014 feat(light): periodic color keep-alive for api_input lights
Background task re-pushes the current color at ~timeout/2 (clamped
0.5-30s) while the light is on, so the server's idle timer never
expires and the strip stays at the picked color instead of reverting
to fallback_color.

Drops the length: 9999 magic value from segment payloads now that the
server defaults length to the remainder of the strip — requires the
matching server change in led-grab.
2026-04-26 23:34:06 +03:00

273 lines
10 KiB
Python

"""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