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