#!/usr/bin/env bun // Home Assistant WebSocket client — manages the Area / Floor registries, // which the REST API cannot touch. Runtime: bun. // // Auth + baseUrl come from the same config + $env:HA_TOKEN as ha.js. // // Usage: // bun ha-ws.js floors # list floors // bun ha-ws.js areas # list areas (with floor) // bun ha-ws.js layout house.json # create floors+areas from a layout file (idempotent) // bun ha-ws.js create-floor "Upstairs" 1 // bun ha-ws.js create-area "Kitchen" "Main Floor" import { readFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = dirname(fileURLToPath(import.meta.url)); const cfg = JSON.parse( readFileSync(existsSync(join(ROOT, "config.json")) ? join(ROOT, "config.json") : join(ROOT, "config.example.json"), "utf8") ); const BASE = (cfg.baseUrl || "").replace(/\/+$/, ""); const TOKEN = process.env[cfg.tokenEnv || "HA_TOKEN"]; if (!TOKEN) { console.error(`No token. Set ${cfg.tokenEnv || "HA_TOKEN"}.`); process.exit(1); } const WS_URL = BASE.replace(/^http/, "ws") + "/api/websocket"; // Minimal WS command runner: authenticates, then lets you send commands // with auto-incrementing ids and await their result. function connect() { return new Promise((resolve, reject) => { const ws = new WebSocket(WS_URL); let id = 0; const pending = new Map(); ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "auth_required") { ws.send(JSON.stringify({ type: "auth", access_token: TOKEN })); } else if (msg.type === "auth_ok") { resolve({ send: (payload) => new Promise((res, rej) => { const myId = ++id; pending.set(myId, { res, rej }); ws.send(JSON.stringify({ id: myId, ...payload })); }), close: () => ws.close(), }); } else if (msg.type === "auth_invalid") { reject(new Error("auth_invalid: " + msg.message)); } else if (msg.type === "result") { const p = pending.get(msg.id); if (p) { pending.delete(msg.id); msg.success ? p.res(msg.result) : p.rej(new Error(JSON.stringify(msg.error))); } } }); ws.addEventListener("error", (e) => reject(new Error("WS error: " + (e.message || e)))); }); } const [cmd, a1, a2] = process.argv.slice(2); const ha = await connect(); async function listFloors() { return ha.send({ type: "config/floor_registry/list" }); } async function listAreas() { return ha.send({ type: "config/area_registry/list" }); } try { switch (cmd) { case "floors": { const f = await listFloors(); console.log(f.length ? f.map((x) => ` [${x.level ?? "-"}] ${x.name} (${x.floor_id})`).join("\n") : " (none)"); break; } case "areas": { const [areas, floors] = [await listAreas(), await listFloors()]; const fname = Object.fromEntries(floors.map((f) => [f.floor_id, f.name])); console.log(areas.length ? areas.map((a) => ` ${a.name.padEnd(22)} floor: ${fname[a.floor_id] || "-"} (${a.area_id})`).join("\n") : " (none)"); break; } case "create-floor": { const r = await ha.send({ type: "config/floor_registry/create", name: a1, ...(a2 != null ? { level: Number(a2) } : {}) }); console.log("created floor:", r.name, r.floor_id); break; } case "devices": { // devices [nameFilter] — list devices with manufacturer/model/area const [devices, areas] = [await ha.send({ type: "config/device_registry/list" }), await listAreas()]; const aname = Object.fromEntries(areas.map((a) => [a.area_id, a.name])); const filt = a1 ? a1.toLowerCase() : null; const rows = devices .filter((d) => !filt || `${d.name_by_user || ""} ${d.name || ""} ${d.manufacturer || ""} ${d.model || ""}`.toLowerCase().includes(filt)) .sort((a, b) => (a.name || "").localeCompare(b.name || "")); for (const d of rows) { const nm = d.name_by_user || d.name || "(unnamed)"; console.log(`${d.id} ${String(d.manufacturer || "").slice(0, 16).padEnd(16)} ${String(d.model || "").slice(0, 22).padEnd(22)} area:${String(aname[d.area_id] || "-").padEnd(16)} ${nm}`); } console.log(`(${rows.length} devices)`); break; } case "assign-device": { // assign-device "" const areas = await listAreas(); const area = areas.find((a) => a.name.toLowerCase() === String(a2).toLowerCase()); if (!area) { console.error(`area not found: ${a2}`); break; } const r = await ha.send({ type: "config/device_registry/update", device_id: a1, area_id: area.area_id }); console.log(`assigned device ${a1} (${r.name_by_user || r.name}) -> ${area.name}`); break; } case "assign": { // assign "" const areas = await listAreas(); const area = areas.find((a) => a.name.toLowerCase() === String(a2).toLowerCase()); if (!area) { console.error(`area not found: ${a2}`); break; } const r = await ha.send({ type: "config/entity_registry/update", entity_id: a1, area_id: area.area_id }); console.log(`assigned ${a1} -> ${area.name}`); break; } case "rename-area": { // rename-area "" "" const areas = await listAreas(); const area = areas.find((a) => a.name.toLowerCase() === String(a1).toLowerCase()); if (!area) { console.error(`area not found: ${a1}`); break; } const r = await ha.send({ type: "config/area_registry/update", area_id: area.area_id, name: a2 }); console.log(`renamed ${a1} -> ${r.name}`); break; } case "delete-area": { const areas = await listAreas(); const area = areas.find((a) => a.name.toLowerCase() === String(a1).toLowerCase()); if (!area) { console.error(`area not found: ${a1}`); break; } await ha.send({ type: "config/area_registry/delete", area_id: area.area_id }); console.log(`deleted area ${a1}`); break; } case "create-area": { const floors = await listFloors(); const floor = floors.find((f) => f.name.toLowerCase() === String(a2).toLowerCase()); const r = await ha.send({ type: "config/area_registry/create", name: a1, ...(floor ? { floor_id: floor.floor_id } : {}) }); console.log("created area:", r.name, r.area_id, floor ? `on ${floor.name}` : "(no floor)"); break; } case "layout": { // house.json: { "floors": [{ "name": "Main Floor", "level": 0, "areas": ["Kitchen","Living Room"] }, ...] } const layout = JSON.parse(readFileSync(join(ROOT, a1 || "house.json"), "utf8")); const existingFloors = await listFloors(); const existingAreas = await listAreas(); const floorByName = new Map(existingFloors.map((f) => [f.name.toLowerCase(), f])); const areaByName = new Map(existingAreas.map((a) => [a.name.toLowerCase(), a])); for (const fl of layout.floors || []) { let floor = floorByName.get(fl.name.toLowerCase()); if (!floor) { floor = await ha.send({ type: "config/floor_registry/create", name: fl.name, ...(fl.level != null ? { level: fl.level } : {}), ...(fl.icon ? { icon: fl.icon } : {}) }); floorByName.set(fl.name.toLowerCase(), floor); console.log(`+ floor ${fl.name}${fl.icon ? " " + fl.icon : ""}`); } else { if (fl.icon && floor.icon !== fl.icon) { floor = await ha.send({ type: "config/floor_registry/update", floor_id: floor.floor_id, icon: fl.icon }); floorByName.set(fl.name.toLowerCase(), floor); console.log(`~ floor ${fl.name} icon -> ${fl.icon}`); } else { console.log(`= floor ${fl.name} (exists)`); } } for (const entry of fl.areas || []) { const areaName = typeof entry === "string" ? entry : entry.name; const icon = typeof entry === "string" ? undefined : entry.icon; const existing = areaByName.get(areaName.toLowerCase()); if (existing) { if (icon && existing.icon !== icon) { const updated = await ha.send({ type: "config/area_registry/update", area_id: existing.area_id, icon }); areaByName.set(areaName.toLowerCase(), updated); console.log(` ~ area ${areaName} icon -> ${icon}`); } else { console.log(` = area ${areaName} (exists)`); } continue; } const area = await ha.send({ type: "config/area_registry/create", name: areaName, floor_id: floor.floor_id, ...(icon ? { icon } : {}) }); areaByName.set(areaName.toLowerCase(), area); console.log(` + area ${areaName}${icon ? " " + icon : ""}`); } } console.log("layout applied."); break; } default: console.log("commands: floors | areas | create-floor [level] | create-area [floor] | layout "); } } finally { ha.close(); }