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>
250 lines
12 KiB
Python
250 lines
12 KiB
Python
"""The Home Assistant page. Every GET reads Steward's own records, which the poll
|
|
fills. Only signing in, removing the connection and Install, an admin's explicit
|
|
actions, ask Home Assistant anything (System 178's ruling)."""
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
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 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 User, UserRole
|
|
|
|
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__)
|
|
|
|
homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates")
|
|
|
|
RECENT_UPDATES = 10
|
|
_SIGN_IN = "homeassistant_sign_in" # in the session between leaving for Home Assistant and coming back
|
|
|
|
|
|
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 sign in again 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())
|
|
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,
|
|
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,
|
|
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("/")
|
|
@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/")
|
|
|
|
|
|
@homeassistant_bp.post("/schedule")
|
|
@require_role(UserRole.admin)
|
|
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:
|
|
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/")
|
|
|
|
|
|
# ── The connection ───────────────────────────────────────────────────────────
|
|
|
|
async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None,
|
|
error: str | None = None, status: int = 200):
|
|
return await render_template(
|
|
"homeassistant/form.html", connection=connection, error=error,
|
|
url=url if url is not None else (connection.url if connection else ""),
|
|
verify_tls=verify_tls if verify_tls is not None else (connection.verify_tls if connection else True)), 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)
|
|
|
|
|
|
@homeassistant_bp.post("/connection/sign-in")
|
|
@require_role(UserRole.admin)
|
|
async def sign_in():
|
|
"""Send the browser to Home Assistant's own sign-in page."""
|
|
form = await request.form
|
|
url = (form.get("url") or "").strip().rstrip("/")
|
|
verify_tls = bool(form.get("verify_tls"))
|
|
if not url.startswith(("http://", "https://")):
|
|
async with current_app.db_sessionmaker() as db:
|
|
connection = await _connection(db)
|
|
return await _render_form(connection, url, verify_tls, "Enter Home Assistant's address: http://… or https://…", 422)
|
|
base, state = public_base_url(request), secrets.token_urlsafe(32)
|
|
client_id = f"{base}/"
|
|
session[_SIGN_IN] = {"state": state, "url": url, "verify_tls": verify_tls, "client_id": client_id}
|
|
return redirect(client.sign_in_url(url, client_id, f"{base}/plugins/homeassistant/connection/callback", state))
|
|
|
|
|
|
@homeassistant_bp.get("/connection/callback")
|
|
@require_role(UserRole.admin)
|
|
async def sign_in_callback():
|
|
"""Where Home Assistant sends the browser back. A GET that asks Home Assistant
|
|
something, because the admin's sign-in has only just happened and its code
|
|
lasts minutes. The state shows the sign-in began here, for this admin."""
|
|
pending = session.pop(_SIGN_IN, None)
|
|
state, code = request.args.get("state", ""), request.args.get("code", "")
|
|
async with current_app.db_sessionmaker() as db:
|
|
connection = await _connection(db)
|
|
if not pending or not hmac.compare_digest(pending["state"].encode(), state.encode()):
|
|
return await _render_form(connection, error="That sign-in didn't start from this page, so it wasn't used. "
|
|
"If it keeps happening, check Public base URL in Settings → General: "
|
|
"Steward must be opened at that address.", status=400)
|
|
url, verify_tls, client_id = pending["url"], pending["verify_tls"], pending["client_id"]
|
|
if not code:
|
|
return await _render_form(connection, url, verify_tls, "Home Assistant didn't finish the sign-in. Nothing changed.", 400)
|
|
try:
|
|
refresh_token, token = await client.redeem_code(url, client_id, code, verify_tls)
|
|
can_install = await client.is_admin(url, token, verify_tls)
|
|
except (client.Refused, client.Unreachable) as exc:
|
|
return await _render_form(connection, url, verify_tls, f"The sign-in couldn't be finished: {exc}", 502)
|
|
user_id, username = _actor()
|
|
previous = None
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
connection = await _connection(db)
|
|
if connection is None:
|
|
connection = HomeAssistantConnection(url=url, client_id=client_id, refresh_token="")
|
|
db.add(connection)
|
|
else:
|
|
previous = (connection.url, connection.client_id, connection.refresh_token, connection.verify_tls)
|
|
connection.url, connection.client_id, connection.verify_tls = url, client_id, verify_tls
|
|
connection.refresh_token = encrypt_secret(refresh_token)
|
|
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 db.flush()
|
|
connection_id = connection.id
|
|
if previous is not None:
|
|
await _end_sign_in(SimpleNamespace(url=previous[0], client_id=previous[1], refresh_token=previous[2],
|
|
verify_tls=previous[3]))
|
|
await log_audit(current_app, user_id, username, "homeassistant.connection_saved",
|
|
entity_type="homeassistant_connection", entity_id=connection_id,
|
|
detail={"name": url, "can_install": can_install})
|
|
return redirect("/plugins/homeassistant/")
|
|
|
|
|
|
async def _end_sign_in(connection) -> bool:
|
|
"""End a sign-in Steward no longer needs, so it doesn't stay valid on Home
|
|
Assistant. It can be ended there by hand too, so failing here stops nothing."""
|
|
try:
|
|
await client.revoke(connection)
|
|
except (client.Refused, client.Unreachable) as exc:
|
|
logger.warning("Home Assistant: couldn't end Steward's old sign-in: %s", exc)
|
|
return False
|
|
return True
|
|
|
|
|
|
@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."""
|
|
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, url = connection.id, connection.url
|
|
await db.delete(connection)
|
|
ended = await _end_sign_in(connection)
|
|
await log_audit(current_app, *_actor(), "homeassistant.connection_removed",
|
|
entity_type="homeassistant_connection", entity_id=connection_id, detail={"name": url, "sign_in_ended": ended})
|
|
if not ended:
|
|
return await _render_index("Removed from Steward, but Home Assistant couldn't be reached to end the sign-in. "
|
|
"End it yourself under Security on that user's profile in Home Assistant.")
|
|
return redirect("/plugins/homeassistant/")
|