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>
215 lines
10 KiB
Python
215 lines
10 KiB
Python
# 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)
|