Power fails.
Your shutdown shouldn't.
Managing a Tripp Lite AVR900U from macOS with Network UPS Tools β drivers, daemons, launchd, automated shutdown, and the native macOS power stack.
1 Β· Mental model
A UPS is not a battery. It is a small computer with a battery attached, and managing it is a software problem: something on your Mac has to hear what the UPS is saying and act on it before the battery runs out. Everything in this guide is about building and trusting that listener.
Why UPS management exists
The AVR900U on your desk solves the easy problem by itself: when wall power dies, it switches to battery in milliseconds and your Mac never notices. But at 480 W of load its battery lasts 2.7 minutes. The hard problem is what happens in minute two of a long outage. Without management software, the battery empties, the UPS drops its outlets, and your machine loses power mid-write β exactly the event you bought the UPS to prevent. The UPS only buys time; software has to spend that time well, by saving state and shutting the machine down cleanly while power remains.
That is the entire job description: detect the power event β decide how bad it is β shut down cleanly before the battery is gone. Every tool in this guide is one link in that chain.
The two stacks on your Mac
The AVR900U speaks USB HID "Power Device Class" β the same protocol family a laptop battery uses. Because of that, two independent software stacks on your Mac can manage it, and understanding their relationship is the single most load-bearing idea in this guide:
- The Apple stack (built in). When you plug in the UPS, macOS claims the HID device with its own drivers.
ioupsdandpowerdtrack it, a battery-style indicator appears,pmsetcan read it, and Energy Saver grows UPS shutdown options. Zero installation, minimal data, minimal control. - The NUT stack (installed). Network UPS Tools is the open-source lingua franca of UPS management: a hardware driver, a network server (
upsd), and a monitor (upsmon) that executes the shutdown decision. Rich data, scriptable, network-capable β but on macOS it must coexist with the Apple stack, which got to the USB device first.
usbhid-ups driver talks straight to the UPS over USB. On macOS, the kernel's HID drivers claim the device and will not let go β so NUT ships a macOS-specific driver, macosx-ups, that reads the UPS through Apple's power-management layer instead of fighting it. You run NUT on top of the Apple stack, not instead of it. This one fact explains most of the driver confusion in Β§5.
NUT's three-layer architecture
NUT splits UPS management into three processes with one job each. The separation looks like overkill for one Mac and one UPS β it exists because NUT also scales to server rooms where one UPS protects many machines β but you benefit from it even standalone, because each layer can be tested alone.
pmset. Everything downstream of the driver is identical to NUT on any other OS.| Layer | Process | One job | Config file |
|---|---|---|---|
| Driver | macosx-ups (via upsdrvctl) | Translate hardware state into NUT's standard variables (battery.charge, ups.status, β¦) | ups.conf |
| Server | upsd | Publish those variables over TCP (port 3493) to authenticated clients | upsd.conf, upsd.users |
| Monitor | upsmon | Watch ups.status, raise alerts, and run the shutdown when the battery is critical | upsmon.conf |
ioupsd keeps running and Energy Saver keeps its own shutdown rules. The better model: the Apple stack is the sensor, NUT is the brain you can program. They read the same hardware; only one of them takes orders from you. Β§10 teaches you to read both.
The vocabulary you need immediately
Three status tokens dominate every conversation about UPS state. They appear in ups.status, in upsmon's log lines, and throughout this guide:
- OL β online. Wall power is good; the battery is idle or charging. The normal state.
- OB β on battery. Wall power failed; you are now spending stored minutes. A countdown has started.
- LB β low battery. The battery is nearly empty. When upsmon sees OB + LB together, it triggers the shutdown. This pair is the tripwire the whole system is built around.
Watch the transitions, not the states: OL β OB is an event you want to know about; OB β LB is an event your Mac must act on without you. The simulator in Β§13 lets you run this sequence as many times as you like without touching a plug.
Sources: NUT user manual Β· macosx-ups(8)
2 Β· Prerequisite floor
This guide assumes a working macOS terminal habit and nothing about UPS software. Check yourself against the floor before continuing β everything above it is taught in place.
Hard prerequisites
- Shell basics. You can run commands, edit a file with
vim/nano/VS Code, and read a man page. - Homebrew. Installed and working; you know
brew install,brew list,brew services. - sudo. You have admin rights and understand why a daemon that shuts your Mac down needs root.
Soft prerequisites (taught just-in-time)
- launchd. macOS's init system. Β§8 introduces exactly the plist vocabulary you need β no prior experience assumed.
- The macOS unified log.
log show/log streamappear in Β§10 with worked commands you can copy. - Electrical units. VA vs W is explained in Β§3 where it matters (load sizing).
Self-assessment
You are ready if you can answer all four without looking anything up:
- What does
brew services listshow, conceptually? - What is the difference between a process run in your terminal and a daemon?
- Why would
sudo pkill -f upsdneedsudo? - Where do Homebrew's config files live on an Intel Mac? (
/usr/local/etc/β¦)
If item 4 surprised you: this guide's paths are for Intel Macs (Homebrew prefix /usr/local), matching the machine it was verified on. On Apple Silicon substitute /opt/homebrew throughout β nothing else changes.
3 Β· Know your hardware
You cannot size, test, or trust a UPS whose numbers you do not know. This section pins down what the AVR900U actually is β and what its marketing numbers mean for your shutdown deadline.
What the AVR900U is
| Fact | Value | Why it matters |
|---|---|---|
| Capacity | 900 VA / 480 W | The hard ceiling on connected load. Watts is the number that limits you in practice. |
| Topology | Line-interactive with AVR | Corrects brownouts/overvoltage via autotransformer without draining the battery β the battery is saved for real outages. |
| Outlets | 12 Γ NEMA 5-15R | Not all are battery-backed on AVR-series units β check the labels on the unit itself before trusting an outlet with your Mac. |
| Runtime, full load (480 W) | β 2.7 min | Your worst-case shutdown budget. |
| Runtime, half load (240 W) | β 10 min | The practical target: keep load near or below half. |
| Battery | 12 V VRLA (sealed lead-acid), replacement model RBC51 | User-replaceable; recharges 10 β 90 % in about 8 hours. |
| Interface | USB (HID Power Device Class) | Why macOS auto-recognizes it, and why driver choice on macOS is subtle (Β§5). |
VA vs W β the sizing trap
Watts measure real power your equipment draws; volt-amperes measure apparent power, which is always β₯ watts for the same load. Marketing leads with the bigger VA number. Size against watts: the AVR900U can carry 480 W, full stop. A Mac, a display, and network gear typically sit well under that β but measure rather than guess. system_profiler SPPowerDataType tells you your Mac's adapter rating; a $15 plug-through power meter tells you the truth for the whole desk.
What "line-interactive with AVR" buys you
Cheap standby UPSes do nothing until power fails. Online double-conversion units rebuild the sine wave continuously and cost 5Γ as much. The AVR900U sits between: its automatic voltage regulation boosts brownouts and trims overvoltage using a transformer tap, switching to battery only when voltage leaves the correctable range. For you this means two things: minor grid sag does not cycle (and age) the battery, and frequent audible relay clicks are a diagnostic signal β your wall voltage is wandering (Β§12).
4 Β· Install & verify
One formula installs the entire NUT toolbox β drivers for hundreds of UPS models, the server, the monitor, and the client tools. Your job in this section is smaller: confirm what you have and learn where everything lives.
brew install nut
Verify the install and pin the version. Both transcripts below are real output from the machine this guide was written on:
brew list --versions nut
nut 2.8.5
/usr/local/sbin/upsd -V
Network UPS Tools upsd 2.8.5 release
NUT 2.8.5 is the current stable release (April 2026). Homebrew tracks it closely, so brew upgrade keeps you current.
Where everything lives (Intel prefix)
| Path | Contents |
|---|---|
/usr/local/etc/nut/ | All configuration: nut.conf, ups.conf, upsd.conf, upsd.users, upsmon.conf, upssched.conf β each with a pristine .sample beside it |
/usr/local/sbin/ | Daemons: upsd, upsmon, upsdrvctl |
/usr/local/bin/ | Clients and drivers: upsc, upscmd, upsrw, upslog, plus 67 hardware driver binaries including macosx-ups, tripplite_usb, and usbhid-ups |
/usr/local/opt/nut/homebrew.mxcl.nut.plist | The launchd plist brew services uses β read Β§8 before trusting it |
ups.conf declares which UPS exists and which driver speaks to it. upsd.conf says where the server listens. upsd.users says who may connect and what they may do. upsmon.conf says what to monitor and what to do when it goes bad. (nut.conf only records the overall MODE β standalone for one Mac protecting itself.)
Set the mode
In /usr/local/etc/nut/nut.conf, one line matters:
MODE=standalone
Standalone means all three layers run on this Mac and protect this Mac. The other modes (netserver, netclient) exist for sharing one UPS across machines β out of scope for this guide, but the config you build here is the server half of that setup if you ever want it.
Sources: Homebrew nut formula Β· nut.conf(5)
5 Β· Choosing the driver
Three NUT drivers plausibly match a Tripp Lite USB unit, and picking by name leads you astray β the obvious choice, tripplite_usb, is wrong for this hardware on this OS. The right question is not "which driver mentions my brand?" but "who owns the USB device?"
The wrong question, then the right one
The wrong question: "Which driver is for Tripp Lite?" β NUT has four with the brand in the name, segmented by protocol generation, not by brand loyalty.
The right question: "What does my unit speak, and can the driver even reach it?" The AVR900U speaks USB HID Power Device Class β the modern, standardized protocol. And on macOS, the kernel claims HID power devices at plug-in, which changes the answer entirely.
Decision matrix
| Driver | Built for | On macOS with the AVR900U |
|---|---|---|
tripplite_usb | Legacy Tripp Lite units with USB product ID 0001 (protocols 0004/1001/2001/3003/3005 β OMNIVS, older SMART series) | Wrong unit. The AVR900U is a modern HID-class device; the tripplite_usb man page itself redirects HID-compliant units to usbhid-ups. |
usbhid-ups | Any USB HID PDC UPS β the standard choice on Linux, including for modern Tripp Lite | Right protocol, blocked path. macOS's kernel HID drivers claim the device, and NUT cannot detach them the way it can on Linux. Expect claim/permission failures. |
macosx-ups | macOS only: reads any UPS that Apple's own power layer recognizes | The working answer. If the UPS shows up in Energy Saver / pmset -g ps, this driver can serve it to NUT. Monitoring-only β see the trade-off below. |
upscmd). You trade telemetry depth for a driver that reliably coexists with the OS. For the actual mission β clean shutdown on outage β the variables it provides are sufficient.
Your working configuration
This is the live /usr/local/etc/nut/ups.conf from the verified machine β two global lines, then the device section:
user = you
group = admin
[myups]
driver = macosx-ups
port = auto
model = AVR900U
desc = "Tripp Lite AVR900U via macOS"
user = you,group = adminβ the account the driver drops to once started, and the group it stamps on its socket so upsd can reach it. Both are overrides of NUT's compiled-in defaults, and both are needed on this machine β the callout below explains why.[myups]β the UPS's NUT name; every later command addressesmyups@localhost.driver = macosx-upsβ the decision this section justified.port = autoβ required by NUT's config grammar; ignored by this driver (there is no port to open β Apple's layer is the port).model = AVR900Uβ a regex matched against the power-source name; only needed to disambiguate if several UPSes are attached, harmless otherwise.
user and group are both there
Homebrew's nut bottle is built with NUT's stock defaults: drop privileges to the account nobody, group nobody. But Homebrew's tree β /usr/local/var/state/ups, where the driver socket lives, and /usr/local/etc/nut β belongs to your user and the admin group, and nobody cannot enter either. user = you fixes that for the driver. It also trips a second mechanism: whenever the user is overridden, the driver hands its socket to the configured group so upsd can still connect. With group left at the default nobody, that hand-off is refused β you are not a member of nobody β and the driver starts anyway, with a warning that reads as harmless and is not. Real output from this machine before the fix:
sudo /usr/local/sbin/upsdrvctl -D start myups
Listening on socket /usr/local/var/state/ups/macosx-ups-myups
WARNING: Needed to fix group access to filesystem socket of this driver, but failed; run the driver with more debugging to see how exactly.
Consumers of the socket, such as upsd data server, can fail to interact with the driver and represent the device: /usr/local/var/state/ups/macosx-ups-myups
group = admin names a group you belong to and the group the socket directory already carries; the hand-off succeeds and the warning disappears. The rule this leaves you with: the whole NUT stack on this machine runs as your user. The driver learns that from ups.conf; upsd does not read these two lines and has to be told separately β Β§6 and Β§8 show where.
tripplite_usb -a myups -DD fails against this unit β not because NUT lacks Tripp Lite support, but because that driver targets a different protocol generation and macOS holds the device. Driver errors about claiming/opening USB devices on macOS are almost never about the UPS model; they are about which stack owns the hardware. Reach for macosx-ups before concluding anything is broken.
Sources: macosx-ups(8) Β· tripplite_usb(8) Β· usbhid-ups(8) Β· ups.conf(5)
6 Β· Manual bring-up
Before any launchd automation, bring the stack up by hand, one layer at a time, in foreground debug mode. When each layer runs where you can see it, failures point at themselves β and you learn the healthy transcript you will later compare sick systems against.
The bring-up order is the architecture
Driver first, then server, then queries. Each layer refuses to work without the one before it, so the order doubles as a diagnostic ladder: whichever step fails names the broken layer.
- Confirm macOS sees the UPS at all β the precondition for
macosx-ups:pmset -g psa line naming the UPS alongside any internal battery. With the UPS unplugged from USB you get only
Now drawing from 'AC Power'and the internal battery β that is this guide's own state, captured on a machine with the UPS detached. No UPS line here means no NUT until the cable is sorted (Β§12). - Start the driver in debug foreground (window 1):
sudo /usr/local/sbin/upsdrvctl -D start myupsthe driver announces
Listening on socket /usr/local/var/state/ups/macosx-ups-myupsand upsdrvctl reportssuccessfully finishedβ the driver has detached into the background, which is upsdrvctl's whole job (spawn, then exit). NoWARNING: Needed to fix group accessβ¦line: if you see one,group = adminis missing fromups.conf(Β§5). Errors about "no matching power source" mean themodelregex matched nothing. To watch the driver narrate in the foreground instead, run the binary directly:sudo /usr/local/bin/macosx-ups -a myups -DD. - Start the server in debug foreground (window 2) β as yourself, no sudo:
/usr/local/sbin/upsd -F -Dlines showing it reading
upsd.conf, thenlistening on 127.0.0.1 port 3493, thenConnected to UPS [myups]: macosx-ups-myups.-Fkeeps it in the foreground;-Dmakes it narrate.Why no sudo: started as root, upsd drops to NUT's compiled-in account
nobodybefore it enters the state directory β andnobodycannot enter/usr/local/var/state/ups, so it exits at once complaining it can't chdir there. Started as you, it stays you (its own log says so:Can not become_user(nobody): not root initially, remaining UID=501), and everything it needs β port 3493, the config files, the driver's socket β is already yours. If you must launch it from root, say who to become:sudo /usr/local/sbin/upsd -u you -F -D. - Query it (any window):
upsc myups@localhosta sorted dump of every variable the driver publishes β
battery.charge,battery.runtime,ups.status,device.model, and friends. Exact variables depend on what Apple's layer exposes for your unit; capture this healthy output somewhere, it is your baseline. - Ask pointed questions β the form you will script with later:
upsc myups@localhost ups.status upsc myups@localhost battery.charge upsc myups@localhost battery.runtime upsc myups@localhost device.modelups.statusprintsOLon wall power;battery.runtimeis in seconds. - Tear down: Ctrl+C in the upsd window;
sudo /usr/local/sbin/upsdrvctl stop myupsfor the driver (it detached in step 2, so there is nothing for Ctrl+C to hit); then confirm nothing lingers:ps aux | egrep "macosx-ups|upsd|upsmon" | grep -v egrepno output.
ups.status: OL from a stack you started, understood, and cleanly stopped.The failure signature worth memorizing
With upsd not running, every client fails identically. This is real output from this machine, captured with the stack down:
upsc myups@localhost
Error: Connection failure: Connection refused
Read it precisely: connection refused means nothing is listening on port 3493 β a upsd problem (or upsd never started). A different error, Driver not connected, means upsd is fine but the driver layer is down. Two errors, two layers, no ambiguity. This distinction does half your future debugging for you.
Sources: upsdrvctl(8) Β· upsd(8) Β· upsc(8)
7 Β· upsd & access control
upsd is a network server, which means it has opinions about who connects. For one Mac protecting itself the policy is short β listen on loopback only, one privileged account for the monitor β but you should understand each line you are trusting.
Listening: upsd.conf
The entire non-comment content of the working /usr/local/etc/nut/upsd.conf:
LISTEN 127.0.0.1 3493
Loopback only. No other machine can query this upsd, which is exactly right for standalone mode β the best network security is not being on the network. Port 3493 is NUT's IANA-registered port. If you one day serve LAN clients, you add a second LISTEN line with a LAN address; until then, leave it.
Accounts: upsd.users
upsmon must log in to upsd to be trusted with power-state decisions. One account, defined in /usr/local/etc/nut/upsd.users (password redacted here β the file on disk holds it in plain text):
[monmaster]
password = <your-password>
upsmon master
The upsmon master line grants the role, not just access: a master (called primary in NUT 2.8's newer terminology β both spellings work) is a machine drawing power from this UPS that coordinates the shutdown. The matching half lives in upsmon.conf:
MONITOR myups@localhost 1 monmaster <your-password> master
Read it as a sentence: monitor the UPS named myups on this machine, which feeds 1 power supply of mine, logging in as monmaster with this password, in the master role. The 1 matters on servers with redundant PSUs; on a desktop it is always 1.
sudo chmod 600 /usr/local/etc/nut/upsd.users /usr/local/etc/nut/upsmon.conf
Use a random string that appears nowhere else. It is a machine-to-machine credential; you will never type it again.
Sources: upsd.conf(5) Β· upsd.users(5) Β· upsmon.conf(5)
8 Β· Auto-start with launchd
A UPS monitor you start by hand protects you only on days you remembered to start it. launchd β macOS's init system β is how the stack becomes furniture: running after every reboot, restarted after every crash, no terminal involved. This is also where Homebrew's convenience story quietly falls short, so look before you lean on it.
What brew services actually starts
The plist Homebrew ships for nut, verbatim from /usr/local/opt/nut/homebrew.mxcl.nut.plist on this machine (boilerplate keys trimmed):
<key>ProgramArguments</key>
<array>
<string>/usr/local/opt/nut/sbin/upsmon</string>
<string>-D</string>
</array>
<key>RunAtLoad</key>
<true/>
upsmon only β the monitor. No driver, no upsd. A freshly rebooted Mac with brew services start nut has a monitor faithfully failing to connect to a server that never started. If you take only one fact from this section: the driver and upsd need their own launchd jobs, which Homebrew does not provide. You will write them below β it is ten minutes and you will understand every line.
launchd in ninety seconds
launchd supervises jobs, each described by a plist file. Files in /Library/LaunchDaemons/ run as root at boot, before login β the right domain for power management. The keys you need: Label (unique job name), ProgramArguments (argv as an array), RunAtLoad (start when loaded), KeepAlive (restart if it exits). One rule shapes the design: launchd runs foreground processes. Daemons that background themselves confuse it β which is why every command below carries -F, NUT's "stay in the foreground" flag.
The three jobs
launchd offers no startup ordering between jobs, and none is needed: each NUT layer retries until the layer below appears. Write these three files (owner root:wheel, mode 644 β launchd refuses anything looser):
1 Β· The driver β /Library/LaunchDaemons/org.nut.driver.plist. Note it runs the driver binary directly with -a myups (pulling settings from ups.conf), not via upsdrvctl, which would exit after spawning and make launchd restart it forever:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>org.nut.driver</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/macosx-ups</string>
<string>-a</string><string>myups</string>
<string>-F</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ThrottleInterval</key> <integer>30</integer>
</dict>
</plist>
2 Β· The server β /Library/LaunchDaemons/org.nut.upsd.plist. Same skeleton, different program β plus -u you, which is not optional (explained under job 3):
<key>Label</key> <string>org.nut.upsd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/sbin/upsd</string>
<string>-u</string><string>you</string>
<string>-F</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ThrottleInterval</key> <integer>30</integer>
3 Β· The monitor β /Library/LaunchDaemons/org.nut.upsmon.plist, replacing brew services so all three jobs live in one place, managed one way:
<key>Label</key> <string>org.nut.upsmon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/sbin/upsmon</string>
<string>-F</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ThrottleInterval</key> <integer>30</integer>
upsmon must start as root: it keeps a tiny privileged stub alive to run the shutdown command, drops the rest of itself to an unprivileged user, and cannot arrange that from a user session. Running it from /Library/LaunchDaemons satisfies this naturally. If you previously ran brew services start nut, stop it first: sudo brew services stop nut && brew services stop nut (clears both the root and user domains).
upsd is the mirror image. LaunchDaemons run as root, and upsd started as root drops to NUT's compiled-in nobody β an account that cannot enter Homebrew's state directory (Β§5), so the job exits immediately and KeepAlive respawns it every 30 s to exit again. -u you tells it to become you instead, which matches the driver (it takes the same instruction from user = in ups.conf) and the socket the driver leaves behind (owner you, group admin). Nothing upsd does needs root: port 3493 is unprivileged and every file it reads is yours. The driver job needs no such flag β it starts as root and reads its user from ups.conf. (launchd's own spelling is a UserName key set to you; either works. The flag keeps the reason visible next to the command.)
- Create the three plists above, then fix ownership and mode β launchd silently ignores world-writable or wrongly-owned daemons:
sudo chown root:wheel /Library/LaunchDaemons/org.nut.*.plist sudo chmod 644 /Library/LaunchDaemons/org.nut.*.plist - Validate the XML before loading β a typo here costs a confusing failure later:
plutil -lint /Library/LaunchDaemons/org.nut.*.plistOKthree times. - Bootstrap all three into the system domain (modern launchctl;
load -wis the legacy spelling):sudo launchctl bootstrap system /Library/LaunchDaemons/org.nut.driver.plist sudo launchctl bootstrap system /Library/LaunchDaemons/org.nut.upsd.plist sudo launchctl bootstrap system /Library/LaunchDaemons/org.nut.upsmon.plist - Verify each job is alive:
sudo launchctl print system/org.nut.upsd | grep -E "state|pid"state = runningand a pid. Repeat for the other two labels.state = spawn scheduledwith no pid means the job exits immediately and launchd is throttling its restarts β for upsd, the classic cause is a plist without-u you(Β§12). launchd reads the plist only at bootstrap, so after editing one:bootout, thenbootstrapagain. - Prove the stack serves data with nobody's hands on it:
upsc myups@localhost ups.statusOL - The only test that counts β reboot, log back in, and query again before touching anything else:
upsc myups@localhost ups.statusOL, from daemons you did not start this boot.
sudo launchctl bootout system/org.nut.<name>.KeepAlive dutifully restarts it every ThrottleInterval (30 s here) forever. Harmless but noisy in the logs. If you detach the UPS for a long period, boot the driver job out; the throttle keeps the loop cheap in the meantime.
Sources: nutupsdrv(8) Β· upsmon(8) Β· man launchd.plist, man launchctl (local)
9 Β· Automated shutdown
Everything so far was plumbing. This is the payoff: the sequence that runs at 3 a.m. while you sleep, taking your Mac from "power just failed" to "halted cleanly with minutes to spare." Build it, then test it on purpose, because an untested shutdown path is a hope, not a system.
The event chain, end to end
upsmon.conf β the decision lines
Beyond the MONITOR line from Β§7, these are the settings that shape behavior (values shown are NUT's defaults, sane for a desktop):
| Directive | Default | Meaning |
|---|---|---|
SHUTDOWNCMD | "/sbin/shutdown -h +0" | What the root stub executes when shutdown is required. On macOS /sbin/shutdown -h now is the canonical clean halt. |
POLLFREQ | 5 | Seconds between status polls while online. |
POLLFREQALERT | 5 | Poll interval once on battery β tighten to 1β2 s if you want faster LB reaction. |
MINSUPPLIES | 1 | Power supplies that must be fed for the system to stay up. Desktop: 1. |
HOSTSYNC | 15 | Master's wait for slaves before proceeding β irrelevant standalone, harmless to leave. |
DEADTIME | 15 | Seconds of silence before a UPS is declared dead and assumed critical if it was on battery. |
Who decides "low battery"? With macosx-ups, the LB flag arrives from Apple's power layer, which takes it from the UPS's own firmware threshold. You are trusting the AVR900U's definition of "low" β typically conservative, but on an aged battery "low" can be seconds from "empty." This is why Β§11's runtime calibration matters and why the belt-and-suspenders timer below exists.
Desktop notifications when events fire
upsmon can run a command on every event via NOTIFYCMD. Point it at a script that posts a macOS notification, and flag which events should call it (EXEC) versus only syslog:
# upsmon.conf
NOTIFYCMD /usr/local/etc/nut/notify.sh
NOTIFYFLAG ONBATT SYSLOG+EXEC
NOTIFYFLAG ONLINE SYSLOG+EXEC
NOTIFYFLAG LOWBATT SYSLOG+EXEC
NOTIFYFLAG COMMBAD SYSLOG+EXEC
NOTIFYFLAG COMMOK SYSLOG
NOTIFYFLAG REPLBATT SYSLOG+EXEC
#!/bin/sh
# /usr/local/etc/nut/notify.sh β upsmon passes the message as $1
# chmod +x; runs in upsmon's unprivileged context
/usr/bin/osascript -e "display notification \"$1\" with title \"UPS\" sound name \"Sosumi\""
osascript notifications come from a root daemon's context, and macOS increasingly gates UI from daemons; if nothing appears, the reliable fallback is logging plus a menu-bar tool, or a terminal-notifier install. Treat the notification as a courtesy β syslog and the shutdown path are the load-bearing parts.
Shutting down early: upssched
Waiting for LB spends the whole battery before acting. On a 2.7-minutes-at-full-load unit, a better policy is often "if the outage lasts longer than N minutes, it is not a blip β shut down now, with margin." That is upssched: upsmon forwards events to it, it runs timers, and cancels them if power returns:
# upsmon.conf β route events through upssched
NOTIFYCMD /usr/local/sbin/upssched
NOTIFYFLAG ONBATT SYSLOG+EXEC
NOTIFYFLAG ONLINE SYSLOG+EXEC
# upssched.conf
CMDSCRIPT /usr/local/etc/nut/upssched-cmd.sh
PIPEFN /usr/local/var/state/ups/upssched.pipe
LOCKFN /usr/local/var/state/ups/upssched.lock
AT ONBATT myups@localhost START-TIMER onbatt-too-long 120
AT ONLINE myups@localhost CANCEL-TIMER onbatt-too-long
#!/bin/sh β upssched-cmd.sh receives the timer name as $1
case "$1" in
onbatt-too-long)
logger -t upssched "On battery 120s; forcing early shutdown"
/usr/local/sbin/upsmon -c fsd
;;
esac
Two minutes on battery β forced shutdown, with the battery still half full instead of empty. Pick the timer from Β§3's runtime math: at your measured load, leave at least 2Γ the time a clean halt takes.
The second brain: macOS's own shutdown rules
With the UPS attached, the Apple stack keeps its own thresholds, visible and settable via pmset's UPS flags (per pmset(1): haltlevel %, haltafter minutes on UPS, haltremain minutes-left) and mirrored in Energy Saver's UPS options. Two brains can both order a shutdown; that is safe β the second order finds the system already halting β but make NUT the authority so behavior is in one config you version and test. Either disable Apple's thresholds or set them strictly later than NUT's, and check the current state with pmset -g ups.
Closing the loop: restart after power returns
Because macosx-ups is monitoring-only, it cannot tell the UPS to cut and restore outlet power the way Linux + usbhid-ups setups do. Sequence that follows a real outage: your Mac halts β UPS discharges to empty β outlets drop β wall power returns β outlets energize. For the Mac to come back on its own at that last step:
sudo pmset -a autorestart 1
pmset -g | grep autorestart
This is macOS's "start up automatically after a power failure." Without it, the 3 a.m. outage ends with a Mac that stays dark until you press the button.
upsmon -c fsd really shuts your Mac down
sudo /usr/local/sbin/upsmon -c fsd declares a forced shutdown: SHUTDOWNCMD runs within seconds, and by design the FSD flag cannot be un-latched without restarting upsd. This is the correct way to prove the shutdown path end-to-end β and the wrong thing to run with unsaved work open. Save everything, run it, watch the halt, boot, and check the logs. Schedule the first full test deliberately.
- Confirm the stack is up (
upsc myups@localhost ups.statusβOL) andautorestartis 1. - Pull the UPS's wall plug (never the USB cable β that tests the wrong failure). Within POLLFREQALERT seconds:
upsc myups@localhost ups.statusOB, a notification if you wired Β§9's NOTIFYCMD, and an ONBATT line in the logs. - Watch
battery.chargefall for a minute, then restore wall power.status returns to
OL(often via a charging flag); ONLINE event logged. You have now seen the full OB round-trip without risking a shutdown. - The real drill: save everything, then either wait out your upssched timer with the plug pulled, or trigger directly:
sudo /usr/local/sbin/upsmon -c fsdclean halt within ~10 s.
- Boot, then read the sequence back from the unified log (Β§10's commands) and from
upsc. Verify the timeline matches the diagram above.
Sources: upsmon(8) Β· upsmon.conf(5) Β· upssched(8) Β· man pmset (local)
10 Β· Monitoring β reading both stacks
When something is off β runtime seems short, the status looks stale, a shutdown fired that shouldn't have β you want testimony from two independent witnesses. Your Mac has them: NUT's view and Apple's view of the same hardware. When they agree, trust the reading. When they disagree, the disagreement itself is the diagnostic.
The NUT witness
# full variable dump β the baseline you captured in Lab 1
upsc myups@localhost
# one variable, script-friendly
upsc myups@localhost battery.charge
# continuous CSV logging: one line per minute to a file; -F keeps it in the foreground so Ctrl+C stops it
upslog -s myups@localhost -l ~/ups-log.csv -i 60 -F
# same, but self-terminating: exit after 60 samples
upslog -s myups@localhost -l ~/ups-log.csv -i 60 -d 60
# quick live watch in a spare terminal
while sleep 5; do printf '%s %s %s%% %ss\n' "$(date +%T)" \
"$(upsc myups@localhost ups.status 2>/dev/null)" \
"$(upsc myups@localhost battery.charge 2>/dev/null)" \
"$(upsc myups@localhost battery.runtime 2>/dev/null)"; done
Three quirks of what macosx-ups hands you. battery.charge arrives in 5-point steps β the AVR900U's gauge reports 65, 70, 75, never 68 β and when the true value sits near a boundary it flaps between neighbours every few seconds (65β70 for an hour on this machine); read a flapping pair as the midpoint, not as a trend. battery.runtime is Apple's Time to Empty in seconds: load-dependent and, on this unit, wildly nonlinear (about 61 minutes reported at 100 %, 9 minutes at 75 %), so it is a warning light, not a health metric. battery.voltage: 0.001 is a placeholder β the driver has no real voltage.
-l anything but -), upslog backgrounds itself and keeps writing after you close the terminal; only -F holds it in the foreground. It has no -c stop like upsd, and its pid file (/usr/local/var/run/upslog.pid) is not reliable β on this machine it named a pid that no longer existed while the real logger ran on. Stop it by name (it runs as you, so no sudo):
pkill -x upslog
ps aux | grep '[u]pslog'
# expect no output; then tidy: rm /usr/local/var/run/upslog.pid
And spell out the file name: -l FILE copied literally writes a file called FILE in whatever directory you started from. Prefer -F for interactive sessions and -d <count> for a bounded capture; a detached upslog is for a logger you mean to leave running for weeks.
The Apple witness
# current power sources β the fastest health check
pmset -g ps
Now drawing from 'AC Power'
-InternalBattery-0 (id=9175139) 100%; charged; 0:00 remaining present: true
-AVR900U (id=19136514) 65%; charging present: true
# Apple's UPS shutdown thresholds (empty when no UPS is attached)
pmset -g ups
# hardware detail: is the UPS even enumerated on USB?
system_profiler SPUSBDataType | grep -i -A10 "tripp\|ups"
system_profiler SPPowerDataType
The transcript above is this machine with the UPS attached: the internal battery and the UPS each get a line. The UPS's percentage is Apple's reading of the same HID gauge that NUT's battery.charge mirrors, so the two witnesses should agree to within one gauge step (see the traps below). With the UPS detached the line simply vanishes β that presence/absence check settles the most fundamental question in seconds and needs no NUT at all.
Deep history: the unified log and ioupsd
Apple's ioupsd narrates every UPS state change into the unified log. This is your black-box recorder: after any incident, the answer to "what did the Mac believe, and when?" is already on disk.
# live tail while you run a fire drill
log stream --predicate 'process == "ioupsd"'
# reconstruct an incident: dump the last 3 hours to a file
log show --debug --last 3h --predicate 'process == "ioupsd"' > ~/macos-ups-log.txt
The dump is verbose β each dispatchEvent block repeats every HID property. The extractor below (a cleaned-up version of a script battle-tested on this machine's own logs) collapses it to one line per state change: charge, alarm, failure flag, charging flag, source, and runtime.
UPS_DUMP="$HOME/macos-ups-log.txt"
perl -0777 -ne '
while (
/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})
[^\n]*dispatchEvent:\s*\{([^}]*)\}/sgx
) {
$time = $1;
$block = $2;
($charge) = $block =~ /"Current Capacity"\s*=\s*(\d+)/;
($alarm) = $block =~ /"Enable Audible Alarm"\s*=\s*(\d+)/;
($failure) = $block =~ /"Internal Failure"\s*=\s*(\d+)/;
($charging) = $block =~ /"Is Charging"\s*=\s*(\d+)/;
($source) = $block =~ /"Power Source State"\s*=\s*"([^"]+)"/;
($runtime) = $block =~ /"Time to Empty"\s*=\s*(\d+)/;
next unless defined $charge && defined $source && defined $runtime;
$alarm = "unavailable" unless defined $alarm;
$failure = "unavailable" unless defined $failure;
$charging = "unavailable" unless defined $charging;
$runtime = "unavailable" unless defined $runtime;
$state = "charge=$charge alarm=$alarm failure=$failure " .
"charging=$charging source=$source runtime=$runtime";
if ($state ne $previous) {
print "$time $state\n";
$previous = $state;
}
}
' "$UPS_DUMP"
Output reads like a flight recorder: a timestamped line each time anything changed. Power Source State flips between "AC Power" and "Battery Power" at the exact millisecond of each transfer β lay it beside upsmon's log and the two witnesses cross-examine each other.
- It is not only the UPS.
ioupsdalso tracks the batteries of Magic Keyboards, Trackpads and Mice β over Bluetooth, and on a Lightning cable as a USB HID device. TheirdispatchEventblocks carryCurrent Capacity,Is ChargingandPower Source Statebut neverTime to Empty, which is why the extractor above insists on$runtime. Real case: a tidy 1-point-per-three-minutes climb from 49 to 100 % between 02:32 and 05:00 looked like the UPS recharging. It was a Magic Trackpad 2 on a charging cable; the UPS, in the same log, was drifting down. - Five-point steps. The UPS gauge flaps between neighbours near a boundary (70β65 every few seconds for over an hour). Only the second-to-second
Skipping duplicate element β¦ IV: 65 β¦ with key Current Capacitylines carry the raw value; the extractor's change-detection shows the flapping as a burst of lines. Read the midpoint. - The clock can lie. If the Mac loses power completely, its clock may stop; after boot, time is stepped and
log showwarnsWall Clock adjustment detected. Timestamps before the step are wrong, and--start/--endwindows leak entries from outside the window. Prefer--last, and treat anything logged before the first post-boot minute as suspect.
log show --debug --last 12h --style compact --predicate 'process == "ioupsd" AND eventMessage CONTAINS "with key Current Capacity"' \
| sed -E 's/^([0-9-]+ [0-9:]+)\.[0-9]+ .*IV: ([0-9]+)\).*/\1 \2/' | awk '$3 != p { print; p = $3 }'
Translation table β pmset instinct β NUT move
| You'd reach for⦠| NUT equivalent | Why the NUT form wins |
|---|---|---|
pmset -g ps | upsc myups@localhost ups.status | Machine-readable tokens (OL/OB/LB) instead of prose; identical syntax against remote UPSes later. |
pmset -g batt percentages | upsc myups@localhost battery.charge / battery.runtime | One variable per query β scriptable without parsing. |
| Energy Saver UPS sliders | upsmon.conf + upssched timers | Version-controllable, testable, comment-able; policies beyond "at N percent." |
log show β¦ ioupsd forensics | upslog continuous CSV | Proactive recording at your chosen interval vs after-the-fact archaeology. (Keep both.) |
| Menu-bar battery icon | the while sleep 5 watch loop above | Shows OB/LB transitions and runtime, not just a percentage. |
Sources: upslog(8) Β· man pmset, man log (local)
11 Β· Battery health & maintenance
Every number in this guide silently assumes a healthy battery. Lead-acid chemistry guarantees that assumption expires: capacity fades slowly, then suddenly, and the UPS keeps reporting green right up until an outage proves otherwise. Maintenance is the practice of finding that out on your schedule instead of the grid's.
What you are maintaining
The AVR900U holds a 12 V valve-regulated lead-acid (VRLA) battery β the sealed, maintenance-free descendant of a car battery. "Maintenance-free" means no fluid to top up; it does not mean immortal. Typical service life for this class is 3β5 years, shortened by heat (chemistry roughly doubles its aging rate per 8β10 Β°C), deep discharges, and time spent sitting empty. Recharge after a full discharge takes about 8 hours to reach 90 %.
battery.charge: 100 measures fullness, not capacity. An aged battery reaches "100 %" of a shrunken tank. The number that exposes aging is runtime under a known load β which is why the calibration below is the one maintenance habit that matters.
| Time | Gauge | Runtime est. | Reported state |
|---|---|---|---|
| Sep 1, 20:03 | 100 % | ~61 min | AC Power, not charging (full) |
| Sep 2, 00:19 (boot) | 80 % | 9 min | AC Power, Is Charging = 1 |
| 02:30 | 75β80 | 8β9 min | AC Power, charging |
| 05:41 | 70β75 | 8 min | AC Power, charging |
| 07:20 | 65β70 | 6β8 min | AC Power, charging, Internal Failure = 0 |
battery.runtime says now, not the spec sheet β and the decision rule is the same: 24 hours on AC without reaching 90 % β replace the battery (RBC51 β see Replacement, below). If it is climbing, it was a slow recovery; confirm with Lab 4's 40 % cut-off variant. Note what upsmon did not do: no REPLBATT, no LB. The gauge trend, logged by upslog or the extractor above, was the only witness.
Runtime calibration β twice a year
The point: measure how long the battery actually lasts at your real load, so LB thresholds and the Β§9 timer rest on today's battery, not the 2.7/10-minute numbers from the datasheet.
- Start recorders on both witnesses:
upslog -s myups@localhost -l ~/calib-$(date +%F).csv -i 10 -Fin one terminal,log stream --predicate 'process == "ioupsd"'in another. (-Fmatters: without it upslog detaches and outlives the terminal β Β§10.) - Note the starting reading:
upsc myups@localhost battery.charge battery.runtime. - Pull the UPS wall plug. Let it discharge at your normal working load.
- Either ride it down to the automated shutdown (the honest full test β your Β§9 machinery gets exercised too), or restore power at 40 % if you only want a capacity data point without the halt.
- Afterwards, from the CSV: elapsed minutes from OB to LB (or to your cutoff), and whether the UPS's
battery.runtimepredictions tracked reality or flattered it.
Replacement
- When: measured runtime clearly declining, the unit's alarm/fault behavior signaling battery trouble, a
REPLBATTevent from upsmon, or age past ~4 years β whichever arrives first. - What: the official replacement is Tripp Lite RBC51; the cell inside is a standard 12 V sealed lead-acid block, and matching-spec third-party cells (12 V, matching Ah, F2 terminals) are widely sold for this model.
- How: power down and unplug the unit, open the battery door per the unit's label, swap the connector, and give the new battery a full 24 h charge before trusting it with a runtime test. Recycle the old cell β any auto-parts store takes sealed lead-acid.
- After: run Lab 4 again. A new battery that can't beat the old one's numbers is a warranty conversation.
Load discipline
Runtime maintenance has a second lever: what you plug in. Keep the battery side of the UPS for things that must survive the outage β the Mac, the display you need to see the shutdown, the router/modem if remote access matters. Move speakers, chargers, lamps, and anything with a motor or heater to the surge-only outlets or the wall. Recheck after every desk reshuffle; load creep is silent and Β§3's math says every watt removed pays back runtime with interest.
Sources: Eaton/Tripp Lite AVR900U product page Β· upsmon.conf(5) (REPLBATT)
12 Β· Troubleshooting
Almost every failure in this stack announces which layer it lives in, if you read the error precisely. Work the ladder from Β§6 β hardware visible β driver up β upsd answering β upsmon deciding β and each symptom below names its own rung.
| Symptom | Likely cause | Fix |
|---|---|---|
upsc: Error: Connection failure: Connection refused | upsd is not running (nothing listens on 3493). | sudo launchctl print system/org.nut.upsd; if absent, bootstrap it (Β§8). Manual check: /usr/local/sbin/upsd -F -D (no sudo β Β§6) and read its complaint. |
upsc: Error: Driver not connected | upsd is fine; the driver layer is down or can't see the UPS. | Run the driver in debug: sudo /usr/local/bin/macosx-ups -a myups -DD. No power source found β check the USB cable and pmset -g ps. |
Driver starts but prints WARNING: Needed to fix group access to filesystem socket of this driver, but failed | user = is set in ups.conf but group = is not: the driver tries to chgrp its socket to the compiled-in nobody after it has already become you, and the kernel refuses (Β§5). | Add group = admin to the global section of ups.conf; sudo upsdrvctl stop myups, then start again. With upsdrvctl -DD the driver should report Group access for this driver successfully fixed. |
upsd exits at once complaining it can't chdir to /usr/local/var/state/ups (Permission denied); under launchd, sudo launchctl print system/org.nut.upsd shows state = spawn scheduled and no pid, and upsc says Connection refused | upsd was started as root without -u, dropped to nobody, and nobody cannot enter Homebrew's directories. | By hand: run it as yourself, /usr/local/sbin/upsd -F -D. In the plist: -u you, always (Β§8). |
UPS absent from pmset -g ps and system_profiler SPUSBDataType | macOS doesn't see the hardware β cable, port, or the UPS's USB interface. | Different cable (data-capable, not charge-only), different port, no hub. Until Apple's stack sees it, macosx-ups has nothing to read. |
| Driver starts then exits; launchd respawns it every 30 s | UPS unplugged from USB while KeepAlive is set (Β§8's known loop). | Reattach the UPS, or sudo launchctl bootout system/org.nut.driver during long detachments. |
tripplite_usb fails to claim/find the unit | Wrong driver for this hardware generation on this OS (Β§5). | Use macosx-ups. Not a hardware fault. |
| Everything ran until a reboot; nothing after | Stack was hand-started, or only brew services (upsmon-only) survives boot. | Install all three launchd jobs (Β§8, Lab 2); confirm with launchctl print after reboot. |
| Stale readings: charge/status frozen while reality changes | Driver wedged, or two driver instances fighting (manual + launchd). | ps aux | egrep "macosx-ups|upsd|upsmon" | grep -v egrep β exactly one of each. Kill strays: sudo pkill -f macosx-ups; launchd relaunches its own cleanly. |
A CSV keeps growing after you closed the terminal that started upslog | upslog backgrounds itself when logging to a file; its pid file can be stale. | pkill -x upslog. Next time add -F (foreground) or -d <count> (bounded) β Β§10. |
| Shutdown fired "too early" during a blip | upssched timer shorter than your grid's ordinary flickers, or Apple's thresholds (the second brain, Β§9) firing first. | Read both logs (Β§10) to see who ordered it; lengthen the timer or defang pmset -g ups thresholds. |
| Shutdown never fired in a real outage | The chain was broken somewhere and never tested end-to-end. | Run Lab 3's fire drill now; the logs from the failed night (Β§10's log show) tell you which link lied. |
| Mac stayed off after power returned | autorestart not set (Β§9). | sudo pmset -a autorestart 1. |
| UPS clicks (relay) frequently but never goes OB | AVR doing its job on wandering wall voltage (Β§3). | Normal in moderation. Constant clicking β wall voltage chronically out of range; worth an electrician's look. |
battery.charge flat or falling for hours while ups.status says OL CHRG | Battery not accepting charge, or charger fault (Β§11's signature). No REPLBATT, no LB β the trend is the only symptom. | Confirm with the ioupsd extractor (Β§10): charging=1, failure=0, charge down. Give it 24 h on AC; short of 90 % β replace the RBC51. Until then treat runtime as today's battery.runtime, not the spec. |
REPLBATT events, or runtime collapsed since last calibration | Battery aging out. | Β§11 β replace, recharge 24 h, re-run Lab 4. |
pmset -g ps (does the OS see the UPS?) and upsc myups@localhost ups.status (does NUT?). The four combinations of see/don't-see point at hardware, driver, server, or nothing wrong β respectively and unambiguously.
13 Β· Power-event simulator
You cannot rehearse outages on demand β but the state machine that manages them is simple enough to carry in your head, and this simulator exists to put it there. It models a 480 W-class UPS with your stack attached: fail the wall power, watch status tokens flip, see upsmon's events fire, and learn the rhythm before the real thing tests you.
Things worth trying deliberately: fail power at 240 W and watch how long the OB phase lasts with the early-shutdown timer on versus off; raise the load to 480 W and see the timer become the only thing standing between you and an LB race; restore power mid-countdown and watch the timer cancel β the exact behavior Β§9's CANCEL-TIMER line buys you. Time compression: one simulated minute β two real seconds.
14 Β· Capstone β the trusted-stack audit
The capstone is not a new build; it is proving the one you have. Run it after finishing the guide, and then again once a quarter β it exercises every layer, both witnesses, the automation, and the battery in one sitting, and ends with a written record you will thank yourself for.
- Cold-start proof. Reboot the Mac. Before opening anything else:
upsc myups@localhost ups.statusβOL. All three launchd jobs reportstate = runningundersudo launchctl print. (Β§8) - Witness agreement.
pmset -g psshows the UPS; its charge agrees withupsc'sbattery.chargewithin one 5-point gauge step (Β§10). Disagreement is your first finding. On a laptop, also read theInternalBatteryline: charged or charging while the UPS is on wall power. A Mac whose own battery drains while it claims Using AC is telling you its charger is not on a battery-backed outlet, or the hub between them is not passing power. (Β§10) - Config review. Read all four config files end to end. Every line should be one you can explain; any you can't, this guide's Β§4βΒ§9 name. Confirm modes/permissions on
upsd.usersandupsmon.confare 600. (Β§7) - Event round-trip. Recorders on (Lab 4 step 1), pull the wall plug for 60 seconds, restore. Verify ONBATT and ONLINE appear in upsmon's log, the notification fired, and ioupsd's log shows the same transitions at the same timestamps. (Β§9, Β§10)
- Fire drill. Work saved; run the full shutdown path β upssched timer or
sudo upsmon -c fsdβ and let the machine halt. Boot; confirmautorestartis still 1. (Β§9, Lab 3) - Runtime measurement. Lab 4 in full. Record date, load, minutes measured. Compare against last quarter; adjust the upssched timer if the battery has faded. (Β§11)
- Write it down. Three lines in a log file next to your configs: date, measured runtime, anything that surprised you. The quarter-over-quarter trend of that file is your battery-replacement early-warning system.
15 Β· Cheat sheet
The working set, one glance. The universal starting pattern comes first β it answers "is everything okay?" in two lines and is the opening move of every Β§12 diagnosis.
# the universal starting pattern
pmset -g ps # does macOS see the UPS?
upsc myups@localhost ups.status # does NUT? want: OL
upsc myups@localhost β everythingupsc myups@localhost battery.chargeupsc myups@localhost battery.runtimeupsc -l localhost β list UPS namessudo upsdrvctl -D start myups Β· sudo upsdrvctl stop myups/usr/local/sbin/upsd -F -D β no sudo (from root: add -u you)sudo /usr/local/bin/macosx-ups -a myups -DDps aux | egrep "macosx-ups|upsd|upsmon"sudo launchctl bootstrap system /Library/LaunchDaemons/org.nut.upsd.plistsudo launchctl print system/org.nut.upsdsudo launchctl bootout system/org.nut.upsdplutil -lint β¦plistpmset -g ps Β· pmset -g upssudo pmset -a autorestart 1system_profiler SPUSBDataTypelog stream --predicate 'process == "ioupsd"'upslog -s myups@localhost -l ~/ups-log.csv -i 60 -F (no -F β detaches)pkill -x upslog β stop a detached loggerlog show --debug --last 3h --predicate 'process == "ioupsd"' > dump.txtΒ§10's perl extractor for state changes
sudo upsmon -c fsd β real shutdown, latchedFire drill = pull wall plug, never USB
Config lives in
/usr/local/etc/nut/Whole stack runs as you:
user/group in ups.conf, -u for upsd| Status token | Meaning | Your posture |
|---|---|---|
OL | Online, wall power good | Normal; ignore. |
OB | On battery β outage in progress | Countdown running; upssched timer armed. |
LB | Low battery | With OB: shutdown is imminent and automatic. |
CHRG / DISCHRG | Charging / discharging flags | Context alongside OL/OB. |
FSD | Forced shutdown declared | Too late to argue; it latches until upsd restarts. |
16 Β· Glossary
- AVR β automatic voltage regulation
- A line-interactive UPS's transformer trick: correcting brownouts/overvoltage without switching to battery. (Β§3)
- Driver (NUT)
- The process that speaks to one UPS and translates its state into NUT's standard variables. Here:
macosx-ups. (Β§5) - FSD β forced shutdown
- upsmon's point of no return: the flag that commits every attached machine to shutting down. Latched by design until upsd restarts. (Β§9)
- HID Power Device Class
- The USB standard for batteries and UPSes. Why macOS auto-recognizes the AVR900U β and why the kernel claims it before NUT can. (Β§1, Β§5)
- ioupsd
- Apple's UPS daemon; narrates UPS state changes into the unified log. The "Apple witness." (Β§10)
- launchd / LaunchDaemon
- macOS's init system and its root-context boot jobs, defined by plists in
/Library/LaunchDaemons. (Β§8) - LB β low battery
- Status token meaning the battery is nearly spent; OB+LB together triggers the automated shutdown. (Β§1, Β§9)
- Line-interactive
- UPS topology between cheap standby and expensive double-conversion: AVR for small deviations, battery for real failures. (Β§3)
- Master / primary
- The upsmon role for a machine powered by the UPS that coordinates shutdown. "Primary" is NUT 2.8's newer spelling. (Β§7)
- MODE=standalone
- NUT's arrangement for one machine protecting itself with a local UPS β this guide's entire scope. (Β§4)
- OB β on battery
- Status token: wall power failed, stored energy is being spent. (Β§1)
- OL β online
- Status token: wall power good. The state you want to be bored by. (Β§1)
- Peukert effect
- Lead-acid batteries yield less energy at higher discharge rates β why half load gives ~4Γ runtime, not 2Γ. (Β§3)
- pmset
- macOS's power-management CLI: reads power sources, sets UPS thresholds and
autorestart. (Β§10, Β§9) - RBC51
- Tripp Lite's replacement battery model for the AVR900U: 12 V sealed lead-acid. (Β§11)
- SHUTDOWNCMD
- The command upsmon's root stub runs when shutdown is required β
/sbin/shutdown -h nowon macOS. (Β§9) - upsc
- NUT's query client: dumps a UPS's variables, or one variable, script-cleanly. (Β§6)
- upsd
- NUT's data server: publishes driver state on TCP 3493 to authenticated clients. (Β§7)
- upsdrvctl
- Start/stop wrapper for NUT drivers listed in
ups.conf. Not used under launchd, which runs the driver binary directly. (Β§6, Β§8) - upslog
- NUT's CSV logger β one status line per interval, ideal for calibration records. Detaches when given a log file unless run with
-F; stop withpkill -x upslog. (Β§10) - upsmon
- NUT's monitor: watches
ups.status, fires notifications, and executes the shutdown decision via a privileged stub. (Β§9) - upssched
- Timer layer between upsmon events and actions β "on battery 120 s β shut down early," cancelled if power returns. (Β§9)
- VA vs W
- Apparent vs real power. Size the UPS by watts (480 W here); VA is the marketing number. (Β§3)
- VRLA β valve-regulated lead-acid
- Sealed "maintenance-free" battery chemistry: no topping up, 3β5 year life, heat-sensitive. (Β§11)
17 Β· Index
- B
- baseline output (upsc)
- battery replacement
- battery.charge
- battery.runtime
- battery gauge (5-point steps)
- battery that won't charge (signature)
- brew services (upsmon-only trap)
- brew install nut
- L
- launchctl bootstrap / bootout
- LaunchDaemons plists
- LB / OB / OL tokens
- load discipline
- log stream (ioupsd)
18 Β· Quiz
Self-scored flashcards β answer out loud before revealing, then mark yourself honestly. Aim for 14/16; every answer links back to the section that taught it, and the two you miss are tonight's re-reading list.
ioupsd, powerd, pmset) and the NUT stack. On macOS they are not rivals: the kernel claims the USB device, so NUT's macosx-ups driver reads the UPS through Apple's layer. Apple is the sensor; NUT is the programmable brain. (Β§1)macosx-ups, hardware β standard variables, ups.conf) β server (upsd, publishes on TCP 3493, upsd.conf/upsd.users) β monitor (upsmon, watches status and executes shutdown, upsmon.conf). (Β§1)macosx-ups the right driver here, when usbhid-ups speaks the AVR900U's exact protocol?macosx-ups sidesteps the fight by reading Apple's power layer. The cost: monitoring-only, fewer variables, no UPS commands. (Β§5)upsc says Connection refused. Same command, another day: Driver not connected. Which layer is broken in each case?upsd is down. Driver not connected β upsd is fine, the driver layer is down or can't see the UPS. Two errors, two layers, no ambiguity. (Β§6)brew services start nut actually start, and why isn't it enough?upsmon β Homebrew's plist runs the monitor and nothing else. After reboot you have a monitor connecting to a server that never started. The driver and upsd need their own LaunchDaemons. (Β§8)macosx-ups -a myups -F directly instead of upsdrvctl start?upsdrvctl spawns the driver and exits, so launchd would consider the job dead and restart it forever. The driver binary with -F stays in the foreground under launchd's supervision. (Β§8)sudo upsmon -c fsd do, and what is deliberately irreversible about it?upssched enable, and why does it fit a 2.7-minute UPS?macosx-ups can't command the UPS, the Mac halts and the UPS drains to empty; when wall power re-energizes the outlets, the Mac only boots if sudo pmset -a autorestart 1 was set. (Β§9)battery.charge: 100 not evidence of a healthy battery, and what measurement is?pmset -g ps and upsc myups@localhost ups.status. Both see it β healthy. Neither β hardware/cable. Apple yes, NUT no β NUT stack (driver/upsd) down. Apple no, NUT yes β impossible-ish/stale data β restart the driver. (Β§12)WARNING: Needed to fix group access to filesystem socketβ¦, and sudo upsd -F -D exits on start. What single fact explains both, and what are the two fixes?nobody:nobody, but Homebrew's directories belong to you and admin. Fix one: group = admin beside user = you in ups.conf, so the driver can hand its socket to a group you are in. Fix two: run upsd as you β no sudo by hand, -u you in the launchd plist. (Β§5, Β§8)OL CHRG, Internal Failure = 0, and battery.charge went 80 β 65. upsmon said nothing. What is it, what do you do, and why did upsmon stay quiet?battery.runtime. upsmon only reacts to status tokens (OB, LB, REPLBATT) and the UPS raised none β the gauge trend, caught by upslog or the ioupsd extractor, was the only witness. (Β§11, Β§10)19 Β· Sources
Checked with NUT 2.8.5 (Homebrew) on macOS 15.6 with a Tripp Lite AVR900U Β· 2026-09-03 Β· external links require network; the guide itself works offline. Command transcripts marked as real were captured on the verified machine; labs requiring a live UPS are marked in place.
- NUT user manual β best for the big picture: modes, architecture, configuration walk-throughs.
- macosx-ups(8) β best for this driver's exact capabilities and limits; short and worth reading whole.
- usbhid-ups(8) β best for understanding what the standard HID driver would give you on Linux, and the variables the protocol defines.
- tripplite_usb(8) β best for the protocol-generation split that makes it the wrong driver here.
- upsmon(8) and upsmon.conf(5) β best for the shutdown decision machinery: privileges, FSD, NOTIFY events.
- upssched(8) β best for timer-based policies beyond "wait for LB."
- nutupsdrv(8) β best for the flags shared by every driver (
-F,-a,-D), which the launchd jobs depend on. - Homebrew nut formula β best for what the package installs and its service definition.
- Eaton/Tripp Lite AVR900U product page β best for the hardware numbers: runtime curve, battery, outlets, topology.
man pmset,man launchd.plist,man launchctl,man logβ local, always current for your macOS build; best for the Apple half of every workflow here.