diff --git a/modules/home-assistant/client.ts b/modules/home-assistant/client.ts new file mode 100644 index 0000000..d7aefa9 --- /dev/null +++ b/modules/home-assistant/client.ts @@ -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; + 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 { + 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[]; + } +} diff --git a/modules/home-assistant/index.ts b/modules/home-assistant/index.ts new file mode 100644 index 0000000..0c9f275 --- /dev/null +++ b/modules/home-assistant/index.ts @@ -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(); +let primed = false; + +async function pollStates(): Promise { + 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, 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"); diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 6003229..2fefc03 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.home-assistant.state.changed" + ], + "own-secrets": { + "broker": "/var/lib/home-assistant/broker" + }, "listens": [ { "port": 8123, diff --git a/modules/home-assistant/package.json b/modules/home-assistant/package.json new file mode 100644 index 0000000..d5a8523 --- /dev/null +++ b/modules/home-assistant/package.json @@ -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" + } +} diff --git a/modules/home-assistant/tools/index.ts b/modules/home-assistant/tools/index.ts new file mode 100644 index 0000000..4325658 --- /dev/null +++ b/modules/home-assistant/tools/index.ts @@ -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 = { ...(args.data as Record | 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 []; + } +}); diff --git a/modules/home-assistant/tsconfig.json b/modules/home-assistant/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/home-assistant/tsconfig.json @@ -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"] +} diff --git a/modules/influxdb/client.ts b/modules/influxdb/client.ts new file mode 100644 index 0000000..12a81eb --- /dev/null +++ b/modules/influxdb/client.ts @@ -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 { + 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 { + return (await (await this.request("/health")).json()) as InfluxHealth; + } + + async listBuckets(): Promise { + const body = (await (await this.request("/api/v2/buckets")).json()) as { buckets?: unknown[] }; + return (body.buckets ?? []).map((b) => { + const bucket = b as Record; + 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[] }> { + 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[] { + 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 = {}; + header.forEach((h, i) => { + if (h) row[h] = cells[i] ?? ""; + }); + return row; + }); +} diff --git a/modules/influxdb/package.json b/modules/influxdb/package.json new file mode 100644 index 0000000..ad7b949 --- /dev/null +++ b/modules/influxdb/package.json @@ -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" + } +} diff --git a/modules/influxdb/tools/index.ts b/modules/influxdb/tools/index.ts new file mode 100644 index 0000000..a2e13ce --- /dev/null +++ b/modules/influxdb/tools/index.ts @@ -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 []; + } +}); diff --git a/modules/influxdb/tsconfig.json b/modules/influxdb/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/influxdb/tsconfig.json @@ -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"] +}