Files
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

169 lines
5.8 KiB
Python

"""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 (`<base>/<repo>/releases/tag/<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()