Main

Sizing a UPS for a Network Closet: Measuring Real Runtime for a Pi, a Switch, and a CPE with Network UPS Tools

Why nameplate sizing fails in a network closet

A Pi, a small managed switch, and a CPE (the ISP’s modem or router) together draw far less than the nominal rating of almost any UPS you’d put in a closet. That means the binding constraint is rarely capacity. It’s runtime at low load — a regime where vendor runtime charts are least useful and where the UPS’s own battery.runtime estimate is a hypothesis, not a measurement.

So the sizing question isn’t “what UPS is big enough.” It’s “how long does this specific load actually run, and where is the low-battery point that sets my shutdown budget?” The only honest way to answer that is to read what the UPS reports, then verify it with a logged discharge test.

This procedure assumes a local-first stack: a Home Assistant host (the Pi) running the NUT server, a switch, and a CPE on the same UPS. It uses NUT’s read-only upsc client to capture the UPS’s own variables before and during a controlled test. It does not assume any particular runtime figure — those depend on your hardware and battery condition, and you should measure them.

Step 1: Inventory the load and decide what must stay up

Before touching the UPS, list every device plugged into it and classify each one:

  • Must stay up: the Pi running Home Assistant and the NUT server, the switch (without it, nothing on the LAN is reachable), and the CPE (without it, remote access and WAN are gone).
  • Can drop: anything else — a spare PoE injector, a USB hub, a status display.

This matters because the Pi is both the thing being protected and the thing running the monitoring. If the Pi loses power first, you lose visibility into the outage. The switch and CPE are what keep remote access alive. Size and sequence the shutdown around that dependency, not around total wattage.

If you haven’t already catalogued which small systems quietly run your week, the audit approach in How to Audit the Small Systems That Quietly Run Your Week is a useful starting point for this inventory.

Step 2: Read what the UPS already reports

NUT’s upsc is a read-only client that polls a UPS server and prints variables. It’s designed for scripts and one-off checks, and it can emit JSON so you can log it cleanly. From the NUT manual: “upsc is provided as a quick way to poll the status of a UPS server. It can be used inside shell scripts and other programs that need UPS data but don’t want to include the full interface.” (upsc(8))

Start with the full variable dump:

upsc myups@localhost

Or as JSON, which is easier to diff and archive:

upsc -j myups@localhost

The manual confirms upsc -j <ups> returns a JSON object of all variables and their values.

The variables that matter for sizing, per NUT’s variable naming scheme (NUT User Manual, Appendix C.4):

  • ups.load — load on the UPS in percent (example value in the manual: 023.4).
  • ups.realpower.nominal — nominal real power in watts (example: 300).
  • battery.runtime — remaining battery runtime as estimated by the device. Home Assistant’s NUT integration documentation describes this sensor as “Remaining battery runtime as estimated by the device” (Home Assistant NUT integration).
  • battery.charge — battery charge in percent.
  • ups.status — status tokens such as OL (online), OB (on battery), LB (low battery).

Two cautions. First, battery.runtime is an estimate from the device’s firmware, not a measurement. Treat it as a hypothesis. Second, ups.load is a percentage of the UPS’s rated capacity, not watts — to get an approximate wattage you’d multiply by ups.realpower.nominal, but that assumes the UPS’s percentage is accurate at low load, which is exactly the regime where it’s least trustworthy. Don’t build a budget on it.

Step 3: Build a repeatable baseline log

Before any test, capture a timestamped baseline so pre-test and post-test readings are comparable. A simple loop writing JSON to a file is enough:

while true; do
  printf '%s ' "$(date -Is)"
  upsc -j myups@localhost
  sleep 30
done >> ~/ups-baseline.log

Run this for a few hours on normal mains power. You want to see whether ups.load and battery.runtime are stable or drifting. If battery.runtime wanders by more than a few percent at steady load, that’s a sign the estimate is noisy and you should trust the discharge test even more.

Keep this log. It’s the reference you’ll compare against after battery replacement.

Step 4: Run a controlled discharge test

This is the part that actually answers the sizing question. The goal is to find the point where ups.status reports LB (low battery) under your real load — because that point, not the full runtime, is what sets your shutdown budget.

NUT’s upsmon treats ONBATT as the event meaning the UPS is on battery and LOWBATT as the event meaning the UPS battery is low as determined by the driver (upsmon(8)). The driver’s determination is what you’re measuring.

Procedure:

  1. Confirm the baseline log is running and the Pi, switch, and CPE are all on the UPS.
  2. Pull mains power to the UPS (unplug it from the wall, or use a switched outlet). Do not power-cycle the devices.
  3. Log ups.status, battery.charge, and battery.runtime at a fixed interval — 30 or 60 seconds is fine.
  4. Watch for ups.status to include OB, then later LB. Record the elapsed time to each.
  5. When LB appears, restore mains immediately. Do not run the battery to shutdown unless you intend to test the shutdown path too.

What you get is a measured curve: time on battery vs. battery.charge vs. the UPS’s own battery.runtime estimate. The gap between the estimate and the measured time-to-LB is the number you care about. If the estimate says 40 minutes and you hit LB at 22, the estimate was optimistic for your load and battery age.

Measure it yourself — don’t take a vendor chart’s word for it.

Step 5: Derive the shutdown budget from the measured LB point

Your usable runtime is not the full discharge time. It’s the time from OB to LB, minus the time needed to gracefully shut down the Pi and any secondaries, minus a margin.

NUT’s upsmon is the process that actually initiates shutdown on the host it runs on. It monitors the UPS, and when the UPS goes critical (on battery and low battery), it invokes the local shutdown command. The manual notes that upsmon deals with secondaries that hang using the HOSTSYNC timer, which defaults to 15 seconds, and that FINALDELAY defaults to 5 seconds (upsmon(8)).

So a rough budget looks like:

usable_runtime = time_to_LB − shutdown_time − margin

Where shutdown_time is how long your Pi actually takes to halt (measure it — it’s usually seconds, not minutes, but a Home Assistant host with a database write can take longer), and margin is your tolerance for the estimate being wrong. If your measured time-to-LB is 22 minutes and shutdown takes 30 seconds, you have roughly 21 minutes of usable runtime before you must have initiated shutdown. Set your trigger well inside that.

Note what upsmon does not do: it shuts down the host it runs on. It does not shut down the switch or the CPE. Those are downstream network devices with no NUT client. If you need them to power off cleanly, that’s a separate mechanism — a switched outlet, a PDU with outlet control, or accepting that they drop when the UPS dies. For most closets, accepting the drop is fine; the switch and CPE are stateless enough that an unclean loss is a reboot, not a corruption. State that tradeoff explicitly rather than pretending NUT covers it.

Step 6: Wire the result into Home Assistant

Home Assistant’s NUT integration talks to a NUT server over the NUT protocol; it cannot communicate directly with a UPS, so the Pi running upsd is the required intermediary (Home Assistant NUT integration).

Two configuration facts that affect reliability:

  • The integration polls by default once every 60 seconds. That’s your worst-case detection latency for a state change. If you need faster reaction, set a custom polling interval, but don’t set it so low that you hammer the UPS’s USB link.
  • Home Assistant cannot automatically detect a NUT server IP address change. The documentation recommends a static IP, a DHCP reservation, or DNS. Do one of those — a Pi that gets a new lease after a reboot will silently break your UPS sensors.

Expose battery.runtime and ups.status as sensors, and trigger notifications on the ONBATT and LOWBATT events. The integration documentation includes YAML examples for exactly this: a notification when the status sensor changes to “On Battery, Battery Discharging,” and one when battery charge drops below a threshold.

One subtlety worth knowing: Home Assistant MQTT sensors only update their last_updated timestamp when the state value changes. If you bridge NUT to MQTT (rather than using the native integration), a status like OL can sit unchanged for days, so a dead bridge looks healthy. The community ha-ups-mqtt bridge addresses this by publishing a heartbeat sensor on every poll (ha-ups-mqtt). If you use the native NUT integration instead, the 60-second poll gives you a similar liveness signal — but only if you’re watching for it.

Graceful degradation when the pieces fail

The procedure above assumes the USB link between the Pi and the UPS stays up. It won’t always. Three failure modes to plan for:

  1. The UPS USB link drops. upsmon emits COMMBAD when communication with the UPS is lost, and NOCOMM when the UPS can’t be contacted for monitoring (upsmon(8)). If you see NOCOMM during an outage, you’ve lost the ability to trigger a graceful shutdown from NUT. Your fallback is a timed shutdown — upssched can schedule a shutdown based on the “on battery” event, though the manual calls this “complicated and messy” and recommends sticking to default critical UPS handling if you can.
  2. The Pi loses network. If the Pi is the NUT server and it’s also the Home Assistant host, a network loss doesn’t stop local shutdown — upsmon runs on the same host. But you lose remote visibility and any secondary hosts lose their NUT feed. The switch and CPE stay up on battery, so the LAN may still work; the Pi’s own network stack is what’s down.
  3. The Pi loses power before the UPS does. This is the worst case: the UPS is still supplying the switch and CPE, but the monitoring host is gone. Nothing triggers a graceful shutdown because there’s nothing left to trigger it. The mitigation is to make sure the Pi’s power draw is small enough that it’s the last thing to matter, and to accept that if the Pi dies, the switch and CPE will ride the battery until it’s exhausted. That’s a reboot, not a catastrophe, for those devices.

If your tolerance for an unclean switch/CPE drop is low, the honest answer is that a UPS alone doesn’t solve it — you need outlet-level control (a PDU with NUT support) or a separate shutdown agent on those devices, which most consumer switches and CPEs don’t have. A mechanical timer or a plain switched outlet is sometimes the smarter choice for a closet where the only goal is “keep the Pi alive long enough to halt cleanly.”

The maintenance task: re-run the test annually and after battery replacement

The sizing decision is only honest for the battery’s current condition. Lead-acid batteries in a UPS degrade; a runtime that was 22 minutes at installation may be 12 minutes two years later. The procedure above is repeatable for exactly this reason.

Re-run the logged discharge test:

  • Annually, as a baseline check.
  • After any battery replacement, to confirm the new battery meets the original measured runtime.
  • After any change to the load — adding a device, swapping the switch, changing the CPE.

Compare the new time-to-LB against the archived baseline. If it has dropped by more than your margin, either replace the battery or reduce the load. Record the delta. That log is the thing that keeps the sizing decision from quietly becoming wrong.

FAQ

Can I trust battery.runtime instead of running a discharge test?

No. NUT and Home Assistant both describe it as an estimate from the device. It’s a useful starting hypothesis and a useful live indicator during an outage, but it is not a measurement of your load’s actual runtime. The discharge test is what turns the estimate into a known number.

Does NUT shut down my switch and CPE?

No. upsmon shuts down the host it runs on. Downstream network devices without a NUT client will drop when the UPS battery is exhausted. If you need them to power off cleanly, that requires outlet-level control or a separate agent.

How often does Home Assistant poll the NUT server?

Once every 60 seconds by default. You can set a custom polling interval, but keep it reasonable — the UPS’s USB link is not a high-throughput interface.

What if the UPS USB link drops during an outage?

upsmon reports COMMBAD and then NOCOMM. You lose the ability to trigger a graceful shutdown from NUT. A timed shutdown via upssched is the fallback, but the NUT manual describes it as complicated and recommends default critical handling where possible.

Is a UPS even the right tool for a Pi, switch, and CPE?

If the goal is “keep the Pi alive long enough to halt cleanly and keep the LAN reachable during brief outages,” yes. If the goal is “ride out a multi-hour outage,” a UPS sized for a closet won’t do it, and a mechanical timer or a plain switched outlet may be the more honest choice for the non-critical devices.