Files
mesh-catalog/modules/home-assistant/client.ts
T
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00

89 lines
3.2 KiB
TypeScript

// The Home Assistant API client — home-assistant's own code, living in the module (novox/hq
// ADR 0044). 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 0051): { 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 {}; }
}
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 ?? 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[];
}
}