Skip to content

feat: add NUT (Network UPS Tools) integration base#126

Closed
RoiArthurB wants to merge 3 commits intodevfrom
feat/nut-integration-11356153773324197732
Closed

feat: add NUT (Network UPS Tools) integration base#126
RoiArthurB wants to merge 3 commits intodevfrom
feat/nut-integration-11356153773324197732

Conversation

@RoiArthurB
Copy link
Copy Markdown
Contributor

@RoiArthurB RoiArthurB commented Mar 9, 2026

This PR introduces the base system for integrating UPS monitoring using Network UPS Tools (NUT). It leverages the modern nut-client package and its Monitor class to listen for UPS events such as ONBATT, ONLINE, and LOWBATT.

When the Controller initializes, it will instantiate NutManager. The manager checks if the specified UPS is available; if not, it gracefully exits and cleans itself up. Stub functions have been added to log events and outline future implementations like executing server shutdowns or turning off connected VR headsets via ADB.

Related #74


PR created automatically by Jules for task 11356153773324197732 started by @RoiArthurB

* Install modern `nut-client` library.
* Add default `.env` configuration (NUT_HOST, NUT_PORT, NUT_UPS_NAME).
* Create `NutManager` to handle UPS connections and monitor `ONBATT`, `ONLINE`, and `LOWBATT` events.
* Integrate `NutManager` into `Controller` initialization and restarts.
* Add list of possible features to implement inside the stub handle methods of `NutManager`.
* Gracefully warn and cleanup the class when no UPS is available.

Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com>
@google-labs-jules
Copy link
Copy Markdown

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

google-labs-jules bot and others added 2 commits March 23, 2026 05:33
* Install modern `nut-client` library.
* Add default `.env` configuration (NUT_HOST, NUT_PORT, NUT_UPS_NAME).
* Create `NutManager` to handle UPS connections and monitor `ONBATT`, `ONLINE`, and `LOWBATT` events.
* Verify `upsc` and `upsd` exist on the system if connecting to `localhost`.
* Integrate `NutManager` into `Controller` initialization and restarts.
* Add list of possible features to implement inside the stub handle methods of `NutManager`.
* Gracefully warn and cleanup the class when no UPS is available.

Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com>
* Install modern `nut-client` library.
* Add default `.env` configuration (NUT_HOST, NUT_PORT, NUT_UPS_NAME).
* Create `NutManager` to handle UPS connections and monitor `ONBATT`, `ONLINE`, and `LOWBATT` events.
* Verify `upsc` and `upsd` exist on the system if connecting to `localhost`.
* Integrate `NutManager` into `Controller` initialization and restarts.
* Add list of possible features to implement inside the stub handle methods of `NutManager`.
* Add explicit code example showing how to trigger a delayed UPS shutdown.
* Gracefully warn and cleanup the class when no UPS is available.

Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com>
@sonarqubecloud
Copy link
Copy Markdown

@RoiArthurB
Copy link
Copy Markdown
Contributor Author

After some long exploration detailed below, using NUT is not a usable solution on MacOS (it's a never ending fighting to get UPS' USB low-level access) and it's not maintainable in any way...

See full exploration log

NUT UPS Driver Setup on macOS — Full Conversation Summary

Context

This was a long troubleshooting session about getting an APC Back-UPS BX2200MI (USB vendor 051d:0002) to be controllable from Node.js via the nut-client npm package on macOS Apple Silicon Mac minis, as part of the M2L2 deployment box in the SIMPLE project. The goal: read UPS status and send shutdown commands (e.g. "shut down in 5 minutes") programmatically.


The Journey — Phase by Phase

Phase 1: Initial NUT Setup (First Mac Mini — macOS 15.7.3)

The first attempt to run NUT via Homebrew (brew install nut) hit two immediate problems:

Problem A — Doubled PID path bug. NUT 2.8.4 from Homebrew had a compilation quirk where the PID file path got concatenated with itself: /opt/homebrew/var/state/ups//opt/homebrew/var/state/ups/usbhid-ups-myups.pid.pid. Fix was to ensure the state directory existed (mkdir -p /opt/homebrew/var/state/ups) and leave the config defaults alone.

Problem B — USB Access Denied. macOS has its own built-in UPS daemon (com.apple.ioupsd) that exclusively claims APC USB devices via IOKit. NUT's usbhid-ups driver (which uses libusb) couldn't claim the device even as root.

Resolution for the first Mac (15.7.3):

  1. Disable SIP (boot into Recovery → csrutil disable)
  2. Disable ioupsd via sudo launchctl disable system/com.apple.ioupsd + reboot
  3. Sign the usbhid-ups binary with the com.apple.vm.device-access entitlement (ad-hoc codesigning)
  4. Run the driver in foreground mode (-FF flag) — daemonizing lost the USB claim
  5. Set allow_killpower = 1 in ups.conf
  6. Discover that APC UPS shutdown commands only work when the UPS is on battery (not on AC power)

This worked. NUT ran, upsd served on localhost:3493, and nut-client from Node.js could read status and send shutdown.default commands.


Phase 2: Replicating on a Second Mac Mini (macOS 15.7.4)

Attempting the exact same setup on a second Mac mini running macOS 15.7.4 (one point release newer) failed spectacularly.

New obstacle — AMFI (Apple Mobile File Integrity). The ad-hoc signed entitlement was rejected at runtime (zsh: killed). The first Mac on 15.7.3 didn't need AMFI disabled; 15.7.4 tightened validation. Fix: sudo nvram boot-args="amfi_get_out_of_my_way=1" + reboot + re-sign the binary.

New obstacle — DriverKit AppleUserHIDDrivers. Even with ioupsd disabled and AMFI off, a new macOS service appeared: com.apple.driverkit.AppleUserHIDDrivers-0x<dynamic_hex>. This DriverKit user-space HID driver replaced the old ioupsd mechanism for claiming USB UPS devices. Key problems:

  • The service ID changes on every reboot (dynamic hex suffix), so launchctl disable can't target it persistently
  • launchctl bootout could kill it, but it respawns immediately
  • Repeatedly killing it crashed the Mac (it also handles keyboard/mouse HID)
  • The IOKit matching system holds an active driver instance on the device that can't be unloaded from userspace

Attempted and failed approaches on 15.7.4:

  • kextunload of IOHIDPowerSource — blocked by reference from AppleBTM kext which has an active IORegistry match
  • Dynamic bootout script with race-condition timing — crashed the Mac
  • macosx-ups NUT driver (uses Apple's IOPowerSources API) — works for monitoring only, shutdown commands are hardcoded as unsupported in the source code
  • UTM/QEMU Linux VM with USB passthrough — macOS claims the device before the VM sees it
  • USB/IP forwarding — no mature macOS tools

Phase 3: The apcupsd Detour

Since NUT/libusb was fundamentally blocked on 15.7.4, we tried apcupsd which has a native macOS IOKit HID backend (doesn't need libusb):

Problem — kext installation blocked. apcupsd needs ApcupsdDummy.kext installed in /System/Library/Extensions/, but:

  • The system volume is a read-only APFS sealed snapshot (SSV) on Apple Silicon
  • Even with SIP off, mount -uw / fails with "Permission denied"
  • Would need csrutil authenticated-root disable on top of csrutil disable
  • Even then, the kext "does not work as expected" per brew's own caveats — requires physical USB replug after every reboot

apcupsd capabilities if it worked:

  • Read status: yes, via node-apcaccess npm over NIS (port 3551)
  • Send commands: no network interface — must shell out to apccontrol/apcupsd --killpower
  • Has built-in graceful shutdown flow with configurable timing (ONBATTERYDELAY, BATTERYLEVEL, MINUTES, KILLDELAY)

Phase 4: Root Cause Identification

The breakthrough was comparing macOS versions:

  First Mac Second Mac
macOS version 15.7.3 15.7.4
UPS daemon ioupsd (old, permanently disableable) AppleUserHIDDrivers (DriverKit, respawns with dynamic ID)
SIP off sufficient? Yes No (also needs AMFI off)
NUT works? ✅ Yes ❌ No — IOKit matching is undefeatable

Key Takeaways

  • macOS on Apple Silicon is fundamentally hostile to third-party USB HID device control. Three independent security layers (SIP, AMFI, SSV/DriverKit) all conspire to prevent it.
  • NUT's usbhid-ups uses libusb, and libusb's own FAQ explicitly says "do not use libusb with HID devices under macOS."
  • Version pinning is the correct M2L2 strategy — all units should ship on the same known-good macOS version (15.7.3) with updates permanently disabled.
  • The nut-client npm package works perfectly once NUT's upsd is running — the entire battle was about getting the USB driver layer working, not the network protocol layer.
  • APC UPS devices ignore shutdown commands while on AC power — this is normal hardware behavior, not a software bug.

Chat Link

Full conversation

# NUT UPS Driver Setup on macOS — Full Conversation Summary

Context

This was a long troubleshooting session about getting an APC Back-UPS BX2200MI (USB vendor 051d:0002) to be controllable from Node.js via the nut-client npm package on macOS Apple Silicon Mac minis, as part of the M2L2 deployment box in the SIMPLE project. The goal: read UPS status and send shutdown commands (e.g. "shut down in 5 minutes") programmatically.


The Journey — Phase by Phase

Phase 1: Initial NUT Setup (First Mac Mini — macOS 15.7.3)

The first attempt to run NUT via Homebrew (brew install nut) hit two immediate problems:

Problem A — Doubled PID path bug. NUT 2.8.4 from Homebrew had a compilation quirk where the PID file path got concatenated with itself: /opt/homebrew/var/state/ups//opt/homebrew/var/state/ups/usbhid-ups-myups.pid.pid. Fix was to ensure the state directory existed (mkdir -p /opt/homebrew/var/state/ups) and leave the config defaults alone.

Problem B — USB Access Denied. macOS has its own built-in UPS daemon (com.apple.ioupsd) that exclusively claims APC USB devices via IOKit. NUT's usbhid-ups driver (which uses libusb) couldn't claim the device even as root.

Resolution for the first Mac (15.7.3):

  1. Disable SIP (boot into Recovery → csrutil disable)
  2. Disable ioupsd via sudo launchctl disable system/com.apple.ioupsd + reboot
  3. Sign the usbhid-ups binary with the com.apple.vm.device-access entitlement (ad-hoc codesigning)
  4. Run the driver in foreground mode (-FF flag) — daemonizing lost the USB claim
  5. Set allow_killpower = 1 in ups.conf
  6. Discover that APC UPS shutdown commands only work when the UPS is on battery (not on AC power)

This worked. NUT ran, upsd served on localhost:3493, and nut-client from Node.js could read status and send shutdown.default commands.


Phase 2: Replicating on a Second Mac Mini (macOS 15.7.4)

Attempting the exact same setup on a second Mac mini running macOS 15.7.4 (one point release newer) failed spectacularly.

New obstacle — AMFI (Apple Mobile File Integrity). The ad-hoc signed entitlement was rejected at runtime (zsh: killed). The first Mac on 15.7.3 didn't need AMFI disabled; 15.7.4 tightened validation. Fix: sudo nvram boot-args="amfi_get_out_of_my_way=1" + reboot + re-sign the binary.

New obstacle — DriverKit AppleUserHIDDrivers. Even with ioupsd disabled and AMFI off, a new macOS service appeared: com.apple.driverkit.AppleUserHIDDrivers-0x<dynamic_hex>. This DriverKit user-space HID driver replaced the old ioupsd mechanism for claiming USB UPS devices. Key problems:

  • The service ID changes on every reboot (dynamic hex suffix), so launchctl disable can't target it persistently
  • launchctl bootout could kill it, but it respawns immediately
  • Repeatedly killing it crashed the Mac (it also handles keyboard/mouse HID)
  • The IOKit matching system holds an active driver instance on the device that can't be unloaded from userspace

Attempted and failed approaches on 15.7.4:

  • kextunload of IOHIDPowerSource — blocked by reference from AppleBTM kext which has an active IORegistry match
  • Dynamic bootout script with race-condition timing — crashed the Mac
  • macosx-ups NUT driver (uses Apple's IOPowerSources API) — works for monitoring only, shutdown commands are hardcoded as unsupported in the source code
  • UTM/QEMU Linux VM with USB passthrough — macOS claims the device before the VM sees it
  • USB/IP forwarding — no mature macOS tools

Phase 3: The apcupsd Detour

Since NUT/libusb was fundamentally blocked on 15.7.4, we tried apcupsd which has a native macOS IOKit HID backend (doesn't need libusb):

Problem — kext installation blocked. apcupsd needs ApcupsdDummy.kext installed in /System/Library/Extensions/, but:

  • The system volume is a read-only APFS sealed snapshot (SSV) on Apple Silicon
  • Even with SIP off, mount -uw / fails with "Permission denied"
  • Would need csrutil authenticated-root disable on top of csrutil disable
  • Even then, the kext "does not work as expected" per brew's own caveats — requires physical USB replug after every reboot

apcupsd capabilities if it worked:

  • Read status: yes, via node-apcaccess npm over NIS (port 3551)
  • Send commands: no network interface — must shell out to apccontrol/apcupsd --killpower
  • Has built-in graceful shutdown flow with configurable timing (ONBATTERYDELAY, BATTERYLEVEL, MINUTES, KILLDELAY)

Phase 4: Root Cause Identification

The breakthrough was comparing macOS versions:

First Mac Second Mac
macOS version 15.7.3 15.7.4
UPS daemon ioupsd (old, permanently disableable) AppleUserHIDDrivers (DriverKit, respawns with dynamic ID)
SIP off sufficient? Yes No (also needs AMFI off)
NUT works? ✅ Yes ❌ No — IOKit matching is undefeatable

Apple deliberately tightened DriverKit UPS handling in macOS 15.7.4. No search results or NUT GitHub issues (#2827, #2642) found a workaround. Both issues remain open with no fixes.


Final Conclusions

For macOS 15.7.3 — NUT Works

The complete working setup requires:

  1. SIP disabled (csrutil disable in Recovery)
  2. AMFI disabled (sudo nvram boot-args="amfi_get_out_of_my_way=1") — needed on some builds
  3. ioupsd disabled (sudo launchctl disable system/com.apple.ioupsd + reboot)
  4. Binary signed with com.apple.vm.device-access entitlement
  5. Driver run in foreground (-FF flag)
  6. allow_killpower = 1 in ups.conf
  7. Shutdown commands only work on battery (APC hardware behavior)

Node.js integration via nut-client on localhost:3493 provides full read+write access including shutdown.default.

For macOS 15.7.4+ — No Solution Exists

The recommended path is to downgrade to macOS 15.7.3 via IPSW restore (Apple Configurator 2 or DFU mode) and pin the OS version with automatic updates disabled. This is the correct architecture for M2L2 as a fixed deployment appliance.

Alternative Architectures Discussed (All Rejected for M2L2)

Alternative Why rejected
Hermes Pi as NUT host Out of scope for M2L2 deployment
OpenWrt on router M2L2 uses TP-Link Deco (no OpenWrt)
Android TV box as USB host Not explored fully, but possible
pmset from Node.js Read-only — can't send UPS commands
macosx-ups driver Monitoring only — shutdown hardcoded as unsupported
apcupsd on macOS Kext install blocked by SSV; no network command interface
Linux VM with USB passthrough Host claims device before VM sees it

Key Takeaways

  • macOS on Apple Silicon is fundamentally hostile to third-party USB HID device control. Three independent security layers (SIP, AMFI, SSV/DriverKit) all conspire to prevent it.
  • NUT's usbhid-ups uses libusb, and libusb's own FAQ explicitly says "do not use libusb with HID devices under macOS."
  • Version pinning is the correct M2L2 strategy — all units should ship on the same known-good macOS version (15.7.3) with updates permanently disabled.
  • The nut-client npm package works perfectly once NUT's upsd is running — the entire battle was about getting the USB driver layer working, not the network protocol layer.
  • APC UPS devices ignore shutdown commands while on AC power — this is normal hardware behavior, not a software bug.

Chat Link

Claude chat

Closing this PR is favor of exploring capabilities of node-hid only (limiting dependencies to nodejs only again, and relying on what MacOS offers)

@RoiArthurB RoiArthurB closed this Mar 24, 2026
@google-labs-jules
Copy link
Copy Markdown

After some long exploration detailed below, using NUT is not a usable solution on MacOS (it's a never ending fighting to get UPS' USB low-level access) and it's not maintainable in any way...

See full exploration log

NUT UPS Driver Setup on macOS — Full Conversation Summary

Context

This was a long troubleshooting session about getting an APC Back-UPS BX2200MI (USB vendor 051d:0002) to be controllable from Node.js via the nut-client npm package on macOS Apple Silicon Mac minis, as part of the M2L2 deployment box in the SIMPLE project. The goal: read UPS status and send shutdown commands (e.g. "shut down in 5 minutes") programmatically.


The Journey — Phase by Phase

Phase 1: Initial NUT Setup (First Mac Mini — macOS 15.7.3)

The first attempt to run NUT via Homebrew (brew install nut) hit two immediate problems:

Problem A — Doubled PID path bug. NUT 2.8.4 from Homebrew had a compilation quirk where the PID file path got concatenated with itself: /opt/homebrew/var/state/ups//opt/homebrew/var/state/ups/usbhid-ups-myups.pid.pid. Fix was to ensure the state directory existed (mkdir -p /opt/homebrew/var/state/ups) and leave the config defaults alone.

Problem B — USB Access Denied. macOS has its own built-in UPS daemon (com.apple.ioupsd) that exclusively claims APC USB devices via IOKit. NUT's usbhid-ups driver (which uses libusb) couldn't claim the device even as root.

Resolution for the first Mac (15.7.3):

  1. Disable SIP (boot into Recovery → csrutil disable)
  2. Disable ioupsd via sudo launchctl disable system/com.apple.ioupsd + reboot
  3. Sign the usbhid-ups binary with the com.apple.vm.device-access entitlement (ad-hoc codesigning)
  4. Run the driver in foreground mode (-FF flag) — daemonizing lost the USB claim
  5. Set allow_killpower = 1 in ups.conf
  6. Discover that APC UPS shutdown commands only work when the UPS is on battery (not on AC power)

This worked. NUT ran, upsd served on localhost:3493, and nut-client from Node.js could read status and send shutdown.default commands.


Phase 2: Replicating on a Second Mac Mini (macOS 15.7.4)

Attempting the exact same setup on a second Mac mini running macOS 15.7.4 (one point release newer) failed spectacularly.

New obstacle — AMFI (Apple Mobile File Integrity). The ad-hoc signed entitlement was rejected at runtime (zsh: killed). The first Mac on 15.7.3 didn't need AMFI disabled; 15.7.4 tightened validation. Fix: sudo nvram boot-args="amfi_get_out_of_my_way=1" + reboot + re-sign the binary.

New obstacle — DriverKit AppleUserHIDDrivers. Even with ioupsd disabled and AMFI off, a new macOS service appeared: com.apple.driverkit.AppleUserHIDDrivers-0x<dynamic_hex>. This DriverKit user-space HID driver replaced the old ioupsd mechanism for claiming USB UPS devices. Key problems:

  • The service ID changes on every reboot (dynamic hex suffix), so launchctl disable can't target it persistently
  • launchctl bootout could kill it, but it respawns immediately
  • Repeatedly killing it crashed the Mac (it also handles keyboard/mouse HID)
  • The IOKit matching system holds an active driver instance on the device that can't be unloaded from userspace

Attempted and failed approaches on 15.7.4:

  • kextunload of IOHIDPowerSource — blocked by reference from AppleBTM kext which has an active IORegistry match
  • Dynamic bootout script with race-condition timing — crashed the Mac
  • macosx-ups NUT driver (uses Apple's IOPowerSources API) — works for monitoring only, shutdown commands are hardcoded as unsupported in the source code
  • UTM/QEMU Linux VM with USB passthrough — macOS claims the device before the VM sees it
  • USB/IP forwarding — no mature macOS tools

Phase 3: The apcupsd Detour

Since NUT/libusb was fundamentally blocked on 15.7.4, we tried apcupsd which has a native macOS IOKit HID backend (doesn't need libusb):

Problem — kext installation blocked. apcupsd needs ApcupsdDummy.kext installed in /System/Library/Extensions/, but:

  • The system volume is a read-only APFS sealed snapshot (SSV) on Apple Silicon
  • Even with SIP off, mount -uw / fails with "Permission denied"
  • Would need csrutil authenticated-root disable on top of csrutil disable
  • Even then, the kext "does not work as expected" per brew's own caveats — requires physical USB replug after every reboot

apcupsd capabilities if it worked:

  • Read status: yes, via node-apcaccess npm over NIS (port 3551)
  • Send commands: no network interface — must shell out to apccontrol/apcupsd --killpower
  • Has built-in graceful shutdown flow with configurable timing (ONBATTERYDELAY, BATTERYLEVEL, MINUTES, KILLDELAY)

Phase 4: Root Cause Identification

The breakthrough was comparing macOS versions:

  First Mac Second Mac
macOS version 15.7.3 15.7.4
UPS daemon ioupsd (old, permanently disableable) AppleUserHIDDrivers (DriverKit, respawns with dynamic ID)
SIP off sufficient? Yes No (also needs AMFI off)
NUT works? ✅ Yes ❌ No — IOKit matching is undefeatable

Key Takeaways

  • macOS on Apple Silicon is fundamentally hostile to third-party USB HID device control. Three independent security layers (SIP, AMFI, SSV/DriverKit) all conspire to prevent it.
  • NUT's usbhid-ups uses libusb, and libusb's own FAQ explicitly says "do not use libusb with HID devices under macOS."
  • Version pinning is the correct M2L2 strategy — all units should ship on the same known-good macOS version (15.7.3) with updates permanently disabled.
  • The nut-client npm package works perfectly once NUT's upsd is running — the entire battle was about getting the USB driver layer working, not the network protocol layer.
  • APC UPS devices ignore shutdown commands while on AC power — this is normal hardware behavior, not a software bug.

Chat Link

Full conversation

# NUT UPS Driver Setup on macOS — Full Conversation Summary

Context

This was a long troubleshooting session about getting an APC Back-UPS BX2200MI (USB vendor 051d:0002) to be controllable from Node.js via the nut-client npm package on macOS Apple Silicon Mac minis, as part of the M2L2 deployment box in the SIMPLE project. The goal: read UPS status and send shutdown commands (e.g. "shut down in 5 minutes") programmatically.


The Journey — Phase by Phase

Phase 1: Initial NUT Setup (First Mac Mini — macOS 15.7.3)

The first attempt to run NUT via Homebrew (brew install nut) hit two immediate problems:

Problem A — Doubled PID path bug. NUT 2.8.4 from Homebrew had a compilation quirk where the PID file path got concatenated with itself: /opt/homebrew/var/state/ups//opt/homebrew/var/state/ups/usbhid-ups-myups.pid.pid. Fix was to ensure the state directory existed (mkdir -p /opt/homebrew/var/state/ups) and leave the config defaults alone.

Problem B — USB Access Denied. macOS has its own built-in UPS daemon (com.apple.ioupsd) that exclusively claims APC USB devices via IOKit. NUT's usbhid-ups driver (which uses libusb) couldn't claim the device even as root.

Resolution for the first Mac (15.7.3):

  1. Disable SIP (boot into Recovery → csrutil disable)
  2. Disable ioupsd via sudo launchctl disable system/com.apple.ioupsd + reboot
  3. Sign the usbhid-ups binary with the com.apple.vm.device-access entitlement (ad-hoc codesigning)
  4. Run the driver in foreground mode (-FF flag) — daemonizing lost the USB claim
  5. Set allow_killpower = 1 in ups.conf
  6. Discover that APC UPS shutdown commands only work when the UPS is on battery (not on AC power)

This worked. NUT ran, upsd served on localhost:3493, and nut-client from Node.js could read status and send shutdown.default commands.


Phase 2: Replicating on a Second Mac Mini (macOS 15.7.4)

Attempting the exact same setup on a second Mac mini running macOS 15.7.4 (one point release newer) failed spectacularly.

New obstacle — AMFI (Apple Mobile File Integrity). The ad-hoc signed entitlement was rejected at runtime (zsh: killed). The first Mac on 15.7.3 didn't need AMFI disabled; 15.7.4 tightened validation. Fix: sudo nvram boot-args="amfi_get_out_of_my_way=1" + reboot + re-sign the binary.

New obstacle — DriverKit AppleUserHIDDrivers. Even with ioupsd disabled and AMFI off, a new macOS service appeared: com.apple.driverkit.AppleUserHIDDrivers-0x<dynamic_hex>. This DriverKit user-space HID driver replaced the old ioupsd mechanism for claiming USB UPS devices. Key problems:

  • The service ID changes on every reboot (dynamic hex suffix), so launchctl disable can't target it persistently
  • launchctl bootout could kill it, but it respawns immediately
  • Repeatedly killing it crashed the Mac (it also handles keyboard/mouse HID)
  • The IOKit matching system holds an active driver instance on the device that can't be unloaded from userspace

Attempted and failed approaches on 15.7.4:

  • kextunload of IOHIDPowerSource — blocked by reference from AppleBTM kext which has an active IORegistry match
  • Dynamic bootout script with race-condition timing — crashed the Mac
  • macosx-ups NUT driver (uses Apple's IOPowerSources API) — works for monitoring only, shutdown commands are hardcoded as unsupported in the source code
  • UTM/QEMU Linux VM with USB passthrough — macOS claims the device before the VM sees it
  • USB/IP forwarding — no mature macOS tools

Phase 3: The apcupsd Detour

Since NUT/libusb was fundamentally blocked on 15.7.4, we tried apcupsd which has a native macOS IOKit HID backend (doesn't need libusb):

Problem — kext installation blocked. apcupsd needs ApcupsdDummy.kext installed in /System/Library/Extensions/, but:

  • The system volume is a read-only APFS sealed snapshot (SSV) on Apple Silicon
  • Even with SIP off, mount -uw / fails with "Permission denied"
  • Would need csrutil authenticated-root disable on top of csrutil disable
  • Even then, the kext "does not work as expected" per brew's own caveats — requires physical USB replug after every reboot

apcupsd capabilities if it worked:

  • Read status: yes, via node-apcaccess npm over NIS (port 3551)
  • Send commands: no network interface — must shell out to apccontrol/apcupsd --killpower
  • Has built-in graceful shutdown flow with configurable timing (ONBATTERYDELAY, BATTERYLEVEL, MINUTES, KILLDELAY)

Phase 4: Root Cause Identification

The breakthrough was comparing macOS versions:

First Mac Second Mac
macOS version 15.7.3 15.7.4
UPS daemon ioupsd (old, permanently disableable) AppleUserHIDDrivers (DriverKit, respawns with dynamic ID)
SIP off sufficient? Yes No (also needs AMFI off)
NUT works? ✅ Yes ❌ No — IOKit matching is undefeatable

Apple deliberately tightened DriverKit UPS handling in macOS 15.7.4. No search results or NUT GitHub issues (#2827, #2642) found a workaround. Both issues remain open with no fixes.


Final Conclusions

For macOS 15.7.3 — NUT Works

The complete working setup requires:

  1. SIP disabled (csrutil disable in Recovery)
  2. AMFI disabled (sudo nvram boot-args="amfi_get_out_of_my_way=1") — needed on some builds
  3. ioupsd disabled (sudo launchctl disable system/com.apple.ioupsd + reboot)
  4. Binary signed with com.apple.vm.device-access entitlement
  5. Driver run in foreground (-FF flag)
  6. allow_killpower = 1 in ups.conf
  7. Shutdown commands only work on battery (APC hardware behavior)

Node.js integration via nut-client on localhost:3493 provides full read+write access including shutdown.default.

For macOS 15.7.4+ — No Solution Exists

The recommended path is to downgrade to macOS 15.7.3 via IPSW restore (Apple Configurator 2 or DFU mode) and pin the OS version with automatic updates disabled. This is the correct architecture for M2L2 as a fixed deployment appliance.

Alternative Architectures Discussed (All Rejected for M2L2)

Alternative Why rejected
Hermes Pi as NUT host Out of scope for M2L2 deployment
OpenWrt on router M2L2 uses TP-Link Deco (no OpenWrt)
Android TV box as USB host Not explored fully, but possible
pmset from Node.js Read-only — can't send UPS commands
macosx-ups driver Monitoring only — shutdown hardcoded as unsupported
apcupsd on macOS Kext install blocked by SSV; no network command interface
Linux VM with USB passthrough Host claims device before VM sees it

Key Takeaways

  • macOS on Apple Silicon is fundamentally hostile to third-party USB HID device control. Three independent security layers (SIP, AMFI, SSV/DriverKit) all conspire to prevent it.
  • NUT's usbhid-ups uses libusb, and libusb's own FAQ explicitly says "do not use libusb with HID devices under macOS."
  • Version pinning is the correct M2L2 strategy — all units should ship on the same known-good macOS version (15.7.3) with updates permanently disabled.
  • The nut-client npm package works perfectly once NUT's upsd is running — the entire battle was about getting the USB driver layer working, not the network protocol layer.
  • APC UPS devices ignore shutdown commands while on AC power — this is normal hardware behavior, not a software bug.

Chat Link

Claude chat

Closing this PR is favor of exploring capabilities of node-hid only (limiting dependencies to nodejs only again, and relying on what MacOS offers)

Understood. Acknowledging that this work is now obsolete and stopping work on this task. Using nut-client on modern macOS versions (Apple Silicon, 15.7.4+) is not viable due to SIP, AMFI, and DriverKit fundamentally blocking libusb access to HID devices. I will stop here so we can pivot to exploring a node-hid solution as requested.

RoiArthurB added a commit that referenced this pull request Mar 24, 2026
- Using `node-hid` to communicate with the UPS
  - Detailed explanation can be found here : #126 (comment)
- Add reverse engineered library to communicate exactly with APC Back-UPS BX2200MI
- Add a test file to check communication with UPS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant