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