用 ESP32 打造「门禁 + 机柜」看门狗

zach Posted on 25 days ago 66 Views HackMech


(电池电压、交流电状态、3×DHT22 温湿度)——支持 Home Assistant MQTT Auto-Discovery、邮件告警与轻量 Web 面板

这个项目把一块 ESP32 变成小型“值班员”,它会持续上报与展示:

  • 电池电压GPIO36 模拟量)
  • 交流电是否存在GPIO39 检测,掉电时点亮板载 LED)
  • 三路 DHT22:环境 / 顶层机架 / 底层机架(°C、°F、%RH)
  • 一个简洁的 网页仪表盘/)与 JSON 数据接口/data
  • Home Assistant MQTT 自动发现,实体开箱即用,无需 YAML
  • Gmail 邮件告警:市电掉电或电压低于阈值时单次提醒

⚠️ 在烧录前,请把下面代码中的 Wi-Fi、Gmail、MQTT 等凭据替换为你自己的占位符。不要在公网帖中泄露真实密码或服务器地址。


准备材料

  • ESP32 开发板(常见 DevKit 均可)
  • 3× DHT22(也可用 1~2 个),若无板载上拉需自备 10 kΩ 上拉电阻
  • 电压采样分压GPIO36(ADC1);引脚最大 3.3 V,请正确选择分压
  • 市电存在检测:经光耦/整流稳压后形成 ≤3.3 V 的直流信号输入 GPIO39
  • 一台可达的 MQTT Broker(如 Mosquitto)
  • 已启用 MQTT 集成 的 Home Assistant
  • 开启两步验证后申请的 Gmail 应用专用密码

⚠️ 启动脚位注意GPIO0 属于上电拉脚。如果 DHT22 在上电时把它拉低,ESP32 可能无法正常启动。若遇到此问题,把“环境”DHT 改接 GPIO4(并同步修改代码)。


工作原理(简述)

  • 电压标定SCALE_CONSTANT 结合分压比与 ADC 标定用于换算电压值。用万用表对比并微调该常数,让 Home Assistant 显示与实际一致。
  • 市电状态:将感应到的电压换算后与阈值比较(示例中约 5 V)。当判断为 OFF
    • 切换 LED 指示;
    • 发送一次 “市电丢失” 邮件(恢复后复位锁存)。
  • DHT:分别发布 °C、由 °C 计算的 °F、以及湿度 %。
  • HA 自动发现:首次连接 MQTT 时发布 homeassistant/.../config 主题;随后每秒发布 .../state 主题。

接线(文字说明)

  • 电池电压:电池 + → 电阻分压 → GPIO36(ADC1);电池 − → GND
  • 市电存在:通过光耦/整流稳压等把“市电 OK”转换为安全直流 → GPIO39(ADC1),公共地 → GND
  • DHT22
    • 环境:数据脚 → GPIO0(若有启动问题改为 GPIO4),供电 3.3 V、GND
    • 顶层机架:数据脚 → GPIO22
    • 底层机架:数据脚 → GPIO13
    • 若模块无上拉,请在数据脚与 3.3 V 之间接 10 kΩ 上拉

Home Assistant 中会出现的实体

  • Battery Voltage(电池电压,V)
  • AC Power(二进制传感器)
  • Ambient / Top Rack / Bottom Rack:温度(°C 与 °F)与湿度(%)

无需 YAML,设备上电联网并成功连接 MQTT 后即可自动出现。


安全与稳定性

  • Gmail 必须用“应用专用密码”,不要用账号真实密码。
  • MQTT 建议在 可信局域网/VPN 内使用,避免暴露到公网。
  • ADC 抖动可通过 多次采样平均 或在模拟端加入 RC 滤波 改善。
  • 切记 ESP32 ADC2 与 Wi-Fi 冲突,本项目选用 ADC1(36/39) 是安全的。

代码(已清理与修正)

需要的库(Arduino IDE“库管理器”可搜):ESP Mail ClientPubSubClientDHT sensor library

#include <WiFi.h>
#include <ESP_Mail_Client.h>
#include <WebServer.h>
#include <PubSubClient.h>
#include "DHT.h"

// -------------------------- Wi-Fi 与邮件配置 --------------------------
#define WIFI_SSID        "<YOUR_WIFI_SSID>"
#define WIFI_PASSWORD    "<YOUR_WIFI_PASSWORD>"

#define SMTP_server      "smtp.gmail.com"
#define SMTP_Port        587                 // STARTTLS
#define sender_email     "<YOUR_GMAIL_ADDRESS>"
#define sender_password  "<YOUR_GMAIL_APP_PASSWORD>"  // 用应用专用密码

// -------------------------- 电压与市电检测 --------------------------
#define VOLTAGE_PIN_1    36   // ADC1 - 与 Wi-Fi 不冲突
#define SCALE_CONSTANT   38.1 // 按分压与标定调整
#define LOW_VOLTAGE_THRESHOLD 20.0  // 低压阈值(V)

#define VOLTAGE_PIN_2    39   // 市电存在检测(≤3.3V)
#define LED_PIN          2
#define SWITCH_ON        LOW
#define SWITCH_OFF       HIGH

// -------------------------- DHT22 传感器 --------------------------
#define DHTPIN0          0    // 若影响启动,改用 4 并同步修改
#define DHTPIN1          22
#define DHTPIN2          13
#define DHTTYPE          DHT22

DHT dht1(DHTPIN0, DHTTYPE);
DHT dht2(DHTPIN1, DHTTYPE);
DHT dht3(DHTPIN2, DHTTYPE);

float dht1_temp = NAN, dht1_hum = NAN;
float dht2_temp = NAN, dht2_hum = NAN;
float dht3_temp = NAN, dht3_hum = NAN;

float dht1_temp_f = NAN;
float dht2_temp_f = NAN;
float dht3_temp_f = NAN;

// -------------------------- MQTT 配置 --------------------------
#define MQTT_SERVER      "<YOUR_MQTT_HOST>"
#define MQTT_PORT        1883
#define MQTT_USER        "<YOUR_MQTT_USER>"
#define MQTT_PASSWORD    "<YOUR_MQTT_PASSWORD>"

// 设备标识
const char* deviceIdentifier = "hackmech_door_01";

WiFiClient espClient;
PubSubClient mqttClient(espClient);

// -------------------------- 全局变量 --------------------------
SMTPSession smtp;
int lostPowerLatched = 0;  // 掉电一次性告警锁存
int lowVoltLatched   = 0;  // 低压一次性告警锁存
WebServer server(80);

// -------------------------- 函数原型 --------------------------
void connectWiFi();
void connectMQTT();
void publishAutoDiscoveryConfigs();
void publishSensorData();
void sendEmail(const String& subject, const String& body);
float readVoltage();
String readSwitchState();
void handleRoot();
void handleData();
void handleNotFound();
String capitalize(const String& s);  // 提前声明,避免原型问题

// -------------------------- Wi-Fi 连接 --------------------------
void connectWiFi() {
  Serial.println("\nConnecting to WiFi...");
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; ++i) {
    delay(250);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\nWiFi connected!");
    Serial.print("IP Address: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("\nFailed to connect to WiFi. Rebooting...");
    delay(2000);
    ESP.restart();
  }
}

// -------------------------- MQTT 连接 --------------------------
void connectMQTT() {
  while (!mqttClient.connected()) {
    Serial.print("Connecting to MQTT...");
    if (mqttClient.connect("HACKMECHDoorMonitor", MQTT_USER, MQTT_PASSWORD)) {
      Serial.println("connected");
      publishAutoDiscoveryConfigs();
    } else {
      Serial.print("failed, rc=");
      Serial.print(mqttClient.state());
      Serial.println(" retrying in 5 seconds");
      delay(5000);
    }
  }
}

// -------------------------- HA 自动发现配置 --------------------------
void publishAutoDiscoveryConfigs() {
  String dev = String("\"device\":{\"identifiers\":[\"") + deviceIdentifier +
               "\"],\"name\":\"HACKMECH Door Monitor\",\"manufacturer\":\"HACKMECH IoT\",\"model\":\"ESP32\"}";

  auto pub_cfg = [&](const String& path, const String& payload){
    mqttClient.publish(path.c_str(), payload.c_str(), true);
  };

  // 电压
  {
    String obj = String(deviceIdentifier) + "_voltage";
    String cfgPath = "homeassistant/sensor/" + String(deviceIdentifier) + "/voltage/config";
    String stPath  = "homeassistant/sensor/" + String(deviceIdentifier) + "/voltage/state";
    String cfg = String("{\"name\":\"Battery Voltage\",\"device_class\":\"voltage\",\"unit_of_measurement\":\"V\",")
               + "\"state_topic\":\"" + stPath + "\",\"unique_id\":\"" + obj + "\"," + dev + "}";
    pub_cfg(cfgPath, cfg);
  }

  // 市电(binary_sensor)
  {
    String obj = String(deviceIdentifier) + "_ac_power";
    String cfgPath = "homeassistant/binary_sensor/" + String(deviceIdentifier) + "/ac_power/config";
    String stPath  = "homeassistant/binary_sensor/" + String(deviceIdentifier) + "/ac_power/state";
    String cfg = String("{\"name\":\"AC Power\",\"device_class\":\"power\",\"payload_on\":\"ON\",\"payload_off\":\"OFF\",")
               + "\"state_topic\":\"" + stPath + "\",\"unique_id\":\"" + obj + "\"," + dev + "}";
    pub_cfg(cfgPath, cfg);
  }

  // 三路 DHT
  for (int i = 1; i <= 3; i++) {
    String sensor = "dht" + String(i);
    struct Item { const char* key; const char* name; const char* unit; const char* devclass; } items[] = {
      {"temp_c", "Temperature C", "°C", "temperature"},
      {"temp_f", "Temperature F", "°F", "temperature"},
      {"humidity","Humidity",      "%",  "humidity"}
    };
    for (auto &it : items) {
      String obj    = String(deviceIdentifier) + "_" + sensor + "_" + it.key;
      String cfgPath= "homeassistant/sensor/" + String(deviceIdentifier) + "/" + sensor + "_" + it.key + "/config";
      String stPath = "homeassistant/sensor/" + String(deviceIdentifier) + "/" + sensor + "/" + it.key + "/state";
      String cfg = String("{\"name\":\"") + capitalize(sensor) + " " + it.name + "\","
                 + "\"device_class\":\"" + it.devclass + "\","
                 + "\"unit_of_measurement\":\"" + it.unit + "\","
                 + "\"state_topic\":\"" + stPath + "\","
                 + "\"unique_id\":\"" + obj + "\"," + dev + "}";
      pub_cfg(cfgPath, cfg);
    }
  }
}

// 把 "dht1" 的首字母变大写 -> "DHT1"(简易实现)
String capitalize(const String& s){
  if (s.length() == 0) return s;
  String t = s;
  char c = t.charAt(0);
  if (c >= 'a' && c <= 'z') t.setCharAt(0, c - 32);
  return t;
}

// -------------------------- 读取电压 --------------------------
float readVoltage() {
  // 注意分压与 3.3V 上限
  return (float)analogRead(VOLTAGE_PIN_1) / 4095.0f * SCALE_CONSTANT;
}

// -------------------------- 市电状态 --------------------------
String readSwitchState() {
  // 阈值请按实际标定(示例把 ADC 映射到约 0~64.5V 的量程,然后以 5V 为阈)
  float v = (float)analogRead(VOLTAGE_PIN_2) / 4095.0f * 64.5f;
  return (v > 5.0f) ? "ON" : "OFF";
}

// -------------------------- 发送邮件 --------------------------
void sendEmail(const String& subject, const String& body) {
  ESP_Mail_Session session;
  session.server.host_name = SMTP_server;
  session.server.port      = SMTP_Port;
  session.login.email      = sender_email;
  session.login.password   = sender_password;
  session.login.user_domain= "";

  SMTP_Message message;
  message.sender.name  = "Door Alarm System";
  message.sender.email = sender_email;
  message.subject      = subject;
  message.priority     = esp_mail_smtp_priority_high;

  struct Recip { const char* name; const char* email; } recipients[] = {
    {"Ops1", "<[email protected]>"},
    {"Ops2", "<[email protected]>"},
    {"Ops3", "<[email protected]>"}
  };
  for (auto &r : recipients) message.addRecipient(r.name, r.email);

  message.html.content = body.c_str();
  message.html.charSet = "utf-8";
  message.html.transfer_encoding = Content_Transfer_Encoding::enc_7bit;

  if (!smtp.connect(&session)) {
    Serial.println("SMTP connect failed: " + smtp.errorReason());
    return;
  }
  if (!MailClient.sendMail(&smtp, &message)) {
    Serial.println("sendMail failed: " + smtp.errorReason());
  } else {
    Serial.println("Email sent successfully");
  }
  smtp.closeSession();
}

// -------------------------- Web 服务 --------------------------
void handleRoot() {
  server.send(200, "text/html", R"rawliteral(
<!DOCTYPE html><html><head><meta charset="utf-8"><title>HACKMECH Door</title>
<style>body{font-family:system-ui;margin:24px} h1{margin:0 0 12px}</style>
<script>
setInterval(()=>fetch('/data').then(r=>r.json()).then(d=>{
  const ids=['voltage','switchState',
    'dht1_temp','dht1_temp_f','dht1_hum',
    'dht2_temp','dht2_temp_f','dht2_hum',
    'dht3_temp','dht3_temp_f','dht3_hum'];
  ids.forEach(id=>{document.getElementById(id).innerText = d[id];});
}),1000);
</script></head><body>
<h1>HACKMECH Door Monitoring</h1>
<p>Battery Voltage: <b><span id="voltage">...</span> V</b></p>
<p>AC Power: <b><span id="switchState">...</span></b></p>
<h2>Ambient</h2>
<p>Temperature (C): <span id="dht1_temp">...</span></p>
<p>Temperature (F): <span id="dht1_temp_f">...</span></p>
<p>Humidity: <span id="dht1_hum">...</span> %</p>
<h2>Top Rack</h2>
<p>Temperature (C): <span id="dht2_temp">...</span></p>
<p>Temperature (F): <span id="dht2_temp_f">...</span></p>
<p>Humidity: <span id="dht2_hum">...</span> %</p>
<h2>Bottom Rack</h2>
<p>Temperature (C): <span id="dht3_temp">...</span></p>
<p>Temperature (F): <span id="dht3_temp_f">...</span></p>
<p>Humidity: <span id="dht3_hum">...</span> %</p>
</body></html>
)rawliteral");
}

void handleData() {
  String json = "{";
  json += "\"voltage\":" + String(readVoltage(), 2) + ",";
  json += "\"switch\":\"" + readSwitchState() + "\",";
  json += "\"dht1_temp\":"     + (isnan(dht1_temp)   ? String("\"N/A\"") : String(dht1_temp,   2)) + ",";
  json += "\"dht1_temp_f\":"   + (isnan(dht1_temp_f) ? String("\"N/A\"") : String(dht1_temp_f, 2)) + ",";
  json += "\"dht1_hum\":"      + (isnan(dht1_hum)    ? String("\"N/A\"") : String(dht1_hum,    2)) + ",";
  json += "\"dht2_temp\":"     + (isnan(dht2_temp)   ? String("\"N/A\"") : String(dht2_temp,   2)) + ",";
  json += "\"dht2_temp_f\":"   + (isnan(dht2_temp_f) ? String("\"N/A\"") : String(dht2_temp_f, 2)) + ",";
  json += "\"dht2_hum\":"      + (isnan(dht2_hum)    ? String("\"N/A\"") : String(dht2_hum,    2)) + ",";
  json += "\"dht3_temp\":"     + (isnan(dht3_temp)   ? String("\"N/A\"") : String(dht3_temp,   2)) + ",";
  json += "\"dht3_temp_f\":"   + (isnan(dht3_temp_f) ? String("\"N/A\"") : String(dht3_temp_f, 2)) + ",";
  json += "\"dht3_hum\":"      + (isnan(dht3_hum)    ? String("\"N/A\"") : String(dht3_hum,    2));
  json += "}";
  server.send(200, "application/json", json);
}

void handleNotFound() {
  server.send(404, "text/plain", "404: Not Found");
}

// -------------------------- 定时发布 MQTT --------------------------
void publishSensorData() {
  // 电压与市电
  mqttClient.publish(
    ("homeassistant/sensor/" + String(deviceIdentifier) + "/voltage/state").c_str(),
    String(readVoltage(), 2).c_str(), true);
  mqttClient.publish(
    ("homeassistant/binary_sensor/" + String(deviceIdentifier) + "/ac_power/state").c_str(),
    readSwitchState().c_str(), true);

  // 三路 DHT
  struct Pack { const char* name; float* tc; float* tf; float* rh; } packs[] = {
    {"dht1", &dht1_temp, &dht1_temp_f, &dht1_hum},
    {"dht2", &dht2_temp, &dht2_temp_f, &dht2_hum},
    {"dht3", &dht3_temp, &dht3_temp_f, &dht3_hum}
  };
  for (auto &p : packs) {
    if (!isnan(*p.tc)) mqttClient.publish(("homeassistant/sensor/" + String(deviceIdentifier) + "/" + p.name + "/temp_c/state").c_str(), String(*p.tc, 2).c_str(), true);
    if (!isnan(*p.tf)) mqttClient.publish(("homeassistant/sensor/" + String(deviceIdentifier) + "/" + p.name + "/temp_f/state").c_str(), String(*p.tf, 2).c_str(), true);
    if (!isnan(*p.rh)) mqttClient.publish(("homeassistant/sensor/" + String(deviceIdentifier) + "/" + p.name + "/humidity/state").c_str(), String(*p.rh, 2).c_str(), true);
  }
}

// -------------------------- Setup --------------------------
void setup() {
  Serial.begin(921600);
  delay(200);

  connectWiFi();

  pinMode(VOLTAGE_PIN_1, INPUT);
  pinMode(VOLTAGE_PIN_2, INPUT);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  dht1.begin();
  dht2.begin();
  dht3.begin();

  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.onNotFound(handleNotFound);
  server.begin();

  mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
  connectMQTT();
}

// -------------------------- Loop --------------------------
void loop() {
  if (!mqttClient.connected()) connectMQTT();
  mqttClient.loop();
  server.handleClient();

  // 读 DHT
  dht1_temp = dht1.readTemperature(); dht1_hum = dht1.readHumidity();
  dht2_temp = dht2.readTemperature(); dht2_hum = dht2.readHumidity();
  dht3_temp = dht3.readTemperature(); dht3_hum = dht3.readHumidity();

  dht1_temp_f = isnan(dht1_temp) ? NAN : (dht1_temp * 9.0f/5.0f + 32.0f);
  dht2_temp_f = isnan(dht2_temp) ? NAN : (dht2_temp * 9.0f/5.0f + 32.0f);
  dht3_temp_f = isnan(dht3_temp) ? NAN : (dht3_temp * 9.0f/5.0f + 32.0f);

  // 告警逻辑
  float v = readVoltage();
  String sw = readSwitchState();

  // 低压一次性告警
  if (v < LOW_VOLTAGE_THRESHOLD && lowVoltLatched == 0) {
    sendEmail("Low Voltage Alert", "Voltage dropped to " + String(v, 1) + " V");
    lowVoltLatched = 1;
  } else if (v >= LOW_VOLTAGE_THRESHOLD) {
    lowVoltLatched = 0;
  }

  // 市电丢失一次性告警 + LED
  digitalWrite(LED_PIN, (sw == "ON") ? HIGH : LOW);
  if (sw == "OFF" && lostPowerLatched == 0) {
    sendEmail("AC Power Lost", "Mains lost — running on battery.");
    lostPowerLatched = 1;
  } else if (sw == "ON") {
    lostPowerLatched = 0;
  }

  publishSensorData();
  delay(1000);
}

快速上手

  1. 安装库:ESP Mail Client、PubSubClient、DHT sensor library。
  2. 申请 Gmail 应用专用密码 并填入 sender_password
  3. 配置 MQTT:填好 MQTT_SERVER/PORT/USER/PASSWORD
  4. 烧录 到 ESP32,串口观察联网/连接日志。
  5. Home Assistant 中启用 MQTT Integration,设备应在连接后 10 秒内自动出现。

故障排查

  • HA 无实体 → 用 MQTT Explorer 检查是否有 homeassistant/.../config.../state 主题。
  • ESP32 不启动 → 把 DHT 从 GPIO0 移到 GPIO4
  • Gmail 发送失败 → 确认端口 587、应用专用密码、网络允许外发 SMTP。
  • ADC 数值偏差 → 标定 SCALE_CONSTANT,核对分压与参考电压,确保引脚不超 3.3 V。
  • 市电状态抖动 → 增加 滞回/平均滤波,或提高检测阈值的鲁棒性。