feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
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>
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
# plugins/homeassistant/client.py
|
||||
"""Talking to Home Assistant, with what its own sign-in gave Steward: a refresh
|
||||
token kept in the connection, and access tokens made from it that last half an
|
||||
hour. Home Assistant bans an address after a few refused requests, so nothing is
|
||||
hour. Repairs and the Supervisor's reports are only offered on its websocket. Home Assistant bans an address after a few refused requests, so nothing is
|
||||
sent without a token and a refusal stops the asking. Every failure comes back as
|
||||
one sentence a page can show."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from websockets.asyncio.client import connect as ws_connect
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
from steward.core.crypto import decrypt_secret
|
||||
|
||||
@@ -23,6 +26,8 @@ ACTION_READ_SECONDS = 10.0
|
||||
MAX_BYTES = 16 * 1024 * 1024
|
||||
# An access token this close to running out isn't used for another request.
|
||||
EXPIRY_MARGIN_SECONDS = 60
|
||||
# One read of the system over the websocket, however its answers dribble in.
|
||||
SYSTEM_READ_SECONDS = 30
|
||||
|
||||
_access: dict[str, tuple[str, float]] = {} # refresh token -> access token, when to stop using it
|
||||
|
||||
@@ -118,12 +123,6 @@ async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, tra
|
||||
return refresh_token, _remember(refresh_token, found)
|
||||
|
||||
|
||||
def forget_access() -> None:
|
||||
"""Drop access tokens made so far: after a refusal the next ask starts from the
|
||||
sign-in, which a dead user is refused at without counting toward a ban."""
|
||||
_access.clear()
|
||||
|
||||
|
||||
async def access_token(connection, transport=None) -> str:
|
||||
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
|
||||
token, until = _access.get(refresh_token, (None, 0.0))
|
||||
@@ -214,3 +213,65 @@ async def call_action(url: str, domain: str, action: str, data: dict, token: str
|
||||
except (ValueError, AttributeError):
|
||||
said = None
|
||||
raise Rejected(said or f"Home Assistant answered {status}.")
|
||||
|
||||
|
||||
# ── The system, over the websocket ───────────────────────────────────────────
|
||||
|
||||
class _Socket:
|
||||
"""Home Assistant's websocket after sign-in: one question at a time, each with
|
||||
an id, and None for one it won't answer (no Supervisor, or not an administrator)."""
|
||||
|
||||
def __init__(self, ws):
|
||||
self.ws, self.asked = ws, 0
|
||||
|
||||
async def sign_in(self, token: str) -> None:
|
||||
await self.ws.recv()
|
||||
await self.ws.send(json.dumps({"type": "auth", "access_token": token}))
|
||||
if json.loads(await self.ws.recv()).get("type") != "auth_ok":
|
||||
raise Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.")
|
||||
|
||||
async def ask(self, **question):
|
||||
self.asked += 1
|
||||
await self.ws.send(json.dumps({"id": self.asked, **question}))
|
||||
while True:
|
||||
answer = json.loads(await self.ws.recv())
|
||||
if answer.get("id") == self.asked:
|
||||
return answer.get("result") if answer.get("success") else None
|
||||
|
||||
async def supervisor(self, endpoint: str):
|
||||
return await self.ask(type="supervisor/api", endpoint=endpoint, method="get")
|
||||
|
||||
|
||||
async def read_system(url: str, token: str, verify_tls: bool, connect=None) -> dict:
|
||||
"""What service.read_health needs, as Home Assistant answered it. `connect`
|
||||
stands in for the websocket in tests."""
|
||||
target = "ws" + url.rstrip("/").removeprefix("http") + "/api/websocket"
|
||||
options: dict = {"max_size": MAX_BYTES}
|
||||
if target.startswith("wss:") and not verify_tls:
|
||||
options["ssl"] = ssl.create_default_context()
|
||||
options["ssl"].check_hostname, options["ssl"].verify_mode = False, ssl.CERT_NONE
|
||||
try:
|
||||
async with asyncio.timeout(SYSTEM_READ_SECONDS):
|
||||
async with (connect or ws_connect)(target, **options) as ws:
|
||||
socket = _Socket(ws)
|
||||
await socket.sign_in(token)
|
||||
found = {"config": await socket.ask(type="get_config"),
|
||||
"issues": (await socket.ask(type="repairs/list_issues") or {}).get("issues"),
|
||||
"resolution": await socket.supervisor("/resolution/info"),
|
||||
"host": await socket.supervisor("/host/info"), "titles": {}, "boots": {}}
|
||||
domains = sorted({i["domain"] for i in found["issues"] or []})
|
||||
if domains:
|
||||
titles = await socket.ask(type="frontend/get_translations", language="en",
|
||||
category="issues", integration=domains)
|
||||
found["titles"] = (titles or {}).get("resources") or {}
|
||||
found["addons"] = (await socket.supervisor("/addons") or {}).get("addons")
|
||||
# Whether one should be running is only on its own page, so a running one isn't asked.
|
||||
for addon in found["addons"] or []:
|
||||
if addon.get("state") != "started":
|
||||
info = await socket.supervisor(f"/addons/{addon['slug']}/info") or {}
|
||||
found["boots"][addon["slug"]] = info.get("boot")
|
||||
return found
|
||||
except TimeoutError:
|
||||
raise Unreachable(f"Home Assistant's system report didn't arrive within {SYSTEM_READ_SECONDS} s.") from None
|
||||
except (OSError, ValueError, KeyError, WebSocketException) as exc:
|
||||
raise Unreachable(f"Couldn't read Home Assistant's system report: {exc}") from None
|
||||
|
||||
Reference in New Issue
Block a user