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>
96 lines
4.4 KiB
Python
96 lines
4.4 KiB
Python
# 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 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
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from steward.core import weekly
|
|
|
|
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
|
|
from .service import installable
|
|
from .updates import CannotStart, start_update, under_way
|
|
|
|
ACTOR = "schedule"
|
|
WINDOW_MINUTES = 240
|
|
_ORDER = {"addon": 0, "core": 1, "os": 2}
|
|
|
|
|
|
def next_to_install(waiting: list[dict], tried: set[str]) -> dict | None:
|
|
left = [u for u in waiting if u["entity_id"] not in tried and not u["in_progress"]]
|
|
return min(left, key=lambda u: _ORDER[u["kind"]], default=None)
|
|
|
|
|
|
async def _stop(app, schedule_id: str, why: str) -> None:
|
|
async with app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
row = await session.get(HomeAssistantSchedule, schedule_id)
|
|
if row is not None:
|
|
row.stopped = why
|
|
|
|
|
|
async def _fire(app, schedule_id: str, slot: datetime) -> None:
|
|
"""Open the slot, and have the next poll say what's waiting at this moment."""
|
|
async with app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
row = await session.get(HomeAssistantSchedule, schedule_id)
|
|
row.last_slot_at, row.stopped = slot, None
|
|
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
|
|
if connection is not None:
|
|
connection.last_polled_at = None
|
|
|
|
|
|
async def step(app, transport=None) -> None:
|
|
"""Fire a slot that has come, or start the next update of one that's open."""
|
|
now = datetime.now(timezone.utc)
|
|
async with app.db_sessionmaker() as session:
|
|
schedule = (await session.execute(select(HomeAssistantSchedule))).scalars().first()
|
|
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
|
|
if schedule is None or connection is None:
|
|
return
|
|
slot = weekly.is_due(schedule, now)
|
|
if slot is not None:
|
|
await _fire(app, schedule.id, slot)
|
|
return
|
|
slot = weekly.fired_slot(schedule)
|
|
if not schedule.enabled or schedule.stopped or slot is None \
|
|
or now - slot >= timedelta(minutes=schedule.window_minutes):
|
|
return
|
|
# Only on a fresh, good read: Home Assistant may be restarting from the last update.
|
|
if connection.refused or connection.last_polled_at is None or connection.last_error:
|
|
return
|
|
if await under_way(session) is not None:
|
|
return
|
|
runs = (await session.execute(select(HomeAssistantUpdate).where(
|
|
HomeAssistantUpdate.started_at >= slot))).scalars().all()
|
|
failed = next((r for r in runs if r.state == "failed"), None)
|
|
if failed is not None:
|
|
await _stop(app, schedule.id, f"{failed.title}: {failed.message} Nothing more was installed in this slot.")
|
|
return
|
|
update = next_to_install(installable(connection), {r.entity_id for r in runs})
|
|
if update is None:
|
|
return
|
|
try:
|
|
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.")
|
|
|
|
|
|
async def save(app, *, enabled: bool, weekday: int, at_time: str, time_zone: str) -> HomeAssistantSchedule:
|
|
"""Set the slot. Its first is the next one from now, not one already past."""
|
|
async with app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
row = (await session.execute(select(HomeAssistantSchedule))).scalars().first()
|
|
if row is None:
|
|
row = HomeAssistantSchedule(window_minutes=WINDOW_MINUTES)
|
|
session.add(row)
|
|
row.enabled, row.weekday, row.time_zone = enabled, weekday, time_zone
|
|
row.at_time = f"{weekly.parse_at(at_time):%H:%M}"
|
|
row.last_slot_at, row.stopped = datetime.now(timezone.utc), None
|
|
return row
|