Source code for duct.sources.power.nut

"""
.. module:: nut
   :platform: Unix
   :synopsis: A source module for NUT (Network UPS Tools) stats

.. moduleauthor:: Colin Alston <colin@tamvera.com>
"""
import asyncio
import logging

from zope.interface import implementer

from duct.interfaces import IDuctSource
from duct.objects import Source

log = logging.getLogger(__name__)

# NUT vars to collect, mapped to (metric suffix, human description)
_UPS_METRICS = {
    'battery.charge':      ('battery.charge',      'Battery charge percent'),
    'battery.charge.low':  ('battery.charge.low',  'Battery low charge threshold'),
    'battery.runtime':     ('battery.runtime',     'Battery runtime remaining (s)'),
    'battery.voltage':     ('battery.voltage',     'Battery voltage'),
    'input.voltage':       ('input.voltage',       'Input voltage'),
    'input.frequency':     ('input.frequency',     'Input frequency'),
    'output.voltage':      ('output.voltage',      'Output voltage'),
    'output.frequency':    ('output.frequency',    'Output frequency'),
    'ups.load':            ('ups.load',            'UPS load percent'),
    'ups.temperature':     ('ups.temperature',     'UPS temperature'),
    'ups.realpower':       ('ups.realpower',       'Real power (W)'),
    'ups.power':           ('ups.power',           'Apparent power (VA)'),
}


async def _nut_list_vars(host, port, ups, timeout=10):
    """Open a TCP connection to a NUT server and return all VAR values for ups."""
    reader, writer = await asyncio.wait_for(
        asyncio.open_connection(host, port), timeout=timeout
    )
    try:
        writer.write(f'LIST VAR {ups}\n'.encode())
        await writer.drain()

        data = {}
        while True:
            line = await asyncio.wait_for(reader.readline(), timeout=timeout)
            line = line.decode().strip()
            if not line:
                continue
            if line.startswith('ERR '):
                raise IOError(line)
            if line.startswith('VAR '):
                # VAR <ups> <key> "<value>"
                parts = line.split(' ', 3)
                if len(parts) == 4:
                    key = parts[2]
                    value = parts[3].strip('"')
                    data[key] = value
            elif line.startswith('END LIST VAR'):
                break
        return data
    finally:
        writer.close()
        try:
            await writer.wait_closed()
        except Exception:
            pass


[docs] @implementer(IDuctSource) class UPS(Source): """Query UPS statistics directly from a NUT server over TCP :param ups: UPS name as configured in NUT (default: 'ups') :type ups: str. :param host: NUT server host (default: 'localhost') :type host: str. :param port: NUT server port (default: 3493) :type port: int. :param timeout: Connection timeout in seconds (default: 10) :type timeout: int. **Metrics:** :(service_name).battery.charge: Battery charge percentage :(service_name).battery.runtime: Battery runtime remaining in seconds :(service_name).battery.voltage: Battery voltage :(service_name).input.voltage: Input mains voltage :(service_name).input.frequency: Input mains frequency :(service_name).output.voltage: Output voltage :(service_name).ups.load: UPS load percentage :(service_name).ups.temperature: UPS temperature (if supported) :(service_name).ups.realpower: Real power in watts (if supported) :(service_name).ups.status: UPS status string (OL, OB, LB, etc.) """ def __init__(self, *a, **kw): Source.__init__(self, *a, **kw) self.ups = self.config.get('ups', 'ups') self.nut_host = self.config.get('host', 'localhost') self.nut_port = int(self.config.get('port', 3493)) self.timeout = int(self.config.get('timeout', 10)) def _status_state(self, status): """Map NUT ups.status flags to a duct event state.""" if not status: return 'unknown' if 'LB' in status or 'OB' in status: return 'critical' if 'ALARM' in status or 'OVER' in status: return 'warning' return 'ok'
[docs] async def get(self): try: data = await _nut_list_vars( self.nut_host, self.nut_port, self.ups, self.timeout ) except asyncio.TimeoutError: msg = f'Timeout connecting to NUT at {self.nut_host}:{self.nut_port}' log.warning(msg) return self.createEvent('critical', msg, None) except Exception as exc: msg = f'NUT error ({self.nut_host}:{self.nut_port} ups={self.ups}): {exc}' log.warning(msg) return self.createEvent('critical', msg, None) status = data.get('ups.status', '') state = self._status_state(status) events = [ self.createEvent(state, f'UPS status: {status or "unknown"}', None, prefix='ups.status') ] for var, (suffix, description) in _UPS_METRICS.items(): raw = data.get(var) if raw is None: continue try: value = float(raw) except ValueError: continue events.append(self.createEvent(state, description, value, prefix=suffix)) return events