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>
93 lines
5.5 KiB
Python
93 lines
5.5 KiB
Python
# plugins/homeassistant/models.py
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, String, Text, text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from steward.core.ids import time_ordered_id
|
|
from steward.models.base import Base
|
|
|
|
|
|
class HomeAssistantConnection(Base):
|
|
"""The Home Assistant instance Steward looks after; at most one row.
|
|
|
|
`refresh_token` is what Home Assistant's sign-in gave Steward, encrypted. It
|
|
has the same access as the user who signed in, whatever that is: Home
|
|
Assistant has no narrower permission. `can_install` is whether that user was
|
|
an administrator, which backups need. The last_* columns and `updates` and
|
|
`backup` and `health` are the newest poll's outcome, which the pages read. `refused` stops
|
|
all polling until someone signs in again or tries again: Home Assistant bans
|
|
an address after a few refused requests.
|
|
"""
|
|
__tablename__ = "homeassistant_connection"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
url: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
client_id: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
refresh_token: Mapped[str] = mapped_column(Text, nullable=False)
|
|
can_install: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
signed_in_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
signed_in_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
|
backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
|
health: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
|
|
|
|
|
class HomeAssistantSchedule(Base):
|
|
"""The weekly slot for installing what's waiting; at most one row, none until an
|
|
admin sets it. `last_slot_at` is the slot that last fired, and `stopped` says why
|
|
that slot ended early. Times are read through steward.core.weekly."""
|
|
__tablename__ = "homeassistant_schedule"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
weekday: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
at_time: Mapped[str] = mapped_column(String(5), nullable=False)
|
|
time_zone: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
window_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
|
last_slot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
stopped: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
|
|
class HomeAssistantUpdate(Base):
|
|
"""One update Steward was asked to install, from the click to its end. It
|
|
moves through backing_up and installing to done or failed, and its row
|
|
carries everything needed to carry on after Steward restarts. The *_before
|
|
columns are Home Assistant's own readings at the start, so "a newer backup"
|
|
is judged on its clock alone. One can be under way at a time. `reboot_after`
|
|
sends it through rebooting too, for an OS update the schedule installs.
|
|
"""
|
|
__tablename__ = "homeassistant_updates"
|
|
__table_args__ = (
|
|
Index("uq_homeassistant_update_under_way", text("(true)"), unique=True,
|
|
postgresql_where=text("state IN ('backing_up', 'installing', 'rebooting')")),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
|
entity_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
kind: Mapped[str] = mapped_column(String(10), nullable=False)
|
|
from_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
to_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
state: Mapped[str] = mapped_column(String(20), nullable=False, default="backing_up")
|
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
started_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
|
backup_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
backup_event_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
backup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
install_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
reboot_after: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
reboot_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|