From c10831e2a66c4aa1098e8a84fe2f2b2ce298f89c Mon Sep 17 00:00:00 2001 From: Ignace Date: Mon, 10 Aug 2026 18:51:41 +0200 Subject: [PATCH] first POC --- README.md | 1192 +++++++++++++++++++++++++++++++++++++++++++++- requirements.txt | 4 + ups2rrd.py | 851 +++++++++++++++++++++++++++++++++ 3 files changed, 2046 insertions(+), 1 deletion(-) create mode 100644 requirements.txt create mode 100644 ups2rrd.py diff --git a/README.md b/README.md index a09cb6a..34ca2fd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1192 @@ -# ups +# 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: + +```bash +sudo apt install librrd-dev python3-dev +python3 -m pip install -r requirements.txt +``` + +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 +/usr/local/bin/ups_rrd.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 three 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 + +--- + +# 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 +* * * * * /usr/local/bin/ups_rrd.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/status-3m.png + +/var/www/html/ups/battery-1y.png +/var/www/html/ups/battery-5y.png + +/var/www/html/ups/runtime-1y.png +/var/www/html/ups/runtime-5y.png + +/var/www/html/ups/load-3m.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/status-3m.png +/ups/battery-1y.png +/ups/battery-5y.png +/ups/runtime-1y.png +/ups/runtime-5y.png +/ups/load-3m.png +/ups/power-1y.png +/ups/power-5y.png +``` + +--- + +# 19. Testing the Python program + +Run it manually: + +```bash +sudo /usr/local/bin/ups_rrd.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 + +The cron entry is: + +```cron +* * * * * /usr/local/bin/ups_rrd.py >> /var/log/ups_rrd.log 2>&1 +``` + +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 +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. +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..161cdfc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyNUTClient +rrdtool + +# The NUT server and USB driver are still required to communicate with the UPS. diff --git a/ups2rrd.py b/ups2rrd.py new file mode 100644 index 0000000..5127125 --- /dev/null +++ b/ups2rrd.py @@ -0,0 +1,851 @@ +#!/usr/bin/env python3 + +""" +APC Back-UPS BX950MI RRD monitor + +Collects UPS information from NUT every minute and stores it in RRDTool. + +Retention: + UPS status: + 3 months @ 1 minute + + Battery charge: + 1 year @ 5 minutes + 5 years @ 1 hour + + Battery runtime: + 1 year @ 5 minutes + 5 years @ 1 hour + + UPS load: + 3 months @ 1 minute + + Nominal power: + 1 year @ 5 minutes + 5 years @ 1 hour + +Requirements: + pip install -r requirements.txt + + NUT's server and USB driver must also be installed and running. + +NUT UPS name: + BX950MI +""" + +import os +import sys +import time + +import PyNUT +import rrdtool + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +UPS_NAME = "BX950MI" + +RRD_DIR = "/var/lib/rrd" +RRD_FILE = os.path.join(RRD_DIR, "bx950mi.rrd") + +GRAPH_DIR = "/var/www/html/ups" + +GRAPH_STATUS = os.path.join(GRAPH_DIR, "status-3m.png") + +GRAPH_BATTERY_1Y = os.path.join( + GRAPH_DIR, "battery-1y.png" +) + +GRAPH_BATTERY_5Y = os.path.join( + GRAPH_DIR, "battery-5y.png" +) + +GRAPH_RUNTIME_1Y = os.path.join( + GRAPH_DIR, "runtime-1y.png" +) + +GRAPH_RUNTIME_5Y = os.path.join( + GRAPH_DIR, "runtime-5y.png" +) + +GRAPH_LOAD = os.path.join( + GRAPH_DIR, "load-3m.png" +) + +GRAPH_POWER_1Y = os.path.join( + GRAPH_DIR, "power-1y.png" +) + +GRAPH_POWER_5Y = os.path.join( + GRAPH_DIR, "power-5y.png" +) + + +# UPS status -> numeric value +# +# Status is inherently categorical, so RRD stores a number. +# +# 0 = OL On Line +# 1 = OB On Battery +# 2 = LB Low Battery +# 3 = RB Replace Battery +# 4 = UNKNOWN + +STATUS_VALUES = { + "OL": 0, + "OB": 1, + "LB": 2, + "RB": 3, + "UNKNOWN": 4, +} + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + +def run_rrdtool(args): + """Call an RRDtool Python binding function.""" + + try: + operation, *operation_args = args + function = getattr(rrdtool, operation) + return function(*operation_args) + + except Exception as exc: + print( + f"RRDTool error: {exc}", + file=sys.stderr, + ) + raise + + +# --------------------------------------------------------------------------- +# NUT +# --------------------------------------------------------------------------- + +def get_ups_values(): + """Read all values from the local NUT server.""" + + try: + client = PyNUT.PyNUTClient( + host="127.0.0.1", + port=3493, + timeout=10, + ) + values = client.GetUPSVars(UPS_NAME) + + # PyNUTClient currently returns byte strings on Python 3. + return { + key.decode("utf-8") if isinstance(key, bytes) else key: + value.decode("utf-8") if isinstance(value, bytes) else value + for key, value in values.items() + } + + except Exception as exc: + print(f"NUT error: {exc}", file=sys.stderr) + return {} + + +def get_status(values): + """ + Read UPS status. + + NUT may return multiple flags, for example: + + OB LB + + We prioritize the more serious states. + """ + + value = values.get("ups.status") + + if not value: + return "UNKNOWN" + + flags = value.split() + + if "LB" in flags: + return "LB" + + if "RB" in flags: + return "RB" + + if "OB" in flags: + return "OB" + + if "OL" in flags: + return "OL" + + return "UNKNOWN" + + +def get_float(values, field): + """Read a numeric value from NUT.""" + + value = values.get(field) + + if value is None: + return None + + try: + return float(value) + + except ValueError: + print( + f"Invalid value for {field}: {value}", + file=sys.stderr, + ) + return None + + +# --------------------------------------------------------------------------- +# RRD creation +# --------------------------------------------------------------------------- + +def create_rrd(): + """ + Create the RRD database. + + The database is created only once. + + IMPORTANT: + RRD databases cannot easily have their DS/RRA layout changed. + If you change this layout later, remove the RRD and recreate it. + """ + + if os.path.exists(RRD_FILE): + return + + os.makedirs(RRD_DIR, exist_ok=True) + + now = int(time.time()) + + # + # Start two minutes in the past. + # + start = now - 120 + + command = [ + "create", + RRD_FILE, + + "--start", + str(start), + + "--step", + "60", + + # + # Data sources + # + # Heartbeat = 2 minutes. + # + + "DS:status:GAUGE:120:0:4", + + "DS:battery_charge:GAUGE:120:0:100", + + "DS:battery_runtime:GAUGE:120:0:U", + + "DS:ups_load:GAUGE:120:0:100", + + "DS:realpower_nominal:GAUGE:120:0:U", + + # + # --------------------------------------------------------------- + # STATUS + # --------------------------------------------------------------- + # + # 3 months @ 1 minute + # + # 90 * 24 * 60 = 129600 + # + + "RRA:LAST:0.5:1:129600", + + # + # --------------------------------------------------------------- + # BATTERY CHARGE / RUNTIME / POWER + # --------------------------------------------------------------- + # + + # + # 1 year @ 5 minutes + # + # 365 * 24 * 12 = 105120 + # + + "RRA:AVERAGE:0.5:5:105120", + + # + # 5 years @ 1 hour + # + # 5 * 365 * 24 = 43800 + # + + "RRA:AVERAGE:0.5:60:43800", + + # + # --------------------------------------------------------------- + # UPS LOAD + # --------------------------------------------------------------- + # + # 3 months @ 1 minute + # + + "RRA:AVERAGE:0.5:1:129600", + ] + + run_rrdtool(command) + + print(f"Created RRD: {RRD_FILE}") + + +# --------------------------------------------------------------------------- +# RRD update +# --------------------------------------------------------------------------- + +def update_rrd(): + """Read UPS values and update RRD.""" + + ups_values = get_ups_values() + + status = get_status(ups_values) + + battery_charge = get_float( + ups_values, + "battery.charge" + ) + + battery_runtime = get_float( + ups_values, + "battery.runtime" + ) + + ups_load = get_float( + ups_values, + "ups.load" + ) + + realpower_nominal = get_float( + ups_values, + "ups.realpower.nominal" + ) + + status_value = STATUS_VALUES.get( + status, + STATUS_VALUES["UNKNOWN"], + ) + + def value_or_unknown(value): + if value is None: + return "U" + + return str(value) + + values = [ + str(status_value), + + value_or_unknown( + battery_charge + ), + + value_or_unknown( + battery_runtime + ), + + value_or_unknown( + ups_load + ), + + value_or_unknown( + realpower_nominal + ), + ] + + timestamp = int(time.time()) + + update_string = ( + f"{timestamp}:" + + ":".join(values) + ) + + run_rrdtool( + [ + "update", + RRD_FILE, + update_string, + ] + ) + + print( + time.strftime( + "%Y-%m-%d %H:%M:%S" + ), + f"status={status}", + f"battery={battery_charge}", + f"runtime={battery_runtime}", + f"load={ups_load}", + f"nominal={realpower_nominal}", + ) + + +# --------------------------------------------------------------------------- +# Graph: UPS status +# --------------------------------------------------------------------------- + +def graph_status(): + """Create 3-month UPS status graph.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_STATUS, + + "--start", + str(now - 90 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - UPS Status (3 Months)", + + "--vertical-label", + "Status", + + "--lower-limit", + "0", + + "--upper-limit", + "4", + + "--rigid", + + # + # Use the LAST RRA. + # + + f"DEF:status={RRD_FILE}:status:LAST", + + "LINE2:status#0000FF:UPS status", + + # + # Labels. + # + + "COMMENT:\\n", + + "COMMENT:0 = On Line (OL)\\n", + + "COMMENT:1 = On Battery (OB)\\n", + + "COMMENT:2 = Low Battery (LB)\\n", + + "COMMENT:3 = Replace Battery (RB)\\n", + + "COMMENT:4 = Unknown\\n", + ] + ) + + +# --------------------------------------------------------------------------- +# Graph: battery charge +# --------------------------------------------------------------------------- + +def graph_battery_1y(): + """Battery charge, 1 year @ 5 minutes.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_BATTERY_1Y, + + "--start", + str(now - 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Battery Charge (1 Year)", + + "--vertical-label", + "Charge (%)", + + "--lower-limit", + "0", + + "--upper-limit", + "100", + + "DEF:charge=" + f"{RRD_FILE}:battery_charge:AVERAGE", + + "LINE2:charge#008000:Battery charge", + + "GPRINT:charge:MIN:" + "Minimum %.1lf%%", + + "GPRINT:charge:AVERAGE:" + "Average %.1lf%%", + + "GPRINT:charge:MAX:" + "Maximum %.1lf%%", + ] + ) + + +def graph_battery_5y(): + """Battery charge, 5 years @ 1 hour.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_BATTERY_5Y, + + "--start", + str(now - 5 * 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Battery Charge (5 Years)", + + "--vertical-label", + "Charge (%)", + + "--lower-limit", + "0", + + "--upper-limit", + "100", + + "DEF:charge=" + f"{RRD_FILE}:battery_charge:AVERAGE", + + "LINE2:charge#008000:Battery charge", + + "GPRINT:charge:MIN:" + "Minimum %.1lf%%", + + "GPRINT:charge:AVERAGE:" + "Average %.1lf%%", + + "GPRINT:charge:MAX:" + "Maximum %.1lf%%", + ] + ) + + +# --------------------------------------------------------------------------- +# Graph: battery runtime +# --------------------------------------------------------------------------- + +def graph_runtime_1y(): + """Battery runtime, 1 year @ 5 minutes.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_RUNTIME_1Y, + + "--start", + str(now - 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Battery Runtime (1 Year)", + + "--vertical-label", + "Runtime (minutes)", + + "DEF:runtime=" + f"{RRD_FILE}:battery_runtime:AVERAGE", + + # + # Convert seconds to minutes. + # + + "CDEF:minutes=runtime,60,/", + + "LINE2:minutes#800080:Battery runtime", + + "GPRINT:minutes:MIN:" + "Minimum %.1lf min", + + "GPRINT:minutes:AVERAGE:" + "Average %.1lf min", + + "GPRINT:minutes:MAX:" + "Maximum %.1lf min", + ] + ) + + +def graph_runtime_5y(): + """Battery runtime, 5 years @ 1 hour.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_RUNTIME_5Y, + + "--start", + str(now - 5 * 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Battery Runtime (5 Years)", + + "--vertical-label", + "Runtime (minutes)", + + "DEF:runtime=" + f"{RRD_FILE}:battery_runtime:AVERAGE", + + "CDEF:minutes=runtime,60,/", + + "LINE2:minutes#800080:Battery runtime", + + "GPRINT:minutes:MIN:" + "Minimum %.1lf min", + + "GPRINT:minutes:AVERAGE:" + "Average %.1lf min", + + "GPRINT:minutes:MAX:" + "Maximum %.1lf min", + ] + ) + + +# --------------------------------------------------------------------------- +# Graph: UPS load +# --------------------------------------------------------------------------- + +def graph_load(): + """UPS load, 3 months @ 1 minute.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_LOAD, + + "--start", + str(now - 90 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - UPS Load (3 Months)", + + "--vertical-label", + "Load (%)", + + "--lower-limit", + "0", + + "--upper-limit", + "100", + + f"DEF:load={RRD_FILE}:ups_load:AVERAGE", + + "LINE2:load#FF8000:UPS load", + + "GPRINT:load:MIN:" + "Minimum %.1lf%%", + + "GPRINT:load:AVERAGE:" + "Average %.1lf%%", + + "GPRINT:load:MAX:" + "Maximum %.1lf%%", + ] + ) + + +# --------------------------------------------------------------------------- +# Graph: nominal power +# --------------------------------------------------------------------------- + +def graph_power_1y(): + """Nominal power, 1 year @ 5 minutes.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_POWER_1Y, + + "--start", + str(now - 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Nominal Power (1 Year)", + + "--vertical-label", + "Power (W)", + + "DEF:power=" + f"{RRD_FILE}:realpower_nominal:AVERAGE", + + "LINE2:power#CC0000:Nominal power", + + "GPRINT:power:MIN:" + "Minimum %.1lf W", + + "GPRINT:power:AVERAGE:" + "Average %.1lf W", + + "GPRINT:power:MAX:" + "Maximum %.1lf W", + ] + ) + + +def graph_power_5y(): + """Nominal power, 5 years @ 1 hour.""" + + now = int(time.time()) + + run_rrdtool( + [ + "graph", + GRAPH_POWER_5Y, + + "--start", + str(now - 5 * 365 * 86400), + + "--end", + str(now), + + "--width", + "1000", + + "--height", + "350", + + "--title", + "APC BX950MI - Nominal Power (5 Years)", + + "--vertical-label", + "Power (W)", + + "DEF:power=" + f"{RRD_FILE}:realpower_nominal:AVERAGE", + + "LINE2:power#CC0000:Nominal power", + + "GPRINT:power:MIN:" + "Minimum %.1lf W", + + "GPRINT:power:AVERAGE:" + "Average %.1lf W", + + "GPRINT:power:MAX:" + "Maximum %.1lf W", + ] + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def create_graphs(): + """Generate all graphs.""" + + os.makedirs(GRAPH_DIR, exist_ok=True) + + graph_status() + + graph_battery_1y() + graph_battery_5y() + + graph_runtime_1y() + graph_runtime_5y() + + graph_load() + + graph_power_1y() + graph_power_5y() + + +def main(): + create_rrd() + + update_rrd() + + create_graphs() + + +if __name__ == "__main__": + main()