For the home-lab operator with one Mac and one UPS

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.

βœ“ NUT 2.8.5 (Homebrew) tested βœ“ macOS 15.6 (Sequoia) βœ“ Tripp Lite AVR900U single file Β· works offline interactive power-event simulator 14-question self-quiz

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. ioupsd and powerd track it, a battery-style indicator appears, pmset can 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.
The macOS twist On Linux, NUT's 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.

Tripp Lite AVR900U USB HID Power Device macOS power layer IOKit HID drivers ioupsd Β· powerd claims the USB device; feeds pmset & Energy Saver Driver macosx-ups hardware β†’ NUT vars Server upsd TCP 3493 upsc queries upsmon decides & shuts down USB IOKit socket
The NUT pipeline on macOS. Unlike Linux, the driver does not own the USB device β€” it reads UPS state from the same Apple power layer that feeds pmset. Everything downstream of the driver is identical to NUT on any other OS.
LayerProcessOne jobConfig file
Drivermacosx-ups (via upsdrvctl)Translate hardware state into NUT's standard variables (battery.charge, ups.status, …)ups.conf
ServerupsdPublish those variables over TCP (port 3493) to authenticated clientsupsd.conf, upsd.users
MonitorupsmonWatch ups.status, raise alerts, and run the shutdown when the battery is criticalupsmon.conf
Misconception: "NUT replaces the macOS power manager." Tempting, because that is how it works on Linux. On macOS the Apple stack cannot be evicted β€” 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 stream appear 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:

  1. What does brew services list show, conceptually?
  2. What is the difference between a process run in your terminal and a daemon?
  3. Why would sudo pkill -f upsd need sudo?
  4. 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

FactValueWhy it matters
Capacity900 VA / 480 WThe hard ceiling on connected load. Watts is the number that limits you in practice.
TopologyLine-interactive with AVRCorrects brownouts/overvoltage via autotransformer without draining the battery β€” the battery is saved for real outages.
Outlets12 Γ— NEMA 5-15RNot 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 minYour worst-case shutdown budget.
Runtime, half load (240 W)β‰ˆ 10 minThe practical target: keep load near or below half.
Battery12 V VRLA (sealed lead-acid), replacement model RBC51User-replaceable; recharges 10 β†’ 90 % in about 8 hours.
InterfaceUSB (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.

Runtime is not linear Half the load gives you roughly 4Γ— the runtime (2.7 min β†’ 10 min), not 2Γ—. Lead-acid batteries deliver less usable energy at higher discharge rates (the Peukert effect). Practical consequence: moving a laser printer or a second display off the battery-backed outlets buys disproportionately more shutdown time. Never put a laser printer on a UPS at all β€” its fuser pulls more than this entire UPS supplies.

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).

Sources: Eaton/Tripp Lite AVR900U product page

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)

PathContents
/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.plistThe launchd plist brew services uses β€” read Β§8 before trusting it
The four config files, one sentence each 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

DriverBuilt forOn macOS with the AVR900U
tripplite_usbLegacy 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-upsAny USB HID PDC UPS β€” the standard choice on Linux, including for modern Tripp LiteRight 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-upsmacOS only: reads any UPS that Apple's own power layer recognizesThe 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.
The trade-off you accept with macosx-ups It reads whatever Apple's abstraction exposes β€” charge, runtime, status β€” and no more. Input voltage, load percentage, and frequency are typically absent, and the driver cannot command the UPS (no beeper toggle, no outlet control, no deep battery test via 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 addresses myups@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.
Why 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.
Misconception: "The driver failing means the UPS is unsupported." A first attempt with 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.

Lab 1 Β· Bring the stack up by hand
10 minutes Β· UPS must be attached via USB Β· two terminal windows Β· nothing persists after reboot
  1. Confirm macOS sees the UPS at all β€” the precondition for macosx-ups:
    pmset -g ps

    a 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).

  2. Start the driver in debug foreground (window 1):
    sudo /usr/local/sbin/upsdrvctl -D start myups

    the driver announces Listening on socket /usr/local/var/state/ups/macosx-ups-myups and upsdrvctl reports successfully finished β€” the driver has detached into the background, which is upsdrvctl's whole job (spawn, then exit). No WARNING: Needed to fix group access… line: if you see one, group = admin is missing from ups.conf (Β§5). Errors about "no matching power source" mean the model regex matched nothing. To watch the driver narrate in the foreground instead, run the binary directly: sudo /usr/local/bin/macosx-ups -a myups -DD.

  3. Start the server in debug foreground (window 2) β€” as yourself, no sudo:
    /usr/local/sbin/upsd -F -D

    lines showing it reading upsd.conf, then listening on 127.0.0.1 port 3493, then Connected to UPS [myups]: macosx-ups-myups. -F keeps it in the foreground; -D makes it narrate.

    Why no sudo: started as root, upsd drops to NUT's compiled-in account nobody before it enters the state directory β€” and nobody cannot 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.

  4. Query it (any window):
    upsc myups@localhost

    a 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.

  5. 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.model

    ups.status prints OL on wall power; battery.runtime is in seconds.

  6. Tear down: Ctrl+C in the upsd window; sudo /usr/local/sbin/upsdrvctl stop myups for 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 egrep

    no output.

you saw 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.

Plain-text password hygiene The password appears in two files. It guards a loopback-only service, so the threat is local users, not the network β€” but tighten the files anyway:
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.
Misconception: "It's all localhost, why passwords at all?" Because upsd will accept an instruction to declare a forced shutdown (FSD) from an authenticated master. Any local process that could reach an unauthenticated upsd could power down your machine. The password converts "anything on this Mac" into "processes that can read the locked-down config files" β€” a real boundary.

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/>
brew services nut starts one third of the stack The plist runs 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.)

Lab 2 Β· Make the stack permanent
15 minutes + one reboot Β· UPS attached Β· requires Lab 1 to have succeeded first
  1. 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
  2. Validate the XML before loading β€” a typo here costs a confusing failure later:
    plutil -lint /Library/LaunchDaemons/org.nut.*.plist

    OK three times.

  3. Bootstrap all three into the system domain (modern launchctl; load -w is 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
  4. Verify each job is alive:
    sudo launchctl print system/org.nut.upsd | grep -E "state|pid"

    state = running and a pid. Repeat for the other two labels. state = spawn scheduled with 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, then bootstrap again.

  5. Prove the stack serves data with nobody's hands on it:
    upsc myups@localhost ups.status

    OL

  6. The only test that counts β€” reboot, log back in, and query again before touching anything else:
    upsc myups@localhost ups.status

    OL, from daemons you did not start this boot.

a reboot no longer costs you your UPS protection. To undo any job later: sudo launchctl bootout system/org.nut.<name>.
KeepAlive + a detached UPS = a respawn loop If the UPS is unplugged from USB, the driver exits, and 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

OL OB OB + LB power back wall power fails UPS switches to battery <10 ms upsmon: ONBATT battery hits low threshold upsmon: LOWBATT β†’ FSD forced-shutdown flag latches SHUTDOWNCMD runs as root macOS flushes disks and halts cleanly UPS keeps discharging until empty β€” macosx-ups cannot cut UPS output wall power returns pmset autorestart β†’ Mac boots
One outage, start to finish. The two decisions you control are when LB is declared (threshold) and what SHUTDOWNCMD does. Everything else is machinery you have already built.

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):

DirectiveDefaultMeaning
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.
POLLFREQ5Seconds between status polls while online.
POLLFREQALERT5Poll interval once on battery β€” tighten to 1–2 s if you want faster LB reaction.
MINSUPPLIES1Power supplies that must be fed for the system to stay up. Desktop: 1.
HOSTSYNC15Master's wait for slaves before proceeding β€” irrelevant standalone, harmless to leave.
DEADTIME15Seconds 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\""
Notification caveat 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.

Testing with 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.
Lab 3 Β· Fire drill
20 minutes Β· UPS attached Β· work saved Β· does actually power-cycle your session
  1. Confirm the stack is up (upsc myups@localhost ups.status β†’ OL) and autorestart is 1.
  2. Pull the UPS's wall plug (never the USB cable β€” that tests the wrong failure). Within POLLFREQALERT seconds:
    upsc myups@localhost ups.status

    OB, a notification if you wired Β§9's NOTIFYCMD, and an ONBATT line in the logs.

  3. Watch battery.charge fall 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.

  4. 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 fsd

    clean halt within ~10 s.

  5. Boot, then read the sequence back from the unified log (Β§10's commands) and from upsc. Verify the timeline matches the diagram above.
you have watched your own Mac take the 3 a.m. decision correctly while you supervised. That is the difference between installed and protected.

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.

upslog detaches β€” know how to stop it Given a log file (-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.

Three traps in the ioupsd log (all met on this machine)
  • It is not only the UPS. ioupsd also tracks the batteries of Magic Keyboards, Trackpads and Mice β€” over Bluetooth, and on a Lightning cable as a USB HID device. Their dispatchEvent blocks carry Current Capacity, Is Charging and Power Source State but never Time 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 Capacity lines 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 show warns Wall Clock adjustment detected. Timestamps before the step are wrong, and --start/--end windows leak entries from outside the window. Prefer --last, and treat anything logged before the first post-boot minute as suspect.
A fast capacity-only trend, when you want the number and not the story (the trackpad trap applies β€” 1-point values are an accessory):
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 equivalentWhy the NUT form wins
pmset -g psupsc myups@localhost ups.statusMachine-readable tokens (OL/OB/LB) instead of prose; identical syntax against remote UPSes later.
pmset -g batt percentagesupsc myups@localhost battery.charge / battery.runtimeOne variable per query β€” scriptable without parsing.
Energy Saver UPS slidersupsmon.conf + upssched timersVersion-controllable, testable, comment-able; policies beyond "at N percent."
log show … ioupsd forensicsupslog continuous CSVProactive recording at your chosen interval vs after-the-fact archaeology. (Keep both.)
Menu-bar battery iconthe while sleep 5 watch loop aboveShows 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 %.

Misconception: "100% charge means a healthy battery." 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.
The signature of a battery that will not take charge A healthy VRLA on this charger is back near 90 % within about 8 hours of a discharge. This is the opposite, recorded on this machine (UPS gauge via ioupsd; the Mac was dark 21:42–00:19 after losing power, and the UPS discharged somewhere in that gap):
TimeGaugeRuntime est.Reported state
Sep 1, 20:03100 %~61 minAC Power, not charging (full)
Sep 2, 00:19 (boot)80 %9 minAC Power, Is Charging = 1
02:3075↔808–9 minAC Power, charging
05:4170↔758 minAC Power, charging
07:2065↔706–8 minAC Power, charging, Internal Failure = 0
Eight hours on wall power with the charger flagged on, no fault flagged, and the gauge fell 13 points. Two explanations survive: the battery no longer accepts charge (age, sulfation, a weak cell), or the charger itself has failed. Both mean the same for you today β€” the runtime you have is whatever 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.

Lab 4 Β· Measured-runtime test
30 minutes Β· UPS attached Β· save your work first β€” this ends in a real shutdown if you let it run
  1. Start recorders on both witnesses: upslog -s myups@localhost -l ~/calib-$(date +%F).csv -i 10 -F in one terminal, log stream --predicate 'process == "ioupsd"' in another. (-F matters: without it upslog detaches and outlives the terminal β€” Β§10.)
  2. Note the starting reading: upsc myups@localhost battery.charge battery.runtime.
  3. Pull the UPS wall plug. Let it discharge at your normal working load.
  4. 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.
  5. Afterwards, from the CSV: elapsed minutes from OB to LB (or to your cutoff), and whether the UPS's battery.runtime predictions tracked reality or flattered it.
you know this quarter's true runtime. If it dropped meaningfully since the last test, shorten the upssched timer now and budget for a battery.

Replacement

  • When: measured runtime clearly declining, the unit's alarm/fault behavior signaling battery trouble, a REPLBATT event 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.

SymptomLikely causeFix
upsc: Error: Connection failure: Connection refusedupsd 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 connectedupsd 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 faileduser = 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 refusedupsd 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 SPUSBDataTypemacOS 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 sUPS 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 unitWrong driver for this hardware generation on this OS (Β§5).Use macosx-ups. Not a hardware fault.
Everything ran until a reboot; nothing afterStack 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 changesDriver 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 upslogupslog 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 blipupssched 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 outageThe 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 returnedautorestart not set (Β§9).sudo pmset -a autorestart 1.
UPS clicks (relay) frequently but never goes OBAVR 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 CHRGBattery 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 calibrationBattery aging out.Β§11 β€” replace, recharge 24 h, re-run Lab 4.
The universal first move Two commands, ten seconds, before any theory: 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.

ups.status
OL
battery.charge
100%
battery.runtime
β€”
system
up

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.

What the simulator deliberately gets wrong It is a teaching model, not a replica: discharge is linearized from the AVR900U's two published runtime points, LB fires at a fixed 20 %, and the halt is instant. Real batteries sag non-linearly (Β§3), real LB comes from firmware opinion (Β§9), and a real macOS halt takes seconds. The state transitions and the order of events are the faithful part β€” that is what you are here to internalize.

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.

  1. Cold-start proof. Reboot the Mac. Before opening anything else: upsc myups@localhost ups.status β†’ OL. All three launchd jobs report state = running under sudo launchctl print. (Β§8)
  2. Witness agreement. pmset -g ps shows the UPS; its charge agrees with upsc's battery.charge within one 5-point gauge step (Β§10). Disagreement is your first finding. On a laptop, also read the InternalBattery line: 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)
  3. 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.users and upsmon.conf are 600. (Β§7)
  4. 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)
  5. Fire drill. Work saved; run the full shutdown path β€” upssched timer or sudo upsmon -c fsd β€” and let the machine halt. Boot; confirm autorestart is still 1. (Β§9, Lab 3)
  6. 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)
  7. 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.
Passing grade Every step verified with output you saw, not remembered; zero steps skipped because "it worked last time." A stack that passes all seven is one you may stop thinking about for three months β€” which was the point of building it.

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
Query
upsc myups@localhost β€” everything
upsc myups@localhost battery.charge
upsc myups@localhost battery.runtime
upsc -l localhost β€” list UPS names
Manual stack (debug)
sudo 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 -DD
ps aux | egrep "macosx-ups|upsd|upsmon"
launchd
sudo launchctl bootstrap system /Library/LaunchDaemons/org.nut.upsd.plist
sudo launchctl print system/org.nut.upsd
sudo launchctl bootout system/org.nut.upsd
plutil -lint …plist
Apple stack
pmset -g ps Β· pmset -g ups
sudo pmset -a autorestart 1
system_profiler SPUSBDataType
log stream --predicate 'process == "ioupsd"'
Logging
upslog -s myups@localhost -l ~/ups-log.csv -i 60 -F (no -F β†’ detaches)
pkill -x upslog β€” stop a detached logger
log show --debug --last 3h --predicate 'process == "ioupsd"' > dump.txt
Β§10's perl extractor for state changes
Danger
sudo upsmon -c fsd β€” real shutdown, latched
Fire 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 tokenMeaningYour posture
OLOnline, wall power goodNormal; ignore.
OBOn battery β€” outage in progressCountdown running; upssched timer armed.
LBLow batteryWith OB: shutdown is imminent and automatic.
CHRG / DISCHRGCharging / discharging flagsContext alongside OL/OB.
FSDForced shutdown declaredToo 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 now on 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 with pkill -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

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.

Score: 0 learned Β· 0 review Β· 0/16 answered
Q1Two software stacks can manage the AVR900U on macOS. Name both and the relationship between them.
The Apple stack (IOKit HID drivers, 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)
Q2NUT's three layers β€” name each process, its one job, and its config file.
Driver (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)
Q3Which exact status combination triggers the automated shutdown?
OB + LB together β€” on battery and low battery. OB alone starts a countdown; LB alone (on wall power) is a battery-health complaint, not an emergency. (Β§1, Β§9)
Q4The AVR900U says "900 VA / 480 W." Which number do you size against, and why?
480 W. Watts measure real power drawn; VA is apparent power, always the larger, marketing-friendly number. The unit can carry 480 W of real load, full stop. (Β§3)
Q5Halving the load takes runtime from 2.7 to ~10 minutes β€” not 5.4. What's the effect called and what's the practical consequence?
The Peukert effect: lead-acid delivers less usable energy at higher discharge rates. Consequence: shedding load pays back runtime disproportionately β€” moving one appliance off battery outlets can double your shutdown budget. (Β§3)
Q6Why is macosx-ups the right driver here, when usbhid-ups speaks the AVR900U's exact protocol?
Because macOS's kernel claims HID power devices and NUT cannot detach it (unlike Linux). macosx-ups sidesteps the fight by reading Apple's power layer. The cost: monitoring-only, fewer variables, no UPS commands. (Β§5)
Q7upsc says Connection refused. Same command, another day: Driver not connected. Which layer is broken in each case?
Connection refused β†’ nothing listens on 3493 β†’ 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)
Q8What does brew services start nut actually start, and why isn't it enough?
Only 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)
Q9Why does the driver's launchd plist run macosx-ups -a myups -F directly instead of upsdrvctl start?
launchd supervises foreground processes. 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)
Q10What does sudo upsmon -c fsd do, and what is deliberately irreversible about it?
Declares a forced shutdown: SHUTDOWNCMD runs within seconds and the machine halts. The FSD flag latches in upsd by design β€” it cannot be cleared without restarting upsd, so a returning wall supply can't strand half a fleet mid-shutdown. (Β§9)
Q11Waiting for LB is one shutdown policy. What better policy does upssched enable, and why does it fit a 2.7-minute UPS?
Time-based early shutdown: "on battery longer than N seconds β†’ halt now, with battery to spare," cancelled if power returns. On a unit with minutes of runtime, acting at LB means acting with nearly nothing left; a 120 s timer halts with margin. (Β§9)
Q12The outage ends at 4 a.m.; power is back. Why might your Mac still be off at 9 β€” and which single command prevents it?
Because 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)
Q13Why is battery.charge: 100 not evidence of a healthy battery, and what measurement is?
Charge measures fullness of whatever capacity remains β€” an aged battery reports 100 % of a shrunken tank. The honest metric is measured runtime under your real load (Lab 4), trended quarter over quarter. (Β§11)
Q14The universal starting pattern: which two commands, and what do the four see/don't-see combinations tell you?
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)
Q15The driver starts with 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?
Homebrew's NUT is compiled to run as 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)
Q16Eight hours of 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?
A battery that no longer accepts charge (or a dead charger). Rule: 24 h on AC without reaching 90 % β†’ replace the RBC51; meanwhile the real runtime is today's 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.