# 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