feat(homeassistant): a scheduled OS update reboots the machine and is done once the new version runs (#6755)
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:
@@ -17,11 +17,13 @@ from .timeline import update_entry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDER_WAY = ("backing_up", "installing")
|
||||
UNDER_WAY = ("backing_up", "installing", "rebooting")
|
||||
BACKUP_DEADLINE_S = 30 * 60
|
||||
REBOOT_DEADLINE_S = 20 * 60
|
||||
# An OS update downloads and writes a whole system image, which is slow on a Pi. Home
|
||||
# Assistant reports the new version as soon as that's written, before the reboot that
|
||||
# makes it run, and raises a repair asking for that reboot.
|
||||
# makes it run, and raises a repair asking for that reboot. The schedule's own OS
|
||||
# updates make that reboot; one started by a person leaves it to them.
|
||||
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
|
||||
|
||||
|
||||
@@ -42,10 +44,11 @@ async def under_way(session) -> HomeAssistantUpdate | None:
|
||||
HomeAssistantUpdate.state.in_(UNDER_WAY)))).scalars().first()
|
||||
|
||||
|
||||
async def start_update(app, entity_id: str, username: str, transport=None) -> HomeAssistantUpdate:
|
||||
async def start_update(app, entity_id: str, username: str, transport=None, *, reboot_after: bool = False) -> 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 signed-in user may install it."""
|
||||
still waiting and the signed-in user may install it. `reboot_after` reboots
|
||||
the machine once it's written, and it's done when the new version is running."""
|
||||
async with app.db_sessionmaker() as session:
|
||||
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
|
||||
if connection is None:
|
||||
@@ -79,7 +82,7 @@ async def start_update(app, entity_id: str, username: str, transport=None) -> Ho
|
||||
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"))
|
||||
backup_event_before=(event or {}).get("state"), reboot_after=reboot_after)
|
||||
async with app.db_sessionmaker() as session:
|
||||
try:
|
||||
async with session.begin():
|
||||
@@ -117,7 +120,7 @@ async def _finish(app, run_id: str, state: str, message: str | None) -> None:
|
||||
logger.exception("Home Assistant update %s: its timeline entry could not be written", run_id)
|
||||
|
||||
|
||||
async def advance(app, transport=None) -> None:
|
||||
async def advance(app, transport=None, connect=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)
|
||||
@@ -130,8 +133,10 @@ async def advance(app, transport=None) -> None:
|
||||
try:
|
||||
if run.state == "backing_up":
|
||||
await _advance_backup(app, run, connection, transport)
|
||||
else:
|
||||
elif run.state == "installing":
|
||||
await _advance_install(app, run, connection, transport)
|
||||
else:
|
||||
await _advance_reboot(app, run, connection, transport, connect)
|
||||
except client.Refused as exc:
|
||||
await _mark_refused(app, connection.id, str(exc))
|
||||
await _finish(app, run.id, "failed", str(exc))
|
||||
@@ -158,6 +163,10 @@ async def _past_deadline(app, run) -> bool:
|
||||
await _finish(app, run.id, "failed",
|
||||
f"Still not on {run.to_version} after {limit // 60} minutes. Look in Home Assistant.")
|
||||
return True
|
||||
if run.state == "rebooting" and _elapsed(run.reboot_sent_at or run.install_sent_at or run.started_at) > REBOOT_DEADLINE_S:
|
||||
await _finish(app, run.id, "failed", f"{run.to_version} was installed, but isn't running "
|
||||
f"{REBOOT_DEADLINE_S // 60} minutes after the reboot. Look in Home Assistant.")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -207,6 +216,44 @@ async def _advance_install(app, run, connection, transport) -> None:
|
||||
installed = attrs.get("installed_version")
|
||||
if installed and (installed == run.to_version
|
||||
or (current.get("state") == "off" and installed != run.from_version)):
|
||||
if run.reboot_after:
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
row = await session.get(HomeAssistantUpdate, run.id)
|
||||
row.state = "rebooting"
|
||||
return
|
||||
await _finish(app, run.id, "done", None)
|
||||
return
|
||||
await _past_deadline(app, run)
|
||||
|
||||
|
||||
async def _mark_reboot_sent(app, run_id: str, at: datetime | None) -> None:
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
row = await session.get(HomeAssistantUpdate, run_id)
|
||||
row.reboot_sent_at = at
|
||||
|
||||
|
||||
async def _advance_reboot(app, run, connection, transport, connect) -> None:
|
||||
"""Reboot once, and wait for the new version to be what's running. The version
|
||||
is judged against the old one, so a machine that fell back to it fails at the
|
||||
deadline, as does one that never comes back."""
|
||||
token = await client.access_token(connection, transport)
|
||||
url, verify = connection.url, connection.verify_tls
|
||||
if run.reboot_sent_at is None:
|
||||
await _mark_reboot_sent(app, run.id, datetime.now(timezone.utc))
|
||||
try:
|
||||
await client.call_action(url, "hassio", "host_reboot", {}, token, verify, transport)
|
||||
except client.Unreachable:
|
||||
await _mark_reboot_sent(app, run.id, None)
|
||||
await _past_deadline(app, run)
|
||||
return
|
||||
except client.Rejected as exc:
|
||||
await _finish(app, run.id, "failed", f"{run.to_version} was installed, but Home Assistant "
|
||||
f"wouldn't reboot: {exc} It needs a reboot to take effect.")
|
||||
return
|
||||
found = await client.read_os(url, token, verify, connect)
|
||||
if found and found.get("version") not in (None, run.from_version) and not found.get("version_pending"):
|
||||
await _finish(app, run.id, "done", None)
|
||||
return
|
||||
await _past_deadline(app, run)
|
||||
|
||||
Reference in New Issue
Block a user