// Home Assistant's own configuration API, as the provisions step uses it — the supported way to // change an integration's connection. Home Assistant keeps every integration in // `.storage/core.config_entries`, a file it owns and rewrites; the mesh may not write it, and it is // not a file the mesh could merge into. What Home Assistant offers instead is the same thing its UI // uses: **config flows** over REST (`/api/config/config_entries/flow`) — a user flow creates an // entry, a reconfigure flow changes one, a reauth flow (which Home Assistant starts itself when a // credential stops working) replaces its credential — each validated by the integration's own // connection test before anything is saved. The two things REST does not answer (which flows Home // Assistant has started, which device an entry made) come over its WebSocket API. // // Nothing here reads `.storage`. Authenticated with the module's accepted long-lived access token. /** A config entry as `GET /api/config/config_entries/entry` lists it — no data, no credentials. */ export interface ConfigEntry { entry_id: string; domain: string; title?: string; source?: string; state?: string; disabled_by?: string | null; } /** One field of a flow's form, as Home Assistant serializes a voluptuous schema. */ export interface SchemaField { name: string; type?: string; required?: boolean; optional?: boolean; default?: unknown; description?: { suggested_value?: unknown } | null; /** A section (`type: "expandable"`) carries its own fields. */ schema?: SchemaField[]; } /** What a flow answered: another form, an entry made, or the flow ended (abort). */ export interface FlowResult { type: string; flow_id?: string; handler?: string; step_id?: string; data_schema?: SchemaField[] | null; errors?: Record | null; reason?: string; result?: { entry_id?: string } | unknown; } /** A flow in progress that Home Assistant started itself (a reauth, a discovery). */ export interface FlowProgress { flow_id: string; handler: string; step_id?: string; context?: { source?: string; entry_id?: string }; } /** A device from the device registry; an integration names where its app is as configuration_url. */ export interface DeviceEntry { id: string; config_entries?: string[]; configuration_url?: string | null; } export interface Hass { entries(domain: string): Promise; /** A user flow for `handler`, or — given an entry — a reconfigure flow for it. */ startFlow(handler: string, entryId?: string): Promise; stepFlow(flowId: string, input: Record): Promise; abortFlow(flowId: string): Promise; flowsInProgress(): Promise; devices(): Promise; } /** Home Assistant over HTTP: REST for entries and flows, one short WebSocket session per question. */ export class HassApi implements Hass { private readonly base: string; constructor(url: string, private readonly token: string) { this.base = url.replace(/\/$/, ""); } private async rest(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${this.base}${path}`, { method, headers: { Authorization: `Bearer ${this.token}`, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); if (!res.ok) { // Home Assistant's error text names fields, never echoes their values. throw new Error(`Home Assistant ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`); } return text ? (JSON.parse(text) as unknown) : undefined; } async entries(domain: string): Promise { return ((await this.rest("GET", `/api/config/config_entries/entry?domain=${encodeURIComponent(domain)}`)) ?? []) as ConfigEntry[]; } async startFlow(handler: string, entryId?: string): Promise { return (await this.rest("POST", "/api/config/config_entries/flow", { handler, show_advanced_options: true, ...(entryId ? { entry_id: entryId } : {}), })) as FlowResult; } async stepFlow(flowId: string, input: Record): Promise { return (await this.rest("POST", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`, input)) as FlowResult; } async abortFlow(flowId: string): Promise { await this.rest("DELETE", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`).catch(() => undefined); } async flowsInProgress(): Promise { return (await this.ws("config_entries/flow/progress")) as FlowProgress[]; } async devices(): Promise { return (await this.ws("config/device_registry/list")) as DeviceEntry[]; } /** One WebSocket command: connect, authenticate, ask, close. */ private ws(type: string): Promise { const url = `${this.base.replace(/^http/, "ws")}/api/websocket`; return new Promise((resolve, reject) => { const socket = new WebSocket(url); const timer = setTimeout(() => { socket.close(); reject(new Error(`Home Assistant's WebSocket did not answer ${type} within 30s`)); }, 30_000); const done = (fn: () => void): void => { clearTimeout(timer); socket.close(); fn(); }; socket.onerror = () => done(() => reject(new Error(`Home Assistant's WebSocket at ${url} failed`))); socket.onmessage = (event: { data: unknown }) => { const msg = JSON.parse(String(event.data)) as { type: string; id?: number; success?: boolean; result?: unknown; error?: { message?: string }; }; if (msg.type === "auth_required") socket.send(JSON.stringify({ type: "auth", access_token: this.token })); else if (msg.type === "auth_invalid") done(() => reject(new Error("Home Assistant refused the token"))); else if (msg.type === "auth_ok") socket.send(JSON.stringify({ id: 1, type })); else if (msg.type === "result" && msg.id === 1) { if (msg.success) done(() => resolve(msg.result)); else done(() => reject(new Error(`Home Assistant ${type}: ${msg.error?.message ?? "failed"}`))); } }; }); } }