a666d9eb9c
- 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.
169 lines
5.8 KiB
Python
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()
|