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