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>
This commit is contained in:
oskar 2026-07-21 12:40:21 +02:00
parent 1dffe330a6
commit ee48319a86
17 changed files with 773 additions and 0 deletions

View file

@ -4,6 +4,19 @@ Centralny tracker tech-długu i znanych usterek. Wpisy ze sesji — dodawaj z da
--- ---
## Nowy podprojekt: Home Assistant configs-as-code (szkielet)
**Data**: 2026-07-21
**Branch**: `task/ha-skeleton`
Szkielet struktury dla `services/home-assistant/` — configs-as-code dla
instancji HA (`ken` na PIHA, `chelsty-ha`). Na razie tylko struktura +
read-only import (`scripts/ha/import.sh`), bez deployu. Fazowanie, wybór
adaptera per instancja, model sync i otwarte pytania —
`services/home-assistant/DESIGN.md`.
---
## Plan: Monitoring floty — Prometheus jako źródło prawdy ## Plan: Monitoring floty — Prometheus jako źródło prawdy
**Data**: 2026-06-22 **Data**: 2026-06-22

187
scripts/ha/import.sh Executable file
View file

@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Read-only import of one Home Assistant instance's /config tree (plus a
# curated .storage/* export and an /api/states fixture snapshot) into this
# repo, normalized and split per services/home-assistant/DESIGN.md.
#
# This script NEVER writes to the HA instance. It is idempotent: re-running
# 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.
#
# Usage: scripts/ha/import.sh <instance>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_DIR="$(cd "$SCRIPT_DIR/../../services/home-assistant" && pwd)"
INSTANCES_FILE="$SERVICE_DIR/instances.yaml"
GITIGNORE_FILE="$SERVICE_DIR/.gitignore"
# shellcheck source=lib/apply_gitignore.sh
source "$SCRIPT_DIR/lib/apply_gitignore.sh"
is_storage_export_candidate() {
local base="$1"
case "$base" in
core.area_registry|core.entity_registry) return 0 ;;
input_*|lovelace*) return 0 ;;
*) return 1 ;;
esac
}
fetch_fixtures() {
local token_file="${HA_TOKEN_PATH/#\~/$HOME}"
if [[ -z "$HA_TOKEN_PATH" || ! -f "$token_file" ]]; then
echo "-> fixtures: no token at '${token_file:-<unset>}' — skipping fixtures fetch (not a failure)" >&2
return 0
fi
local token
token="$(<"$token_file")"
mkdir -p "$FIXTURES_DIR"
local dest="$FIXTURES_DIR/${INSTANCE}-states-$(date +%F).yaml"
local raw_file
raw_file="$(mktemp)"
local fetch_ok=1
if [[ "$HA_ADAPTER" == "docker-exec" ]]; then
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" \
> "$raw_file" || fetch_ok=0
fi
if [[ "$fetch_ok" -ne 1 ]]; then
echo "-> fixtures: fetch failed for instance '$INSTANCE' — skipping (not failing the import)" >&2
rm -f "$raw_file"
return 0
fi
python3 "$SCRIPT_DIR/lib/normalize.py" json "$raw_file" > "$dest"
rm -f "$raw_file"
echo "-> fixtures: wrote $dest" >&2
}
list_known_instances() {
python3 - "$INSTANCES_FILE" <<'PYEOF'
import sys
import yaml
data = yaml.safe_load(open(sys.argv[1], encoding="utf-8"))
print(" ".join(sorted((data or {}).get("instances", {}))))
PYEOF
}
usage() {
echo "usage: $0 <instance>" >&2
echo "known instances: $(list_known_instances)" >&2
}
if [[ $# -ne 1 ]]; then
usage
exit 2
fi
INSTANCE="$1"
if [[ ! -f "$INSTANCES_FILE" ]]; then
echo "error: instances file not found: $INSTANCES_FILE" >&2
exit 1
fi
INSTANCE_CONFIG_OUTPUT="$(python3 "$SCRIPT_DIR/lib/instance_config.py" "$INSTANCES_FILE" "$INSTANCE")" || exit 1
eval "$INSTANCE_CONFIG_OUTPUT"
CONFIG_OUT_DIR="$SERVICE_DIR/config/$INSTANCE"
STORAGE_OUT_DIR="$SERVICE_DIR/storage-export/$INSTANCE"
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 [[ -z "$HA_SSH_USER" || -z "$HA_SSH_HOST" ]]; then
echo "error: instance '$INSTANCE' is missing ssh.user/ssh.host in instances.yaml" >&2
exit 1
fi
if [[ -z "$HA_CONTAINER" ]]; then
echo "error: instance '$INSTANCE' is missing 'container' in instances.yaml" >&2
exit 1
fi
TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/ha-import-${INSTANCE}.XXXXXX")"
trap 'rm -rf "$TMPDIR"' EXIT
EXTRACT_DIR="$TMPDIR/config"
mkdir -p "$EXTRACT_DIR"
echo "-> pulling /config from ${HA_CONTAINER} on ${HA_SSH_HOST} via docker exec ..." >&2
if ! ssh "${HA_SSH_USER}@${HA_SSH_HOST}" "docker exec ${HA_CONTAINER} tar cf - -C /config ." > "$TMPDIR/config.tar"; then
echo "error: ssh/docker exec pull failed for instance '$INSTANCE' (host=${HA_SSH_HOST} container=${HA_CONTAINER})" >&2
exit 1
fi
tar xf "$TMPDIR/config.tar" -C "$EXTRACT_DIR"
echo "-> filtering excluded paths (per $GITIGNORE_FILE) ..." >&2
apply_gitignore_filter "$EXTRACT_DIR" "$GITIGNORE_FILE"
echo "-> normalizing + splitting config into $CONFIG_OUT_DIR ..." >&2
mkdir -p "$CONFIG_OUT_DIR"
while IFS= read -r -d '' src; do
rel="${src#"$EXTRACT_DIR"/}"
base="$(basename "$src")"
case "$base" in
automations.yaml|scripts.yaml|scenes.yaml)
python3 "$SCRIPT_DIR/lib/split.py" "$src" "$CONFIG_OUT_DIR" > /dev/null
;;
*)
dest="$CONFIG_OUT_DIR/$rel"
mkdir -p "$(dirname "$dest")"
python3 "$SCRIPT_DIR/lib/normalize.py" yaml "$src" > "$dest"
;;
esac
done < <(find "$EXTRACT_DIR" -type f -name '*.yaml' -not -path '*/.storage/*' -print0)
echo "-> exporting curated .storage/* into $STORAGE_OUT_DIR ..." >&2
mkdir -p "$STORAGE_OUT_DIR"
if [[ -d "$EXTRACT_DIR/.storage" ]]; then
while IFS= read -r -d '' src; do
base="$(basename "$src")"
if is_storage_export_candidate "$base"; then
dest="$STORAGE_OUT_DIR/${base}.yaml"
python3 "$SCRIPT_DIR/lib/normalize.py" json "$src" > "$dest"
fi
done < <(find "$EXTRACT_DIR/.storage" -maxdepth 1 -type f -print0)
else
echo " (no .storage directory in pulled config — skipping)" >&2
fi
fetch_fixtures
echo "-> summary of changes:" >&2
REPO_ROOT="$(git -C "$SERVICE_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -n "$REPO_ROOT" ]]; then
git -C "$REPO_ROOT" status --porcelain -- \
"services/home-assistant/config/$INSTANCE" \
"services/home-assistant/storage-export/$INSTANCE" || true
else
echo " (not inside a git worktree — skipping git diff summary)" >&2
fi
echo "== import complete: $INSTANCE ==" >&2

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Delete files from an extracted /config tree that match
# services/home-assistant/.gitignore patterns.
#
# Intentionally NOT a full gitignore engine — the exclusion list is small
# and fixed (see DESIGN.md, "Scope"), so each line is applied as either a
# directory-name prune (trailing "/") or a path/basename glob.
set -euo pipefail
apply_gitignore_filter() {
local root_dir="$1"
local gitignore_file="$2"
if [[ ! -f "$gitignore_file" ]]; then
echo "apply_gitignore_filter: gitignore file not found: $gitignore_file" >&2
return 1
fi
local pattern
while IFS= read -r pattern; do
[[ -z "$pattern" || "$pattern" == \#* ]] && continue
if [[ "$pattern" == */ ]]; then
local dirname="${pattern%/}"
find "$root_dir" -type d -name "$dirname" -prune -exec rm -rf {} +
elif [[ "$pattern" == */* ]]; then
# path-relative pattern, e.g. ".storage/auth*"
find "$root_dir" -type f -path "*/${pattern}" -delete
else
find "$root_dir" -type f -name "$pattern" -delete
fi
done < "$gitignore_file"
}

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Print shell-safe `KEY=value` lines for one instance from instances.yaml.
Used by scripts/ha/import.sh via:
eval "$(python3 lib/instance_config.py instances.yaml <name>)"
"""
import shlex
import sys
import yaml
FIELDS = [
"host",
"container",
"config_mount",
"adapter",
"base_url",
"token_path",
"status",
"site",
]
def main(argv):
if len(argv) != 3:
print("usage: instance_config.py <instances.yaml> <instance-name>", file=sys.stderr)
return 2
instances_path, name = argv[1], argv[2]
with open(instances_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
instances = (data or {}).get("instances", {})
if name not in instances:
known = ", ".join(sorted(instances)) or "(none)"
print(f"unknown instance '{name}' (known: {known})", file=sys.stderr)
return 1
inst = instances[name] or {}
ssh = inst.get("ssh") or {}
print(f"HA_INSTANCE={shlex.quote(name)}")
for field in FIELDS:
print(f"HA_{field.upper()}={shlex.quote(str(inst.get(field, '')))}")
print(f"HA_SSH_USER={shlex.quote(str(ssh.get('user', '')))}")
print(f"HA_SSH_HOST={shlex.quote(str(ssh.get('host', '')))}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

63
scripts/ha/lib/normalize.py Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Canonical YAML normalization used by every HA import path.
One function, one style, used everywhere data lands in the repo (see
services/home-assistant/DESIGN.md, "Canonical format"): block collections,
indent width 2, unlimited line width, sorted keys, UTF-8.
"""
import json
import sys
import yaml
class _CanonicalDumper(yaml.SafeDumper):
pass
def _represent_none(dumper, _value):
return dumper.represent_scalar("tag:yaml.org,2002:null", "")
_CanonicalDumper.add_representer(type(None), _represent_none)
def dump_canonical_yaml(data):
"""Render `data` as canonical YAML text."""
return yaml.dump(
data,
Dumper=_CanonicalDumper,
default_flow_style=False,
sort_keys=True,
allow_unicode=True,
width=1 << 31,
indent=2,
)
def normalize_yaml_text(text):
"""Parse YAML text then re-dump it through the canonical formatter."""
data = yaml.safe_load(text)
return dump_canonical_yaml(data)
def normalize_json_text(text):
"""Parse JSON text (e.g. a `.storage/*` file) and dump as canonical YAML."""
data = json.loads(text)
return dump_canonical_yaml(data)
def main(argv):
if len(argv) != 3 or argv[1] not in ("yaml", "json"):
print("usage: normalize.py <yaml|json> <input-path>", file=sys.stderr)
return 2
mode, path = argv[1], argv[2]
with open(path, "r", encoding="utf-8") as f:
text = f.read()
out = normalize_yaml_text(text) if mode == "yaml" else normalize_json_text(text)
sys.stdout.write(out)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

99
scripts/ha/lib/split.py Executable file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Split HA list/dict config files into one-object-per-file layouts.
See services/home-assistant/DESIGN.md ("Split + normalization"):
automations.yaml -> automations/<id>.yaml
scenes.yaml -> scenes/<id>.yaml
scripts.yaml -> scripts/<key>.yaml
"""
import os
import sys
import yaml
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from normalize import dump_canonical_yaml # noqa: E402
def split_list_by_id(items, id_field="id"):
"""items: list of dicts, each expected to carry an `id_field` key.
Returns {id: obj}. Raises ValueError on a missing/duplicate id so a bad
split can never silently drop or overwrite an object.
"""
result = {}
for obj in items:
if not isinstance(obj, dict) or id_field not in obj:
raise ValueError(f"object missing '{id_field}' field: {obj!r}")
key = str(obj[id_field])
if key in result:
raise ValueError(f"duplicate {id_field} '{key}'")
result[key] = obj
return result
def split_dict(data):
"""scripts.yaml is already a dict keyed by script id — pass through."""
return dict(data or {})
SPLIT_SPECS = {
"automations.yaml": ("automations", lambda data: split_list_by_id(data or [], "id")),
"scenes.yaml": ("scenes", lambda data: split_list_by_id(data or [], "id")),
"scripts.yaml": ("scripts", split_dict),
}
def write_split(objects_by_key, out_dir):
"""Write out_dir/<key>.yaml (canonical form) for each key/obj pair.
Removes stale *.yaml files left over from a previous run for keys that
no longer exist, so the split output always mirrors the source exactly.
"""
os.makedirs(out_dir, exist_ok=True)
expected = {f"{key}.yaml" for key in objects_by_key}
for existing in os.listdir(out_dir):
if existing.endswith(".yaml") and existing not in expected:
os.remove(os.path.join(out_dir, existing))
for key, obj in objects_by_key.items():
path = os.path.join(out_dir, f"{key}.yaml")
with open(path, "w", encoding="utf-8") as f:
f.write(dump_canonical_yaml(obj))
def split_file(source_path, out_root):
"""source_path: path to e.g. .../automations.yaml. out_root: config/<instance>/.
Returns the output subdirectory written, or None if source_path's
basename isn't one of the known split targets.
"""
basename = os.path.basename(source_path)
if basename not in SPLIT_SPECS:
return None
subdir_name, splitter = SPLIT_SPECS[basename]
with open(source_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
objects_by_key = splitter(data)
out_dir = os.path.join(out_root, subdir_name)
write_split(objects_by_key, out_dir)
return out_dir
def main(argv):
if len(argv) != 3:
print("usage: split.py <source-yaml-path> <config-out-root>", file=sys.stderr)
return 2
source_path, out_root = argv[1], argv[2]
result_dir = split_file(source_path, out_root)
if result_dir is None:
print(
f"split.py: '{os.path.basename(source_path)}' is not a known split target",
file=sys.stderr,
)
return 1
print(result_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -0,0 +1,37 @@
- id: "morning_lights"
alias: "Morning lights on"
trigger:
- platform: time
at: "06:30:00"
condition: []
action:
- service: light.turn_on
target:
entity_id: light.living_room
mode: single
- id: "goodnight"
alias: "Goodnight routine"
trigger:
- platform: state
entity_id: input_boolean.goodnight
to: "on"
condition: []
action:
- service: light.turn_off
target:
entity_id: all
mode: single
- id: "low_battery_alert"
alias: "Low battery alert"
trigger:
- platform: numeric_state
entity_id: sensor.front_door_battery
below: 20
condition: []
action:
- service: notify.mobile_app
data:
message: "Front door sensor battery low"
mode: single

View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Offline determinism test for split + normalize (scripts/ha/lib/split.py,
# normalize.py): running the pipeline twice against the same fixture must
# produce byte-identical output. No network access, no HA instance needed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HA_LIB_DIR="$SCRIPT_DIR/../lib"
FIXTURE="$SCRIPT_DIR/fixtures/automations.yaml"
run_once() {
local out_root="$1"
mkdir -p "$out_root"
python3 "$HA_LIB_DIR/split.py" "$FIXTURE" "$out_root" > /dev/null
}
TMP1="$(mktemp -d)"
TMP2="$(mktemp -d)"
trap 'rm -rf "$TMP1" "$TMP2"' EXIT
run_once "$TMP1"
run_once "$TMP2"
fail=0
if ! diff -r "$TMP1/automations" "$TMP2/automations" > /dev/null; then
echo "FAIL: split+normalize output differs between two runs of the same fixture" >&2
diff -r "$TMP1/automations" "$TMP2/automations" >&2 || true
fail=1
else
echo "PASS: split+normalize is deterministic across two runs"
fi
expected_files=(morning_lights.yaml goodnight.yaml low_battery_alert.yaml)
for f in "${expected_files[@]}"; do
if [[ ! -f "$TMP1/automations/$f" ]]; then
echo "FAIL: expected split output '$f' not found" >&2
fail=1
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "PASS: expected per-id automation files present"
fi
# re-running split against a directory that already holds a stale file (from
# a since-removed automation id) must remove it — split output should always
# mirror the source exactly, never accumulate stale files.
STALE_DIR="$(mktemp -d)"
trap 'rm -rf "$STALE_DIR"' EXIT
mkdir -p "$STALE_DIR/automations"
touch "$STALE_DIR/automations/removed_automation.yaml"
python3 "$HA_LIB_DIR/split.py" "$FIXTURE" "$STALE_DIR" > /dev/null
if [[ -f "$STALE_DIR/automations/removed_automation.yaml" ]]; then
echo "FAIL: stale split output was not cleaned up" >&2
fail=1
else
echo "PASS: stale split output is cleaned up on re-run"
fi
exit "$fail"

11
services/home-assistant/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
secrets.yaml
*.db
*.db-*
*.log*
.storage/auth*
.storage/core.restore_state
.storage/*token*
tts/
deps/
backups/
.cloud/

View file

@ -0,0 +1,143 @@
# 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.yaml``config/<instance>/automations/<id>.yaml`
- `scripts.yaml``config/<instance>/scripts/<key>.yaml`
- `scenes.yaml``config/<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.

View file

@ -0,0 +1,45 @@
# home-assistant (configs-as-code)
**Status: skeleton.** Structure and read-only import tooling only — no
deploy path exists 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` (PIHA, container `homeassistant5`, docker-exec adapter),
`chelsty-ha` (Tailscale, api adapter — see `instances.yaml`).
## Import
```bash
scripts/ha/import.sh ken
```
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.
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).
## Tests
```bash
scripts/ha/tests/test_split_normalize.sh
```
Offline determinism check for the split+normalize pipeline — no network,
no HA instance required.

View file

@ -0,0 +1,35 @@
# Home Assistant instances managed as config-as-code.
# Schema and adapter rationale: see DESIGN.md ("Deploy path: adapter per instance").
# This file is read by scripts/ha/import.sh — do not add secrets/tokens here,
# only the path to where each instance's token is expected on disk.
instances:
ken:
host: piha
container: homeassistant5
config_mount: /home/pi/homeassistant/config
adapter: docker-exec
ssh:
user: oskar
host: piha
token_path: ~/.config/ha-deploy/ken.token
# TODO: verify actual container port mapping via `docker inspect homeassistant5`
# on piha (not runnable from this worktree — no SSH access here). Known so far
# from docs/infra/inventory-2026-06-30.md + inventory/topology.yaml:
# - ha-diag-agent on piha targets http://localhost:8123 (container reachable
# on piha's own loopback, so likely host networking or 8123:8123 published)
# - NPM ingress ha.kapala.org -> 192.168.31.7:8123 (piha's LAN IP), per
# inventory/topology.yaml services.home_assistant
# Until confirmed with `docker inspect`, treat this as unverified.
base_url: "http://localhost:8123" # TODO: confirm via docker inspect on piha
status: active
site: ken
chelsty-ha:
host: chelsty-ha
tailscale_ip: 100.70.180.90
adapter: api
base_url: "http://100.70.180.90:8123"
token_path: ~/.config/ha-deploy/chelsty-ha.token
status: offline
site: chelsty