home-assistant, influxdb: full nox modules (ADR 0044/0046)

home-assistant: states/call-service/config tools, emits state.changed bounded
to actuator/contact domains (not attribute ticks), overridable via a watch
allowlist. influxdb: health/buckets/flux-query tools — tools-only, since a
time-series DB has no lifecycle event to emit here. Typecheck; manifests parse.
This commit is contained in:
2026-09-04 02:44:17 +02:00
parent eccfc70991
commit aca237b2e0
10 changed files with 430 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
// 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.
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;
}
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 url = env.MESH_HOMEASSISTANT_URL ?? `http://127.0.0.1:${env.HOMEASSISTANT_PORT ?? "8123"}`;
const 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[];
}
}
+66
View File
@@ -0,0 +1,66 @@
// home-assistant's events. The tool runtime imports this once the broker is bound. It watches the
// entities whose state changing is a real signal — a door opening, a lock turning, a switch
// flipping — and emits when one does.
//
// Emits (novox/hq ADR 0046/0047):
// module.home-assistant.state.changed — a watched entity's state value changed
//
// Bounded on purpose. Home Assistant has hundreds of entities and many (temperature, humidity,
// power draw) tick constantly; emitting every tick would be noise, not signal. So the watch is
// limited to actuator/contact domains where a change is an event a human would care about, and
// only the discrete `state` value is diffed — not the attribute bag. The set is overridable with
// MESH_HOMEASSISTANT_WATCH (comma-separated entity ids) for a node that wants a specific few.
import { emit } from "@novox/mesh-sdk/events";
import { HomeAssistantClient, type HAEntityState } from "./client.js";
const ha = HomeAssistantClient.fromEnv();
// Domains whose state changing is meaningful rather than a continuous reading.
const WATCH_DOMAINS = new Set(["binary_sensor", "lock", "cover", "switch", "input_boolean", "light", "alarm_control_panel"]);
// An explicit allowlist of entity ids, if the node set one; otherwise fall back to the domain filter.
const watchList = (process.env.MESH_HOMEASSISTANT_WATCH ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const watchSet = watchList.length ? new Set(watchList) : null;
function isWatched(s: HAEntityState): boolean {
if (watchSet) return watchSet.has(s.entity_id);
return WATCH_DOMAINS.has(s.entity_id.split(".")[0] ?? "");
}
const nameOf = (s: HAEntityState): string | undefined =>
typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined;
// Last seen state per watched entity. Primed silently on the first poll so a restart does not
// re-announce the current state of everything as a fresh change.
const lastState = new Map<string, string>();
let primed = false;
async function pollStates(): Promise<void> {
const states = (await ha.getStates()).filter(isWatched);
for (const s of states) {
const prev = lastState.get(s.entity_id);
if (primed && prev !== undefined && prev !== s.state) {
await emit("module.home-assistant.state.changed", {
entity: s.entity_id,
name: nameOf(s),
from: prev,
to: s.state,
});
}
lastState.set(s.entity_id, s.state);
}
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[home-assistant] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollStates, 15_000);
console.log("[home-assistant] watching entity states, emitting on change");
+6
View File
@@ -4,6 +4,12 @@
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [
"module.home-assistant.state.changed"
],
"own-secrets": {
"broker": "/var/lib/home-assistant/broker"
},
"listens": [ "listens": [
{ {
"port": 8123, "port": 8123,
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-home-assistant",
"version": "0.1.0",
"description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+76
View File
@@ -0,0 +1,76 @@
// home-assistant's tools — its own code (novox/hq ADR 0044), importing its own client. They return
// structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { HomeAssistantClient, type HAEntityState } from "../client.js";
/** Trim an entity to the fields worth returning — the full attribute bag is large and mostly noise. */
function summarize(s: HAEntityState): { entity_id: string; state: string; name?: string; last_changed?: string } {
return {
entity_id: s.entity_id,
state: s.state,
name: typeof s.attributes.friendly_name === "string" ? s.attributes.friendly_name : undefined,
last_changed: s.last_changed,
};
}
export function getHomeAssistantTools(ha: HomeAssistantClient): ToolDefinition[] {
return [
{
name: "homeassistant_states",
description:
"Entity states from Home Assistant. With no argument, lists every entity; with `entity` (e.g. light.kitchen), returns just that one with its full attributes.",
input: { entity: { type: "string", description: "an entity id to fetch one entity; omitted lists all" } },
run: async (args) => {
if (args.entity) {
const s = await ha.getState(String(args.entity));
return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed };
}
const states = await ha.getStates();
return { count: states.length, entities: states.map(summarize) };
},
},
{
name: "homeassistant_call_service",
description:
"Call a Home Assistant service — turn a switch/light on or off, lock a door, etc. Give the domain (e.g. switch), the service (e.g. turn_on), and optionally a target entity and extra data.",
input: {
domain: { type: "string", description: "the service domain, e.g. light, switch, lock" },
service: { type: "string", description: "the service, e.g. turn_on, turn_off, toggle" },
entity: { type: "string", description: "the entity id to target (optional)" },
data: { type: "object", description: "extra service data merged into the call (optional)" },
},
run: async (args) => {
const data: Record<string, unknown> = { ...(args.data as Record<string, unknown> | undefined) };
if (args.entity) data.entity_id = String(args.entity);
const changed = await ha.callService(String(args.domain), String(args.service), data);
return { changed: changed.map(summarize) };
},
},
{
name: "homeassistant_config",
description: "Home Assistant instance config: version, location, timezone, loaded components.",
input: {},
run: async () => {
const c = await ha.getConfig();
return {
location: c.location_name,
version: c.version,
time_zone: c.time_zone,
state: c.state,
components: c.components?.length ?? 0,
};
},
},
];
}
// The tools exist only when a token is configured; without one, home-assistant contributes none
// rather than failing the whole runtime.
registerModuleTools("home-assistant", (env) => {
try {
return getHomeAssistantTools(HomeAssistantClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
+106
View File
@@ -0,0 +1,106 @@
// The InfluxDB API client — influxdb's own code, living in the module (novox/hq ADR 0044). Only
// this module's tools import it. Talks to the InfluxDB 2.x HTTP API (/api/v2) with a token.
export interface InfluxHealth {
name?: string;
status?: string;
message?: string;
version?: string;
}
export interface InfluxBucket {
id: string;
name: string;
orgID?: string;
retentionSeconds?: number;
}
export class InfluxDBClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
private readonly org: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The token is the InfluxDB API token (the admin
* token the server was initialised with, or a scoped one) — required, since every /api/v2 call
* is token-authenticated. The org scopes bucket listing and queries.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const url = env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
const token = env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
const org = env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org);
}
private async request(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
Authorization: `Token ${this.token}`,
...(init?.headers ?? {}),
},
});
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
return res;
}
/** Server health — the one endpoint that needs no token, but we send it anyway. */
async health(): Promise<InfluxHealth> {
return (await (await this.request("/health")).json()) as InfluxHealth;
}
async listBuckets(): Promise<InfluxBucket[]> {
const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] };
return (body.buckets ?? []).map((b) => {
const bucket = b as Record<string, unknown>;
const rules = (bucket.retentionRules as { everySeconds?: number }[] | undefined) ?? [];
return {
id: String(bucket.id),
name: String(bucket.name),
orgID: bucket.orgID ? String(bucket.orgID) : undefined,
retentionSeconds: rules[0]?.everySeconds,
};
});
}
/**
* Run a read-only Flux query and return the raw CSV InfluxDB answers with, plus a light parse
* into rows. Read-only: Flux has no write verb, and the token's own permissions bound the rest —
* this client never calls the write endpoint.
*/
async query(flux: string): Promise<{ csv: string; rows: Record<string, string>[] }> {
const res = await this.request(`/api/v2/query?org=${encodeURIComponent(this.org)}`, {
method: "POST",
headers: {
"Content-Type": "application/vnd.flux",
Accept: "application/csv",
},
body: flux,
});
const csv = await res.text();
return { csv, rows: parseAnnotatedCsv(csv) };
}
}
/** Parse InfluxDB's annotated CSV into rows keyed by column header. Annotation lines (starting
* with #) and blanks are skipped; the first non-annotation line is the header. */
function parseAnnotatedCsv(csv: string): Record<string, string>[] {
const lines = csv.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
if (lines.length < 2) return [];
const header = lines[0].split(",");
return lines.slice(1).map((line) => {
const cells = line.split(",");
const row: Record<string, string> = {};
header.forEach((h, i) => {
if (h) row[h] = cells[i] ?? "";
});
return row;
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-influxdb",
"version": "0.1.0",
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0044).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+46
View File
@@ -0,0 +1,46 @@
// influxdb's tools — its own code (novox/hq ADR 0044), importing its own client. They return
// structured data; the mesh serves them through the sdk's tool harness. Read-only: health, bucket
// listing, and Flux queries — no write path is exposed.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { InfluxDBClient } from "../client.js";
export function getInfluxDBTools(influx: InfluxDBClient): ToolDefinition[] {
return [
{
name: "influxdb_health",
description: "InfluxDB server health and version.",
input: {},
run: async () => influx.health(),
},
{
name: "influxdb_list_buckets",
description: "List InfluxDB buckets in the org, with their retention.",
input: {},
run: async () => {
const buckets = await influx.listBuckets();
return { count: buckets.length, buckets };
},
},
{
name: "influxdb_query",
description:
"Run a read-only Flux query against InfluxDB and return the parsed rows (plus raw CSV). The query is Flux, e.g. from(bucket:\"default\") |> range(start:-1h).",
input: { flux: { type: "string", description: "the Flux query to run" } },
run: async (args) => {
const { csv, rows } = await influx.query(String(args.flux));
return { rowCount: rows.length, rows, csv };
},
},
];
}
// The tools exist only when a token is configured; without one, influxdb contributes none rather
// than failing the whole runtime.
registerModuleTools("influxdb", (env) => {
try {
return getInfluxDBTools(InfluxDBClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "tools/index.ts"]
}