homelab-codex-ws/scripts/ha/lib/ha_ws.py
oskar 5b9111afa7 feat(ha): api adapter for import.sh
ken is HAOS (no SSH/docker exec path), so it needs a REST/WebSocket-only
adapter: automations/scripts/scenes via one GET per
/api/config/<domain>/config/<id>, dashboards/area+entity registries/
input_* helpers via the HA WebSocket API (read-only commands only).

scripts/ha/lib/ha_api.py and ha_ws.py never take a token as a value —
only a token_path, read from disk in-process — so the bearer token never
touches a subprocess argv or a log line. ha_ws.py depends on the optional
websocket-client package and raises a clear, actionable ImportError if
it's missing rather than a raw traceback; import.sh still completes the
REST-only part of the import in that case.

Automations/scripts/scenes reuse split.write_split() so both adapters
produce byte-identical config/<instance>/ layouts and the same
idempotent stale-file cleanup on re-run.

docker-exec adapter logic is untouched.
2026-07-22 18:07:26 +02:00

103 lines
3.9 KiB
Python
Executable file

#!/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}")