Skip to content

nut-ups-prometheus

Modules

A drop-in NixOS module that wires a locally-attached UPS into NUT (Network UPS Tools) and the Prometheus NUT exporter, with everything bound to 127.0.0.1. It captures two traps that bite people the first time they do this, and turns them into options.

What problem it solves

You have a UPS plugged into a machine over USB and you want:

  • NUT to talk to it (upsc ups@localhost returns live telemetry), and
  • Prometheus to scrape battery charge / runtime, input & output voltage, load, temperature, ups.status, etc.

The exporter and the NUT server both listen only on loopback, so nothing is exposed on the network. Remote scraping goes over a VPN or a reverse proxy of your choosing — the module makes no assumptions about that.

The two traps (why this exists)

1. nutdrv_qx misidentifies the device under port = "auto"

usbhid-ups (the default) autodetects most APC Back-UPS / Smart-UPS USB models fine. But cheap and OEM "megatec"-protocol units driven by nutdrv_qx are frequently misidentified when the port is left on auto — the driver grabs the wrong USB device or fails to match at all.

The fix is an explicit USB match. Run lsusb, find the xxxx:yyyy for the UPS, and set:

services.nut-ups-prometheus = {
  driver    = "nutdrv_qx";
  vendorid  = "0001";   # the part before the colon in lsusb
  productid = "0000";   # the part after
};

2. Auto-shutdown, deliberately disabled

By default NUT will power the host down when the battery reaches critical. That is often not what you want: for machines that should ride the battery all the way out, an automated shutdown is worse than the outage.

This module defaults to disableAutoShutdown = true, which sets MINSUPPLIES = 0 and replaces SHUTDOWNCMD with a command that only writes a log line. A critical-battery event is recorded but never triggers a shutdown.

Set disableAutoShutdown = false to get the normal, shut-the-host-down behaviour.

Usage

{
  imports = [ ./modules/nut-ups-prometheus ];

  services.nut-ups-prometheus = {
    enable = true;

    # A file containing the NUT monitor-user password. Provide it via any
    # secrets mechanism (plain file, sops-nix, agenix, …). Must be readable
    # by the NUT daemons.
    passwordFile = "/run/secrets/nut-ups-password";

    # APC USB models usually just work with the defaults:
    #   driver = "usbhid-ups";  port = "auto";
    #
    # For an OEM/megatec unit, pin the driver + USB ids instead:
    # driver    = "nutdrv_qx";
    # vendorid  = "0001";
    # productid = "0000";
  };
}

Then confirm with upsc ups@localhost and scrape http://127.0.0.1:9199/ups_metrics from Prometheus.

Options

Option Default Purpose
enable false Turn the module on.
passwordFile (required) Path to the file holding the monitor user's password.
upsName "ups" NUT instance name; addressed as <name>@localhost.
driver "usbhid-ups" NUT driver. Use nutdrv_qx for OEM/megatec units.
port "auto" Driver port. auto = USB autodetect.
vendorid / productid null Explicit USB match — required when auto misidentifies the device.
monUser "monuser" Name of the upsmon monitor user.
disableAutoShutdown true Ride the battery out instead of shutting down on critical battery.
description "Local UPS" Free-text description in ups.conf.
extraConfig "" Lines appended verbatim to the UPS's ups.conf section.
exporterPort 9199 prometheus-nut-exporter port (bound to 127.0.0.1).

Caveats

  • Localhost only. The exporter is bound to 127.0.0.1 explicitly. upsd gets no LISTEN directive from this module, which leaves it on NUT's own default of loopback — if you add power.ups.upsd.listen entries yourself you are widening that. Remote Prometheus scraping must go over a VPN or reverse proxy you set up yourself.
  • passwordFile permissions. The file must be readable by the NUT service accounts. Mode 0440 with owner/group matching the NUT services is a safe default.
  • extraConfig is an escape hatch. Driver settings not covered by the named options (polling intervals, battery voltage overrides, …) go there and are appended verbatim to the UPS's ups.conf section.

Source

modules/nut-ups-prometheus/default.nix
# nut-ups-prometheus
#
# Wire a locally-attached UPS into NUT (Network UPS Tools) plus the Prometheus
# NUT exporter, everything bound to localhost. Two real-world traps are baked in
# as options:
#
#   1. Cheap / OEM "megatec"-style UPSes driven by `nutdrv_qx` are frequently
#      misidentified under `port = "auto"`, so they need an explicit
#      `vendorid`/`productid` USB match.
#   2. Auto-shutdown can be deliberately disabled (`MINSUPPLIES = 0` plus a
#      log-only `SHUTDOWNCMD`) so a critical-battery event rides the battery out
#      instead of powering the host down.
#
# Drop-in NixOS module. Import it and set at minimum `enable` and
# `passwordFile`.

{ config, lib, pkgs, ... }:

with lib;

let
  cfg = config.services.nut-ups-prometheus;
in
{
  options.services.nut-ups-prometheus = {
    enable = mkEnableOption "NUT-managed UPS + Prometheus exporter (localhost-only)";

    upsName = mkOption {
      type = types.str;
      default = "ups";
      description = "NUT UPS instance name. Used as `<name>@localhost` in upsc / dashboards.";
    };

    driver = mkOption {
      type = types.str;
      default = "usbhid-ups";
      description = ''
        NUT driver. `usbhid-ups` autodetects most APC Back-UPS / Smart-UPS USB
        models. Cheap / OEM "megatec"-protocol units typically need
        `nutdrv_qx` together with an explicit `vendorid`/`productid`.
      '';
    };

    port = mkOption {
      type = types.str;
      default = "auto";
      description = "NUT driver port (`auto` = USB autodetect).";
    };

    vendorid = mkOption {
      type = types.nullOr types.str;
      default = null;
      example = "0001";
      description = ''
        Optional USB vendor ID. Drivers such as `nutdrv_qx` often misidentify
        the device under `port = "auto"`, so pin the exact USB match here. Find
        it with `lsusb` (the `xxxx:yyyy` before the colon is the vendor ID).
      '';
    };

    productid = mkOption {
      type = types.nullOr types.str;
      default = null;
      example = "0000";
      description = "Optional USB product ID, paired with `vendorid`.";
    };

    description = mkOption {
      type = types.str;
      default = "Local UPS";
      description = "Human-readable description written to the UPS's `ups.conf` section.";
    };

    monUser = mkOption {
      type = types.str;
      default = "monuser";
      description = "NUT monitor user (the upsmon client that reads UPS state).";
    };

    passwordFile = mkOption {
      type = types.path;
      example = "/run/secrets/nut-ups-password";
      description = ''
        Path to a file containing the password for `monUser`. Provide it via
        whatever secrets mechanism you use (plain file, sops-nix, agenix, …).
        The file must be readable by the NUT daemons (mode 0440, owner/group
        matching the NUT service is a safe choice).
      '';
    };

    disableAutoShutdown = mkOption {
      type = types.bool;
      default = true;
      description = ''
        When true (the default), a critical-battery event does NOT power the
        host down: `MINSUPPLIES = 0` and `SHUTDOWNCMD` only logs. The machine
        rides the battery out. Set to false for the normal NUT behaviour of
        shutting the host down when the battery hits critical.
      '';
    };

    extraConfig = mkOption {
      type = types.lines;
      default = "";
      description = ''
        Extra lines appended verbatim to this UPS's section in `ups.conf` —
        escape hatch for driver-specific settings not covered by the named
        options above.
      '';
    };

    exporterPort = mkOption {
      type = types.port;
      default = 9199;
      description = "prometheus-nut-exporter listen port (bound to 127.0.0.1).";
    };
  };

  config = mkIf cfg.enable {
    power.ups = {
      enable = true;
      mode = "standalone";

      ups.${cfg.upsName} = {
        inherit (cfg) driver port description;
        directives =
          lib.optional (cfg.vendorid != null) ''vendorid = "${cfg.vendorid}"''
          ++ lib.optional (cfg.productid != null) ''productid = "${cfg.productid}"''
          ++ lib.optional (cfg.extraConfig != "") cfg.extraConfig;
      };

      users.${cfg.monUser} = {
        upsmon = "primary";
        passwordFile = cfg.passwordFile;
      };

      upsmon = {
        monitor.${cfg.upsName} = {
          system = "${cfg.upsName}@localhost";
          user = cfg.monUser;
          passwordFile = cfg.passwordFile;
          type = "primary";
          powerValue = 0;
        };
        settings = mkIf cfg.disableAutoShutdown {
          # Ride the battery out: never trigger an automated shutdown.
          MINSUPPLIES = 0;
          SHUTDOWNCMD = ''"${pkgs.util-linux}/bin/logger -t upsmon ALERT: UPS reports critical battery; auto-shutdown intentionally disabled"'';
        };
      };
    };

    # Silence the "no discharge estimate" init warning some drivers emit.
    systemd.services.upsdrv.environment.NUT_QUIET_INIT_NDE_WARNING = "true";

    services.prometheus.exporters.nut = {
      enable = true;
      listenAddress = "127.0.0.1";
      port = cfg.exporterPort;
      nutServer = "127.0.0.1";
      nutVariables = [
        "battery.charge"
        "battery.runtime"
        "battery.voltage"
        "battery.voltage.high"
        "battery.voltage.low"
        "battery.voltage.nominal"
        "input.voltage"
        "input.voltage.nominal"
        "input.frequency"
        "input.frequency.nominal"
        "output.voltage"
        "output.voltage.nominal"
        "output.current"
        "output.current.nominal"
        "output.frequency"
        "output.frequency.nominal"
        "output.powerfactor"
        "ups.load"
        "ups.power.nominal"
        "ups.temperature"
        "ups.status"
      ];
    };
  };
}