Files
FabledSteward/plugins/homeassistant/routes.py
T
bvandeusenandClaude Opus 5.5 b2a6e92ed3
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped
feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
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>
2026-10-30 23:09:27 -04:00

207 lines
9.5 KiB
Python

"""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)