// home-assistant's provisions step — run once by the host after Home Assistant starts, and again // whenever a binding or pair credential it reads changes (the container's `restart-on`, novox/hq // ADR 0099). It points Home Assistant's MQTT integration at the `mqtt-topic` broker and its Sonarr, // Radarr and Lidarr integrations at the `sonarr-api`, `radarr-api` and `lidarr-api` apps, through // Home Assistant's own config flows (connections.ts). It connects to no mesh broker. // // Exits non-zero when anything could not be put right, so the node reports the step failed and the // host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing // else of home-assistant's (novox/hq ADR 0136). Never prints a key or password. import { join } from "node:path"; import { APPS, MQTT_PROVISION, reconcileApp, reconcileMqtt, type Outcome } from "./connections.js"; import { HassApi } from "./hass.js"; import { marksIn, readBinding, readIfThere } from "./mesh.js"; import { probeBroker } from "./probe.js"; const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions"; const url = process.env.MESH_HOMEASSISTANT_URL ?? "http://127.0.0.1:8123"; const token = (await readIfThere(process.env.MESH_HOMEASSISTANT_TOKEN_FILE))?.trim() ?? ""; const marks = marksIn(process.env.MESH_WRITTEN_DIR ?? "/var/lib/home-assistant-provisions"); const waitSeconds = Number(process.env.MESH_HOMEASSISTANT_WAIT_SECONDS ?? "300"); if (!token) { console.error("[hass-provisions] no Home Assistant token — home-assistant's own `token` secret has not been accepted"); process.exit(1); } /** Home Assistant answers /api/ with 200 once it is up and the token is good. */ async function ready(): Promise { const until = Date.now() + waitSeconds * 1000; for (;;) { try { const res = await fetch(`${url.replace(/\/$/, "")}/api/`, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 200) return true; if (res.status === 401 || res.status === 403) { console.error("[hass-provisions] Home Assistant refuses the token — accept a long-lived access token it issued"); return false; } } catch { // not listening yet } if (Date.now() >= until) return false; await new Promise((r) => setTimeout(r, 3000)); } } if (!(await ready())) { console.error(`[hass-provisions] Home Assistant did not answer at ${url} within ${waitSeconds}s`); process.exit(1); } const hass = new HassApi(url, token); const read = async (p: string) => [await readBinding(join(dir, `${p}.json`)), await readIfThere(join(dir, `${p}.secret`))] as const; const outcomes: Outcome[] = []; { const [binding, secret] = await read(MQTT_PROVISION); outcomes.push(await reconcileMqtt({ hass, probe: probeBroker, marks }, binding, secret)); } for (const spec of APPS) { const [binding, secret] = await read(spec.provision); outcomes.push(await reconcileApp({ hass, http: { fetch: (u, init) => fetch(u, init) } }, spec, binding, secret)); } let failed = 0; for (const o of outcomes) { switch (o.result) { case "unchanged": console.log(`[hass-provisions] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`); break; case "written": console.log(`[hass-provisions] ${o.what}: wrote ${o.fields.join(", ")}; Home Assistant's own test passed${o.note ? ` — ${o.note}` : ""}`); break; case "equivalent": console.log(`[hass-provisions] ${o.what}: ${o.note}`); break; case "refused": failed++; console.error(`[hass-provisions] ${o.what}: ${o.problem}`); break; } } process.exitCode = failed > 0 ? 1 : 0;