#!/usr/bin/env bun // Home Assistant REST API client for the CoyotePack / SW Labs smart-home project. // Runtime: bun (this machine has no node). See README.md. // // Config: baseUrl comes from config.json (falls back to config.example.json). // Token comes from the env var named by config.tokenEnv (default HA_TOKEN) — never hard-coded. // // Usage: // HA_TOKEN=... bun ha.js ping # verify connection + show HA version // HA_TOKEN=... bun ha.js states # dump all entity states -> data/states.json // HA_TOKEN=... bun ha.js states light # only entities in the "light" domain // HA_TOKEN=... bun ha.js get light.kitchen # one entity // HA_TOKEN=... bun ha.js call light turn_on '{"entity_id":"light.kitchen"}' // HA_TOKEN=... bun ha.js services # list available services -> data/services.json import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = dirname(fileURLToPath(import.meta.url)); function loadConfig() { const path = existsSync(join(ROOT, "config.json")) ? join(ROOT, "config.json") : join(ROOT, "config.example.json"); return JSON.parse(readFileSync(path, "utf8")); } const cfg = loadConfig(); const BASE = (cfg.baseUrl || "").replace(/\/+$/, ""); const TOKEN = process.env[cfg.tokenEnv || "HA_TOKEN"]; if (!BASE) fail("No baseUrl in config."); if (!TOKEN) fail(`No token. Set the ${cfg.tokenEnv || "HA_TOKEN"} environment variable.`); function fail(msg) { console.error(`ERROR: ${msg}`); process.exit(1); } async function api(path, { method = "GET", body } = {}) { const res = await fetch(`${BASE}/api${path}`, { method, headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text(); fail(`${method} ${path} -> HTTP ${res.status}\n${text}`); } const ct = res.headers.get("content-type") || ""; return ct.includes("application/json") ? res.json() : res.text(); } function save(name, obj) { const p = join(ROOT, "data", name); writeFileSync(p, JSON.stringify(obj, null, 2)); console.log(`saved ${p}`); } const [cmd, arg1, arg2] = process.argv.slice(2); switch (cmd) { case "ping": { const info = await api("/"); const cfgInfo = await api("/config"); console.log(`Connected to ${BASE}`); console.log(` message: ${info.message}`); console.log(` version: ${cfgInfo.version}`); console.log(` location: ${cfgInfo.location_name}`); break; } case "states": { const all = await api("/states"); const filtered = arg1 ? all.filter((e) => e.entity_id.startsWith(`${arg1}.`)) : all; // Summary by domain const byDomain = {}; for (const e of all) { const d = e.entity_id.split(".")[0]; byDomain[d] = (byDomain[d] || 0) + 1; } console.log(`${all.length} entities across ${Object.keys(byDomain).length} domains:`); for (const [d, n] of Object.entries(byDomain).sort((a, b) => b[1] - a[1])) { console.log(` ${String(n).padStart(4)} ${d}`); } save(arg1 ? `states.${arg1}.json` : "states.json", filtered); break; } case "get": { if (!arg1) fail("usage: get "); console.log(JSON.stringify(await api(`/states/${arg1}`), null, 2)); break; } case "services": { save("services.json", await api("/services")); break; } case "call": { if (!arg1 || !arg2) fail('usage: call \'{"entity_id":"..."}\''); const [domain, service] = [arg1, arg2]; const data = process.argv[5] ? JSON.parse(process.argv[5]) : {}; console.log(JSON.stringify(await api(`/services/${domain}/${service}`, { method: "POST", body: data }), null, 2)); break; } default: console.log("commands: ping | states [domain] | get | services | call "); }