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
+120
View File
@@ -49,6 +49,7 @@ class FakeHomeAssistant:
self.down = False
self.admin = True
self.reject_install = None
self.reject_reboot = None
self.cut_off = False
self.entities = {
ADDON: self._update("on", "1.0"),
@@ -92,6 +93,8 @@ class FakeHomeAssistant:
raise httpx.ReadTimeout("no answer yet")
if self.reject_install and path.endswith("update/install"):
return httpx.Response(500, json={"message": self.reject_install})
if self.reject_reboot and path.endswith("hassio/host_reboot"):
return httpx.Response(500, json={"message": self.reject_reboot})
return httpx.Response(200, json=[])
if path == "/api/states":
return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()])
@@ -701,3 +704,120 @@ def test_a_slot_past_its_window_starts_nothing_and_a_new_schedule_waits_for_its_
assert asyncio.run(go()) == []
assert ha.calls == []
REBOOT = ("hassio/host_reboot", {})
def _ha_with_os_update_waiting():
ha = FakeHomeAssistant()
del ha.entities[ADDON]
ha.entities[_ha("service").OS] = ha._update("on", "1.0")
return ha
async def _scheduled_os_update_written(app, ha):
"""A slot's OS update, followed to where it has been written and needs its reboot."""
sched, plan, updates = _ha("scheduler"), _ha("schedule"), _ha("updates")
await _clean(app)
await _a_slot_just_passed(app)
await plan.step(app, ha.transport)
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
await plan.step(app, ha.transport)
ha.backup_lands()
await updates.advance(app, ha.transport) # the backup landed
await updates.advance(app, ha.transport) # the install is sent
# Home Assistant reports the new version as installed before the reboot.
ha.entities[_ha("service").OS] = ha._update("off", "1.1")
await updates.advance(app, ha.transport)
[run] = (await _state(app))[1]
assert run.state == "rebooting" and REBOOT not in ha.calls
def test_a_scheduled_os_update_reboots_once_and_is_done_when_the_new_version_runs(app):
updates = _ha("updates")
ha = _ha_with_os_update_waiting()
def down(url, **options):
raise OSError("connection refused")
async def go():
await _scheduled_os_update_written(app, ha)
await updates.advance(app, ha.transport)
assert ha.calls.count(REBOOT) == 1
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.0", "version_pending": "1.1"}}))
assert (await _state(app))[1][0].state == "rebooting" # not gone down yet
web, _ = await _admin_client(app)
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
assert "rebooting into 1.1" in page and 'hx-trigger="every 15s"' in page
await updates.advance(app, ha.transport, down)
assert (await _state(app))[1][0].state == "rebooting" # down, and expected to be
await updates.advance(app, ha.transport, FakeSocket({}))
assert (await _state(app))[1][0].state == "rebooting" # back, but the Supervisor isn't answering yet
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.1", "version_pending": None}}))
return (await _state(app))[1][0]
run = asyncio.run(go())
assert run.state == "done" and run.finished_at is not None
assert ha.calls.count(REBOOT) == 1
def test_a_machine_that_falls_back_to_the_old_os_fails_at_the_deadline_and_ends_the_slot(app):
from datetime import timedelta
updates, plan, m = _ha("updates"), _ha("schedule"), _ha("models")
ha = _ha_with_os_update_waiting()
old_os = FakeSocket({"/os/info": {"version": "1.0", "version_pending": None}})
async def go():
await _scheduled_os_update_written(app, ha)
await updates.advance(app, ha.transport)
await updates.advance(app, ha.transport, old_os)
assert (await _state(app))[1][0].state == "rebooting"
async with app.db_sessionmaker() as s:
async with s.begin():
run = (await s.execute(select(m.HomeAssistantUpdate))).scalars().first()
run.reboot_sent_at = datetime.now(timezone.utc) - timedelta(minutes=updates.REBOOT_DEADLINE_S // 60 + 1)
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.0", "version_pending": None}}))
await _ha("scheduler").poll(app, transport=ha.transport, connect=FakeSocket({}))
await plan.step(app, ha.transport)
return (await _state(app))[1][0], await _schedule_row(app)
run, schedule = asyncio.run(go())
assert run.state == "failed" and "isn't running" in run.message
assert "isn't running" in schedule.stopped
def test_a_refused_reboot_fails_the_update_and_says_it_still_needs_one(app):
updates = _ha("updates")
ha = _ha_with_os_update_waiting()
ha.reject_reboot = "Not now"
async def go():
await _scheduled_os_update_written(app, ha)
await updates.advance(app, ha.transport)
return (await _state(app))[1][0]
run = asyncio.run(go())
assert run.state == "failed" and "Not now" in run.message and "needs a reboot" in run.message
def test_an_os_update_started_by_a_person_leaves_the_reboot_to_them(app):
sched, updates = _ha("scheduler"), _ha("updates")
ha = _ha_with_os_update_waiting()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
await updates.start_update(app, _ha("service").OS, "someone", ha.transport)
ha.backup_lands()
await updates.advance(app, ha.transport)
await updates.advance(app, ha.transport)
ha.entities[_ha("service").OS] = ha._update("off", "1.1")
await updates.advance(app, ha.transport)
await updates.advance(app, ha.transport)
return (await _state(app))[1][0]
assert asyncio.run(go()).state == "done"
assert REBOOT not in ha.calls