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:
@@ -5,23 +5,35 @@ update as the page's Install does. The sign-in Steward holds is never returned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.core import weekly
|
||||
from steward.core.formatting import zone
|
||||
from steward.mcp._context import current_caller, mcp_app
|
||||
from steward.mcp.tools._audit import audit_mcp, mcp_username
|
||||
from steward.mcp.tools._shape import age_s, db_session, iso, now_utc, redact_url
|
||||
from steward.mcp.tools.overview import finding
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from . import metrics
|
||||
from .models import HomeAssistantConnection, HomeAssistantUpdate
|
||||
from . import schedule
|
||||
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
|
||||
from .routes import connection_says
|
||||
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
|
||||
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open
|
||||
from .updates import CannotStart, start_update
|
||||
|
||||
RECENT_UPDATES = 10
|
||||
|
||||
|
||||
def _schedule_view(plan, now) -> dict | None:
|
||||
if plan is None:
|
||||
return None
|
||||
return {"enabled": plan.enabled, "when": weekly.describe(plan),
|
||||
"next_slot_at": iso(weekly.next_slot(plan, now)) if plan.enabled else None,
|
||||
"last_slot_at": iso(weekly.fired_slot(plan)), "stopped": plan.stopped}
|
||||
|
||||
|
||||
def _run_view(r: HomeAssistantUpdate) -> dict:
|
||||
return {"update_id": r.id, "entity_id": r.entity_id, "title": r.title, "kind": r.kind,
|
||||
"from_version": r.from_version, "to_version": r.to_version, "state": r.state, "message": r.message,
|
||||
@@ -36,16 +48,17 @@ async def get_home_assistant() -> dict:
|
||||
`installable` is what update_home_assistant takes (Core, OS, add-ons);
|
||||
`others_waiting` is firmware and custom integrations, which Steward leaves
|
||||
to Home Assistant. `can_install` is whether the Home Assistant user who signed
|
||||
Steward in is an administrator, which installing needs. `metrics` is the
|
||||
Prometheus target that reads its metrics, `uses_sign_in` saying whether that
|
||||
same user does; null when the Prometheus plugin is off.
|
||||
Steward in is an administrator, which installing needs. `system.problems` is
|
||||
what's wrong with the system itself: open repairs, Supervisor health, low disk,
|
||||
recovery mode, add-ons that should be running. `schedule` is the weekly slot
|
||||
for installing what's waiting, null until one is set.
|
||||
"""
|
||||
now = now_utc()
|
||||
async with db_session() as db:
|
||||
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
|
||||
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
|
||||
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
|
||||
scrape = await metrics.state(db, c) if c else None
|
||||
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
|
||||
if c is None:
|
||||
return {"connected": False, "says": "No connection yet: an admin signs in on the Home Assistant page."}
|
||||
reach, says = connection_says(c, now)
|
||||
@@ -59,7 +72,8 @@ async def get_home_assistant() -> dict:
|
||||
"backup": {"verdict": backup_verdict(backup, now)[1], "last_ok": iso(parse_moment(backup.get("last_ok"))),
|
||||
"age_hours": round(age, 1) if age is not None else None, "stale_after_hours": STALE_BACKUP_HOURS,
|
||||
"next": iso(parse_moment(backup.get("next"))), "last_result": backup.get("last_result")},
|
||||
"metrics": scrape, "updates": [_run_view(r) for r in runs]}
|
||||
"system": c.health or {}, "schedule": _schedule_view(plan, now),
|
||||
"updates": [_run_view(r) for r in runs]}
|
||||
|
||||
|
||||
async def update_home_assistant(entity_id: str) -> dict:
|
||||
@@ -82,9 +96,35 @@ async def update_home_assistant(entity_id: str) -> dict:
|
||||
return {**_run_view(run), "next": "get_home_assistant follows it: `updates` shows its state."}
|
||||
|
||||
|
||||
def overview_findings(c, latest_run, now) -> list[dict]:
|
||||
"""A connection that isn't answering, stale backups, and a newest update that
|
||||
failed. Updates waiting aren't a problem to raise."""
|
||||
async def set_home_assistant_schedule(enabled: bool, weekday: int = 5, at_time: str = "05:00",
|
||||
time_zone: str = "UTC") -> dict:
|
||||
"""Set the weekly slot for installing what's waiting, or turn it off, as the page does.
|
||||
|
||||
weekday: 0 for Monday to 6 for Sunday. at_time: "HH:MM" in time_zone.
|
||||
|
||||
At each slot Steward installs add-ons, then Core, then the OS, one at a time,
|
||||
each after a confirmed backup, stopping at the first failure. A Core update
|
||||
restarts Home Assistant and an OS update reboots the machine, with nobody
|
||||
watching. The first slot is the next one from now. Admin only, and audited.
|
||||
"""
|
||||
try:
|
||||
weekly.parse_at(at_time)
|
||||
except ValueError:
|
||||
raise ValueError(f"at_time is HH:MM, not {at_time!r}.") from None
|
||||
if not 0 <= weekday <= 6:
|
||||
raise ValueError("weekday is 0 for Monday to 6 for Sunday.")
|
||||
if zone(time_zone) is timezone.utc and time_zone.upper() != "UTC":
|
||||
raise ValueError(f"Unknown time zone {time_zone!r}.")
|
||||
row = await schedule.save(mcp_app(), enabled=enabled, weekday=weekday, at_time=at_time, time_zone=time_zone)
|
||||
await audit_mcp("homeassistant.schedule_saved", "homeassistant_schedule", row.id,
|
||||
{"enabled": row.enabled, "when": weekly.describe(row)})
|
||||
return _schedule_view(row, now_utc())
|
||||
|
||||
|
||||
def overview_findings(c, latest_run, now, plan=None) -> list[dict]:
|
||||
"""A connection that isn't answering, a problem with the system, stale backups,
|
||||
a newest update that failed, and a scheduled run that stopped. Updates waiting
|
||||
aren't a problem to raise."""
|
||||
if c is None:
|
||||
return []
|
||||
found = []
|
||||
@@ -92,6 +132,11 @@ def overview_findings(c, latest_run, now) -> list[dict]:
|
||||
if reach == "down":
|
||||
found.append(finding("warning", "home_assistant_down", "Home Assistant",
|
||||
f"Home Assistant isn't answering: {says}", since=c.last_ok_at, next="get_home_assistant()"))
|
||||
problems = problems_open(c.health)
|
||||
if problems:
|
||||
more = f" (and {len(problems) - 1} more)" if len(problems) > 1 else ""
|
||||
found.append(finding("warning", "home_assistant_problem", "Home Assistant", f"{problems[0]['title']}{more}",
|
||||
next="get_home_assistant()"))
|
||||
tone, backup_says = backup_verdict(c.backup, now)
|
||||
if c.last_ok_at is not None and tone == "warn":
|
||||
found.append(finding("warning", "home_assistant_backup", "Home Assistant", backup_says,
|
||||
@@ -100,6 +145,10 @@ def overview_findings(c, latest_run, now) -> list[dict]:
|
||||
found.append(finding("warning", "home_assistant_update_failed", latest_run.title,
|
||||
f"The update of {latest_run.title} failed: {latest_run.message}",
|
||||
since=latest_run.finished_at, next="get_home_assistant()"))
|
||||
if plan is not None and plan.stopped:
|
||||
found.append(finding("warning", "home_assistant_schedule_stopped", "Home Assistant",
|
||||
f"The scheduled update run stopped: {plan.stopped}", since=weekly.fired_slot(plan),
|
||||
next="get_home_assistant()"))
|
||||
return found
|
||||
|
||||
|
||||
@@ -107,9 +156,11 @@ async def mcp_overview(db) -> list[dict]:
|
||||
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
|
||||
latest = (await db.execute(select(HomeAssistantUpdate).order_by(
|
||||
HomeAssistantUpdate.started_at.desc()).limit(1))).scalars().first()
|
||||
return overview_findings(c, latest, now_utc())
|
||||
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
|
||||
return overview_findings(c, latest, now_utc(), plan)
|
||||
|
||||
|
||||
def register_tools(register) -> None:
|
||||
register(get_home_assistant, role=UserRole.viewer, read_only=True)
|
||||
register(update_home_assistant, role=UserRole.admin, read_only=False)
|
||||
register(set_home_assistant_schedule, role=UserRole.admin, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user