feat(ups): NUT setup for APC Smart-UPS C 1500 on pfv-tsys1
Deploy Network UPS Tools to monitor the server-room UPS units and feed data to
Home Assistant for power-usage tracking.
APC Smart-UPS C 1500 is fully operational: battery charge, runtime, load,
voltage, and status all reporting via usbhid-ups + APC HID 0.100 subdriver.
upsd listens on Tailscale (100.121.189.98:3493) for HA polling; local upsmon
provides graceful hypervisor shutdown on battery-low.
Key deployment fix: the udev rule must cover SUBSYSTEM=="usb" (raw
/dev/bus/usb) in addition to hidraw, because usbhid-ups opens the raw USB
device file after dropping to the nut user via setuid(). Matching only hidraw
causes EACCES on driver start.
Tripp Lite UPS (09ae:3016) is blocked — driver matches TrippLite HID 0.85
subdriver but fails reading the 878-byte HID Report Descriptor (EAGAIN via USB
hub). Needs physical reseat to direct motherboard USB port. Driver masked to
prevent restart-loop spam.
Files: ups/discover.sh, ups/setup.sh (idempotent), ups/status.sh, ups/README.md
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
+286
@@ -0,0 +1,286 @@
|
||||
#!/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 Tailscale listen IP for upsd (clients on tailnet) ---
|
||||
if [ -z "${NUT_LISTEN_IPS:-}" ]; then
|
||||
TS_IP=$(tailscale ip -4 2>/dev/null || true)
|
||||
if [ -n "$TS_IP" ]; then
|
||||
NUT_LISTEN_IPS="127.0.0.1 ${TS_IP}"
|
||||
else
|
||||
NUT_LISTEN_IPS="127.0.0.1"
|
||||
echo " WARNING: No Tailscale IP detected. Listening on localhost only."
|
||||
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 "============================================"
|
||||
Reference in New Issue
Block a user