feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# plugins/homeassistant/service.py
|
||||
"""Reading what Home Assistant reports: which updates it knows of, which of them
|
||||
Steward may install, and how its backups stand."""
|
||||
Steward may install, how its backups stand, and whether the system has a problem."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -108,3 +109,50 @@ def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]:
|
||||
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 []
|
||||
|
||||
Reference in New Issue
Block a user