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.
This commit is contained in:
parent
9a5c160b39
commit
5b9111afa7
|
|
@ -22,12 +22,15 @@ dawny kontener piha (docker-exec, archived).
|
|||
`services/home-assistant/config/ken-legacy/` → `docker stop` (BEZ `rm`)
|
||||
→ 7 dni obserwacji (upewnić się, że nic w domu nie polega na tym
|
||||
kontenerze) → decyzja o `docker rm`.
|
||||
3. **Adapter `api` w `import.sh` dla `ken`**: obecnie tylko `docker-exec`
|
||||
jest zaimplementowany (`scripts/ha/import.sh`), a `ken` po cutoverze
|
||||
używa `api`. Automatyzacje/skrypty/sceny przez
|
||||
`/api/config/<domain>/config/<id>`, dashboardy przez websocket API.
|
||||
Pełny import `/config` pozostaje poza zasięgiem (HAOS bez SSH) — patrz
|
||||
DESIGN.md.
|
||||
3. ✅ ZROBIONE (2026-07-22) — **Adapter `api` w `import.sh` dla `ken`**:
|
||||
automatyzacje/skrypty/sceny przez `/api/config/<domain>/config/<id>`
|
||||
(REST), dashboardy/area+entity registry/`input_*` helpery przez websocket
|
||||
API (`scripts/ha/lib/ha_api.py`, `ha_ws.py`, `import_api.py`). Pierwszy
|
||||
realny import `ken` zaimportował 118 automatyzacji, 5 skryptów, 3 sceny,
|
||||
7 dashboardów (default + 6 named; jeden zarejestrowany dashboard nigdy
|
||||
nie skonfigurowany — `config_not_found`, odnotowany w raporcie, nie
|
||||
twardy błąd). Pełny import `/config` pozostaje poza zasięgiem (HAOS bez
|
||||
SSH) — patrz DESIGN.md.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@
|
|||
# against an unchanged instance produces no diff under
|
||||
# services/home-assistant/config/<instance>/ or storage-export/<instance>/.
|
||||
#
|
||||
# Only the "docker-exec" adapter (instance "ken") is implemented here. Other
|
||||
# adapters (e.g. "api", used by "chelsty-ha") are out of scope for this
|
||||
# skeleton — see DESIGN.md, "Deploy path: adapter per instance". The
|
||||
# /api/states fixture fetch is the one piece that works for any adapter,
|
||||
# since it only needs a reachable base_url + token, not full config access.
|
||||
# Two adapters are implemented — see DESIGN.md, "Deploy path: adapter per
|
||||
# instance":
|
||||
# - "docker-exec": pulls the whole /config tree over `ssh ... docker exec
|
||||
# ... tar`. Used where the HA instance's filesystem is reachable.
|
||||
# - "api": for instances with no filesystem/SSH access (HAOS, e.g. "ken").
|
||||
# Pulls automations/scripts/scenes via REST and dashboards/registries/
|
||||
# helpers via the WebSocket API (scripts/ha/lib/import_api.py). Full
|
||||
# /config import is out of scope for this adapter.
|
||||
#
|
||||
# The /api/states fixture fetch works for either adapter, since it only
|
||||
# needs a reachable base_url + token, not full config access.
|
||||
#
|
||||
# Usage: scripts/ha/import.sh <instance>
|
||||
set -euo pipefail
|
||||
|
|
@ -40,9 +46,6 @@ fetch_fixtures() {
|
|||
return 0
|
||||
fi
|
||||
|
||||
local token
|
||||
token="$(<"$token_file")"
|
||||
|
||||
mkdir -p "$FIXTURES_DIR"
|
||||
local dest="$FIXTURES_DIR/${INSTANCE}-states-$(date +%F).yaml"
|
||||
local raw_file
|
||||
|
|
@ -50,11 +53,19 @@ fetch_fixtures() {
|
|||
|
||||
local fetch_ok=1
|
||||
if [[ "$HA_ADAPTER" == "docker-exec" ]]; then
|
||||
# docker-exec adapter: token is read into a shell variable and embedded
|
||||
# in the remote curl command over ssh (pre-existing behavior, left
|
||||
# untouched — see DESIGN.md, "Deploy path: adapter per instance").
|
||||
local token
|
||||
token="$(<"$token_file")"
|
||||
ssh "${HA_SSH_USER}@${HA_SSH_HOST}" \
|
||||
"curl -sf -H 'Authorization: Bearer ${token}' http://localhost:8123/api/states" \
|
||||
> "$raw_file" || fetch_ok=0
|
||||
else
|
||||
curl -sf -H "Authorization: Bearer ${token}" "${HA_BASE_URL}/api/states" \
|
||||
# api adapter: the token never touches a shell variable or a subprocess
|
||||
# argv here — ha_api.py reads token_file itself and sets the
|
||||
# Authorization header in-process via `requests`.
|
||||
python3 "$SCRIPT_DIR/lib/ha_api.py" get-raw "$HA_BASE_URL" "$token_file" /api/states \
|
||||
> "$raw_file" || fetch_ok=0
|
||||
fi
|
||||
|
||||
|
|
@ -104,13 +115,7 @@ FIXTURES_DIR="$SERVICE_DIR/fixtures"
|
|||
|
||||
echo "== ha import: instance=$INSTANCE adapter=$HA_ADAPTER host=$HA_HOST ==" >&2
|
||||
|
||||
if [[ "$HA_ADAPTER" != "docker-exec" ]]; then
|
||||
echo "error: adapter '$HA_ADAPTER' has no config-extraction implementation in this skeleton." >&2
|
||||
echo " see DESIGN.md, 'Deploy path: adapter per instance' — only docker-exec is built." >&2
|
||||
echo " config/${INSTANCE}/ and storage-export/${INSTANCE}/ were NOT touched." >&2
|
||||
fetch_fixtures || true
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HA_ADAPTER" == "docker-exec" ]]; then
|
||||
|
||||
if [[ -z "$HA_SSH_USER" || -z "$HA_SSH_HOST" ]]; then
|
||||
echo "error: instance '$INSTANCE' is missing ssh.user/ssh.host in instances.yaml" >&2
|
||||
|
|
@ -172,6 +177,29 @@ else
|
|||
echo " (no .storage directory in pulled config — skipping)" >&2
|
||||
fi
|
||||
|
||||
elif [[ "$HA_ADAPTER" == "api" ]]; then
|
||||
|
||||
api_token_file="${HA_TOKEN_PATH/#\~/$HOME}"
|
||||
if [[ -z "$HA_TOKEN_PATH" || ! -f "$api_token_file" ]]; then
|
||||
echo "error: instance '$INSTANCE' has adapter=api but no token at '${api_token_file:-<unset>}'." >&2
|
||||
echo " the api adapter has no filesystem fallback (HAOS has no SSH) — without a" >&2
|
||||
echo " deploy_agent long-lived access token there is nothing to import at all." >&2
|
||||
echo " See DESIGN.md, 'Tokens', for how to provision one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CONFIG_OUT_DIR" "$STORAGE_OUT_DIR"
|
||||
echo "-> importing automations/scripts/scenes (REST) + dashboards/registries/helpers (websocket) ..." >&2
|
||||
python3 "$SCRIPT_DIR/lib/import_api.py" "$HA_BASE_URL" "$api_token_file" "$CONFIG_OUT_DIR" "$STORAGE_OUT_DIR"
|
||||
|
||||
else
|
||||
echo "error: adapter '$HA_ADAPTER' has no config-extraction implementation." >&2
|
||||
echo " see DESIGN.md, 'Deploy path: adapter per instance' — only docker-exec and api are built." >&2
|
||||
echo " config/${INSTANCE}/ and storage-export/${INSTANCE}/ were NOT touched." >&2
|
||||
fetch_fixtures || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fetch_fixtures
|
||||
|
||||
echo "-> summary of changes:" >&2
|
||||
|
|
|
|||
114
scripts/ha/lib/ha_api.py
Executable file
114
scripts/ha/lib/ha_api.py
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal REST client for the HA "api" adapter (see DESIGN.md, "Deploy path:
|
||||
adapter per instance").
|
||||
|
||||
Every entrypoint here takes a *token_path*, never a token value, and reads
|
||||
the token from disk itself. The bearer token must never appear as a process
|
||||
argv (visible to any local user via `ps`) or in a log line — see DESIGN.md,
|
||||
"Tokens", and the api-adapter requirements in docs/backlog.md. Callers
|
||||
needing the token value in-process (e.g. the WebSocket client in
|
||||
ha_ws.py) should call read_token() directly rather than shelling out.
|
||||
|
||||
Read-only by construction: the only method this module exposes is GET.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class HaApiError(RuntimeError):
|
||||
"""Raised for adapter-level failures (missing/empty token, etc.)."""
|
||||
|
||||
|
||||
def read_token(token_path):
|
||||
"""Read and strip the bearer token from `token_path` (`~` expanded).
|
||||
|
||||
Hard error (raises HaApiError) if the file is absent or empty — the api
|
||||
adapter has no filesystem fallback, so a missing token means the import
|
||||
cannot do anything at all, unlike the fixtures fetch, which soft-skips.
|
||||
"""
|
||||
path = os.path.expanduser(token_path) if token_path else ""
|
||||
if not path or not os.path.isfile(path):
|
||||
raise HaApiError(
|
||||
f"no HA token at '{path or '<unset>'}' — the api adapter requires a "
|
||||
"deploy_agent long-lived access token (see DESIGN.md, 'Tokens'); "
|
||||
"this is a hard error, not a soft-skip, because nothing in this "
|
||||
"adapter works without one"
|
||||
)
|
||||
token = open(path, "r", encoding="utf-8").read().strip()
|
||||
if not token:
|
||||
raise HaApiError(f"token file '{path}' is empty")
|
||||
return token
|
||||
|
||||
|
||||
class Client:
|
||||
"""Thin wrapper around one HA instance's REST API (GET only)."""
|
||||
|
||||
def __init__(self, base_url, token, timeout=30):
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
self._timeout = timeout
|
||||
|
||||
def _headers(self):
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
|
||||
def get(self, path):
|
||||
"""GET `path` (e.g. "/api/states"), return parsed JSON.
|
||||
|
||||
Returns None on 404 so callers can treat "config not found" (e.g. an
|
||||
automation removed between the states poll and the config fetch) as
|
||||
a skip-and-report condition instead of a hard failure. Any other
|
||||
non-2xx status raises requests.HTTPError.
|
||||
"""
|
||||
resp = requests.get(
|
||||
self._base_url + path, headers=self._headers(), timeout=self._timeout
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def get_raw_text(self, path):
|
||||
"""GET `path`, return the raw response body (used for fixtures)."""
|
||||
resp = requests.get(
|
||||
self._base_url + path, headers=self._headers(), timeout=self._timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _cli_get_raw(args):
|
||||
if len(args) != 3:
|
||||
print("usage: ha_api.py get-raw <base_url> <token_path> <api_path>", file=sys.stderr)
|
||||
return 2
|
||||
base_url, token_path, api_path = args
|
||||
token = read_token(token_path)
|
||||
client = Client(base_url, token)
|
||||
sys.stdout.write(client.get_raw_text(api_path))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
"""CLI entrypoint. Only sub-command: `get-raw` — print a raw GET body.
|
||||
|
||||
Used by import.sh's fixtures fetch for the api adapter so the token
|
||||
never touches a subprocess argv (unlike `curl -H "Authorization: ..."`,
|
||||
which would put it in `ps` output for anyone on the same host).
|
||||
"""
|
||||
if len(argv) < 2:
|
||||
print("usage: ha_api.py get-raw <base_url> <token_path> <api_path>", file=sys.stderr)
|
||||
return 2
|
||||
command, rest = argv[1], argv[2:]
|
||||
try:
|
||||
if command == "get-raw":
|
||||
return _cli_get_raw(rest)
|
||||
print(f"ha_api.py: unknown command '{command}'", file=sys.stderr)
|
||||
return 2
|
||||
except (HaApiError, requests.RequestException) as exc:
|
||||
print(f"ha_api.py: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
102
scripts/ha/lib/ha_ws.py
Executable file
102
scripts/ha/lib/ha_ws.py
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
#!/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}")
|
||||
278
scripts/ha/lib/import_api.py
Executable file
278
scripts/ha/lib/import_api.py
Executable file
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only import for the HA "api" adapter (see DESIGN.md, "Deploy path:
|
||||
adapter per instance", and instances.yaml's `ken` entry).
|
||||
|
||||
For instances with no filesystem/SSH access (HAOS), this pulls whatever the
|
||||
HA REST + WebSocket API exposes:
|
||||
|
||||
- automations, scripts, scenes — REST, one GET per object
|
||||
(`/api/config/<domain>/config/<id>`), split one-file-per-object exactly
|
||||
like the docker-exec adapter's automations.yaml/scripts.yaml/scenes.yaml
|
||||
handling (scripts/ha/lib/split.py), so both adapters produce the same
|
||||
config/<instance>/{automations,scripts,scenes}/ layout.
|
||||
- dashboards, area/entity registries, input_* helpers — WebSocket, list/get
|
||||
commands only. Never a mutating command.
|
||||
|
||||
Full /config import is out of scope here (HAOS has no SSH) — see DESIGN.md.
|
||||
|
||||
Entities without a storage-backed id (YAML-configured automations/scenes)
|
||||
can't be fetched via `/api/config/.../config/<id>` — they are skipped and
|
||||
noted in the report, never silently dropped.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import ha_api # noqa: E402
|
||||
from split import write_split # noqa: E402
|
||||
|
||||
INPUT_HELPER_DOMAINS = (
|
||||
"input_boolean",
|
||||
"input_number",
|
||||
"input_text",
|
||||
"input_datetime",
|
||||
"input_select",
|
||||
"input_button",
|
||||
)
|
||||
|
||||
|
||||
def entity_domain(entity_id):
|
||||
return entity_id.split(".", 1)[0]
|
||||
|
||||
|
||||
def entity_object_id(entity_id):
|
||||
return entity_id.split(".", 1)[1]
|
||||
|
||||
|
||||
def new_report():
|
||||
return {
|
||||
"automations_imported": 0,
|
||||
"automations_skipped_yaml": [],
|
||||
"automations_skipped_missing": [],
|
||||
"scripts_imported": 0,
|
||||
"scripts_skipped_missing": [],
|
||||
"scenes_imported": 0,
|
||||
"scenes_skipped_yaml": [],
|
||||
"scenes_skipped_missing": [],
|
||||
"dashboards_imported": 0,
|
||||
"dashboards_skipped": [],
|
||||
"helpers_imported": {},
|
||||
"websocket_error": None,
|
||||
}
|
||||
|
||||
|
||||
def import_automations(client, states, config_out_dir, report):
|
||||
entities = [s for s in states if entity_domain(s["entity_id"]) == "automation"]
|
||||
objects_by_id = {}
|
||||
for ent in entities:
|
||||
auto_id = ent.get("attributes", {}).get("id")
|
||||
if auto_id is None:
|
||||
report["automations_skipped_yaml"].append(ent["entity_id"])
|
||||
continue
|
||||
cfg = client.get(f"/api/config/automation/config/{auto_id}")
|
||||
if cfg is None:
|
||||
report["automations_skipped_missing"].append(str(auto_id))
|
||||
continue
|
||||
objects_by_id[str(auto_id)] = cfg
|
||||
write_split(objects_by_id, os.path.join(config_out_dir, "automations"))
|
||||
report["automations_imported"] = len(objects_by_id)
|
||||
|
||||
|
||||
def import_scripts(client, states, config_out_dir, report):
|
||||
entities = [s for s in states if entity_domain(s["entity_id"]) == "script"]
|
||||
objects_by_key = {}
|
||||
for ent in entities:
|
||||
object_id = entity_object_id(ent["entity_id"])
|
||||
cfg = client.get(f"/api/config/script/config/{object_id}")
|
||||
if cfg is None:
|
||||
report["scripts_skipped_missing"].append(object_id)
|
||||
continue
|
||||
objects_by_key[object_id] = cfg
|
||||
write_split(objects_by_key, os.path.join(config_out_dir, "scripts"))
|
||||
report["scripts_imported"] = len(objects_by_key)
|
||||
|
||||
|
||||
def import_scenes(client, states, config_out_dir, report):
|
||||
entities = [s for s in states if entity_domain(s["entity_id"]) == "scene"]
|
||||
objects_by_id = {}
|
||||
for ent in entities:
|
||||
scene_id = ent.get("attributes", {}).get("id")
|
||||
if scene_id is None:
|
||||
report["scenes_skipped_yaml"].append(ent["entity_id"])
|
||||
continue
|
||||
cfg = client.get(f"/api/config/scene/config/{scene_id}")
|
||||
if cfg is None:
|
||||
report["scenes_skipped_missing"].append(str(scene_id))
|
||||
continue
|
||||
objects_by_id[str(scene_id)] = cfg
|
||||
write_split(objects_by_id, os.path.join(config_out_dir, "scenes"))
|
||||
report["scenes_imported"] = len(objects_by_id)
|
||||
|
||||
|
||||
def import_dashboards_and_registries(base_url, token, states, storage_out_dir, report):
|
||||
"""WebSocket-only exports: dashboards, area/entity registries, input_* helpers.
|
||||
|
||||
Written through split.write_split() like the config-side objects above,
|
||||
so a domain/dashboard that disappears between runs has its stale export
|
||||
file cleaned up too (idempotent: unchanged instance -> zero diff).
|
||||
"""
|
||||
try:
|
||||
import ha_ws
|
||||
except ImportError as exc:
|
||||
report["websocket_error"] = str(exc)
|
||||
return
|
||||
|
||||
documents = {}
|
||||
|
||||
try:
|
||||
with ha_ws.HaWsClient(base_url, token) as ws:
|
||||
try:
|
||||
default_config = ws.command("lovelace/config")
|
||||
except ha_ws.HaWsError as exc:
|
||||
report["dashboards_skipped"].append(("<default>", str(exc)))
|
||||
else:
|
||||
documents["lovelace"] = {"key": "lovelace", "data": {"config": default_config}}
|
||||
report["dashboards_imported"] += 1
|
||||
|
||||
dashboards = ws.command("lovelace/dashboards/list")
|
||||
documents["lovelace_dashboards"] = {
|
||||
"key": "lovelace_dashboards",
|
||||
"data": {"items": dashboards},
|
||||
}
|
||||
for dash in dashboards:
|
||||
dash_id = dash["id"]
|
||||
if dash.get("mode") != "storage":
|
||||
report["dashboards_skipped"].append(
|
||||
(dash_id, f"mode={dash.get('mode')!r} is file-based, not reachable via api")
|
||||
)
|
||||
continue
|
||||
try:
|
||||
cfg = ws.command("lovelace/config", url_path=dash["url_path"])
|
||||
except ha_ws.HaWsError as exc:
|
||||
report["dashboards_skipped"].append((dash_id, str(exc)))
|
||||
continue
|
||||
documents[f"lovelace.{dash_id}"] = {
|
||||
"key": f"lovelace.{dash_id}",
|
||||
"data": {"config": cfg},
|
||||
}
|
||||
report["dashboards_imported"] += 1
|
||||
|
||||
resources = ws.command("lovelace/resources")
|
||||
documents["lovelace_resources"] = {
|
||||
"key": "lovelace_resources",
|
||||
"data": {"items": resources},
|
||||
}
|
||||
|
||||
areas = ws.command("config/area_registry/list")
|
||||
documents["core.area_registry"] = {
|
||||
"key": "core.area_registry",
|
||||
"data": {"areas": areas},
|
||||
}
|
||||
|
||||
entity_registry = ws.command("config/entity_registry/list")
|
||||
documents["core.entity_registry"] = {
|
||||
"key": "core.entity_registry",
|
||||
"data": {"entities": entity_registry},
|
||||
}
|
||||
|
||||
present_domains = {entity_domain(s["entity_id"]) for s in states}
|
||||
for domain in INPUT_HELPER_DOMAINS:
|
||||
if domain not in present_domains:
|
||||
continue
|
||||
items = ws.command(f"{domain}/list")
|
||||
documents[domain] = {"key": domain, "data": {"items": items}}
|
||||
report["helpers_imported"][domain] = len(items)
|
||||
except Exception as exc: # noqa: BLE001 - surface any transport/handshake error verbatim
|
||||
report["websocket_error"] = str(exc)
|
||||
return
|
||||
|
||||
write_split(documents, storage_out_dir)
|
||||
|
||||
|
||||
def print_report(report):
|
||||
print("-> api import report:", file=sys.stderr)
|
||||
print(
|
||||
f" automations: {report['automations_imported']} imported, "
|
||||
f"{len(report['automations_skipped_yaml'])} skipped (yaml, no storage id), "
|
||||
f"{len(report['automations_skipped_missing'])} skipped (config not found)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for eid in report["automations_skipped_yaml"]:
|
||||
print(f" yaml automation (not importable via api): {eid}", file=sys.stderr)
|
||||
for eid in report["automations_skipped_missing"]:
|
||||
print(f" automation id {eid}: config not found (404)", file=sys.stderr)
|
||||
|
||||
print(
|
||||
f" scripts: {report['scripts_imported']} imported, "
|
||||
f"{len(report['scripts_skipped_missing'])} skipped (config not found)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for object_id in report["scripts_skipped_missing"]:
|
||||
print(f" script {object_id}: config not found (404)", file=sys.stderr)
|
||||
|
||||
print(
|
||||
f" scenes: {report['scenes_imported']} imported, "
|
||||
f"{len(report['scenes_skipped_yaml'])} skipped (yaml, no storage id), "
|
||||
f"{len(report['scenes_skipped_missing'])} skipped (config not found)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for eid in report["scenes_skipped_yaml"]:
|
||||
print(f" yaml scene (not importable via api): {eid}", file=sys.stderr)
|
||||
for eid in report["scenes_skipped_missing"]:
|
||||
print(f" scene id {eid}: config not found (404)", file=sys.stderr)
|
||||
|
||||
if report["websocket_error"]:
|
||||
print(
|
||||
f" dashboards/registries/helpers: SKIPPED — {report['websocket_error']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
print(f" dashboards: {report['dashboards_imported']} imported", file=sys.stderr)
|
||||
for dash_id, reason in report["dashboards_skipped"]:
|
||||
print(f" skipped dashboard '{dash_id}': {reason}", file=sys.stderr)
|
||||
for domain, count in report["helpers_imported"].items():
|
||||
print(f" {domain}: {count} items", file=sys.stderr)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 5:
|
||||
print(
|
||||
"usage: import_api.py <base_url> <token_path> <config_out_dir> <storage_out_dir>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
_, base_url, token_path, config_out_dir, storage_out_dir = argv
|
||||
|
||||
try:
|
||||
token = ha_api.read_token(token_path)
|
||||
except ha_api.HaApiError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
client = ha_api.Client(base_url, token)
|
||||
try:
|
||||
states = client.get("/api/states")
|
||||
except Exception as exc: # noqa: BLE001 - surface any transport error verbatim
|
||||
print(f"error: fetching /api/states failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
report = new_report()
|
||||
try:
|
||||
import_automations(client, states, config_out_dir, report)
|
||||
import_scripts(client, states, config_out_dir, report)
|
||||
import_scenes(client, states, config_out_dir, report)
|
||||
except Exception as exc: # noqa: BLE001 - surface any transport error verbatim
|
||||
print(f"error: importing automations/scripts/scenes failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import_dashboards_and_registries(base_url, token, states, storage_out_dir, report)
|
||||
|
||||
print_report(report)
|
||||
|
||||
return 1 if report["websocket_error"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
21
scripts/ha/tests/fixtures/api_automation_config_111.json
vendored
Normal file
21
scripts/ha/tests/fixtures/api_automation_config_111.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"id": "111",
|
||||
"alias": "Morning lights",
|
||||
"description": "",
|
||||
"trigger": [
|
||||
{
|
||||
"platform": "time",
|
||||
"at": "07:00:00"
|
||||
}
|
||||
],
|
||||
"condition": [],
|
||||
"action": [
|
||||
{
|
||||
"type": "turn_on",
|
||||
"device_id": "abc123",
|
||||
"entity_id": "light.living_room",
|
||||
"domain": "light"
|
||||
}
|
||||
],
|
||||
"mode": "single"
|
||||
}
|
||||
10
scripts/ha/tests/fixtures/api_scene_config_222.json
vendored
Normal file
10
scripts/ha/tests/fixtures/api_scene_config_222.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "222",
|
||||
"name": "Demo scene",
|
||||
"entities": {
|
||||
"light.living_room": {
|
||||
"state": "on",
|
||||
"brightness": 180
|
||||
}
|
||||
}
|
||||
}
|
||||
12
scripts/ha/tests/fixtures/api_script_config_demo_script.json
vendored
Normal file
12
scripts/ha/tests/fixtures/api_script_config_demo_script.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"alias": "demo_script",
|
||||
"description": "",
|
||||
"sequence": [
|
||||
{
|
||||
"type": "turn_on",
|
||||
"device_id": "def456",
|
||||
"entity_id": "switch.demo",
|
||||
"domain": "switch"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
scripts/ha/tests/fixtures/api_states.json
vendored
Normal file
39
scripts/ha/tests/fixtures/api_states.json
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[
|
||||
{
|
||||
"entity_id": "automation.morning_lights",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"id": "111",
|
||||
"friendly_name": "Morning lights"
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity_id": "automation.legacy_yaml_automation",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"friendly_name": "Legacy YAML automation (no storage id)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity_id": "script.demo_script",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "Demo script"
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity_id": "scene.demo_scene",
|
||||
"state": "scening",
|
||||
"attributes": {
|
||||
"id": "222",
|
||||
"friendly_name": "Demo scene"
|
||||
}
|
||||
},
|
||||
{
|
||||
"entity_id": "input_boolean.on_leave",
|
||||
"state": "off",
|
||||
"attributes": {
|
||||
"friendly_name": "On leave"
|
||||
}
|
||||
}
|
||||
]
|
||||
217
scripts/ha/tests/test_import_api_offline.sh
Executable file
217
scripts/ha/tests/test_import_api_offline.sh
Executable file
|
|
@ -0,0 +1,217 @@
|
|||
#!/usr/bin/env bash
|
||||
# Offline tests for the "api" adapter (scripts/ha/lib/import_api.py, ha_api.py,
|
||||
# ha_ws.py): normalization of real-shaped HA API responses saved as fixtures,
|
||||
# idempotency, stale-file cleanup, and the "websocket-client missing" error
|
||||
# path. No network access, no HA instance needed — REST calls are replaced
|
||||
# with a fake in-memory client, and the WebSocket client is replaced via
|
||||
# sys.modules injection (see PYEOF below).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HA_LIB_DIR="$SCRIPT_DIR/../lib"
|
||||
FIXTURES_DIR="$SCRIPT_DIR/fixtures"
|
||||
|
||||
python3 - "$HA_LIB_DIR" "$FIXTURES_DIR" <<'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
lib_dir, fixtures_dir = sys.argv[1:3]
|
||||
sys.path.insert(0, lib_dir)
|
||||
|
||||
import import_api # noqa: E402
|
||||
from normalize import parse_yaml_text # noqa: E402
|
||||
|
||||
fail = False
|
||||
|
||||
|
||||
def check(condition, message):
|
||||
global fail
|
||||
if condition:
|
||||
print(f"PASS: {message}")
|
||||
else:
|
||||
print(f"FAIL: {message}", file=sys.stderr)
|
||||
fail = True
|
||||
|
||||
|
||||
def load_fixture(name):
|
||||
with open(os.path.join(fixtures_dir, name), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Stands in for ha_api.Client: canned responses keyed by exact path."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self._responses = responses
|
||||
|
||||
def get(self, path):
|
||||
return self._responses.get(path)
|
||||
|
||||
|
||||
# --- automations / scripts / scenes: REST-shaped fixtures, no network ---
|
||||
|
||||
states = load_fixture("api_states.json")
|
||||
responses = {
|
||||
"/api/config/automation/config/111": load_fixture("api_automation_config_111.json"),
|
||||
"/api/config/script/config/demo_script": load_fixture("api_script_config_demo_script.json"),
|
||||
"/api/config/scene/config/222": load_fixture("api_scene_config_222.json"),
|
||||
}
|
||||
client = FakeClient(responses)
|
||||
|
||||
with tempfile.TemporaryDirectory() as config_out_dir:
|
||||
report = import_api.new_report()
|
||||
import_api.import_automations(client, states, config_out_dir, report)
|
||||
import_api.import_scripts(client, states, config_out_dir, report)
|
||||
import_api.import_scenes(client, states, config_out_dir, report)
|
||||
|
||||
check(report["automations_imported"] == 1, "one automation imported (the one with attributes.id)")
|
||||
check(
|
||||
report["automations_skipped_yaml"] == ["automation.legacy_yaml_automation"],
|
||||
"id-less automation entity reported as yaml-skipped, not silently dropped",
|
||||
)
|
||||
check(report["scripts_imported"] == 1, "one script imported")
|
||||
check(report["scenes_imported"] == 1, "one scene imported")
|
||||
|
||||
auto_path = os.path.join(config_out_dir, "automations", "111.yaml")
|
||||
script_path = os.path.join(config_out_dir, "scripts", "demo_script.yaml")
|
||||
scene_path = os.path.join(config_out_dir, "scenes", "222.yaml")
|
||||
check(os.path.isfile(auto_path), "automations/111.yaml written")
|
||||
check(os.path.isfile(script_path), "scripts/demo_script.yaml written")
|
||||
check(os.path.isfile(scene_path), "scenes/222.yaml written")
|
||||
|
||||
with open(auto_path, encoding="utf-8") as f:
|
||||
auto_text = f.read()
|
||||
check("alias: Morning lights" in auto_text, "automation config normalized to canonical (sorted-key) YAML")
|
||||
parsed_back = parse_yaml_text(auto_text)
|
||||
check(
|
||||
parsed_back == load_fixture("api_automation_config_111.json"),
|
||||
"normalized automation YAML round-trips to the original API response",
|
||||
)
|
||||
|
||||
# stale-file cleanup: pre-seed an id that no longer exists, re-run, must be gone
|
||||
stale_path = os.path.join(config_out_dir, "automations", "999.yaml")
|
||||
with open(stale_path, "w", encoding="utf-8") as f:
|
||||
f.write("alias: stale\n")
|
||||
report2 = import_api.new_report()
|
||||
import_api.import_automations(client, states, config_out_dir, report2)
|
||||
check(not os.path.isfile(stale_path), "stale automation export removed on re-run (idempotent cleanup)")
|
||||
|
||||
# idempotency: second run over the same fixture data is byte-identical
|
||||
with open(auto_path, encoding="utf-8") as f:
|
||||
pass1 = f.read()
|
||||
import_api.import_automations(client, states, config_out_dir, report2)
|
||||
with open(auto_path, encoding="utf-8") as f:
|
||||
pass2 = f.read()
|
||||
check(pass1 == pass2, "automations/111.yaml byte-identical across two runs (idempotent)")
|
||||
|
||||
|
||||
# --- dashboards / registries / helpers: WebSocket-shaped fixtures ---
|
||||
|
||||
FAKE_DASHBOARD_LIST = [
|
||||
{"id": "map", "title": "Map", "url_path": "map", "mode": "storage"},
|
||||
{"id": "kiosk", "title": "Kiosk", "url_path": "kiosk", "mode": "yaml"},
|
||||
]
|
||||
FAKE_WS_RESULTS = {
|
||||
("lovelace/config", ()): {"title": "Overview", "views": []},
|
||||
("lovelace/config", (("url_path", "map"),)): {"title": "Map", "views": []},
|
||||
("lovelace/dashboards/list", ()): FAKE_DASHBOARD_LIST,
|
||||
("lovelace/resources", ()): [{"id": "r1", "type": "module", "url": "/local/x.js"}],
|
||||
("config/area_registry/list", ()): [{"id": "hall", "name": "Hall"}],
|
||||
("config/entity_registry/list", ()): [{"entity_id": "light.living_room"}],
|
||||
("input_boolean/list", ()): [{"id": "on_leave", "name": "On leave"}],
|
||||
}
|
||||
|
||||
|
||||
class FakeHaWsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FakeHaWsClient:
|
||||
def __init__(self, base_url, token):
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
def command(self, command_type, **kwargs):
|
||||
key = (command_type, tuple(sorted(kwargs.items())))
|
||||
if key not in FAKE_WS_RESULTS:
|
||||
raise FakeHaWsError(f"unexpected command in test double: {key!r}")
|
||||
return FAKE_WS_RESULTS[key]
|
||||
|
||||
|
||||
fake_ha_ws = type(sys)("ha_ws")
|
||||
fake_ha_ws.HaWsClient = FakeHaWsClient
|
||||
fake_ha_ws.HaWsError = FakeHaWsError
|
||||
|
||||
with tempfile.TemporaryDirectory() as storage_out_dir:
|
||||
sys.modules["ha_ws"] = fake_ha_ws
|
||||
report = import_api.new_report()
|
||||
import_api.import_dashboards_and_registries(
|
||||
"http://ha.example", "test-token", states, storage_out_dir, report
|
||||
)
|
||||
del sys.modules["ha_ws"]
|
||||
|
||||
check(report["websocket_error"] is None, "no websocket error with the fake client installed")
|
||||
check(report["dashboards_imported"] == 2, "default dashboard + one storage-mode dashboard imported")
|
||||
check(
|
||||
report["dashboards_skipped"] == [("kiosk", "mode='yaml' is file-based, not reachable via api")],
|
||||
"yaml-mode dashboard reported as skipped, not silently dropped",
|
||||
)
|
||||
check(report["helpers_imported"] == {"input_boolean": 1}, "only the present input_* domain was queried")
|
||||
|
||||
expected_files = {
|
||||
"lovelace.yaml",
|
||||
"lovelace.map.yaml",
|
||||
"lovelace_dashboards.yaml",
|
||||
"lovelace_resources.yaml",
|
||||
"core.area_registry.yaml",
|
||||
"core.entity_registry.yaml",
|
||||
"input_boolean.yaml",
|
||||
}
|
||||
actual_files = set(os.listdir(storage_out_dir))
|
||||
check(actual_files == expected_files, f"expected storage-export files present, got {sorted(actual_files)}")
|
||||
|
||||
# stale-file cleanup: a leftover domain export must be removed on re-run
|
||||
stale_path = os.path.join(storage_out_dir, "input_number.yaml")
|
||||
with open(stale_path, "w", encoding="utf-8") as f:
|
||||
f.write("key: input_number\n")
|
||||
sys.modules["ha_ws"] = fake_ha_ws
|
||||
report2 = import_api.new_report()
|
||||
import_api.import_dashboards_and_registries(
|
||||
"http://ha.example", "test-token", states, storage_out_dir, report2
|
||||
)
|
||||
del sys.modules["ha_ws"]
|
||||
check(not os.path.isfile(stale_path), "stale storage-export file removed on re-run (idempotent cleanup)")
|
||||
|
||||
|
||||
# --- missing dependency path: no traceback, clear actionable message ---
|
||||
|
||||
with tempfile.TemporaryDirectory() as storage_out_dir:
|
||||
# Force the *real* ha_ws.py to execute (not a fake) and hit its own
|
||||
# `import websocket as _ws` line, so we exercise the actual actionable
|
||||
# ImportError message it raises rather than Python's generic one.
|
||||
sys.modules["websocket"] = None
|
||||
sys.modules.pop("ha_ws", None)
|
||||
report = import_api.new_report()
|
||||
import_api.import_dashboards_and_registries(
|
||||
"http://ha.example", "test-token", states, storage_out_dir, report
|
||||
)
|
||||
sys.modules.pop("ha_ws", None)
|
||||
del sys.modules["websocket"]
|
||||
|
||||
check(report["websocket_error"] is not None, "missing websocket-client dependency is reported, not raised")
|
||||
check(
|
||||
"websocket-client" in (report["websocket_error"] or ""),
|
||||
"the reported error names the missing package so an operator knows what to install",
|
||||
)
|
||||
check(os.listdir(storage_out_dir) == [], "no storage-export files written when the dependency is missing")
|
||||
|
||||
sys.exit(1 if fail else 0)
|
||||
PYEOF
|
||||
|
|
@ -26,23 +26,77 @@ import only, never deploy), `chelsty-ha` (Tailscale, api adapter — see
|
|||
|
||||
```bash
|
||||
scripts/ha/import.sh ken
|
||||
scripts/ha/import.sh ken-legacy
|
||||
```
|
||||
|
||||
Read-only: pulls `/config` from the instance, filters it through
|
||||
`.gitignore`, normalizes and splits it into `config/ken/`, exports curated
|
||||
`.storage/*` into `storage-export/ken/`, and (if a deploy token exists at
|
||||
`~/.config/ha-deploy/ken.token`) writes a dated `/api/states` fixture.
|
||||
Idempotent — re-running against an unchanged instance produces no diff.
|
||||
Read-only, and idempotent for both adapters — re-running against an
|
||||
unchanged instance produces no diff.
|
||||
|
||||
Only the `docker-exec` adapter is implemented; running `import.sh
|
||||
chelsty-ha` today exits with a clear "not implemented" error (its `api`
|
||||
adapter is out of scope for this skeleton).
|
||||
**`docker-exec` adapter** (`ken-legacy`): pulls the whole `/config` tree
|
||||
over `ssh ... docker exec ... tar`, filters it through `.gitignore`,
|
||||
normalizes and splits it into `config/<instance>/`, exports curated
|
||||
`.storage/*` into `storage-export/<instance>/`.
|
||||
|
||||
**`api` adapter** (`ken`, `chelsty-ha`): for instances with no
|
||||
filesystem/SSH access (HAOS has none). Pulls automations/scripts/scenes via
|
||||
one REST GET per object (`/api/config/<domain>/config/<id>`) and
|
||||
dashboards/area+entity registries/`input_*` helpers via the WebSocket API
|
||||
(`/api/websocket`) — read-only commands only, nothing that mutates the live
|
||||
instance. Full `/config` import is out of scope for this adapter; see
|
||||
DESIGN.md, "Deploy path: adapter per instance".
|
||||
|
||||
Both adapters also write a dated `/api/states` fixture snapshot to
|
||||
`fixtures/` if a deploy token exists at
|
||||
`~/.config/ha-deploy/<instance>.token`. For the `api` adapter, that token is
|
||||
not optional — see "Tokens" below.
|
||||
|
||||
### Tokens
|
||||
|
||||
The `api` adapter has no filesystem fallback, so a missing or empty token
|
||||
at `token_path` (see `instances.yaml`) is a hard error, not a soft-skip —
|
||||
`import.sh` aborts immediately with a clear message rather than silently
|
||||
producing an empty import. See `DESIGN.md`, "Tokens", for how the
|
||||
`deploy_agent` account and its long-lived access token are provisioned.
|
||||
|
||||
The token itself never touches a subprocess argv or a log line: REST calls
|
||||
go through `scripts/ha/lib/ha_api.py`, which reads the token file itself and
|
||||
sets the `Authorization` header in-process via `requests` (never `curl -H`,
|
||||
which would put the token in that process's argv, visible to any local user
|
||||
via `ps`). `requests` (`python3-requests`) is a very common preinstalled/
|
||||
transitive package on Debian-based nodes; if it's ever missing, `import.sh`
|
||||
fails with Python's own `ModuleNotFoundError` — install it the same way as
|
||||
`python3-websocket` below (`sudo apt install python3-requests` or `pip
|
||||
install --user requests`).
|
||||
|
||||
### WebSocket dependency
|
||||
|
||||
The dashboard/registry/helper export (`scripts/ha/lib/ha_ws.py`) depends on
|
||||
the `websocket-client` PyPI package (import name `websocket`) — not in the
|
||||
stdlib, not installed by default. Install one of:
|
||||
|
||||
```bash
|
||||
sudo apt install python3-websocket # Debian/Ubuntu package name
|
||||
# or
|
||||
pip install --user websocket-client
|
||||
```
|
||||
|
||||
If it's missing, `import.sh ken` still imports automations/scripts/scenes
|
||||
(REST-only, no extra dependency needed) and then reports the
|
||||
dashboards/registries/helpers step as skipped with an actionable message
|
||||
naming the package to install — it does not crash with a raw traceback, and
|
||||
it does not silently produce an incomplete `storage-export/` without saying
|
||||
so.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
scripts/ha/tests/test_split_normalize.sh
|
||||
scripts/ha/tests/test_normalize_tags.sh
|
||||
scripts/ha/tests/test_import_api_offline.sh
|
||||
```
|
||||
|
||||
Offline determinism check for the split+normalize pipeline — no network,
|
||||
no HA instance required.
|
||||
All offline — no network, no HA instance required. `test_import_api_offline.sh`
|
||||
covers the `api` adapter: normalization of real-shaped API responses (saved
|
||||
as fixtures under `tests/fixtures/`), idempotency, stale-file cleanup, and
|
||||
the missing-`websocket-client` error path (via `sys.modules` injection, not
|
||||
an actual network call).
|
||||
|
|
|
|||
Loading…
Reference in a new issue