103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Minimal synchronous client for HA's `/api/websocket` API.
|
||
|
|
|
||
|
|
Used by the "api" adapter (see DESIGN.md, "Deploy path: adapter per
|
||
|
|
instance") to reach dashboards, area/entity registries, and input_* helpers
|
||
|
|
— none of these are exposed over REST. Read-only by construction: the only
|
||
|
|
commands this module ever sends are `auth` and whatever `*_list`/
|
||
|
|
`lovelace/config` command the caller passes to `HaWsClient.command()` — it
|
||
|
|
has no method that issues a create/update/delete command.
|
||
|
|
|
||
|
|
Depends on the `websocket-client` PyPI package (import name `websocket`),
|
||
|
|
which is not in the stdlib. See services/home-assistant/README.md,
|
||
|
|
"WebSocket dependency", for the install command. Importing this module
|
||
|
|
raises ImportError with an actionable message if it's missing, instead of
|
||
|
|
the default traceback, so the api adapter can surface one clear line
|
||
|
|
instead of a stack trace.
|
||
|
|
"""
|
||
|
|
import itertools
|
||
|
|
import json
|
||
|
|
|
||
|
|
try:
|
||
|
|
import websocket as _ws
|
||
|
|
except ImportError as exc: # pragma: no cover - exercised via import.sh error path
|
||
|
|
raise ImportError(
|
||
|
|
"the 'websocket-client' package is required for the api adapter's "
|
||
|
|
"dashboard/registry/helper export (HA's WebSocket API) — install it "
|
||
|
|
"with 'sudo apt install python3-websocket' or 'pip install --user "
|
||
|
|
"websocket-client' (see services/home-assistant/README.md, "
|
||
|
|
"'WebSocket dependency')"
|
||
|
|
) from exc
|
||
|
|
|
||
|
|
|
||
|
|
class HaWsError(RuntimeError):
|
||
|
|
"""Raised for handshake/auth failures or a command that reports failure."""
|
||
|
|
|
||
|
|
|
||
|
|
class HaWsClient:
|
||
|
|
"""One `/api/websocket` connection: authenticate once, issue N read-only commands."""
|
||
|
|
|
||
|
|
def __init__(self, base_url, token, timeout=30):
|
||
|
|
ws_url = base_url.rstrip("/")
|
||
|
|
if ws_url.startswith("https://"):
|
||
|
|
ws_url = "wss://" + ws_url[len("https://"):]
|
||
|
|
elif ws_url.startswith("http://"):
|
||
|
|
ws_url = "ws://" + ws_url[len("http://"):]
|
||
|
|
self._url = ws_url + "/api/websocket"
|
||
|
|
self._token = token
|
||
|
|
self._timeout = timeout
|
||
|
|
self._next_id = itertools.count(1)
|
||
|
|
self._conn = None
|
||
|
|
|
||
|
|
def __enter__(self):
|
||
|
|
self.connect()
|
||
|
|
return self
|
||
|
|
|
||
|
|
def __exit__(self, *exc_info):
|
||
|
|
self.close()
|
||
|
|
|
||
|
|
def connect(self):
|
||
|
|
self._conn = _ws.create_connection(self._url, timeout=self._timeout)
|
||
|
|
hello = self._recv()
|
||
|
|
if hello.get("type") != "auth_required":
|
||
|
|
self.close()
|
||
|
|
raise HaWsError(f"unexpected handshake message from {self._url}: {hello!r}")
|
||
|
|
|
||
|
|
self._conn.send(json.dumps({"type": "auth", "access_token": self._token}))
|
||
|
|
auth_result = self._recv()
|
||
|
|
if auth_result.get("type") != "auth_ok":
|
||
|
|
self.close()
|
||
|
|
raise HaWsError(
|
||
|
|
"websocket auth rejected — check the deploy_agent token "
|
||
|
|
f"(server said: {auth_result!r})"
|
||
|
|
)
|
||
|
|
|
||
|
|
def close(self):
|
||
|
|
if self._conn is not None:
|
||
|
|
self._conn.close()
|
||
|
|
self._conn = None
|
||
|
|
|
||
|
|
def _recv(self):
|
||
|
|
return json.loads(self._conn.recv())
|
||
|
|
|
||
|
|
def command(self, command_type, **kwargs):
|
||
|
|
"""Send one read-only WS command, return its `result` payload.
|
||
|
|
|
||
|
|
Raises HaWsError if the server reports failure or sends something
|
||
|
|
other than a matching `result` message.
|
||
|
|
"""
|
||
|
|
msg_id = next(self._next_id)
|
||
|
|
payload = {"id": msg_id, "type": command_type}
|
||
|
|
payload.update(kwargs)
|
||
|
|
self._conn.send(json.dumps(payload))
|
||
|
|
while True:
|
||
|
|
msg = self._recv()
|
||
|
|
if msg.get("id") != msg_id:
|
||
|
|
# Not our response (e.g. an unrelated event) — keep waiting.
|
||
|
|
continue
|
||
|
|
if msg.get("type") == "result":
|
||
|
|
if not msg.get("success", False):
|
||
|
|
raise HaWsError(f"{command_type} failed: {msg.get('error')}")
|
||
|
|
return msg.get("result")
|
||
|
|
raise HaWsError(f"unexpected response to {command_type}: {msg!r}")
|