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.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -11,12 +11,20 @@ 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__)
|
||||
|
||||
|
||||
@@ -36,6 +44,7 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
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)
|
||||
|
||||
@@ -50,9 +59,6 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
async with asyncio.timeout(DEFAULT_TIMEOUT * 3):
|
||||
if self.server_version == "unknown":
|
||||
await self._fetch_server_version()
|
||||
|
||||
targets_list = await self._fetch_targets()
|
||||
|
||||
# Fetch state and metrics for all targets in parallel
|
||||
@@ -92,21 +98,49 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
target_id, data = r
|
||||
targets_data[target_id] = data
|
||||
|
||||
# Fetch devices, CSS sources, value sources, and scene presets in parallel
|
||||
devices_data, css_sources, value_sources, scene_presets = await asyncio.gather(
|
||||
# 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:
|
||||
@@ -114,19 +148,96 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
async def _fetch_server_version(self) -> None:
|
||||
"""Fetch server version from health endpoint."""
|
||||
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()
|
||||
data = await resp.json()
|
||||
self.server_version = data.get("version", "unknown")
|
||||
return await resp.json()
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Failed to fetch server version: %s", err)
|
||||
self.server_version = "unknown"
|
||||
_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."""
|
||||
@@ -294,6 +405,27 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
_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(
|
||||
@@ -418,6 +550,56 @@ class LedGrabCoordinator(DataUpdateCoordinator):
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user