An Ansible role plus a Grafana dashboard for monitoring a USB UPS through
Network UPS Tools (NUT), without a
dedicated network-facing exporter. Built for cheap Megatec/Qx-protocol
boards (the nutdrv_qx driver) commonly found under budget UPS brands —
ExeGate, Ippon, PowerCom, Forza and similar OEM relabels — where the
control board often doesn't report battery.charge at all, only
battery.voltage.
Most NUT-to-Prometheus setups assume upsd is reachable over the network
and point a separate exporter pod/process at it (e.g.
hon95/nut-exporter, metric
prefix network_ups_tools_*). That's overkill for a UPS attached by USB
to a single host: upsd, the driver, and everything else can just run
locally with MODE=standalone and listen on loopback only.
This role instead:
- Installs and configures
nut-server(upsd+nutdrv_qx) as a normal systemd service, listening on127.0.0.1only. - Deploys a small Python script + systemd timer that polls the UPS via
upscevery 30 seconds (configurable) and writes the result as a.promfile into node-exporter's textfile collector directory.
Whatever already scrapes node-exporter on that host — Prometheus,
VictoriaMetrics, or anything else — picks the UPS metrics up for free. No
extra scrape target, no extra pod, no network exposure of upsd.
UPS --USB--> host: nutdrv_qx + upsd (loopback only)
|
| upsc, every 30s (systemd timer)
v
nut-textfile-exporter.py
|
v
node-exporter textfile collector (.prom file)
|
v
Prometheus / VictoriaMetrics scrape
|
v
Grafana
The exporter script doesn't hardcode a list of fields — cheap Qx boards
vary wildly in what they report, so it takes whatever upsc returns and
maps every numeric NUT variable directly: dots become underscores, the
value is passed through as-is.
| NUT variable | Prometheus metric |
|---|---|
battery.charge |
nut_battery_charge |
battery.runtime |
nut_battery_runtime |
input.voltage |
nut_input_voltage |
ups.load |
nut_ups_load |
Whatever your board doesn't report simply doesn't appear — no script
changes needed. ups.status is parsed separately into
nut_ups_status_flag{ups="...", flag="OL|OB|LB|CHRG|..."}, one series per
token, all emitted every poll (0 or 1) so the series stays continuous
instead of flapping in and out of existence. Static identity fields
(model, serial, driver) land in nut_ups_info{...}=1, and nut_up
reports whether the poll itself succeeded.
If upsc shows battery.voltage but no battery.charge, nutdrv_qx can
estimate the charge linearly between two configured voltages — see man nutdrv_qx, section BATTERY CHARGE GUESSTIMATION. The role's defaults
(nut_ups_battery_voltage_nominal/high/low) assume a single 12V SLA cell,
the typical setup for this class of hardware; override them for anything
else. Note that only .nominal has an override. counterpart in NUT —
.high/.low only exist as default.*.
To pick sane values for your board: check battery.voltage while the UPS
is on mains power and fully charged (that's your high), and use a
typical SLA cutoff (~11.0V for a 12V cell) for low.
- A Debian-family host (the role checks
ansible_os_family) with the UPS attached over USB. lsusboutput to find your board'sidVendor/idProduct— required, there's no sane default.- node-exporter already running with
--collector.textfile.directory=<nut_textfile_collector_dir>pointed at the same directory this role writes to (default:/var/lib/node_exporter/textfile_collector). - Grafana with a Prometheus-compatible datasource already scraping that node-exporter.
| Variable | Default | Notes |
|---|---|---|
nut_ups_name |
ups |
Section name in ups.conf and the ups= label on every metric. Keep it ASCII. |
nut_ups_vendorid |
(none, required) | USB idVendor of the UPS's USB bridge, from lsusb. |
nut_ups_productid |
(none, required) | USB idProduct, from lsusb. |
nut_ups_description |
(none, required) | Free-text desc = in ups.conf. |
nut_ups_battery_voltage_nominal |
12 |
See guesstimation above. |
nut_ups_battery_voltage_high |
13.7 |
Fully-charged voltage on mains, single 12V SLA cell. |
nut_ups_battery_voltage_low |
11.0 |
Typical cutoff voltage, single 12V SLA cell. |
nut_upsd_listen_address |
127.0.0.1 |
upsd bind address. |
nut_upsd_listen_port |
3493 |
upsd bind port. |
nut_textfile_collector_dir |
/var/lib/node_exporter/textfile_collector |
Must match node-exporter's --collector.textfile.directory. |
nut_textfile_poll_interval |
30s |
Timer's OnUnitActiveSec — include the unit suffix. |
ansible-playbook -i inventory examples/playbook.ymlImport grafana/nut-ups-dashboard.json
into Grafana and point it at your Prometheus/VictoriaMetrics datasource
(edit the datasource UID if it isn't VictoriaMetrics). It exposes two
template variables, Instance and UPS, so one dashboard covers any
number of hosts/UPS units reporting through this scheme. Panels for
fields your board doesn't report (e.g. ups.load, input.voltage on
boards that don't expose them) are expected to stay empty — that's not a
collection failure.
ssh <host> upsc <nut_ups_name>@localhost
ssh <host> cat /var/lib/node_exporter/textfile_collector/nut_ups.prom
curl -sG --data-urlencode 'query=nut_up' http://<prometheus>/api/v1/queryThe first command shows what your board actually reports — that tells you whether the "Load" and "Input Voltage" dashboard panels will ever show data for your specific hardware.
MIT, see LICENSE.