Files
2026-07-30 18:39:43 -05:00

192 lines
8.9 KiB
JavaScript

#!/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 <device_id> "<area name>"
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 <entity_id> "<area name>"
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 "<old name>" "<new name>"
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 <name> [level] | create-area <name> [floor] | layout <file.json>");
}
} finally {
ha.close();
}