Twenty-five percent is a countdown

Update, 2026-11-02: The thresholds in this design (60, 35 and 15 % for the three tiers, with the first tier also acting after 5 minutes on battery) are no longer the deployed ones. Since 2026-11-02 all three tiers act at 15 % or 8 minutes of runtime, with no on-battery timer. Why is in Three shutdowns, three causes.

The NUT client's man page has a sentence that decided most of this design before any of it was drawn:

A dead UPS that was last known to be on battery is assumed to have changed to a low battery condition.

"Dead" means the client couldn't fetch status for DEADTIME seconds, 15 by default. Low battery means shut down. So whatever answers the hosts during an outage has to stay up and keep answering, because the moment it stops, every host on battery starts shutting down about fifteen seconds later.

When I wrote this down, none of it was built. It is the design for the last piece of the chain, from the ESP32 bridge that publishes the AC180's state over MQTT to the machines that should power off before the battery does, and how the decision got made.

Three ways to tell a host the grid is gone#

The bridge publishes a reading every 20 seconds. Nothing consumes it yet. I started with two ideas, and laying out the options added a third:

  • Broadcast. Push the state to everyone, over MQTT or plain UDP, about once a minute with a pessimistic estimate of the charge left. Each host decides for itself whether to shut down now or wait for the next reading.
  • Poll. Hosts ask something on the router, or the bridge itself, for the current state.
  • NUT. The Network UPS Tools protocol, which almost every OS and NAS already speaks as a client.

The answer is a hybrid: publish a pessimistic runtime estimate with every reading, and let a host count down from the last one if the readings stop mid-outage. Said loosely, that sounds like a host should assume the worst once the data goes quiet. It's the opposite. A host that last heard 25 % doesn't need fresh data to know it has a while. Dead reckoning, not panic.

The arithmetic agrees. The design's estimate takes the charge, subtracts a step for rounding and a 10 % reserve, and assumes the inverter delivers 82 % of the battery's 1,152 Wh, below the 1,007 Wh PCWorld measured, about 87 %. At the roughly 150 W this setup draws, plus 15 W for the station itself:

(25 − 1 − 10) % × 1152 Wh × 0.82 ÷ 165 W ≈ 0.80 h ≈ 48 min

That's the runtime. The most critical tier only needs to act at 15 %. With the same rounding step taken off, that leaves nine points of charge to spend, which at the same load is about 31 minutes. A host on the last tier can go half an hour without data before it has to act.

Then I picked a side: NUT, with one observer that knows which hosts go down at which battery level. The hosts keep their stock clients, and the observer does the thinking.

The first sketch followed within a minute: one virtual UPS per tier, the observer setting low battery on a tier when that tier's threshold is reached, and a preference for an observer that speaks NUT itself rather than feeding NUT's own server through files, which can freeze.

Five angles on one design#

I didn't want that built straight away. First the design got worked through from five angles, each with the whole picture:

  1. the NUT protocol, and which clients would accept a homemade server;
  2. the safe route, real upsd with its dummy-ups driver, tested in Docker;
  3. the runtime estimate and the shutdown tiers;
  4. failure modes and security;
  5. a devil's advocate on the whole architecture and its alternatives.

The sketch survived, tested and filled in, and the picture above is where it ended up. A Go program, the observer, would run in a container on the router, next to the MQTT broker, on the router's SSD and backup power. It would read the bridge's state, run the policy, and answer NUT on port 3493. Each station would get one virtual UPS per tier, ac180-t1 to ac180-t3, and a host would only watch its own tier.

Real upsd froze in a Docker test#

The obvious way to serve NUT is the real thing: NUT's own upsd, fed by dummy-ups, a driver that reads UPS state from a file. The observer would just write the file. That route went into a container, to see what happens when the observer stops writing:

== after file change (3s)
OB DISCHRG
80
== no further writes for 40 s (observer 'dead')
OB DISCHRG

The tier went to battery within three seconds of the file changing. More than forty seconds after the last write, it still said battery. The driver was alive and still serving its file, so upsd's staleness timer never fired, and nothing ever added low battery. Every host on that tier would have waited for a shutdown signal that couldn't come, on a battery that would eventually run out.

A watchdog that kills a tier's driver when the observer's heartbeat goes stale would fix it. Stopping a driver by hand showed the effect: the tier answered Driver not connected, and the other tier stayed online. But it makes three moving parts: the drivers, the watchdog and the observer. A Go program that speaks NUT itself has one. If it dies, clients get connection refused, and the man page's rule takes over: last known on battery, so low battery, so shut down. That's harsh, but it's a failure NUT clients already handle correctly.

The other half of the argument: every public bridge between the two protocols that turned up in the search goes the other way, publishing NUT state to MQTT. Nothing off the shelf turns this bridge's messages into NUT. The real upsd route stays in the design as a fallback, in case some client refuses the homemade server.

The other rejected options, briefly. NUT inside the ESP32 itself would put Bluetooth, Wi-Fi, MQTT and the shutdown server in one failure domain, and the second station would need its own. A small client on every host leaves out the NAS and Windows. UDP broadcast can be forged unless it's signed, and every host would need custom software to hear it.

Where the angles disagreed#

DEADTIME. The protocol and architecture reviews kept pointing at the 15-second default: if the observer restarts during an outage, every client on battery shuts down. The policy work wanted 300 to 600 seconds on T2 and T3, so a container restart doesn't take the house down, and the default on T1. The design settled on at least 300 seconds on any client that allows changing it. A NAS may not allow it, and that's on the list to check.

NAS boxes that can't choose a UPS name. Third-party integration docs and forum threads say that Synology's NUT client is fixed to a UPS called ups with built-in credentials, and QNAP's to qnapups. Neither can say ac180-t2. One fix is a separate router IP per tier. The other is to read the client's source IP and map it to a tier. The design took the second, and the mapping lives in the host file below. Neither has been tried on a real NAS yet.

Who comes back. The end of an outage has a problem of its own. A classic NUT setup cuts the UPS output after the hosts shut down, and a BIOS setting powers them on when the output returns. The AC180 can't be told to cut its outlets, and they stay live through the whole outage. The hosts never see power disappear, so they never come back by themselves. The observer will send Wake-on-LAN instead, critical tier first, once the charge is 15 points above each tier's threshold, to wired network cards only.

What I decided#

That left questions, and I answered them the same evening:

  • The host list. I wanted a file, not a list kept by hand. Hosts pick their own tier through the UPS name they monitor. A hosts.yaml on the router's SSD holds only the exceptions: NAS addresses mapped to tiers, and Wake-on-LAN addresses and order. It has to stay out of the guest-shared media/ folder, where anyone on the LAN could rewrite the tiers.
  • Thresholds. The defaults stay: 60, 35 and 15 % for the three tiers, with 15 % as the floor on the AC180.
  • A grid canary. The design suggested a smart plug on mains only, to tell the observer whether the grid is back while the bridge is silent. I'm not buying one. That leaves the design's other canary option: ping a mains-only device on the LAN, and if it goes silent while the router stays up, the grid is down. Which device is still open; my old smart plug, reachable through Home Assistant, is one candidate.
  • Power for the bridge. Tested the same evening and covered in Pulling plugs on purpose: a power bank plus the PC's USB port. A twelve-second reboot gap remains only if an outage starts while the PC is already off, and I accepted it. The observer has to treat a short offline/online flap at the start of an outage as normal.

Nothing was built yet#

The plan has six phases. The first real one is an observer serving a single UPS, one low-battery threshold, and a stock upsmon on one PC with its shutdown command replaced by a log line, all driven by a simulated feed. No battery gets drained to test it.

Open before that: whether the router's port forwarding keeps the clients' source addresses, which the NAS mapping depends on; what Synology and QNAP actually do on current firmware; whether the AC180 turns its outlets back on by itself after running completely flat; and which device becomes the canary.

Filed under: ups, power, nut, mqtt, mikrotik, design

Sources#

  • upsmon.conf(5) — DEADTIME, and the rule that a dead UPS last seen on battery counts as low battery.
  • dummy-ups(8) — the file-driven driver in the Docker test, and its re-read-on-change behaviour.
  • instantlinux/nut-upsd — the NUT image used for the Docker test and kept as the fallback.
  • PCWorld's AC180 review — measured usable energy (1,007 Wh at 200 W), above the 82 % the estimate assumes.