Files
ledgrab-haos-integration/custom_components/ledgrab/coordinator.py
T
alexei.dolgolyov a666d9eb9c feat: server telemetry, update entity, sync-clock controls
- Server device exposing CPU/RAM/GPU/temperature/battery sensors via
  /api/v1/system/performance, plus last-restart timestamp (cached with
  jitter threshold so the recorder doesn't see poll wobble) and version.
- Update entity backed by /api/v1/system/update — installs via
  /apply, hides the install button when the server reports
  can_auto_update=false.
- Sync-clock entities: reset button, speed number, running switch, and
  the event listener now refreshes on entity_changed events too.
- Bump manifest to 0.4.0.
2026-04-27 01:35:42 +03:00

622 lines
24 KiB
Python

"""Data update coordinator for LED Screen Controller."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import logging
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
DEFAULT_TIMEOUT,
)
# Boot-time jitter threshold: candidate boot_time is recomputed each poll from
# `now - uptime_seconds`, so network latency and clock skew make it wobble by
# sub-second amounts. Only update the cached value if the candidate differs by
# more than this many seconds — anything larger almost certainly means the
# server actually restarted.
_BOOT_TIME_JITTER_S = 5.0
_LOGGER = logging.getLogger(__name__)
class LedGrabCoordinator(DataUpdateCoordinator):
"""Class to manage fetching LED Screen Controller data."""
def __init__(
self,
hass: HomeAssistant,
session: aiohttp.ClientSession,
server_url: str,
api_key: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
self.server_url = server_url
self.session = session
self.api_key = api_key
self.server_version = "unknown"
self.boot_time: datetime | None = None
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from API."""
try:
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
targets_list = await self._fetch_targets()
# Fetch state and metrics for all targets in parallel
targets_data: dict[str, dict[str, Any]] = {}
async def fetch_target_data(target: dict) -> tuple[str, dict]:
target_id = target["id"]
try:
state, metrics = await asyncio.gather(
self._fetch_target_state(target_id),
self._fetch_target_metrics(target_id),
)
except Exception as err:
_LOGGER.warning(
"Failed to fetch data for target %s: %s",
target_id,
err,
)
state = None
metrics = None
return target_id, {
"info": target,
"state": state,
"metrics": metrics,
}
results = await asyncio.gather(
*(fetch_target_data(t) for t in targets_list),
return_exceptions=True,
)
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Target fetch failed: %s", r)
continue
target_id, data = r
targets_data[target_id] = data
# Fetch devices, CSS sources, value sources, scene presets,
# sync clocks, system performance, health, and update status
# in parallel
(
devices_data,
css_sources,
value_sources,
scene_presets,
sync_clocks,
performance,
health,
update_status,
) = await asyncio.gather(
self._fetch_devices(),
self._fetch_css_sources(),
self._fetch_value_sources(),
self._fetch_scene_presets(),
self._fetch_sync_clocks(),
self._fetch_system_performance(),
self._fetch_health(),
self._fetch_update_status(),
)
if health:
version = health.get("version")
if version:
self.server_version = version
self._update_boot_time(health.get("uptime_seconds"))
return {
"targets": targets_data,
"devices": devices_data,
"css_sources": css_sources,
"value_sources": value_sources,
"scene_presets": scene_presets,
"sync_clocks": sync_clocks,
"server_version": self.server_version,
"system": {
"performance": performance,
"health": health,
"boot_time": self.boot_time,
"update": update_status,
},
}
except asyncio.TimeoutError as err:
raise UpdateFailed(f"Timeout fetching data: {err}") from err
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
async def _fetch_health(self) -> dict[str, Any] | None:
"""Fetch /health (unauthenticated, cheap; gives version + uptime)."""
try:
async with self.session.get(
f"{self.server_url}/health",
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
except Exception as err:
_LOGGER.debug("Failed to fetch health: %s", err)
return None
async def _fetch_system_performance(self) -> dict[str, Any] | None:
"""Fetch CPU/RAM/GPU/temperature metrics from the server.
Returns ``None`` on older servers without the endpoint or on transient
failures — callers must treat the absence as "no telemetry this poll".
"""
try:
async with self.session.get(
f"{self.server_url}/api/v1/system/performance",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except Exception as err:
_LOGGER.debug("Failed to fetch system performance: %s", err)
return None
async def _fetch_update_status(self) -> dict[str, Any] | None:
"""Fetch auto-update status (current version, available release, …).
Older servers without the update service return 404 — treat that as
"no update entity should be registered" by returning ``None``.
"""
try:
async with self.session.get(
f"{self.server_url}/api/v1/system/update/status",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 404:
return None
resp.raise_for_status()
return await resp.json()
except Exception as err:
_LOGGER.debug("Failed to fetch update status: %s", err)
return None
async def apply_update(self) -> None:
"""Trigger the server's apply-update flow.
The server downloads the available release if needed and shuts down
once the new binaries are in place. We refresh straight away so the
update entity reflects ``applying=True`` until the connection drops.
"""
async with self.session.post(
f"{self.server_url}/api/v1/system/update/apply",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status not in (200, 202):
body = await resp.text()
_LOGGER.error(
"Failed to apply update: %s %s",
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
def _update_boot_time(self, uptime_seconds: float | None) -> None:
"""Recompute cached server boot time from /health uptime.
Updates only when the candidate moves more than ``_BOOT_TIME_JITTER_S``
seconds, so the timestamp sensor stays stable across polls and the
recorder doesn't see spurious state changes.
"""
if uptime_seconds is None:
return
candidate = dt_util.utcnow() - timedelta(seconds=float(uptime_seconds))
if (
self.boot_time is None
or abs((self.boot_time - candidate).total_seconds()) > _BOOT_TIME_JITTER_S
):
self.boot_time = candidate
async def _fetch_targets(self) -> list[dict[str, Any]]:
"""Fetch all output targets."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("targets", [])
async def _fetch_target_state(self, target_id: str) -> dict[str, Any]:
"""Fetch target processing state."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/state",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_target_metrics(self, target_id: str) -> dict[str, Any]:
"""Fetch target metrics."""
async with self.session.get(
f"{self.server_url}/api/v1/output-targets/{target_id}/metrics",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
return await resp.json()
async def _fetch_devices(self) -> dict[str, dict[str, Any]]:
"""Fetch all devices with capabilities and brightness."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
devices = data.get("devices", [])
except Exception as err:
_LOGGER.warning("Failed to fetch devices: %s", err)
return {}
# Fetch brightness for all capable devices in parallel
async def fetch_device_entry(device: dict) -> tuple[str, dict[str, Any]]:
device_id = device["id"]
entry: dict[str, Any] = {"info": device, "brightness": None}
if "brightness_control" in (device.get("capabilities") or []):
try:
async with self.session.get(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 200:
bri_data = await resp.json()
entry["brightness"] = bri_data.get("brightness")
except Exception as err:
_LOGGER.warning(
"Failed to fetch brightness for device %s: %s",
device_id,
err,
)
return device_id, entry
results = await asyncio.gather(
*(fetch_device_entry(d) for d in devices),
return_exceptions=True,
)
devices_data: dict[str, dict[str, Any]] = {}
for r in results:
if isinstance(r, Exception):
_LOGGER.warning("Device fetch failed: %s", r)
continue
device_id, entry = r
devices_data[device_id] = entry
return devices_data
async def set_brightness(self, device_id: str, brightness: int) -> None:
"""Set brightness for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/brightness",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"brightness": brightness},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set brightness for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def set_color(self, device_id: str, color: list[int] | None) -> None:
"""Set or clear the static color for a device."""
async with self.session.put(
f"{self.server_url}/api/v1/devices/{device_id}/color",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"color": color},
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to set color for device %s: %s %s",
device_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def _fetch_css_sources(self) -> list[dict[str, Any]]:
"""Fetch all color strip sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/color-strip-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch CSS sources: %s", err)
return []
async def _fetch_value_sources(self) -> list[dict[str, Any]]:
"""Fetch all value sources."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/value-sources",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("sources", [])
except Exception as err:
_LOGGER.warning("Failed to fetch value sources: %s", err)
return []
async def _fetch_scene_presets(self) -> list[dict[str, Any]]:
"""Fetch all scene presets."""
try:
async with self.session.get(
f"{self.server_url}/api/v1/scene-presets",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("presets", [])
except Exception as err:
_LOGGER.warning("Failed to fetch scene presets: %s", err)
return []
async def _fetch_sync_clocks(self) -> list[dict[str, Any]]:
"""Fetch all synchronization clocks (with runtime state).
Older servers without the sync-clock endpoint return 404 — treat
that as "no clocks" rather than failing the whole refresh.
"""
try:
async with self.session.get(
f"{self.server_url}/api/v1/sync-clocks",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 404:
return []
resp.raise_for_status()
data = await resp.json()
return data.get("clocks", [])
except Exception as err:
_LOGGER.warning("Failed to fetch sync clocks: %s", err)
return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=self._timeout,
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset."""
async with self.session.post(
f"{self.server_url}/api/v1/scene-presets/{preset_id}/activate",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to activate scene %s: %s %s",
preset_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def update_source(self, source_id: str, **kwargs: Any) -> None:
"""Update a color strip source's fields.
The server uses a discriminated-union body keyed on ``source_type``;
if the caller didn't supply it, look it up from the cached sources so
the request validates server-side.
"""
if "source_type" not in kwargs and self.data:
for source in self.data.get("css_sources", []):
if source.get("id") == source_id:
src_type = source.get("source_type")
if src_type:
kwargs["source_type"] = src_type
break
async with self.session.put(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update source %s: %s %s",
source_id,
resp.status,
body,
)
resp.raise_for_status()
async def update_target(self, target_id: str, **kwargs: Any) -> None:
"""Update an output target's fields."""
async with self.session.put(
f"{self.server_url}/api/v1/output-targets/{target_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def start_processing(self, target_id: str) -> None:
"""Start processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/start",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already processing", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to start target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def _sync_clock_action(self, clock_id: str, action: str) -> None:
"""POST a sync-clock control action (pause/resume/reset)."""
async with self.session.post(
f"{self.server_url}/api/v1/sync-clocks/{clock_id}/{action}",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to %s sync clock %s: %s %s",
action,
clock_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def pause_sync_clock(self, clock_id: str) -> None:
"""Pause a sync clock (linked animations freeze)."""
await self._sync_clock_action(clock_id, "pause")
async def resume_sync_clock(self, clock_id: str) -> None:
"""Resume a paused sync clock."""
await self._sync_clock_action(clock_id, "resume")
async def reset_sync_clock(self, clock_id: str) -> None:
"""Reset a sync clock to t=0 (linked animations restart)."""
await self._sync_clock_action(clock_id, "reset")
async def update_sync_clock(self, clock_id: str, **kwargs: Any) -> None:
"""Update a sync clock's persistent fields (name/speed/...)."""
async with self.session.put(
f"{self.server_url}/api/v1/sync-clocks/{clock_id}",
headers={**self._auth_headers, "Content-Type": "application/json"},
json=kwargs,
timeout=self._timeout,
) as resp:
if resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to update sync clock %s: %s %s",
clock_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()
async def stop_processing(self, target_id: str) -> None:
"""Stop processing for a target."""
async with self.session.post(
f"{self.server_url}/api/v1/output-targets/{target_id}/stop",
headers=self._auth_headers,
timeout=self._timeout,
) as resp:
if resp.status == 409:
_LOGGER.debug("Target %s already stopped", target_id)
elif resp.status != 200:
body = await resp.text()
_LOGGER.error(
"Failed to stop target %s: %s %s",
target_id,
resp.status,
body,
)
resp.raise_for_status()
await self.async_request_refresh()