diff --git a/README.md b/README.md index 17271cc..87760fe 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ See `docs/plugins/` for a full plugin development guide. | `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide | | `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples | | `docs/plugins/prometheus.md` | Prometheus plugin: scraping any /metrics endpoint, how series are stored, the Fabled Archives recipe | +| `docs/plugins/homeassistant.md` | Home Assistant plugin: waiting updates, backup age, and installing updates after a confirmed backup | | `docs/plugins/syslog.md` | Syslog plugin: sources, patterns and alerts, publishing port 5514 (ingress or host mode under Swarm) | | `docs/plugins/prometheus-widgets.md` | Prometheus widget templates: writing, sharing and copying one in, every block, the format reference | | `docs/reference/code-map.md` | Where every key function, model, and route lives in the codebase | diff --git a/docs/plugins/homeassistant.md b/docs/plugins/homeassistant.md new file mode 100644 index 0000000..3611c29 --- /dev/null +++ b/docs/plugins/homeassistant.md @@ -0,0 +1,27 @@ +# Home Assistant plugin + +Shows which Home Assistant updates are waiting and how old its last backup is, and installs Core, OS and add-on updates, each only after a fresh backup is confirmed. Each update goes on the timeline. + +Steward never holds a credential that can act on your house. It reads with a read-only token, and acts only through a webhook whose automation can take a backup or install an allowed update. + +## Setting it up + +1. In Home Assistant, turn on automatic backups (Settings → System → Backups). +2. Make a local-only user in the read-only group (`system-read-only`) and a long-lived token for it. +3. On Steward's Home Assistant page, choose Connect, enter Home Assistant's local address and the token, and save. +4. Copy the automation the connection page shows into Home Assistant (Settings → Automations → Create → Edit in YAML). Steward reads whether it is there and on. + +Steward must reach Home Assistant by a local address, because the webhook only answers local requests. Home Assistant bans an address after a few refused requests, so Steward stops asking after one refusal until you save the connection or choose Try again. + +## Installing an update + +Install asks once, then Steward: + +1. Asks for a backup and waits until Home Assistant's last successful backup time moves. If it doesn't, nothing is installed. +2. Asks for the update and follows it until the new version is installed, or until a deadline: 20 minutes for an add-on, 30 for Core, 45 for the OS. + +One runs at a time, and it carries on if Steward restarts. Firmware and custom integrations are left to Home Assistant, and Supervisor updates itself. + +## Metrics + +Under source `homeassistant`, resource `homeassistant`, read every 5 minutes: `is_up`, `updates_waiting` and `backup_age_hours`. An alert rule on `backup_age_hours` above 26 warns when a daily backup has been missed; a rule's no-data warning covers Home Assistant going quiet. diff --git a/plugins/homeassistant/__init__.py b/plugins/homeassistant/__init__.py new file mode 100644 index 0000000..50dbea9 --- /dev/null +++ b/plugins/homeassistant/__init__.py @@ -0,0 +1,45 @@ +# plugins/homeassistant/__init__.py +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from quart import Quart + +_app: "Quart | None" = None + + +def setup(app: "Quart") -> None: + global _app + _app = app + from .models import HomeAssistantConnection, HomeAssistantUpdate # noqa: F401 — registers the tables + from .timeline import register + register() + + +def get_scheduled_tasks() -> list: + from .scheduler import make_tick_task + return [make_tick_task(_app)] + + +def get_blueprint(): + from .routes import homeassistant_bp + return homeassistant_bp + + +def get_nav() -> list[dict]: + return [{"label": "Home Assistant", "href": "/plugins/homeassistant/"}] + + +def get_alert_metrics() -> dict[str, list[str]]: + """What the poll records, all under the resource "homeassistant".""" + return {"homeassistant": ["is_up", "backup_age_hours", "updates_waiting"]} + + +def register_mcp_tools(register) -> None: + from .mcp_tools import register_tools + register_tools(register) + + +async def mcp_overview(db) -> list[dict]: + from .mcp_tools import mcp_overview as findings + return await findings(db) diff --git a/plugins/homeassistant/client.py b/plugins/homeassistant/client.py new file mode 100644 index 0000000..689bf64 --- /dev/null +++ b/plugins/homeassistant/client.py @@ -0,0 +1,76 @@ +# plugins/homeassistant/client.py +"""Talking to Home Assistant. Every request but the webhook carries the token, +because Home Assistant bans an address after a few refused requests, token-less +ones included. Every failure comes back as one sentence a page can show.""" +from __future__ import annotations + +import json + +import httpx + +TIMEOUT_SECONDS = 15.0 +MAX_BYTES = 16 * 1024 * 1024 + + +class Unreachable(Exception): + """Home Assistant didn't answer usefully; it may be restarting.""" + + +class Refused(Exception): + """Home Assistant refused the token, or has banned this address.""" + + +async def _get(url: str, path: str, token: str, verify_tls: bool, transport) -> list | dict | None: + """The parsed JSON of a GET, or None for a 404.""" + headers = {"Authorization": f"Bearer {token}"} + try: + async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, + follow_redirects=False) as client: + async with client.stream("GET", url.rstrip("/") + path, headers=headers) as response: + if response.status_code == 401: + raise Refused("Home Assistant refused the token. Check it, or make a new one.") + if response.status_code == 403: + raise Refused("Home Assistant answered 403: it may have banned Steward's address " + "after refused requests. Look in its ip_bans.yaml.") + if response.status_code == 404: + return None + if response.status_code != 200: + raise Unreachable(f"Home Assistant answered {response.status_code}.") + body = bytearray() + async for chunk in response.aiter_bytes(): + body.extend(chunk) + if len(body) > MAX_BYTES: + raise Unreachable("Home Assistant's answer was too large to read.") + return json.loads(body) + except httpx.TimeoutException: + raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None + except httpx.HTTPError as exc: + raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None + except ValueError: + raise Unreachable("Home Assistant's answer wasn't JSON.") from None + + +async def read_states(url: str, token: str, verify_tls: bool, transport=None) -> list[dict]: + states = await _get(url, "/api/states", token, verify_tls, transport) + if not isinstance(states, list): + raise Unreachable("Home Assistant's answer wasn't a list of entities.") + return states + + +async def read_state(url: str, entity_id: str, token: str, verify_tls: bool, transport=None) -> dict | None: + return await _get(url, f"/api/states/{entity_id}", token, verify_tls, transport) + + +async def send_webhook(url: str, webhook_id: str, body: dict, verify_tls: bool, transport=None) -> None: + """Hand the maintenance automation a request. Home Assistant answers 200 + whether or not the id is known, so what the request did is read afterwards.""" + try: + async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, + follow_redirects=False) as client: + response = await client.post(f"{url.rstrip('/')}/api/webhook/{webhook_id}", json=body) + except httpx.TimeoutException: + raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None + except httpx.HTTPError as exc: + raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None + if response.status_code != 200: + raise Unreachable(f"Home Assistant answered {response.status_code} to the webhook.") diff --git a/plugins/homeassistant/mcp_tools.py b/plugins/homeassistant/mcp_tools.py new file mode 100644 index 0000000..544fb3b --- /dev/null +++ b/plugins/homeassistant/mcp_tools.py @@ -0,0 +1,111 @@ +"""The Home Assistant plugin's MCP tools and its get_overview findings. + +get_home_assistant reads what the poll stored. update_home_assistant starts an +update as the page's Install does. The token and webhook id are never returned. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.mcp._context import current_caller, mcp_app +from steward.mcp.tools._audit import audit_mcp, mcp_username +from steward.mcp.tools._shape import age_s, db_session, iso, now_utc, redact_url +from steward.mcp.tools.overview import finding +from steward.models.users import UserRole + +from .models import HomeAssistantConnection, HomeAssistantUpdate +from .routes import connection_says +from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment +from .updates import CannotStart, start_update + +RECENT_UPDATES = 10 + + +def _run_view(r: HomeAssistantUpdate) -> dict: + return {"update_id": r.id, "entity_id": r.entity_id, "title": r.title, "kind": r.kind, + "from_version": r.from_version, "to_version": r.to_version, "state": r.state, "message": r.message, + "started_by": r.started_by, "started_at": iso(r.started_at), "backup_at": iso(r.backup_at), + "finished_at": iso(r.finished_at)} + + +async def get_home_assistant() -> dict: + """Home Assistant as of the last poll: whether it answers, which updates are + waiting, how old its last backup is, and the updates Steward was asked to install. + + `installable` is what update_home_assistant takes (Core, OS, add-ons); + `others_waiting` is firmware and custom integrations, which Steward leaves + to Home Assistant. `automation` is the Steward maintenance automation's + state: "on" is needed to install, null means it wasn't found. + """ + now = now_utc() + async with db_session() as db: + c = (await db.execute(select(HomeAssistantConnection))).scalars().first() + runs = list((await db.execute(select(HomeAssistantUpdate).order_by( + HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars()) + if c is None: + return {"connected": False, "says": "No connection yet: an admin adds it on the Home Assistant page."} + reach, says = connection_says(c, now) + backup = c.backup or {} + age = backup_age_hours(backup, now) + return {"connected": True, "url": redact_url(c.url), "reach": reach, "says": says, + "polled_at": iso(c.last_polled_at), "ok_age_s": age_s(c.last_ok_at, now), "automation": c.automation, + "installable": [{k: u[k] for k in ("entity_id", "title", "kind", "installed", "latest", "in_progress")} + for u in installable(c)], + "others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]], + "backup": {"verdict": backup_verdict(backup, now)[1], "last_ok": iso(parse_moment(backup.get("last_ok"))), + "age_hours": round(age, 1) if age is not None else None, "stale_after_hours": STALE_BACKUP_HOURS, + "next": iso(parse_moment(backup.get("next"))), "last_result": backup.get("last_result")}, + "updates": [_run_view(r) for r in runs]} + + +async def update_home_assistant(entity_id: str) -> dict: + """Install one Core, OS or add-on update, as the page's Install does: Steward + takes a backup, confirms it landed, installs, and follows it to its end. + + entity_id: from get_home_assistant's `installable`. + + An update restarts Home Assistant or the add-on, and an OS update reboots the + machine. Refused while another is under way, or if nothing is waiting there. + Admin only, as on the page. Recorded in the audit log. + """ + caller = current_caller() + try: + run = await start_update(mcp_app(), entity_id, mcp_username(caller.username)) + except CannotStart as exc: + raise ValueError(f"Nothing was started: {exc}.") from None + await audit_mcp("homeassistant.update_started", "homeassistant_update", run.id, + {"entity_id": entity_id, "title": run.title, "to": run.to_version}) + return {**_run_view(run), "next": "get_home_assistant follows it: `updates` shows its state."} + + +def overview_findings(c, latest_run, now) -> list[dict]: + """A connection that isn't answering, stale backups, and a newest update that + failed. Updates waiting aren't a problem to raise.""" + if c is None: + return [] + found = [] + reach, says = connection_says(c, now) + if reach == "down": + found.append(finding("warning", "home_assistant_down", "Home Assistant", + f"Home Assistant isn't answering: {says}", since=c.last_ok_at, next="get_home_assistant()")) + tone, backup_says = backup_verdict(c.backup, now) + if c.last_ok_at is not None and tone == "warn": + found.append(finding("warning", "home_assistant_backup", "Home Assistant", backup_says, + since=parse_moment((c.backup or {}).get("last_ok")), next="get_home_assistant()")) + if latest_run is not None and latest_run.state == "failed": + found.append(finding("warning", "home_assistant_update_failed", latest_run.title, + f"The update of {latest_run.title} failed: {latest_run.message}", + since=latest_run.finished_at, next="get_home_assistant()")) + return found + + +async def mcp_overview(db) -> list[dict]: + c = (await db.execute(select(HomeAssistantConnection))).scalars().first() + latest = (await db.execute(select(HomeAssistantUpdate).order_by( + HomeAssistantUpdate.started_at.desc()).limit(1))).scalars().first() + return overview_findings(c, latest, now_utc()) + + +def register_tools(register) -> None: + register(get_home_assistant, role=UserRole.viewer, read_only=True) + register(update_home_assistant, role=UserRole.admin) diff --git a/plugins/homeassistant/migrations/__init__.py b/plugins/homeassistant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/homeassistant/migrations/versions/__init__.py b/plugins/homeassistant/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/homeassistant/migrations/versions/homeassistant_001_connection.py b/plugins/homeassistant/migrations/versions/homeassistant_001_connection.py new file mode 100644 index 0000000..71b3b94 --- /dev/null +++ b/plugins/homeassistant/migrations/versions/homeassistant_001_connection.py @@ -0,0 +1,59 @@ +"""Home Assistant: the connection and the updates Steward was asked to install + +Revision ID: homeassistant_001_connection +Revises: (none — a branch off core via depends_on) +Create Date: 2026-10-31 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "homeassistant_001_connection" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = "homeassistant" +depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",) + + +def upgrade() -> None: + op.create_table( + "homeassistant_connection", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("url", sa.String(500), nullable=False), + sa.Column("token", sa.Text, nullable=False), + sa.Column("webhook_id", sa.Text, nullable=False), + sa.Column("verify_tls", sa.Boolean, nullable=False, server_default=sa.true()), + sa.Column("refused", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("last_polled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_ok_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text, nullable=True), + sa.Column("updates", sa.JSON, nullable=False, server_default="[]"), + sa.Column("backup", sa.JSON, nullable=False, server_default="{}"), + sa.Column("automation", sa.String(20), nullable=True), + ) + op.create_table( + "homeassistant_updates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("entity_id", sa.String(255), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("kind", sa.String(10), nullable=False), + sa.Column("from_version", sa.String(100), nullable=True), + sa.Column("to_version", sa.String(100), nullable=True), + sa.Column("state", sa.String(20), nullable=False, server_default="backing_up"), + sa.Column("message", sa.Text, nullable=True), + sa.Column("started_by", sa.String(100), nullable=False, server_default=""), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("backup_before", sa.String(64), nullable=True), + sa.Column("backup_event_before", sa.String(64), nullable=True), + sa.Column("triggered_before", sa.String(64), nullable=True), + sa.Column("backup_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("install_sent_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + ) + op.execute("CREATE UNIQUE INDEX uq_homeassistant_update_under_way ON homeassistant_updates ((true)) " + "WHERE state IN ('backing_up', 'installing')") + + +def downgrade() -> None: + op.drop_table("homeassistant_updates") + op.drop_table("homeassistant_connection") diff --git a/plugins/homeassistant/models.py b/plugins/homeassistant/models.py new file mode 100644 index 0000000..e3634a6 --- /dev/null +++ b/plugins/homeassistant/models.py @@ -0,0 +1,68 @@ +# plugins/homeassistant/models.py +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import JSON, Boolean, DateTime, Index, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from steward.core.ids import time_ordered_id +from steward.models.base import Base + + +class HomeAssistantConnection(Base): + """The Home Assistant instance Steward looks after; at most one row. + + The token is a read-only user's and the webhook id can only reach the + maintenance automation, so nothing stored here can act on the house. Both + are encrypted. The last_* columns and `updates` and `backup` are the newest + poll's outcome, which the pages read. `refused` stops all polling until + someone saves the connection or tries again: Home Assistant bans an address + after a few refused requests. + """ + __tablename__ = "homeassistant_connection" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) + url: Mapped[str] = mapped_column(String(500), nullable=False) + token: Mapped[str] = mapped_column(Text, nullable=False) + webhook_id: Mapped[str] = mapped_column(Text, nullable=False) + verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + automation: Mapped[str | None] = mapped_column(String(20), nullable=True) + + +class HomeAssistantUpdate(Base): + """One update Steward was asked to install, from the click to its end. It + moves through backing_up and installing to done or failed, and its row + carries everything needed to carry on after Steward restarts. The *_before + columns are Home Assistant's own readings at the start, so "a newer backup" + is judged on its clock alone. One can be under way at a time. + """ + __tablename__ = "homeassistant_updates" + __table_args__ = ( + Index("uq_homeassistant_update_under_way", text("(true)"), unique=True, + postgresql_where=text("state IN ('backing_up', 'installing')")), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) + entity_id: Mapped[str] = mapped_column(String(255), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[str] = mapped_column(String(10), nullable=False) + from_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + to_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + state: Mapped[str] = mapped_column(String(20), nullable=False, default="backing_up") + message: Mapped[str | None] = mapped_column(Text, nullable=True) + started_by: Mapped[str] = mapped_column(String(100), nullable=False, default="") + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + backup_before: Mapped[str | None] = mapped_column(String(64), nullable=True) + backup_event_before: Mapped[str | None] = mapped_column(String(64), nullable=True) + triggered_before: Mapped[str | None] = mapped_column(String(64), nullable=True) + backup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + install_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/plugins/homeassistant/plugin.yaml b/plugins/homeassistant/plugin.yaml new file mode 100644 index 0000000..12efeea --- /dev/null +++ b/plugins/homeassistant/plugin.yaml @@ -0,0 +1,17 @@ +# plugins/homeassistant/plugin.yaml +name: homeassistant +version: "1.0.0" +description: "Shows which Home Assistant updates are waiting and how old its last backup is, and installs Core, OS and add-on updates after a confirmed backup." +author: "Steward" +license: "MIT" +min_app_version: "0.1.0" +repository_url: "https://git.fabledsword.com/bvandeusen/FabledSteward" +homepage: "https://git.fabledsword.com/bvandeusen/FabledSteward/src/branch/dev/plugins/homeassistant" +tags: + - home-assistant + - updates + - backups + +# The connection is added and edited on the Home Assistant page +# (/plugins/homeassistant/). +config: {} diff --git a/plugins/homeassistant/routes.py b/plugins/homeassistant/routes.py new file mode 100644 index 0000000..bc739e0 --- /dev/null +++ b/plugins/homeassistant/routes.py @@ -0,0 +1,206 @@ +"""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) diff --git a/plugins/homeassistant/scheduler.py b/plugins/homeassistant/scheduler.py new file mode 100644 index 0000000..f7e84ac --- /dev/null +++ b/plugins/homeassistant/scheduler.py @@ -0,0 +1,95 @@ +# plugins/homeassistant/scheduler.py +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import select + +from steward.core.crypto import decrypt_secret +from steward.core.scheduler import ScheduledTask + +from . import client +from .models import HomeAssistantConnection +from .service import RESOURCE, SOURCE, backup_age_hours, installable, read_snapshot +from .updates import advance + +if TYPE_CHECKING: + from quart import Quart + +logger = logging.getLogger(__name__) + +# An update under way is followed each tick, with one small read of its entity. +TICK_SECONDS = 15 +# The whole entity list is a few hundred KB, and updates and backups change rarely. +POLL_SECONDS = 300 +# A step past this is given up until the next tick, whatever the client's own +# timeouts did: a slow drip of bytes can keep each read inside its timeout for ever. +STEP_DEADLINE_SECONDS = 60 + + +def make_tick_task(app: "Quart") -> ScheduledTask: + busy = asyncio.Lock() + + async def tick() -> None: + if busy.locked(): + return + async with busy: + for step in (poll, advance): + try: + await asyncio.wait_for(step(app), STEP_DEADLINE_SECONDS) + except TimeoutError: + logger.warning("Home Assistant %s gave up after %d s", step.__name__, STEP_DEADLINE_SECONDS) + + return ScheduledTask(name="homeassistant_tick", coro_factory=tick, + interval_seconds=TICK_SECONDS, run_on_startup=True) + + +def is_due(connection, now: datetime) -> bool: + """Whether to poll. Never after a refusal: another refused request counts + toward Home Assistant banning this address.""" + if connection.refused: + return False + if connection.last_polled_at is None: + return True + return (now - connection.last_polled_at).total_seconds() >= POLL_SECONDS - TICK_SECONDS / 2 + + +async def poll(app: "Quart", transport=None) -> None: + from steward.core.alerts import MetricBatch + + now = datetime.now(timezone.utc) + async with app.db_sessionmaker() as session: + connection = (await session.execute(select(HomeAssistantConnection))).scalars().first() + if connection is None or not is_due(connection, now): + return + token = decrypt_secret(connection.token, context="Home Assistant token") + snapshot, error, refused = None, None, False + try: + snapshot = read_snapshot(await client.read_states(connection.url, token, connection.verify_tls, transport)) + except client.Refused as exc: + error, refused = str(exc), True + except client.Unreachable as exc: + error = str(exc) + async with app.db_sessionmaker() as session: + async with session.begin(): + row = await session.get(HomeAssistantConnection, connection.id) + if row is None: # removed while it was being polled + return + batch = MetricBatch() + row.last_polled_at, row.last_error, row.refused = now, error, refused + if snapshot is None: + batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=0.0, recorded_at=now) + else: + row.last_ok_at = now + row.updates, row.backup, row.automation = snapshot["updates"], snapshot["backup"], snapshot["automation"] + batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=1.0, recorded_at=now) + batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="updates_waiting", + value=len(installable(row)), recorded_at=now) + age = backup_age_hours(snapshot["backup"], now) + if age is not None: + batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="backup_age_hours", + value=age, recorded_at=now) + await batch.record(session) diff --git a/plugins/homeassistant/service.py b/plugins/homeassistant/service.py new file mode 100644 index 0000000..0f1f145 --- /dev/null +++ b/plugins/homeassistant/service.py @@ -0,0 +1,169 @@ +# plugins/homeassistant/service.py +"""Reading what Home Assistant reports: which updates it knows of, which of them +Steward may install, and how its backups stand.""" +from __future__ import annotations + +import secrets +from datetime import datetime, timezone + +SOURCE = "homeassistant" +RESOURCE = "homeassistant" +# Backups run daily, so past a day and a bit one has been missed. +STALE_BACKUP_HOURS = 26 +AUTOMATION_ID = "steward_maintenance" + +CORE = "update.home_assistant_core_update" +OS = "update.home_assistant_operating_system_update" +SUPERVISOR = "update.home_assistant_supervisor_update" +LAST_BACKUP = "sensor.backup_last_successful_automatic_backup" +NEXT_BACKUP = "sensor.backup_next_scheduled_automatic_backup" +BACKUP_MANAGER = "sensor.backup_backup_manager_state" +BACKUP_EVENT = "event.backup_automatic_backup" + +_KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"} +_KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3} + +# The automation that gives Steward its only way to act on Home Assistant. It +# takes a backup or installs a Core, OS or add-on update, and does nothing else. +AUTOMATION_YAML = """\ +id: steward_maintenance +alias: Steward maintenance +description: Lets Steward take a backup or install a Core, OS or add-on update. Does nothing else. +mode: single +triggers: + - trigger: webhook + webhook_id: {webhook_id} + allowed_methods: + - POST + local_only: true +variables: + request: "{{{{ trigger.json | default({{}}) }}}}" + allowed: >- + {{{{ integration_entities('hassio') | select('match', 'update\\\\.') + | reject('eq', 'update.home_assistant_supervisor_update') | list }}}} +actions: + - choose: + - conditions: + - condition: template + value_template: "{{{{ request.get('action') == 'backup' }}}}" + sequence: + - action: backup.create_automatic + - conditions: + - condition: template + value_template: "{{{{ request.get('action') == 'install' and request.get('entity_id') in allowed }}}}" + sequence: + - action: update.install + target: + entity_id: "{{{{ request.get('entity_id') }}}}" +""" + + +def new_webhook_id() -> str: + return "steward_" + secrets.token_urlsafe(32) + + +def automation_yaml(webhook_id: str) -> str: + return AUTOMATION_YAML.format(webhook_id=webhook_id) + + +def no_spaces(typed: str | None) -> str: + """A pasted token or id with a terminal's wrapping taken back out.""" + return "".join((typed or "").split()) + + +def classify(entity: dict) -> str | None: + """"core", "os" or "addon" for an update Steward may install; None for the + rest: Supervisor updates itself, and firmware and custom integrations are + Home Assistant's own to offer.""" + entity_id = entity.get("entity_id") + if entity_id == CORE: + return "core" + if entity_id == OS: + return "os" + if entity_id == SUPERVISOR: + return None + picture = (entity.get("attributes") or {}).get("entity_picture") or "" + return "addon" if picture.startswith("/api/hassio/addons/") else None + + +def _update(entity: dict) -> dict: + a = entity.get("attributes") or {} + kind = classify(entity) + url = a.get("release_url") or "" + return {"entity_id": entity["entity_id"], "kind": kind, "kind_word": _KIND_WORDS.get(kind, "Other"), + "title": a.get("title") or a.get("friendly_name") or entity["entity_id"], + "installed": a.get("installed_version"), "latest": a.get("latest_version"), + "waiting": entity.get("state") == "on", "in_progress": bool(a.get("in_progress")), + "auto_update": bool(a.get("auto_update")), + "release_url": url if url.startswith(("http://", "https://")) else None} + + +def _state(entity: dict | None) -> str | None: + value = (entity or {}).get("state") + return None if value in (None, "unknown", "unavailable") else value + + +def parse_moment(value: str | None) -> datetime | None: + try: + moment = datetime.fromisoformat(value) if value else None + except ValueError: + return None + if moment is not None and moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment + + +def is_newer(value: str | None, than: str | None) -> bool: + """Whether reading `value` is a later moment than `than`, where `than` may be never.""" + moment, before = parse_moment(value), parse_moment(than) + return moment is not None and (before is None or moment > before) + + +def read_snapshot(states: list[dict]) -> dict: + """What a poll keeps of Home Assistant's entities.""" + by_id = {e.get("entity_id"): e for e in states} + updates = [_update(e) for i, e in by_id.items() if (i or "").startswith("update.") and e.get("state") != "unavailable"] + updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower())) + event = by_id.get(BACKUP_EVENT) or {} + event_attrs = event.get("attributes") or {} + automation = next((e for e in states if (e.get("entity_id") or "").startswith("automation.") + and (e.get("attributes") or {}).get("id") == AUTOMATION_ID), None) + return { + "updates": updates, + "backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)), + "manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"), + "failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id, + "automation_entity": automation.get("entity_id") if automation else None}, + "automation": automation.get("state") if automation else None, + } + + +def backup_age_hours(backup: dict | None, now: datetime) -> float | None: + last = parse_moment((backup or {}).get("last_ok")) + return None if last is None else max(0.0, (now - last).total_seconds() / 3600) + + +def installable(connection) -> list[dict]: + """The waiting updates Steward may install.""" + return [u for u in (connection.updates or []) if u["waiting"] and u["kind"]] + + +def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]: + """(tone, sentence) for how the backups stand.""" + backup = backup or {} + if not backup.get("known"): + return "warn", "Home Assistant reports no automatic backups. Turn them on under Settings → System → Backups." + age = backup_age_hours(backup, now) + if age is None: + return "warn", "Home Assistant has no successful automatic backup yet." + if backup.get("last_result") == "failed": + reason = backup.get("failed_reason") + return "warn", f"The newest backup attempt failed{f' ({reason})' if reason else ''}." + if age > STALE_BACKUP_HOURS: + return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected." + return "ok", "Backups are up to date." + + +def automation_entity_id(connection) -> str: + """The automation's entity, as a poll found it.""" + return (connection.backup or {}).get("automation_entity") or f"automation.{AUTOMATION_ID}" diff --git a/plugins/homeassistant/templates/homeassistant/_test.html b/plugins/homeassistant/templates/homeassistant/_test.html new file mode 100644 index 0000000..325c09c --- /dev/null +++ b/plugins/homeassistant/templates/homeassistant/_test.html @@ -0,0 +1,6 @@ +{# plugins/homeassistant/templates/homeassistant/_test.html — what Test found. #} +{% if found.error %}
{{ found.error }}
+{% else %}Answered: {{ found.entities }} entities, {{ found.updates }} Core, OS and add-on update entities, {{ found.installable }} waiting.
+{{ "Automatic backups are readable." if found.backup else "No automatic backup sensor: turn on automatic backups in Home Assistant." }}
+{% if found.automation == 'on' %}The Steward maintenance automation is on.{% elif found.automation %}The Steward maintenance automation is {{ found.automation }}.{% else %}The Steward maintenance automation isn't there yet.{% endif %}
+{% endif %} diff --git a/plugins/homeassistant/templates/homeassistant/form.html b/plugins/homeassistant/templates/homeassistant/form.html new file mode 100644 index 0000000..908062b --- /dev/null +++ b/plugins/homeassistant/templates/homeassistant/form.html @@ -0,0 +1,80 @@ +{# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant + is, the read-only token Steward reads it with, and the automation to add so + Steward can install updates. A secret is never sent back in a field; a blank + one keeps what's saved. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} +{% block title %}Home Assistant connection — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Home Assistant", "/plugins/homeassistant/"), ("Connection", "")]) }}{% endblock %} +{% block content %} +{{ page_header("Home Assistant connection") }} + +{{ error }}
{% endif %} + + +{% if connection %} +Steward stops reading Home Assistant and forgets its token. Remove the automation in Home Assistant too.
+{{ error }}
{% endif %} ++ {% if under_way %}{{ status_badge('pending', 'Updating') }} + {{ under_way.title }}: {{ "taking a backup first" if under_way.state == 'backing_up' else "installing " ~ under_way.to_version }}. Home Assistant may stop answering for a while. + {% elif reach == 'up' %}{{ status_badge('up', 'Answering') }} + {% if installable %}{{ installable|length }} update{{ "s" if installable|length != 1 }} waiting.{% else %}Everything Steward can update is up to date.{% endif %} + Read {{ connection.last_polled_at|ago }}. + {% elif reach == 'down' %}{{ status_badge('down', 'Not answering') }} {{ says }} + {% if connection.last_ok_at %}Last answered {{ connection.last_ok_at|ago }}; what's below is from then.{% endif %} + {% if admin and connection.refused %}
{% endif %} + {% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %} + + +{% if connection.last_ok_at and connection.automation != 'on' %} +{% if connection.automation %}The Steward maintenance automation is {{ connection.automation }} in Home Assistant{% else %}The Steward maintenance automation isn't in Home Assistant{% endif %}, so updates can't be installed from here. + {% if admin %}See what to add.{% endif %}
+{% endif %} + +{{ backup_says }}
+ +| What | Now | New | {% if admin %}{% endif %} |
|---|---|---|---|
| {{ u.title }} {{ u.kind_word }}{% if u.auto_update %}, updates itself{% endif %} |
+ {{ u.installed or "—" }} | +{% if u.release_url %}{{ u.latest or "—" }}{% else %}{{ u.latest or "—" }}{% endif %} | + {% if admin %}+ {% if connection.automation == 'on' and not under_way and not connection.refused and not u.in_progress %} + + {% endif %} + | {% endif %} +
Nothing waiting.
+ {% endif %} + {% if current %}Up to date: {% for u in current %}{{ u.title }} {{ u.installed }}{{ ", " if not loop.last }}{% endfor %}.
{% endif %} + {% if others %}Waiting, for Home Assistant to install: {% for u in others %}{{ u.title }}{{ ", " if not loop.last }}{% endfor %}. Steward installs Core, OS and add-on updates only.
{% endif %} +| What | Versions | Outcome | Started |
|---|---|---|---|
| {{ r.title }} by {{ r.started_by or "Steward" }} |
+ {{ r.from_version or "—" }} → {{ r.to_version or "—" }} | +{% if r.state == 'done' %}{{ status_badge('up', 'Done') }}
+ {% elif r.state == 'failed' %}{{ status_badge('down', 'Failed') }} {{ r.message }}
+ {% else %}{{ status_badge('pending', 'Taking a backup' if r.state == 'backing_up' else 'Installing') }}{% endif %} |
+ {{ r.started_at|ago }} | +