本文最后更新于 2025-10-24,文章内容可能已经过时。

起因:

之前升级了NAS,联想tiny小主机,采用20V供电。现在用的是12V升压20V模块,最近升压模块总是过热导致掉电关机,因此想改善一下;同时库存有个户外电源(老五家的控制板),自己打印了外壳,感觉户外用不到,打算作为NAS的UPS,但该电源没有串口通讯来告知PVE系统目前的用电状态。因此开展了下面的设计:

目标需求:

已有ESP32 ESP-WROOM-32 CP2102 开发板一个 ,220V-12V电源一个

1. 设计一个检测电路,实现esp32获取市电状态(有电/无电)

设计一个检测电路,检测UPS有没有输出

2. 设计一个继电器电路,实现esp32控制电网火线导通/断开

设计一个继电器电路,实现esp32控制UPS启动按钮

3. Esp32电源为独立5V锂电池供电

4. 当esp32识别到断电时,立即通过局域网发送信号给pve让其在UPS选项中识别为UPS供电,延迟5分钟通过局域网发送信号到pve系统通知其关机,并出发继电器断开电网火线。在关机1分钟之后esp32发送信号给两个继电器,恢复火线并使UPS关机。

5. 电网恢复时,PVE设置来电自启动。当esp32识别到有电时,发送信号给继电器,使其短接0.5秒开启UPS。并发送信号给pve让其在UPS选项中识别为AC供电。

硬件:

image-SEIe.png

image-cwTA.png

image-BEym.png

image-LeHy.png

image-CpkC.png

PCB设计:

配件选型完毕,将所有配件以插接件形式进行集成,降低设计开发时间,PCB已下单嘉立创,最近好像不能白嫖了,花了20¥。

image-fccv.png

ESP32代码:(AI生成,已验证)

/*
 * esp32_ups_daemon_v14_web.ino
 * 电池控制:GPIO32 高脉冲1.2 s → 反转输出(COM-NO接通1.2 s)
 * 检测脚:GPIO15 高=有输出
 * AC火线:GPIO33  COM-NC:低=有电,高=断电
 * 时间轴:
 * 0 s      停电开始
 * 1 s      发UPS DC
 * 2 s      发SHUTDOWN
 * 3 s      AC断开(GPIO33=HIGH)
 * 5 min    发反转脉冲→关闭DC
 * 5+30 s   AC闭合(GPIO33=LOW)
 * 5.5 min  解锁:有电→0,无电→2
 * 新增:非阶段1每10秒巡检,最多3次自动打开电池;3次失败后降为30分钟巡检,直到成功
 * 新增:网页控制界面
 */

#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>

const char *ssid = "XXXX", *password = "XXXXX";
const IPAddress pveIP(192, 168, 1X, X);
const int udpSendPort = XXXX, udpRecvPort = XXXX;

#define MAINS_PIN       34   // 高=有电
#define BAT_SWITCH_PIN  32   // 高脉冲反转,空闲低
#define AC_SWITCH_PIN   33   // 高=断电,低=有电
#define BAT_OUT_PIN     15   // 高=有输出(已修正)

#define PULSE_WIDTH     1200 // 反转脉冲宽度
#define STAGE1_TOTAL    (5*60*1000UL + 30000UL) // 5.5 min
#define DETECT_PERIOD   10000 // 10秒心跳(非阶段1巡检也用)

WiFiUDP udpSend, udpRecv;
WebServer server(80);  // Web服务器端口80

/* ---------- 状态 ---------- */
bool mainsNow        = true;
bool batNow          = true;
bool stage1Running   = false;
uint32_t stage1Start = 0;

bool dcSent          = false; // 1 s
bool shutSent        = false; // 2 s
bool acOffDone       = false; // 3 s
bool batOffDone      = false; // 5 min
bool acRestoreDone   = false; // 5+30 s

/* ---------- 网页控制状态 ---------- */
bool autoMode = true;        // 自动模式
bool manualACState = false;  // 手动AC状态: false=AC on, true=AC off
bool manualDCState = false;  // 手动DC状态

/* ---------- 新增:非阶段1重试机制 ---------- */
uint8_t batOpenRetry = 0;        // 已尝试次数
const uint8_t MAX_RETRY = 3;     // 最多3次
const uint32_t LONG_CHECK = 30*60*1000UL; // 30min

/* ---------- 工具 ---------- */
void sendUDP(const char *msg) {
  if (WiFi.status() == WL_CONNECTED) {
    udpSend.beginPacket(pveIP, udpSendPort);
    udpSend.print(msg);
    udpSend.endPacket();
  }
  Serial.printf("[UDP] %s\n", msg);
}

void pulseBattery() {
  digitalWrite(BAT_SWITCH_PIN, HIGH);
  delay(PULSE_WIDTH);
  digitalWrite(BAT_SWITCH_PIN, LOW);
  Serial.println("[BAT] 反转脉冲1.2 s");
}

bool batOut() { return digitalRead(BAT_OUT_PIN) == HIGH; }

void checkShutOK() {
  int n = udpRecv.parsePacket();
  if (n) {
    char b[10]{0}; udpRecv.read(b, sizeof(b)-1);
    if (strcmp(b, "SHUT_OK") == 0) Serial.println("[UDP] SHUT_OK");
  }
}

/* ---------- 网页处理函数 ---------- */
void handleRoot() {
  String html = R"(
<!DOCTYPE html>
<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <title>UPS Controller</title>
    <style>
        body { 
            font-family: Arial, sans-serif; 
            max-width: 600px; 
            margin: 0 auto; 
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container { 
            background: white; 
            padding: 20px; 
            border-radius: 10px; 
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .status-panel { 
            margin: 20px 0; 
            padding: 15px;
            border-radius: 5px;
            background: #f8f9fa;
        }
        .indicator { 
            display: inline-block; 
            width: 20px; 
            height: 20px; 
            border-radius: 50%; 
            margin-right: 10px;
            border: 2px solid #333;
        }
        .on { background-color: #28a745; }
        .off { background-color: #dc3545; }
        .control-panel { 
            margin: 20px 0; 
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .switch { 
            position: relative; 
            display: inline-block; 
            width: 60px; 
            height: 34px; 
            margin: 0 10px;
        }
        .switch input { 
            opacity: 0; 
            width: 0; 
            height: 0; 
        }
        .slider { 
            position: absolute; 
            cursor: pointer; 
            top: 0; 
            left: 0; 
            right: 0; 
            bottom: 0; 
            background-color: #ccc; 
            transition: .4s; 
            border-radius: 34px;
        }
        .slider:before {
            position: absolute;
            content: "";
            height: 26px;
            width: 26px;
            left: 4px;
            bottom: 4px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }
        input:checked + .slider { background-color: #2196F3; }
        input:checked + .slider:before { transform: translateX(26px); }
        button { 
            padding: 10px 20px; 
            margin: 5px; 
            border: none; 
            border-radius: 5px; 
            cursor: pointer;
            background: #007bff;
            color: white;
        }
        button:hover { background: #0056b3; }
        button:disabled { background: #6c757d; cursor: not-allowed; }
        .mode-indicator { 
            padding: 5px 10px; 
            border-radius: 15px; 
            color: white;
            font-weight: bold;
        }
        .auto { background: #28a745; }
        .manual { background: #dc3545; }
        .log { 
            margin-top: 20px; 
            padding: 10px; 
            background: #f8f9fa; 
            border-radius: 5px;
            height: 100px;
            overflow-y: auto;
            font-family: monospace;
            font-size: 12px;
        }
    </style>
</head>
<body>
    <div class='container'>
        <h1>UPS Controller</h1>
        
        <div class='status-panel'>
            <h3>Status Monitor</h3>
            <p>AC Power: <span class='indicator' id='acStatus'></span> <span id='acText'>Loading...</span></p>
            <p>DC Output: <span class='indicator' id='dcStatus'></span> <span id='dcText'>Loading...</span></p>
            <p>Operation Mode: <span id='modeIndicator' class='mode-indicator'>Loading...</span></p>
            <p>Stage: <span id='stageInfo'>Stage 0 - Normal</span></p>
        </div>

        <div class='control-panel'>
            <h3>Manual Control</h3>
            <p>
                Mode: 
                <label class='switch'>
                    <input type='checkbox' id='modeSwitch' onchange='toggleMode()'>
                    <span class='slider'></span>
                </label>
                <span id='modeText'>Auto</span>
            </p>
            
            <p>
                AC Relay: 
                <button onclick='controlAC(true)' id='acOnBtn'>AC ON</button>
                <button onclick='controlAC(false)' id='acOffBtn'>AC OFF</button>
                <span id='acState'>-</span>
            </p>
            
            <p>
                DC Relay: 
                <button onclick='pulseDC()' id='dcBtn'>Pulse DC (1.2s)</button>
                <span id='dcState'>-</span>
            </p>
        </div>

        <div class='log'>
            <div id='logContent'>System started...</div>
        </div>
    </div>

    <script>
        let autoMode = true;
        
        function updateStatus(data) {
            document.getElementById('acStatus').className = 'indicator ' + (data.acPower ? 'on' : 'off');
            document.getElementById('acText').textContent = data.acPower ? 'ON' : 'OFF';
            document.getElementById('dcStatus').className = 'indicator ' + (data.dcOutput ? 'on' : 'off');
            document.getElementById('dcText').textContent = data.dcOutput ? 'ON' : 'OFF';
            document.getElementById('modeIndicator').textContent = data.autoMode ? 'AUTO' : 'MANUAL';
            document.getElementById('modeIndicator').className = 'mode-indicator ' + (data.autoMode ? 'auto' : 'manual');
            document.getElementById('stageInfo').textContent = data.stageInfo;
            document.getElementById('acState').textContent = data.acRelayState;
            document.getElementById('dcState').textContent = data.dcRelayState;
            
            // Update mode switch
            document.getElementById('modeSwitch').checked = !data.autoMode;
            document.getElementById('modeText').textContent = data.autoMode ? 'Auto' : 'Manual';
            
            // Update button states
            document.getElementById('acOnBtn').disabled = data.autoMode;
            document.getElementById('acOffBtn').disabled = data.autoMode;
            document.getElementById('dcBtn').disabled = data.autoMode;
            
            autoMode = data.autoMode;
        }
        
        function toggleMode() {
            fetch('/mode?auto=' + !document.getElementById('modeSwitch').checked)
                .then(response => response.json())
                .then(data => updateStatus(data));
        }
        
        function controlAC(state) {
            if (autoMode) return;
            fetch('/ac?state=' + (state ? 'on' : 'off'))
                .then(response => response.json())
                .then(data => {
                    updateStatus(data);
                    addLog('AC relay ' + (state ? 'ON' : 'OFF'));
                });
        }
        
        function pulseDC() {
            if (autoMode) return;
            fetch('/dc')
                .then(response => response.json())
                .then(data => {
                    updateStatus(data);
                    addLog('DC pulse sent (1.2s)');
                });
        }
        
        function addLog(message) {
            const log = document.getElementById('logContent');
            const time = new Date().toLocaleTimeString();
            log.innerHTML = time + ' - ' + message + '<br>' + log.innerHTML;
        }
        
        function updateAll() {
            fetch('/status')
                .then(response => response.json())
                .then(data => updateStatus(data));
        }
        
        // Update status every 2 seconds
        setInterval(updateAll, 2000);
        updateAll();
    </script>
</body>
</html>
)";
  
  server.send(200, "text/html", html);
}

void handleStatus() {
  String stageInfo = "Stage 0 - Normal";
  if (stage1Running) {
    uint32_t elapsed = millis() - stage1Start;
    if (elapsed < STAGE1_TOTAL) {
      stageInfo = "Stage 1 - Power Loss (" + String(elapsed/1000) + "s)";
    }
  } else if (!mainsNow) {
    stageInfo = "Stage 2 - Waiting Recovery";
  }
  
  String json = "{";
  json += "\"acPower\":" + String(mainsNow ? "true" : "false") + ",";
  json += "\"dcOutput\":" + String(batOut() ? "true" : "false") + ",";
  json += "\"autoMode\":" + String(autoMode ? "true" : "false") + ",";
  json += "\"stageInfo\":\"" + stageInfo + "\",";
  json += "\"acRelayState\":\"" + String(digitalRead(AC_SWITCH_PIN) == LOW ? "AC ON" : "AC OFF") + "\",";
  json += "\"dcRelayState\":\"" + String(digitalRead(BAT_SWITCH_PIN) == HIGH ? "Ready" : "Pulsing") + "\"";
  json += "}";
  
  server.send(200, "application/json", json);
}

void handleMode() {
  if (server.hasArg("auto")) {
    autoMode = server.arg("auto") == "true";
    Serial.println("[WEB] Mode changed to: " + String(autoMode ? "Auto" : "Manual"));
  }
  handleStatus();
}

void handleAC() {
  if (!autoMode && server.hasArg("state")) {
    if (server.arg("state") == "on") {
      digitalWrite(AC_SWITCH_PIN, LOW);  // AC ON
      manualACState = false;
      Serial.println("[WEB] Manual AC ON");
    } else {
      digitalWrite(AC_SWITCH_PIN, HIGH); // AC OFF
      manualACState = true;
      Serial.println("[WEB] Manual AC OFF");
    }
  }
  handleStatus();
}

void handleDC() {
  if (!autoMode) {
    pulseBattery();
    Serial.println("[WEB] Manual DC pulse");
  }
  handleStatus();
}

/* ---------- setup ---------- */
void setup() {
  Serial.begin(115200);
  pinMode(MAINS_PIN, INPUT_PULLUP);
  pinMode(BAT_SWITCH_PIN, OUTPUT);
  pinMode(AC_SWITCH_PIN, OUTPUT);
  pinMode(BAT_OUT_PIN, INPUT_PULLUP);

  // 初始状态:
  // AC有电(GPIO33=LOW),电池控制脚空闲低
  digitalWrite(BAT_SWITCH_PIN, LOW);
  digitalWrite(AC_SWITCH_PIN, LOW);

  // WiFi连接详情打印
  Serial.println("\n[WiFi] Connecting...");
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\n[WiFi] Connected! SSID: %s\n", WiFi.SSID().c_str());
  Serial.print("[WiFi] IP Address: ");
  Serial.println(WiFi.localIP());

  // 启动Web服务器
  server.on("/", handleRoot);
  server.on("/status", handleStatus);
  server.on("/mode", handleMode);
  server.on("/ac", handleAC);
  server.on("/dc", handleDC);
  server.begin();
  Serial.println("[WEB] HTTP server started");

  udpRecv.begin(udpRecvPort);

  // 上电阶段0:确保电池有输出
  if (!batOut()) {
    Serial.println("[INIT] No DC output → sending pulse");
    pulseBattery();
    delay(500);
  }
  sendUDP("UPS AC");
}

/* ---------- loop ---------- */
void loop() {
  server.handleClient();  // 处理Web请求
  
  // 自动模式下的原有逻辑
  if (autoMode) {
    runAutoMode();
  }
}

void runAutoMode() {
  /* ---------- 非阶段1:每10秒/30min 巡检 AC+DC ---------- */
  static uint32_t tLastCheck = 0;
  uint32_t checkInterval = (batOpenRetry >= MAX_RETRY) ? LONG_CHECK : DETECT_PERIOD;
  if (!stage1Running && (millis() - tLastCheck >= checkInterval)) {
    tLastCheck = millis();
    if (mainsNow && !batOut()) {              // AC有电 且 电池无输出
      if (batOpenRetry < MAX_RETRY) {
        Serial.printf("[TRACE] AC on, DC off (attempt %d)→send pulse\n", batOpenRetry+1);
        pulseBattery();
        batOpenRetry++;                       // 计数+1
      } else {
        Serial.println("[WARN] 3 attempts failed, check again in 30min");
      }
    } else if (mainsNow && batOut()) {        // 成功打开
      if (batOpenRetry > 0) {
        Serial.println("[INFO] DC opened, reset retry count");
        batOpenRetry = 0;                     // 成功→清零
      }
    }
  }

  /* ---------- 原10秒心跳(阶段0/1/2总定时) ---------- */
  static uint32_t tLast = 0;
  if (millis() - tLast < DETECT_PERIOD) return;
  tLast = millis();

  bool mainsPrev = mainsNow;
  mainsNow = digitalRead(MAINS_PIN);
  batNow = batOut();
  checkShutOK();

  /* ---- 阶段1锁内时间轴 ---- */
  if (stage1Running) {
    uint32_t e = millis() - stage1Start;

    if (!dcSent && e >= 1000) {
      sendUDP("UPS DC");
      dcSent = true;
      Serial.println("[STAGE1] 1s send UPS DC");
    }
    if (!shutSent && e >= 2000) {
      sendUDP("SHUTDOWN");
      shutSent = true;
      Serial.println("[STAGE1] 2s send SHUTDOWN");
    }
    if (!acOffDone && e >= 3000) {
      digitalWrite(AC_SWITCH_PIN, HIGH); // 高=断电
      acOffDone = true;
      Serial.println("[STAGE1] 3s AC off");
    }
    if (!batOffDone && e >= 5 * 60 * 1000) {
      Serial.println("[STAGE1] 5min close DC (pulse)");
      pulseBattery();
      batOffDone = true;
    }
    if (!acRestoreDone && e >= 5 * 60 * 1000 + 30000UL) {
      digitalWrite(AC_SWITCH_PIN, LOW); // 低=有电
      acRestoreDone = true;
      Serial.println("[STAGE1] 5min+30s AC restore");
    }
    if (e >= STAGE1_TOTAL) {
      Serial.println("[STAGE1] 5.5min complete, unlock");
      stage1Running = false;
      if (mainsNow) {
        Serial.println("[STAGE1→0] Power back, back to stage 0");
        if (!batOut()) pulseBattery();
        sendUDP("UPS AC");
      } else {
        Serial.println("[STAGE1→2] Still no power, enter stage 2");
      }
    }
    return;
  }

  /* ---- 阶段0:有电 ---- */
  if (mainsNow) {
    if (!batOut()) {
      Serial.println("[STAGE0] Power on but DC off→send pulse");
      pulseBattery();
    }
    digitalWrite(AC_SWITCH_PIN, LOW); // 低=有电
    sendUDP("UPS AC");
    return;
  }

  /* ---- 无电且阶段1未运行 → 启动阶段1 ---- */
  if (!stage1Running) {
    stage1Running = true;
    stage1Start = millis();
    dcSent = shutSent = acOffDone = batOffDone = acRestoreDone = false;
    digitalWrite(AC_SWITCH_PIN, HIGH); // 高=断电(隔离火线)
    Serial.println("[MAIN] Power loss→stage 1 started (5.5min lock) AC isolated");
  }
}

PVE端UDP监听脚本配置

1. 创建UDP监听脚本

在PVE系统中创建 /usr/local/bin/ups_listener.py

#!/usr/bin/env python3
import socket, subprocess, sys, time

UDP_IP = "0.0.0.0"
UDP_PORT = 4210
SHUT_PORT = 4211              # 回传端口(可选)
UPS_IP   = "192.168.1XXX"    # ESP32 IP

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
print("[PVE] UDP监听 4210...", file=sys.stderr)

while True:
    data, addr = sock.recvfrom(1024)
    msg = data.decode().strip()
    print(f"[PVE] 收到: {msg}", file=sys.stderr)
    if msg == "SHUTDOWN":
        print("[PVE] 2 s规则触发,开始关闭所有虚拟机", file=sys.stderr)
        # 1. 先尝试优雅关机,超时30秒
        subprocess.run(["qm", "stop", "--all", "--timeout", "250"])
        # 2. 强制断电任何仍在运行的VM
        subprocess.run(["qm", "stop", "--all", "--forceStop", "--skiplock"])
        # 3. 可选:回传关机完成(让UPS提前断AC)
        sock.sendto(b"SHUT_OK", (UPS_IP, SHUT_PORT))
        # 4. 本机断电
        print("[PVE] 所有VM已关闭,主机即将断电", file=sys.stderr)
        subprocess.run(["shutdown", "-h", "now"])

2. 设置脚本权限

bash

chmod +x /usr/local/bin/ups_listener.py

3. 创建Systemd服务

创建 /etc/systemd/system/ups-listener.service

[Unit]
Description=PVE UPS Daemon (UDP→Shutdown)
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/ups_daemon.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

4. 启用并启动服务

bash

systemctl daemon-reload
systemctl enable ups-listener.service
systemctl start ups-listener.service
systemctl status ups-listener.service

防火墙配置

确保PVE防火墙允许UDP通信:

bash

# 允许UDP端口4210和4211
iptables -A INPUT -p udp --dport 4210 -j ACCEPT
iptables -A INPUT -p udp --dport 4211 -j ACCEPT

# 或者使用PVE防火墙管理
pve-firewall localnet --add 4210 --proto udp
pve-firewall localnet --add 4211 --proto udp

测试通信

在PVE上测试UDP通信:

bash

# 安装netcat
apt update && apt install netcat

# 测试发送消息到ESP32(替换为ESP32的IP)
echo "TEST" | nc -u 192.168.1XX 4211

# 在另一个终端监听,测试接收
nc -lu 4210

验证: