Skip to content

Instantly share code, notes, and snippets.

@imavroukakis
Last active November 15, 2025 19:11
Show Gist options
  • Select an option

  • Save imavroukakis/164a17a79655c265aceb887978f4038f to your computer and use it in GitHub Desktop.

Select an option

Save imavroukakis/164a17a79655c265aceb887978f4038f to your computer and use it in GitHub Desktop.
A script to expose a locally attached UPS via SNMP
#!/usr/bin/env python3
import sys
import subprocess
DEBUG_LOG_FILE = "/tmp/nut_ups_mib.log"
UNKNOWN_OID_LOG_FILE = "/tmp/nut_ups_unknown_oids.log"
debug_logging = False # Set to True to enable debug logging
def debug_log(message):
if not debug_logging:
return
try:
with open(DEBUG_LOG_FILE, "a") as log_file:
log_file.write(message + "\n")
except Exception:
pass
def log_unknown_oid(oid):
"""Log requested OIDs that are not in the mapping."""
try:
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(UNKNOWN_OID_LOG_FILE, "a") as log_file:
log_file.write(f"{timestamp} - Unknown OID requested: {oid}\n")
except Exception:
pass
def status_to_source(status_str):
"""Convert NUT ups.status to RFC 1628 upsOutputSource integer.
RFC 1628 upsOutputSource values:
other(1), none(2), normal(3), bypass(4), battery(5), booster(6), reducer(7)
NUT status flags:
OL = On Line, OB = On Battery, BYPASS = On Bypass, etc.
"""
status_str = status_str.upper()
# Priority order: battery > bypass > online
if 'OB' in status_str:
return 5 # battery
elif 'BYPASS' in status_str:
return 4 # bypass
elif 'OL' in status_str:
return 3 # normal
else:
return 1 # other
def status_to_battery_status(status_str):
"""Convert NUT ups.status to RFC 1628 upsBatteryStatus integer.
RFC 1628 upsBatteryStatus values:
unknown(1), batteryNormal(2), batteryLow(3), batteryDepleted(4)
"""
status_str = status_str.upper()
if 'OB' in status_str and 'LB' in status_str:
return 3 # batteryLow
elif 'OB' in status_str:
return 2 # batteryNormal (on battery but not low)
elif 'OL' in status_str:
return 2 # batteryNormal (charging or charged)
else:
return 1 # unknown
def beeper_to_audible_status(beeper_str):
"""Convert NUT ups.beeper.status to RFC 1628 upsConfigAudibleStatus.
RFC 1628 upsConfigAudibleStatus values:
disabled(1), enabled(2), muted(3)
"""
beeper_str = beeper_str.lower()
if 'disabled' in beeper_str:
return 1 # disabled
elif 'muted' in beeper_str:
return 3 # muted
elif 'enabled' in beeper_str:
return 2 # enabled
else:
return 1 # disabled (default)
# Mapping from UPS MIB OIDs (based on RFC 1628) to a tuple:
# (NUT key, conversion function, SNMP type)
# Only includes mappings where the NUT key exists in upsc output
mapping = {
# Identification group (.1.3.6.1.2.1.33.1.1)
'.1.3.6.1.2.1.33.1.1.1.0': ('ups.mfr', lambda x: x, "string"), # upsIdentManufacturer
'.1.3.6.1.2.1.33.1.1.2.0': ('ups.model', lambda x: x, "string"), # upsIdentModel
'.1.3.6.1.2.1.33.1.1.3.0': ('ups.firmware', lambda x: x, "string"), # upsIdentUPSSoftwareVersion
'.1.3.6.1.2.1.33.1.1.4.0': ('driver.version', lambda x: x, "string"), # upsIdentAgentSoftwareVersion
'.1.3.6.1.2.1.33.1.1.5.0': ('ups.serial', lambda x: x, "string"), # upsIdentName (using for serial)
# Battery group (.1.3.6.1.2.1.33.1.2)
'.1.3.6.1.2.1.33.1.2.1.0': ('ups.status', status_to_battery_status, "integer"), # upsBatteryStatus (derived from status)
'.1.3.6.1.2.1.33.1.2.3.0': ('battery.runtime', lambda x: int(float(x)), "integer"), # upsEstimatedMinutesRemaining
'.1.3.6.1.2.1.33.1.2.4.0': ('battery.charge', lambda x: int(float(x)), "integer"), # upsEstimatedChargeRemaining (0-100%)
'.1.3.6.1.2.1.33.1.2.5.0': (None, lambda x: 0, "integer"), # upsBatteryCurrent (not available)
'.1.3.6.1.2.1.33.1.2.6.0': (None, lambda x: 0, "integer"), # upsBatteryTemperature (not available)
'.1.3.6.1.2.1.33.1.2.7.0': ('battery.type', lambda x: x, "string"), # upsBatteryTemperature (repurposed for type)
# Input group (.1.3.6.1.2.1.33.1.3)
# Note: RFC 1628 uses tables for multi-line, using simplified scalar approach
'.1.3.6.1.2.1.33.1.3.2.0': ('output.frequency', lambda x: int(float(x)*10), "integer"), # upsInputFrequency (using output freq as approximation)
'.1.3.6.1.2.1.33.1.3.3.1.3.1': ('input.voltage', lambda x: int(float(x)), "integer"), # upsInputVoltage (line 1)
# Output group (.1.3.6.1.2.1.33.1.4)
'.1.3.6.1.2.1.33.1.4.1.0': ('ups.status', status_to_source, "integer"), # upsOutputSource (OL=3, OB=5)
'.1.3.6.1.2.1.33.1.4.2.0': ('output.frequency', lambda x: int(float(x)*10), "integer"), # upsOutputFrequency (in 0.1 Hz)
'.1.3.6.1.2.1.33.1.4.3.0': (None, lambda x: 1, "integer"), # upsOutputNumLines (hardcoded to 1)
'.1.3.6.1.2.1.33.1.4.4.1.2.1': ('output.voltage', lambda x: int(float(x)), "integer"), # upsOutputVoltage (line 1)
'.1.3.6.1.2.1.33.1.4.4.1.4.1': ('ups.realpower', lambda x: int(float(x)), "integer"), # upsOutputPower (line 1, in watts)
'.1.3.6.1.2.1.33.1.4.4.1.5.1': ('ups.load', lambda x: int(float(x)), "integer"), # upsOutputPercentLoad (line 1)
# Bypass group (.1.3.6.1.2.1.33.1.5)
'.1.3.6.1.2.1.33.1.5.2.0': ('output.frequency', lambda x: int(float(x)*10), "integer"), # upsBypassFrequency (using output freq)
# Bypass additional (.1.3.6.1.2.1.33.1.6)
'.1.3.6.1.2.1.33.1.6.3.8': (None, lambda x: 0, "integer"), # Non-standard OID
# Test/Alarm group (.1.3.6.1.2.1.33.1.7)
'.1.3.6.1.2.1.33.1.7.1.0': (None, lambda x: 0, "integer"), # upsAlarmsPresent (hardcoded to 0)
'.1.3.6.1.2.1.33.1.7.3.0': (None, lambda x: 1, "integer"), # upsAlarmDescr (hardcoded)
# Control group (.1.3.6.1.2.1.33.1.8)
'.1.3.6.1.2.1.33.1.8.2.0': ('ups.delay.start', lambda x: int(float(x)), "integer"), # upsShutdownAfterDelay
'.1.3.6.1.2.1.33.1.8.3.0': ('ups.delay.shutdown', lambda x: int(float(x)), "integer"), # upsStartupAfterDelay
'.1.3.6.1.2.1.33.1.8.4.0': ('ups.delay.shutdown', lambda x: int(float(x)), "integer"), # upsRebootWithDuration
'.1.3.6.1.2.1.33.1.8.5.0': (None, lambda x: 0, "integer"), # upsAutoRestart
# Config group (.1.3.6.1.2.1.33.1.9)
'.1.3.6.1.2.1.33.1.9.1.0': ('battery.runtime', lambda x: int(float(x)), "integer"), # upsConfiguredAlarmTime
'.1.3.6.1.2.1.33.1.9.2.0': ('input.voltage', lambda x: int(float(x)), "integer"), # upsConfigInputVoltage
'.1.3.6.1.2.1.33.1.9.3.0': ('output.frequency.nominal', lambda x: int(float(x)*10), "integer"), # upsConfigInputFreq
'.1.3.6.1.2.1.33.1.9.4.0': ('output.voltage.nominal', lambda x: int(float(x)), "integer"), # upsConfigOutputVoltage
'.1.3.6.1.2.1.33.1.9.5.0': ('output.frequency.nominal', lambda x: int(float(x)*10), "integer"), # upsConfigOutputFreq (in 0.1 Hz)
'.1.3.6.1.2.1.33.1.9.6.0': ('ups.power.nominal', lambda x: int(float(x)), "integer"), # upsConfigOutputVA
'.1.3.6.1.2.1.33.1.9.7.0': ('ups.realpower.nominal', lambda x: int(float(x)), "integer"), # upsConfigOutputPower
'.1.3.6.1.2.1.33.1.9.8.0': ('battery.charge.low', lambda x: int(float(x)), "integer"), # upsConfigLowBattTime
'.1.3.6.1.2.1.33.1.9.9.0': ('ups.beeper.status', beeper_to_audible_status, "integer"), # upsConfigAudibleStatus
'.1.3.6.1.2.1.33.1.9.10.0': (None, lambda x: 0, "integer"), # upsConfigLowVoltageTransferPoint
}
def get_nut_values():
try:
debug_log("Calling upsc to get UPS values.")
output = subprocess.check_output(['/usr/bin/upsc', 'eaton'],
universal_newlines=True,
stderr=subprocess.STDOUT)
debug_log("upsc output:\n" + output)
except Exception as e:
debug_log("upsc call failed: " + str(e))
return {}
values = {}
for line in output.splitlines():
if "Init SSL without certificate database" in line:
debug_log("Skipping SSL init line: " + line)
continue
if ':' in line:
key, value = line.split(':', 1)
values[key.strip()] = value.strip()
debug_log("Parsed UPS values: " + str(values))
return values
def main():
# Always ignore the first argument (-g) and use the second as the requested OID.
if len(sys.argv) < 3:
debug_log("Insufficient arguments provided.")
sys.exit(1)
requested_oid = sys.argv[2]
debug_log("Requested OID: " + requested_oid)
if requested_oid in mapping:
nut_key, conv, snmp_type = mapping[requested_oid]
debug_log("Mapping found: " + requested_oid + " -> " + str(nut_key))
# Handle hardcoded values (where nut_key is None)
if nut_key is None:
try:
value = conv(None)
debug_log("Hardcoded value: " + str(value))
print(requested_oid)
print(snmp_type)
print(value)
except Exception as e:
debug_log("Conversion error for hardcoded value: " + str(e))
print("NONE")
else:
nut_values = get_nut_values()
if nut_key in nut_values:
try:
value = conv(nut_values[nut_key])
debug_log("Converted value: " + str(value))
# Output exactly three lines: OID, SNMP type, and the value.
print(requested_oid)
print(snmp_type)
print(value)
except Exception as e:
debug_log("Conversion error for " + nut_key + ": " + str(e))
print("NONE")
else:
debug_log("NUT key not found in UPS output: " + nut_key)
print("NONE")
else:
debug_log("Requested OID not in mapping: " + requested_oid)
log_unknown_oid(requested_oid)
print("NONE")
if __name__ == '__main__':
main()
@imavroukakis
Copy link
Author

imavroukakis commented Mar 12, 2025

couple this with

rocommunity ups
pass .1.3.6.1.2.1.33 /etc/snmp/nut_ups_mib.py

supports only snmpget not snmpwalk e.g.

snmpget -v2c -c ups localhost .1.3.6.1.2.1.33.1.1.2.0
SNMPv2-SMI::mib-2.33.1.1.2.0 = STRING: "Eaton 5E 1200 G2"

@imavroukakis
Copy link
Author

The Synology SNMP UPS implementation asks for these OIDs

.1.3.6.1.2.1.33.1.1.2.0
.1.3.6.1.2.1.33.1.7.1.0
.1.3.6.1.2.1.33.1.8.2.0
.1.3.6.1.2.1.33.1.8.3.0
.1.3.6.1.2.1.33.1.9.8.0
.1.3.6.1.2.1.33.1.1.1.0
.1.3.6.1.2.1.33.1.1.2.0
.1.3.6.1.2.1.33.1.1.3.0
.1.3.6.1.2.1.33.1.1.4.0
.1.3.6.1.2.1.33.1.2.1.0
.1.3.6.1.2.1.33.1.2.3.0
.1.3.6.1.2.1.33.1.2.4.0
.1.3.6.1.2.1.33.1.2.5.0
.1.3.6.1.2.1.33.1.2.6.0
.1.3.6.1.2.1.33.1.2.7.0
.1.3.6.1.2.1.33.1.3.2.0
.1.3.6.1.2.1.33.1.4.1.0
.1.3.6.1.2.1.33.1.4.2.0
.1.3.6.1.2.1.33.1.4.3.0
.1.3.6.1.2.1.33.1.5.2.0
.1.3.6.1.2.1.33.1.6.3.8
.1.3.6.1.2.1.33.1.7.1.0
.1.3.6.1.2.1.33.1.7.3.0
.1.3.6.1.2.1.33.1.8.2.0
.1.3.6.1.2.1.33.1.8.3.0
.1.3.6.1.2.1.33.1.8.4.0
.1.3.6.1.2.1.33.1.8.5.0
.1.3.6.1.2.1.33.1.9.1.0
.1.3.6.1.2.1.33.1.9.2.0
.1.3.6.1.2.1.33.1.9.3.0
.1.3.6.1.2.1.33.1.9.4.0
.1.3.6.1.2.1.33.1.9.5.0
.1.3.6.1.2.1.33.1.9.6.0
.1.3.6.1.2.1.33.1.9.7.0
.1.3.6.1.2.1.33.1.9.8.0
.1.3.6.1.2.1.33.1.9.9.0
.1.3.6.1.2.1.33.1.9.10.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment