Import smart-home into Gitea
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
config.json
|
||||||
|
data/
|
||||||
|
*.token
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user