diff --git a/docs/plugins/homeassistant.md b/docs/plugins/homeassistant.md index 5d8c8f9..eed1275 100644 --- a/docs/plugins/homeassistant.md +++ b/docs/plugins/homeassistant.md @@ -29,7 +29,7 @@ Install asks once, then Steward: 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. -An OS update takes effect only after the machine reboots, and Steward leaves that to you. Steward records the update as done once Home Assistant reports the new version, which it does before the reboot. Home Assistant then raises a repair asking for the reboot, which appears under System problems until you do it. +An OS update takes effect only after the machine reboots, and Install leaves that to you. Steward records the update as done once Home Assistant reports the new version, which it does before the reboot. Home Assistant then raises a repair asking for the reboot, which appears under System problems until you do it. ## System problems @@ -45,7 +45,9 @@ Repairs need an administrator's sign-in. Disk space and add-ons come from the Su ## Scheduled updates -Set a day and time under Scheduled updates on the page. It's off until you do. At that time Steward installs what's waiting, add-ons first, then Core, then the OS, one at a time and each after its own confirmed backup. It starts nothing new more than 4 hours after the slot. The first failure ends the run and the rest wait, and the page, the overview and `update_failed` say so. A Core update restarts Home Assistant with nobody there to watch, an update can change how automations behave, and an OS update still needs you to reboot the machine afterwards. +Set a day and time under Scheduled updates on the page. It's off until you do. At that time Steward installs what's waiting, add-ons first, then Core, then the OS, one at a time and each after its own confirmed backup. It starts nothing new more than 4 hours after the slot. The first failure ends the run and the rest wait, and the page, the overview and `update_failed` say so. A Core update restarts Home Assistant with nobody there to watch, and an update can change how automations behave. + +An OS update installed by the schedule also reboots the machine, with nobody there to watch. It counts as done once the new version is running. If that hasn't happened 20 minutes after the reboot, for example because the machine didn't come back or fell back to the old version, the update is marked failed. ## Metrics diff --git a/plugins/homeassistant/client.py b/plugins/homeassistant/client.py index 6baacc8..6402de2 100644 --- a/plugins/homeassistant/client.py +++ b/plugins/homeassistant/client.py @@ -10,6 +10,7 @@ import asyncio import json import ssl import time +from contextlib import asynccontextmanager from urllib.parse import urlencode import httpx @@ -242,9 +243,9 @@ class _Socket: return await self.ask(type="supervisor/api", endpoint=endpoint, method="get") -async def read_system(url: str, token: str, verify_tls: bool, connect=None) -> dict: - """What service.read_health needs, as Home Assistant answered it. `connect` - stands in for the websocket in tests.""" +@asynccontextmanager +async def _signed_in(url: str, token: str, verify_tls: bool, connect): + """A signed-in socket. `connect` stands in for the websocket in tests.""" target = "ws" + url.rstrip("/").removeprefix("http") + "/api/websocket" options: dict = {"max_size": MAX_BYTES} if target.startswith("wss:") and not verify_tls: @@ -255,23 +256,37 @@ async def read_system(url: str, token: str, verify_tls: bool, connect=None) -> d async with (connect or ws_connect)(target, **options) as ws: socket = _Socket(ws) await socket.sign_in(token) - found = {"config": await socket.ask(type="get_config"), - "issues": (await socket.ask(type="repairs/list_issues") or {}).get("issues"), - "resolution": await socket.supervisor("/resolution/info"), - "host": await socket.supervisor("/host/info"), "titles": {}, "boots": {}} - domains = sorted({i["domain"] for i in found["issues"] or []}) - if domains: - titles = await socket.ask(type="frontend/get_translations", language="en", - category="issues", integration=domains) - found["titles"] = (titles or {}).get("resources") or {} - found["addons"] = (await socket.supervisor("/addons") or {}).get("addons") - # Whether one should be running is only on its own page, so a running one isn't asked. - for addon in found["addons"] or []: - if addon.get("state") != "started": - info = await socket.supervisor(f"/addons/{addon['slug']}/info") or {} - found["boots"][addon["slug"]] = info.get("boot") - return found + yield socket except TimeoutError: raise Unreachable(f"Home Assistant's system report didn't arrive within {SYSTEM_READ_SECONDS} s.") from None except (OSError, ValueError, KeyError, WebSocketException) as exc: raise Unreachable(f"Couldn't read Home Assistant's system report: {exc}") from None + + +async def read_system(url: str, token: str, verify_tls: bool, connect=None) -> dict: + """What service.read_health needs, as Home Assistant answered it.""" + async with _signed_in(url, token, verify_tls, connect) as socket: + found = {"config": await socket.ask(type="get_config"), + "issues": (await socket.ask(type="repairs/list_issues") or {}).get("issues"), + "resolution": await socket.supervisor("/resolution/info"), + "host": await socket.supervisor("/host/info"), "titles": {}, "boots": {}} + domains = sorted({i["domain"] for i in found["issues"] or []}) + if domains: + titles = await socket.ask(type="frontend/get_translations", language="en", + category="issues", integration=domains) + found["titles"] = (titles or {}).get("resources") or {} + found["addons"] = (await socket.supervisor("/addons") or {}).get("addons") + # Whether one should be running is only on its own page, so a running one isn't asked. + for addon in found["addons"] or []: + if addon.get("state") != "started": + info = await socket.supervisor(f"/addons/{addon['slug']}/info") or {} + found["boots"][addon["slug"]] = info.get("boot") + return found + + +async def read_os(url: str, token: str, verify_tls: bool, connect=None) -> dict | None: + """The Supervisor's report on the OS: `version`, and `version_pending` while a + written update waits for its reboot. None if it isn't answering yet, as just + after a boot.""" + async with _signed_in(url, token, verify_tls, connect) as socket: + return await socket.supervisor("/os/info") diff --git a/plugins/homeassistant/mcp_tools.py b/plugins/homeassistant/mcp_tools.py index 2ee45cd..b4735cf 100644 --- a/plugins/homeassistant/mcp_tools.py +++ b/plugins/homeassistant/mcp_tools.py @@ -105,9 +105,9 @@ async def set_home_assistant_schedule(enabled: bool, weekday: int = 5, at_time: At each slot Steward installs add-ons, then Core, then the OS, one at a time, each after a confirmed backup, stopping at the first failure. A Core update - restarts Home Assistant with nobody watching. An OS update takes effect after - a reboot, which Steward leaves to a person, and `system.problems` shows it until - then. The first slot is the next one from now. Admin only, and audited. + restarts Home Assistant and an OS update reboots the machine, which Steward + does itself here, with nobody watching. The first slot is the next one from + now. Admin only, and audited. """ try: weekly.parse_at(at_time) diff --git a/plugins/homeassistant/migrations/versions/homeassistant_004_reboot.py b/plugins/homeassistant/migrations/versions/homeassistant_004_reboot.py new file mode 100644 index 0000000..b083178 --- /dev/null +++ b/plugins/homeassistant/migrations/versions/homeassistant_004_reboot.py @@ -0,0 +1,34 @@ +"""Home Assistant: an update the schedule installs can reboot the machine and follow it + +Revision ID: homeassistant_004_reboot +Revises: homeassistant_003_health +Create Date: 2026-11-02 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "homeassistant_004_reboot" +down_revision: Union[str, None] = "homeassistant_003_health" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _under_way(states: str) -> None: + op.execute("DROP INDEX uq_homeassistant_update_under_way") + op.execute("CREATE UNIQUE INDEX uq_homeassistant_update_under_way ON homeassistant_updates ((true)) " + f"WHERE state IN ({states})") + + +def upgrade() -> None: + op.add_column("homeassistant_updates", + sa.Column("reboot_after", sa.Boolean, nullable=False, server_default=sa.false())) + op.add_column("homeassistant_updates", sa.Column("reboot_sent_at", sa.DateTime(timezone=True), nullable=True)) + _under_way("'backing_up', 'installing', 'rebooting'") + + +def downgrade() -> None: + _under_way("'backing_up', 'installing'") + op.drop_column("homeassistant_updates", "reboot_sent_at") + op.drop_column("homeassistant_updates", "reboot_after") diff --git a/plugins/homeassistant/models.py b/plugins/homeassistant/models.py index 7cbd404..06caa2a 100644 --- a/plugins/homeassistant/models.py +++ b/plugins/homeassistant/models.py @@ -63,12 +63,13 @@ class HomeAssistantUpdate(Base): 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. + is judged on its clock alone. One can be under way at a time. `reboot_after` + sends it through rebooting too, for an OS update the schedule installs. """ __tablename__ = "homeassistant_updates" __table_args__ = ( Index("uq_homeassistant_update_under_way", text("(true)"), unique=True, - postgresql_where=text("state IN ('backing_up', 'installing')")), + postgresql_where=text("state IN ('backing_up', 'installing', 'rebooting')")), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) @@ -86,4 +87,6 @@ class HomeAssistantUpdate(Base): backup_event_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) + reboot_after: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + reboot_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/plugins/homeassistant/routes.py b/plugins/homeassistant/routes.py index 6322d39..8569fa0 100644 --- a/plugins/homeassistant/routes.py +++ b/plugins/homeassistant/routes.py @@ -23,7 +23,7 @@ from steward.models.users import User, UserRole from . import client, schedule from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open -from .updates import CannotStart, start_update +from .updates import UNDER_WAY, CannotStart, start_update logger = logging.getLogger(__name__) @@ -72,7 +72,7 @@ async def _render_index(error: str | None = None, status: int = 200): 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), + runs=runs, under_way=next((r for r in runs if r.state in UNDER_WAY), None), backup=backup, backup_tone=tone, backup_says=backup_says, backup_at=parse_moment(backup.get("last_ok")), backup_next=parse_moment(backup.get("next")), backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS, diff --git a/plugins/homeassistant/schedule.py b/plugins/homeassistant/schedule.py index 517a1b3..af4baa5 100644 --- a/plugins/homeassistant/schedule.py +++ b/plugins/homeassistant/schedule.py @@ -1,7 +1,7 @@ # plugins/homeassistant/schedule.py """Installing what's waiting at a weekly slot: one at a time through the same path as the page's Install, each after its own confirmed backup. Add-ons go first and -the OS last, as it waits on a reboot. The first failure ends the slot, leaving the +the OS last, as it reboots the machine. The first failure ends the slot, leaving the rest for someone to look at. Everything is on the rows, so a restart of Steward or of Home Assistant only delays the next step.""" from __future__ import annotations @@ -76,7 +76,7 @@ async def step(app, transport=None) -> None: if update is None: return try: - await start_update(app, update["entity_id"], ACTOR, transport) + await start_update(app, update["entity_id"], ACTOR, transport, reboot_after=update["kind"] == "os") except CannotStart as exc: await _stop(app, schedule.id, f"{update['title']} wasn't started: {exc}. Nothing more was installed in this slot.") diff --git a/plugins/homeassistant/templates/homeassistant/index.html b/plugins/homeassistant/templates/homeassistant/index.html index eda8cd6..90967e5 100644 --- a/plugins/homeassistant/templates/homeassistant/index.html +++ b/plugins/homeassistant/templates/homeassistant/index.html @@ -20,7 +20,7 @@

{% 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. + {{ under_way.title }}: {{ {'backing_up': "taking a backup first", 'rebooting': "rebooting into " ~ under_way.to_version}.get(under_way.state, "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 }}. @@ -97,13 +97,13 @@

Scheduled updates

{% if plan and plan.enabled %} -

{{ plan_when }}. Next: {{ plan_next|ago }}. Add-ons first, then Core, then the OS, each after a confirmed backup. A failure ends the run and the rest wait.

+

{{ plan_when }}. Next: {{ plan_next|ago }}. Add-ons first, then Core, then the OS and its reboot, each after a confirmed backup. A failure ends the run and the rest wait.

{% else %}

Off. Updates are only installed when someone chooses Install.

{% endif %} {% if plan and plan.stopped %}

The last run stopped early. {{ plan.stopped }}

{% endif %} {% if admin %} -

A Core update restarts Home Assistant with nobody there to watch, and an update can change how automations behave. An OS update takes effect only once you reboot the machine: Steward leaves that to you, and shows it under System until it's done.

+

A Core update restarts Home Assistant and an OS update reboots the machine, with nobody there to watch. An update can also change how automations behave.

{{ csrf_field() }}