网络结构:光猫->PVE主机->PVE内部虚拟路由器系统->群晖

原来正常的工作流程:

  1. 市电断开->UPS供电->群晖识别低电量自动关机并发送邮件通知->PVE nut-monitor->PVE 自动关机
  2. 市电恢复->群晖、PVE来电自启->PVE恢复网络通信->群晖发邮件通知UPS恢复正常

群晖通过USB接入UPS后,所有信息都来源于UPS通告,购入的 APC BK650M2_CH默认低电量通报为93%,目前无法通过DSM界面进行任何修改

PVE主机设置nut-client slave,监控群晖的UPS状态,在电量低于93%并且在耗尽前恢复市电的情况下,电池状态会被错误标记为 FSD OL CHRG(Forced Shutdown + Online + Charging),导致PVE主机在来电自启后就会马上识别到 FSD信号并迅速关机,只能通过控制面板手动开关UPS功能保存应用刷新为正常的 OL CHRG

逼不得已求助AI,将UPS转向接入PVE主机作主设备master,群晖降为slave,这样就能强行覆写不合适的低电量阈值(下述配置为15%)

软件依赖
apt update
apt install nut nut-server nut-client snmpd snmp
NUT部分
/etc/nut/nut.conf
MODE=netserver
/etc/nut/ups.conf
pollinterval = 5
[ups]
    driver = usbhid-ups
    port = auto
# APC BK650M2_CH默认报告93%为低电量, 不然会让PVE和群晖过早触发关机, 覆写为15%
    override.battery.charge.low = 15
/etc/nut/upsd.conf
LISTEN 0.0.0.0 3493
/etc/nut/upsd.users
# 群晖用, 这里使用monuser/secret作为用户名/密码, slave模式
[monuser]
password = secret
upsmon slave

# PVE宿主机自身用, 拥有高权限
[pvemon]
password = pvemon_password
upsmon primary
actions = SET
instcmds = ALL
/etc/nut/upsmon.conf
MONITOR ups@localhost 1 pvemon pvemon_password primary

# 关机指令PVE识别不了默认的now参数,改为+0
SHUTDOWNCMD "/sbin/shutdown -h +0"
SNMP部分
/etc/snmp/snmpd.conf
# 添加
view    upsview    included   .1.3.6.1.2.1.33

# 给community组ups_ro分配权限
rocommunity ups_ro 127.0.0.1 -V upsview
rocommunity ups_ro 192.168.9.11 -V upsview

# 将NUT相关信息传递给后续的nut-ups-snmp python脚本
pass_persist .1.3.6.1.2.1.33 /usr/local/bin/nut-ups-snmp
/usr/local/bin/nut-ups-snmp

AI生成,基于 RFC1628传递相对应的NUT信息到SNMP

#!/usr/bin/env python3

import sys
import subprocess

BASE = "1.3.6.1.2.1.33"
NUT = "ups@localhost"


def get_nut():
    try:
        p = subprocess.run(
            ["upsc", NUT],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=3,
        )

        data = {}

        for line in p.stdout.splitlines():
            if ":" not in line:
                continue

            k, v = line.split(":", 1)
            data[k.strip()] = v.strip()

        return data

    except Exception:
        return {}


def integer(value, default=0):
    try:
        return int(float(value))
    except Exception:
        return default


def build_objects():

    d = get_nut()

    manufacturer = d.get(
        "device.mfr",
        d.get("ups.mfr", "Unknown")
    )

    model = d.get(
        "device.model",
        d.get("ups.model", "Unknown")
    )

    serial = d.get(
        "device.serial",
        d.get("ups.serial", "")
    )

    software = d.get(
        "driver.version.data",
        d.get("driver.version", "NUT")
    )

    charge = integer(d.get("battery.charge"), 0)
    runtime = integer(d.get("battery.runtime"), 0)

    input_voltage = integer(
        d.get("input.voltage"),
        0
    )

    output_voltage = integer(
        d.get("output.voltage"),
        0
    )

    load = integer(
        d.get("ups.load"),
        0
    )

    status = d.get("ups.status", "")

    objects = {

        # ============================================================
        # upsIdent
        # ============================================================

        f"{BASE}.1.1.1.0":
            ("string", manufacturer),

        f"{BASE}.1.1.2.0":
            ("string", model),

        f"{BASE}.1.1.3.0":
            ("string", software),

        f"{BASE}.1.1.4.0":
            ("string", "NUT 2.8.1"),

        f"{BASE}.1.1.5.0":
            ("string", serial),


        # ============================================================
        # upsBattery
        # ============================================================

        # upsBatteryStatus
        # 1 unknown
        # 2 battery normal
        # 3 battery low
        # 4 battery depleted
        f"{BASE}.1.2.1.0":
            (
                "integer",
                3 if "LB" in status
                else 2 if "OB" in status
                else 2
            ),

        # upsSecondsOnBattery
        f"{BASE}.1.2.2.0":
            (
                "integer",
                integer(d.get("battery.runtime"), 0)
                if "OB" in status else 0
            ),

        # upsEstimatedMinutesRemaining
        f"{BASE}.1.2.3.0":
            (
                "integer",
                max(0, runtime // 60)
            ),

        # upsEstimatedChargeRemaining
        f"{BASE}.1.2.4.0":
            (
                "integer",
                charge
            ),

        # upsBatteryVoltage
        # RFC1628 uses 0.1V
        f"{BASE}.1.2.5.0":
            (
                "integer",
                integer(float(d.get("battery.voltage", 0)) * 10)
            ),

        # upsBatteryCurrent
        f"{BASE}.1.2.6.0":
            ("integer", 0),

        # upsBatteryTemperature
        f"{BASE}.1.2.7.0":
            ("integer", 0),

        # upsBatteryCurrent
        f"{BASE}.1.2.8.0":
            ("integer", 0),


        # ============================================================
        # upsInput
        # ============================================================

        # upsInputLineBads
        f"{BASE}.3.1.1.0":
            ("counter", 0),

        # upsInputNumLines
        f"{BASE}.3.1.2.0":
            ("integer", 1),

        # upsInputTable index 1
        #
        # upsInputLineIndex
        f"{BASE}.3.3.1.2.1":
            ("integer", 1),

        # upsInputFrequency
        # 0.1 Hz
        f"{BASE}.3.3.1.3.1":
            (
                "integer",
                integer(float(d.get("input.frequency", 0)) * 10)
            ),

        # upsInputVoltage
        f"{BASE}.3.3.1.4.1":
            (
                "integer",
                input_voltage
            ),

        # upsInputCurrent
        f"{BASE}.3.3.1.5.1":
            ("integer", 0),

        # ============================================================
        # upsOutput
        # ============================================================

        # upsOutputSource
        #
        # 1 other
        # 2 none
        # 3 normal
        # 4 bypass
        # 5 battery
        # 6 booster
        # 7 reducer
        f"{BASE}.4.1.1.0":
            (
                "integer",
                5 if "OB" in status else 3
            ),

        # upsOutputFrequency
        f"{BASE}.4.1.2.0":
            (
                "integer",
                integer(float(d.get("output.frequency", 0)) * 10)
            ),

        # upsOutputNumLines
        f"{BASE}.4.1.3.0":
            ("integer", 1),

        # upsOutputTable index 1

        # upsOutputLineIndex
        f"{BASE}.4.4.1.2.1":
            ("integer", 1),

        # upsOutputVoltage
        f"{BASE}.4.4.1.3.1":
            (
                "integer",
                output_voltage
            ),

        # upsOutputCurrent
        f"{BASE}.4.4.1.4.1":
            ("integer", 0),

        # upsOutputPower
        f"{BASE}.4.4.1.5.1":
            (
                "integer",
                integer(d.get("ups.realpower"), 0)
            ),


        # ============================================================
        # upsAlarms
        # ============================================================

        # upsAlarmsPresent
        f"{BASE}.6.1.0":
            (
                "gauge",
                1 if "OB" in status else 0
            ),


        # ============================================================
        # upsTest
        # ============================================================

        # upsTestBatteryStatus
        f"{BASE}.7.1.0":
            ("integer", 1),


        # ============================================================
        # upsControl
        # ============================================================

        # upsShutdownType
        f"{BASE}.8.1.0":
            ("integer", 1),

        # upsShutdownAfterDelay
        f"{BASE}.8.2.0":
            (
                "integer",
                integer(d.get("ups.delay.shutdown"), 0)
            ),

        # upsStartupAfterDelay
        f"{BASE}.8.3.0":
            ("integer", 0),
    }

    return objects


def find_next(objects, oid):

    try:
        requested = tuple(int(x) for x in oid.strip(".").split("."))
    except Exception:
        return None

    candidates = []

    for key in objects:
        try:
            current = tuple(int(x) for x in key.split("."))
        except Exception:
            continue

        if current > requested:
            candidates.append((current, key))

    if not candidates:
        return None

    candidates.sort()
    return candidates[0][1]


def handle():

    objects = build_objects()

    while True:

        line = sys.stdin.readline()

        if not line:
            break

        command = line.strip()

        # ------------------------------------------------------------
        # PING
        # ------------------------------------------------------------

        if command == "PING":
            print("PONG", flush=True)
            continue

        # ------------------------------------------------------------
        # GET / GETNEXT
        # ------------------------------------------------------------

        if command in ("get", "getnext"):

            oid_line = sys.stdin.readline()

            if not oid_line:
                break

            oid = oid_line.strip().lstrip(".")

            objects = build_objects()

            if command == "get":
                target = oid
            else:
                target = find_next(objects, oid)

            if target is None or target not in objects:
                print("NONE", flush=True)
                continue

            typ, value = objects[target]

            print(target, flush=True)
            print(typ, flush=True)
            print(value, flush=True)

            continue

        # ------------------------------------------------------------
        # SET
        # ------------------------------------------------------------

        if command == "set":

            oid_line = sys.stdin.readline()

            if not oid_line:
                break

            oid = oid_line.strip().lstrip(".")

            value_line = sys.stdin.readline()

            if not value_line:
                break

            # 当前只提供只读 UPS 信息
            print("not-writable", flush=True)
            continue

        # ------------------------------------------------------------
        # Unknown command
        # ------------------------------------------------------------

        print("NONE", flush=True)


if __name__ == "__main__":
    handle()
相关服务
systemctl restart nut-driver@ups
systemctl restart nut-server
systemctl restart nut-monitor
最后修改:2026 年 08 月 13 日
个人分享,随意打赏