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:
@@ -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
|
||||
|
||||
|
||||
+46
-11
@@ -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@<upsname>.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 <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/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
|
||||
|
||||
|
||||
@@ -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).")
|
||||
@@ -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
@@ -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:-<none>}"
|
||||
|
||||
Reference in New Issue
Block a user