192 lines
8.9 KiB
JavaScript
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();
|
|
}
|