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>
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
# steward/core/weekly.py
|
|
"""A weekly slot: a weekday and a time in a time zone, with a window after it. A
|
|
slot fires once, and only while its window is open, so a Steward that was down
|
|
over the slot starts late rather than a day late. A schedule is anything with
|
|
weekday, at_time, time_zone, window_minutes, enabled, last_slot_at and created_at."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, time, timedelta, timezone
|
|
|
|
from steward.core.formatting import zone
|
|
|
|
WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
|
|
|
|
|
def parse_at(at_time: str) -> time:
|
|
h, m = (int(x) for x in at_time.split(":", 1))
|
|
if not (0 <= h < 24 and 0 <= m < 60):
|
|
raise ValueError(at_time)
|
|
return time(h, m)
|
|
|
|
|
|
def last_slot(plan, now: datetime) -> datetime:
|
|
"""The most recent scheduled time at or before `now`, in UTC."""
|
|
tz = zone(plan.time_zone)
|
|
local = now.astimezone(tz)
|
|
day = local.date() - timedelta(days=(local.weekday() - plan.weekday) % 7)
|
|
slot = datetime.combine(day, parse_at(plan.at_time), tzinfo=tz)
|
|
if slot > local:
|
|
slot -= timedelta(days=7)
|
|
return slot.astimezone(timezone.utc)
|
|
|
|
|
|
def fired_slot(plan) -> datetime | None:
|
|
"""The last slot that fired, or None while none has. A new schedule's
|
|
last_slot_at holds the moment it was made, so its first slot is the
|
|
next one rather than one already past; that moment is no slot."""
|
|
at = plan.last_slot_at
|
|
if at is None:
|
|
return None
|
|
return at if last_slot(plan, at) == at else None
|
|
|
|
|
|
def next_slot(plan, now: datetime) -> datetime:
|
|
return last_slot(plan, now) + timedelta(days=7)
|
|
|
|
|
|
def is_due(plan, now: datetime) -> datetime | None:
|
|
"""The slot to fire now, or None."""
|
|
if not plan.enabled:
|
|
return None
|
|
slot = last_slot(plan, now)
|
|
since = plan.last_slot_at or plan.created_at
|
|
if since is not None and slot <= since:
|
|
return None
|
|
if now - slot >= timedelta(minutes=plan.window_minutes):
|
|
return None
|
|
return slot
|
|
|
|
|
|
def describe(plan) -> str:
|
|
""""Sundays at 03:00 (Europe/London), up to 4 h" for a page."""
|
|
hours = plan.window_minutes / 60
|
|
span = f"{hours:g} h" if plan.window_minutes % 60 == 0 else f"{plan.window_minutes} min"
|
|
return f"{WEEKDAYS[plan.weekday]}s at {plan.at_time} ({plan.time_zone}), up to {span}"
|