feat(homeassistant): a scheduled OS update reboots the machine and is done once the new version runs (#6755)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 2m0s
CI / publish (push) Skipped

Home Assistant writes an OS update and leaves the reboot to someone, so a scheduled
one never finished unattended. The schedule now asks for the reboot once, follows it
through Home Assistant going away, and counts it done when the Supervisor reports a
different version with nothing pending. A machine that doesn't come back or falls
back to the old version fails after 20 minutes, which ends the slot and raises
update_failed. Install on the page and through MCP still leave the reboot to the person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 19:15:03 -05:00
co-authored by Claude Opus 5.5
parent 4c57735dd9
commit ecc59cc882
10 changed files with 262 additions and 41 deletions
+34 -19
View File
@@ -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")