"""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 UNDER_WAY, 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 UNDER_WAY), 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/")