feat(ups): add Home Assistant NUT integration via REST config-flow API

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
This commit is contained in:
2026-07-30 11:42:25 -05:00
parent 5456c783c6
commit 4b26aca5ee
5 changed files with 270 additions and 18 deletions
+9 -3
View File
@@ -3,7 +3,7 @@
> **Human read-only. Agents maintain this file automatically after each work > **Human read-only. Agents maintain this file automatically after each work
> session.** Do not edit by hand — the next agent run will overwrite it. > 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 > **Capacity targets are now codified** in
> [`proxmox/docs/CAPACITY-GOALS.md`](proxmox/docs/CAPACITY-GOALS.md): 80% RAM > [`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] Local upsmon (master) configured for graceful hypervisor shutdown
- [x] Home Assistant credentials generated (`homeassistant` user in upsd.users) - [x] Home Assistant credentials generated (`homeassistant` user in upsd.users)
- [x] Scripts in `ups/`: discover, setup (idempotent), status - [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 - [x] **Key fix:** udev rule covers BOTH `usb` and `hidraw` subsystems (driver
opens raw `/dev/bus/usb` after setuid drop, not `/dev/hidraw`) opens raw `/dev/bus/usb` after setuid drop, not `/dev/hidraw`)
- [ ] **Tripp Lite UPS blocked:** driver matches TrippLite HID 0.85 subdriver - [ ] **Tripp Lite UPS blocked:** driver matches TrippLite HID 0.85 subdriver
but fails reading 878-byte HID Report Descriptor (EAGAIN via USB hub). but fails reading 878-byte HID Report Descriptor (EAGAIN via USB hub).
Needs physical reseat to direct motherboard USB port or cable replacement. Needs physical reseat to direct motherboard USB port or cable replacement.
Driver masked to prevent restart-loop spam. 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) ### Console Management (ser2net telnet(rfc2217) + conman on pfv-tsys4 — LIVE)
- [x] 7 network switch/router consoles managed via ser2net + conman - [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) - **Friday maintenance:** tsys4 (PCIe NIC + RAM), tsys5 (2nd CPU candidate, NVMe, 2nd ethernet)
- **PDU:** Rename outlets in powerman.conf (Friday onsite) - **PDU:** Rename outlets in powerman.conf (Friday onsite)
- **k8s deferred:** ETL tooling, HPC scheduler, vcluster policy, solar-aware scale-out - **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 ## Infrastructure Summary
+46 -11
View File
@@ -63,17 +63,18 @@ pfv-tsys1 (192.168.3.11 / Tailscale 100.121.189.98)
nut-driver@apc-smartups-c1500 (usbhid-ups) 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) 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 - **Driver layer** — `usbhid-ups` process, pinned by USB serial. Debian uses
templated `nut-driver@<upsname>.service` units managed by templated `nut-driver@<upsname>.service` units managed by
`nut-driver-enumerator`. `nut-driver-enumerator`.
- **Server layer** — `upsd` exposes UPS data on TCP 3493 (localhost + Tailscale - **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 - **Monitor layer** — `upsmon` runs locally as `master` to trigger
`SHUTDOWNCMD` (`/sbin/shutdown -h now`) when a UPS reports `LOWBATT`. `SHUTDOWNCMD` (`/sbin/shutdown -h now`) when a UPS reports `LOWBATT`.
- **Home Assistant** — native NUT integration connects to `upsd` over Tailscale - **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 ## 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 ```bash
# Idempotent install + configure (safe to re-run): # 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 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 `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 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 `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/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/ups.conf` | `usbhid-ups` device(s), pinned by serial + subdriver |
| `/etc/nut/upsd.conf` | `LISTEN 127.0.0.1` + `LISTEN <tailscale>` on port 3493 | | `/etc/nut/upsd.conf` | `LISTEN 127.0.0.1` + `LISTEN <tailscale>` + `LISTEN <lan>` on port 3493 |
| `/etc/nut/upsd.users` | `monuser` (master) + `homeassistant` (read-only) credentials | | `/etc/nut/upsd.users` | `monuser` (master) + `homeassistant` (read-only) credentials |
| `/etc/nut/upsmon.conf` | Local master monitor + `SHUTDOWNCMD` | | `/etc/nut/upsmon.conf` | Local master monitor + `SHUTDOWNCMD` |
| `/etc/nut/nut.conf` | `MODE=netserver` | | `/etc/nut/nut.conf` | `MODE=netserver` |
## Home Assistant integration ## 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**: In Home Assistant → **Settings → Devices & Services → Add Integration → NUT**:
| Field | Value | | 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` | | Port | `3493` |
| Username | `homeassistant` | | 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` | | UPS | `apc-smartups-c1500` |
HA auto-discovers available UPS names after authentication. Useful sensors for ### Live sensors
power-usage tracking: `sensor.apc_smartups_c1500_ups_load` (Watts, via `ups.load`
× VA rating), `sensor.apc_smartups_c1500_battery_runtime`, HA exposes UPS data as sensors (prefix `sensor.apc_smartups_c1500_`):
`sensor.apc_smartups_c1500_ups_realpower`. `battery_charge`, `status` (Online/On Battery), `status_data` (OL/OB/DISCHRG).
Additional sensors (load, runtime, voltage) populate as the UPS reports them.
## Daily operations ## Daily operations
+134
View File
@@ -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).")
+65
View File
@@ -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"
+16 -4
View File
@@ -82,14 +82,26 @@ HA_PASSWORD="${HA_PASSWORD:-$(extract_pw "$HA_USER")}"
[ -n "$MON_PASSWORD" ] || MON_PASSWORD="$(gen_pw)" [ -n "$MON_PASSWORD" ] || MON_PASSWORD="$(gen_pw)"
[ -n "$HA_PASSWORD" ] || HA_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 if [ -z "${NUT_LISTEN_IPS:-}" ]; then
NUT_LISTEN_IPS="127.0.0.1"
TS_IP=$(tailscale ip -4 2>/dev/null || true) TS_IP=$(tailscale ip -4 2>/dev/null || true)
if [ -n "$TS_IP" ]; then if [ -n "$TS_IP" ]; then
NUT_LISTEN_IPS="127.0.0.1 ${TS_IP}" NUT_LISTEN_IPS="${NUT_LISTEN_IPS} ${TS_IP}"
else else
NUT_LISTEN_IPS="127.0.0.1" echo " WARNING: No Tailscale IP detected."
echo " WARNING: No Tailscale IP detected. Listening on localhost only." 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
fi fi
echo " upsd LISTEN IPs: ${NUT_LISTEN_IPS:-<none>}" echo " upsd LISTEN IPs: ${NUT_LISTEN_IPS:-<none>}"