feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped

A new plugin reads Home Assistant with a read-only token every 5 minutes and
shows which Core, OS and add-on updates are waiting and how old the last backup
is. Install takes a backup through a local-only webhook, confirms Home
Assistant's last-backup time moved, installs, and follows the update to its end,
through Home Assistant's and Steward's own restarts. One runs at a time. Each
ends as one timeline entry.

Steward holds nothing that can act on the house: the webhook's automation can
only take a backup or install an allowed update (System 231's ruling). A refused
token stops polling, as Home Assistant bans an address after a few refusals.

Milestone 576 steps 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 23:09:27 -04:00
co-authored by Claude Opus 5.5
parent 8b9b7fc11d
commit b2a6e92ed3
27 changed files with 1802 additions and 4 deletions
+206
View File
@@ -0,0 +1,206 @@
"""The Home Assistant page. Every GET reads Steward's own records, which the poll
fills. Only Test and Install, an admin's explicit actions, ask Home Assistant
anything (System 178's ruling)."""
from __future__ import annotations
from datetime import datetime, timezone
from quart import Blueprint, current_app, redirect, render_template, request, session
from sqlalchemy import select
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core.crypto import decrypt_secret, encrypt_secret
from steward.models.users import UserRole
from . import client
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import (STALE_BACKUP_HOURS, automation_yaml, backup_age_hours, backup_verdict, installable,
new_webhook_id, no_spaces, parse_moment, read_snapshot)
from .updates import CannotStart, start_update
homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates")
RECENT_UPDATES = 10
def _actor() -> tuple[str | None, str]:
return session.get("user_id"), session.get("username", "")
async def _connection(db) -> HomeAssistantConnection | None:
return (await db.execute(select(HomeAssistantConnection))).scalars().first()
def connection_says(connection, now: datetime) -> tuple[str, str]:
"""(badge state, sentence) for whether Home Assistant is answering."""
if connection.refused:
return "down", f"{connection.last_error} Steward has stopped asking until you save the connection or try again."
if connection.last_polled_at is None:
return "pending", "Not read yet: the first read runs within seconds."
if connection.last_error:
return "down", connection.last_error
return "up", "Answering."
async def _render_index(error: str | None = None, status: int = 200):
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
if connection is None:
return await render_template("homeassistant/index.html", connection=None), status
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,
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
@homeassistant_bp.get("/")
@require_role(UserRole.viewer)
async def index():
return await _render_index()
@homeassistant_bp.post("/install")
@require_role(UserRole.admin)
async def install():
form = await request.form
entity_id = (form.get("entity_id") or "").strip()
user_id, username = _actor()
try:
run = await start_update(current_app, entity_id, username)
except CannotStart as exc:
return await _render_index(f"Nothing was started: {exc}.", 409)
await log_audit(current_app, user_id, username, "homeassistant.update_started",
entity_type="homeassistant_update", entity_id=run.id,
detail={"entity_id": entity_id, "title": run.title, "to": run.to_version})
return redirect("/plugins/homeassistant/")
# ── The connection ───────────────────────────────────────────────────────────
def _values(connection, form=None) -> dict:
if form is not None:
return {"url": form.get("url", ""), "verify_tls": bool(form.get("verify_tls")),
"has_token": bool(connection), "has_webhook": bool(connection)}
return {"url": connection.url if connection else "", "verify_tls": connection.verify_tls if connection else True,
"has_token": bool(connection), "has_webhook": bool(connection)}
async def _render_form(connection, values: dict, error: str | None = None, status: int = 200):
yaml_text = automation_yaml(decrypt_secret(connection.webhook_id, context="Home Assistant webhook id")) \
if connection is not None else None
return await render_template("homeassistant/form.html", connection=connection, v=values, error=error,
automation=yaml_text), status
@homeassistant_bp.get("/connection")
@require_role(UserRole.admin)
async def connection_form():
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
return await _render_form(connection, _values(connection))
@homeassistant_bp.post("/connection")
@require_role(UserRole.admin)
async def save_connection():
form = await request.form
url = (form.get("url") or "").strip().rstrip("/")
token, webhook = no_spaces(form.get("token")), no_spaces(form.get("webhook_id"))
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
error = None
if not url.startswith(("http://", "https://")):
error = "Enter Home Assistant's address: http://… or https://…"
elif not token and connection is None:
error = "Enter a token: a long-lived token of a read-only Home Assistant user."
if error:
return await _render_form(connection, _values(connection, form), error, 422)
if connection is None:
connection = HomeAssistantConnection(url=url, token=encrypt_secret(token),
webhook_id=encrypt_secret(webhook or new_webhook_id()))
db.add(connection)
else:
connection.url = url
if token:
connection.token = encrypt_secret(token)
if webhook:
connection.webhook_id = encrypt_secret(webhook)
connection.verify_tls = bool(form.get("verify_tls"))
connection.refused, connection.last_error, connection.last_polled_at = False, None, None
await db.flush()
connection_id = connection.id
await log_audit(current_app, *_actor(), "homeassistant.connection_saved",
entity_type="homeassistant_connection", entity_id=connection_id, detail={"url": url})
return redirect("/plugins/homeassistant/connection")
@homeassistant_bp.post("/connection/retry")
@require_role(UserRole.admin)
async def retry_connection():
"""Ask again after a refusal, once the token or a ban has been dealt with."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
if connection is not None:
connection.refused, connection.last_polled_at = False, None
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/connection/delete")
@require_role(UserRole.admin)
async def delete_connection():
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)
if connection is None:
return redirect("/plugins/homeassistant/")
connection_id = connection.id
await db.delete(connection)
await log_audit(current_app, *_actor(), "homeassistant.connection_removed",
entity_type="homeassistant_connection", entity_id=connection_id)
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/connection/test")
@require_role(UserRole.admin)
async def test_connection():
"""Read Home Assistant once with what's typed and say what Steward would see.
An admin's explicit action, so it may contact it."""
form = await request.form
url = (form.get("url") or "").strip().rstrip("/")
token = no_spaces(form.get("token"))
if not token:
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if connection is not None:
token = decrypt_secret(connection.token, context="Home Assistant token")
found: dict = {}
if not url.startswith(("http://", "https://")):
found["error"] = "Enter the address first: http://… or https://…"
elif not token:
found["error"] = "Enter a token first. A request without one counts toward Home Assistant banning this address."
else:
try:
states = await client.read_states(url, token, bool(form.get("verify_tls")))
except (client.Unreachable, client.Refused) as exc:
found["error"] = str(exc)
else:
snapshot = read_snapshot(states)
found = {"entities": len(states), "installable": sum(1 for u in snapshot["updates"] if u["waiting"] and u["kind"]),
"updates": sum(1 for u in snapshot["updates"] if u["kind"]),
"backup": snapshot["backup"]["known"], "automation": snapshot["automation"]}
return await render_template("homeassistant/_test.html", found=found)