A new plugin reads Home Assistant with a read-only token every 5 minutes and shows which Core, OS and add-on updates are waiting and how old the last backup is. Install takes a backup through a local-only webhook, confirms Home Assistant's last-backup time moved, installs, and follows the update to its end, through Home Assistant's and Steward's own restarts. One runs at a time. Each ends as one timeline entry. Steward holds nothing that can act on the house: the webhook's automation can only take a backup or install an allowed update (System 231's ruling). A refused token stops polling, as Home Assistant bans an address after a few refusals. Milestone 576 steps 1 to 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
3.7 KiB
Python
77 lines
3.7 KiB
Python
# plugins/homeassistant/client.py
|
|
"""Talking to Home Assistant. Every request but the webhook carries the token,
|
|
because Home Assistant bans an address after a few refused requests, token-less
|
|
ones included. Every failure comes back as one sentence a page can show."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
|
|
TIMEOUT_SECONDS = 15.0
|
|
MAX_BYTES = 16 * 1024 * 1024
|
|
|
|
|
|
class Unreachable(Exception):
|
|
"""Home Assistant didn't answer usefully; it may be restarting."""
|
|
|
|
|
|
class Refused(Exception):
|
|
"""Home Assistant refused the token, or has banned this address."""
|
|
|
|
|
|
async def _get(url: str, path: str, token: str, verify_tls: bool, transport) -> list | dict | None:
|
|
"""The parsed JSON of a GET, or None for a 404."""
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport,
|
|
follow_redirects=False) as client:
|
|
async with client.stream("GET", url.rstrip("/") + path, headers=headers) as response:
|
|
if response.status_code == 401:
|
|
raise Refused("Home Assistant refused the token. Check it, or make a new one.")
|
|
if response.status_code == 403:
|
|
raise Refused("Home Assistant answered 403: it may have banned Steward's address "
|
|
"after refused requests. Look in its ip_bans.yaml.")
|
|
if response.status_code == 404:
|
|
return None
|
|
if response.status_code != 200:
|
|
raise Unreachable(f"Home Assistant answered {response.status_code}.")
|
|
body = bytearray()
|
|
async for chunk in response.aiter_bytes():
|
|
body.extend(chunk)
|
|
if len(body) > MAX_BYTES:
|
|
raise Unreachable("Home Assistant's answer was too large to read.")
|
|
return json.loads(body)
|
|
except httpx.TimeoutException:
|
|
raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None
|
|
except httpx.HTTPError as exc:
|
|
raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None
|
|
except ValueError:
|
|
raise Unreachable("Home Assistant's answer wasn't JSON.") from None
|
|
|
|
|
|
async def read_states(url: str, token: str, verify_tls: bool, transport=None) -> list[dict]:
|
|
states = await _get(url, "/api/states", token, verify_tls, transport)
|
|
if not isinstance(states, list):
|
|
raise Unreachable("Home Assistant's answer wasn't a list of entities.")
|
|
return states
|
|
|
|
|
|
async def read_state(url: str, entity_id: str, token: str, verify_tls: bool, transport=None) -> dict | None:
|
|
return await _get(url, f"/api/states/{entity_id}", token, verify_tls, transport)
|
|
|
|
|
|
async def send_webhook(url: str, webhook_id: str, body: dict, verify_tls: bool, transport=None) -> None:
|
|
"""Hand the maintenance automation a request. Home Assistant answers 200
|
|
whether or not the id is known, so what the request did is read afterwards."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport,
|
|
follow_redirects=False) as client:
|
|
response = await client.post(f"{url.rstrip('/')}/api/webhook/{webhook_id}", json=body)
|
|
except httpx.TimeoutException:
|
|
raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None
|
|
except httpx.HTTPError as exc:
|
|
raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None
|
|
if response.status_code != 200:
|
|
raise Unreachable(f"Home Assistant answered {response.status_code} to the webhook.")
|