Files
FabledSteward/plugins/homeassistant/mcp_tools.py
T
bvandeusenandClaude Opus 5.5 0aed863c67
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m55s
CI / publish (push) Skipped
feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
Replaces the read-only token and the webhook automation, which every install
would have had to paste into its own Home Assistant. Connect now goes to Home
Assistant's own sign-in page, and Steward keeps the refresh token that comes
back, encrypted, and calls backup.create_automatic and update.install directly.

The connection page warns that the stored credential has the same access as
the user who signed in, and recommends a service account so Steward's actions
are easy to tell apart in Home Assistant's logs. A user who isn't an
administrator gets the status page and no Install.

Those actions answer only when finished, so a request cut off once sent counts
as sent and the outcome is read from the entities. Removing the connection, or
signing in again, ends the old sign-in on Home Assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:14:34 -04:00

112 lines
5.8 KiB
Python

"""The Home Assistant plugin's MCP tools and its get_overview findings.
get_home_assistant reads what the poll stored. update_home_assistant starts an
update as the page's Install does. The sign-in Steward holds is never returned.
"""
from __future__ import annotations
from sqlalchemy import select
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp, mcp_username
from steward.mcp.tools._shape import age_s, db_session, iso, now_utc, redact_url
from steward.mcp.tools.overview import finding
from steward.models.users import UserRole
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .routes import connection_says
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
from .updates import CannotStart, start_update
RECENT_UPDATES = 10
def _run_view(r: HomeAssistantUpdate) -> dict:
return {"update_id": r.id, "entity_id": r.entity_id, "title": r.title, "kind": r.kind,
"from_version": r.from_version, "to_version": r.to_version, "state": r.state, "message": r.message,
"started_by": r.started_by, "started_at": iso(r.started_at), "backup_at": iso(r.backup_at),
"finished_at": iso(r.finished_at)}
async def get_home_assistant() -> dict:
"""Home Assistant as of the last poll: whether it answers, which updates are
waiting, how old its last backup is, and the updates Steward was asked to install.
`installable` is what update_home_assistant takes (Core, OS, add-ons);
`others_waiting` is firmware and custom integrations, which Steward leaves
to Home Assistant. `can_install` is whether the Home Assistant user who signed
Steward in is an administrator, which installing needs.
"""
now = now_utc()
async with db_session() as db:
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
if c is None:
return {"connected": False, "says": "No connection yet: an admin signs in on the Home Assistant page."}
reach, says = connection_says(c, now)
backup = c.backup or {}
age = backup_age_hours(backup, now)
return {"connected": True, "url": redact_url(c.url), "reach": reach, "says": says,
"polled_at": iso(c.last_polled_at), "ok_age_s": age_s(c.last_ok_at, now), "can_install": c.can_install,
"installable": [{k: u[k] for k in ("entity_id", "title", "kind", "installed", "latest", "in_progress")}
for u in installable(c)],
"others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]],
"backup": {"verdict": backup_verdict(backup, now)[1], "last_ok": iso(parse_moment(backup.get("last_ok"))),
"age_hours": round(age, 1) if age is not None else None, "stale_after_hours": STALE_BACKUP_HOURS,
"next": iso(parse_moment(backup.get("next"))), "last_result": backup.get("last_result")},
"updates": [_run_view(r) for r in runs]}
async def update_home_assistant(entity_id: str) -> dict:
"""Install one Core, OS or add-on update, as the page's Install does: Steward
takes a backup, confirms it landed, installs, and follows it to its end.
entity_id: from get_home_assistant's `installable`.
An update restarts Home Assistant or the add-on, and an OS update reboots the
machine. Refused while another is under way, or if nothing is waiting there.
Admin only, as on the page. Recorded in the audit log.
"""
caller = current_caller()
try:
run = await start_update(mcp_app(), entity_id, mcp_username(caller.username))
except CannotStart as exc:
raise ValueError(f"Nothing was started: {exc}.") from None
await audit_mcp("homeassistant.update_started", "homeassistant_update", run.id,
{"entity_id": entity_id, "title": run.title, "to": run.to_version})
return {**_run_view(run), "next": "get_home_assistant follows it: `updates` shows its state."}
def overview_findings(c, latest_run, now) -> list[dict]:
"""A connection that isn't answering, stale backups, and a newest update that
failed. Updates waiting aren't a problem to raise."""
if c is None:
return []
found = []
reach, says = connection_says(c, now)
if reach == "down":
found.append(finding("warning", "home_assistant_down", "Home Assistant",
f"Home Assistant isn't answering: {says}", since=c.last_ok_at, next="get_home_assistant()"))
tone, backup_says = backup_verdict(c.backup, now)
if c.last_ok_at is not None and tone == "warn":
found.append(finding("warning", "home_assistant_backup", "Home Assistant", backup_says,
since=parse_moment((c.backup or {}).get("last_ok")), next="get_home_assistant()"))
if latest_run is not None and latest_run.state == "failed":
found.append(finding("warning", "home_assistant_update_failed", latest_run.title,
f"The update of {latest_run.title} failed: {latest_run.message}",
since=latest_run.finished_at, next="get_home_assistant()"))
return found
async def mcp_overview(db) -> list[dict]:
c = (await db.execute(select(HomeAssistantConnection))).scalars().first()
latest = (await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(1))).scalars().first()
return overview_findings(c, latest, now_utc())
def register_tools(register) -> None:
register(get_home_assistant, role=UserRole.viewer, read_only=True)
register(update_home_assistant, role=UserRole.admin, read_only=False)