From 4b26aca5eeee5afb3742b3f6c3c68f6d0ab13e35 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 30 Jul 2026 11:42:25 -0500 Subject: [PATCH] feat(ups): add Home Assistant NUT integration via REST config-flow API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive HA's REST config-flow endpoint to add the NUT integration programmatically, no manual UI clicks required. The script (setup-ha-nut.sh + ha-nut-setup.py) is idempotent — skips if a NUT entry already exists. Key finding: HAOS runs Tailscale as an isolated add-on container, so the HA core container cannot route to Tailscale IPs. Added a LAN listener (192.168.3.11:3493) to upsd so HA can reach it over the shared vmbr0 bridge. Both VMs (pfv-bms HA at 192.168.3.12 and pfv-tsys1 at 192.168.3.11) are on the same bridge. Integration is live — sensors for battery charge (100%), status (Online), and status data (OL) are reporting. Secrets (HA token, NUT password) are read from ~/.config/pfvcluster/ and never committed to the repo. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- STATUS.md | 12 +++- ups/README.md | 57 +++++++++++++++---- ups/ha-nut-setup.py | 134 ++++++++++++++++++++++++++++++++++++++++++++ ups/setup-ha-nut.sh | 65 +++++++++++++++++++++ ups/setup.sh | 20 +++++-- 5 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 ups/ha-nut-setup.py create mode 100644 ups/setup-ha-nut.sh diff --git a/STATUS.md b/STATUS.md index 343924e..bdea609 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,7 +3,7 @@ > **Human read-only. Agents maintain this file automatically after each work > session.** Do not edit by hand — the next agent run will overwrite it. -> **Last updated:** 2026-07-30 by Crush (GLM-5.2) +> **Last updated:** 2026-07-30 by Crush (GLM-5.2) — HA NUT integration live > > **Capacity targets are now codified** in > [`proxmox/docs/CAPACITY-GOALS.md`](proxmox/docs/CAPACITY-GOALS.md): 80% RAM @@ -74,13 +74,19 @@ infrastructure, Proxmox cluster ops, and k8s control plane. - [x] Local upsmon (master) configured for graceful hypervisor shutdown - [x] Home Assistant credentials generated (`homeassistant` user in upsd.users) - [x] Scripts in `ups/`: discover, setup (idempotent), status +- [x] Home Assistant NUT integration **configured via REST API** (`ups/setup-ha-nut.sh`) +- [x] **Key fix:** upsd now listens on LAN IP (192.168.3.11) in addition to + Tailscale — HAOS runs Tailscale as an isolated add-on, so the HA container + cannot route to Tailscale IPs. Shared vmbr0 bridge provides instant LAN path. - [x] **Key fix:** udev rule covers BOTH `usb` and `hidraw` subsystems (driver opens raw `/dev/bus/usb` after setuid drop, not `/dev/hidraw`) - [ ] **Tripp Lite UPS blocked:** driver matches TrippLite HID 0.85 subdriver but fails reading 878-byte HID Report Descriptor (EAGAIN via USB hub). Needs physical reseat to direct motherboard USB port or cable replacement. Driver masked to prevent restart-loop spam. -- [ ] **Home Assistant NUT integration:** not yet configured in HA UI (user action) +- [ ] **Home Assistant NUT integration:** configured and live (3 sensors: + battery charge, status, status data). Additional sensors populate as UPS + reports more variables. ### Console Management (ser2net telnet(rfc2217) + conman on pfv-tsys4 — LIVE) - [x] 7 network switch/router consoles managed via ser2net + conman @@ -160,7 +166,7 @@ infrastructure, Proxmox cluster ops, and k8s control plane. - **Friday maintenance:** tsys4 (PCIe NIC + RAM), tsys5 (2nd CPU candidate, NVMe, 2nd ethernet) - **PDU:** Rename outlets in powerman.conf (Friday onsite) - **k8s deferred:** ETL tooling, HPC scheduler, vcluster policy, solar-aware scale-out -- **UPS:** APC Smart-UPS C 1500 operational via NUT on pfv-tsys1. Tripp Lite blocked on HID report descriptor (USB hub/cable issue — try direct motherboard port). HA NUT integration pending user config in HA UI. +- **UPS:** APC Smart-UPS C 1500 operational via NUT on pfv-tsys1. HA NUT integration live (battery charge, status, status data sensors). Tripp Lite blocked on HID report descriptor (USB hub/cable issue — try direct motherboard port). ## Infrastructure Summary diff --git a/ups/README.md b/ups/README.md index 2c74ed1..a83336b 100644 --- a/ups/README.md +++ b/ups/README.md @@ -63,17 +63,18 @@ pfv-tsys1 (192.168.3.11 / Tailscale 100.121.189.98) ▼ nut-driver@apc-smartups-c1500 (usbhid-ups) ▼ - upsd :3493 (LISTEN 127.0.0.1 + Tailscale) + upsd :3493 (LISTEN 127.0.0.1 + Tailscale + LAN) ▼ ▼ upsmon (local) Home Assistant (NUT integration) - graceful shutdown power/load/runtime sensors + graceful shutdown via LAN 192.168.3.11 (HAOS can't + route to Tailscale IPs) ``` - **Driver layer** — `usbhid-ups` process, pinned by USB serial. Debian uses templated `nut-driver@.service` units managed by `nut-driver-enumerator`. - **Server layer** — `upsd` exposes UPS data on TCP 3493 (localhost + Tailscale - only). Clients authenticate via `upsd.users`. + + LAN). Clients authenticate via `upsd.users`. - **Monitor layer** — `upsmon` runs locally as `master` to trigger `SHUTDOWNCMD` (`/sbin/shutdown -h now`) when a UPS reports `LOWBATT`. - **Home Assistant** — native NUT integration connects to `upsd` over Tailscale @@ -89,7 +90,7 @@ it needs write access to those raw USB files. The udev rule must match ## Scripts -All scripts run on the target host (pfv-tsys1) via `tests/remote.sh`: +NUT host scripts run on pfv-tsys1 via `tests/remote.sh`: ```bash # Idempotent install + configure (safe to re-run): @@ -102,6 +103,13 @@ PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/discover.sh PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/status.sh ``` +The HA integration script runs from your workstation (needs HA API access): + +```bash +# Add the NUT integration to Home Assistant (idempotent): +bash ups/setup-ha-nut.sh +``` + `setup.sh` accepts environment overrides for serials/VIDs/PIDs/usernames, so it can be repurposed for other hosts or UPS units. Passwords for `monuser` and `homeassistant` are auto-generated on first run and reused on subsequent runs @@ -116,27 +124,54 @@ unplugged). |------|---------| | `/etc/udev/rules.d/99-nut-ups.rules` | Grant nut group rw on raw USB + hidraw devices (both subsystems) | | `/etc/nut/ups.conf` | `usbhid-ups` device(s), pinned by serial + subdriver | -| `/etc/nut/upsd.conf` | `LISTEN 127.0.0.1` + `LISTEN ` on port 3493 | +| `/etc/nut/upsd.conf` | `LISTEN 127.0.0.1` + `LISTEN ` + `LISTEN ` on port 3493 | | `/etc/nut/upsd.users` | `monuser` (master) + `homeassistant` (read-only) credentials | | `/etc/nut/upsmon.conf` | Local master monitor + `SHUTDOWNCMD` | | `/etc/nut/nut.conf` | `MODE=netserver` | ## Home Assistant integration +The NUT integration is added automatically by `setup-ha-nut.sh`, which drives +HA's REST config-flow API. It is idempotent (skips if the entry exists). + +```bash +# Prerequisites: create token + password files (one-time): +mkdir -p ~/.config/pfvcluster +# HA → Profile → Long-Lived Access Tokens → Create Token: +echo -n 'YOUR_HA_TOKEN' > ~/.config/pfvcluster/ha-token +# Password is in /etc/nut/upsd.users on pfv-tsys1 (the homeassistant user): +echo -n 'YOUR_NUT_PASS' > ~/.config/pfvcluster/nut-password +chmod 600 ~/.config/pfvcluster/{ha-token,nut-password} + +# Run: +bash ups/setup-ha-nut.sh +``` + +### Why LAN IP, not Tailscale + +upsd listens on **both** the Tailscale IP (`100.121.189.98`) **and** the LAN IP +(`192.168.3.11`). The HA NUT integration uses the **LAN IP** because HAOS runs +Tailscale as an isolated add-on container — the HA core container cannot route +to Tailscale IPs. Since pfv-bms (HA, `192.168.3.12`) and pfv-tsys1 (`192.168.3.11`) +share the same vmbr0 bridge, LAN connectivity is instant and reliable. + +### Manual UI alternative + In Home Assistant → **Settings → Devices & Services → Add Integration → NUT**: | Field | Value | |-------|-------| -| Host | `pfv-tsys1` (or `100.121.189.98`) over Tailscale | +| Host | `192.168.3.11` (LAN — HAOS can't reach Tailscale IPs from the HA container) | | Port | `3493` | | Username | `homeassistant` | -| Password | *(printed by `setup.sh` / stored in `/etc/nut/upsd.users`)* | +| Password | *(stored in `/etc/nut/upsd.users` on pfv-tsys1)* | | UPS | `apc-smartups-c1500` | -HA auto-discovers available UPS names after authentication. Useful sensors for -power-usage tracking: `sensor.apc_smartups_c1500_ups_load` (Watts, via `ups.load` -× VA rating), `sensor.apc_smartups_c1500_battery_runtime`, -`sensor.apc_smartups_c1500_ups_realpower`. +### Live sensors + +HA exposes UPS data as sensors (prefix `sensor.apc_smartups_c1500_`): +`battery_charge`, `status` (Online/On Battery), `status_data` (OL/OB/DISCHRG). +Additional sensors (load, runtime, voltage) populate as the UPS reports them. ## Daily operations diff --git a/ups/ha-nut-setup.py b/ups/ha-nut-setup.py new file mode 100644 index 0000000..1c9eae3 --- /dev/null +++ b/ups/ha-nut-setup.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +ha-nut-setup.py — Add the Home Assistant NUT integration via REST config-flow API. + +Stdlib-only (no pip). Idempotent: skips if a NUT config entry already exists. + +Env: + HA_HOST (default pfv-bms.knel.net) + HA_PORT (default 8123) + HA_TOKEN (long-lived access token) + NUT_HOST (default 100.121.189.98) + NUT_PORT (default 3493) + NUT_USER (default homeassistant) + NUT_PASS (required) + NUT_UPS (default apc-smartups-c1500) +""" +import os, json, sys, time, urllib.request, urllib.error + +HA_HOST = os.environ.get("HA_HOST", "pfv-bms.knel.net") +HA_PORT = int(os.environ.get("HA_PORT", "8123")) +TOKEN = os.environ["HA_TOKEN"] +NUT_HOST = os.environ.get("NUT_HOST", "192.168.3.11") +NUT_PORT = int(os.environ.get("NUT_PORT", "3493")) +NUT_USER = os.environ.get("NUT_USER", "homeassistant") +NUT_PASS = os.environ["NUT_PASS"] +NUT_UPS = os.environ.get("NUT_UPS", "apc-smartups-c1500") +BASE = f"http://{HA_HOST}:{HA_PORT}" + +def api(method, path, data=None): + body = json.dumps(data).encode() if data else None + req = urllib.request.Request( + f"{BASE}/api{path}", data=body, method=method, + headers={"Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return json.loads(raw) + except Exception: + return {"_http_error": e.code, "_raw": raw[:300]} + except Exception as e: + return {"_error": str(e)} + +# ── verify token ── +cfg = api("GET", "/config") +if "_http_error" in cfg or "_error" in cfg: + print(f"Cannot reach HA or token invalid: {cfg}"); sys.exit(1) +print(f"HA {cfg.get('version')} — token valid") + +# ── check existing entries (idempotent) ── +entries = api("GET", "/config/config_entries/entry") +existing = [e for e in entries if e.get("domain") == "nut"] +if existing: + for e in existing: + print(f"NUT already configured: {e.get('title')} " + f"(data={json.dumps(e.get('data', {}))})") + print("Skipping — delete it in HA UI first if you want to re-run.") + sys.exit(0) +print("No existing NUT entry. Starting config flow.") + +# ── initiate flow ── +flow = api("POST", "/config/config_entries/flow", {"handler": "nut"}) +if "flow_id" not in flow: + print(f"Flow init failed: {json.dumps(flow)}"); sys.exit(1) +fid = flow["flow_id"] +print(f"Flow started: step={flow.get('step_id')} " + f"fields={[f.get('name') for f in flow.get('data_schema', [])]}") + +# ── submit connection details ── +creds = {"host": NUT_HOST, "port": NUT_PORT, + "username": NUT_USER, "password": NUT_PASS} +flow = api("POST", f"/config/config_entries/flow/{fid}", creds) +if flow.get("errors"): + print(f"Validation errors: {flow['errors']}"); sys.exit(1) +print(f"After submit: type={flow.get('type')} step={flow.get('step_id')}") + +# ── handle follow-up steps (UPS selection etc.) ── +while flow.get("type") == "form": + step = flow.get("step_id", "?") + schema = flow.get("data_schema", []) + print(f"Step '{step}': fields={[f.get('name') for f in schema]}") + for f in schema: + opts = f.get("options") or f.get("values") + if opts: + print(f" {f.get('name')} options: {opts}") + submission = {} + for f in schema: + nm = f.get("name") + ftype = f.get("type", "") + if ftype == "multi_select": + opts = f.get("options", []) + vals = [o[0] if isinstance(o, list) else o for o in opts] + submission[nm] = [NUT_UPS] if NUT_UPS in vals else vals[:1] + elif nm in creds: + submission[nm] = creds[nm] + elif "default" in f: + submission[nm] = f["default"] + elif ftype == "select": + opts = f.get("options", []) + vals = [o[0] if isinstance(o, list) else o for o in opts] + submission[nm] = NUT_UPS if NUT_UPS in vals else (vals[0] if vals else "") + fid = flow.get("flow_id", fid) + flow = api("POST", f"/config/config_entries/flow/{fid}", submission) + if flow.get("errors"): + print(f"Validation errors: {flow['errors']}"); sys.exit(1) + print(f" -> type={flow.get('type')} step={flow.get('step_id')}") + +# ── result ── +if flow.get("type") == "create_entry": + print(f"\nNUT integration created: {flow.get('title')}") +elif flow.get("type") == "abort": + print(f"\nFlow aborted: {flow.get('reason')}"); sys.exit(1) +else: + print(f"\nFinal state: {flow.get('type')} — {json.dumps(flow)[:200]}") + +# ── verify sensors ── +print("\nWaiting 10s for entities ...") +time.sleep(10) +states = api("GET", "/states") +ups = [s for s in states + if "apc_smartups" in s["entity_id"].lower() + or "sensor.ups_" in s["entity_id"].lower()] +if ups: + print(f"Found {len(ups)} UPS sensors:") + for e in sorted(ups, key=lambda x: x["entity_id"]): + st = e.get("state", "?") + unit = e.get("attributes", {}).get("unit_of_measurement", "") + name = e.get("attributes", {}).get("friendly_name", "") + print(f" {e['entity_id']:55s} {st:>8} {unit:4s} {name}") +else: + print("No UPS sensors yet (may still be initialising — check HA UI).") diff --git a/ups/setup-ha-nut.sh b/ups/setup-ha-nut.sh new file mode 100644 index 0000000..63b7b36 --- /dev/null +++ b/ups/setup-ha-nut.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# setup-ha-nut.sh — Add the Home Assistant NUT integration via REST API. +# +# Idempotent: skips if a NUT entry already exists. Reads secrets from +# ~/.config/pfvcluster/ (ha-token, nut-password) or env vars. +# +# Usage: +# bash ups/setup-ha-nut.sh +# +# Env overrides: +# HA_TOKEN HA long-lived access token +# NUT_PASS NUT upsd password for the homeassistant user +# HA_HOST HA host (default pfv-bms.knel.net) +# NUT_HOST upsd host (default 192.168.3.11 — LAN, see README) +# NUT_PORT upsd port (default 3493) +# NUT_USER upsd user (default homeassistant) +# NUT_UPS UPS name (default apc-smartups-c1500) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONF_DIR="${PFV_CONF_DIR:-$HOME/.config/pfvcluster}" + +# --- HA token --- +HA_TOKEN="${HA_TOKEN:-}" +if [[ -z "$HA_TOKEN" ]]; then + TOKEN_FILE="$CONF_DIR/ha-token" + if [[ -f "$TOKEN_FILE" ]]; then + HA_TOKEN="$(head -1 "$TOKEN_FILE" | tr -d '[:space:]')" + else + echo "error: no HA token. Set \$HA_TOKEN or create $TOKEN_FILE" >&2 + echo " (HA → Profile → Long-Lived Access Tokens → Create Token)" >&2 + exit 1 + fi +fi + +# --- NUT password --- +NUT_PASS="${NUT_PASS:-}" +if [[ -z "$NUT_PASS" ]]; then + PASS_FILE="$CONF_DIR/nut-password" + if [[ -f "$PASS_FILE" ]]; then + NUT_PASS="$(head -1 "$PASS_FILE" | tr -d '[:space:]')" + else + echo "error: no NUT password. Set \$NUT_PASS or create $PASS_FILE" >&2 + echo " (value is in /etc/nut/upsd.users on the NUT host)" >&2 + exit 1 + fi +fi + +# --- connection params (override for your own kit) --- +export HA_TOKEN +export NUT_PASS +export HA_HOST="${HA_HOST:-pfv-bms.knel.net}" +export HA_PORT="${HA_PORT:-8123}" +export NUT_HOST="${NUT_HOST:-192.168.3.11}" +export NUT_PORT="${NUT_PORT:-3493}" +export NUT_USER="${NUT_USER:-homeassistant}" +export NUT_UPS="${NUT_UPS:-apc-smartups-c1500}" + +echo "HA: ${HA_HOST}:${HA_PORT}" +echo "NUT: ${NUT_USER}@${NUT_HOST}:${NUT_PORT} (UPS: ${NUT_UPS})" +echo "" + +exec python3 "$SCRIPT_DIR/ha-nut-setup.py" diff --git a/ups/setup.sh b/ups/setup.sh index fc0cdcd..43b3cd8 100644 --- a/ups/setup.sh +++ b/ups/setup.sh @@ -82,14 +82,26 @@ HA_PASSWORD="${HA_PASSWORD:-$(extract_pw "$HA_USER")}" [ -n "$MON_PASSWORD" ] || MON_PASSWORD="$(gen_pw)" [ -n "$HA_PASSWORD" ] || HA_PASSWORD="$(gen_pw)" -# --- 0b. Auto-detect Tailscale listen IP for upsd (clients on tailnet) --- +# --- 0b. Auto-detect listen IPs for upsd --- +# Tailscale IP: tailnet clients (workstation, etc.) +# LAN IP: HAOS VMs where Tailscale runs as an isolated add-on (HA container +# cannot route to Tailscale IPs, so the shared-LAN bridge is required) if [ -z "${NUT_LISTEN_IPS:-}" ]; then + NUT_LISTEN_IPS="127.0.0.1" TS_IP=$(tailscale ip -4 2>/dev/null || true) if [ -n "$TS_IP" ]; then - NUT_LISTEN_IPS="127.0.0.1 ${TS_IP}" + NUT_LISTEN_IPS="${NUT_LISTEN_IPS} ${TS_IP}" else - NUT_LISTEN_IPS="127.0.0.1" - echo " WARNING: No Tailscale IP detected. Listening on localhost only." + echo " WARNING: No Tailscale IP detected." + fi + if [ "${NUT_INCLUDE_LAN:-1}" = "1" ]; then + LAN_IP=$(ip -4 addr show vmbr0 2>/dev/null | awk '/scope global/{print $2}' | cut -d/ -f1 | head -1) + if [ -z "$LAN_IP" ]; then + LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}') + fi + if [ -n "$LAN_IP" ]; then + NUT_LISTEN_IPS="${NUT_LISTEN_IPS} ${LAN_IP}" + fi fi fi echo " upsd LISTEN IPs: ${NUT_LISTEN_IPS:-}"