UPS — Reading and Managing It from the Media Server

UPS — Reading and Managing It from the Media Server

Search terms: UPS · CyberPower · CP1350 · NUT · Network UPS Tools · upsc · upsd · battery runtime · on battery · power failure · driver not connected · usbhid-ups

Installed 2026-08-13. The CyberPower CP1350PFCLCDa (1350 VA / 880 W) is connected by USB to media-server, which reads it with NUT. Every command below runs on media-server — nothing else on the LAN talks to the UPS directly.


What is and isn’t set up

Status
Reading the UPS (battery, load, voltage) working — verified 2026-08-13
Logging power events to /var/log/ups-events.log was BROKEN — fixed 2026-08-18
Discord alerts to #power-failure was BROKEN — fixed 2026-08-18
Automatic clean shutdown on low battery configured, NOT YET TESTED

The 2026-08-18 correction — alerts never worked in production

This page previously claimed Discord alerts were “working — test messages delivered 19:19.” They were not. A real mains pull on 2026-08-18 06:13 produced no Discord alert, and the same had been true for every real event since at least 2026-08-14.

Root cause: upsmon does NOT run NOTIFYCMD as root.

root  1985       upsmon    <- privileged parent, used ONLY for the shutdown
nut   1988  1985 upsmon    <- drops privileges; THIS is what runs NOTIFYCMD

As user nut, the script could neither write /var/log/ups-events.log (644 root:root) nor read ~/.config/discord-webhooks/power.txt (600 aztechguy:aztechguy). It exited at the webhook readability test before ever attempting the POST.

The 19:19 test passed because it was run by hand with sudo — as root. It exercised the script in a security context production never uses. The page even carried the caveat “this tests the notification path, not the shutdown” — correct, but one level too shallow. It tested the notification script, not the notification path.

The old claim on this page — "upsmon runs as root … which is how it can read a mode-600 webhook file" — was the false assumption, copied from the script’s own header comment. It propagated from code comment to documentation to four days of silent failure.

The shutdown path still has never been exercised. The config is in place and the services are running, but no real power interruption has driven the battery to the low-battery threshold to prove upsmon triggers a clean shutdown. Until that happens, that row is a claim rather than a fact — see Testing it for real below.


The one command to remember

upsc cyberpower

Prints everything the UPS knows. The lines that matter:

upsc cyberpower | grep -E 'battery.charge|battery.runtime|ups.load|input.voltage|ups.status'
battery.charge: 82        percent
battery.runtime: 8937     SECONDS remaining -- divide by 60
ups.load: 0               percent of 880 W
input.voltage: 120.0      mains voltage as the UPS sees it
ups.status: OL CHRG       see the table below

Single values, useful in scripts:

upsc cyberpower battery.charge
upsc cyberpower ups.status
echo "$(( $(upsc cyberpower battery.runtime) / 60 )) minutes left"

Decoding ups.status

Flag Meaning
OL On Line — running on mains. Normal.
OB On Battery — mains has failed
LB Low Battery — shutdown threshold reached
CHRG battery is charging
DISCHRG battery is discharging
RB Replace Battery — the one that matters at 3-5 years
OVER overloaded
TRIM / BOOST actively correcting high / low mains voltage

OL CHRG is normal after a power cut or a fresh install. Steady state is plain OL.

RB is the sleeper. UPS batteries die silently at 3-5 years, and without a check the first sign is a unit that drops instantly during a real outage. Worth watching for.


Watching it live during an outage

watch -n 2 'upsc cyberpower 2>/dev/null | grep -E "battery.charge|battery.runtime|ups.load|ups.status"'

From the laptop, without logging in:

ssh media-server 'upsc cyberpower | grep -E "charge|runtime|load|status"'

Runtime: the number that misleads

battery.runtime at idle is meaningless for planning. With nothing on the battery outlets it read 8937 seconds (149 minutes).

Measured 2026-08-13 with the load connected: ups.load: 9 (~79 W), battery.runtime 70 minutes. That is more headroom than expected — and the low figure is itself worth a second look, because the media server alone should draw 80-110 W with four spinning drives. A load that reads lower than the hardware can explain usually means something is plugged into the SURGE-ONLY outlets rather than the battery side. Confirm by watching ups.load while unplugging one device at a time.

Load maths for this unit:

ups.load is a PERCENTAGE of 880 W
  ups.load: 20   ->  ~176 W
  ups.load: 25   ->  ~220 W

Total draw across all outlets must stay under 12 A (from the label, surge outlets included). At ~200 W that is about 1.7 A — roughly 14% of capacity.


Monitoring and Discord alerts

Three pieces, all on media-server:

/etc/nut/upsd.users            upsmon's credentials for upsd (root:nut 640)
/etc/nut/upsmon.conf           MONITOR line, shutdown command, NOTIFYFLAGs
/usr/local/bin/ups-notify.sh   posts to Discord #power-failure, logs locally
/etc/nut/power-webhook.txt     the webhook, readable by nut (root:nut 640)
/var/log/ups-events.log        local event log, writable by nut (root:nut 664)

The privilege model — get this wrong and alerts fail silently

upsmon does NOT run NOTIFYCMD as root. It forks: a privileged parent that exists only to execute the shutdown, and an unprivileged child — running as user nut — that does the monitoring and runs your notification script.

ps -eo user,pid,ppid,comm | grep upsmon
root  1985     1  upsmon     # privileged parent, shutdown only
nut   1988  1985  upsmon     # THIS runs NOTIFYCMD

So every file the notify script touches must be reachable by nut:

File Required Why
/var/log/ups-events.log 664 root:nut nut must write it
the webhook 640 root:nut, in /etc/nut/ nut must read it

A webhook at ~/.config/discord-webhooks/power.txt (mode 600, owned by aztechguy) is unreadable by nut and the script will exit before it ever reaches the POST. That is exactly what happened between 2026-08-14 and 2026-08-18 — see the correction box at the top.

Webhook rotation now touches two files. Rotating #power-failure means updating ~/.config/discord-webhooks/power.txt and re-copying it:

sudo install -o root -g nut -m 640 \
     ~/.config/discord-webhooks/power.txt /etc/nut/power-webhook.txt

Miss the second and alerting dies quietly again.

logrotate will undo this. If a rotate config is ever added for ups-events.log, it must carry create 664 root nut — the default recreates the file root:root and re-breaks alerting with no visible symptom.

Firing a test alert without an outage — and why it proves less than it looks

sudo NOTIFYTYPE=ONBATT /usr/local/bin/ups-notify.sh "TEST -- simulated, no actual outage"
sudo NOTIFYTYPE=ONLINE /usr/local/bin/ups-notify.sh "TEST -- simulated restore"
tail -4 /var/log/ups-events.log

⚠️ This test runs as ROOT. Production runs as nut.

That is the whole trap, and it cost four days of silent alerting. Invoking the script with sudo gives it privileges upsmon never grants it. It will read a mode-600 webhook and write a root-owned log happily — and then fail on both when a real event arrives.

A green result here says the script’s logic is sound. It says nothing about whether the script can run at all in production.

To test as nut actually does:

sudo -u nut NOTIFYTYPE=ONBATT /usr/local/bin/ups-notify.sh "TEST -- as the nut user"
tail -3 /var/log/ups-events.log

If that lands in #power-failure with posted OK, the permissions are genuinely correct. If it doesn’t, the script is broken in production no matter what the sudo version did.

Each should land in #power-failure with live battery, runtime, load and voltage, and the local log should record posted OK.

Neither version tests the shutdown. They prove Discord works. They prove nothing about whether upsmon reacts correctly to a real power cut.

The only fully honest test is pulling the plug, which drives the real state machine and calls the script the way production will. Done 2026-08-18 — see below.

The events, and the flag that matters

Event Meaning
ONBATT mains lost, running on battery
ONLINE mains restored
LOWBATT low-battery threshold — shutdown imminent
SHUTDOWN / FSD shutting down now
REPLBATT battery needs replacing — the 3-5 year sleeper
COMMBAD / COMMOK / NOCOMM lost / regained / never had contact with the UPS
NOTIFYFLAG ONBATT SYSLOG+WALL+EXEC

EXEC is what runs the script. Omit it from a line and that event is logged to syslog and silently never alerts. It is the easy thing to get wrong, and it fails quietly — which is the worst combination.

Why the script always writes to a local log

/var/log/ups-events.log is written before the Discord attempt and regardless of whether it succeeds. During a power cut the internet is often the first thing to go, and an alert that only exists if the network survives is not a record of what happened.


Testing it for real

Only meaningful with the battery at 100% (ups.status: OL, no CHRG).

  1. Watch it from the laptop, so you are not logged into the machine you are testing:
    ssh media-server 'watch -n 2 "upsc cyberpower | grep -E \"charge|runtime|status\""'
    
  2. Pull the UPS mains plug.
  3. Within ~5 seconds expect ups.status: OB DISCHRG, an ONBATT message in #power-failure, and a wall broadcast.
  4. Plug back in — expect OL and an ONLINE alert.

To test the shutdown as well, leave it on battery until the low-battery threshold. At ~9% load that is over an hour, so drive it artificially only if you have a reason to.

A UPS configuration that has never been tested is a guess. Do it while standing beside the machine, not during a real outage.

Measured: the real pull, 2026-08-18

Mains pulled 06:13, restored 06:54. 40 minutes on battery, load steady at 9–10% (~79 W).

06:13  100%   ONBATT fires
06:16   78%   surface charge gone within 3 minutes of load
06:24   75%     0.38 %/min
06:32   70%     0.63 %/min
06:40   60%     1.25 %/min   <- rate more than TRIPLED
06:48   51%     1.13 %/min
06:52   47%     1.00 %/min
06:54   46%   ONLINE, mains restored

Do not extrapolate runtime linearly from battery.charge. The first ten minutes are the flattest part of the curve. Reading 0.4 %/min early and projecting forward predicted two hours to cutoff; the UPS’s own battery.runtime said ~51 minutes and was very nearly right. battery.runtime is the honest number — trust it over your own arithmetic.

Battery verdict: healthy. Projecting the observed rate, 47% → the 10% cutoff lands near 07:29 — roughly 76 minutes total against a predicted 62 minutes at 100%. It beat its own estimate by ~20%, with no sag and no cliff. The estimator errs conservative, which is the direction you want.

Thresholds this unit reports (upsc cyberpower):

battery.charge.low       10     LB asserted here
battery.charge.warning   20
battery.runtime.low      300    seconds (5 min)

Stop a deliberate test at 40% or 30 minutes, whichever comes first — well clear of the warning at 20%, since the clean-shutdown path is still unproven and a full drain would take the media server and the NAS down hard.

The ONLINE … input=0.0V transient

[ONLINE] UPS cyberpower@localhost on line power | charge=46% runtime=32 min load=10% input=0.0V status=OL DISCHRG

Not a bug. The script samples the UPS at the instant upsmon fires the event, which is mid-transition — the UPS had already declared OL before its input-voltage reading and load transfer caught up. Expect one contradictory-looking line per recovery.


Service management

systemctl status nut-server            # upsd -- publishes UPS data
systemctl status nut-driver@cyberpower # the driver that talks to the USB device
sudo systemctl restart nut-driver@cyberpower
sudo systemctl restart nut-server

Two separate services. nut-server can be running happily while the driver is dead — which produces Error: Driver not connected from upsc, and is the first thing to check.


Troubleshooting

Error: Driver not connected

The driver could not open the USB device. Almost always permissions, and almost always because the UPS was plugged in before NUT was installed — udev applies device rules when a device appears, so rules that arrive later were never applied to a device already present.

sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=usb --action=add
sudo systemctl restart nut-driver@cyberpower
sleep 4 && upsc cyberpower

Unplugging and replugging the USB cable does the same thing, since udev fires on the add event.

/dev/hidraw0 disappeared — is that bad?

No, that is expected. usbhid-ups claims the USB device through libusb, which detaches the kernel HID driver and removes /dev/hidraw0 and /dev/usb/hiddev0. Their absence means the driver has the device, which is the opposite of a fault.

Confirm the UPS is still on the bus:

lsusb -d 0764:            # CyberPower's vendor id
systemctl is-active nut-driver@cyberpower

The USB descriptor says the wrong model

lsusb reports PR1500LCDRT2U. That is a generic string baked into CyberPower’s HID product id 0764:0601, shared across many models — it is not what you own. NUT queries the device properly:

upsc cyberpower device.model     # CP1350PFCLCDa -- the truth
upsc cyberpower device.serial    # CX5RN7002465

Hardware facts worth not re-deriving

Model            CyberPower CP1350PFCLCDa
Capacity         1350 VA / 880 W · 12 A total output
Serial           CX5RN7002465
Connection       USB to media-server (front port)
NUT name         cyberpower       driver: usbhid-ups
On battery       modem · wireless router · media-server · UGREEN NAS

There is no network management card on this model. The RJ45 jacks on the back are a surge pass-through, not an interface — no IP, no web console. They are also commonly rated to only 10/100 Mbps, so running a gigabit link through them would quietly cost you 90% of your throughput. Leave ethernet out of the UPS; surge protection on an indoor patch cable buys nothing.

Remote monitoring on this unit is NUT over the network, which is what the “Network” in Network UPS Tools means — status shared between computers, not a network port on the UPS.


The BIOS setting that matters more than any of this

Restore on AC Power Loss → Power On

If an outage outlasts the battery, the server shuts down cleanly, mains returns — and it stays dark until someone presses the button. The default on most boards is “Stay Off.” Checked 2026-08-13: not yet set.