Skip to content
Walid Karray
Go back

My UPS Lies: Setting Up NUT on a PowerWalker VI 750 R1U

Updated:

The PowerWalker VI 750 R1U UPS in the bottom 1U slot of the rack, its front display lit, with the Synology DS723+ NAS on the shelf above it

I plugged the UPS’s USB cable into the Proxmox host and set up NUT, the Network UPS Tools. It turns out the UPS reports several things that are simply not true, and one of them would have defeated the whole point of the exercise.

The setup

The UPS is a PowerWalker VI 750 R1U: 1U, line-interactive, 750 VA / 450 W, two 6 V 9 Ah lead-acid blocks inside. It feeds everything in the rack. The rack holds a ThinkCentre running Proxmox VE (the host is called pve), a Synology NAS, a switch, a small router and an IP-KVM. All together they draw about 14% of the UPS’s capacity, around 60 W. The whole lab is described in How I Run My Homelab in 2026.

The plan is the classic NUT layout:

Hand-drawn diagram of the NUT layout: the UPS connects by USB to pve, where the usbhid-ups driver feeds upsd and upsmon runs as primary; the NAS runs upsmon as secondary over the network and can read the UPS but not command it

On low battery, pve’s upsmon raises the forced-shutdown flag (FSD), waits for the NAS to let go, shuts pve down (Proxmox stops every guest cleanly on the way), and as its very last act tells the UPS to cut its outlets a little later.

The host runs Debian 13, which ships NUT 2.8.1. Everything is applied with Ansible, so the config below is what’s actually on the box.

What upsc says

The first upsc ups looked fine at a glance. It took a second look to notice how much of it was wrong.

Output of upsc for the PowerWalker VI 750 R1U before any override, with battery.charge.low at 0, output.voltage at 25.2, ups.beeper.status disabled, device.mfr "1" and input.voltage.nominal 0 highlighted
What the UPS reported out of the box, before any override.

Going down the list:

output.voltage: 25.2. The UPS’s own display shows 230 V. This one turns out to be a known NUT bug for this family of devices. The USB vendor ID is 0764, which is CyberPower’s, so NUT picks its CyberPower subdriver, and that subdriver decodes this value wrong on this model, because of a bad range in the device’s HID descriptor. It was fixed upstream in nut#2718, after 2.8.1. More on that below.

ups.beeper.status: disabled. It beeps. upscmd ups beeper.disable answers OK, and nothing happens. The only way to silence it is to hold MUTE on the front panel for three seconds. The variable is left out of monitoring.

input.voltage.nominal: 0 and device.mfr: 1. Harmless, just odd. I dumped the raw HID reports later, and the device really does send ConfigVoltage = 0, so that one is on the firmware, not on NUT.

battery.charge.low: 0. This is the one that matters.

The low-battery flag that comes too late

NUT’s whole shutdown logic hangs on one status flag, LB (low battery). When ups.status contains OB LB, on battery and low, upsmon starts the shutdown. By default usbhid-ups raises LB when the UPS’s own HID flags say so, and the threshold the UPS publishes for that is battery.charge.low.

This UPS says the threshold is zero. Taken at its word, it raises LB when the battery is already empty. Maybe the firmware has a voltage-based cutoff that would fire earlier, as many units do; I didn’t want to find out during a real outage. Proxmox takes more than three minutes to stop my guests cleanly (I measured it, see below), the NAS needs its own time, and none of that happens on a battery that just hit 0%.

The fix is to stop trusting the UPS’s flag and let the driver compute its own:

# /etc/nut/ups.conf
[ups]
	driver = usbhid-ups
	port = auto
	vendorid = 0764
	productid = 0601
	desc = "PowerWalker VI 750 R1U"
	ignorelb
	override.battery.charge.low = 40
	override.battery.runtime.low = 300
	offdelay = 120
	ondelay = 180

With ignorelb, the driver ignores the device’s own LB and raises it itself as soon as battery.charge drops below battery.charge.low or battery.runtime drops below battery.runtime.low, whichever comes first. You can see it in the driver’s startup log:

using 'battery.charge' to set battery low state
using 'battery.runtime' to set battery low state

I set both thresholds on purpose, because battery.runtime on this unit jumps around. It’s computed from the instantaneous load, and in a homelab the load is never still: sitting on mains, it went from 3300 seconds down to 1080 and back within the same hour, every time a VM got busy and the load went from 15% to 24%. A runtime threshold alone would fire on a load spike, or too late after a quiet stretch. The charge reading moves much more smoothly. 40% is a wide margin for a lab that draws 60 W.

One trap when you change these: the driver supports a live reload, and it will happily tell you it completed one, but a live reload can’t change these values. The log says so if you look:

dstate_setflags: base variable (battery.charge.low) is immutable
dstate_setflags: base variable (battery.runtime.low) is immutable
Completed requested live reload of NUT driver configuration for [ups]: 0

The driver has to be restarted, and restarting it has its own trap.

Don’t restart the driver while on battery

The day I set this up, I pulled the UPS’s mains plug to watch it switch to battery, and while it was unplugged I applied the next change with Ansible. That change restarted the driver. Here’s upsmon’s point of view:

08:32:25 UPS ups@localhost on battery
08:36:35 Poll UPS [ups@localhost] failed - Driver not connected
08:36:35 Communications with UPS ups@localhost lost
08:36:35 Client [email protected] set FSD on UPS [ups]
08:36:35 Executing automatic power-fail shutdown
08:36:35 Auto logout and shutdown proceeding

upsmon is doing exactly what it should. The last thing it knew was “on battery”, and now it can’t reach the UPS at all. On battery plus no news is treated as critical, because from where it sits the battery could be empty. So it started the shutdown.

What saved me was luck. As a primary, upsmon first waits for its secondaries to log out (HOSTSYNC, 15 seconds here) before it runs the shutdown command. One second into that wait, the next Ansible handlers restarted upsd and then upsmon. The upsd restart cleared the FSD flag, so the new upsmon had no idea that a shutdown had been started. If only upsmon had been restarted, it would have found FSD set on the server and shut the host down anyway. The host stayed up.

The full test

Then I ran the full test: mains unplugged, nothing touched.

Battery charge during the full test: from 100% the charge falls steadily for 26 minutes to the 40% threshold, the forced shutdown fires, the series stops while pve is off, then the charge climbs back from 48%
The data comes from upsd through nut_exporter into Prometheus.

At about 60 W, the charge went from 100% to 40% in 26 minutes. Keep in mind that this percentage is estimated from the battery voltage, and under load the voltage sags, so 40% under load is not 40% of the real capacity left. It’s a reading, not a fuel gauge, which is one more reason to keep the margin wide.

At 40% the driver raised LB, and in the same second the NAS logged out, upsmon set FSD, and pve began shutting down:

13:34:09 User monuser@… logged out from UPS [ups]
13:34:09 Client [email protected] set FSD on UPS [ups]
13:34:09 Executing automatic power-fail shutdown

Hand-drawn diagram of the shutdown sequence: at 40% the driver raises LB and FSD follows; the NAS logs out and shuts down while Proxmox stops the guests in 3 min 40 s, pve powers off, and after a 120 s offdelay the outlets go off; when mains return, the ondelay switches the outlets on, the NAS boots by itself and pve stays off until powered on through the IP-KVM

Then came the part I hadn’t measured before. Proxmox started stopping the guests at 13:34:17 and the last one was down at 13:37:34. The host powered off at 13:37:49: three minutes and forty seconds after FSD. That’s the real number behind the 40% threshold.

offdelay doesn’t start at FSD. On Debian, the very last step of the shutdown is the nutshutdown hook in /lib/systemd/system-shutdown/, which sends the kill-power command to the UPS, and that’s when the 120 seconds start counting. So the outlets went dark two minutes after pve was off, and the NAS, which had started its own shutdown at 13:34:09, had close to six minutes in total.

When the mains came back, the UPS waited for ondelay and powered the outlets again. The NAS boots by itself when power returns. pve doesn’t: its firmware is set to stay in standby after an AC loss. That’s deliberate: I power it on from the IP-KVM. If you want yours to come back on its own, that’s the BIOS setting to change (“after power loss: power on” or similar), and without it the outlet cycling is useless.

Once pve was back, the charge read 48%, higher than where it stopped. The battery didn’t recover anything: with the load gone, the voltage rose, and the estimate went up with it. It took several hours of charging to get back to full. And this kind of test isn’t free. Taking a lead-acid battery down to 40% wears it a little, so the full test is not a routine check; the quick test is.

This UPS counts offdelay and ondelay in whole minutes, so both are multiples of 60 seconds. ondelay must be longer than offdelay, so that the outlets are cycled even if the mains returns during the shutdown, and a machine that boots on AC return comes back.

The Synology side

Synology’s DSM can act as a NUT client, but on its own terms. Under Control Panel → Hardware & Power → UPS, pick “Synology UPS server” (it means any NUT server), and give it the address of the host running upsd. DSM doesn’t ask for anything else, because it hardcodes the rest: the UPS must be named ups, and it logs in as monuser with the password secret.

So upsd.users on pve has exactly that:

[upsmon]
	password = <a real one>
	upsmon primary

[monuser]
	password = secret
	upsmon secondary

A public password feels wrong, but think about what the account can do: a secondary can read the UPS and be told to shut down. It can’t send commands or set the FSD flag. The account that can, upsmon on pve, has a real password. Even so, upsd only listens on localhost and on the lab’s own network (LISTEN in upsd.conf), not on every interface the host has.

Nobody gets to send commands

upscmd -l ups lists what this UPS accepts: battery tests, beeper commands (useless, see above), and load.off and shutdown.stayoff. The last two turn the outlets off. Everything in the rack, including the host that runs NUT, goes dark at once.

So no account in upsd.users has instcmds. When I do want to send a command, which in practice means the quick battery test, I add a throwaway account allowed to send that one command, send it, and remove the account in the same script, with a trap so the file is restored even if something fails:

CMD=test.battery.start.quick
P=$(head -c 18 /dev/urandom | base64 | tr -d "/+=")
cp /etc/nut/upsd.users /dev/shm/upsd.users.orig
trap 'cp /dev/shm/upsd.users.orig /etc/nut/upsd.users; systemctl reload nut-server' EXIT
printf '\n[once]\n\tpassword = %s\n\tinstcmds = %s\n' "$P" "$CMD" >> /etc/nut/upsd.users
systemctl reload nut-server
upscmd -u once -p "$P" ups "$CMD"

Thirty seconds later, upsc ups ups.test.result reads Done and passed. The quick test runs on mains and doesn’t drain anything. The deep test is a different matter: it drains the battery the lab would need if the power went out right after.

Testing NUT 2.8.5, and a UPS that forgot its USB configuration

Since output.voltage was fixed upstream, I wanted to know whether a newer NUT would fix the rest. I ran the 2.8.5 driver against the UPS by hand and compared it with Debian’s 2.8.1:

I posted these results on that issue, along with one side effect I hadn’t expected. After running the 2.8.5 driver once with debug on, the stock 2.8.1 driver couldn’t open the UPS anymore:

insufficient permissions on everything

The device node was owned by root:nut as it should be, so it wasn’t permissions. The UPS had been left without an active USB configuration: bConfigurationValue was empty, and the kernel logged can't set config #1, error -71. A USB reset through sysfs didn’t help, and neither did usbreset. Writing the configuration back by hand did:

for d in /sys/bus/usb/devices/*; do
  [ "$(cat $d/idVendor 2>/dev/null)$(cat $d/idProduct 2>/dev/null)" = 07640601 ] \
    && echo 1 > $d/bConfigurationValue
done
systemctl restart nut-driver@ups

I haven’t figured out whether that’s the 2.8.5 driver’s exit path or this UPS’s USB firmware, and I said so on the issue. If your PowerWalker suddenly stops talking after you experiment with drivers, that’s the line to try before you start replugging cables.

For now pve stays on Debian’s 2.8.1. With output.voltage and the beeper status out of the exporter, there’s nothing 2.8.5 would fix that I actually use.

Icons in the diagrams: Simple Icons (CC0) and Lucide (ISC).


Share this post:

Related posts

Follow new posts by RSS

Previous Post
My Homelab Is Written by Coding Agents
Next Post
I Got Tired of aws sso login. So I Built a Terminal Dashboard That Never Lets My Credentials Die