# APC Back-UPS BX950MI on Raspberry Pi 5 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 ``` 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) ```bash sudo apt install nginx rrdtool librrd-dev python3-dev python3-venv build-essential cd /opt/ups python3 -m venv --system-site-packages .venv .venv/bin/python -m pip install -r requirements.txt ``` The collector uses the virtual environment at `/opt/ups/.venv`. Calling its Python executable directly means cron does not need to activate the environment. 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 = 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 /opt/ups/ups2rrd.py ``` 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 ``` The script performs four main tasks: 1. Create the RRD database if it doesn't exist 2. Read the current UPS values 3. Update the RRD and regenerate the graphs 4. Generate the HTML dashboard --- # 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 * * * * * /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py >> /var/log/ups_rrd.log 2>&1 ``` 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 /var/www/html/ups/index.html /var/www/html/ups/week.html /var/www/html/ups/status-3m.png /var/www/html/ups/status-1w.png /var/www/html/ups/battery-1w.png /var/www/html/ups/battery-1y.png /var/www/html/ups/battery-5y.png /var/www/html/ups/runtime-1w.png /var/www/html/ups/runtime-1y.png /var/www/html/ups/runtime-5y.png /var/www/html/ups/load-1w.png /var/www/html/ups/load-3m.png /var/www/html/ups/power-1w.png /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 /ups/ /ups/week.html /ups/status-3m.png /ups/status-1w.png /ups/battery-1w.png /ups/battery-1y.png /ups/battery-5y.png /ups/runtime-1w.png /ups/runtime-1y.png /ups/runtime-5y.png /ups/load-1w.png /ups/load-3m.png /ups/power-1w.png /ups/power-1y.png /ups/power-5y.png ``` 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. ## UPS notifications The collector also sends an ntfy notification when: - The UPS status changes - Battery charge crosses below 50% - Battery charge crosses below 5% - Estimated battery runtime crosses below 5 minutes - Local Raspberry Pi shutdown is requested below 200 seconds of runtime 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. ### 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`. --- # 19. Testing the Python program Run it manually: ```bash sudo /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py ``` 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 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: ```cron * * * * * /opt/ups/.venv/bin/python /opt/ups/ups2rrd.py >> /var/log/ups_rrd.log 2>&1 ``` There is no need to run `source`, activate the virtual environment, or use a wrapper script. ## 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. 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 from PyNUTClient import PyNUT 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. ```