feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.
A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.
The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# 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)
|
||||
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
|
||||
Reference in New Issue
Block a user