feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped

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:
2026-11-01 14:16:42 -05:00
co-authored by Claude Opus 5.5
parent 4c9ed02652
commit f7a856cb90
31 changed files with 835 additions and 503 deletions
+31 -26
View File
@@ -13,14 +13,16 @@ from quart import Blueprint, current_app, redirect, render_template, request, se
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.core import weekly
from steward.core.audit import log_audit
from steward.core.crypto import encrypt_secret
from steward.core.formatting import zone
from steward.core.settings import public_base_url
from steward.models.users import UserRole
from steward.models.users import User, UserRole
from . import client, metrics
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
from . import client, schedule
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open
from .updates import CannotStart, start_update
logger = logging.getLogger(__name__)
@@ -55,23 +57,29 @@ async def _render_index(error: str | None = None, status: int = 200):
connection = await _connection(db)
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
scrape = await metrics.state(db, connection) if connection else None
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
me = await db.get(User, session.get("user_id")) if session.get("user_id") else None
if connection is None:
return await render_template("homeassistant/index.html", connection=None, error=error), status
default_zone = (me.time_zone if me and me.time_zone else "UTC")
now = datetime.now(timezone.utc)
reach, says = connection_says(connection, now)
updates = connection.updates or []
backup = connection.backup or {}
tone, backup_says = backup_verdict(backup, now)
return await render_template(
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says, metrics=scrape,
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says,
installable=installable(connection),
others=[u for u in updates if u["waiting"] and not u["kind"]],
current=[u for u in updates if not u["waiting"] and u["kind"]],
runs=runs, under_way=next((r for r in runs if r.state in ("backing_up", "installing")), None),
backup=backup, backup_tone=tone, backup_says=backup_says,
backup_at=parse_moment(backup.get("last_ok")), backup_next=parse_moment(backup.get("next")),
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS), status
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS,
health=connection.health or {}, problems=problems_open(connection.health),
plan=plan, plan_when=weekly.describe(plan) if plan else None,
plan_next=weekly.next_slot(plan, now) if plan and plan.enabled else None,
weekdays=weekly.WEEKDAYS, default_zone=default_zone), status
@homeassistant_bp.get("/")
@@ -96,23 +104,23 @@ async def install():
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/metrics")
@homeassistant_bp.post("/schedule")
@require_role(UserRole.admin)
async def connect_metrics():
"""Read Home Assistant's metrics with this sign-in, after asking for them once
to show they're there. An admin's explicit action, so it may ask."""
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if connection is None:
return redirect("/plugins/homeassistant/")
async def save_schedule():
"""Set the weekly slot for installing what's waiting, or turn it off."""
form = await request.form
time_zone = (form.get("time_zone") or "UTC").strip()
try:
await metrics.try_reading(current_app, connection)
target_id, name, created = await metrics.use_sign_in(current_app, connection)
except metrics.CannotRead as exc:
return await _render_index(f"Nothing changed: {exc}", 409)
await log_audit(current_app, *_actor(), "prometheus.target_created" if created else "prometheus.target_updated",
entity_type="prometheus_target", entity_id=target_id,
detail={"name": name, "token_from": metrics.SOURCE})
weekday, at_time = int(form.get("weekday", "")), (form.get("at_time") or "").strip()
weekly.parse_at(at_time)
except ValueError:
return await _render_index("Choose a day and a time.", 422)
if not 0 <= weekday <= 6 or (zone(time_zone) is timezone.utc and time_zone.upper() != "UTC"):
return await _render_index(f"Choose a day, and a known time zone rather than “{time_zone}”.", 422)
row = await schedule.save(current_app, enabled=bool(form.get("enabled")), weekday=weekday,
at_time=at_time, time_zone=time_zone)
await log_audit(current_app, *_actor(), "homeassistant.schedule_saved", entity_type="homeassistant_schedule",
entity_id=row.id, detail={"enabled": row.enabled, "when": weekly.describe(row)})
return redirect("/plugins/homeassistant/")
@@ -188,7 +196,6 @@ async def sign_in_callback():
connection.can_install, connection.signed_in_by = can_install, username
connection.signed_in_at = datetime.now(timezone.utc)
connection.refused, connection.last_error, connection.last_polled_at = False, None, None
await metrics.follow_sign_in(db, connection)
await db.flush()
connection_id = connection.id
if previous is not None:
@@ -214,9 +221,7 @@ async def _end_sign_in(connection) -> bool:
@homeassistant_bp.post("/connection/retry")
@require_role(UserRole.admin)
async def retry_connection():
"""Ask again after a refusal, once a ban has been dealt with. A metrics scrape
using this sign-in starts again with it."""
client.forget_access()
"""Ask again after a refusal, once a ban has been dealt with."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)