Files
FabledSteward/plugins/homeassistant/client.py
T
bvandeusenandClaude Opus 5.5 b2a6e92ed3
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m42s
CI / integration (push) Successful in 1m51s
CI / publish (push) Skipped
feat(homeassistant): waiting updates, backup age, and updates installed after a confirmed backup (#6355)
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>
2026-10-30 23:09:27 -04:00

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.")