1
0
mirror of https://github.com/librenms/librenms-agent.git synced 2024-05-09 09:54:52 +00:00
Files
librenms-librenms-agent/snmp/powermon-snmp.py
2021-03-18 20:07:25 +01:00

389 lines
11 KiB
Python
Executable File

#!/usr/bin/python3
#
# Copyright(C) 2021 Ben Carbery yrebrac@upaya.net.au
#
# LICENSE - GPLv3
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# version 3. See https://www.gnu.org/licenses/gpl-3.0.txt
#
# DESCRIPTION
#
# The script attempts to determine the current power consumption of the host via
# one or more methods. The scripts should make it easier to add your own methods
# if no included one is suitable for your host machine.
#
# The script should be called by the snmpd daemon on the host machine. This is
# achieved via the 'extend' functionality in snmpd. For example, in
# /etc/snmp/snmpd.conf:
# extend powermon /usr/local/bin/powermon-snmp.py
#
# CUSTOMISING RESULTS
#
# The results can be accessed via the nsExtend MIBs from another host, e.g.
# snmpwalk -v 2c -c <community_string> <host> \
# <nsExtendConfigTable|nsExtendOutputFull|nsExtendOutput1Table>
#
# The results are returned in a JSON format suitable for graphing in LibreNMS.
# A LibreNMS 'application' is available for this purpose.
#
# The application expects to see a single top-level reading in the results in
# terms of Watts. This can be derived from a reading from one of the sub-
# components, currently the ACPI 'meter' or 'psus'. But you must tell the script
# which is the top-level or final reading you want to use in the results. This
# allows you to sum results from dual PSUs or apply your own power factor for
# example. To achieve this see the definition of 'data["reading"]' at the end
# of the script, and modify as required. Two examples are provided.
#
# If you want to track your electricity cost you should also update the cost
# per kWh value below. When you cost changes you can update the value. The
# supply rate will be returned in the results
#
# COMPATIBILITY
#
# - Linux, not tested on other OS
# - Tested on python 3.6, 3.8
#
# INSTALLATION
#
# - Sensors method: pip install PySensors
# - hpasmcli method: install hp-health package for your distribution
# - Copy this script somewhere, e.g. /usr/local/bin
# - Uncomment costPerkWh and change the value
# - Test then customise top-level reading
# - Add the 'extend' config to snmpd.conf
# - https://docs.librenms.org/Extensions/Applications/#powermon
#
# CHANGELOG
#
# 20210130 - v1.0 - initial, implemented PySensors method
# 20210131 - v1.1 - implemented hpasmcli method
# 20210204 - v1.2 - added top-level reading, librenms option
# 20210205 - v1.3 - added cents per kWh
# 20210205 - v1.4 - improvement to UI
version = 1.4
### Libraries
import getopt
import json
import os
import re
import shutil
import subprocess
import sys
### Option defaults
method = "" # must be one of methods array
verbose = False
warnings = False
librenms = True # Return results in a JSON format suitable for Librenms
# Set to false to return JSON data only
pretty = False # Pretty printing
### Globals
error = 0
errorString = ""
data = {}
result = {}
usage = (
"USAGE: "
+ os.path.basename(__file__)
+ " [-h|--help] |"
+ " [-m|--method <method>] [-N|--no-librenms] [-p|--pretty]"
+ " [-v|--verbose] [-w|--warnings] | -l|--list-methods | -h|--help"
)
methods = ["sensors", "hpasmcli"]
# costPerkWh = 0.15 # <<<< CHANGE
### General functions
def errorMsg(message):
sys.stderr.write("ERROR: " + message + "\n")
def usageError(message="Invalid argument"):
errorMsg(message)
sys.stderr.write(usage + "\n")
sys.exit(1)
def warningMsg(message):
if verbose or warnings:
sys.stderr.write("WARN: " + message + "\n")
def verboseMsg(message):
if verbose:
sys.stderr.write("INFO: " + message + "\n")
def listMethods():
global verbose
verbose = True
verboseMsg("Available methods are: " + str(methods).strip("[]"))
### Data functions
def getData(method):
if method == "sensors":
data = getSensorData()
elif method == "hpasmcli":
data = getHPASMData()
else:
usageError("You must specify a method.")
return data
def getSensorData():
global error, errorString
error = 2
errorString = "No power sensor found"
try:
import sensors
sensors.init()
except ModuleNotFoundError as e:
errorMsg(str(e))
verboseMsg("Try 'pip install PySensors'")
sys.exit(1)
except FileNotFoundError as e:
errorMsg("Module 'sensors' appears to be missing a dependancy: " + str(e))
verboseMsg("Try 'dnf install lm_sensors'")
sys.exit(1)
except:
e = sys.exc_info()
errorMsg("Module sensors is installed but failed to initialise: " + str(e))
sys.exit(1)
sdata = {}
sdata["meter"] = {}
sdata["psu"] = {}
re_meter = "^power_meter"
power_chips = []
try:
for chip in sensors.iter_detected_chips():
chip_name = str(chip)
verboseMsg("Found chip: " + chip_name)
if re.search(re_meter, chip_name):
verboseMsg("Found power meter: " + chip_name)
error = 0
errorString = ""
junk, meter_id = chip_name.split("acpi-", 1)
sdata["meter"][meter_id] = {}
for feature in chip:
feature_label = str(feature.label)
verboseMsg("Found feature: " + feature_label)
if re.search("^power", feature_label):
sdata["meter"][meter_id]["reading"] = feature.get_value()
if feature.get_value() == 0:
# warning as downstream may try to divide by 0
warningMsg("Sensors returned a zero value")
else:
# store anything else in case label is something unexpected
sdata[chip_name][feature_label] = feature.get_value()
except:
es = sys.exc_info()
error = 1
errorString = "Unable to get data: General exception: " + str(es)
finally:
sensors.cleanup()
return sdata
def getHPASMData():
global error, errorString
exe = shutil.which("hpasmcli")
# if not os.access(candidate, os.W_OK):
cmd = [exe, "-s", "show powermeter; show powersupply"]
warningMsg("hpasmcli only runs as root")
try:
output = subprocess.run(
cmd, capture_output=True, check=True, text=True, timeout=2
)
except subprocess.CalledProcessError as e:
errorMsg(str(e) + ": " + str(e.stdout).strip("\n"))
sys.exit(1)
rawdata = str(output.stdout).replace("\t", " ").replace("\n ", "\n").split("\n")
hdata = {}
hdata["meter"] = {}
hdata["psu"] = {}
re_meter = "^Power Meter #([0-9]+)"
re_meter_reading = "^Power Reading :"
re_psu = "^Power supply #[0-9]+"
re_psu_present = "^Present :"
re_psu_redundant = "^Redundant:"
re_psu_condition = "^Condition:"
re_psu_hotplug = "^Hotplug :"
re_psu_reading = "^Power :"
for line in rawdata:
if re.match(re_meter, line):
verboseMsg("found power meter: " + line)
junk, meter_id = line.split("#", 1)
hdata["meter"][meter_id] = {}
elif re.match(re_meter_reading, line):
verboseMsg("found power meter reading: " + line)
junk, meter_reading = line.split(":", 1)
hdata["meter"][meter_id]["reading"] = meter_reading.strip()
elif re.match(re_psu, line):
verboseMsg("found power supply: " + line)
junk, psu_id = line.split("#", 1)
hdata["psu"][psu_id] = {}
elif re.match(re_psu_present, line):
verboseMsg("found power supply present: " + line)
junk, psu_present = line.split(":", 1)
hdata["psu"][psu_id]["present"] = psu_present.strip()
elif re.match(re_psu_redundant, line):
verboseMsg("found power supply redundant: " + line)
junk, psu_redundant = line.split(":", 1)
hdata["psu"][psu_id]["redundant"] = psu_redundant.strip()
elif re.match(re_psu_condition, line):
verboseMsg("found power supply condition: " + line)
junk, psu_condition = line.split(":", 1)
hdata["psu"][psu_id]["condition"] = psu_condition.strip()
elif re.match(re_psu_hotplug, line):
verboseMsg("found power supply hotplug: " + line)
junk, psu_hotplug = line.split(":", 1)
hdata["psu"][psu_id]["hotplug"] = psu_hotplug.strip()
elif re.match(re_psu_reading, line):
verboseMsg("found power supply reading: " + line)
junk, psu_reading = line.split(":", 1)
hdata["psu"][psu_id]["reading"] = psu_reading.replace("Watts", "").strip()
return hdata
# Argument Parsing
try:
opts, args = getopt.gnu_getopt(
sys.argv[1:],
"m:hlNpvw",
[
"method",
"help",
"list-methods",
"no-librenms",
"pretty",
"verbose",
"warnings",
],
)
if len(args) != 0:
usageError("Unknown argument")
except getopt.GetoptError as e:
usageError(str(e))
for opt, val in opts:
if opt in ["-h", "--help"]:
print(usage)
sys.exit(0)
elif opt in ["-l", "--list-methods"]:
listMethods()
sys.exit(0)
elif opt in ["-m", "--method"]:
if val not in methods:
usageError("Invalid method: '" + val + "'")
else:
method = val
elif opt in ["-N", "--no-librenms"]:
librenms = False
elif opt in ["-p", "--pretty"]:
pretty = True
elif opt in ["-v", "--verbose"]:
verbose = True
elif opt in ["-w", "--warnings"]:
warnings = True
else:
continue
# Electricity Cost
try:
costPerkWh
except NameError:
errorMsg("cost per kWh is undefined (uncomment in script)")
sys.exit(1)
# Get data
data = getData(method)
data["supply"] = {}
data["supply"]["rate"] = costPerkWh # pylint: disable=E0602
# Top-level reading
# CUSTOMISE THIS FOR YOUR HOST
# i.e. by running with -p -n -m and see what you get and then updating where
# in the JSON data the top-level reading is sourced from
try:
# Example 1 - take reading from ACPI meter id 1
data["reading"] = data["meter"]["1"]["reading"]
# Example 2 - sum the two power supplies and apply a power factor
# pf = 0.95
# data["reading"] = str( float(data["psu"]["1"]["reading"]) \
# + float(data["psu"]["2"]["reading"]) / pf )
except:
data["reading"] = 0.0
# Build result
if librenms:
result["version"] = version
result["error"] = error
result["errorString"] = errorString
result["data"] = data
else:
result = data
# Print result
if pretty:
print(json.dumps(result, indent=2))
else:
print(json.dumps(result))