apc-ups-hid
v0.1.2
Published
Direct HID control and monitoring of APC UPS devices from Node.js, without NUT or libusb.
Maintainers
Readme
apc-ups-hid
Read status from, and send commands to, APC UPS devices directly over USB HID from Node.js.
It talks to the UPS through node-hid (hidapi), so it needs neither NUT (Network UPS Tools) nor a libusb driver detach. On macOS that matters: hidapi goes through IOKit and coexists with the built-in DriverKit UPS driver instead of fighting it, so no SIP changes, AMFI flags, or entitlement signing are required.
This library is extracted from the SIMPLE Project's WebPlatform, where it powers the M2L2 portable classroom's power monitoring.
Status
Developed and tested against (only) an APC Back-UPS BX2200MI (vendorId 0x051d, productId 0x0002) on Linux, macOS and windows.
The HID report IDs are specific to this device family they were mapped from the NUT apc-hid.c source plus empirical probing. Other APC models are likely to work, but the report map may differ; see Verifying a new model.
Install
npm install apc-ups-hidNode.js 20 or newer. node-hid is a native addon: it ships prebuilt binaries for common platforms and falls back to compiling from source (which needs a C++ toolchain and, on Linux, libudev-dev).
Linux permissions
hidraw devices are root-only by default. To use the UPS as a normal user, add a udev rule:
# /etc/udev/rules.d/50-apc-ups.rules
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="051d", ATTRS{idProduct}=="0002", MODE="0666"Then reload and re-plug the device:
sudo udevadm control --reload-rules && sudo udevadm triggermacOS and Windows need no special setup.
Quick start
import { ApcUpsHid } from "apc-ups-hid";
const ups = new ApcUpsHid(); // opens the first matching device
console.log(ups.info); // { manufacturer, product, serialNumber, path }
console.log(ups.getStatus()); // full snapshot, one call
ups.on("power-lost", () => console.log("Running on battery"));
ups.on("power-restored", () => console.log("AC is back"));
ups.on("battery-critical", (charge) => console.log(`Battery at ${charge}% - shutting down`));
ups.startPolling(5000); // emits 'status' every 5s and the transition events above
// later
ups.close();CommonJS works too:
const { ApcUpsHid } = require("apc-ups-hid");Listing devices without opening one
import { ApcUpsHid } from "apc-ups-hid";
for (const device of ApcUpsHid.listDevices()) {
console.log(device.product, device.serialNumber);
}API
new ApcUpsHid(options?)
| Option | Type | Default | Description |
| ----------- | --------- | -------- | -------------------------------------------------------- |
| vendorId | number | 0x051d | USB vendor ID (APC). |
| productId | number | 0x0002 | USB product ID. |
| autoOpen | boolean | true | Open the device in the constructor. false defers to open(). |
open() throws if no matching device is present, so with autoOpen: true the constructor itself can throw.
Connection
| Member | Description |
| --------------------------------------------- | ------------------------------------------------------------------ |
| open(): void | Open the HID handle. No-op when already open. Emits connected. |
| close(): void | Stop polling and release the handle. Emits disconnected. |
| isConnected: boolean | Whether the handle is currently open. |
| info: UpsDeviceInfo \| null | Manufacturer, product, serial number and HID path. |
| static listDevices(vendorId?, productId?) | Enumerate matching devices without opening them (deduplicated by path). |
Reading
| Method | Returns | Description |
| ------------------------------- | ----------- | ------------------------------------------------------ |
| getStatus() | UpsStatus | Every field below, read in one pass. |
| getBatteryCharge() | number | Charge percentage, 0–100. |
| getRuntimeSeconds() | number | Estimated runtime on battery, in seconds. |
| isOnAC() | boolean | Whether mains power is present. |
| getInputVoltage() | number | Input voltage (coarse, 1-byte report). |
| getShutdownTimer() | number | Seconds until the armed shutdown, or -1 if not armed. |
| getBeeperStatus() | BeeperState | "disabled" \| "enabled" \| "muted" \| "unknown". |
| getStartupDelay() | number | Seconds the UPS waits before restoring output. |
UpsStatus fields:
| Field | Type | Description |
| ---------------------- | ------------- | ------------------------------------------------------------ |
| batteryCharge | number | Charge percentage, 0–100. |
| runtimeSeconds | number | Estimated runtime remaining, in seconds. |
| runtimeMinutes | number | Same value in minutes, rounded to one decimal. |
| inputVoltage | number | Coarse input voltage. |
| inputVoltagePrecise | number | Input voltage from the UPS ADC; drops to 0 on battery. |
| acPresent | boolean | Mains power present. |
| onBattery | boolean | Inverse of acPresent. |
| shutdownTimer | number | Seconds until shutdown, -1 when not armed. |
| beeperStatus | BeeperState | Audible alarm state. |
| nominalPowerWatts | number | Configured active power, in watts. |
| lowVoltageTransfer | number | Low transfer point, in volts. |
| highVoltageTransfer | number | High transfer point, in volts. |
| fullChargeCapacity | number | Full-charge capacity percentage. |
| timestamp | number | Date.now() at the time of the read. |
Commands
| Method | Description |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| shutdown(delaySeconds) | Arm the shutdown timer (1–32767 s). Throws outside that range. |
| cancelShutdown() | Disarm it (writes -1). |
| setBeeper(state) | "enabled", "muted" or "disabled". Throws on anything else. |
| setStartupDelay(seconds) | Seconds to wait after AC returns before restoring output. |
APC devices ignore
shutdown()while on AC power. The timer is only honoured when the UPS is running on battery. Arming it on mains and expecting the output to cut is the most common surprise with this hardware.
Polling and events
startPolling(intervalMs = 5000) polls immediately, then on every interval. The first poll establishes the baseline, so no transition event fires for it. stopPolling() stops; isPolling reports the current state.
| Event | Payload | Fired when |
| ------------------ |------------------| ----------------------------------------------------------------- |
| status | UpsStatus | Every poll tick. |
| power-lost | - | acPresent goes true → false. |
| power-restored | - | acPresent goes false → true. |
| battery-low | charge: number | On battery, charge crosses down through 20%. |
| battery-critical | charge: number | On battery, charge crosses down through 10%. |
| connected | UpsDeviceInfo | The device is opened. |
| disconnected | - | The device is closed (or lost). |
| error | Error | A read fails mid-poll. Polling continues; it does not throw. |
Battery events fire once per crossing, not on every tick below the threshold, and never while on AC power. A drop past both thresholds in a single tick emits battery-critical only.
A read error usually means the USB cable was pulled. A robust consumer closes and retries:
ups.on("error", () => {
ups.close(); // triggers 'disconnected'
});
ups.on("disconnected", () => setTimeout(() => ups.open(), 5000));HID report map
Read with getFeatureReport(id, length); the first byte of the response is the report ID itself. Multi-byte values are little-endian.
| Report | ID | Bytes | Meaning |
| ------ | ------ | ----- | ---------------------------------------------- |
| REMAINING_CAPACITY | 0x0c | 1 | Battery charge, 0–100%. |
| FULL_CHARGE_CAPACITY | 0x0d | 1 | Full-charge capacity, 0–100%. |
| DESIGN_CAPACITY | 0x0e | 1 | Design capacity, 0–100%. |
| RUNTIME_TO_EMPTY | 0x0f | 2 | Runtime remaining, seconds. |
| AC_PRESENT | 0x13 | 1 | 1 = mains present, 0 = on battery. |
| DELAY_BEFORE_SHUTDOWN | 0x15 | 2 | Signed: -1 not armed, >0 seconds. |
| DELAY_BEFORE_STARTUP | 0x16 | 2 | Seconds. |
| DELAY_BEFORE_REBOOT | 0x17 | 2 | Seconds. |
| INPUT_VOLTAGE | 0x30 | 1 | Volts, approximate. |
| INPUT_VOLTAGE_PRECISE | 0x31 | 2 | Volts; reads 0 on battery. |
| LOW_VOLTAGE_TRANSFER | 0x32 | 2 | Volts. |
| HIGH_VOLTAGE_TRANSFER | 0x33 | 2 | Volts. |
| CONFIG_ACTIVE_POWER | 0x52 | 2 | Nominal output power, watts. |
| AUDIBLE_ALARM_CONTROL | 0x78 | 1 | 1 disabled, 2 enabled, 3 muted. |
| PRESENT_STATUS | 0x07 | var | Status bitfield. Not decoded by this library. |
Development
npm install
npm test # hermetic: node-hid is mocked, no hardware needed
npm run typecheck
npm run lint
npm run build # dist/esm + dist/cjs, with .d.tsFixtures
The suite replays recorded HID reports through a fake device, so it runs in CI. Two fixture kinds live in test/fixtures/:
*.jsona single snapshot (reportsplus theexpecteddecode). Every file with a non-emptyexpectedblock is picked up automatically by the decode suite.*.trace.jsona series of samples over time. The trace suite replays them throughstartPolling()and checks the emitted power events against an independent count of the AC transitions in the recording.
Both are produced from real hardware with:
npm run capture -- ac-power # snapshot
npm run capture -- power-pull --trace 60 --interval 2000 # trace; pull the plug while it runsDrop the resulting file in test/fixtures/ and it is tested automatically, no edit to the test file needed.
Verifying a new model
- Plug the UPS in and run
npm run hardware-check. It enumerates the device, reads every field, exercises the beeper, and polls for a few ticks. The shutdown step is commented out on purpose. - If the model uses a different product ID, pass it:
new ApcUpsHid({ productId: 0x0003 }), andnpm run capture -- my-model --product 0x0003. - Capture a snapshot, check the decoded values against the UPS's own display, and open a PR with the fixture.
