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>
207 lines
9.5 KiB
Python
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)
|