# home-assistant (configs-as-code)

**Status: phase 1 (partial).** Read-only import tooling, plus a deploy
(repo -> instance) write path for the `api` adapter's automations/scripts/
scenes scope only (`scripts/ha/deploy.sh`) — see "Deploy" below. Dashboards,
helpers, and the `docker-exec` adapter have no write path yet. See
`DESIGN.md` for the full phasing, adapter, sync, and validation model, and
for the open questions still blocking phase 2/3.

## Layout

```
services/home-assistant/
├── DESIGN.md              # decision registry — read this first
├── instances.yaml         # per-instance adapter/host/token config
├── .gitignore             # excludes secrets/db/log/token paths from every import
├── config/<instance>/     # canonical, normalized /config mirror per instance
├── storage-export/<instance>/  # curated .storage/* export (registries, dashboards)
└── fixtures/              # dated /api/states snapshots
```

Instances: `ken` (RPi4/HAOS, LAN 192.168.31.7, api adapter — canonical home
instance since the 2026-07-22 cutover), `ken-legacy` (PIHA, container
`homeassistant5`, docker-exec adapter, archived — pre-migration instance,
import only, never deploy), `chelsty-ha` (Tailscale, api adapter — see
`instances.yaml` and `DESIGN.md` "Incident log").

## Import

```bash
scripts/ha/import.sh ken
scripts/ha/import.sh ken-legacy
```

Read-only, and idempotent for both adapters — re-running against an
unchanged instance produces no diff.

**`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.

## Deploy

```bash
scripts/ha/deploy.sh ken --dry-run                              # plan only, read-only
scripts/ha/deploy.sh ken --dry-run automations/111.yaml          # plan for one object
scripts/ha/deploy.sh ken                                         # deploy everything in scope
scripts/ha/deploy.sh ken scripts/notify_email_ntfy.yaml           # deploy one object
```

Repo -> instance, `api` adapter only, automations/scripts/scenes only
(dashboards/helpers are WebSocket-only — `ha_ws.py` has no mutating
command, deliberately, and that write path doesn't exist yet). Refuses any
instance with `status != active` (see `instances.yaml`) or an adapter other
than `api` — no override flag.

Hard sequence, per `DESIGN.md`'s "Sync model" and "Validation gate", any
failure aborts before the next step:

1. **drift-check** — re-imports the instance's current state and diffs it
   against the last commit (`HEAD`) for every object *not* being deployed
   this run. Any difference aborts with a diff printed; drift is never
   silently overwritten.
2. **validate** — local sanity (YAML parses, required keys present per
   domain) plus a live `check_config` gate on the instance.
3. **write** — one `POST /api/config/<domain>/config/<id>` per object.
4. **verify** — GETs the same object back and compares it to what was
   written. A mismatch is reported (which object, what differs); there is
   no auto-rollback.

`--dry-run` runs steps 1-2 (both read-only in effect) and stops before any
write, printing the same plan a live run would act on.

## Tests

```bash
scripts/ha/tests/test_split_normalize.sh
scripts/ha/tests/test_normalize_tags.sh
scripts/ha/tests/test_import_api_offline.sh
scripts/ha/tests/test_deploy_api_offline.sh
```

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). `test_deploy_api_offline.sh` covers the deploy
write path: a clean deploy, drift-abort, local validation rejecting broken/
unparseable YAML, and verify detecting a post-write mismatch — using a real
temporary git repo for the `HEAD` comparison and a fake in-memory client for
`get`/`post_config`/`check_config` (no network).
