prep for next ai session

This commit is contained in:
2026-08-01 15:45:23 -05:00
parent 46c35106fb
commit 6244c1cc25
139 changed files with 16712 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
# UPS Management (NUT — Network UPS Tools)
Centralized UPS monitoring for the server room via
[NUT](https://networkupstools.org/), running on **pfv-tsys1**. USB HID UPS
units feed one `upsd` network server; Home Assistant polls it over Tailscale for
real-time power/load/runtime tracking, and a local `upsmon` shuts the hypervisor
down gracefully when battery is low.
> **Why NUT (not apcupsd)?** Two different UPS brands (APC + Tripp Lite) must be
> covered. `apcupsd` only supports APC, so it would require a second daemon
> stack. NUT's `usbhid-ups` driver speaks to **both** via the USB HID Power
> Device class, and Home Assistant ships a first-class NUT integration.
## Hardware
| UPS | Model | VID:PID | USB Serial | Status |
|-----|-------|---------|------------|--------|
| **APC** | Smart-UPS C 1500 (FW 02.2) | `051d:0003` | `AS1213210423` | **LIVE** |
| **Tripp Lite** | UPS (HID PDC) | `09ae:3016` | `2352CVLSM871900694` | **Blocked** — see below |
## Current State (2026-07-30)
### APC Smart-UPS C 1500 — OPERATIONAL
Fully reporting via `usbhid-ups` + `APC HID 0.100` subdriver. Data validated:
```
battery.charge: 100 battery.runtime: 1800 battery.voltage: 27.4
ups.status: OL ups.load: (via HA) ups.model: Smart-UPS C 1500
```
### Tripp Lite UPS — BLOCKED (hardware issue)
The driver finds the device, matches the `TrippLite HID 0.85` subdriver, claims
the interface, and reads the HID descriptor — but **fails reading the 878-byte
HID Report Descriptor** (`Resource temporarily unavailable` / EAGAIN after 5s).
The driver is masked to prevent restart-loop spam.
USB descriptors (manufacturer, product, serial) are readable via `lsusb -v` and
`nut-scanner`, but the bulk control transfer for the full report descriptor
times out. Likely causes:
1. **USB hub** — the Tripp Lite is behind a Genesys Logic hub (`05e3:0608`).
Try plugging directly into a motherboard USB port.
2. **USB cable** — try a high-quality data cable (not charge-only).
3. **UPS firmware** — the USB controller may not properly implement all HID
endpoints.
**To retry after physical reseat:**
```bash
# On pfv-tsys1:
systemctl unmask nut-driver@tripp-lite-ups
systemctl start nut-driver@tripp-lite-ups
upsc tripp-lite-ups@localhost
```
## Architecture
```
pfv-tsys1 (192.168.3.11 / Tailscale 100.121.189.98)
├─ APC Smart-UPS C 1500 ──┐
└─ Tripp Lite UPS (masked) ──┤ USB HID
nut-driver@apc-smartups-c1500 (usbhid-ups)
upsd :3493 (LISTEN 127.0.0.1 + Tailscale + LAN)
▼ ▼
upsmon (local) Home Assistant (NUT integration)
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
+ 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
and exposes `ups.load`, `battery.runtime`, `ups.status`, etc. as sensors.
### Key deployment lesson: udev must cover raw USB devices
The `usbhid-ups` driver opens `/dev/bus/usb/BBB/DDD` (raw USB device files),
**not** `/dev/hidraw*`. After calling `setuid(111)` to drop to the `nut` user,
it needs write access to those raw USB files. The udev rule must match
`SUBSYSTEM=="usb"` by vendor/product ID to set `GROUP="nut"` — matching only
`hidraw` is insufficient. See `/etc/udev/rules.d/99-nut-ups.rules`.
## Scripts
NUT host scripts run on pfv-tsys1 via `tests/remote.sh`:
```bash
# Idempotent install + configure (safe to re-run):
PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/setup.sh
# Discover USB UPS + NUT state (read-only diagnostic):
PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/discover.sh
# Query UPS data + service health:
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
(stored in `/etc/nut/upsd.users`).
Set `TRIPP_ENABLED=0` to skip the Tripp Lite entirely (useful if it's physically
unplugged).
## Configuration files on pfv-tsys1
| File | Purpose |
|------|---------|
| `/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>` + `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 | `192.168.3.11` (LAN — HAOS can't reach Tailscale IPs from the HA container) |
| Port | `3493` |
| Username | `homeassistant` |
| Password | *(stored in `/etc/nut/upsd.users` on pfv-tsys1)* |
| UPS | `apc-smartups-c1500` |
### 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
From pfv-tsys1 (or any tailnet host with NUT client installed):
```bash
# List UPS units served by upsd
upsc -l pfv-tsys1
# Full variable dump for one UPS
upsc apc-smartups-c1500@pfv-tsys1
# Battery runtime (the only runtime/charge data this UPS exposes)
upsc apc-smartups-c1500@pfv-tsys1 battery.runtime
```
## Notes
- **No USB passthrough to the HA VM.** Keeping the UPS on the host preserves
hypervisor graceful-shutdown capability and matches the `powerman/` pattern
(PDU managed on the host where the adapter physically lives).
- **No `ups.load` / `ups.realpower` on this UPS (FW 02.2, mfg 2012):** The
APC Smart-UPS C 1500 does not expose load or power data over USB HID.
Both NUT `usbhid-ups` and `apcupsd` (USB mode, tested 2026-07-30) read the
same HID descriptor — the variable simply isn't there. This means the HA
NUT integration provides **battery/runtime/status sensors only**, not
wattage for the Energy Dashboard.
- **apcupsd test note:** Debian's `apcupsd` package conflicts with
`nut-server` (mutually exclusive). apcupsd USB mode returned `COMMLOST`
even before we could check load. The APC Smart Serial protocol (serial
cable, AP940-1524C, ~$30) DOES report load%, but this requires a serial
port on the UPS and on the host.
- **Energy Dashboard path:** A smart plug (Shelly Plug S / TP-Link Kasa,
~$15-25) on the UPS output reports real watts natively and feeds the
Energy Dashboard with zero UPS-driver hacking. The NUT sensors remain
valuable for outage detection and graceful-shutdown automations.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/bash
#
# ups/discover.sh — probe USB UPS units and NUT state on the local host
#
# Read-only. Prints everything needed to configure NUT. No changes made.
#
# Usage (run ON the target host via remote.sh):
# PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/discover.sh
#
set -uo pipefail
echo "======================================================"
echo " UPS / NUT Discovery on $(hostname)"
echo "======================================================"
# --- 1. USB UPS devices ------------------------------------------------------
echo ""
echo "--- [1/5] USB UPS devices (lsusb) ---"
lsusb 2>/dev/null | grep -iE "UPS|American Power|Tripp|APC" || echo " (no UPS devices found in lsusb)"
echo ""
echo "--- [2/5] UPS detail (vendor/product/serial/model) ---"
# Common UPS vendor IDs: 051d (APC), 09ae (Tripp Lite), 0463 (Eaton),
# 06da (MGE), 0764 (Cyber Power)
for vid in 051d 09ae 0463 06da 0764; do
while read -r bus dev pid; do
[ -n "$bus" ] || continue
echo " --- $bus:$dev ($vid:$pid) ---"
lsusb -v -s "${bus}:${dev}" 2>/dev/null \
| grep -iE "iManufacturer|iProduct|iSerial|bcdDevice" \
| sed 's/^/ /'
done < <(lsusb 2>/dev/null | awk -v v="$vid" '$0~v{split($2,a,":"); split($4,b,":"); print a[1], b[1], $6}')
done
# --- 2. sysfs paths (for udev rules) -----------------------------------------
echo ""
echo "--- [3/5] sysfs device paths + serials ---"
for d in /sys/bus/usb/devices/*; do
man=$(cat "$d/manufacturer" 2>/dev/null)
prod=$(cat "$d/product" 2>/dev/null)
ser=$(cat "$d/serial" 2>/dev/null)
vid=$(cat "$d/idVendor" 2>/dev/null)
pid=$(cat "$d/idProduct" 2>/dev/null)
if echo "$man $prod" | grep -qiE "apc|tripp|power conversion|ups|eaton|mge|cyber power"; then
# Resolve stable ID_PATH for udev pinning
path=$(udevadm info -q property -p "$d" 2>/dev/null | awk -F= '/^ID_PATH=/{print $2}')
echo " $d"
echo " vendor=$vid product=$pid"
echo " manufacturer=$man"
echo " product=$prod"
echo " serial=$ser"
echo " ID_PATH=$path"
fi
done
# --- 3. HID device nodes -----------------------------------------------------
echo ""
echo "--- [4/5] HID device nodes ---"
ls -la /dev/hidraw* /dev/usb/hiddev* 2>/dev/null || echo " (no hidraw/hiddev nodes)"
# --- 4. NUT install state ----------------------------------------------------
echo ""
echo "--- [5/5] NUT install + service state ---"
if dpkg -l nut-server nut-client 2>/dev/null | grep -q '^ii'; then
echo " NUT installed:"
dpkg -l nut-server nut-client 2>/dev/null | awk '/^ii/{print " "$2" "$3}'
else
echo " NUT not installed (apt: nut-server nut-client)"
fi
echo ""
echo " Services:"
for svc in nut-driver nut-server nut-monitor; do
printf " %-14s " "$svc:"
systemctl is-active "$svc" 2>/dev/null || true
done
echo ""
echo " Existing config:"
# shellcheck disable=SC2012 # ls -la is intentional for human-readable listing
ls -la /etc/nut/ 2>/dev/null | sed 's/^/ /' || echo " (no /etc/nut)"
echo ""
echo "======================================================"
echo " Discovery complete."
echo "======================================================"
+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"
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/bash
#
# ups/setup.sh — idempotent Network UPS Tools (NUT) setup on pfv-tsys1
#
# Installs NUT, configures two USB HID UPS units (APC + Tripp Lite) pinned by
# USB serial, runs upsd as a network server for Home Assistant polling, and
# runs upsmon locally so the hypervisor can shut down gracefully on battery.
#
# Designed to run ON the target host (pfv-tsys1) as root, idempotent.
#
# Usage:
# PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/setup.sh
#
# Overrides (defaults suit pfv-tsys1):
# APC_SERIAL APC UPS USB serial (default AS1213210423)
# APC_VID/APC_PID APC vendor/product ID (default 051d / 0003)
# APC_NAME NUT section name for APC (default apc-smartups-c1500)
# TRIPP_SERIAL Tripp Lite UPS USB serial (default 2352CVLSM871900694)
# TRIPP_VID/TRIPP_PID Tripp Lite vendor/product (default 09ae / 3016)
# TRIPP_NAME NUT section name for Tripp (default tripp-lite-ups)
# TRIPP_SUBDRIVER Forced HID subdriver for Tripp (default "TrippLite HID 0.85")
# TRIPP_ENABLED Set to 0 to disable Tripp Lite (default 1)
# NUT_LISTEN_IPS space-separated upsd LISTEN IPs (default: auto Tailscale + 127.0.0.1)
# HA_USER upsd username for HA (default homeassistant)
# HA_PASSWORD upsd password for HA (default: reuse or generate)
# MON_USER upsd username for local upsmon (default monuser)
# MON_PASSWORD upsd password for upsmon (default: reuse or generate)
#
set -euo pipefail
# --- Config (overridable via env) ---
APC_SERIAL="${APC_SERIAL:-AS1213210423}"
APC_VID="${APC_VID:-051d}"
APC_PID="${APC_PID:-0003}"
APC_NAME="${APC_NAME:-apc-smartups-c1500}"
TRIPP_SERIAL="${TRIPP_SERIAL:-2352CVLSM871900694}"
TRIPP_VID="${TRIPP_VID:-09ae}"
TRIPP_PID="${TRIPP_PID:-3016}"
TRIPP_NAME="${TRIPP_NAME:-tripp-lite-ups}"
TRIPP_SUBDRIVER="${TRIPP_SUBDRIVER:-TrippLite HID 0.85}"
TRIPP_ENABLED="${TRIPP_ENABLED:-1}"
HA_USER="${HA_USER:-homeassistant}"
MON_USER="${MON_USER:-monuser}"
NUT_PORT="${NUT_PORT:-3493}"
UDEV_RULE="/etc/udev/rules.d/99-nut-ups.rules"
UPS_CONF="/etc/nut/ups.conf"
UPSD_CONF="/etc/nut/upsd.conf"
UPSD_USERS="/etc/nut/upsd.users"
UPS_CONF_MON="/etc/nut/upsmon.conf"
NUT_CONF="/etc/nut/nut.conf"
# --- Helpers ---
gen_pw() { head -c 24 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20; }
# Reuse existing passwords if config already present (idempotent re-runs)
extract_pw() { # $1=user
if [ -f "$UPSD_USERS" ]; then
awk -v u="[$1]" '
$0==u {inblk=1; next}
/^\[/ {inblk=0}
inblk && $1=="password" {gsub(/"/,"",$3); print $3; exit}
' "$UPSD_USERS" 2>/dev/null
fi
}
echo "============================================"
echo " NUT UPS Setup on $(hostname)"
echo " APC: $APC_NAME ($APC_VID:$APC_PID serial $APC_SERIAL)"
if [ "$TRIPP_ENABLED" = "1" ]; then
echo " Tripp Lite: $TRIPP_NAME ($TRIPP_VID:$TRIPP_PID serial $TRIPP_SERIAL)"
else
echo " Tripp Lite: DISABLED (TRIPP_ENABLED=0)"
fi
echo "============================================"
# --- 0. Resolve / generate passwords (idempotent) ---
MON_PASSWORD="${MON_PASSWORD:-$(extract_pw "$MON_USER")}"
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 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="${NUT_LISTEN_IPS} ${TS_IP}"
else
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>}"
# --- 1. Install NUT ---
echo ""
echo "--- [1/8] Installing NUT (nut-server, nut-client) ---"
if dpkg -l nut-server 2>/dev/null | grep -q '^ii'; then
echo " NUT already installed: $(dpkg -l nut-server | awk '/^ii/{print $3}')"
else
apt-get update -qq && apt-get install -y -qq nut-server nut-client
fi
mkdir -p /etc/nut
# --- 2. udev rules: grant nut group access to BOTH raw USB + hidraw devices ---
# CRITICAL: usbhid-ups opens /dev/bus/usb/BBB/DDD (raw USB), not /dev/hidraw.
# The driver drops to the nut user via setuid(), so the nut group needs write
# access to the raw USB device files. Matching on subsystem=="usb" by VID:PID
# is required because ATTRS{serial} does not reliably traverse for usb devices.
echo ""
echo "--- [2/8] Writing udev rules (raw USB + hidraw, group nut) ---"
{
echo "# Stable permissions for NUT USB HID UPS units"
echo "# Generated by ups/setup.sh — grants the 'nut' group access to both"
echo "# the raw USB device files (/dev/bus/usb) and hidraw devices."
echo "# Match BOTH subsystems: the usbhid-ups driver opens the raw USB device"
echo "# after dropping to the nut user via setuid()."
echo ""
echo "# APC Smart-UPS C 1500 ($APC_VID:$APC_PID)"
echo "SUBSYSTEM==\"usb\", ATTR{idVendor}==\"$APC_VID\", ATTR{idProduct}==\"$APC_PID\", GROUP=\"nut\", MODE=\"0664\""
echo "SUBSYSTEM==\"hidraw\", ATTRS{serial}==\"$APC_SERIAL\", GROUP=\"nut\", MODE=\"0660\""
echo ""
echo "# Tripp Lite UPS ($TRIPP_VID:$TRIPP_PID)"
echo "SUBSYSTEM==\"usb\", ATTR{idVendor}==\"$TRIPP_VID\", ATTR{idProduct}==\"$TRIPP_PID\", GROUP=\"nut\", MODE=\"0664\""
echo "SUBSYSTEM==\"hidraw\", ATTRS{serial}==\"$TRIPP_SERIAL\", GROUP=\"nut\", MODE=\"0660\""
} > "$UDEV_RULE"
echo " Written: $UDEV_RULE"
udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=usb 2>/dev/null || true
udevadm trigger --subsystem-match=hidraw 2>/dev/null || true
sleep 1
# --- 3. ups.conf ---
echo ""
echo "--- [3/8] Writing ups.conf ---"
{
echo "# NUT UPS devices — generated by ups/setup.sh on $(date)"
echo ""
echo "maxretry = 3"
echo ""
echo "[${APC_NAME}]"
echo " driver = usbhid-ups"
echo " port = auto"
echo " vendorid = ${APC_VID}"
echo " productid = ${APC_PID}"
echo " serial = ${APC_SERIAL}"
echo " desc = \"APC Smart-UPS C 1500\""
if [ "$TRIPP_ENABLED" = "1" ]; then
echo ""
echo "[${TRIPP_NAME}]"
echo " driver = usbhid-ups"
echo " port = auto"
echo " vendorid = ${TRIPP_VID}"
echo " productid = ${TRIPP_PID}"
echo " serial = ${TRIPP_SERIAL}"
echo " subdriver = \"${TRIPP_SUBDRIVER}\""
echo " desc = \"Tripp Lite UPS\""
fi
} > "$UPS_CONF"
echo " Written: $UPS_CONF"
# --- 4. upsd.conf: network server (localhost + Tailscale for HA) ---
echo ""
echo "--- [4/8] Writing upsd.conf ---"
{
echo "# NUT upsd — generated by ups/setup.sh on $(date)"
for ip in $NUT_LISTEN_IPS; do
echo "LISTEN ${ip} ${NUT_PORT}"
done
echo "MAXAGE 25"
} > "$UPSD_CONF"
echo " Written: $UPSD_CONF (LISTEN: $(echo "$NUT_LISTEN_IPS" | tr '\n' ' '))"
# --- 5. upsd.users: monuser (master) + homeassistant (read-only monitor) ---
echo ""
echo "--- [5/8] Writing upsd.users ---"
cat > "$UPSD_USERS" <<USERS
# NUT upsd users — generated by ups/setup.sh on $(date)
# monuser : local upsmon (master) — graceful hypervisor shutdown
# ${HA_USER} : Home Assistant NUT integration (read-only polling)
[${MON_USER}]
password = "${MON_PASSWORD}"
upsmon master
[${HA_USER}]
password = "${HA_PASSWORD}"
upsmon slave
USERS
echo " Written: $UPSD_USERS"
# --- 6. upsmon.conf + nut.conf ---
echo ""
echo "--- [6/8] Writing upsmon.conf + nut.conf ---"
{
echo "# NUT upsmon (local monitor) — generated by ups/setup.sh on $(date)"
echo "# Monitors the APC UPS as master. On battery-low, shuts the host down."
echo ""
echo "MONITOR ${APC_NAME}@localhost 1 ${MON_USER} \"${MON_PASSWORD}\" master"
if [ "$TRIPP_ENABLED" = "1" ]; then
echo "MONITOR ${TRIPP_NAME}@localhost 1 ${MON_USER} \"${MON_PASSWORD}\" master"
fi
echo ""
echo "SHUTDOWNCMD \"/sbin/shutdown -h now\""
echo "POWERDOWNFLAG /etc/killpower"
echo "NOTIFYFLAG ONLINE SYSLOG+WALL"
echo "NOTIFYFLAG ONBATT SYSLOG+WALL"
echo "NOTIFYFLAG LOWBATT SYSLOG+WALL"
echo "POLLFREQ 15"
echo "POLLFREQALERT 5"
echo "HOSTSYNC 15"
} > "$UPS_CONF_MON"
cat > "$NUT_CONF" <<NUTCONF
# NUT mode — generated by ups/setup.sh on $(date)
# netserver = this host runs drivers + upsd; serves UPS data to clients (HA).
MODE=netserver
NUTCONF
echo " Written: $UPS_CONF_MON + $NUT_CONF"
# --- 6b. Fix ownership/permissions (Debian: nut group must read config) ---
chown root:nut "$UPS_CONF" "$UPSD_CONF" "$UPSD_USERS" "$UPS_CONF_MON" 2>/dev/null || true
chmod 640 "$UPS_CONF" "$UPSD_CONF" "$UPSD_USERS" "$UPS_CONF_MON" 2>/dev/null || true
chmod 644 "$NUT_CONF" 2>/dev/null || true
# --- 7. Start services (Debian uses templated nut-driver@<name> units) ---
echo ""
echo "--- [7/8] Starting NUT services ---"
# Re-read ups.conf to generate per-UPS driver instances
systemctl restart nut-driver-enumerator 2>/dev/null || true
sleep 2
# Start per-UPS driver instances
systemctl restart "nut-driver@${APC_NAME}" 2>/dev/null || true
if [ "$TRIPP_ENABLED" = "1" ]; then
systemctl restart "nut-driver@${TRIPP_NAME}" 2>/dev/null || true
else
systemctl stop "nut-driver@${TRIPP_NAME}" 2>/dev/null || true
systemctl mask "nut-driver@${TRIPP_NAME}" 2>/dev/null || true
fi
sleep 3
systemctl restart nut-server 2>/dev/null || true
sleep 1
systemctl restart nut-monitor 2>/dev/null || true
echo ""
echo " Service status:"
for svc in "nut-driver@${APC_NAME}" "nut-driver@${TRIPP_NAME}" nut-server nut-monitor; do
if systemctl list-unit-files "$svc" >/dev/null 2>&1; then
printf " %-42s " "$svc"
systemctl is-active "$svc" 2>/dev/null || echo "(unknown)"
fi
done
# --- 8. Validate ---
echo ""
echo "--- [8/8] Validation ---"
echo ""
echo " upsc — ${APC_NAME}:"
upsc "${APC_NAME}@localhost" 2>&1 | head -25 || echo " (APC UPS not responding yet)"
if [ "$TRIPP_ENABLED" = "1" ]; then
echo ""
echo " upsc — ${TRIPP_NAME}:"
upsc "${TRIPP_NAME}@localhost" 2>&1 | head -25 || echo " (Tripp Lite UPS not responding yet)"
fi
echo ""
echo "============================================"
echo " Setup complete."
echo ""
echo " Home Assistant NUT integration:"
echo " Host: $(echo "$NUT_LISTEN_IPS" | awk '{print $2}') (or any LISTEN IP above)"
echo " Port: ${NUT_PORT}"
echo " Username: ${HA_USER}"
echo " Password: ${HA_PASSWORD}"
if [ "$TRIPP_ENABLED" = "1" ]; then
echo " UPS names: ${APC_NAME}, ${TRIPP_NAME}"
else
echo " UPS names: ${APC_NAME}"
fi
echo ""
echo " Save the HA password now — it is stored in ${UPSD_USERS}."
echo "============================================"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/bash
#
# ups/status.sh — query NUT UPS state + service health (read-only)
#
# Usage (run ON the target host via remote.sh):
# PROX_HOST=pfv-tsys1 bash tests/remote.sh prox-file ups/status.sh
#
# shellcheck disable=SC2012 # ss/awk field extraction is intentional
set -uo pipefail
APC_NAME="${APC_NAME:-apc-smartups-c1500}"
TRIPP_NAME="${TRIPP_NAME:-tripp-lite-ups}"
echo "======================================================"
echo " NUT UPS Status on $(hostname)"
echo "======================================================"
echo ""
echo "--- Services ---"
for svc in "nut-driver@${APC_NAME}" "nut-driver@${TRIPP_NAME}" nut-server nut-monitor; do
if systemctl list-unit-files "$svc" >/dev/null 2>&1; then
printf " %-42s " "$svc"
systemctl is-active "$svc" 2>/dev/null || echo "(unknown)"
fi
done
for ups in "$APC_NAME" "$TRIPP_NAME"; do
echo ""
echo "--- ${ups} ---"
if upsc "${ups}@localhost" >/tmp/.nutstatus.$$ 2>&1; then
awk -v u="$ups" '
BEGIN{printf " %s\n", u}
/^battery\.charge:/ {printf " battery.charge: %s\n", $3}
/^battery\.runtime:/ {printf " battery.runtime: %ss (%.0f min)\n", $3, $3/60}
/^battery\.voltage:/ {printf " battery.voltage: %s\n", $3}
/^ups\.status:/ {printf " ups.status: %s\n", $3}
/^ups\.load:/ {printf " ups.load: %s%%\n", $3}
/^ups\.power:/ {printf " ups.power: %s\n", $3}
/^ups\.realpower:/ {printf " ups.realpower: %s W\n", $3}
/^input\.voltage:/ {printf " input.voltage: %s\n", $3}
/^output\.voltage:/ {printf " output.voltage: %s\n", $3}
/^ups\.model:/ {printf " ups.model: %s\n", $3}
/^ups\.serial:/ {printf " ups.serial: %s\n", $3}
/^device\.mfr:/ {printf " device.mfr: %s\n", $3}
' /tmp/.nutstatus.$$
echo " (full dump: upsc ${ups}@localhost)"
else
echo " NOT RESPONDING:"
sed 's/^/ /' /tmp/.nutstatus.$$
fi
rm -f /tmp/.nutstatus.$$
done
echo ""
echo "--- upsd LISTEN sockets ---"
ss -ltnp 2>/dev/null | grep -E "3493|nut" | sed 's/^/ /' || echo " (upsd not listening on 3493)"
echo ""
echo "======================================================"