Import smart-home into Gitea

This commit is contained in:
Claude
2026-07-30 18:39:43 -05:00
commit c79e0a2d87
7 changed files with 462 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
config.json
data/
*.token
node_modules/
dist/
.astro/
__pycache__/
*.pyc
.venv/
+63
View File
@@ -0,0 +1,63 @@
# Smart Home — Home Assistant
Integration project for the home Home Assistant instance running on **RogueOne**.
## Connection
| | |
|---|---|
| Host | **R2D2** (Unraid, `10.10.40.10`, VLAN 40) |
| Base URL | `http://10.10.40.10:8123` |
| Version | Home Assistant 2026.7.2 |
| API | REST (`/api/...`), Bearer token auth |
| Status | ✅ Connected — token stored in Vaultwarden ("Home Assistant - R2D2") |
## Auth token (one-time setup)
Home Assistant uses **long-lived access tokens**. Create one in the HA UI:
1. Open `http://10.10.40.10:8123` → click your **user name** (bottom-left).
2. **Security** tab → scroll to **Long-Lived Access Tokens****Create Token**.
3. Name it `claude-smart-home`, copy the token (shown once).
Then store it — do **not** commit it to a file:
```powershell
# Save to Vaultwarden org "Homelab - Claude" (preferred)
bw create ... "Home Assistant - R2D2" # already done — stored 2026-07-20
# Or just export it for the current shell
$env:HA_TOKEN = "<paste token>"
```
## Usage (bun — this machine has no node)
```bash
$env:HA_TOKEN="..."; bun ha.js ping # verify connection + HA version
bun ha.js states # all entities -> data/states.json (+ domain summary)
bun ha.js states light # just the "light" domain
bun ha.js get light.kitchen # one entity
bun ha.js services # available services -> data/services.json
bun ha.js call light turn_on '{"entity_id":"light.kitchen"}'
```
Or use the wrapper (auto-pulls the token from Vaultwarden):
```powershell
pwsh -File scripts\connect.ps1 ping
```
## Layout
```
smart-home/
ha.js # bun HA REST client (ping/states/get/services/call)
config.example.json # baseUrl + which env var holds the token
config.json # (optional) local override, gitignored
scripts/connect.ps1 # pull token from Vaultwarden + run a command
data/ # entity/service dumps (gitignored)
```
## Notes
- RogueOne is on the home LAN (`192.168.1.x`), not the VLAN-30/40 lab network.
- The token is **never** hard-coded — `ha.js` reads it from `$env:HA_TOKEN`.
+5
View File
@@ -0,0 +1,5 @@
{
"baseUrl": "http://10.10.40.10:8123",
"// token": "Do NOT put the token here. Set env var HA_TOKEN instead (see README).",
"tokenEnv": "HA_TOKEN"
}
+191
View File
@@ -0,0 +1,191 @@
#!/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();
}
+112
View File
@@ -0,0 +1,112 @@
#!/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 <entity_id>");
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 <domain> <service> \'{"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 <entity_id> | services | call <domain> <service> <json>");
}
+52
View File
@@ -0,0 +1,52 @@
{
"_comment": "9144 Meredith Ave, Omaha NE 68134. Areas may be a string or {name, icon}. Icons are MDI (mdi:...). Idempotent: `bun ha-ws.js layout house.json` creates missing floors/areas and updates icons on existing ones.",
"floors": [
{
"name": "Lower Level",
"level": -1,
"icon": "mdi:home-floor-negative-1",
"areas": [
{ "name": "Family Room", "icon": "mdi:sofa" },
{ "name": "Gym", "icon": "mdi:dumbbell" },
{ "name": "Lower Bath", "icon": "mdi:shower" },
{ "name": "Laundry Room", "icon": "mdi:washing-machine" },
{ "name": "Utility Room", "icon": "mdi:water-boiler" },
{ "name": "Server Room", "icon": "mdi:server-network" }
]
},
{
"name": "Main Floor",
"level": 0,
"icon": "mdi:home-floor-0",
"areas": [
{ "name": "Kitchen", "icon": "mdi:fridge" },
{ "name": "Living Room", "icon": "mdi:television" },
{ "name": "Dining Room", "icon": "mdi:silverware-fork-knife" },
{ "name": "Primary Bedroom", "icon": "mdi:bed-king" },
{ "name": "Bedroom 4", "icon": "mdi:bed" },
{ "name": "Garage", "icon": "mdi:garage" }
]
},
{
"name": "Upstairs",
"level": 1,
"icon": "mdi:home-floor-1",
"areas": [
{ "name": "Bedroom 2", "icon": "mdi:bed-single" },
{ "name": "Bedroom 3", "icon": "mdi:bed-single" },
{ "name": "Rec Room", "icon": "mdi:gamepad-variant" },
{ "name": "Upstairs Bath", "icon": "mdi:bathtub" }
]
},
{
"name": "Outside",
"level": 2,
"icon": "mdi:tree",
"areas": [
{ "name": "Front Yard", "icon": "mdi:grass" },
{ "name": "Backyard", "icon": "mdi:grill" },
{ "name": "Driveway", "icon": "mdi:car" }
]
}
]
}
+30
View File
@@ -0,0 +1,30 @@
# Quick-connect helper for the smart-home / Home Assistant project.
# Pulls the long-lived token from Vaultwarden (org "Homelab - Claude"),
# exports it as HA_TOKEN, and runs a connection test.
#
# Usage: pwsh -File scripts\connect.ps1 [command]
# (defaults to "ping")
param([string]$Command = "ping")
$ErrorActionPreference = "Stop"
$projRoot = Split-Path -Parent $PSScriptRoot
# Retrieve token from Bitwarden/Vaultwarden CLI (item name: "Home Assistant - RogueOne")
# Falls back to an already-set $env:HA_TOKEN if bw is unavailable.
if (-not $env:HA_TOKEN) {
try {
$env:HA_TOKEN = (bw get password "Home Assistant - RogueOne").Trim()
} catch {
Write-Warning "Could not read token from Bitwarden. Set `$env:HA_TOKEN manually."
}
}
if (-not $env:HA_TOKEN) { throw "HA_TOKEN not set." }
Push-Location $projRoot
try {
bun ha.js $Command
} finally {
Pop-Location
}