diff --git a/custom_components/ledgrab/__init__.py b/custom_components/ledgrab/__init__.py
index 23f2df8..ac4cc97 100644
--- a/custom_components/ledgrab/__init__.py
+++ b/custom_components/ledgrab/__init__.py
@@ -35,6 +35,7 @@ PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.NUMBER,
Platform.SELECT,
+ Platform.UPDATE,
]
@@ -61,6 +62,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create device entries for each target and remove stale ones
device_registry = dr.async_get(hass)
current_identifiers: set[tuple[str, str]] = set()
+
+ # Server device — owns the system telemetry sensors (CPU, RAM, GPU, etc.)
+ server_identifier = (DOMAIN, f"{entry.entry_id}_server")
+ sw_version = (
+ coordinator.server_version
+ if coordinator.server_version and coordinator.server_version != "unknown"
+ else None
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={server_identifier},
+ name=server_name,
+ manufacturer="LedGrab",
+ model="Server",
+ sw_version=sw_version,
+ configuration_url=server_url,
+ )
+ current_identifiers.add(server_identifier)
+
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
info = target_data["info"]
@@ -93,6 +113,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
current_identifiers.add(scenes_identifier)
+ # One device per sync clock — groups its switch/number/button/sensor.
+ sync_clocks = coordinator.data.get("sync_clocks", []) if coordinator.data else []
+ for clock in sync_clocks:
+ clock_identifier = (DOMAIN, clock["id"])
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={clock_identifier},
+ name=clock.get("name", clock["id"]),
+ manufacturer=server_name,
+ model="Sync Clock",
+ configuration_url=server_url,
+ )
+ current_identifiers.add(clock_identifier)
+
# Remove devices for targets that no longer exist
for device_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if not device_entry.identifiers & current_identifiers:
@@ -106,28 +140,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DATA_EVENT_LISTENER: event_listener,
}
- # Track target and scene IDs to detect changes
+ # Track target, scene, and sync-clock IDs to detect changes
known_target_ids = set(coordinator.data.get("targets", {}).keys() if coordinator.data else [])
known_scene_ids = set(
p["id"] for p in (coordinator.data.get("scene_presets", []) if coordinator.data else [])
)
+ known_clock_ids = set(
+ c["id"] for c in (coordinator.data.get("sync_clocks", []) if coordinator.data else [])
+ )
def _on_coordinator_update() -> None:
- """Detect target/scene list changes and trigger reload."""
- nonlocal known_target_ids, known_scene_ids
+ """Detect target/scene/clock list changes and trigger reload."""
+ nonlocal known_target_ids, known_scene_ids, known_clock_ids
if not coordinator.data:
return
targets = coordinator.data.get("targets", {})
- # Reload if target or scene list changed
+ # Reload if target, scene, or sync-clock list changed
current_ids = set(targets.keys())
current_scene_ids = set(p["id"] for p in coordinator.data.get("scene_presets", []))
- if current_ids != known_target_ids or current_scene_ids != known_scene_ids:
+ current_clock_ids = set(c["id"] for c in coordinator.data.get("sync_clocks", []))
+ if (
+ current_ids != known_target_ids
+ or current_scene_ids != known_scene_ids
+ or current_clock_ids != known_clock_ids
+ ):
known_target_ids = current_ids
known_scene_ids = current_scene_ids
- _LOGGER.info("Target or scene list changed, reloading integration")
+ known_clock_ids = current_clock_ids
+ _LOGGER.info("Target, scene, or sync-clock list changed, reloading integration")
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
coordinator.async_add_listener(_on_coordinator_update)
diff --git a/custom_components/ledgrab/button.py b/custom_components/ledgrab/button.py
index 2c7f684..c03e459 100644
--- a/custom_components/ledgrab/button.py
+++ b/custom_components/ledgrab/button.py
@@ -25,12 +25,16 @@ async def async_setup_entry(
data = hass.data[DOMAIN][entry.entry_id]
coordinator: LedGrabCoordinator = data[DATA_COORDINATOR]
- entities = []
+ entities: list[ButtonEntity] = []
if coordinator.data:
for preset in coordinator.data.get("scene_presets", []):
entities.append(
SceneActivateButton(coordinator, preset, entry.entry_id)
)
+ for clock in coordinator.data.get("sync_clocks", []):
+ entities.append(
+ SyncClockResetButton(coordinator, clock["id"], entry.entry_id)
+ )
async_add_entities(entities)
@@ -72,3 +76,38 @@ class SceneActivateButton(CoordinatorEntity, ButtonEntity):
async def async_press(self) -> None:
"""Activate the scene preset."""
await self.coordinator.activate_scene(self._preset_id)
+
+
+class SyncClockResetButton(CoordinatorEntity, ButtonEntity):
+ """Button that resets a sync clock to t=0 (linked animations restart)."""
+
+ _attr_has_entity_name = True
+ _attr_icon = "mdi:restart"
+
+ def __init__(
+ self,
+ coordinator: LedGrabCoordinator,
+ clock_id: str,
+ entry_id: str,
+ ) -> None:
+ super().__init__(coordinator)
+ self._clock_id = clock_id
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{clock_id}_reset"
+ self._attr_translation_key = "sync_clock_reset"
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, self._clock_id)}}
+
+ @property
+ def available(self) -> bool:
+ if not self.coordinator.data:
+ return False
+ return any(
+ c.get("id") == self._clock_id
+ for c in self.coordinator.data.get("sync_clocks", [])
+ )
+
+ async def async_press(self) -> None:
+ await self.coordinator.reset_sync_clock(self._clock_id)
diff --git a/custom_components/ledgrab/coordinator.py b/custom_components/ledgrab/coordinator.py
index d0a2a57..4fb2948 100644
--- a/custom_components/ledgrab/coordinator.py
+++ b/custom_components/ledgrab/coordinator.py
@@ -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(
diff --git a/custom_components/ledgrab/event_listener.py b/custom_components/ledgrab/event_listener.py
index 5ffa7a0..53d6fa4 100644
--- a/custom_components/ledgrab/event_listener.py
+++ b/custom_components/ledgrab/event_listener.py
@@ -99,7 +99,7 @@ class EventStreamListener:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
- if data.get("type") == "state_change":
+ if data.get("type") in ("state_change", "entity_changed"):
await self._coordinator.async_request_refresh()
elif msg.type in (
aiohttp.WSMsgType.CLOSED,
diff --git a/custom_components/ledgrab/manifest.json b/custom_components/ledgrab/manifest.json
index b4d7d05..be23128 100644
--- a/custom_components/ledgrab/manifest.json
+++ b/custom_components/ledgrab/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration/issues",
"requirements": ["aiohttp>=3.9.0"],
- "version": "0.2.2"
+ "version": "0.4.0"
}
diff --git a/custom_components/ledgrab/number.py b/custom_components/ledgrab/number.py
index bb4252d..45fc576 100644
--- a/custom_components/ledgrab/number.py
+++ b/custom_components/ledgrab/number.py
@@ -32,6 +32,9 @@ async def async_setup_entry(
if source.get("source_type") == "api_input":
entities.append(ApiInputTimeout(coordinator, source, entry.entry_id))
+ for clock in coordinator.data.get("sync_clocks") or []:
+ entities.append(SyncClockSpeed(coordinator, clock["id"], entry.entry_id))
+
if coordinator.data and "targets" in coordinator.data:
devices = coordinator.data.get("devices") or {}
@@ -300,3 +303,60 @@ class ApiInputTimeout(CoordinatorEntity, NumberEntity):
if source.get("id") == self._source_id:
return source
return None
+
+
+# --- Sync clock number entities ---
+
+
+class SyncClockSpeed(CoordinatorEntity, NumberEntity):
+ """Speed multiplier for a sync clock (server range 0.1–10.0).
+
+ Hot-applied — running animations linked to the clock change pace
+ immediately without resetting their position.
+ """
+
+ _attr_has_entity_name = True
+ _attr_native_min_value = 0.1
+ _attr_native_max_value = 10.0
+ _attr_native_step = 0.1
+ _attr_mode = NumberMode.SLIDER
+ _attr_icon = "mdi:speedometer"
+
+ def __init__(
+ self,
+ coordinator: LedGrabCoordinator,
+ clock_id: str,
+ entry_id: str,
+ ) -> None:
+ super().__init__(coordinator)
+ self._clock_id = clock_id
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{clock_id}_speed"
+ self._attr_translation_key = "sync_clock_speed"
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, self._clock_id)}}
+
+ @property
+ def native_value(self) -> float | None:
+ clock = self._get_clock()
+ if not clock:
+ return None
+ speed = clock.get("speed")
+ return float(speed) if speed is not None else None
+
+ @property
+ def available(self) -> bool:
+ return self._get_clock() is not None
+
+ async def async_set_native_value(self, value: float) -> None:
+ await self.coordinator.update_sync_clock(self._clock_id, speed=round(value, 2))
+
+ def _get_clock(self) -> dict[str, Any] | None:
+ if not self.coordinator.data:
+ return None
+ for clock in self.coordinator.data.get("sync_clocks", []):
+ if clock.get("id") == self._clock_id:
+ return clock
+ return None
diff --git a/custom_components/ledgrab/sensor.py b/custom_components/ledgrab/sensor.py
index 62dca1d..500ab92 100644
--- a/custom_components/ledgrab/sensor.py
+++ b/custom_components/ledgrab/sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from datetime import datetime
import logging
from typing import Any
@@ -11,7 +12,14 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ PERCENTAGE,
+ UnitOfInformation,
+ UnitOfTemperature,
+ UnitOfTime,
+)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -47,9 +55,55 @@ async def async_setup_entry(
if info.get("target_type") == TARGET_TYPE_HA_LIGHT:
entities.append(HALightMappedLightsSensor(coordinator, target_id, entry.entry_id))
+ if coordinator.data:
+ for clock in coordinator.data.get("sync_clocks", []):
+ entities.append(SyncClockElapsedSensor(coordinator, clock["id"], entry.entry_id))
+
+ entities.extend(_build_server_sensors(coordinator, entry.entry_id))
+
async_add_entities(entities)
+def _build_server_sensors(
+ coordinator: LedGrabCoordinator,
+ entry_id: str,
+) -> list[SensorEntity]:
+ """Build the server-level telemetry sensors.
+
+ GPU/CPU-temp/battery sensors are only registered when the first refresh
+ actually carries those readings — we don't want phantom unavailable
+ entities on hardware that doesn't support a metric. A reload picks up
+ metrics that come online later (e.g. user installs LibreHardwareMonitor).
+ """
+ system = (coordinator.data or {}).get("system") or {}
+ perf = system.get("performance") or {}
+ health = system.get("health")
+
+ sensors: list[SensorEntity] = [
+ ServerCpuPercentSensor(coordinator, entry_id),
+ ServerRamPercentSensor(coordinator, entry_id),
+ ServerAppCpuPercentSensor(coordinator, entry_id),
+ ServerAppRamSensor(coordinator, entry_id),
+ ServerVersionSensor(coordinator, entry_id),
+ ]
+
+ if health is not None:
+ # Only meaningful when /health actually responded on first refresh.
+ sensors.append(ServerLastRestartSensor(coordinator, entry_id))
+
+ if perf.get("gpu") is not None:
+ sensors.append(ServerGpuUtilizationSensor(coordinator, entry_id))
+ sensors.append(ServerGpuTemperatureSensor(coordinator, entry_id))
+
+ if perf.get("cpu_temp_c") is not None:
+ sensors.append(ServerCpuTemperatureSensor(coordinator, entry_id))
+
+ if perf.get("battery_percent") is not None:
+ sensors.append(ServerBatterySensor(coordinator, entry_id))
+
+ return sensors
+
+
class LedGrabFPSSensor(CoordinatorEntity, SensorEntity):
"""FPS sensor for a LED Screen Controller target."""
@@ -160,6 +214,60 @@ class LedGrabStatusSensor(CoordinatorEntity, SensorEntity):
return self.coordinator.data.get("targets", {}).get(self._target_id)
+class SyncClockElapsedSensor(CoordinatorEntity, SensorEntity):
+ """Elapsed seconds since the sync clock last reset.
+
+ Disabled by default — the value advances on every coordinator poll
+ (~3s) while the clock is running, which would write a recorder row
+ each refresh. Power users can enable it explicitly.
+ """
+
+ _attr_has_entity_name = True
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_device_class = SensorDeviceClass.DURATION
+ _attr_native_unit_of_measurement = UnitOfTime.SECONDS
+ _attr_icon = "mdi:timer-outline"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
+ _attr_suggested_display_precision = 1
+
+ def __init__(
+ self,
+ coordinator: LedGrabCoordinator,
+ clock_id: str,
+ entry_id: str,
+ ) -> None:
+ super().__init__(coordinator)
+ self._clock_id = clock_id
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{clock_id}_elapsed"
+ self._attr_translation_key = "sync_clock_elapsed"
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, self._clock_id)}}
+
+ @property
+ def native_value(self) -> float | None:
+ clock = self._get_clock()
+ if not clock:
+ return None
+ elapsed = clock.get("elapsed_time")
+ return float(elapsed) if elapsed is not None else None
+
+ @property
+ def available(self) -> bool:
+ return self._get_clock() is not None
+
+ def _get_clock(self) -> dict[str, Any] | None:
+ if not self.coordinator.data:
+ return None
+ for clock in self.coordinator.data.get("sync_clocks", []):
+ if clock.get("id") == self._clock_id:
+ return clock
+ return None
+
+
class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the number of mapped HA lights for an HA Light target."""
@@ -223,3 +331,274 @@ class HALightMappedLightsSensor(CoordinatorEntity, SensorEntity):
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
+
+
+# ─────────────────────────── Server-level sensors ───────────────────────────
+
+
+class _ServerSensorBase(CoordinatorEntity, SensorEntity):
+ """Shared plumbing for sensors attached to the synthetic Server device."""
+
+ _attr_has_entity_name = True
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ coordinator: LedGrabCoordinator,
+ entry_id: str,
+ unique_suffix: str,
+ translation_key: str,
+ ) -> None:
+ super().__init__(coordinator)
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{entry_id}_server_{unique_suffix}"
+ self._attr_translation_key = translation_key
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, f"{self._entry_id}_server")}}
+
+ def _perf(self) -> dict[str, Any] | None:
+ if not self.coordinator.data:
+ return None
+ system = self.coordinator.data.get("system") or {}
+ return system.get("performance")
+
+ @property
+ def available(self) -> bool:
+ # Performance metrics are missing whenever /api/v1/system/performance
+ # fails — surface that as unavailable instead of stale numbers.
+ return self._perf() is not None
+
+
+class ServerCpuPercentSensor(_ServerSensorBase):
+ """System-wide CPU usage on the led-grab server."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_icon = "mdi:cpu-64-bit"
+ _attr_suggested_display_precision = 1
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "cpu_percent", "server_cpu_percent")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("cpu_percent")
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ perf = self._perf() or {}
+ return {"cpu_name": perf.get("cpu_name")}
+
+
+class ServerRamPercentSensor(_ServerSensorBase):
+ """System-wide RAM usage on the led-grab server."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_icon = "mdi:memory"
+ _attr_suggested_display_precision = 1
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "ram_percent", "server_ram_percent")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("ram_percent")
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ perf = self._perf() or {}
+ return {
+ "used_mb": perf.get("ram_used_mb"),
+ "total_mb": perf.get("ram_total_mb"),
+ }
+
+
+class ServerAppCpuPercentSensor(_ServerSensorBase):
+ """CPU usage of the led-grab process itself."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_icon = "mdi:application-cog"
+ _attr_suggested_display_precision = 1
+ _attr_entity_registry_enabled_default = False
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "app_cpu_percent", "server_app_cpu_percent")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("app_cpu_percent")
+
+
+class ServerAppRamSensor(_ServerSensorBase):
+ """Resident memory of the led-grab process."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_device_class = SensorDeviceClass.DATA_SIZE
+ _attr_native_unit_of_measurement = UnitOfInformation.MEGABYTES
+ _attr_icon = "mdi:memory"
+ _attr_suggested_display_precision = 1
+ _attr_entity_registry_enabled_default = False
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "app_ram_mb", "server_app_ram")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("app_ram_mb")
+
+
+class ServerGpuUtilizationSensor(_ServerSensorBase):
+ """GPU utilization (NVML)."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_icon = "mdi:expansion-card"
+ _attr_suggested_display_precision = 1
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "gpu_utilization", "server_gpu_utilization")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ if perf is None:
+ return None
+ gpu = perf.get("gpu") or {}
+ return gpu.get("utilization")
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ perf = self._perf() or {}
+ gpu = perf.get("gpu") or {}
+ return {
+ "name": gpu.get("name"),
+ "memory_used_mb": gpu.get("memory_used_mb"),
+ "memory_total_mb": gpu.get("memory_total_mb"),
+ "app_memory_mb": gpu.get("app_memory_mb"),
+ }
+
+ @property
+ def available(self) -> bool:
+ perf = self._perf()
+ return perf is not None and perf.get("gpu") is not None
+
+
+class ServerGpuTemperatureSensor(_ServerSensorBase):
+ """GPU temperature (NVML)."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_device_class = SensorDeviceClass.TEMPERATURE
+ _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
+ _attr_suggested_display_precision = 0
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "gpu_temp", "server_gpu_temp")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ if perf is None:
+ return None
+ gpu = perf.get("gpu") or {}
+ return gpu.get("temperature_c")
+
+ @property
+ def available(self) -> bool:
+ perf = self._perf()
+ return perf is not None and (perf.get("gpu") or {}).get("temperature_c") is not None
+
+
+class ServerCpuTemperatureSensor(_ServerSensorBase):
+ """Hottest CPU/SoC thermal-zone reading.
+
+ Only registered when the first refresh actually reports a temperature —
+ Windows needs LibreHardwareMonitor / OpenHardwareMonitor publishing WMI
+ sensors, otherwise the field stays null.
+ """
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_device_class = SensorDeviceClass.TEMPERATURE
+ _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
+ _attr_suggested_display_precision = 0
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "cpu_temp", "server_cpu_temp")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("cpu_temp_c")
+
+
+class ServerBatterySensor(_ServerSensorBase):
+ """Battery charge level (laptops / portable hosts)."""
+
+ _attr_state_class = SensorStateClass.MEASUREMENT
+ _attr_device_class = SensorDeviceClass.BATTERY
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_suggested_display_precision = 0
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "battery", "server_battery")
+
+ @property
+ def native_value(self) -> float | None:
+ perf = self._perf()
+ return None if perf is None else perf.get("battery_percent")
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ perf = self._perf() or {}
+ return {"temperature_c": perf.get("battery_temp_c")}
+
+
+class ServerLastRestartSensor(_ServerSensorBase):
+ """Timestamp of the last server start.
+
+ Derived from ``/health.uptime_seconds``; the coordinator caches the
+ computed boot time and only refreshes it when the candidate moves more
+ than a few seconds, so the recorder doesn't get poll-jitter writes.
+ """
+
+ _attr_device_class = SensorDeviceClass.TIMESTAMP
+ _attr_icon = "mdi:restart"
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "last_restart", "server_last_restart")
+
+ @property
+ def native_value(self) -> datetime | None:
+ if not self.coordinator.data:
+ return None
+ system = self.coordinator.data.get("system") or {}
+ return system.get("boot_time")
+
+ @property
+ def available(self) -> bool:
+ return self.native_value is not None
+
+
+class ServerVersionSensor(_ServerSensorBase):
+ """Reported led-grab server version."""
+
+ _attr_icon = "mdi:tag-outline"
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator, entry_id, "version", "server_version")
+
+ @property
+ def native_value(self) -> str | None:
+ version = self.coordinator.server_version
+ return version if version and version != "unknown" else None
+
+ @property
+ def available(self) -> bool:
+ return self.native_value is not None
diff --git a/custom_components/ledgrab/strings.json b/custom_components/ledgrab/strings.json
index 2292311..3f326a9 100644
--- a/custom_components/ledgrab/strings.json
+++ b/custom_components/ledgrab/strings.json
@@ -44,7 +44,7 @@
"name": "Processing"
},
"sync_clock_running": {
- "name": "Running"
+ "name": "Active"
}
},
"sensor": {
@@ -65,6 +65,36 @@
},
"sync_clock_elapsed": {
"name": "Elapsed Time"
+ },
+ "server_cpu_percent": {
+ "name": "CPU Usage"
+ },
+ "server_ram_percent": {
+ "name": "RAM Usage"
+ },
+ "server_app_cpu_percent": {
+ "name": "App CPU Usage"
+ },
+ "server_app_ram": {
+ "name": "App Memory"
+ },
+ "server_gpu_utilization": {
+ "name": "GPU Usage"
+ },
+ "server_gpu_temp": {
+ "name": "GPU Temperature"
+ },
+ "server_cpu_temp": {
+ "name": "CPU Temperature"
+ },
+ "server_battery": {
+ "name": "Battery"
+ },
+ "server_last_restart": {
+ "name": "Last Restart"
+ },
+ "server_version": {
+ "name": "Server Version"
}
},
"number": {
@@ -105,6 +135,11 @@
"nearest": "Nearest"
}
}
+ },
+ "update": {
+ "server_update": {
+ "name": "Server Update"
+ }
}
},
"services": {
diff --git a/custom_components/ledgrab/switch.py b/custom_components/ledgrab/switch.py
index 4a46402..b865f6e 100644
--- a/custom_components/ledgrab/switch.py
+++ b/custom_components/ledgrab/switch.py
@@ -25,13 +25,19 @@ async def async_setup_entry(
data = hass.data[DOMAIN][entry.entry_id]
coordinator: LedGrabCoordinator = data[DATA_COORDINATOR]
- entities = []
+ entities: list[SwitchEntity] = []
if coordinator.data and "targets" in coordinator.data:
for target_id, target_data in coordinator.data["targets"].items():
entities.append(
LedGrabSwitch(coordinator, target_id, entry.entry_id)
)
+ if coordinator.data:
+ for clock in coordinator.data.get("sync_clocks", []):
+ entities.append(
+ LedGrabSyncClockSwitch(coordinator, clock["id"], entry.entry_id)
+ )
+
async_add_entities(entities)
@@ -107,3 +113,54 @@ class LedGrabSwitch(CoordinatorEntity, SwitchEntity):
if not self.coordinator.data:
return None
return self.coordinator.data.get("targets", {}).get(self._target_id)
+
+
+class LedGrabSyncClockSwitch(CoordinatorEntity, SwitchEntity):
+ """Running/paused control for a sync clock.
+
+ On = clock running (linked animations advance), off = paused (frozen).
+ """
+
+ _attr_has_entity_name = True
+ _attr_icon = "mdi:clock-outline"
+
+ def __init__(
+ self,
+ coordinator: LedGrabCoordinator,
+ clock_id: str,
+ entry_id: str,
+ ) -> None:
+ super().__init__(coordinator)
+ self._clock_id = clock_id
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{clock_id}_running"
+ self._attr_translation_key = "sync_clock_running"
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, self._clock_id)}}
+
+ @property
+ def is_on(self) -> bool:
+ clock = self._get_clock()
+ if not clock:
+ return False
+ return bool(clock.get("is_running", False))
+
+ @property
+ def available(self) -> bool:
+ return self._get_clock() is not None
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ await self.coordinator.resume_sync_clock(self._clock_id)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ await self.coordinator.pause_sync_clock(self._clock_id)
+
+ def _get_clock(self) -> dict[str, Any] | None:
+ if not self.coordinator.data:
+ return None
+ for clock in self.coordinator.data.get("sync_clocks", []):
+ if clock.get("id") == self._clock_id:
+ return clock
+ return None
diff --git a/custom_components/ledgrab/translations/en.json b/custom_components/ledgrab/translations/en.json
index 5452dc0..a068aac 100644
--- a/custom_components/ledgrab/translations/en.json
+++ b/custom_components/ledgrab/translations/en.json
@@ -29,6 +29,9 @@
"button": {
"activate_scene": {
"name": "{scene_name}"
+ },
+ "sync_clock_reset": {
+ "name": "Reset"
}
},
"light": {
@@ -39,6 +42,9 @@
"switch": {
"processing": {
"name": "Processing"
+ },
+ "sync_clock_running": {
+ "name": "Active"
}
},
"sensor": {
@@ -56,6 +62,39 @@
},
"mapped_lights": {
"name": "Mapped Lights"
+ },
+ "sync_clock_elapsed": {
+ "name": "Elapsed Time"
+ },
+ "server_cpu_percent": {
+ "name": "CPU Usage"
+ },
+ "server_ram_percent": {
+ "name": "RAM Usage"
+ },
+ "server_app_cpu_percent": {
+ "name": "App CPU Usage"
+ },
+ "server_app_ram": {
+ "name": "App Memory"
+ },
+ "server_gpu_utilization": {
+ "name": "GPU Usage"
+ },
+ "server_gpu_temp": {
+ "name": "GPU Temperature"
+ },
+ "server_cpu_temp": {
+ "name": "CPU Temperature"
+ },
+ "server_battery": {
+ "name": "Battery"
+ },
+ "server_last_restart": {
+ "name": "Last Restart"
+ },
+ "server_version": {
+ "name": "Server Version"
}
},
"number": {
@@ -76,6 +115,9 @@
},
"api_input_timeout": {
"name": "Fallback Timeout"
+ },
+ "sync_clock_speed": {
+ "name": "Speed"
}
},
"select": {
@@ -93,6 +135,11 @@
"nearest": "Nearest"
}
}
+ },
+ "update": {
+ "server_update": {
+ "name": "Server Update"
+ }
}
}
}
diff --git a/custom_components/ledgrab/translations/ru.json b/custom_components/ledgrab/translations/ru.json
index 3ada8ba..faa322b 100644
--- a/custom_components/ledgrab/translations/ru.json
+++ b/custom_components/ledgrab/translations/ru.json
@@ -29,6 +29,9 @@
"button": {
"activate_scene": {
"name": "{scene_name}"
+ },
+ "sync_clock_reset": {
+ "name": "Сброс"
}
},
"light": {
@@ -39,6 +42,9 @@
"switch": {
"processing": {
"name": "Обработка"
+ },
+ "sync_clock_running": {
+ "name": "Активно"
}
},
"sensor": {
@@ -56,6 +62,39 @@
},
"mapped_lights": {
"name": "Привязанные светильники"
+ },
+ "sync_clock_elapsed": {
+ "name": "Прошло времени"
+ },
+ "server_cpu_percent": {
+ "name": "Загрузка CPU"
+ },
+ "server_ram_percent": {
+ "name": "Загрузка ОЗУ"
+ },
+ "server_app_cpu_percent": {
+ "name": "CPU приложения"
+ },
+ "server_app_ram": {
+ "name": "Память приложения"
+ },
+ "server_gpu_utilization": {
+ "name": "Загрузка GPU"
+ },
+ "server_gpu_temp": {
+ "name": "Температура GPU"
+ },
+ "server_cpu_temp": {
+ "name": "Температура CPU"
+ },
+ "server_battery": {
+ "name": "Батарея"
+ },
+ "server_last_restart": {
+ "name": "Последний запуск"
+ },
+ "server_version": {
+ "name": "Версия сервера"
}
},
"number": {
@@ -76,6 +115,9 @@
},
"api_input_timeout": {
"name": "Таймаут подмены"
+ },
+ "sync_clock_speed": {
+ "name": "Скорость"
}
},
"select": {
@@ -93,6 +135,11 @@
"nearest": "Ближайший"
}
}
+ },
+ "update": {
+ "server_update": {
+ "name": "Обновление сервера"
+ }
}
}
}
diff --git a/custom_components/ledgrab/update.py b/custom_components/ledgrab/update.py
new file mode 100644
index 0000000..93ddda2
--- /dev/null
+++ b/custom_components/ledgrab/update.py
@@ -0,0 +1,168 @@
+"""Update platform for LED Screen Controller.
+
+Surfaces the led-grab server's auto-update service (``/api/v1/system/update``)
+as a Home Assistant Update entity attached to the synthetic Server device.
+
+The entity is only registered when the server actually exposes the endpoint —
+older servers return 404 and the coordinator stores ``None`` in
+``data["system"]["update"]``.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from homeassistant.components.update import (
+ UpdateDeviceClass,
+ UpdateEntity,
+ UpdateEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DATA_COORDINATOR, DOMAIN
+from .coordinator import LedGrabCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+# release_summary is capped at 255 chars by HA's frontend; the full markdown
+# body is exposed via async_release_notes() and rendered in the notes dialog.
+_RELEASE_SUMMARY_MAX = 255
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the server update entity, when supported by the server."""
+ coordinator: LedGrabCoordinator = hass.data[DOMAIN][entry.entry_id][
+ DATA_COORDINATOR
+ ]
+
+ update_data = (coordinator.data or {}).get("system", {}).get("update")
+ if update_data is None:
+ # Server doesn't expose /api/v1/system/update/status — skip silently.
+ return
+
+ async_add_entities([LedGrabServerUpdate(coordinator, entry.entry_id)])
+
+
+class LedGrabServerUpdate(CoordinatorEntity, UpdateEntity):
+ """Update entity for the led-grab server itself."""
+
+ _attr_has_entity_name = True
+ _attr_translation_key = "server_update"
+ _attr_device_class = UpdateDeviceClass.FIRMWARE
+
+ def __init__(self, coordinator: LedGrabCoordinator, entry_id: str) -> None:
+ super().__init__(coordinator)
+ self._entry_id = entry_id
+ self._attr_unique_id = f"{entry_id}_server_update"
+
+ @property
+ def device_info(self) -> dict[str, Any]:
+ return {"identifiers": {(DOMAIN, f"{self._entry_id}_server")}}
+
+ def _data(self) -> dict[str, Any] | None:
+ if not self.coordinator.data:
+ return None
+ return (self.coordinator.data.get("system") or {}).get("update")
+
+ def _release(self) -> dict[str, Any]:
+ return (self._data() or {}).get("release") or {}
+
+ @property
+ def supported_features(self) -> UpdateEntityFeature:
+ # The server's apply endpoint refuses to run for install types that
+ # can't auto-update (e.g. docker-managed containers). Hide the install
+ # button in those cases instead of letting users click it and 400.
+ features = UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES
+ data = self._data() or {}
+ if data.get("can_auto_update"):
+ features |= UpdateEntityFeature.INSTALL
+ return features
+
+ @property
+ def installed_version(self) -> str | None:
+ data = self._data() or {}
+ return data.get("current_version")
+
+ @property
+ def latest_version(self) -> str | None:
+ data = self._data()
+ if not data:
+ return None
+ if data.get("has_update"):
+ return self._release().get("version") or data.get("current_version")
+ # No update — match installed so HA shows "Up to date".
+ return data.get("current_version")
+
+ @property
+ def release_url(self) -> str | None:
+ data = self._data() or {}
+ base = data.get("releases_url") or None
+ tag = self._release().get("tag")
+ if base and tag:
+ # Works for both Gitea (`//releases/tag/`)
+ # and GitHub — `releases_url` already ends in `/releases`.
+ return f"{base.rstrip('/')}/tag/{tag}"
+ return base or None
+
+ @property
+ def release_summary(self) -> str | None:
+ body = self._release().get("body")
+ if not body:
+ return None
+ return body[:_RELEASE_SUMMARY_MAX]
+
+ async def async_release_notes(self) -> str | None:
+ """Return the full release body for the notes dialog."""
+ return self._release().get("body") or None
+
+ @property
+ def in_progress(self) -> bool | int:
+ data = self._data() or {}
+ if data.get("applying"):
+ # Apply phase has no granular progress reporting from the server.
+ return True
+ if data.get("downloading"):
+ # Server reports 0..1; HA wants 0..100.
+ progress = float(data.get("download_progress") or 0.0)
+ return max(0, min(100, int(round(progress * 100))))
+ return False
+
+ @property
+ def available(self) -> bool:
+ return self._data() is not None
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ data = self._data() or {}
+ return {
+ "install_type": data.get("install_type"),
+ "can_auto_update": data.get("can_auto_update"),
+ "checking": data.get("checking"),
+ "last_check": data.get("last_check"),
+ "last_error": data.get("last_error"),
+ "dismissed_version": data.get("dismissed_version"),
+ "prerelease": self._release().get("prerelease"),
+ "published_at": self._release().get("published_at"),
+ }
+
+ async def async_install(
+ self,
+ version: str | None,
+ backup: bool,
+ **kwargs: Any,
+ ) -> None:
+ """Apply the available update.
+
+ The server picks up whichever release it has cached; the ``version``
+ and ``backup`` arguments from HA aren't separately addressable on the
+ server side, so we ignore them.
+ """
+ await self.coordinator.apply_update()