Files
mesh-catalog/modules/home-assistant/client.ts
T
jschoubben d289e5a928 modules: wire tool-runtime app credentials as own-secrets
The six modules that run a mesh-<mod> tool-runtime sidecar read an app
credential from an env var the manifest never provided, so the sidecar
crash-looped in the whole-mesh dry-run (e.g. "no Plex token — set
MESH_PLEX_TOKEN"). These are operator-set app secrets, so deliver them the
same way cloudflare-dns delivers its API token: an own-secret file mounted
read-only, with a MESH_<APP>_*_FILE env pointing at the mount, and the
runtime code preferring that file (falling back to the existing env so
nothing regresses).

- plex: own-secret token -> /run/secrets/token, MESH_PLEX_TOKEN_FILE
- bazarr: own-secret api-key -> /run/secrets/api-key, MESH_BAZARR_API_KEY_FILE
- ombi: own-secret api-key -> /run/secrets/api-key, MESH_OMBI_API_KEY_FILE
- home-assistant: own-secret token -> /run/secrets/token, MESH_HOMEASSISTANT_TOKEN_FILE
- nzbget: own-secret password -> /run/secrets/password, MESH_NZBGET_PASSWORD_FILE (URL stays plain env)
- qbittorrent: own-secret password -> /run/secrets/password, MESH_QBITTORRENT_PASSWORD_FILE (URL stays plain env)

The operator now completes each with `secret accept <node> <module> <name> --from <file>`.
tsc passes for all six.
2026-09-08 18:40:23 +02:00

97 lines
3.6 KiB
TypeScript

// 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<string, unknown>;
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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<unknown> {
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<HAConfig> {
return (await this.request("/api/config")) as HAConfig;
}
/** All entity states, or one entity when an id is given. */
async getStates(): Promise<HAEntityState[]> {
return (await this.request("/api/states")) as HAEntityState[];
}
async getState(entityId: string): Promise<HAEntityState> {
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<string, unknown> = {}): Promise<HAEntityState[]> {
return (await this.request(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
method: "POST",
body: JSON.stringify(data),
})) as HAEntityState[];
}
}