WiFi智能插座项目:远程控制与能耗监测¶
概述¶
本文从零构建一个生产级WiFi智能插座,覆盖从交流电测量理论到完整固件实现的全栈知识。读者将学习:
| 学习目标 | 涉及技术 | 难度 |
|---|---|---|
| 交流电功率测量原理 | 真有效值(True RMS)、功率因数、无功功率 | ★★★★ |
| 专用能量计量芯片 | HLW8012/BL0937 脉冲输出计量 | ★★★★ |
| OTA固件升级 | MQTT触发 + HTTP下载 + 分区切换 | ★★★★ |
| 零点检测调光 | 相位角控制 + TRIAC驱动 | ★★★★★ |
| 电能质量监测 | 谐波失真(THD)、功率因数校正 | ★★★★★ |
| 完整项目集成 | 能耗监测 + 定时 + OTA + 本地Web配置 | ★★★★★ |
前置知识: - 继电器控制基础 - MQTT协议详解 - C/C++ 基础,Arduino/ESP-IDF 开发环境
安全警告:本项目涉及220V/110V交流高压,必须做好绝缘隔离。建议先在低压仿真环境充分测试,再接入市电。所有高压部分须符合当地电气安全法规。
背景知识¶
交流电功率测量理论¶
基本电量定义¶
交流电路中的功率分析比直流复杂得多,需要理解三种功率的概念:
交流电路功率三角形:
S (视在功率, VA)
/|
/ |
/ |
/θ |
/ |
P-----Q
有功功率 无功功率
(W) (VAR)
关系式:
S² = P² + Q²
P = S × cos(θ) [有功功率,实际消耗]
Q = S × sin(θ) [无功功率,储能/释放]
cos(θ) = PF [功率因数 Power Factor]
有功功率 P(Active Power,单位:W): - 实际被负载消耗转化为热能、机械能的功率 - P = U_rms × I_rms × cos(θ) - 纯电阻负载:cos(θ)=1,P=S
无功功率 Q(Reactive Power,单位:VAR): - 电感/电容与电源之间交换但不消耗的功率 - 感性负载(电机、变压器):Q > 0,电流滞后电压 - 容性负载(电容补偿):Q < 0,电流超前电压
视在功率 S(Apparent Power,单位:VA): - S = U_rms × I_rms - 决定供电设备(变压器、导线)的容量需求
功率因数 PF(Power Factor): - PF = P / S = cos(θ)(纯正弦波情况下) - 非线性负载(开关电源、变频器):PF = P / S ≠ cos(θ),需考虑谐波
真有效值(True RMS)测量¶
为什么需要 True RMS?
普通万用表使用"平均值校正法"测量交流,仅对纯正弦波准确。现代电子设备(开关电源、LED驱动器、变频空调)产生非正弦电流波形,必须用 True RMS 方法:
True RMS 数学定义:
┌─────────────────────────────┐
│ 1 T │
I_rms = │ ─── × ∫ i²(t) dt │
│ T 0 │
└─────────────────────────────┘
离散采样实现:
┌──────────────────────────────────┐
│ 1 N-1 │
I_rms = │ ─── × Σ i²[k] │
│ N k=0 │
└──────────────────────────────────┘
其中 N 为一个完整工频周期内的采样点数
工频50Hz,采样率10kHz → N=200点/周期
采样率要求: - 奈奎斯特定理:采样率 ≥ 2 × 最高谐波频率 - 测量到第19次谐波(950Hz):采样率 ≥ 2kHz - 实际推荐:10kHz(覆盖到第100次谐波)
ADC 精度影响:
| ADC位数 | 分辨率 | 电流测量精度(20A量程) |
|---|---|---|
| 10-bit | 1/1024 | ±20mA(±0.1%) |
| 12-bit | 1/4096 | ±5mA(±0.025%) |
| 16-bit | 1/65536 | ±0.3mA(±0.0015%) |
ESP8266 内置 10-bit ADC,对于家用插座(0.1A精度要求)已足够。
功率因数与谐波失真¶
位移功率因数(Displacement PF): - 仅考虑基波分量的相位差 - DPF = cos(θ₁),θ₁为基波电压与基波电流的相位差
总谐波失真(THD,Total Harmonic Distortion):
┌──────────────────────────────────────────┐
│ √(I₂² + I₃² + I₄² + ... + Iₙ²) │
THD_I = │ ─────────────────────────────────────── │
│ I₁ │
└──────────────────────────────────────────┘
I₁ = 基波电流有效值
Iₙ = 第n次谐波电流有效值
真功率因数(True PF): - True PF = DPF / √(1 + THD²) - 开关电源典型值:THD=100%,True PF ≈ 0.7
谐波危害: - 增加线路损耗(I²R,谐波电流也产生热量) - 导致中性线过载(三相系统中3次谐波叠加) - 干扰其他设备(EMI) - 降低变压器效率
电气安全标准概述¶
IEC 60884 — 家用插头插座标准¶
IEC 60884(国际电工委员会)规定了家用和类似用途插头插座的安全要求:
主要技术要求:
| 参数 | 要求 |
|---|---|
| 额定电压 | 最高 250V AC |
| 额定电流 | 最高 16A(家用) |
| 绝缘电阻 | ≥ 5MΩ(500V DC测试) |
| 介电强度 | 2000V AC,1分钟无击穿 |
| 接触电阻 | ≤ 5mΩ(每个接触点) |
| 温升限制 | 外壳 ≤ 35K,接线端子 ≤ 45K |
| 机械寿命 | ≥ 5000次插拔 |
爬电距离与电气间隙(250V AC,污染等级2):
爬电距离(Creepage Distance):沿绝缘表面的最短路径
电气间隙(Clearance):通过空气的最短路径
250V AC 要求:
爬电距离 ≥ 3.0mm(CTI ≥ 175)
电气间隙 ≥ 3.0mm(过电压类别 II)
PCB设计中高压与低压之间必须保持足够间距!
UL 498 — 美国插座标准¶
UL 498(Underwriters Laboratories)是北美市场的强制认证标准:
与 IEC 60884 主要差异:
| 项目 | IEC 60884 | UL 498 |
|---|---|---|
| 额定电压 | 250V | 125V/250V |
| 额定电流 | 16A | 15A/20A |
| 接地要求 | 可选 | 强制(NEMA 5-15) |
| 测试电压 | 2000V | 1500V |
| 温升测试 | 35K | 30°C |
GB 2099 — 中国插座标准¶
中国强制性国家标准,基于 IEC 60884 修改: - GB 2099.1:家用和类似用途插头插座通用要求 - GB 2099.3:转换器的特殊要求 - 认证标志:CCC(中国强制认证)
智能插座额外要求: - 软件控制的继电器必须有机械互锁或独立硬件过流保护 - 无线通信模块须通过 SRRC(无线电型号核准) - 数据安全:用户数据不得明文传输
系统架构总览¶
┌─────────────────────────────────────────────────────────────────┐
│ 智能插座系统架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 220V AC ──┬── HLK-PM01 ──→ 5V ──→ AMS1117-3.3 ──→ 3.3V │
│ │ (AC/DC) │
│ │ │
│ ├── HLW8012 ──→ CF/CF1脉冲 ──→ ESP8266 GPIO │
│ │ (能量计量) │
│ │ │
│ ├── 零点检测电路 ──→ ESP8266 INT引脚 │
│ │ (过零检测) │
│ │ │
│ └── 继电器触点 ──→ 负载输出 │
│ ↑ │
│ ESP8266 GPIO ──→ ULN2003 ──→ 继电器线圈 │
│ │
│ ESP8266 ──WiFi──→ MQTT Broker ──→ 手机App/云平台 │
│ ──HTTP──→ OTA服务器 (固件升级) │
│ ──HTTP──→ 本地Web配置界面 │
│ │
└─────────────────────────────────────────────────────────────────┘
核心内容¶
HLW8012/BL0937 能量计量芯片¶
芯片概述与选型¶
HLW8012 是专为智能插座设计的单相电能计量 IC,广泛用于小米、公牛等品牌智能插座。BL0937 是其国产替代品,引脚兼容。
主要特性对比:
| 特性 | HLW8012 | BL0937 | ADE7953 |
|---|---|---|---|
| 测量参数 | 电压/电流/功率 | 电压/电流/功率 | 电压/电流/功率/无功 |
| 输出方式 | 脉冲频率 | 脉冲频率 | SPI/I2C |
| 精度 | ±1% | ±1% | ±0.1% |
| 工作电压 | 5V | 5V | 3.3V |
| 封装 | SOP-8 | SOP-8 | LFCSP-28 |
| 价格 | ¥1.5 | ¥1.2 | ¥25 |
| 适用场景 | 消费级智能插座 | 消费级智能插座 | 工业电表 |
HLW8012 工作原理¶
HLW8012 内部框图:
┌─────────────────────────────────────────┐
│ HLW8012 │
│ │
VCC ───→│ VCC │
│ ┌──────────┐ │
IP+ ───→│ IP+ │ │ CF ├───→ 功率脉冲
IP- ───→│ IP- │ Σ-Δ ADC │ CF1 ├───→ 电流/电压脉冲
│ │ │ │
VP ───→│ VP └──────────┘ SEL ├←── 选择输出
│ │
GND ───→│ GND │
└─────────────────────────────┘
CF 引脚:功率脉冲输出,频率正比于有功功率
CF1 引脚:电流或电压脉冲输出(由SEL引脚选择)
SEL 引脚:HIGH=电流,LOW=电压
脉冲频率与物理量的关系:
功率计算(CF引脚):
P(W) = F_CF × K_P
K_P = 1 / (F_CF_ref / P_ref) [校准系数]
电流计算(CF1引脚,SEL=HIGH):
I(A) = F_CF1 × K_I
电压计算(CF1引脚,SEL=LOW):
U(V) = F_CF1 × K_U
典型参数(5V供电,参考电路):
1W → CF 约 3.4Hz
1A → CF1 约 15.6Hz(SEL=HIGH)
1V → CF1 约 0.97Hz(SEL=LOW)
硬件连接电路¶
HLW8012 参考电路(220V AC 输入):
220V L线
│
┌────────┤
│ │
R1(1MΩ) 电流互感器CT
R2(1MΩ) │
R3(1MΩ) ┌──┴──┐
│ │ │
└──→ VP IP+/IP-
│ │
HLW8012 │
│ │
GND R_shunt(1mΩ)
│
GND
注意:
1. 分压电阻 R1+R2+R3 = 3MΩ,将220V分压到安全范围
2. 电流检测使用低阻值分流电阻(1mΩ)或电流互感器
3. 高压侧与低压侧必须保持 ≥ 3mm 爬电距离
4. 建议在 VP 引脚加 TVS 二极管防浪涌
ESP8266 驱动代码¶
// HLW8012 驱动库(基于脉冲计数)
// 适用:ESP8266 Arduino Core 3.x,HLW8012/BL0937
#include <Arduino.h>
// 引脚定义
#define HLW_CF_PIN D5 // 功率脉冲输入(中断)
#define HLW_CF1_PIN D6 // 电流/电压脉冲输入(中断)
#define HLW_SEL_PIN D7 // 选择引脚
// 校准系数(出厂后需用标准负载校准)
#define HLW_POWER_RATIO 1.0f // 功率校准系数
#define HLW_CURRENT_RATIO 1.0f // 电流校准系数
#define HLW_VOLTAGE_RATIO 1.0f // 电压校准系数
// 测量窗口(毫秒)
#define MEASURE_WINDOW_MS 2000
class HLW8012Driver {
public:
volatile uint32_t cf_count = 0; // 功率脉冲计数
volatile uint32_t cf1_count = 0; // 电流/电压脉冲计数
bool sel_mode = true; // true=电流,false=电压
float power_w = 0.0f;
float current_a = 0.0f;
float voltage_v = 0.0f;
float energy_kwh = 0.0f;
// 校准系数(通过已知负载校准后写入)
float k_power = 12530.0f; // Hz/W(参考值,需校准)
float k_current = 25740.0f; // Hz/A
float k_voltage = 15420.0f; // Hz/V
void begin() {
pinMode(HLW_CF_PIN, INPUT_PULLUP);
pinMode(HLW_CF1_PIN, INPUT_PULLUP);
pinMode(HLW_SEL_PIN, OUTPUT);
digitalWrite(HLW_SEL_PIN, HIGH); // 先测电流
sel_mode = true;
// 附加中断(上升沿计数)
attachInterrupt(digitalPinToInterrupt(HLW_CF_PIN),
[]() { instance->cf_count++; }, RISING);
attachInterrupt(digitalPinToInterrupt(HLW_CF1_PIN),
[]() { instance->cf1_count++; }, RISING);
instance = this;
}
// 每 MEASURE_WINDOW_MS 调用一次,更新测量值
void update() {
static uint32_t last_ms = 0;
static uint32_t last_cf = 0, last_cf1 = 0;
static float last_power = 0;
uint32_t now = millis();
uint32_t dt = now - last_ms;
if (dt < MEASURE_WINDOW_MS) return;
// 读取计数差值(关中断保证原子性)
noInterrupts();
uint32_t cf_delta = cf_count - last_cf;
uint32_t cf1_delta = cf1_count - last_cf1;
last_cf = cf_count;
last_cf1 = cf1_count;
interrupts();
float dt_s = dt / 1000.0f;
// 计算功率(CF引脚)
float cf_hz = cf_delta / dt_s;
power_w = (cf_hz > 0.5f) ? (cf_hz / k_power * HLW_POWER_RATIO) : 0.0f;
// 计算电流或电压(CF1引脚)
float cf1_hz = cf1_delta / dt_s;
if (sel_mode) {
// 当前测电流
current_a = (cf1_hz > 0.5f) ? (cf1_hz / k_current * HLW_CURRENT_RATIO) : 0.0f;
// 切换到测电压
sel_mode = false;
digitalWrite(HLW_SEL_PIN, LOW);
} else {
// 当前测电压
voltage_v = (cf1_hz > 0.5f) ? (cf1_hz / k_voltage * HLW_VOLTAGE_RATIO) : 0.0f;
// 切换回测电流
sel_mode = true;
digitalWrite(HLW_SEL_PIN, HIGH);
}
// 累计电能(kWh)
energy_kwh += power_w * (dt_s / 3600.0f) / 1000.0f;
// 计算功率因数(需要同时有电压和电流)
// PF = P / (U × I)
float apparent = voltage_v * current_a;
float pf = (apparent > 1.0f) ? (power_w / apparent) : 0.0f;
pf = constrain(pf, 0.0f, 1.0f);
last_ms = now;
last_power = power_w;
}
// 校准:使用已知功率的纯电阻负载(如白炽灯)
// known_power_w: 已知功率(W),known_voltage_v: 已知电压(V)
void calibrate(float known_power_w, float known_voltage_v) {
// 测量原始频率
uint32_t start = millis();
uint32_t cf_start = cf_count;
delay(5000); // 采集5秒
float cf_hz = (cf_count - cf_start) / 5.0f;
k_power = cf_hz / known_power_w;
Serial.printf("校准完成: k_power=%.1f Hz/W\n", k_power);
}
float get_power_factor() {
float apparent = voltage_v * current_a;
return (apparent > 1.0f) ? constrain(power_w / apparent, 0.0f, 1.0f) : 0.0f;
}
static HLW8012Driver* instance;
};
HLW8012Driver* HLW8012Driver::instance = nullptr;
HLW8012Driver hlw;
OTA 固件升级(MQTT触发)¶
OTA 升级流程¶
OTA 升级状态机:
IDLE ──→ [收到MQTT OTA命令] ──→ DOWNLOADING
↑ │
│ [下载固件]
│ │
│ [校验MD5]
│ │
│ ┌──────────┴──────────┐
│ │ │
│ [校验通过] [校验失败]
│ │ │
│ FLASHING IDLE(报错)
│ │
│ [写入Flash]
│ │
└──────────────── [重启完成] ←── REBOOTING
ESP8266 OTA 实现¶
// OTA 固件升级模块
// 依赖:ESP8266HTTPUpdate 库(Arduino Core 内置)
#include <ESP8266HTTPUpdate.h>
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
// OTA 状态
enum OTAState {
OTA_IDLE,
OTA_DOWNLOADING,
OTA_SUCCESS,
OTA_FAILED
};
OTAState ota_state = OTA_IDLE;
String ota_url = "";
String ota_md5 = "";
// MQTT 命令格式:
// Topic: socket/node001/ota
// Payload: {"url":"http://192.168.1.100/firmware.bin","md5":"abc123...","version":"2.1.0"}
void handle_ota_command(const JsonDocument& doc) {
if (ota_state != OTA_IDLE) {
Serial.println("[OTA] 升级进行中,忽略新命令");
return;
}
ota_url = doc["url"].as<String>();
ota_md5 = doc["md5"].as<String>();
ota_state = OTA_DOWNLOADING;
Serial.printf("[OTA] 开始升级: %s\n", ota_url.c_str());
}
// 在主循环中调用(非阻塞触发,实际下载在此函数内阻塞)
void process_ota() {
if (ota_state != OTA_DOWNLOADING) return;
// 发布升级开始通知
mqtt.publish("socket/node001/ota_status",
"{\"status\":\"downloading\"}");
// 设置 MD5 校验
if (ota_md5.length() == 32) {
ESPhttpUpdate.setMD5(ota_md5.c_str());
}
// 注册回调
ESPhttpUpdate.onStart([]() {
Serial.println("[OTA] 开始下载...");
});
ESPhttpUpdate.onProgress([](int cur, int total) {
Serial.printf("[OTA] 进度: %d/%d bytes\n", cur, total);
});
ESPhttpUpdate.onEnd([]() {
Serial.println("[OTA] 下载完成,准备重启");
});
ESPhttpUpdate.onError([](int err) {
Serial.printf("[OTA] 错误: %d\n", err);
});
// 执行升级(阻塞直到完成或失败)
t_httpUpdate_return ret = ESPhttpUpdate.update(wifiClient, ota_url);
switch (ret) {
case HTTP_UPDATE_FAILED:
ota_state = OTA_FAILED;
{
char msg[128];
snprintf(msg, sizeof(msg),
"{\"status\":\"failed\",\"error\":\"%s\"}",
ESPhttpUpdate.getLastErrorString().c_str());
mqtt.publish("socket/node001/ota_status", msg);
}
break;
case HTTP_UPDATE_NO_UPDATES:
ota_state = OTA_IDLE;
mqtt.publish("socket/node001/ota_status",
"{\"status\":\"no_update\"}");
break;
case HTTP_UPDATE_OK:
ota_state = OTA_SUCCESS;
mqtt.publish("socket/node001/ota_status",
"{\"status\":\"success\",\"rebooting\":true}");
delay(500);
ESP.restart(); // 重启加载新固件
break;
}
}
本地 Web 配置界面(Captive Portal)¶
配网与配置流程¶
首次配置流程(Captive Portal):
用户操作 ESP8266 行为
─────────────────────────────────────────────────────
1. 长按配置按钮3秒 → 进入AP模式
SSID: SmartSocket_XXXX
IP: 192.168.4.1
2. 手机连接AP → DNS劫持所有域名到192.168.4.1
浏览器自动弹出配置页面
3. 填写WiFi/MQTT配置 → 保存到EEPROM/SPIFFS
点击"保存并重启"
4. ESP8266重启 → 连接配置的WiFi
连接MQTT Broker
正常工作模式
Web 服务器实现¶
// 本地 Web 配置服务器
// 依赖:ESP8266WebServer, DNSServer(Arduino Core 内置)
#include <ESP8266WebServer.h>
#include <DNSServer.h>
#include <EEPROM.h>
ESP8266WebServer webServer(80);
DNSServer dnsServer;
// 配置数据结构(存储在 EEPROM)
struct Config {
char magic[4]; // "CFG\0" 标识有效配置
char wifi_ssid[32];
char wifi_pass[64];
char mqtt_host[64];
uint16_t mqtt_port;
char mqtt_user[32];
char mqtt_pass[32];
char device_id[16];
float overload_a; // 过载阈值(A)
uint8_t checksum; // 简单校验和
};
Config cfg;
// 配置页面 HTML(内嵌,无需文件系统)
const char CONFIG_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>智能插座配置</title>
<style>
body { font-family: Arial; max-width: 400px; margin: 20px auto; padding: 0 20px; }
input { width: 100%; padding: 8px; margin: 5px 0 15px; box-sizing: border-box; }
button { width: 100%; padding: 10px; background: #4CAF50; color: white; border: none; cursor: pointer; }
h2 { color: #333; }
</style>
</head>
<body>
<h2>智能插座配置</h2>
<form action="/save" method="POST">
<label>WiFi名称:</label>
<input name="ssid" value="%SSID%" required>
<label>WiFi密码:</label>
<input name="pass" type="password" value="%PASS%">
<label>MQTT服务器:</label>
<input name="mqtt_host" value="%MQTT_HOST%" required>
<label>MQTT端口:</label>
<input name="mqtt_port" type="number" value="%MQTT_PORT%">
<label>MQTT用户名:</label>
<input name="mqtt_user" value="%MQTT_USER%">
<label>MQTT密码:</label>
<input name="mqtt_pass" type="password" value="%MQTT_PASS%">
<label>设备ID:</label>
<input name="device_id" value="%DEVICE_ID%">
<label>过载阈值(A):</label>
<input name="overload" type="number" step="0.1" value="%OVERLOAD%">
<button type="submit">保存并重启</button>
</form>
</body>
</html>
)rawliteral";
void start_config_portal() {
// 启动 AP 模式
char ap_ssid[32];
snprintf(ap_ssid, sizeof(ap_ssid), "SmartSocket_%06X",
(uint32_t)(ESP.getChipId() & 0xFFFFFF));
WiFi.softAP(ap_ssid, "12345678");
Serial.printf("[AP] SSID: %s, IP: %s\n",
ap_ssid, WiFi.softAPIP().toString().c_str());
// DNS 劫持(Captive Portal)
dnsServer.start(53, "*", WiFi.softAPIP());
// 注册路由
webServer.on("/", HTTP_GET, []() {
String html = FPSTR(CONFIG_HTML);
html.replace("%SSID%", cfg.wifi_ssid);
html.replace("%PASS%", cfg.wifi_pass);
html.replace("%MQTT_HOST%", cfg.mqtt_host);
html.replace("%MQTT_PORT%", String(cfg.mqtt_port));
html.replace("%MQTT_USER%", cfg.mqtt_user);
html.replace("%MQTT_PASS%", cfg.mqtt_pass);
html.replace("%DEVICE_ID%", cfg.device_id);
html.replace("%OVERLOAD%", String(cfg.overload_a, 1));
webServer.send(200, "text/html", html);
});
webServer.on("/save", HTTP_POST, []() {
strlcpy(cfg.wifi_ssid, webServer.arg("ssid").c_str(), sizeof(cfg.wifi_ssid));
strlcpy(cfg.wifi_pass, webServer.arg("pass").c_str(), sizeof(cfg.wifi_pass));
strlcpy(cfg.mqtt_host, webServer.arg("mqtt_host").c_str(),sizeof(cfg.mqtt_host));
cfg.mqtt_port = webServer.arg("mqtt_port").toInt();
strlcpy(cfg.mqtt_user, webServer.arg("mqtt_user").c_str(),sizeof(cfg.mqtt_user));
strlcpy(cfg.mqtt_pass, webServer.arg("mqtt_pass").c_str(),sizeof(cfg.mqtt_pass));
strlcpy(cfg.device_id, webServer.arg("device_id").c_str(),sizeof(cfg.device_id));
cfg.overload_a = webServer.arg("overload").toFloat();
memcpy(cfg.magic, "CFG", 4);
save_config();
webServer.send(200, "text/html",
"<h2>配置已保存,设备将在3秒后重启...</h2>");
delay(3000);
ESP.restart();
});
// 捕获所有未知路径(Captive Portal 重定向)
webServer.onNotFound([]() {
webServer.sendHeader("Location", "http://192.168.4.1/");
webServer.send(302, "text/plain", "");
});
webServer.begin();
}
void save_config() {
// 计算校验和
uint8_t sum = 0;
uint8_t* p = (uint8_t*)&cfg;
for (size_t i = 0; i < sizeof(cfg) - 1; i++) sum += p[i];
cfg.checksum = sum;
EEPROM.begin(sizeof(Config));
EEPROM.put(0, cfg);
EEPROM.commit();
EEPROM.end();
}
bool load_config() {
EEPROM.begin(sizeof(Config));
EEPROM.get(0, cfg);
EEPROM.end();
// 验证魔数和校验和
if (memcmp(cfg.magic, "CFG", 4) != 0) return false;
uint8_t sum = 0;
uint8_t* p = (uint8_t*)&cfg;
for (size_t i = 0; i < sizeof(cfg) - 1; i++) sum += p[i];
return (sum == cfg.checksum);
}
深入原理¶
零点检测与相位角调光¶
零点检测原理¶
相位角调光(Phase-Angle Dimming)通过在交流电每个半周期的特定时刻触发 TRIAC(双向可控硅),控制负载获得的有效电压,从而调节亮度或功率。
相位角调光波形图:
全功率(α=0°):
┌──────────────────────────────────────────────────────┐
│ 正弦波 │
│ ╭──────╮ ╭──────╮ │
│ ╱ ╲ ╱ ╲ │
│───╱──────────╲──────────╱──────────╲────────────────│
│ ╲ ╱ ╲ ╱ │
│ ╲──────╯ ╲──────╯ │
└──────────────────────────────────────────────────────┘
50%功率(α=90°):
┌──────────────────────────────────────────────────────┐
│ 正弦波(仅后半段导通) │
│ ╭──────╮ ╭──────╮ │
│ ╱ ↑ ╲ ╱ ↑ ╲ │
│───╱ α=90° ╲──────────╱ α=90° ╲────────────────│
│ ╲ ╱ ╲ ╱ │
│ ╲──────╯ ╲──────╯ │
│ ████████████████████████████████████████████████ │
│ ↑导通区域(阴影部分) │
└──────────────────────────────────────────────────────┘
有效功率与触发角关系:
P(α) = P_max × (1 - α/π + sin(2α)/(2π))
α=0° → P=100%
α=90° → P=50%
α=180°→ P=0%
零点检测电路¶
零点检测电路(光耦隔离):
220V L ──┬── R1(100kΩ) ──┬── R2(100kΩ) ──┐
│ │ │
│ D1(1N4148) │
│ │ │
220V N ──┴───────────────┴──────────────→ PC817光耦阳极
│
LED
│
GND(高压侧)
PC817光耦集电极 ──→ 3.3V(通过10kΩ上拉)
PC817光耦发射极 ──→ GND(低压侧)
PC817光耦集电极 ──→ ESP8266 GPIO(中断引脚)
工作原理:
- 交流电过零时,光耦 LED 电流最小,输出高电平
- 交流电峰值时,光耦 LED 电流最大,输出低电平
- 每个过零点产生一个上升沿中断
- 50Hz交流:每秒100个过零点(正负各50个)
相位角调光实现¶
// 相位角调光控制器
// 注意:调光仅适用于白炽灯、卤素灯、部分调光LED
// 不适用于:开关电源、电机(需使用专用调速器)
#include <Arduino.h>
#define ZC_PIN D2 // 零点检测输入(中断)
#define TRIAC_PIN D3 // TRIAC 触发输出
// 调光参数
volatile uint8_t dimmer_level = 100; // 0-100%
volatile bool zc_detected = false;
volatile uint32_t zc_time_us = 0;
// 零点检测中断服务程序
ICACHE_RAM_ATTR void zc_isr() {
zc_time_us = micros();
zc_detected = true;
}
// 定时器中断:在正确时刻触发 TRIAC
// 50Hz交流:半周期 = 10000μs
// 触发延迟 = (1 - level/100) × 10000μs
void setup_dimmer() {
pinMode(ZC_PIN, INPUT_PULLUP);
pinMode(TRIAC_PIN, OUTPUT);
digitalWrite(TRIAC_PIN, LOW);
attachInterrupt(digitalPinToInterrupt(ZC_PIN), zc_isr, RISING);
}
void process_dimmer() {
if (!zc_detected) return;
zc_detected = false;
if (dimmer_level == 0) {
digitalWrite(TRIAC_PIN, LOW);
return;
}
if (dimmer_level >= 100) {
// 全功率:直接触发
digitalWrite(TRIAC_PIN, HIGH);
delayMicroseconds(100);
digitalWrite(TRIAC_PIN, LOW);
return;
}
// 计算触发延迟(μs)
// level=100 → delay=0μs(立即触发)
// level=50 → delay=5000μs(半周期后触发)
// level=0 → delay=10000μs(不触发)
uint32_t delay_us = (uint32_t)((100 - dimmer_level) * 100); // 0-10000μs
// 等待到触发时刻
uint32_t target = zc_time_us + delay_us;
while (micros() < target) {
yield(); // 允许 ESP8266 处理 WiFi 任务
}
// 触发 TRIAC(脉冲宽度 100μs)
digitalWrite(TRIAC_PIN, HIGH);
delayMicroseconds(100);
digitalWrite(TRIAC_PIN, LOW);
}
// 设置调光级别(0-100%)
void set_dimmer_level(uint8_t level) {
dimmer_level = constrain(level, 0, 100);
}
重要限制: - 相位角调光会产生大量谐波,THD 可达 100%+ - 不适合容性/感性负载(开关电源、电机) - 建议仅用于纯电阻负载(白炽灯、电热丝) - 调光 LED 需要专门的"可调光"型号
电能质量监测(THD 与谐波分析)¶
谐波分析原理¶
谐波分析(离散傅里叶变换 DFT):
采集 N 个样本(覆盖整数个工频周期):
x[0], x[1], ..., x[N-1]
DFT 计算第 k 次谐波幅值:
N-1
X[k] = Σ x[n] × e^(-j2πkn/N)
n=0
谐波幅值:|X[k]| = √(Re²[k] + Im²[k])
谐波相位:∠X[k] = arctan(Im[k] / Re[k])
对于50Hz基波,N=200点(10kHz采样率):
k=1 → 50Hz 基波
k=3 → 150Hz 3次谐波
k=5 → 250Hz 5次谐波
k=7 → 350Hz 7次谐波
...
简化 THD 计算(ESP8266 实现)¶
// 简化谐波分析(仅计算到第7次谐波)
// 使用 Goertzel 算法(比完整 FFT 更高效)
#define SAMPLE_RATE_HZ 10000 // 10kHz 采样率
#define FUNDAMENTAL_HZ 50 // 工频 50Hz
#define N_SAMPLES 200 // 一个工频周期的样本数
// Goertzel 算法计算单个频率的幅值
float goertzel_magnitude(int16_t* samples, int n, int target_freq, int sample_rate) {
float k = (float)target_freq * n / sample_rate;
float omega = 2.0f * M_PI * k / n;
float coeff = 2.0f * cosf(omega);
float s0 = 0, s1 = 0, s2 = 0;
for (int i = 0; i < n; i++) {
s0 = samples[i] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
float real = s1 - s2 * cosf(omega);
float imag = s2 * sinf(omega);
return sqrtf(real * real + imag * imag) / (n / 2.0f);
}
struct PowerQuality {
float fundamental_a; // 基波电流(A)
float harmonics[7]; // 2-8次谐波幅值(A)
float thd_percent; // 总谐波失真(%)
float true_pf; // 真功率因数
float displacement_pf; // 位移功率因数
};
PowerQuality analyze_power_quality(int16_t* current_samples, int n) {
PowerQuality pq = {};
// 计算基波和各次谐波
pq.fundamental_a = goertzel_magnitude(current_samples, n,
FUNDAMENTAL_HZ, SAMPLE_RATE_HZ);
float harmonic_sum_sq = 0;
for (int h = 2; h <= 8; h++) {
pq.harmonics[h-2] = goertzel_magnitude(current_samples, n,
h * FUNDAMENTAL_HZ, SAMPLE_RATE_HZ);
harmonic_sum_sq += pq.harmonics[h-2] * pq.harmonics[h-2];
}
// THD 计算
if (pq.fundamental_a > 0.01f) {
pq.thd_percent = 100.0f * sqrtf(harmonic_sum_sq) / pq.fundamental_a;
}
// 真功率因数(考虑谐波)
// True PF = 1 / sqrt(1 + (THD/100)^2) × DPF
float thd_ratio = pq.thd_percent / 100.0f;
pq.true_pf = pq.displacement_pf / sqrtf(1.0f + thd_ratio * thd_ratio);
return pq;
}
// 电能质量报告(通过 MQTT 上报)
void report_power_quality(const PowerQuality& pq) {
StaticJsonDocument<512> doc;
doc["fundamental_a"] = serialized(String(pq.fundamental_a, 3));
doc["thd_percent"] = serialized(String(pq.thd_percent, 1));
doc["true_pf"] = serialized(String(pq.true_pf, 3));
JsonArray harmonics = doc.createNestedArray("harmonics");
for (int i = 0; i < 7; i++) {
harmonics.add(serialized(String(pq.harmonics[i], 3)));
}
char buf[512];
serializeJson(doc, buf);
mqtt.publish("socket/node001/power_quality", buf);
}
电能质量等级评估¶
IEC 61000-3-2 谐波电流限值(A类设备,16A以下):
次数 限值(A) 典型开关电源 典型白炽灯
─────────────────────────────────────────
3 2.30 1.5A 0.01A
5 1.14 0.8A 0.01A
7 0.77 0.4A 0.01A
9 0.40 0.2A 0.01A
11 0.33 0.1A 0.01A
13 0.21 0.05A 0.01A
评估结论:
THD < 5% → 优秀(纯电阻负载)
THD 5-20% → 良好(高效LED灯)
THD 20-50% → 一般(普通开关电源)
THD > 50% → 差(劣质充电器、调光器)
完整项目实战¶
项目规格与物料清单¶
项目目标:制作一个生产级WiFi智能插座,具备能耗监测、定时控制、OTA升级和本地Web配置功能。
完整物料清单(BOM):
| 序号 | 器件 | 型号/规格 | 数量 | 参考价格 | 采购渠道 |
|---|---|---|---|---|---|
| 1 | 主控模块 | ESP8266 NodeMCU v3 | 1 | ¥12 | 淘宝/立创 |
| 2 | 能量计量IC | HLW8012 SOP-8 | 1 | ¥1.5 | 立创商城 |
| 3 | 继电器 | HF32F-G 5V 10A | 1 | ¥3 | 立创商城 |
| 4 | 继电器驱动 | ULN2003A SOP-16 | 1 | ¥0.5 | 立创商城 |
| 5 | AC/DC电源 | HLK-PM01 5V/600mA | 1 | ¥8 | 淘宝 |
| 6 | LDO稳压 | AMS1117-3.3 SOT-223 | 1 | ¥0.3 | 立创商城 |
| 7 | 零点检测光耦 | PC817 SOP-4 | 1 | ¥0.3 | 立创商城 |
| 8 | 分压电阻 | 1MΩ ¼W × 3 | 3 | ¥0.1 | 立创商城 |
| 9 | 限流电阻 | 100kΩ ¼W | 2 | ¥0.1 | 立创商城 |
| 10 | 滤波电容 | 100μF/16V 电解 | 2 | ¥0.5 | 立创商城 |
| 11 | 去耦电容 | 100nF 0805 × 10 | 10 | ¥0.5 | 立创商城 |
| 12 | TVS二极管 | P6KE250A | 1 | ¥1 | 立创商城 |
| 13 | 状态LED | 红/绿 3mm × 2 | 2 | ¥0.2 | 立创商城 |
| 14 | 按键 | 6×6mm 轻触开关 | 1 | ¥0.2 | 立创商城 |
| 15 | PCB | 双面板 50×80mm | 1 | ¥5 | 嘉立创 |
| 16 | 外壳 | 86型插座面板 | 1 | ¥15 | 淘宝 |
| 合计 | ≈¥48 |
PCB 布局指南¶
PCB 分区布局(俯视图):
┌─────────────────────────────────────────────────────────┐
│ PCB 50×80mm │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ 高压区域 │ │ 低压区域 │ │
│ │ (220V AC) │ │ (3.3V/5V DC) │ │
│ │ │ │ │ │
│ │ HLK-PM01 │ │ ESP8266 HLW8012 │ │
│ │ 继电器 │ │ NodeMCU 能量计量 │ │
│ │ TVS二极管 │ │ │ │
│ │ │ │ ULN2003 AMS1117 │ │
│ │ ← 3mm间距 →│ │ 继电器驱动 LDO │ │
│ └──────────────┘ └──────────────────────────────┘ │
│ │
│ ← 高压区爬电距离 ≥ 3mm,建议开槽隔离 → │
│ │
│ 接地平面:低压 GND 与高压 GND 单点连接(安全地) │
└─────────────────────────────────────────────────────────┘
关键布局规则:
1. 高压区与低压区之间开 V 形槽(增加爬电距离)
2. HLW8012 分压电阻靠近 VP 引脚放置
3. 去耦电容紧靠 IC 电源引脚
4. 继电器线圈并联续流二极管(1N4007)
5. 零点检测光耦放在高低压边界处
完整固件实现¶
// 智能插座完整固件
// 硬件:ESP8266 NodeMCU v3
// 依赖库:
// - PubSubClient 2.8.0 (MQTT)
// - ArduinoJson 6.21.0
// - ESP8266HTTPUpdate (内置)
// - ESP8266WebServer (内置)
// - NTPClient 3.2.1 (时间同步)
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <ESP8266HTTPUpdate.h>
#include <ESP8266WebServer.h>
#include <DNSServer.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <EEPROM.h>
#include <Ticker.h>
// ─── 引脚定义 ───────────────────────────────────────────
#define RELAY_PIN D1 // 继电器(低电平触发)
#define LED_R_PIN D4 // 红色LED(低电平亮)
#define LED_G_PIN D0 // 绿色LED(低电平亮)
#define BTN_PIN D3 // 配置按键(低电平按下)
#define HLW_CF_PIN D5 // HLW8012 功率脉冲
#define HLW_CF1_PIN D6 // HLW8012 电流/电压脉冲
#define HLW_SEL_PIN D7 // HLW8012 选择引脚
#define ZC_PIN D2 // 零点检测
// ─── 全局对象 ────────────────────────────────────────────
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
ESP8266WebServer webServer(80);
DNSServer dnsServer;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 28800); // UTC+8
Ticker reportTicker;
Ticker timerTicker;
// ─── 状态变量 ────────────────────────────────────────────
bool relay_state = false;
bool config_mode = false;
uint32_t boot_time_ms = 0;
// ─── HLW8012 测量数据 ────────────────────────────────────
volatile uint32_t hlw_cf_count = 0;
volatile uint32_t hlw_cf1_count = 0;
bool hlw_sel_mode = true; // true=电流,false=电压
float meas_power_w = 0;
float meas_current_a = 0;
float meas_voltage_v = 220.0f; // 默认220V(无电压测量时)
float meas_energy_kwh = 0;
float meas_pf = 0;
// HLW8012 校准系数(出厂校准后存入EEPROM)
float k_power = 12530.0f;
float k_current = 25740.0f;
float k_voltage = 15420.0f;
// ─── 定时任务 ────────────────────────────────────────────
struct TimerTask {
uint8_t hour, minute;
bool action;
bool enabled;
uint8_t weekdays; // bit0=周日...bit6=周六
};
#define MAX_TIMERS 8
TimerTask timers[MAX_TIMERS] = {};
// ─── 配置结构 ────────────────────────────────────────────
struct Config {
char magic[4];
char wifi_ssid[32];
char wifi_pass[64];
char mqtt_host[64];
uint16_t mqtt_port;
char mqtt_user[32];
char mqtt_pass[32];
char device_id[16];
float overload_a;
float k_power;
float k_current;
float k_voltage;
TimerTask timers[MAX_TIMERS];
uint8_t checksum;
} cfg;
// ─── MQTT 主题 ───────────────────────────────────────────
char TOPIC_CTRL[64];
char TOPIC_STATUS[64];
char TOPIC_ALERT[64];
char TOPIC_OTA[64];
char TOPIC_POWER_QUALITY[64];
// ─── 中断服务程序 ────────────────────────────────────────
ICACHE_RAM_ATTR void hlw_cf_isr() { hlw_cf_count++; }
ICACHE_RAM_ATTR void hlw_cf1_isr() { hlw_cf1_count++; }
// ─── 继电器控制 ──────────────────────────────────────────
void relay_set(bool on) {
relay_state = on;
digitalWrite(RELAY_PIN, on ? LOW : HIGH);
digitalWrite(LED_G_PIN, on ? LOW : HIGH);
// 保存状态
EEPROM.write(sizeof(Config), on ? 1 : 0);
EEPROM.commit();
}
// ─── HLW8012 更新 ────────────────────────────────────────
void hlw_update() {
static uint32_t last_ms = 0;
static uint32_t last_cf = 0;
static uint32_t last_cf1 = 0;
uint32_t now = millis();
uint32_t dt = now - last_ms;
if (dt < 2000) return;
noInterrupts();
uint32_t cf_d = hlw_cf_count - last_cf;
uint32_t cf1_d = hlw_cf1_count - last_cf1;
last_cf = hlw_cf_count;
last_cf1 = hlw_cf1_count;
interrupts();
float dt_s = dt / 1000.0f;
float cf_hz = cf_d / dt_s;
float cf1_hz = cf1_d / dt_s;
meas_power_w = (cf_hz > 0.5f) ? (cf_hz / k_power) : 0.0f;
if (hlw_sel_mode) {
meas_current_a = (cf1_hz > 0.5f) ? (cf1_hz / k_current) : 0.0f;
hlw_sel_mode = false;
digitalWrite(HLW_SEL_PIN, LOW);
} else {
meas_voltage_v = (cf1_hz > 0.5f) ? (cf1_hz / k_voltage) : 220.0f;
hlw_sel_mode = true;
digitalWrite(HLW_SEL_PIN, HIGH);
}
// 累计电能
meas_energy_kwh += meas_power_w * (dt_s / 3600.0f) / 1000.0f;
// 功率因数
float apparent = meas_voltage_v * meas_current_a;
meas_pf = (apparent > 1.0f) ? constrain(meas_power_w / apparent, 0.0f, 1.0f) : 0.0f;
last_ms = now;
}
// ─── 过载保护 ────────────────────────────────────────────
void check_overload() {
static uint32_t overload_start = 0;
if (!relay_state) { overload_start = 0; return; }
if (meas_current_a > cfg.overload_a) {
if (overload_start == 0) overload_start = millis();
else if (millis() - overload_start > 2000) {
relay_set(false);
overload_start = 0;
digitalWrite(LED_R_PIN, LOW); // 红灯亮表示故障
StaticJsonDocument<128> doc;
doc["type"] = "overload";
doc["current"] = serialized(String(meas_current_a, 2));
doc["limit"] = cfg.overload_a;
char buf[128];
serializeJson(doc, buf);
mqtt.publish(TOPIC_ALERT, buf);
}
} else {
overload_start = 0;
digitalWrite(LED_R_PIN, HIGH); // 红灯灭
}
}
// ─── 定时任务检查 ────────────────────────────────────────
void check_timers() {
if (!timeClient.isTimeSet()) return;
int hour = timeClient.getHours();
int minute = timeClient.getMinutes();
int weekday = timeClient.getDay(); // 0=周日
for (int i = 0; i < MAX_TIMERS; i++) {
if (!timers[i].enabled) continue;
if (!(timers[i].weekdays & (1 << weekday))) continue;
if (timers[i].hour == hour && timers[i].minute == minute) {
// 防止同一分钟内重复触发
static uint32_t last_trigger[MAX_TIMERS] = {};
uint32_t now = millis();
if (now - last_trigger[i] > 60000) {
relay_set(timers[i].action);
last_trigger[i] = now;
}
}
}
}
// ─── 状态上报 ────────────────────────────────────────────
void report_status() {
if (!mqtt.connected()) return;
StaticJsonDocument<384> doc;
doc["state"] = relay_state;
doc["current"] = serialized(String(meas_current_a, 2));
doc["voltage"] = serialized(String(meas_voltage_v, 1));
doc["power"] = serialized(String(meas_power_w, 1));
doc["energy"] = serialized(String(meas_energy_kwh, 4));
doc["pf"] = serialized(String(meas_pf, 3));
doc["uptime"] = (millis() - boot_time_ms) / 1000;
doc["rssi"] = WiFi.RSSI();
doc["ip"] = WiFi.localIP().toString();
char buf[384];
serializeJson(doc, buf);
mqtt.publish(TOPIC_STATUS, buf, true); // 保留消息
}
// ─── MQTT 消息处理 ───────────────────────────────────────
void on_mqtt_message(char* topic, byte* payload, unsigned int len) {
StaticJsonDocument<512> doc;
if (deserializeJson(doc, payload, len) != DeserializationError::Ok) return;
if (strcmp(topic, TOPIC_CTRL) == 0) {
const char* cmd = doc["cmd"];
if (!cmd) return;
else if (strcmp(cmd, "on") == 0) relay_set(true);
else if (strcmp(cmd, "off") == 0) relay_set(false);
else if (strcmp(cmd, "toggle") == 0) relay_set(!relay_state);
else if (strcmp(cmd, "query") == 0) report_status();
else if (strcmp(cmd, "reset_energy") == 0) meas_energy_kwh = 0;
else if (strcmp(cmd, "set_timer") == 0) {
int id = doc["id"] | -1;
if (id >= 0 && id < MAX_TIMERS) {
timers[id].hour = doc["hour"] | 0;
timers[id].minute = doc["minute"] | 0;
timers[id].action = doc["action"] | false;
timers[id].weekdays = doc["weekdays"] | 0x7F;
timers[id].enabled = true;
memcpy(cfg.timers, timers, sizeof(timers));
save_config();
}
}
} else if (strcmp(topic, TOPIC_OTA) == 0) {
handle_ota_command(doc);
}
}
// ─── MQTT 重连 ───────────────────────────────────────────
void mqtt_reconnect() {
if (mqtt.connected()) return;
static uint32_t last_attempt = 0;
if (millis() - last_attempt < 5000) return;
last_attempt = millis();
// 遗嘱消息(设备离线时自动发布)
char will_msg[64];
snprintf(will_msg, sizeof(will_msg),
"{\"state\":\"offline\",\"id\":\"%s\"}", cfg.device_id);
if (mqtt.connect(cfg.device_id, cfg.mqtt_user, cfg.mqtt_pass,
TOPIC_STATUS, 1, true, will_msg)) {
mqtt.subscribe(TOPIC_CTRL);
mqtt.subscribe(TOPIC_OTA);
// 上线通知
char online_msg[64];
snprintf(online_msg, sizeof(online_msg),
"{\"state\":\"online\",\"id\":\"%s\"}", cfg.device_id);
mqtt.publish(TOPIC_STATUS, online_msg, true);
Serial.println("[MQTT] 已连接");
}
}
// ─── 初始化 ──────────────────────────────────────────────
void setup() {
Serial.begin(115200);
boot_time_ms = millis();
// GPIO 初始化
pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_R_PIN, OUTPUT);
pinMode(LED_G_PIN, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP);
pinMode(HLW_CF_PIN, INPUT_PULLUP);
pinMode(HLW_CF1_PIN, INPUT_PULLUP);
pinMode(HLW_SEL_PIN, OUTPUT);
digitalWrite(RELAY_PIN, HIGH); // 继电器默认断开
digitalWrite(LED_R_PIN, HIGH); // LED 默认灭
digitalWrite(LED_G_PIN, HIGH);
digitalWrite(HLW_SEL_PIN, HIGH); // 先测电流
// 加载配置
if (!load_config()) {
// 无有效配置,进入配置模式
Serial.println("[CFG] 无配置,进入AP配置模式");
config_mode = true;
start_config_portal();
return;
}
// 恢复上次继电器状态
EEPROM.begin(sizeof(Config) + 1);
bool saved_state = EEPROM.read(sizeof(Config));
relay_set(saved_state);
// 复制定时任务
memcpy(timers, cfg.timers, sizeof(timers));
// 设置 MQTT 主题
snprintf(TOPIC_CTRL, sizeof(TOPIC_CTRL),
"socket/%s/control", cfg.device_id);
snprintf(TOPIC_STATUS, sizeof(TOPIC_STATUS),
"socket/%s/status", cfg.device_id);
snprintf(TOPIC_ALERT, sizeof(TOPIC_ALERT),
"socket/%s/alert", cfg.device_id);
snprintf(TOPIC_OTA, sizeof(TOPIC_OTA),
"socket/%s/ota", cfg.device_id);
snprintf(TOPIC_POWER_QUALITY, sizeof(TOPIC_POWER_QUALITY),
"socket/%s/power_quality", cfg.device_id);
// 连接 WiFi
WiFi.begin(cfg.wifi_ssid, cfg.wifi_pass);
Serial.printf("[WiFi] 连接 %s ...\n", cfg.wifi_ssid);
uint32_t wifi_start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wifi_start < 15000) {
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n[WiFi] 已连接,IP: %s\n",
WiFi.localIP().toString().c_str());
} else {
Serial.println("\n[WiFi] 连接失败,进入配置模式");
config_mode = true;
start_config_portal();
return;
}
// NTP 时间同步
timeClient.begin();
timeClient.update();
// MQTT 初始化
mqtt.setServer(cfg.mqtt_host, cfg.mqtt_port);
mqtt.setCallback(on_mqtt_message);
mqtt.setBufferSize(512);
mqtt_reconnect();
// HLW8012 中断
attachInterrupt(digitalPinToInterrupt(HLW_CF_PIN), hlw_cf_isr, RISING);
attachInterrupt(digitalPinToInterrupt(HLW_CF1_PIN), hlw_cf1_isr, RISING);
// 定时上报(每10秒)
reportTicker.attach(10, report_status);
// 定时任务检查(每分钟)
timerTicker.attach(60, check_timers);
Serial.println("[INIT] 初始化完成");
}
// ─── 主循环 ──────────────────────────────────────────────
void loop() {
if (config_mode) {
dnsServer.processNextRequest();
webServer.handleClient();
return;
}
// 长按按键3秒进入配置模式
static uint32_t btn_press_ms = 0;
if (digitalRead(BTN_PIN) == LOW) {
if (btn_press_ms == 0) btn_press_ms = millis();
else if (millis() - btn_press_ms > 3000) {
Serial.println("[BTN] 长按,进入配置模式");
config_mode = true;
start_config_portal();
return;
}
} else {
if (btn_press_ms > 0 && millis() - btn_press_ms < 1000) {
// 短按:切换继电器
relay_set(!relay_state);
}
btn_press_ms = 0;
}
// MQTT 维护
if (!mqtt.connected()) mqtt_reconnect();
mqtt.loop();
// HLW8012 测量更新
hlw_update();
// 过载保护
check_overload();
// OTA 处理
process_ota();
// NTP 每小时同步一次
static uint32_t last_ntp = 0;
if (millis() - last_ntp > 3600000) {
timeClient.update();
last_ntp = millis();
}
}
性能调优¶
WiFi 功耗优化¶
// 降低 WiFi 发射功率(减少干扰,降低功耗)
WiFi.setOutputPower(17.5); // 默认20.5dBm,降至17.5dBm
// 启用 Modem Sleep(MQTT 空闲时降低功耗)
// 注意:Modem Sleep 会增加 MQTT 延迟
WiFi.setSleepMode(WIFI_MODEM_SLEEP);
// 固定 IP(避免 DHCP 延迟)
IPAddress ip(192, 168, 1, 100);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
WiFi.config(ip, gateway, subnet);
MQTT 消息优化¶
// 使用 QoS 0 减少握手开销(状态上报不需要可靠传输)
mqtt.publish(TOPIC_STATUS, buf, false); // QoS 0,不保留
// 使用 QoS 1 保证控制命令可靠(继电器控制需要确认)
// PubSubClient 默认 QoS 0,控制命令建议在应用层加确认
// 批量上报(减少 MQTT 连接次数)
// 将多个测量值合并到一个 JSON 消息
完整项目集成测试¶
功能测试清单¶
| 测试项 | 测试步骤 | 预期结果 | 通过标准 |
|---|---|---|---|
| 1. 基本开关 | MQTT发送 {"cmd":"on"} |
继电器吸合,绿灯亮 | 延迟 < 500ms |
| 2. 能耗测量 | 接入100W白炽灯 | 功率显示 95-105W | 误差 < 5% |
| 3. 过载保护 | 接入超过阈值负载 | 2秒后自动断开,红灯亮 | 保护动作正常 |
| 4. 定时任务 | 设置定时开关 | 到时自动执行 | 时间误差 < 1分钟 |
| 5. OTA升级 | 发送OTA命令 | 下载并重启,版本更新 | 升级成功率 > 95% |
| 6. 配网功能 | 长按按键3秒 | 进入AP模式,手机可连接 | 配置页面正常显示 |
| 7. 断电记忆 | 断电后重新上电 | 恢复断电前状态 | 状态保持一致 |
| 8. WiFi重连 | 路由器重启 | 自动重连 | 重连时间 < 30秒 |
安全测试¶
电气安全测试(必须由专业人员使用专业设备进行):
1. 绝缘电阻测试:
- 测试仪器:兆欧表(500V DC)
- 测试点:高压侧 ↔ 低压侧
- 合格标准:≥ 5MΩ
2. 介电强度测试:
- 测试仪器:耐压测试仪
- 测试电压:2000V AC,持续1分钟
- 合格标准:无击穿、无闪络
3. 接触电阻测试:
- 测试仪器:微欧计
- 测试点:继电器触点
- 合格标准:≤ 5mΩ
4. 温升测试:
- 测试条件:额定负载,连续运行2小时
- 测试点:外壳、接线端子、继电器
- 合格标准:外壳温升 ≤ 35K,端子温升 ≤ 45K
5. EMC测试(可选,认证必需):
- 传导骚扰:EN 55014-1
- 辐射骚扰:EN 55014-1
- 抗扰度:EN 55014-2
长期稳定性测试¶
老化测试方案:
测试时长:7×24小时(168小时)
测试负载:额定功率的80%(如10A插座接8A负载)
测试周期:
- 每小时开关一次(模拟日常使用)
- 每6小时记录一次温度、功率、电流
- 每24小时检查一次继电器触点
监测指标:
✓ 继电器动作次数:≥ 168次
✓ 测量精度漂移:< 2%
✓ WiFi断线次数:< 5次
✓ MQTT消息丢失率:< 0.1%
✓ 外壳最高温度:< 60°C
✓ 继电器触点电阻增长:< 10%
合格标准:
- 所有监测指标在范围内
- 无自动重启
- 无功能异常
常见问题与调试¶
WiFi 连接问题¶
问题1:WiFi 连接失败或频繁断线¶
症状:
- 串口输出 [WiFi] 连接失败
- 设备反复重启
- MQTT 消息延迟或丢失
可能原因与解决方案:
| 原因 | 诊断方法 | 解决方案 |
|---|---|---|
| 信号弱 | 检查 RSSI < -75dBm | 移近路由器或增加中继器 |
| 信道拥挤 | 使用 WiFi 分析仪 | 路由器切换到⅙/11信道 |
| DHCP 冲突 | 检查路由器日志 | 设置静态 IP |
| 功耗不足 | 测量 3.3V 电压 | 更换更大容量电源模块 |
| 天线问题 | 对比其他 ESP8266 | 更换模块或外接天线 |
调试代码:
// WiFi 诊断信息
void wifi_diagnostics() {
Serial.printf("[WiFi] SSID: %s\n", WiFi.SSID().c_str());
Serial.printf("[WiFi] RSSI: %d dBm\n", WiFi.RSSI());
Serial.printf("[WiFi] Channel: %d\n", WiFi.channel());
Serial.printf("[WiFi] IP: %s\n", WiFi.localIP().toString().c_str());
Serial.printf("[WiFi] Gateway: %s\n", WiFi.gatewayIP().toString().c_str());
Serial.printf("[WiFi] DNS: %s\n", WiFi.dnsIP().toString().c_str());
Serial.printf("[WiFi] MAC: %s\n", WiFi.macAddress().c_str());
}
// WiFi 事件监听
WiFi.onStationModeDisconnected([](const WiFiEventStationModeDisconnected& evt) {
Serial.printf("[WiFi] 断开连接,原因: %d\n", evt.reason);
// 原因代码:
// 2 = AUTH_EXPIRE(认证超时)
// 3 = AUTH_LEAVE(主动断开)
// 4 = ASSOC_EXPIRE(关联超时)
// 8 = ASSOC_LEAVE(AP 踢出)
// 15 = 4WAY_HANDSHAKE_TIMEOUT(握手超时)
// 201= NO_AP_FOUND(找不到AP)
});
问题2:配网模式无法进入¶
症状: - 长按按键3秒无反应 - 手机搜不到 AP
解决方案:
// 强制进入配网模式(调试用)
// 在 setup() 开头添加:
if (digitalRead(BTN_PIN) == LOW) {
delay(100);
if (digitalRead(BTN_PIN) == LOW) {
Serial.println("[DEBUG] 强制进入配网模式");
config_mode = true;
start_config_portal();
while(1) {
dnsServer.processNextRequest();
webServer.handleClient();
yield();
}
}
}
// 或者通过串口命令触发
if (Serial.available()) {
char c = Serial.read();
if (c == 'C' || c == 'c') {
config_mode = true;
start_config_portal();
}
}
继电器控制问题¶
问题3:继电器抖动或无法吸合¶
症状: - 继电器发出"嗒嗒"声 - 继电器吸合后立即释放 - 负载无法正常工作
可能原因:
1. 驱动电流不足:
- ULN2003 输出电流 < 继电器线圈电流
- 解决:更换大电流驱动(如 ULN2803)或使用 MOSFET
2. 电源纹波过大:
- 5V 电源带载能力不足
- 解决:在继电器线圈并联 100μF 电容
3. 续流二极管缺失或反接:
- 线圈断电时产生反向电动势
- 解决:检查 1N4007 二极管方向(阴极接 VCC)
4. GPIO 输出电平错误:
- 低电平触发继电器,但 GPIO 输出高电平
- 解决:检查代码逻辑,确保 digitalWrite(RELAY_PIN, LOW) 吸合
测试代码:
// 继电器测试程序(独立运行)
void relay_test() {
Serial.println("[TEST] 继电器测试开始");
for (int i = 0; i < 10; i++) {
Serial.printf("[TEST] 第 %d 次吸合\n", i+1);
digitalWrite(RELAY_PIN, LOW); // 吸合
delay(1000);
Serial.printf("[TEST] 第 %d 次释放\n", i+1);
digitalWrite(RELAY_PIN, HIGH); // 释放
delay(1000);
}
Serial.println("[TEST] 继电器测试完成");
}
HLW8012 测量问题¶
问题4:功率/电流测量不准确¶
症状: - 测量值偏差 > 10% - 空载时显示非零功率 - 电压显示异常
校准步骤:
// HLW8012 校准程序
// 需要:标准功率计、纯电阻负载(如100W白炽灯)
void hlw_calibration() {
Serial.println("[CAL] HLW8012 校准开始");
Serial.println("[CAL] 请接入已知功率的纯电阻负载(如100W白炽灯)");
Serial.println("[CAL] 使用标准功率计测量实际功率");
Serial.println("[CAL] 输入实际功率值(W):");
while (!Serial.available()) delay(100);
float actual_power = Serial.parseFloat();
Serial.println("[CAL] 采集5秒数据...");
uint32_t cf_start = hlw_cf_count;
delay(5000);
uint32_t cf_end = hlw_cf_count;
float cf_hz = (cf_end - cf_start) / 5.0f;
k_power = cf_hz / actual_power;
cfg.k_power = k_power;
save_config();
Serial.printf("[CAL] 校准完成!k_power = %.1f Hz/W\n", k_power);
Serial.printf("[CAL] 测量频率: %.1f Hz\n", cf_hz);
Serial.printf("[CAL] 实际功率: %.1f W\n", actual_power);
}
// 在 setup() 中添加串口命令触发
if (Serial.available() && Serial.read() == 'K') {
hlw_calibration();
}
常见测量误差来源:
| 误差来源 | 影响 | 解决方案 |
|---|---|---|
| 分压电阻误差 | 电压测量偏差 | 使用 1% 精度电阻 |
| 温度漂移 | 长期运行精度下降 | 定期重新校准 |
| 非线性负载 | 功率因数 < 1 | 使用真有效值测量 |
| 电源纹波 | 测量抖动 | 加强滤波电容 |
| PCB 布线 | 高频干扰 | 缩短 HLW8012 到采样点距离 |
问题5:ACS712 电流传感器噪声大¶
症状: - 电流读数跳动 ± 0.5A - 空载时显示 0.2-0.3A
解决方案:
// ACS712 软件滤波(移动平均)
#define ACS712_PIN A0
#define ACS712_SAMPLES 50
float read_acs712_current() {
long sum = 0;
for (int i = 0; i < ACS712_SAMPLES; i++) {
sum += analogRead(ACS712_PIN);
delayMicroseconds(100);
}
float avg = sum / (float)ACS712_SAMPLES;
// ACS712-20A:2.5V = 0A,灵敏度 100mV/A
// ESP8266 ADC:0-1V → 0-1023
float voltage = avg * (1.0f / 1023.0f); // 0-1V
float current = (voltage - 0.5f) / 0.1f; // (V - Vref) / 灵敏度
// 死区处理(< 0.1A 视为 0)
if (fabs(current) < 0.1f) current = 0.0f;
return current;
}
// 硬件改进:
// 1. ACS712 输出端并联 0.1μF 电容(滤除高频噪声)
// 2. 使用差分放大器(如 INA240)替代 ACS712
// 3. 增加 RC 低通滤波器(R=10kΩ, C=1μF, fc=16Hz)
OTA 升级问题¶
问题6:OTA 升级失败¶
症状: - 下载卡在某个百分比 - 提示 "MD5 校验失败" - 升级后无法启动
调试方法:
// OTA 详细日志
ESPhttpUpdate.onProgress([](int cur, int total) {
static int last_percent = -1;
int percent = (cur * 100) / total;
if (percent != last_percent) {
Serial.printf("[OTA] 进度: %d%% (%d/%d bytes)\n",
percent, cur, total);
last_percent = percent;
}
});
ESPhttpUpdate.onError([](int err) {
Serial.printf("[OTA] 错误代码: %d\n", err);
Serial.printf("[OTA] 错误信息: %s\n",
ESPhttpUpdate.getLastErrorString().c_str());
// 错误代码:
// -1 = HTTP_UPDATE_FAILED
// -2 = HTTP_UPDATE_NO_UPDATES
// -3 = HTTP_UPDATE_NO_PARTITION
});
常见失败原因:
| 错误 | 原因 | 解决方案 |
|---|---|---|
| MD5 校验失败 | 固件损坏或传输错误 | 重新生成 MD5,检查网络 |
| 分区不足 | 固件大小 > OTA 分区 | 减小固件或使用 minimal SPIFFS |
| 下载超时 | 网络不稳定 | 增加超时时间或使用本地服务器 |
| 启动失败 | 固件与硬件不匹配 | 检查编译目标板型 |
// 增加 OTA 超时时间
ESPhttpUpdate.setTimeout(60000); // 60秒
// 使用本地 HTTP 服务器(Python)
// python -m http.server 8000
// OTA URL: http://192.168.1.100:8000/firmware.bin
电能质量监测问题¶
问题7:THD 计算结果异常¶
症状: - THD 显示 > 200% - 谐波幅值大于基波
可能原因:
1. 采样率不足:
- 10kHz 采样率无法捕捉高次谐波
- 解决:降低分析的最高谐波次数(如仅到第7次)
2. 采样窗口不对齐:
- 采样点数不是工频周期的整数倍
- 解决:确保 N = 采样率 / 工频(如 10000/50 = 200)
3. ADC 噪声:
- ESP8266 ADC 精度有限(10-bit)
- 解决:使用外部 ADC(如 ADS1115,16-bit)
4. 算法错误:
- Goertzel 算法参数错误
- 解决:使用经过验证的 FFT 库(如 arduinoFFT)
验证方法:
// 使用纯正弦波测试(如白炽灯)
// 预期结果:THD < 5%,仅基波有明显幅值
void test_thd_with_resistive_load() {
Serial.println("[TEST] 请接入纯电阻负载(白炽灯)");
delay(5000);
// 采集数据
int16_t samples[N_SAMPLES];
for (int i = 0; i < N_SAMPLES; i++) {
samples[i] = analogRead(A0);
delayMicroseconds(100); // 10kHz 采样
}
// 分析
PowerQuality pq = analyze_power_quality(samples, N_SAMPLES);
Serial.printf("[TEST] 基波: %.3f A\n", pq.fundamental_a);
Serial.printf("[TEST] THD: %.1f%%\n", pq.thd_percent);
Serial.println("[TEST] 各次谐波:");
for (int h = 2; h <= 8; h++) {
Serial.printf(" %d次: %.3f A\n", h, pq.harmonics[h-2]);
}
// 预期:白炽灯 THD < 5%,2-8次谐波 < 0.01A
}
测试与验证¶
单元测试¶
// 单元测试框架(使用 AUnit 库)
#include <AUnit.h>
test(HLW8012_PowerCalculation) {
// 模拟 CF 脉冲频率
float cf_hz = 3400.0f; // 对应 1W(假设 k_power=3400)
float power = cf_hz / 3400.0f;
assertEqual(power, 1.0f);
}
test(OverloadProtection) {
meas_current_a = 15.0f; // 超过阈值
cfg.overload_a = 10.0f;
check_overload();
delay(2100); // 等待2秒保护动作
check_overload();
assertFalse(relay_state); // 继电器应断开
}
test(TimerTask) {
timers[0].hour = 10;
timers[0].minute = 30;
timers[0].action = true;
timers[0].enabled = true;
timers[0].weekdays = 0x7F; // 每天
// 模拟时间到达
// (需要 mock NTPClient)
check_timers();
assertTrue(relay_state);
}
void setup() {
Serial.begin(115200);
while (!Serial);
}
void loop() {
aunit::TestRunner::run();
}
集成测试¶
# MQTT 集成测试脚本(Python)
# 依赖:pip install paho-mqtt
import paho.mqtt.client as mqtt
import json
import time
BROKER = "192.168.1.100"
DEVICE_ID = "node001"
def on_connect(client, userdata, flags, rc):
print(f"[MQTT] 已连接,返回码: {rc}")
client.subscribe(f"socket/{DEVICE_ID}/status")
def on_message(client, userdata, msg):
print(f"[MQTT] 收到消息: {msg.topic}")
payload = json.loads(msg.payload)
print(json.dumps(payload, indent=2, ensure_ascii=False))
client = mqtt.Client()
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883, 60)
client.loop_start()
# 测试用例
def test_relay_control():
print("\n[TEST] 测试继电器控制")
client.publish(f"socket/{DEVICE_ID}/control", json.dumps({"cmd": "on"}))
time.sleep(2)
client.publish(f"socket/{DEVICE_ID}/control", json.dumps({"cmd": "off"}))
time.sleep(2)
print("[TEST] 继电器控制测试完成")
def test_timer():
print("\n[TEST] 测试定时任务")
timer = {
"cmd": "set_timer",
"id": 0,
"hour": 14,
"minute": 30,
"action": True,
"weekdays": 0x7F
}
client.publish(f"socket/{DEVICE_ID}/control", json.dumps(timer))
print("[TEST] 定时任务已设置")
def test_ota():
print("\n[TEST] 测试 OTA 升级")
ota_cmd = {
"url": "http://192.168.1.100:8000/firmware.bin",
"md5": "abc123...",
"version": "2.1.0"
}
client.publish(f"socket/{DEVICE_ID}/ota", json.dumps(ota_cmd))
print("[TEST] OTA 命令已发送")
# 运行测试
try:
test_relay_control()
time.sleep(5)
test_timer()
time.sleep(5)
# test_ota() # 谨慎使用
time.sleep(60)
except KeyboardInterrupt:
print("\n[TEST] 测试中断")
finally:
client.loop_stop()
client.disconnect()
延伸阅读¶
内部链接¶
- 继电器控制基础 — 继电器工作原理与驱动电路
- MQTT协议详解 — MQTT QoS、遗嘱消息、TLS加密
- 数据可视化 — Grafana仪表盘设计
- 自动化控制系统 — Modbus RTU 工业控制
- 环境监控系统 — 多传感器融合与规则引擎
外部资源¶
芯片数据手册: - HLW8012 Datasheet — 能量计量芯片完整规格 - BL0937 Datasheet — 国产替代方案 - ESP8266 Technical Reference — ESP8266 完整技术手册
标准文档: - IEC 60884-1:2002 — 家用插头插座标准 - IEC 61000-3-2:2018 — 谐波电流限值 - GB 2099.1-2008 — 中国插座标准
开源项目: - Tasmota — 开源智能插座固件(支持 HLW8012) - ESPHome — 声明式 ESP 固件框架 - Sonoff-Tasmota — Sonoff Pow 智能插座固件
技术文章: - True RMS vs Average RMS — Fluke 官方解释 - Power Factor Correction — TI 功率因数校正应用笔记 - ESP8266 OTA Updates — 官方 OTA 文档
参考资料¶
- IEC 60884-1:2002 — Plugs and socket-outlets for household and similar purposes
- UL 498 — Standard for Safety Attachment Plugs and Receptacles
- GB 2099.1-2008 — 家用和类似用途插头插座 第1部分:通用要求
- IEC 61000-3-2:2018 — Electromagnetic compatibility (EMC) – Part 3-2: Limits for harmonic current emissions
- HLW8012 Datasheet — Single Phase Energy Metering IC, Hilink Electronics
- ESP8266 Technical Reference — Espressif Systems, v1.7, 2020
- IEEE Std 1459-2010 — IEEE Standard Definitions for the Measurement of Electric Power Quantities Under Sinusoidal, Nonsinusoidal, Balanced, or Unbalanced Conditions
- MQTT Version 5.0 — OASIS Standard, 2019
- Arduino ESP8266 Core Documentation — https://arduino-esp8266.readthedocs.io/
- Tasmota Documentation — https://tasmota.github.io/docs/
版本历史: - v2.0 (2026-03-10):完整重写,新增 HLW8012 能量计量、OTA 升级、零点检测调光、电能质量监测、完整项目实战和故障排查 - v1.0 (2025-12-15):初始版本,基础 WiFi 继电器控制
作者:嵌入式知识平台
许可:CC BY-NC-SA 4.0