Last active
November 15, 2025 19:11
-
-
Save imavroukakis/164a17a79655c265aceb887978f4038f to your computer and use it in GitHub Desktop.
A script to expose a locally attached UPS via SNMP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
Author
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
couple this with
supports only
snmpgetnotsnmpwalke.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"