2026-08-10 18:51:41 +02:00
# APC Back-UPS BX950MI on Raspberry Pi 5
2026-08-10 18:31:14 +02:00
2026-08-10 18:51:41 +02:00
This document describes the working setup for monitoring an **APC Back-UPS BX950MI** connected to a **Raspberry Pi 5** over USB.
The setup uses:
- **NUT (Network UPS Tools)** for communication with the UPS
- `usbhid-ups` as the USB/HID driver
- `upsd` as the NUT server
- `upsc` to read UPS values
- **RRDTool** for historical data
- A Python script to periodically collect the UPS data and generate graphs
The UPS is connected to the Raspberry Pi using its USB data cable.
---
## 1. Hardware
UPS:
```text
American Power Conversion
Back-UPS BX950MI
```
USB identification:
```text
Vendor ID: 051d
Product ID: 0002
Serial: 9B2545A39513
```
The Raspberry Pi detects the UPS as:
```text
American Power Conversion Uninterruptible Power Supply
```
The USB device can be verified with:
```bash
lsusb
```
Expected output includes:
```text
Bus 002 Device 004: ID 051d:0002 American Power Conversion Uninterruptible Power Supply
```
More detailed information:
```bash
lsusb -v -d 051d:0002
```
The device identifies itself as:
```text
Manufacturer: American Power Conversion
Product: Back-UPS BX950MI
```
---
# 2. Install NUT
Install the NUT packages:
```bash
sudo apt install nut nut-client nut-server
```
2026-08-13 23:08:07 +02:00
Also install RRDTool for the historical data collector (the rrdtool cannot be installed in the py3.13 venv - its incompatible. so install rrdtoor plus libs on system level and create a .venv that falls back to system-level libs)
2026-08-10 18:51:41 +02:00
```bash
2026-08-13 23:08:07 +02:00
sudo apt install nginx rrdtool librrd-dev python3-dev python3-venv build-essential
2026-08-10 19:07:26 +02:00
cd /opt/ups
2026-08-13 23:08:07 +02:00
python3 -m venv --system-site-packages .venv
2026-08-10 19:07:26 +02:00
.venv/bin/python -m pip install -r requirements.txt
2026-08-10 18:51:41 +02:00
```
2026-08-10 19:07:26 +02:00
The collector uses the virtual environment at `/opt/ups/.venv` . Calling its
Python executable directly means cron does not need to activate the environment.
2026-08-10 18:51:41 +02:00
Check the NUT version:
```bash
nut-scanner --version
```
The working installation used:
```text
Network UPS Tools 2.8.1
```
---
# 3. USB detection
The Raspberry Pi kernel recognizes the UPS as a USB HID device.
The relevant kernel message is:
```text
hid-generic 0003:051D:0002.0003:
hiddev0,hidraw0:
USB HID v1.10 Device
[American Power Conversion Back-UPS BX950MI]
```
The device is therefore using the standard USB HID interface.
The relevant kernel modules are:
```bash
lsmod | grep hid
```
which showed:
```text
hid_generic
usbhid
```
The USB interface is:
```text
Interface Class: HID
Endpoint: EP 1 IN
Transfer Type: Interrupt
```
This is exactly what the NUT `usbhid-ups` driver is designed to communicate with.
---
# 4. Finding the UPS with nut-scanner
The USB scanner was eventually able to identify the UPS with:
```bash
sudo nut-scanner -U
```
The important part of the result was:
```text
[nutdev1]
driver = "usbhid-ups"
port = "auto"
vendorid = "051D"
productid = "0002"
product = "Back-UPS BX950MI"
serial = "9B2545A39513"
vendor = "American Power Conversion"
```
The important information is therefore:
```text
driver = usbhid-ups
vendorid = 051D
productid = 0002
serial = 9B2545A39513
```
The other warnings from `nut-scanner` about missing optional libraries are not relevant to this USB UPS.
For example:
```text
Cannot load SNMP library
Cannot load XML library
Cannot load AVAHI library
Cannot load IPMI library
```
Those simply mean that NUT cannot scan those other types of UPS/network devices.
---
# 5. NUT configuration
The UPS is configured in:
```text
/etc/nut/ups.conf
```
The working configuration is:
```ini
[BX950MI]
driver = usbhid-ups
port = auto
vendorid = 051D
productid = 0002
serial = 9B2545A39513
```
The name:
```text
BX950MI
```
is important.
It becomes the UPS name used by commands such as:
```bash
upsc BX950MI
```
---
# 6. NUT operating mode
The NUT operating mode is configured in:
```text
/etc/nut/nut.conf
```
The required setting is:
```ini
MODE = standalone
```
This tells NUT that the Raspberry Pi is operating as a standalone UPS monitoring system.
It allows the local NUT server (`upsd` ) and monitor to operate on the Raspberry Pi.
---
# 7. NUT users
NUT authentication is configured in:
```text
/etc/nut/upsd.users
```
The working monitoring user is:
```ini
[monuser]
password = elvis
upsmon primary
```
This user is used by `upsmon` to monitor the UPS.
The password should of course be changed if this machine is accessible to other users.
A stronger example would be:
```ini
[monuser]
password = <strong-password>
upsmon primary
```
---
# 8. NUT server
The NUT server is `upsd` .
Its job is to provide UPS information to NUT clients such as:
```text
upsc
upsmon
```
The server listens on:
```text
127.0.0.1:3493
```
The successful startup showed:
```text
listening on 127.0.0.1 port 3493
```
and:
```text
Connected to UPS [BX950MI]: usbhid-ups-BX950MI
```
This confirms that:
1. `upsd` is running
2. the UPS is defined
3. the `usbhid-ups` driver is running
4. `upsd` can communicate with the driver
Check it with:
```bash
sudo systemctl status nut-server
```
A successful status contains:
```text
Active: active (running)
```
and:
```text
Connected to UPS [BX950MI]
```
---
# 9. NUT driver
The actual USB communication is handled by:
```text
usbhid-ups
```
On this system it runs as the systemd service:
```text
nut-driver@BX950MI.service
```
Check it with:
```bash
sudo systemctl status nut-driver@BX950MI
```
The successful configuration showed:
```text
Active: active (running)
```
and:
```text
Using subdriver: APC HID 0.100
```
followed by:
```text
Startup successful
```
This is the important indication that the UPS is correctly communicating with NUT.
---
# 10. Reading UPS information
The easiest way to test the complete setup is:
```bash
upsc BX950MI
```
The working UPS returned:
```text
battery.charge: 100
battery.charge.low: 10
battery.mfr.date: 2001/01/01
battery.runtime: 3584
battery.runtime.low: 120
battery.type: PbAc
battery.voltage: 13.6
battery.voltage.nominal: 12.0
device.mfr: American Power Conversion
device.model: Back-UPS BX950MI
device.serial: 9B2545A39513
device.type: ups
input.sensitivity: medium
input.transfer.high: 295
input.transfer.low: 145
input.voltage: 234.0
input.voltage.nominal: 230
ups.beeper.status: enabled
ups.load: 0
ups.mfr: American Power Conversion
ups.mfr.date: 2025/11/16
ups.model: Back-UPS BX950MI
ups.productid: 0002
ups.realpower.nominal: 520
ups.serial: 9B2545A39513
ups.status: OL
ups.test.result: Done and passed
ups.vendorid: 051d
```
This confirms that the complete chain is working:
```text
APC UPS
│
│ USB
▼
Raspberry Pi USB
│
▼
Linux HID
│
▼
usbhid-ups
│
▼
NUT
│
▼
upsc
```
---
# 11. Important UPS values
The Python collector uses these NUT values:
## Battery charge
```text
battery.charge
```
Current example:
```text
100 %
```
This is the UPS's estimated battery charge.
---
## Battery runtime
```text
battery.runtime
```
NUT reports this in seconds.
Example:
```text
3584
```
which is approximately:
```text
59.7 minutes
```
The Python graphs convert this to minutes.
---
## UPS load
```text
ups.load
```
Example:
```text
0 %
```
This is the estimated current load on the UPS.
---
## Nominal power
```text
ups.realpower.nominal
```
Example:
```text
520 W
```
This is the nominal real-power capacity reported by the UPS.
It should not be confused with actual current power consumption.
---
## UPS status
```text
ups.status
```
Example:
```text
OL
```
The commonly encountered values are:
```text
OL = On Line
OB = On Battery
LB = Low Battery
RB = Replace Battery
```
The Python collector converts these into numeric values for RRDTool.
---
# 12. The Python RRD collector
The Python program is:
```text
2026-08-10 19:07:26 +02:00
/opt/ups/ups2rrd.py
2026-08-10 18:51:41 +02:00
```
Its job is deliberately simple:
```text
NUT
│
│ PyNUTClient
▼
Python
│
├── battery.charge
├── battery.runtime
├── ups.load
├── ups.realpower.nominal
└── ups.status
│
▼
RRDTool
│
▼
/var/lib/rrd/bx950mi.rrd
│
▼
PNG graphs
```
2026-08-10 19:07:26 +02:00
The script performs four main tasks:
2026-08-10 18:51:41 +02:00
1. Create the RRD database if it doesn't exist
2. Read the current UPS values
3. Update the RRD and regenerate the graphs
2026-08-10 19:07:26 +02:00
4. Generate the HTML dashboard
2026-08-10 18:51:41 +02:00
---
# 13. RRD database
The database is:
```text
/var/lib/rrd/bx950mi.rrd
```
RRDTool means:
```text
Round Robin Database
```
It is particularly suitable for time-series measurements such as UPS monitoring.
Instead of growing indefinitely, the database has predefined storage periods.
Old high-resolution data is automatically represented at lower resolution.
---
# 14. One-minute collection
The RRD uses a primary step of:
```text
60 seconds
```
The corresponding configuration is:
```python
"--step" ,
"60" ,
```
The cron job runs the Python script every minute:
```cron
2026-08-10 19:07:26 +02:00
* * * * * /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py >> /var/log/ups_rrd.log 2>&1
2026-08-10 18:51:41 +02:00
```
Therefore, approximately one measurement is stored every minute.
---
# 15. RRD retention
The database uses different resolutions for different periods.
## Three months
The status and load retain:
```text
1 minute
```
for approximately:
```text
90 days
```
This is useful for detecting short power failures.
For example, an event like:
```text
18:21 OL
18:22 OL
18:23 OB
18:24 OB
18:25 OB
18:26 OL
```
will clearly show up as a battery event.
---
## One year
Battery charge, battery runtime and nominal power are retained at:
```text
5 minute
```
resolution.
This is enough detail to see long-term battery behaviour without requiring every minute for the entire year.
---
## Five years
Long-term data is consolidated to:
```text
1 hour
```
resolution.
This keeps the database small while allowing trends such as battery degradation to be observed over several years.
---
# 16. Status conversion
RRDTool works with numeric values, so the Python program converts the UPS status.
The mapping is:
```python
STATUS_VALUES = {
"OL" : 0 ,
"OB" : 1 ,
"LB" : 2 ,
"RB" : 3 ,
"UNKNOWN" : 4 ,
}
```
Therefore:
```text
0 = On Line
1 = On Battery
2 = Low Battery
3 = Replace Battery
4 = Unknown
```
The graph can then display the status history.
---
# 17. Handling missing data
The Python program converts unavailable measurements into:
```text
U
```
which means:
```text
Unknown
```
in RRDTool.
For example, if NUT temporarily fails to return:
```text
battery.runtime
```
the script doesn't write a bogus zero.
Instead it writes:
```text
U
```
RRDTool will leave a gap in the graph.
This is important because:
```text
0 seconds
```
and:
```text
measurement unavailable
```
are completely different things.
---
# 18. Generated graphs
The script creates:
```text
2026-08-10 19:07:26 +02:00
/var/www/html/ups/index.html
2026-08-10 20:49:20 +02:00
/var/www/html/ups/week.html
2026-08-10 19:07:26 +02:00
2026-08-10 18:51:41 +02:00
/var/www/html/ups/status-3m.png
2026-08-10 20:49:20 +02:00
/var/www/html/ups/status-1w.png
2026-08-10 18:51:41 +02:00
2026-08-10 20:49:20 +02:00
/var/www/html/ups/battery-1w.png
2026-08-10 18:51:41 +02:00
/var/www/html/ups/battery-1y.png
/var/www/html/ups/battery-5y.png
2026-08-10 20:49:20 +02:00
/var/www/html/ups/runtime-1w.png
2026-08-10 18:51:41 +02:00
/var/www/html/ups/runtime-1y.png
/var/www/html/ups/runtime-5y.png
2026-08-10 20:49:20 +02:00
/var/www/html/ups/load-1w.png
2026-08-10 18:51:41 +02:00
/var/www/html/ups/load-3m.png
2026-08-10 20:49:20 +02:00
/var/www/html/ups/power-1w.png
2026-08-10 18:51:41 +02:00
/var/www/html/ups/power-1y.png
/var/www/html/ups/power-5y.png
```
If `/var/www/html` is served by the web server, these can therefore be viewed as:
```text
2026-08-10 19:07:26 +02:00
/ups/
2026-08-10 20:49:20 +02:00
/ups/week.html
2026-08-10 19:07:26 +02:00
2026-08-10 18:51:41 +02:00
/ups/status-3m.png
2026-08-10 20:49:20 +02:00
/ups/status-1w.png
/ups/battery-1w.png
2026-08-10 18:51:41 +02:00
/ups/battery-1y.png
/ups/battery-5y.png
2026-08-10 20:49:20 +02:00
/ups/runtime-1w.png
2026-08-10 18:51:41 +02:00
/ups/runtime-1y.png
/ups/runtime-5y.png
2026-08-10 20:49:20 +02:00
/ups/load-1w.png
2026-08-10 18:51:41 +02:00
/ups/load-3m.png
2026-08-10 20:49:20 +02:00
/ups/power-1w.png
2026-08-10 18:51:41 +02:00
/ups/power-1y.png
/ups/power-5y.png
```
2026-08-10 20:49:20 +02:00
The main responsive dashboard groups the long-term graphs on one page. The
weekly dashboard at `/ups/week.html` shows one-week history for every collected
value. The pages link to each other, refresh every 60 seconds, and open a
full-size PNG when a graph is clicked.
2026-08-10 19:07:26 +02:00
2026-08-10 21:07:53 +02:00
## UPS notifications
The collector also sends an ntfy notification when:
2026-09-10 20:46:31 +02:00
- The UPS status changes and the new status persists for 10 minutes
2026-08-15 15:52:11 +02:00
- Mean battery charge over the last 10 measurements crosses below 50%
- Mean battery charge over the last 10 measurements crosses below 5%
2026-08-10 21:07:53 +02:00
- Estimated battery runtime crosses below 5 minutes
2026-08-10 21:19:06 +02:00
- Local Raspberry Pi shutdown is requested below 200 seconds of runtime
2026-08-10 21:07:53 +02:00
Alert state is stored in `/var/lib/rrd/bx950mi-alert-state.json` , allowing
changes to be detected across cron runs. The first run establishes a baseline
without sending notifications. Threshold notifications can fire again after a
value recovers above the threshold and later drops below it.
2026-09-10 20:46:31 +02:00
Status alerts wait until the new status has persisted for 10 minutes. Returning
to the last confirmed status cancels the pending alert, so brief flip-flops
produce no status notifications. A different pending status restarts the timer.
Pending changes are saved across cron runs; alerts are sent on the first run
at or after the 10-minute mark. This delay applies only to status notifications:
battery/runtime alerts and automatic shutdowns still use the current readings.
2026-08-15 15:52:11 +02:00
Battery-charge alerts start after 10 valid measurements have been collected;
using their rolling mean prevents a single erroneous `0%` report from causing
a false alarm. Raw measurements are still stored in the RRD and graphed.
2026-08-10 21:07:53 +02:00
2026-08-10 21:19:06 +02:00
### Automatic shutdown of the Plex server
When the UPS status is `OB` or `LB` and estimated runtime is below five
minutes, the collector requests a shutdown of `plex.local` over SSH as user
`ups` . The request is sent once per outage. A failed SSH request is retried by
the next cron run, while a return to `OL` resets the shutdown state.
Generate a dedicated key on the UPS monitor:
```bash
sudo install -d -m 700 /opt/ups/.ssh
sudo ssh-keygen -t ed25519 -f /opt/ups/.ssh/plex_shutdown -N ""
sudo ssh-keyscan -H plex.local | sudo tee /opt/ups/.ssh/known_hosts
sudo chmod 600 /opt/ups/.ssh/plex_shutdown /opt/ups/.ssh/known_hosts
```
Verify the scanned SSH host-key fingerprint against `plex.local` through a
trusted channel before enabling automatic shutdown.
On `plex.local` , create the restricted account and authorize shutdown:
```bash
sudo useradd --create-home --shell /bin/bash ups
sudo visudo -f /etc/sudoers.d/ups-shutdown
```
Add this exact sudoers rule:
```sudoers
ups ALL=(root) NOPASSWD: /usr/sbin/shutdown -h now
```
Add the public key from `/opt/ups/.ssh/plex_shutdown.pub` to
`/home/ups/.ssh/authorized_keys` on `plex.local` , prefixed with a forced command
and SSH restrictions:
```text
restrict,command="sudo /usr/sbin/shutdown -h now" ssh-ed25519 AAAA... ups-shutdown
```
Verify SSH before relying on the automatic action:
```bash
sudo ssh -i /opt/ups/.ssh/plex_shutdown \
-o UserKnownHostsFile = /opt/ups/.ssh/known_hosts ups@plex.local
```
This test will shut down `plex.local` . The hostname, user, key, and known-hosts
file can be overridden for the cron job with:
```text
UPS_SHUTDOWN_HOST
UPS_SHUTDOWN_USER
UPS_SHUTDOWN_KEY
UPS_SHUTDOWN_KNOWN_HOSTS
```
### Automatic shutdown of the Raspberry Pi
When UPS status is `OB` or `LB` and estimated runtime drops below 200 seconds,
the collector sends an ntfy warning and requests shutdown of the Raspberry Pi
running the script. It calls systemd-logind through D-Bus using the `dbus-next`
Python package; it does not invoke a shell command.
Because the collector runs from root's crontab, systemd-logind permits the
power-off request. A failed request is retried by the next cron run, and the
shutdown state resets after the UPS returns to `OL` .
2026-08-10 18:51:41 +02:00
---
# 19. Testing the Python program
Run it manually:
```bash
2026-08-10 19:07:26 +02:00
sudo /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py
2026-08-10 18:51:41 +02:00
```
A successful run should produce something similar to:
```text
2026-08-10 18:00:00 status=OL battery=100.0 runtime=3584.0 load=0.0 nominal=520.0
```
Check the database:
```bash
ls -lh /var/lib/rrd/bx950mi.rrd
```
Check the most recent RRD values:
```bash
rrdtool lastupdate /var/lib/rrd/bx950mi.rrd
```
---
# 20. Checking cron
2026-08-10 19:07:26 +02:00
Install the cron entry in root's crontab because the collector writes to
`/var/lib/rrd` and `/var/www/html/ups` :
```bash
sudo crontab -e
```
Add:
2026-08-10 18:51:41 +02:00
```cron
2026-08-10 19:07:26 +02:00
* * * * * /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py >> /var/log/ups_rrd.log 2>&1
2026-08-10 18:51:41 +02:00
```
2026-08-10 19:07:26 +02:00
There is no need to run `source` , activate the virtual environment, or use a
wrapper script.
2026-08-10 20:56:51 +02:00
## Sending an ntfy notification
`ntfy_notify.py` provides both a command-line interface and a reusable Python
function. Send a message from the command line with:
```bash
/opt/ups/.venv/bin/python /opt/ups/ntfy_notify.py "Some text"
```
Use it as a Python library with:
```python
from ntfy_notify import send_notification
send_notification ( "Some text" )
```
The default topic can be overridden with `--topic-url` or the
`NTFY_TOPIC_URL` environment variable.
2026-08-10 18:51:41 +02:00
After a few minutes:
```bash
tail -f /var/log/ups_rrd.log
```
You should see a new line approximately every minute.
---
# 21. Checking NUT services
Useful commands:
```bash
sudo systemctl status nut-driver@BX950MI
```
```bash
sudo systemctl status nut-server
```
And:
```bash
sudo systemctl list-units --all 'nut*'
```
The important services are:
```text
nut-driver@BX950MI.service
nut-server.service
```
The driver should be:
```text
active (running)
```
The server should also be:
```text
active (running)
```
---
# 22. Troubleshooting
## UPS not detected
Check:
```bash
lsusb
```
The UPS should appear as:
```text
051d:0002
```
Then:
```bash
sudo nut-scanner -U
```
---
## Driver not running
Check:
```bash
sudo systemctl status nut-driver@BX950MI
```
Then:
```bash
sudo journalctl -u nut-driver@BX950MI -n 50
```
A successful driver startup contains:
```text
Using subdriver: APC HID 0.100
```
and:
```text
Startup successful
```
---
## `upsc` says connection refused
Check:
```bash
sudo systemctl status nut-server
```
If `nut-server` isn't running, start it:
```bash
sudo systemctl start nut-server
```
Then:
```bash
upsc BX950MI
```
The server should report:
```text
listening on 127.0.0.1 port 3493
```
---
# 23. USB device permissions
The UPS is a USB HID device.
NUT's `usbhid-ups` driver runs as the:
```text
nut
```
user.
The NUT systemd service therefore needs to be allowed to access the USB device.
The working setup uses the NUT/systemd configuration supplied by the Ubuntu NUT package.
It is important not to manually run the driver as root as part of the normal operation.
For example, this is useful for debugging:
```bash
sudo /lib/nut/usbhid-ups -a BX950MI -DD
```
but normal operation should be handled by:
```text
nut-driver@BX950MI.service
```
---
# 24. Current working architecture
The complete system now looks like this:
```text
APC Back-UPS BX950MI
│
│ USB
▼
Raspberry Pi 5
│
▼
Linux USB HID
│
▼
usbhid-ups
│
▼
NUT
┌─────────────┴─────────────┐
│ │
upsd upsmon
│
│ PyNUTClient
▼
Python script
│
▼
RRDTool
│
▼
bx950mi.rrd
│
▼
PNG graphs
```
The important point is that **Python does not communicate directly with the UPS** .
It communicates with the NUT server through a Python client:
```python
2026-08-10 18:58:29 +02:00
from PyNUTClient import PyNUT
2026-08-10 18:51:41 +02:00
client = PyNUT . PyNUTClient ( host = "127.0.0.1" )
values = client . GetUPSVars ( "BX950MI" )
```
This is preferable because NUT handles all of the USB/HID details.
The Python program only needs to understand normal NUT values.
---
# 25. Useful commands
Show all UPS values:
```bash
upsc BX950MI
```
Show battery charge:
```bash
upsc BX950MI battery.charge
```
Show battery runtime:
```bash
upsc BX950MI battery.runtime
```
Show UPS load:
```bash
upsc BX950MI ups.load
```
Show UPS status:
```bash
upsc BX950MI ups.status
```
Show nominal power:
```bash
upsc BX950MI ups.realpower.nominal
```
Check USB:
```bash
lsusb
```
Check NUT driver:
```bash
sudo systemctl status nut-driver@BX950MI
```
Check NUT server:
```bash
sudo systemctl status nut-server
```
Check recent driver messages:
```bash
sudo journalctl -u nut-driver@BX950MI -n 50
```
Check recent server messages:
```bash
sudo journalctl -u nut-server -n 50
```
Check RRD:
```bash
rrdtool info /var/lib/rrd/bx950mi.rrd
```
Check latest RRD values:
```bash
rrdtool lastupdate /var/lib/rrd/bx950mi.rrd
```
Check Python collector:
```bash
tail -f /var/log/ups_rrd.log
```
---
# 26. Summary
The successful setup consists of four layers:
### 1. USB
The Raspberry Pi detects:
```text
051d:0002
American Power Conversion Back-UPS BX950MI
```
### 2. NUT driver
```text
usbhid-ups
```
communicates with the UPS using USB HID.
### 3. NUT server
```text
upsd
```
provides the UPS information locally on:
```text
127.0.0.1:3493
```
### 4. Python + RRDTool
The Python program runs every minute, obtains selected values from:
```text
upsd on 127.0.0.1:3493 (through PyNUTClient)
```
and stores them in:
```text
/var/lib/rrd/bx950mi.rrd
```
RRDTool then provides historical data for:
```text
3 months @ 1 minute
1 year @ 5 minutes
5 years @ 1 hour
```
This provides a lightweight UPS monitoring system running entirely on the Raspberry Pi, without requiring an external monitoring service.
```