Replaces the read-only token and the webhook automation, which every install would have had to paste into its own Home Assistant. Connect now goes to Home Assistant's own sign-in page, and Steward keeps the refresh token that comes back, encrypted, and calls backup.create_automatic and update.install directly. The connection page warns that the stored credential has the same access as the user who signed in, and recommends a service account so Steward's actions are easy to tell apart in Home Assistant's logs. A user who isn't an administrator gets the status page and no Install. Those actions answer only when finished, so a request cut off once sent counts as sent and the outcome is read from the entities. Removing the connection, or signing in again, ends the old sign-in on Home Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
9.5 KiB
Python
211 lines
9.5 KiB
Python
# 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}.")
|