# 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 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 time from urllib.parse import urlencode import httpx from steward.core.crypto import decrypt_secret TIMEOUT_SECONDS = 15.0 # Home Assistant answers an action when it has finished, minutes later for a # backup or an update, and carries on if the request ends. So its answer is only # waited for this long, and the outcome is read from its entities. 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 _access: dict[str, tuple[str, float]] = {} # refresh token -> access token, when to stop using it class Unreachable(Exception): """Home Assistant didn't answer usefully; it may be restarting.""" class Refused(Exception): """Home Assistant refused Steward's sign-in, or has banned this address.""" class Rejected(Exception): """Home Assistant understood an action and wouldn't do it.""" def _refusal(status: int) -> Refused | None: if status == 401: _access.clear() return Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.") if status == 403: return Refused("Home Assistant answered 403: it may have banned Steward's address " "after refused requests. Look in its ip_bans.yaml.") return None async def _request(method: str, url: str, path: str, verify_tls: bool, transport, *, token: str | None = None, read_timeout: float = TIMEOUT_SECONDS, **kwargs) -> tuple[int, bytes]: headers = {"Authorization": f"Bearer {token}"} if token else {} timeout = httpx.Timeout(TIMEOUT_SECONDS, read=read_timeout) try: # A slow drip of bytes can keep each read inside its own timeout for ever. async with asyncio.timeout(TIMEOUT_SECONDS + read_timeout): async with httpx.AsyncClient(timeout=timeout, verify=verify_tls, transport=transport, follow_redirects=False) as client: async with client.stream(method, url.rstrip("/") + path, headers=headers, **kwargs) as response: 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 response.status_code, bytes(body) except TimeoutError: raise httpx.ReadTimeout("no complete answer in time") from None def _unreachable(exc: httpx.HTTPError) -> Unreachable: if isinstance(exc, httpx.TimeoutException): return Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") return Unreachable(f"Couldn't reach Home Assistant: {exc}") # ── Signing in ─────────────────────────────────────────────────────────────── def sign_in_url(url: str, client_id: str, redirect_uri: str, state: str) -> str: """Home Assistant's own sign-in page, which sends the browser back to `redirect_uri`. It accepts `client_id` as it is because the two share an address, so it needn't be able to reach Steward.""" return f"{url.rstrip('/')}/auth/authorize?" + urlencode( {"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state}) async def _tokens(url: str, form: dict, verify_tls: bool, transport) -> dict: try: status, body = await _request("POST", url, "/auth/token", verify_tls, transport, data=form) except httpx.HTTPError as exc: raise _unreachable(exc) from None if status in (400, 401, 403): raise Refused("Home Assistant no longer accepts Steward's sign-in. Sign in again.") if status != 200: raise Unreachable(f"Home Assistant answered {status}.") try: found = json.loads(body) found["access_token"], int(found["expires_in"]) except (ValueError, KeyError, TypeError): raise Unreachable("Home Assistant's answer wasn't a sign-in.") from None return found def _remember(refresh_token: str, found: dict) -> str: until = time.monotonic() + int(found["expires_in"]) - EXPIRY_MARGIN_SECONDS _access[refresh_token] = (found["access_token"], until) return found["access_token"] async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, transport=None) -> tuple[str, str]: """(refresh token, access token) for the code the sign-in page sent back.""" found = await _tokens(url, {"grant_type": "authorization_code", "client_id": client_id, "code": code}, verify_tls, transport) refresh_token = found.get("refresh_token") if not refresh_token: raise Unreachable("Home Assistant's answer wasn't a sign-in.") return refresh_token, _remember(refresh_token, found) 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)) if token and time.monotonic() < until: return token found = await _tokens(connection.url, {"grant_type": "refresh_token", "client_id": connection.client_id, "refresh_token": refresh_token}, connection.verify_tls, transport) return _remember(refresh_token, found) async def is_admin(url: str, token: str, verify_tls: bool, transport=None) -> bool: """Whether the signed-in user is an administrator, which its API only says by refusing someone who isn't. So this is asked once, right after signing in, and a refusal here counts once toward a ban.""" try: status, _ = await _request("POST", url, "/api/template", verify_tls, transport, token=token, json={"template": "1"}) except httpx.HTTPError as exc: raise _unreachable(exc) from None if status == 401: return False if status != 200: raise (_refusal(status) or Unreachable(f"Home Assistant answered {status}.")) return True async def revoke(connection, transport=None) -> None: """End this sign-in on Home Assistant. It answers 200 whether or not it knew it.""" refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in") _access.pop(refresh_token, None) try: status, _ = await _request("POST", connection.url, "/auth/revoke", connection.verify_tls, transport, data={"token": refresh_token}) except httpx.HTTPError as exc: raise _unreachable(exc) from None if status != 200: raise Unreachable(f"Home Assistant answered {status}.") # ── Reading and acting ─────────────────────────────────────────────────────── 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.""" try: status, body = await _request("GET", url, path, verify_tls, transport, token=token) except httpx.HTTPError as exc: raise _unreachable(exc) from None if refusal := _refusal(status): raise refusal if status == 404: return None if status != 200: raise Unreachable(f"Home Assistant answered {status}.") try: return json.loads(body) 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 call_action(url: str, domain: str, action: str, data: dict, token: str, verify_tls: bool, transport=None) -> None: """Ask Home Assistant to do something. Returning means it got the request, not that it has finished: a request cut off once sent has been taken up, and an update that restarts Home Assistant cuts its own request off.""" try: status, body = await _request("POST", url, f"/api/services/{domain}/{action}", verify_tls, transport, token=token, read_timeout=ACTION_READ_SECONDS, json=data) except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError): return except httpx.HTTPError as exc: raise _unreachable(exc) from None if refusal := _refusal(status): raise refusal if status != 200: try: said = json.loads(body).get("message") except (ValueError, AttributeError): said = None raise Rejected(said or f"Home Assistant answered {status}.")