homelab-codex-ws/services/home-assistant/DESIGN.md
oskar ee48319a86 feat(ha): skeleton for Home Assistant configs-as-code subproject
Structure only, no deploy path: DESIGN.md decision registry (phasing,
per-instance adapter, bidirectional sync with drift-abort on deploy,
reload-vs-restart gating, token handling), instances.yaml (ken/piha via
docker-exec, chelsty-ha via api), config/storage-export/fixtures dirs,
and a read-only scripts/ha/import.sh (docker-exec adapter only) with a
canonical YAML normalize+split library and an offline determinism test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:29:39 +02:00

7.4 KiB

Home Assistant configs-as-code — design decisions

Status: skeleton only. No deploy path is implemented yet — this document and the accompanying structure/import tooling are phase 0/1 scaffolding. See docs/backlog.md for the tracking entry.

Phasing

Phase Scope
0 — Snapshot Import-only tooling (this skeleton). Pull /config + .storage from each instance into the repo, read-only. No deploy, no write path back to HA.
1 — Repo + CC Repo is the reviewable source of truth. Changes are authored in the repo (by a human or Claude Code) and pushed manually via the docker-exec / api adapters described below. Deploy has a hard drift-abort (see Sync model).
2 — MCP read-only Expose HA state (entities, areas, config) to agents via an MCP server in read-only mode — either a self-hosted MCP or hass-mcp — to let agents reason about the live instance without touching import/deploy paths. Undecided which (see Open questions).
3 — Agents Agents propose changes (automations, scripts, scenes) through the same reviewable repo path used by humans; the human-in-the-loop approval flow from services/control-plane/ (pending → approved → executed) governs anything destructive. Telegram becomes a first-class interface alongside CC.

Each phase is a hard gate: no phase-N tooling depends on phase-(N+1) existing.

Scope

Full /config tree per instance, plus a curated export of .storage/* JSON files that are meaningfully version-controllable (registries, dashboards, helpers) — not secrets, tokens, or runtime databases. See .gitignore below for the exact exclusion list.

Canonical format

All YAML committed to the repo passes through one normalization function (scripts/ha/lib/normalize.py), used by every import path with no per-caller variation:

  • block style (no flow/inline collections)
  • fixed indent width 2
  • keys sorted (stable diffs)
  • unlimited line width (no wrapping)
  • UTF-8, no BOM

.storage/* files are JSON on disk in HA; on import they are parsed as JSON and re-emitted through the same YAML normalizer so the whole repo — config and storage-export alike — has one diff format.

Deploy path: adapter per instance

There is no single deploy mechanism — each HA instance gets an adapter behind a common interface (import.sh/eventual deploy.sh <instance>):

Instance Adapter Why
ken (piha, container homeassistant5) docker-exec over SSH No HA API port reachable from where imports run today; container filesystem is reachable via ssh oskar@piha "docker exec homeassistant5 ...". See hosts/piha/README.md.
chelsty-ha api Reachable over Tailscale at 100.70.180.90:8123 (confirmed working path — services/ha-diag-agent/DEPLOY.md already curls this for health checks). Config-as-code deploy will reuse the same reachability, calling the HA REST/websocket API rather than shelling into the container.

Open: a file adapter (direct bind-mount / SSH rsync to the config directory, bypassing docker exec) is worth revisiting once SSH access to the chelsty-ha VM itself is verified — see Open questions.

Sync model: bidirectional, asymmetric safety

  • Deploy (repo → instance): hard drift-abort. Before writing, the adapter re-imports the instance's current state and diffs it against the last-known-imported snapshot committed in the repo. Any unexpected difference aborts the deploy with a non-zero exit and a diff printed — never silently overwrites live drift. This applies unconditionally on PIHA (ken); untested/undecided whether it should be relaxed for chelsty-ha given its intermittent LTE uplink (see Open questions).
  • Import (instance → repo): the reverse direction is intentionally permissive. Running import.sh <instance> on a dev workstation pulls current state and, if it differs from the last commit, the operator commits it under a conventional message: drift(<instance>): <summary>. This is how out-of-band UI changes (made directly in the HA web UI) get captured back into git history instead of being silently overwritten by the next deploy.

Validation gate

check_config (HA's built-in config validator, invoked via the running container/instance — docker exec homeassistant5 python -m homeassistant --script check_config -c /config or the equivalent over the api adapter) is a mandatory gate before any deploy. A failing check_config blocks the deploy entirely; it is not a warning.

Change classification: reload vs. restart

Deploys default to the least disruptive mechanism:

  • reload — default for anything HA exposes a reload service for (automations, scripts, scenes, input_* helpers, template entities, etc.) via homeassistant.reload_config_entry / domain-specific *.reload services.
  • restart — only when the changed file requires it (e.g. configuration.yaml core changes, new integrations, .storage registry edits) and only when the deploy is invoked with an explicit --restart flag. No implicit restarts, ever — an unattended restart on chelsty-ha during a period of LTE unavailability would leave the site without automation until someone is physically present.

Split + normalization

On import, list-of-object YAML files are split one-object-per-file so diffs stay scoped to what actually changed:

  • automations.yamlconfig/<instance>/automations/<id>.yaml
  • scripts.yamlconfig/<instance>/scripts/<key>.yaml
  • scenes.yamlconfig/<instance>/scenes/<id>.yaml

Every other *.yaml file under /config is copied through the normalizer 1:1 (same relative path, same filename). Non-YAML files under /config (binaries, databases, secrets.yaml, logs — see .gitignore) are never copied into the repo.

Tokens

  • A dedicated deploy_agent HA user account (admin rights, local-only — never exposed through the public API/ingress) is created per instance, mirroring the existing diag_agent account pattern documented in services/ha-diag-agent/DEPLOY.md. Reusing diag_agent is explicitly rejected — deploy tooling and the diagnostic agent must be revocable independently.
  • Long-lived access tokens for deploy_agent live at ~/.config/ha-deploy/<instance>.token on PIHA (the control node where import/deploy tooling runs), chmod 600.
  • Tokens are never committed to the repo, never templated into env.example-style files, and never logged. Import scripts that need a token to hit /api/states fail soft (skip the fixtures step with a message) if the token file is absent, rather than aborting the whole import — see scripts/ha/import.sh.

Interface

  • Phase 0/1: Claude Code is the interface, with the operator's mobile CC client acting as the bridge for approvals made away from a desk.
  • Phase 3 (agents): Telegram joins CC as a first-class interface, reusing the existing Telegram bot / approval-queue pattern from services/control-plane/.

Open questions

  • What actually drives the phase-3 operational agent (a new agent process vs. extending an existing one in services/)?
  • Own minimal MCP server vs. adopting hass-mcp for phase 2 read-only access — tradeoffs not yet evaluated.
  • Whether SSH access to chelsty-ha itself (not just its HA API) is available/reliable enough to justify a file adapter there, which would let phase-1 tooling treat chelsty-ha more like ken for drift-checking purposes.