#!/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:-}" # --- 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" < "$UPS_CONF_MON" cat > "$NUT_CONF" </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@ 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 "============================================"