feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-10-30 23:09:27 -04:00
co-authored by Claude Opus 5.5
parent 8b9b7fc11d
commit b2a6e92ed3
27 changed files with 1802 additions and 4 deletions
+1
View File
@@ -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 |
+27
View File
@@ -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.
+45
View File
@@ -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)
+76
View File
@@ -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.")
+111
View File
@@ -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)
@@ -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")
+68
View File
@@ -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)
+17
View File
@@ -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: {}
+206
View File
@@ -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)
+95
View File
@@ -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)
+169
View File
@@ -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}"
@@ -0,0 +1,6 @@
{# plugins/homeassistant/templates/homeassistant/_test.html — what Test found. #}
{% if found.error %}<p class="tone-crit" style="margin-bottom:0;">{{ found.error }}</p>
{% else %}<p class="tone-ok" style="margin-bottom:0.25rem;">Answered: {{ found.entities }} entities, {{ found.updates }} Core, OS and add-on update entities, {{ found.installable }} waiting.</p>
<p class="{{ 'field-hint' if found.backup else 'tone-warn' }}" style="margin:0;">{{ "Automatic backups are readable." if found.backup else "No automatic backup sensor: turn on automatic backups in Home Assistant." }}</p>
<p class="{{ 'field-hint' if found.automation == 'on' else 'tone-warn' }}" style="margin:0;">{% 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 %}</p>
{% endif %}
@@ -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") }}
<div class="form-page">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" id="ha-form" action="/plugins/homeassistant/connection">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="ha-where">
<h2 id="ha-where" class="section-title">Reading it</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="ha-url">Address</label>
<input class="input mono" id="ha-url" name="url" required maxlength="500" value="{{ v.url or '' }}" placeholder="http://homeassistant.local:8123" autocomplete="off">
<span class="field-hint">Its address on your local network, not a public one: the webhook only answers local requests.</span>
</div>
<div class="field">
<label class="field-label" for="ha-token">Read-only token</label>
<input class="input" id="ha-token" name="token" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_token else 'a long-lived access token' }}">
<span class="field-hint">Use a long-lived token of a user in Home Assistant's read-only group, so nothing Steward holds can act on your house.</span>
</div>
</div>
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.9rem;">
<input type="checkbox" name="verify_tls" value="on"{% if v.verify_tls %} checked{% endif %}> Check its HTTPS certificate
</label>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-ghost"
hx-post="/plugins/homeassistant/connection/test" hx-include="#ha-form" hx-target="#ha-test"
hx-indicator="#ha-testing">Test</button>
<span id="ha-testing" class="field-hint htmx-indicator">Reading…</span>
<span class="field-hint">Reads Home Assistant once with what's typed here. Nothing is saved.</span>
</div>
<div id="ha-test" aria-live="polite"></div>
</section>
<section class="card form-section" aria-labelledby="ha-install">
<h2 id="ha-install" class="section-title">Installing updates</h2>
<div class="field">
<label class="field-label" for="ha-webhook">Webhook id</label>
<input class="input" id="ha-webhook" name="webhook_id" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_webhook else 'blank makes a new one' }}">
<span class="field-hint">Steward makes one when you first save. Type one only if the automation in Home Assistant already has its own.</span>
</div>
{% if automation %}
<p class="field-hint">Add this automation in Home Assistant (Settings → Automations → Create → Edit in YAML). It can take a backup or install a Core, OS or add-on update, and nothing else. It contains the webhook id, so treat it like a password.</p>
<pre class="mono" id="ha-automation" style="overflow-x:auto;font-size:0.8rem;">{{ automation }}</pre>
<button type="button" class="btn btn-sm btn-ghost"
onclick="navigator.clipboard.writeText(document.getElementById('ha-automation').textContent).then(()=>this.textContent='Copied')">Copy</button>
{% else %}
<p class="field-hint" style="margin-bottom:0;">Once saved, this shows the automation to add to Home Assistant.</p>
{% endif %}
</section>
<div class="form-actions">
<button type="submit" class="btn">Save</button>
<a href="/plugins/homeassistant/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% if connection %}
<section class="card danger-zone" aria-labelledby="ha-remove">
<div>
<h2 id="ha-remove" class="section-title">Remove this connection</h2>
<p class="field-hint">Steward stops reading Home Assistant and forgets its token. Remove the automation in Home Assistant too.</p>
</div>
<form method="post" action="/plugins/homeassistant/connection/delete"
data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its token.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Remove connection</button>
</form>
</section>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,97 @@
{# plugins/homeassistant/templates/homeassistant/index.html — whether Home
Assistant answers, which updates are waiting with Install beside each, how its
backups stand, and the updates Steward has run. Refreshes itself while one is
under way. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}Home Assistant — Steward{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% call page_header("Home Assistant", connection.url if connection else none) %}
{% if admin and connection %}<a class="btn btn-sm btn-ghost" href="/plugins/homeassistant/connection">Connection</a>{% endif %}
{% endcall %}
{% if not connection %}
<div class="card">{{ empty_state("Not connected. Connect a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.",
"Connect" if admin else none, "/plugins/homeassistant/connection") }}</div>
{% else %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div id="ha-body"{% if under_way %} hx-get="/plugins/homeassistant/" hx-select="#ha-body" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
<p class="page-verdict">
{% 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') }} <span class="tone-crit">{{ says }}</span>
{% if connection.last_ok_at %}Last answered {{ connection.last_ok_at|ago }}; what's below is from then.{% endif %}
{% if admin and connection.refused %}<form method="post" action="/plugins/homeassistant/connection/retry" style="display:inline;">{{ csrf_field() }}<button class="btn btn-sm btn-ghost" type="submit">Try again</button></form>{% endif %}
{% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %}
</p>
{% if connection.last_ok_at and connection.automation != 'on' %}
<p class="tone-warn">{% 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 %}<a href="/plugins/homeassistant/connection">See what to add.</a>{% endif %}</p>
{% endif %}
<div class="stat-strip">
{{ stat_tile("Updates waiting", installable|length if connection.last_ok_at else "—") }}
{{ stat_tile("Last good backup", (backup_at|ago) if backup_at else "—", state="degraded" if backup_tone == 'warn' else none) }}
{{ stat_tile("Next backup", (backup_next|ago) if backup_next else "—") }}
</div>
<p class="{{ 'tone-warn' if backup_tone == 'warn' else 'field-hint' }}">{{ backup_says }}</p>
<section class="card" aria-labelledby="ha-updates">
<h2 id="ha-updates" class="section-title">Updates</h2>
{% if installable %}
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Now</th><th scope="col">New</th>{% if admin %}<th scope="col"></th>{% endif %}</tr></thead>
<tbody>
{% for u in installable %}
<tr>
<td>{{ u.title }}<div class="cell-sub">{{ u.kind_word }}{% if u.auto_update %}, updates itself{% endif %}</div></td>
<td class="mono">{{ u.installed or "—" }}</td>
<td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td>
{% if admin %}<td style="text-align:right;">
{% if connection.automation == 'on' and not under_way and not connection.refused and not u.in_progress %}
<form method="post" action="/plugins/homeassistant/install"
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('The machine reboots.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
<input type="hidden" name="entity_id" value="{{ u.entity_id }}">
<button class="btn btn-sm" type="submit">Install</button>
</form>
{% endif %}
</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="field-hint" style="margin:0;">Nothing waiting.</p>
{% endif %}
{% if current %}<p class="field-hint">Up to date: {% for u in current %}{{ u.title }} {{ u.installed }}{{ ", " if not loop.last }}{% endfor %}.</p>{% endif %}
{% if others %}<p class="field-hint" style="margin-bottom:0;">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.</p>{% endif %}
</section>
{% if runs %}
<section class="card" aria-labelledby="ha-runs">
<h2 id="ha-runs" class="section-title">Updates run from Steward</h2>
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Versions</th><th scope="col">Outcome</th><th scope="col">Started</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.title }}<div class="cell-sub">by {{ r.started_by or "Steward" }}</div></td>
<td class="mono">{{ r.from_version or "—" }} → {{ r.to_version or "—" }}</td>
<td>{% if r.state == 'done' %}{{ status_badge('up', 'Done') }}
{% elif r.state == 'failed' %}{{ status_badge('down', 'Failed') }}<div class="cell-sub">{{ r.message }}</div>
{% else %}{{ status_badge('pending', 'Taking a backup' if r.state == 'backing_up' else 'Installing') }}{% endif %}</td>
<td>{{ r.started_at|ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
# plugins/homeassistant/timeline.py
"""What Home Assistant maintenance puts on the timeline: each update once it ends."""
from __future__ import annotations
from steward.timeline import catalog
from steward.timeline.record import record_or_update
TYPES = (
("homeassistant.updated", "Home Assistant updated", "automation", "service", True,
"Steward installed a Home Assistant update after a confirmed backup."),
("homeassistant.update_failed", "Home Assistant update failed", "automation", "service", True,
"A Home Assistant update Steward was asked to install didn't finish."),
)
def register() -> None:
for t in TYPES:
catalog.register_type(*t)
from .models import HomeAssistantUpdate
catalog.register_ref("homeassistant_update", HomeAssistantUpdate)
async def update_entry(session, run) -> None:
done = run.state == "done"
versions = f"{run.from_version} to {run.to_version}" if run.to_version else "a new version"
summary = (f"{run.title} updated from {versions}" if done
else f"{run.title} update failed: {run.message}")
await record_or_update(
session, ("homeassistant_update", run.id),
type="homeassistant.updated" if done else "homeassistant.update_failed",
at=run.started_at, until=run.finished_at, summary=summary, subject=run.title,
detail={"entity_id": run.entity_id, "from": run.from_version, "to": run.to_version,
"url": "/plugins/homeassistant/"},
actor=run.started_by or "Steward")
+214
View File
@@ -0,0 +1,214 @@
# plugins/homeassistant/updates.py
"""Installing one update: take a backup, confirm it landed, install, and follow
it to its end. Its row holds where it has got to, so Steward restarting, or Home
Assistant restarting as an update does, only delays the next step."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from steward.core.crypto import decrypt_secret
from . import client
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import BACKUP_EVENT, LAST_BACKUP, automation_entity_id, is_newer, parse_moment
from .timeline import update_entry
logger = logging.getLogger(__name__)
UNDER_WAY = ("backing_up", "installing")
BACKUP_DEADLINE_S = 30 * 60
# How long the automation has to show it ran before the webhook is called wrong.
TRIGGER_GRACE_S = 90
# An OS update reboots the machine, and a Pi is slow to come back.
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
class CannotStart(Exception):
"""Why an update wasn't started, said to the person."""
def _secrets(connection) -> tuple[str, str]:
return (decrypt_secret(connection.token, context="Home Assistant token"),
decrypt_secret(connection.webhook_id, context="Home Assistant webhook id"))
async def _mark_refused(app, connection_id: str, said: str) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantConnection, connection_id)
if row is not None:
row.refused, row.last_error = True, said
async def under_way(session) -> HomeAssistantUpdate | None:
return (await session.execute(select(HomeAssistantUpdate).where(
HomeAssistantUpdate.state.in_(UNDER_WAY)))).scalars().first()
async def start_update(app, entity_id: str, username: str, transport=None) -> HomeAssistantUpdate:
"""Start installing `entity_id`, an explicit action that may contact Home
Assistant. Raises CannotStart, with nothing started, unless the update is
still waiting and the automation is there to install it."""
async with app.db_sessionmaker() as session:
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None:
raise CannotStart("Home Assistant isn't connected")
if connection.refused:
raise CannotStart("Home Assistant refused Steward's token")
if await under_way(session) is not None:
raise CannotStart("another update is under way")
if connection.automation != "on":
raise CannotStart("the Steward maintenance automation isn't on in Home Assistant")
url, verify = connection.url, connection.verify_tls
token, webhook_id = _secrets(connection)
known = next((u for u in connection.updates or [] if u["entity_id"] == entity_id), None)
if known is None or not known["kind"]:
raise CannotStart("that isn't a Core, OS or add-on update")
try:
current = await client.read_state(url, entity_id, token, verify, transport)
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
raise CannotStart(str(exc).rstrip(".")) from None
except client.Unreachable as exc:
raise CannotStart(str(exc).rstrip(".")) from None
if backup is None:
raise CannotStart("Home Assistant has no automatic backups to confirm one with")
if current is None or current.get("state") != "on":
raise CannotStart("that update is no longer waiting")
attrs = current.get("attributes") or {}
run = HomeAssistantUpdate(
entity_id=entity_id, title=known["title"], kind=known["kind"],
from_version=attrs.get("installed_version"), to_version=attrs.get("latest_version"),
started_by=username, backup_before=backup.get("state"),
backup_event_before=(event or {}).get("state"),
triggered_before=((automation or {}).get("attributes") or {}).get("last_triggered"))
async with app.db_sessionmaker() as session:
try:
async with session.begin():
session.add(run)
await session.flush()
run_id = run.id
except IntegrityError:
raise CannotStart("another update is under way") from None
try:
await client.send_webhook(url, webhook_id, {"action": "backup"}, verify, transport)
except client.Unreachable as exc:
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
raise CannotStart(str(exc).rstrip(".")) from None
async with app.db_sessionmaker() as session:
return await session.get(HomeAssistantUpdate, run_id)
async def _finish(app, run_id: str, state: str, message: str | None) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
run = await session.get(HomeAssistantUpdate, run_id)
run.state, run.message, run.finished_at = state, message, datetime.now(timezone.utc)
# The page should show the new versions now, not at the next poll.
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is not None:
connection.last_polled_at = None
try:
async with session.begin_nested():
await update_entry(session, run)
except Exception:
logger.exception("Home Assistant update %s: its timeline entry could not be written", run_id)
async def advance(app, transport=None) -> None:
"""Move the update under way one step on, if there is one."""
async with app.db_sessionmaker() as session:
run = await under_way(session)
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if run is None:
return
if connection is None:
await _finish(app, run.id, "failed", "The Home Assistant connection was removed.")
return
try:
if run.state == "backing_up":
await _advance_backup(app, run, connection, transport)
else:
await _advance_install(app, run, connection, transport)
except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
await _finish(app, run.id, "failed", str(exc))
except client.Unreachable as exc:
# Expected while Home Assistant restarts: the next tick asks again.
logger.info("Home Assistant update %s: %s", run.id, exc)
await _past_deadline(app, run)
def _elapsed(since: datetime) -> float:
return (datetime.now(timezone.utc) - since).total_seconds()
async def _past_deadline(app, run) -> bool:
if run.state == "backing_up" and _elapsed(run.started_at) > BACKUP_DEADLINE_S:
await _finish(app, run.id, "failed",
f"No new backup within {BACKUP_DEADLINE_S // 60} minutes. Nothing was installed.")
return True
if run.state == "installing":
limit = INSTALL_DEADLINE_S.get(run.kind, INSTALL_DEADLINE_S["os"])
if _elapsed(run.install_sent_at or run.backup_at or run.started_at) > limit:
await _finish(app, run.id, "failed",
f"Still not on {run.to_version} after {limit // 60} minutes. Look in Home Assistant.")
return True
return False
async def _advance_backup(app, run, connection, transport) -> None:
token, _webhook = _secrets(connection)
url, verify = connection.url, connection.verify_tls
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
value = (backup or {}).get("state")
if is_newer(value, run.backup_before):
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run.id)
row.state, row.backup_at = "installing", parse_moment(value)
return
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
if event and event.get("state") != run.backup_event_before \
and (event.get("attributes") or {}).get("event_type") == "failed":
reason = (event.get("attributes") or {}).get("failed_reason")
await _finish(app, run.id, "failed",
f"The backup failed{f' ({reason})' if reason else ''}. Nothing was installed.")
return
if _elapsed(run.started_at) > TRIGGER_GRACE_S:
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
triggered = ((automation or {}).get("attributes") or {}).get("last_triggered")
if not is_newer(triggered, run.triggered_before):
await _finish(app, run.id, "failed",
"Home Assistant didn't run the maintenance automation, so its webhook id may not "
"match Steward's, or Steward isn't on its local network. Nothing was installed.")
return
await _past_deadline(app, run)
async def _advance_install(app, run, connection, transport) -> None:
token, webhook_id = _secrets(connection)
url, verify = connection.url, connection.verify_tls
if run.install_sent_at is None:
await client.send_webhook(url, webhook_id, {"action": "install", "entity_id": run.entity_id},
verify, transport)
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run.id)
row.install_sent_at = datetime.now(timezone.utc)
return
current = await client.read_state(url, run.entity_id, token, verify, transport)
attrs = (current or {}).get("attributes") or {}
installed = attrs.get("installed_version")
if installed and (installed == run.to_version
or (current.get("state") == "off" and installed != run.from_version)):
await _finish(app, run.id, "done", None)
return
await _past_deadline(app, run)
+4 -1
View File
@@ -214,6 +214,9 @@ ACTIONS = {
"prometheus.template_created": "added Prometheus widget template",
"prometheus.template_updated": "changed Prometheus widget template",
"prometheus.template_deleted": "deleted Prometheus widget template",
"homeassistant.connection_saved": "saved the Home Assistant connection",
"homeassistant.connection_removed": "removed the Home Assistant connection",
"homeassistant.update_started": "started a Home Assistant update",
"syslog.source_added": "added syslog source",
"syslog.source_updated": "changed syslog source",
"syslog.source_removed": "removed syslog source",
@@ -244,7 +247,7 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), ("
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"),
("syslog.", "Syslog"), ("backup.", "Backups"), ("timeline.", "Timeline"),
("syslog.", "Syslog"), ("homeassistant.", "Home Assistant"), ("backup.", "Backups"), ("timeline.", "Timeline"),
("ticket.", "Tickets"))
+6 -1
View File
@@ -29,6 +29,7 @@ KEPT: frozenset[str] = frozenset({
# Syslog senders: which were added, under what name, and which ignored.
"syslog_sources",
"syslog_patterns",
"homeassistant_connection",
})
# Rows that are measurements, logs, history or in-flight work: left out.
@@ -67,6 +68,7 @@ EXCLUDED: frozenset[str] = frozenset({
"unifi_events", "unifi_fw_rules", "unifi_networks", "unifi_port_forwards",
"unifi_speedtest_results", "unifi_wan_stats", "unifi_wlan_stats",
"syslog_lines",
"homeassistant_updates",
})
# ── What a check compares (#5823) ────────────────────────────────────────────
@@ -91,6 +93,8 @@ ACTIVITY: dict[str, frozenset[str]] = {
"snmp_devices": frozenset({"last_polled_at", "last_ok_at", "last_error", "missing"}),
"prometheus_targets": frozenset({"last_scraped_at", "last_ok_at", "last_error", "last_kept",
"last_dropped", "last_capped"}),
"homeassistant_connection": frozenset({"refused", "last_polled_at", "last_ok_at", "last_error",
"updates", "backup", "automation"}),
# What the sender last sent; its address, name, state and host are setup.
"syslog_sources": frozenset({"hostname_seen", "first_seen_at", "last_seen_at", "lines_total",
"last_severity", "last_line"}),
@@ -121,5 +125,6 @@ AREAS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Ansible", ("ansible_targets", "ansible_groups", "ansible_target_groups", "ansible_schedules")),
("Dashboards", ("dashboards", "dashboard_widgets", "dashboard_share_tokens")),
("Plugins", ("snmp_devices", "prometheus_targets", "prometheus_widget_templates",
"unifi_known_clients", "syslog_sources", "syslog_patterns")),
"unifi_known_clients", "syslog_sources", "syslog_patterns",
"homeassistant_connection")),
)
+3
View File
@@ -298,6 +298,8 @@ def metric_value(name: str, value: Any) -> str:
# its underscores spaced (SNMP's OID labels are the operator's own words).
METRICS: dict[str, tuple[str, str]] = {
"is_up": ("Up or down", "whether the check passed"),
"backup_age_hours": ("Age of last backup (hours)", "hours since Home Assistant's last successful automatic backup"),
"updates_waiting": ("Updates waiting", "Core, OS and add-on updates Home Assistant has waiting"),
"is_healthy": ("Health check", "the container's own HEALTHCHECK result"),
"response_ms": ("Response time", "how long the check took"),
"ingest_posts": ("Agent posts per minute", "host-agent reports Steward took in the last minute"),
@@ -421,6 +423,7 @@ SOURCES: dict[str, str] = {
"http": "HTTP monitors", "steward": "Steward itself", "host_agent": "Host agent",
"docker": "Docker containers", "traefik": "Traefik routers", "unifi": "UniFi",
"snmp": "SNMP devices", "prometheus": "Prometheus targets", "syslog": "Syslog patterns",
"homeassistant": "Home Assistant",
}
+1
View File
@@ -181,6 +181,7 @@ DEFAULTS: dict[str, Any] = {
"plugin.snmp": {"enabled": True},
"plugin.prometheus": {"enabled": True},
"plugin.syslog": {"enabled": True},
"plugin.homeassistant": {"enabled": True},
# OIDC single-sign-on
"oidc.enabled": False,
"oidc.discovery_url": "",
+3 -1
View File
@@ -118,7 +118,7 @@ for _t in (
# A source not listed here is Monitoring.
_SOURCE_AREAS = {
"icmp": "network", "dns": "network", "tcp": "service", "http": "service",
"docker": "service", "prometheus": "service",
"docker": "service", "prometheus": "service", "homeassistant": "service",
"traefik": "network", "unifi": "network",
"snmp": "hardware",
}
@@ -154,6 +154,8 @@ _AUDIT_TYPES = (
("account.time_zone", None), ("account.email", None),
("host_agent.maintenance_plan", "steward.config"), ("host_agent.maintenance", None),
("docker.prune.", None),
# An update is on the timeline as itself, once it ends (homeassistant.timeline).
("homeassistant.update", None),
# A change recorded by hand is on the timeline as itself (timeline.changes).
("timeline.change", None),
# A ticket's opening, resolution and reopening are on the timeline as
+1 -1
View File
@@ -9,7 +9,7 @@ import steward
from steward.alerts.service import CORE_METRICS
_REPO = pathlib.Path(steward.__file__).parent.parent
_PLUGINS = ("traefik", "unifi", "docker", "host_agent", "snmp", "prometheus")
_PLUGINS = ("traefik", "unifi", "docker", "host_agent", "snmp", "prometheus", "homeassistant")
def catalog() -> dict[str, set[str]]:
+290
View File
@@ -0,0 +1,290 @@
"""Integration: Steward reads Home Assistant, and installs one update through its
webhook only after a newer backup, following it to its end. A fake Home Assistant
answers over httpx's mock transport. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import importlib
import json
import os
import uuid
import httpx
import pytest
from sqlalchemy import delete, select
pytestmark = pytest.mark.integration
ADDON = "update.example_update"
BEFORE, AFTER = "2026-10-30T03:00:00+00:00", "2026-10-31T03:00:00+00:00"
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def _ha(sub):
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("homeassistant")
base = loaded.__name__ if loaded is not None else "plugins.homeassistant"
return importlib.import_module(f"{base}.{sub}")
class FakeHomeAssistant:
"""Its entities, the webhook calls it received, and what it refuses."""
def __init__(self):
self.calls = []
self.tokens = set()
self.refuse = False
self.down = False
self.hook_runs = True
self.entities = {
ADDON: self._update("on", "1.0"),
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
"event.backup_automatic_backup": {"state": BEFORE, "attributes": {"event_type": "completed"}},
"automation.steward_maintenance": {"state": "on", "attributes": {"id": "steward_maintenance",
"last_triggered": None}},
}
def _update(self, state, installed):
return {"state": state, "attributes": {"title": "Example", "installed_version": installed,
"latest_version": "1.1", "in_progress": False,
"entity_picture": "/api/hassio/addons/example/icon"}}
def backup_lands(self):
self.entities["sensor.backup_last_successful_automatic_backup"]["state"] = AFTER
def update_lands(self):
self.entities[ADDON] = self._update("off", "1.1")
def handler(self, request: httpx.Request) -> httpx.Response:
if self.down:
raise httpx.ConnectError("connection refused")
path = request.url.path
if path.startswith("/api/webhook/"):
assert "authorization" not in request.headers
self.calls.append(json.loads(request.content))
if self.hook_runs:
self.entities["automation.steward_maintenance"]["attributes"]["last_triggered"] = AFTER
return httpx.Response(200)
self.tokens.add(request.headers.get("authorization"))
if self.refuse:
return httpx.Response(401)
if path == "/api/states":
return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()])
entity = self.entities.get(path.removeprefix("/api/states/"))
return httpx.Response(200, json=entity) if entity else httpx.Response(404)
@property
def transport(self):
return httpx.MockTransport(self.handler)
async def _clean(app, **connection):
from steward.core.crypto import encrypt_secret
m = _ha("models")
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(m.HomeAssistantUpdate))
await s.execute(delete(m.HomeAssistantConnection))
s.add(m.HomeAssistantConnection(url="http://ha.test:8123", token=encrypt_secret("read-only"),
webhook_id=encrypt_secret(f"hook{uuid.uuid4().hex}"), **connection))
async def _state(app):
m = _ha("models")
async with app.db_sessionmaker() as s:
connection = (await s.execute(select(m.HomeAssistantConnection))).scalars().first()
runs = (await s.execute(select(m.HomeAssistantUpdate))).scalars().all()
return connection, runs
def test_a_poll_stores_what_it_read_and_records_its_metrics(app):
from steward.models.metrics import PluginMetricLatest
sched = _ha("scheduler")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
connection, _ = await _state(app)
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(PluginMetricLatest).where(
PluginMetricLatest.source_module == "homeassistant"))).scalars().all()
return connection, {r.metric_name: r.value for r in rows}
connection, metrics = asyncio.run(go())
assert ha.tokens == {"Bearer read-only"}
assert connection.automation == "on" and connection.last_error is None and not connection.refused
assert [u["entity_id"] for u in connection.updates] == [ADDON]
assert connection.backup["last_ok"] == BEFORE
assert metrics["is_up"] == 1.0 and metrics["updates_waiting"] == 1.0 and metrics["backup_age_hours"] >= 0
def test_a_refused_token_is_asked_once_and_not_again(app):
sched = _ha("scheduler")
ha = FakeHomeAssistant()
ha.refuse = True
seen = []
real = ha.handler
def counted(request):
seen.append(request.url.path)
return real(request)
transport = httpx.MockTransport(counted)
async def go():
await _clean(app)
await sched.poll(app, transport=transport)
await sched.poll(app, transport=transport)
return (await _state(app))[0]
connection = asyncio.run(go())
assert seen == ["/api/states"]
assert connection.refused and "refused the token" in connection.last_error
def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeline(app):
from steward.models.timeline import TimelineEntry
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
assert ha.calls == [{"action": "backup"}]
assert run.state == "backing_up" and run.from_version == "1.0" and run.to_version == "1.1"
# No newer backup yet: nothing is installed, however often it is asked.
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}]
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
_, [row] = await _state(app)
assert row.state == "installing" and row.backup_at is not None
await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}, {"action": "install", "entity_id": ADDON}]
# Home Assistant restarting is expected, and isn't the end of the update.
ha.down = True
await upd.advance(app, transport=ha.transport)
_, [row] = await _state(app)
assert row.state == "installing"
ha.down = False
ha.update_lands()
await upd.advance(app, transport=ha.transport)
connection, [row] = await _state(app)
assert row.state == "done" and row.finished_at is not None
assert connection.last_polled_at is None # the page shows the new version at the next tick
await upd.advance(app, transport=ha.transport)
assert len(ha.calls) == 2
async with app.db_sessionmaker() as s:
entries = (await s.execute(select(TimelineEntry).where(
TimelineEntry.ref_type == "homeassistant_update", TimelineEntry.ref_id == row.id))).scalars().all()
return entries
[entry] = asyncio.run(go())
assert entry.type == "homeassistant.updated" and entry.actor == "someone"
assert "1.0 to 1.1" in entry.summary and entry.area == "service"
def test_a_failed_backup_installs_nothing(app):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
ha.entities["event.backup_automatic_backup"] = {
"state": AFTER, "attributes": {"event_type": "failed", "failed_reason": "upload_failed"}}
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "upload_failed" in run.message and "Nothing was installed" in run.message
assert ha.calls == [{"action": "backup"}]
def test_a_webhook_that_never_ran_the_automation_says_so(app):
from datetime import timedelta
from sqlalchemy import update
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
ha = FakeHomeAssistant()
ha.hook_runs = False
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
await upd.advance(app, transport=ha.transport)
assert (await _state(app))[1][0].state == "backing_up" # within its grace
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(m.HomeAssistantUpdate).where(m.HomeAssistantUpdate.id == run.id).values(
started_at=run.started_at - timedelta(seconds=upd.TRIGGER_GRACE_S + 1)))
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "webhook id" in run.message and "Nothing was installed" in run.message
def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
from datetime import timedelta
from sqlalchemy import update
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
with pytest.raises(upd.CannotStart, match="under way"):
await upd.start_update(app, ADDON, "someone else", transport=ha.transport)
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport)
async with app.db_sessionmaker() as s:
async with s.begin():
row = await s.get(m.HomeAssistantUpdate, run.id)
row.install_sent_at -= timedelta(seconds=upd.INSTALL_DEADLINE_S["addon"] + 1)
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "Still not on 1.1" in run.message
assert len(ha.calls) == 2
@pytest.mark.parametrize("entity,change,says", [
("update.unknown", None, "isn't a Core, OS or add-on"),
(ADDON, "no_longer_waiting", "no longer waiting"),
(ADDON, "no_automation", "automation isn't on"),
])
def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
if change == "no_automation":
del ha.entities["automation.steward_maintenance"]
await sched.poll(app, transport=ha.transport)
if change == "no_longer_waiting":
ha.update_lands()
with pytest.raises(upd.CannotStart, match=says):
await upd.start_update(app, entity, "someone", transport=ha.transport)
return (await _state(app))[1]
assert asyncio.run(go()) == []
assert ha.calls == []
+189
View File
@@ -0,0 +1,189 @@
"""Reading what Home Assistant reports: which updates Steward may install, how
its backups stand, the automation to add, and what the pages and MCP show."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import yaml
import plugins.homeassistant as homeassistant_plugin
from plugins.homeassistant import mcp_tools, scheduler, service as s
from steward.models.users import UserRole
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
NOW = datetime(2026, 10, 31, 12, 0, tzinfo=timezone.utc)
def _entity(entity_id, state="on", **attrs):
return {"entity_id": entity_id, "state": state, "attributes": attrs}
def _addon(slug="core_example", state="on"):
return _entity(f"update.{slug}_update", state, title="Example add-on", installed_version="1.0",
latest_version="1.1", entity_picture=f"/api/hassio/addons/{slug}/icon")
def test_only_core_os_and_add_ons_can_be_installed():
assert s.classify(_entity(s.CORE)) == "core"
assert s.classify(_entity(s.OS)) == "os"
assert s.classify(_addon()) == "addon"
assert s.classify(_entity(s.SUPERVISOR, entity_picture="/api/brands/integration/hassio/icon.png")) is None
assert s.classify(_entity("update.switch_firmware", device_class="firmware",
entity_picture="/api/brands/integration/unifi/icon.png")) is None
assert s.classify(_entity("update.custom_locks_update",
entity_picture="https://brands.home-assistant.io/_/locks/icon.png")) is None
def test_a_snapshot_keeps_waiting_updates_first_and_finds_the_automation_by_its_id():
snapshot = s.read_snapshot([
_addon("core_quiet", "off"), _entity("update.switch_firmware", title="Switch"),
_addon(), _entity(s.CORE, title="Home Assistant Core"),
_entity("update.gone", "unavailable"),
_entity(s.LAST_BACKUP, "2026-10-31T03:00:00+00:00"), _entity(s.NEXT_BACKUP, "unknown"),
_entity(s.BACKUP_EVENT, "2026-10-31T03:00:00+00:00", event_type="completed"),
_entity("automation.renamed_by_someone", "on", id=s.AUTOMATION_ID, last_triggered=None),
_entity("light.kitchen", "on"),
])
assert [u["entity_id"] for u in snapshot["updates"]] == [
s.CORE, "update.core_example_update", "update.switch_firmware", "update.core_quiet_update"]
assert snapshot["automation"] == "on"
backup = snapshot["backup"]
assert backup["last_ok"] == "2026-10-31T03:00:00+00:00" and backup["next"] is None
assert backup["last_result"] == "completed"
assert s.automation_entity_id(NS(backup=backup)) == "automation.renamed_by_someone"
assert s.read_snapshot([])["automation"] is None
def test_a_release_link_is_only_kept_when_it_is_a_web_address():
assert s.read_snapshot([_entity(s.CORE, release_url="javascript:alert(1)")])["updates"][0]["release_url"] is None
assert s.read_snapshot([_entity(s.CORE, release_url="https://example.com/r")])["updates"][0]["release_url"]
def test_newer_is_judged_on_the_readings_alone():
assert s.is_newer("2026-10-31T03:00:01+00:00", "2026-10-31T03:00:00+00:00")
assert not s.is_newer("2026-10-31T03:00:00+00:00", "2026-10-31T03:00:00+00:00")
assert not s.is_newer("2026-10-30T03:00:00+00:00", "2026-10-31T03:00:00+00:00")
assert s.is_newer("2026-10-31T03:00:00+00:00", "unknown")
assert not s.is_newer("unavailable", "2026-10-31T03:00:00+00:00")
def test_how_the_backups_read():
ok = {"known": True, "last_ok": (NOW - timedelta(hours=25)).isoformat(), "last_result": "completed"}
assert s.backup_verdict(ok, NOW)[0] == "ok"
assert abs(s.backup_age_hours(ok, NOW) - 25) < 0.01
stale = {**ok, "last_ok": (NOW - timedelta(hours=27)).isoformat()}
assert s.backup_verdict(stale, NOW) == ("warn", "The last good backup is 27 hours old, past the 26 hours expected.")
failed = {**ok, "last_result": "failed", "failed_reason": "upload_failed"}
assert "failed (upload_failed)" in s.backup_verdict(failed, NOW)[1]
assert "no successful" in s.backup_verdict({"known": True, "last_ok": None}, NOW)[1]
assert "reports no automatic backups" in s.backup_verdict({}, NOW)[1]
def test_the_automation_to_add_only_takes_a_backup_or_installs_what_it_allows():
text = s.automation_yaml("steward_abc")
config = yaml.safe_load(text)
assert config["id"] == s.AUTOMATION_ID and config["mode"] == "single"
[trigger] = config["triggers"]
assert trigger == {"trigger": "webhook", "webhook_id": "steward_abc", "allowed_methods": ["POST"], "local_only": True}
assert "integration_entities('hassio')" in config["variables"]["allowed"]
assert "'update\\\\.'" in config["variables"]["allowed"]
assert s.SUPERVISOR in config["variables"]["allowed"]
branches = config["actions"][0]["choose"]
assert [b["sequence"][0]["action"] for b in branches] == ["backup.create_automatic", "update.install"]
assert "in allowed" in branches[1]["conditions"][0]["value_template"]
def test_a_pasted_secret_loses_a_terminals_wrapping():
assert s.no_spaces(" eyJabc\n def ghi\n") == "eyJabcdefghi"
assert s.no_spaces(None) == ""
def test_a_refused_token_stops_polling_until_someone_acts():
fresh = NS(refused=False, last_polled_at=NOW - timedelta(seconds=60))
assert not scheduler.is_due(fresh, NOW)
assert scheduler.is_due(NS(refused=False, last_polled_at=NOW - timedelta(seconds=300)), NOW)
assert scheduler.is_due(NS(refused=False, last_polled_at=None), NOW)
assert not scheduler.is_due(NS(refused=True, last_polled_at=None), NOW)
def _connection(**over):
return NS(**{"url": "http://ha.example:8123", "token": "tok-s3cret", "webhook_id": "hook-s3cret",
"refused": False, "last_error": None, "last_polled_at": NOW, "last_ok_at": NOW,
"automation": "on", "backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
"updates": [], **over})
def test_the_overview_names_what_needs_a_look_and_no_more():
assert mcp_tools.overview_findings(None, None, NOW) == []
assert mcp_tools.overview_findings(_connection(), None, NOW) == []
waiting = _connection(updates=[{"waiting": True, "kind": "core", "title": "Core"}])
assert mcp_tools.overview_findings(waiting, None, NOW) == []
down = mcp_tools.overview_findings(_connection(last_error="Couldn't reach Home Assistant."), None, NOW)
assert [f["kind"] for f in down] == ["home_assistant_down"]
stale = _connection(backup={"known": True, "last_ok": (NOW - timedelta(days=2)).isoformat()})
assert [f["kind"] for f in mcp_tools.overview_findings(stale, None, NOW)] == ["home_assistant_backup"]
failed = NS(state="failed", title="Core", message="No new backup.", finished_at=NOW)
[f] = mcp_tools.overview_findings(_connection(), failed, NOW)
assert f["kind"] == "home_assistant_update_failed" and "No new backup." in f["summary"]
def test_reading_is_open_to_a_viewer_and_installing_is_for_an_admin():
seen = {}
homeassistant_plugin.register_mcp_tools(lambda fn, role, read_only=False: seen.update({fn.__name__: (role, read_only)}))
assert seen == {"get_home_assistant": (UserRole.viewer, True), "update_home_assistant": (UserRole.admin, False)}
def _page_values(**over):
update = {"entity_id": s.CORE, "title": "Core", "kind": "core", "kind_word": "Core", "installed": "1.0",
"latest": "1.1", "waiting": True, "in_progress": False, "auto_update": False,
"release_url": "https://example.com/r"}
return {"connection": _connection(), "reach": "up", "says": "Answering.", "installable": [update], "others": [],
"current": [], "runs": [], "under_way": None, "backup": {}, "backup_tone": "ok",
"backup_says": "Backups are up to date.", "backup_at": NOW, "backup_next": None, "backup_age": 1.0,
"stale_hours": 26, **over}
async def _render(app, role, **values):
from quart import session
async with app.test_request_context("/plugins/homeassistant/"):
session["user_role"] = role
page = await render_plugin_template(app, "homeassistant", "homeassistant/index.html", **values)
return " ".join(page.split())
async def test_an_admin_is_asked_before_an_install_and_a_viewer_cant_install(app):
page = await _render(app, "admin", **_page_values())
assert "1 update waiting" in page and 'action="/plugins/homeassistant/install"' in page
assert "Steward takes a backup first" in page and "Home Assistant restarts." in page
assert "tok-s3cret" not in page and "hook-s3cret" not in page
main = page[page.index("<main"):]
assert not hex_colours(main), hex_colours(main)
assert 'action="/plugins/homeassistant/install"' not in await _render(app, "viewer", **_page_values())
async def test_install_is_held_back_while_it_couldnt_work_and_the_page_says_why(app):
run = NS(id="r", title="Core", state="backing_up", from_version="1.0", to_version="1.1", started_by="someone",
started_at=NOW, message=None)
busy = await _render(app, "admin", **_page_values(under_way=run, runs=[run]))
assert 'action="/plugins/homeassistant/install"' not in busy and "taking a backup first" in busy
assert 'hx-trigger="every 15s"' in busy
gone = await _render(app, "admin", **_page_values(connection=_connection(automation=None)))
assert 'action="/plugins/homeassistant/install"' not in gone
assert "maintenance automation isn't in Home Assistant" in gone
async def test_an_unconnected_page_offers_to_connect(app):
page = await _render(app, "admin", connection=None)
assert 'href="/plugins/homeassistant/connection"' in page
async def test_the_form_shows_the_automation_but_never_a_saved_secret_in_a_field(app):
from quart import session
async with app.test_request_context("/plugins/homeassistant/connection"):
session["user_role"] = "admin"
page = await render_plugin_template(
app, "homeassistant", "homeassistant/form.html", connection=_connection(),
v={"url": "http://ha.example:8123", "verify_tls": True, "has_token": True, "has_webhook": True},
error=None, automation=s.automation_yaml("generated-id"))
assert "generated-id" in page and "tok-s3cret" not in page
assert "kept; type to change" in page