"""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 token and webhook id are 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. `automation` is the Steward maintenance automation's state: "on" is needed to install, null means it wasn't found. """ 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 adds it 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), "automation": c.automation, "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)