// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq // ADR 0039). Both this module's tools and its events entrypoint import it, and nothing outside // home-assistant does. Talks to the HA REST API (/api) with a long-lived access token. import { readFileSync } from "node:fs"; export interface HAEntityState { entity_id: string; state: string; attributes: Record; last_changed?: string; last_updated?: string; } export interface HAConfig { location_name?: string; version?: string; components?: string[]; time_zone?: string; state?: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } /** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`); * absent or unreadable yields undefined so callers fall back rather than crash. */ function readSecret(file?: string): string | undefined { if (!file) return undefined; try { return readFileSync(file, "utf8").trim(); } catch { return undefined; } } export class HomeAssistantClient { readonly baseUrl: string; constructor( url: string, private readonly token: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** * Build from the module's resolved environment. The URL defaults to the local server (HA runs on * the node); the token is the long-lived access token minted in HA's profile — required, since * every API call is Bearer-authenticated and there is nowhere to discover it from. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): HomeAssistantClient { const cfg = meshConfig(env.MESH_HOMEASSISTANT_CONFIG_FILE); const url = cfg.url ?? env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`; const token = cfg.token ?? readSecret(env.MESH_HOMEASSISTANT_TOKEN_FILE) ?? env.MESH_HOMEASSISTANT_TOKEN; if (!token) throw new Error("no Home Assistant token — set MESH_HOMEASSISTANT_TOKEN"); return new HomeAssistantClient(url, token); } private async request(path: string, init?: RequestInit): Promise { const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json", Accept: "application/json", ...(init?.headers ?? {}), }, }); if (!res.ok) throw new Error(`Home Assistant API ${path}: ${res.status} ${await res.text()}`); return res.json(); } async getConfig(): Promise { return (await this.request("/api/config")) as HAConfig; } /** All entity states, or one entity when an id is given. */ async getStates(): Promise { return (await this.request("/api/states")) as HAEntityState[]; } async getState(entityId: string): Promise { return (await this.request(`/api/states/${encodeURIComponent(entityId)}`)) as HAEntityState; } /** Call a service (e.g. switch.turn_on) — how "turn the light on" reaches HA. Returns the states * the call changed. */ async callService(domain: string, service: string, data: Record = {}): Promise { return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, { method: "POST", body: JSON.stringify(data), })) as HAEntityState[]; } }