The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.
A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.
The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
7.6 KiB
Python
159 lines
7.6 KiB
Python
# plugins/homeassistant/service.py
|
|
"""Reading what Home Assistant reports: which updates it knows of, which of them
|
|
Steward may install, how its backups stand, and whether the system has a problem."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
SOURCE = "homeassistant"
|
|
RESOURCE = "homeassistant"
|
|
# Backups run daily, so past a day and a bit one has been missed.
|
|
STALE_BACKUP_HOURS = 26
|
|
LOW_DISK_PCT = 10
|
|
|
|
CORE = "update.home_assistant_core_update"
|
|
OS = "update.home_assistant_operating_system_update"
|
|
SUPERVISOR = "update.home_assistant_supervisor_update"
|
|
LAST_BACKUP = "sensor.backup_last_successful_automatic_backup"
|
|
NEXT_BACKUP = "sensor.backup_next_scheduled_automatic_backup"
|
|
BACKUP_MANAGER = "sensor.backup_backup_manager_state"
|
|
BACKUP_EVENT = "event.backup_automatic_backup"
|
|
|
|
_KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"}
|
|
_KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3}
|
|
|
|
def classify(entity: dict) -> str | None:
|
|
""""core", "os" or "addon" for an update Steward may install; None for the
|
|
rest: Supervisor updates itself, and firmware and custom integrations are
|
|
Home Assistant's own to offer."""
|
|
entity_id = entity.get("entity_id")
|
|
if entity_id == CORE:
|
|
return "core"
|
|
if entity_id == OS:
|
|
return "os"
|
|
if entity_id == SUPERVISOR:
|
|
return None
|
|
picture = (entity.get("attributes") or {}).get("entity_picture") or ""
|
|
return "addon" if picture.startswith("/api/hassio/addons/") else None
|
|
|
|
|
|
def _update(entity: dict) -> dict:
|
|
a = entity.get("attributes") or {}
|
|
kind = classify(entity)
|
|
url = a.get("release_url") or ""
|
|
return {"entity_id": entity["entity_id"], "kind": kind, "kind_word": _KIND_WORDS.get(kind, "Other"),
|
|
"title": a.get("title") or a.get("friendly_name") or entity["entity_id"],
|
|
"installed": a.get("installed_version"), "latest": a.get("latest_version"),
|
|
"waiting": entity.get("state") == "on", "in_progress": bool(a.get("in_progress")),
|
|
"auto_update": bool(a.get("auto_update")),
|
|
"release_url": url if url.startswith(("http://", "https://")) else None}
|
|
|
|
|
|
def _state(entity: dict | None) -> str | None:
|
|
value = (entity or {}).get("state")
|
|
return None if value in (None, "unknown", "unavailable") else value
|
|
|
|
|
|
def parse_moment(value: str | None) -> datetime | None:
|
|
try:
|
|
moment = datetime.fromisoformat(value) if value else None
|
|
except ValueError:
|
|
return None
|
|
if moment is not None and moment.tzinfo is None:
|
|
moment = moment.replace(tzinfo=timezone.utc)
|
|
return moment
|
|
|
|
|
|
def is_newer(value: str | None, than: str | None) -> bool:
|
|
"""Whether reading `value` is a later moment than `than`, where `than` may be never."""
|
|
moment, before = parse_moment(value), parse_moment(than)
|
|
return moment is not None and (before is None or moment > before)
|
|
|
|
|
|
def read_snapshot(states: list[dict]) -> dict:
|
|
"""What a poll keeps of Home Assistant's entities."""
|
|
by_id = {e.get("entity_id"): e for e in states}
|
|
updates = [_update(e) for i, e in by_id.items() if (i or "").startswith("update.") and e.get("state") != "unavailable"]
|
|
updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower()))
|
|
event = by_id.get(BACKUP_EVENT) or {}
|
|
event_attrs = event.get("attributes") or {}
|
|
return {
|
|
"updates": updates,
|
|
"backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)),
|
|
"manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"),
|
|
"failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id},
|
|
}
|
|
|
|
|
|
def backup_age_hours(backup: dict | None, now: datetime) -> float | None:
|
|
last = parse_moment((backup or {}).get("last_ok"))
|
|
return None if last is None else max(0.0, (now - last).total_seconds() / 3600)
|
|
|
|
|
|
def installable(connection) -> list[dict]:
|
|
"""The waiting updates Steward may install."""
|
|
return [u for u in (connection.updates or []) if u["waiting"] and u["kind"]]
|
|
|
|
|
|
def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]:
|
|
"""(tone, sentence) for how the backups stand."""
|
|
backup = backup or {}
|
|
if not backup.get("known"):
|
|
return "warn", "Home Assistant reports no automatic backups. Turn them on under Settings → System → Backups."
|
|
age = backup_age_hours(backup, now)
|
|
if age is None:
|
|
return "warn", "Home Assistant has no successful automatic backup yet."
|
|
if backup.get("last_result") == "failed":
|
|
reason = backup.get("failed_reason")
|
|
return "warn", f"The newest backup attempt failed{f' ({reason})' if reason else ''}."
|
|
if age > STALE_BACKUP_HOURS:
|
|
return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected."
|
|
return "ok", "Backups are up to date."
|
|
|
|
|
|
def _issue_title(issue: dict, titles: dict) -> str:
|
|
key = issue.get("translation_key") or issue.get("issue_id") or ""
|
|
title = titles.get(f"component.{issue.get('domain')}.issues.{key}.title")
|
|
if not title:
|
|
return key.removeprefix("issue_").replace("_", " ").capitalize() or "A repair is open"
|
|
for name, value in (issue.get("translation_placeholders") or {}).items():
|
|
title = title.replace("{" + name + "}", str(value))
|
|
return title
|
|
|
|
|
|
def read_health(found: dict) -> dict:
|
|
"""What a poll keeps of client.read_system: the problems to show, and the disk.
|
|
A part Home Assistant wouldn't answer is left out rather than read as fine:
|
|
`repairs_read` says whether the repairs were seen, and `supervised` whether a
|
|
Supervisor answered, as it doesn't on every install or for every user."""
|
|
problems = []
|
|
if (found.get("config") or {}).get("recovery_mode"):
|
|
problems.append({"kind": "recovery", "severity": "critical",
|
|
"title": "Home Assistant started in recovery mode: its configuration didn't load."})
|
|
issues = found.get("issues")
|
|
for issue in issues or []:
|
|
if not issue.get("ignored") and not issue.get("dismissed_version"):
|
|
problems.append({"kind": "repair", "severity": issue.get("severity") or "warning",
|
|
"title": _issue_title(issue, found.get("titles") or {})})
|
|
resolution = found.get("resolution") or {}
|
|
for word in ("unhealthy", "unsupported"):
|
|
for reason in resolution.get(word) or []:
|
|
problems.append({"kind": "supervisor", "severity": "error" if word == "unhealthy" else "warning",
|
|
"title": f"The Supervisor calls this system {word}: {str(reason).replace('_', ' ')}."})
|
|
host = found.get("host") or {}
|
|
free, total = host.get("disk_free"), host.get("disk_total")
|
|
free_pct = round(100 * free / total, 1) if free is not None and total else None
|
|
if free_pct is not None and free_pct < LOW_DISK_PCT:
|
|
problems.append({"kind": "disk", "severity": "error", "title": f"Only {free:g} GB of {total:g} GB free."})
|
|
boots = found.get("boots") or {}
|
|
for addon in found.get("addons") or []:
|
|
if boots.get(addon.get("slug")) == "auto":
|
|
problems.append({"kind": "addon", "severity": "error",
|
|
"title": f"{addon.get('name') or addon.get('slug')} should start on its own and isn't running."})
|
|
return {"repairs_read": issues is not None, "supervised": found.get("resolution") is not None, "problems": problems,
|
|
"disk_free_gb": free, "disk_total_gb": total, "disk_free_pct": free_pct}
|
|
|
|
|
|
def problems_open(health: dict | None) -> list[dict]:
|
|
return (health or {}).get("problems") or []
|