From c79e0a2d87227c987442c65b36e1d44823a73a8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:39:43 -0500 Subject: [PATCH] Import smart-home into Gitea --- .gitignore | 9 +++ README.md | 63 +++++++++++++++ config.example.json | 5 ++ ha-ws.js | 191 ++++++++++++++++++++++++++++++++++++++++++++ ha.js | 112 ++++++++++++++++++++++++++ house.json | 52 ++++++++++++ scripts/connect.ps1 | 30 +++++++ 7 files changed, 462 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.json create mode 100644 ha-ws.js create mode 100644 ha.js create mode 100644 house.json create mode 100644 scripts/connect.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7af1b93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +config.json +data/ +*.token +node_modules/ +dist/ +.astro/ +__pycache__/ +*.pyc +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd9323d --- /dev/null +++ b/README.md @@ -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 = "" +``` + +## 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`. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..95e89df --- /dev/null +++ b/config.example.json @@ -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" +} diff --git a/ha-ws.js b/ha-ws.js new file mode 100644 index 0000000..06ec042 --- /dev/null +++ b/ha-ws.js @@ -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 "" + 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(); +} diff --git a/ha.js b/ha.js new file mode 100644 index 0000000..f1819c5 --- /dev/null +++ b/ha.js @@ -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 "); + 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 "); +} diff --git a/house.json b/house.json new file mode 100644 index 0000000..88483c3 --- /dev/null +++ b/house.json @@ -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" } + ] + } + ] +} diff --git a/scripts/connect.ps1 b/scripts/connect.ps1 new file mode 100644 index 0000000..fc54d1f --- /dev/null +++ b/scripts/connect.ps1 @@ -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 +}