UPS 控制板-完结
本文最后更新于 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供电。

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

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.py3. 创建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验证:
