Files
FabledSteward/plugins/homeassistant/mcp_tools.py
T
bvandeusenandClaude Opus 5.5 4c9ed02652
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s
feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
A Prometheus target's token_from names the plugin that supplies its token at each
scrape. Read its metrics makes the target; Use this sign-in moves one at that address
across, keeping its name, filters and history. A refusal stops the connection, so one
Try again or sign-in starts everything, and the sign-in is only sent to its own address.
A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't
change Home Assistant. Refused also joins the volatile columns a backup check ignores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:06:55 -05:00

116 lines
6.0 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 . import metrics
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. `metrics` is the
Prometheus target that reads its metrics, `uses_sign_in` saying whether that
same user does; null when the Prometheus plugin is off.
"""
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())
scrape = await metrics.state(db, c) if c else None
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")},
"metrics": scrape, "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)