先交代一下背景,这个项目我从硬件选型到 Web 上位机落地,前后折腾了大概三周。中间踩了不少坑,也推翻过一版设计,最后沉淀出来的这套方案在稳定性、可维护性和扩展性上达到了我个人比较满意的状态。这篇东西不是官方文档的复述,而是把整个设计决策链、关键代码思路、以及实测中遇到的现象和处理方式完整梳理一遍,希望能给正在做同类项目的朋友一些参考。
1. 为什么自建 Web 上位机,而不是直接 App 控制
很多人拿到 ESP32 第一反应就是接一个某云平台,或者写个蓝牙 App。这两种路线我都试过,最后全部放弃了。
先说云平台方案。涂鸦、阿里云物联网这些平台确实快,模块烧个固件就能用,App 也是现成的。但问题在于:整个链路是黑盒的。设备状态上报延迟多少、服务器挂在哪儿、断网重连策略是什么,这些你完全不可控。我实际测试中遇到最典型的问题是,平台侧偶尔会出现设备在线但控制指令延迟 3 到 5 秒才下发的情况,排查起来非常被动,因为你连日志都拿不到完整的。对于智能插座这种对响应速度有要求的设备,这种不确定性是致命的。
蓝牙 App 方案的问题更直接:脱离手机就废了。我想实现的是人在外面也能看家里设备状态,蓝牙根本做不到远程。而且 BLE 配对连接这套流程,在 iOS 和 Android 上的兼容性处理也够写一篇长文了,投入产出比不划算。
所以最后我选择了ESP32 作为设备端 + 局域网 Web 服务器 + 浏览器访问的架构。设备端直接跑一个轻量级 HTTP Server,内置 Web 页面,所有设备接入同一个局域网后,用浏览器打开设备 IP 就能完成所有操作。这个方案的核心优势有三个:
- 零依赖:不需要注册任何平台账号,没有第三方服务器,没有年费。
- 低延迟:局域网内通信,控制指令从点击到继电器动作通常能控制在 100ms 以内。
- 可定制性强:整个 Web 上位机的界面、功能、协议全部由自己掌控,想加什么功能直接改代码。
这套架构还有一个隐性收益:因为上位机和设备端跑在同一块芯片上,调试的时候不需要额外的串口线连电脑看日志,直接在浏览器里就能看到设备实时状态,开发效率提升非常明显。
2. 硬件电路与器件选型:继电器、电源和电流采集的三个关键点
硬件设计是这次项目里最容易被低估的部分。很多人觉得 ESP32 开发板插上继电器模块就能用,实际量产和长期稳定运行根本不行,里面有三处细节必须处理好。
2.1 继电器驱动电路:光耦隔离是底线
ESP32 的 GPIO 输出能力有限,直接驱动继电器线圈是不可靠的。我最初用的方案是 GPIO -> 三极管 S8050 -> 继电器线圈,用 5V 供电,实测能用,但存在两个隐患:
第一是上电瞬间的 GPIO 状态不确定。ESP32 在复位期间,所有引脚会处于高阻态或随机电平,如果此时继电器默认低电平触发,可能会导致插座在上电瞬间误动作。解决方法是加一个 10kΩ 下拉电阻,确保默认状态为断开。
第二是感性负载的反向电动势。继电器线圈断电瞬间会产生很高的反向电压,如果不加续流二极管,轻则干扰 ESP32 工作,重则击穿 GPIO。所以完整的驱动电路应该是:
GPIO -> 电阻限流(1kΩ) -> 光耦(PC817) -> 三极管(S8050) -> 继电器线圈 | 1N4007 续流二极管光耦 PC817 的作用是电气隔离。虽然共地的情况下不隔离也能工作,但隔离之后,强电侧的干扰不会通过地线回流到 ESP32 的电源系统,这在插座连接电机、开关电源等设备时尤其重要。
提示:光耦输入端电阻的计算逻辑是:PC817 的 IF 典型值 10mA,ESP32 GPIO 输出 3.3V,减去光耦压降约 1.2V,所以 (3.3-1.2)/0.01 = 210Ω,取 1kΩ 是为了降低功耗,实测 1kΩ 下光耦也能正常导通,只是响应稍慢几微秒,继电器动作完全不受影响。
2.2 电源方案:AC-DC 降压模块的纹波问题
智能插座是要装进 86 盒里的,空间非常有限,而且直接接触 220V 交流电,电源方案必须同时兼顾体积、效率和安全性。
我测试过三种方案:
| 方案 | 输出纹波 | 待机功耗 | 安全性 | 体积 |
|---|---|---|---|---|
| 阻容降压 + 稳压管 | 大(数百mV) | 低 | 差(非隔离) | 最小 |
| HLK-PM01 AC-DC 模块 | 小(<50mV) | 较高 | 好(隔离) | 小 |
| 手机充电器改造 | 小 | 高 | 好(隔离) | 大 |
最后选了 HLK-PM01,5V/3W 的隔离型 AC-DC 模块。这个模块的纹波控制得不错,实测 ESP32 在 Wi-Fi 发射瞬间(电流峰值约 300mA)时,电压跌落控制在 100mV 以内,不会导致 ESP32 复位。
但这里有个必须注意的问题:ESP32 对 3.3V 的纹波敏感程度远高于 5V。如果直接用 AMS1117 从 5V 降到 3.3V,输出端的纹波会非常难看。我实测在 Wi-Fi 开启时,AMS1117 输出端纹波能达到 80mV 以上,虽然不至于让 ESP32 死机,但不稳定因素越少越好。最终方案是在 AMS1117 输出端并联了一个 10μF 钽电容 + 0.1μF 陶瓷电容,两级滤波后纹波降到 30mV 以内。
2.3 电流采集:计量芯片 vs 互感器+运放
因为目标是做一个"能用"的智能插座,而不是单纯的遥控开关,所以电流采集是必须的。两个方案:
- 互感器 + 运放 + ADC:成本低,但需要自己搭偏置电路、校准,而且 ESP32 内置 ADC 的非线性(特别是接近 0V 时)会让你怀疑人生。这个方案需要外置运放(如 LM358)将交流信号抬升到 0-3.3V 区间,还要做半波整流或 RMS 计算,误差普遍在 5% 以上。
- 专用计量芯片 HLW8032 / BL0942:大概 2 块钱一颗,内部集成了放大器、ADC 和 RMS 计算,直接输出有效值,通过 UART 读取,精度可以达到 1% 以内。
我用的 HLW8032,它有两个关键参数需要配置:电流采样电阻和分压电阻。电流采样电阻取 1mΩ(毫欧级,必须是合金电阻,不能用普通贴片电阻,温度系数太大),电压采样通过电阻分压到芯片的 VP 引脚。HLW8032 内部会做增益校准,UART 输出的数据可以直接换算成功率、电压、电流。
HLW8032 的输出数据是 24 位寄存器值,需要乘以系数才是实际物理量。计算公式为:
- 电流 = 寄存器值 × 电流系数(由采样电阻决定)
- 电压 = 寄存器值 × 电压系数(由分压电阻决定)
- 功率 = 电流 × 电压(芯片内部计算)
这个芯片还有一个坑:它输出的功率是瞬时功率,不是平均功率。如果直接显示,数字会跳得比较厉害,尤其是接了感性负载(如风扇、电机)的时候。我的做法是在上位机端做了 2 秒滑动平均,实测稳定很多。
3. 固件端的设计:HTTP API 与 WebSocket 状态同步
固件架构是整个系统的大脑,直接决定了上位机体验的上限。我一开始用的是纯 HTTP 轮询,每隔 1 秒请求一次状态接口,发现两个问题:一是页面数据刷新不够实时(1 秒延迟感知很明显),二是 HTTP 请求开销大(每次请求都有完整的 Header),在 ESP32 这种资源受限的芯片上,频繁请求会挤占 Wi-Fi 吞吐。
所以最终固件端采用了HTTP(控制)+ WebSocket(状态推送)的双通道设计。
3.1 控制指令走 HTTP POST,简单可靠
控制指令用 HTTP POST,数据格式用 JSON。比如打开插座:
POST /api/relay Content-Type: application/json { "state": 1, "source": "web_ui" }固件收到后解析 JSON,控制 GPIO,然后返回当前状态:
{ "success": true, "state": 1, "timestamp": 1692600000 }为什么控制不走 WebSocket?因为控制操作是低频事件(用户不会每秒都按开关),HTTP 的请求-响应模型在语义上更清晰,调试也方便(浏览器直接访问 URL 就能测试)。而且 HTTP 天然自带状态码,成功、失败、参数错误都能用 HTTP 状态码表达,WebSocket 还需要自己在应用层定义一套响应格式,徒增复杂度。
3.2 状态同步走 WebSocket,实时且省资源
WebSocket 通道负责两件事:设备主动推送状态变化和周期推送监测数据。
状态变化的来源有两个:一是用户通过 Web 界面控制,二是用户实体按下插座上的物理按键(在 GPIO 上接了一个轻触开关)。物理按键是必须保留的,因为我始终认为"如果断网了插座就完全没法用了"这种设计很反人类。
按键触发后,固件立即通过 WebSocket 广播新状态,所有正在查看页面的浏览器都会实时刷新。这就涉及到一个关键点:WebSocket 是点对点的,不是广播协议,每个浏览器客户端连接时都需要单独推送。我在固件里维护了一个客户端列表,连接时加入,断开时移除。
监测数据(电压、电流、功率)则通过定时任务每 2 秒推送一次。这个频率是权衡后的结果:太频繁浪费带宽,太稀疏数据曲线不连续。
固件端核心代码逻辑:
void sendStatusToAllClients() { StaticJsonDocument<256> doc; doc["type"] = "status"; doc["state"] = relayState; doc["power"] = currentPower; doc["voltage"] = currentVoltage; doc["current"] = currentCurrent; doc["rssi"] = WiFi.RSSI(); String payload; serializeJson(doc, payload); for (WebSocketClient* client : wsClients) { client->sendTXT(payload); } }这里用 ArduinoJson 库处理序列化,比手动拼接字符串安全得多。手动拼接引号、转义、中文字符编码,总有一天会出 BUG,而且出了还很难排查。
3.3 ESP32 资源管理:Wi-Fi 保活与看门狗
ESP32 跑 Web 服务器最怕的就是Wi-Fi 掉线后不会自动重连。ESP32 的 Wi-Fi 模块在长时间运行后可能因为路由器踢设备、信道拥塞等原因断连,默认行为是 不 会 自 动 重 连。
所以在固件里必须实现自己的 Wi-Fi 保活机制:
unsigned long lastWiFiCheckTime = 0; const unsigned long WIFI_CHECK_INTERVAL = 30000; // 30秒检查一次 void checkWiFiConnection() { if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi lost, reconnecting..."); WiFi.disconnect(); WiFi.reconnect(); } }另外,看门狗也是必须的。ESP32 在某些极端情况下(比如内存碎片严重、异常中断)会死循环,如果没有看门狗,整个系统就瘫了。ESP32 用的是任务看门狗(TWDT),可以在 core 1 上跑一个定期喂狗的任务,主循环阻塞超过 5 秒就触发重启。
还有一点很关键:Flash 写入寿命。ESP32 的 Flash 擦写寿命大约 10 万次,如果每次状态变化都写入 NVS(非易失性存储),很快会损耗 Flash。我的做法是:状态变化时写入 NVS,但做了 5 秒的防抖——即状态稳定 5 秒后如果没再次变化才写入,避免用户快速连按时频繁擦写 Flash。
4. Web 上位机的前端实现细节
上位机页面我用的方案是原生 HTML + CSS + JavaScript,没有引入 Vue/React。原因很简单:这块芯片上的 HTTP Server 主要瓶颈是 Flash 存储和内存,而不是 CPU。一个完整功能的单页应用,压缩后约 30KB,如果用 Vue 全家桶,体积直接翻倍甚至三倍,而且引入构建工具链会让整个项目复杂度失控。
4.1 页面架构与交互流程
页面布局非常简单:顶部是状态卡片(当前功率、电压、电流),中间是大按钮(开关控制),底部是历史数据折线图。这样的布局足够日常使用了。
WebSocket 连接的生命周期处理是整个前端最需要注意的部分:
function connectWebSocket() { const ws = new WebSocket(`ws://${location.host}/ws`); ws.onopen = () => { console.log('WebSocket connected'); setStatusIndicator('online'); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); updateUI(data); }; ws.onclose = () => { console.log('WebSocket disconnected, retrying in 3 seconds...'); setStatusIndicator('offline'); setTimeout(connectWebSocket, 3000); }; ws.onerror = (error) => { console.error('WebSocket error:', error); ws.close(); }; }这段代码里最关键的是onclose里的setTimeout(connectWebSocket, 3000)重连逻辑。用户可能会刷新页面、休眠电脑、切换网络,WebSocket 连接随时可能断开,自动重连是 Web 上位机能长期稳定使用的前提。
4.2 控制按钮的防抖处理
用户快速点击开关按钮时,如果不做防抖,会出现一种情况:按钮状态变成了"开",但实际继电器还没动作(因为上一个指令还在处理中),用户再次点击,发送的却是"关"指令,结果状态混乱。
我的处理方式是前端禁用按钮 + 后端处理队列双重保险:
async function toggleRelay() { const btn = document.getElementById('relayBtn'); btn.disabled = true; // 前端先禁用,防止连点 try { const response = await fetch('/api/relay', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: targetState }) }); const data = await response.json(); // 等服务器确认后才更新按钮状态 setButtonState(data.state); } catch (error) { console.error('Control failed:', error); } finally { btn.disabled = false; } }注意这里的状态更新逻辑:按钮状态永远以服务器响应的数据为准,而不是以用户点击时的期望值。这是一个很重要的设计原则,前端只负责展示,状态永远以真实设备状态为准。
4.3 历史数据可视化
我用的是轻量级的 Chart.js 库,画功率/电流趋势图。ESP32 的 Flash 存储空间有限,无法长期保存历史数据,所以我的方案是:每次打开页面时,从最近的数据开始画,保留最近 1 小时的数据,超过部分丢弃。
实际上历史数据只是锦上添花,真正使得这个功能有价值的是,你可以在页面上直观地看到设备运行周期的功率变化曲线:触发瞬间功率陡增、稳定运行时的平缓波动、关闭后的骤降。我测试时拿电热水壶做负载,很清晰地看到了加热周期(功率约 1800W)中的波形。
5. 实测数据与联动控制逻辑
5.1 插座基本性能测试
系统跑起来后,我做了几组基础测试,数据如下:
| 项目 | 测试结果 | 说明 |
|---|---|---|
| 控制响应延迟 | 30-80ms | 局域网内 HTTP 请求,不含网络抖动 |
| WebSocket 状态推送延迟 | <50ms | 从继电器动作到浏览器刷新显示 |
| 待机功耗 | 约 0.8W | 主要是 AC-DC 模块损耗 |
| 最大负载 | 10A / 2200W | 继电器额定值,设计余量按 80% 使用 |
| 功率测量误差 | <2% | 与 2000W 电热水壶对比 |
| 连续运行稳定性 | 72 小时无掉线 | 未出现死机、重启 |
这里重点说一下控制响应延迟。这个数字对用户体验的影响非常大:超过 200ms 会有明显"卡顿感",低于 100ms 用户几乎感知不到。我用的是局域网,所以能稳定在 100ms 内。如果你要部署到公网,这个延迟会显著上升,因为多了路由转发和服务器中转的环节,这也是我坚持局域网方案的一个重要原因。
5.2 联动控制与旧设备改造
我的实际使用场景是:把家里一个老式电暖器改造成智能控制。电暖器本身只有机械旋钮,没有遥控功能,我给它加了个智能插座,配合 Web 上位机,实现了以下联动逻辑:
- 定时开关:每天早上 7 点自动开启,晚上 11 点自动关闭。
- 功率阈值联动:当检测到总功率超过 1800W 时,自动断开并推送告警。
- 过压保护:当电压超过 245V 时,自动断电。
实现定时开关不需要外部服务器,ESP32 自带 RTC 和一个简单的 24 小时定时器,代码逻辑极简:
void checkScheduledTasks() { struct tm timeinfo; if (!getLocalTime(&timeinfo, 5000)) { return; } int currentMinutes = timeinfo.tm_hour * 60 + timeinfo.tm_min; if (currentMinutes == ON_MINUTE && !relayState) { setRelayState(true, "scheduled"); } if (currentMinutes == OFF_MINUTE && relayState) { setRelayState(false, "scheduled"); } }这里需要从 NTP 服务器同步时间,ESP32 内置的configTimeAPI 可以轻松实现。但要注意:如果设备没联网,RTC 时间会漂移。我的设备是长期在线状态,所以漂移问题不大。如果你做的是低功耗电池设备,这个方案不适用。
5.3 远程访问的替代方案
纯局域网方案有一个明显的局限:人不在家时无法查看和控制。如果要远程访问,我不建议直接暴露 ESP32 的 80 端口到公网(不安全,而且 ESP32 的 TLS 性能很差),更合理的做法是:
- 智能路由器端口转发 + DDNS:需要自己搭一个反向代理,且必须有 HTTPS 证书,否则数据明文传输风险太大。
- 内网穿透方案:在局域网内加一个树莓派或者软路由运行内网穿透服务(如 frp、ngrok),把 ESP32 的 80 端口安全地暴露到公网。
- MQTT 桥接:ESP32 上报到本地 MQTT broker,再由一台长期在线的服务器转发到公网,这是最安全的架构,但需要额外一台设备。
我最推荐的是第三种架构:ESP32 只管本地局域网,远程访问通过 MQTT 桥接。这样即使远程服务器挂了,也不影响本地控制,可靠性最高。
6. 踩坑实录:掉线、卡死与配置丢失
6.1 WebSocket 连接数泄漏
开发过程中遇到的最诡异的问题:页面打开大约半小时后,ESP32 的内存占用逐渐升高,最后因为堆内存不足导致崩溃重启。
排查过程:
- 第一步,用
ESP.getFreeHeap()打印堆内存,发现每隔几秒就掉一点,典型的内存泄漏特征。 - 第二步,检查代码中所有
new和malloc对应的释放逻辑,没有发现问题。 - 第三步,检查 WebSocket 库的源码,发现问题出在客户端断开时,没有正确释放 WebSocket 客户端对象。
我用的是WebSocketsServer库,默认情况下它会在webSocketEvent回调中传递客户端索引,但如果你在事件处理中创建了局部对象(比如String或JSONDocument),这些对象如果被错误地保存在全局变量中,就会导致内存只增不减。最终解决方式是:在WS_EVT_DISCONNECT事件中显式释放相关资源,确保每个连接的生命周期被严格管理。
6.2 掉线后重连闪断问题
ESP32 在频繁掉线重连时会出现一个奇怪的现象:能连上路由器,但几秒钟后又掉线,反复循环。查了很久发现原因:Wi-Fi 接入点记录的是 ESP32 的 MAC 地址,频繁重连会让路由器认为有攻击行为,从而暂时拉黑这个设备。
解决方法是:在重连前增加一个随机延迟(1~5 秒),并且最多连续重试 5 次,然后进入深度睡眠几分钟再尝试。这个策略非常有效,实测掉线恢复的成功率从 60% 提升到接近 100%。
6.3 NVS 配置丢失
排查了很久才发现,ESP32 的 NVS 区域在某些情况下会被擦除。最常见的原因是:代码改动后,分区表变了,旧固件写入的数据在新固件的分区表下地址错乱,导致看起来"配置丢失"。
这个问题很隐蔽,因为你的代码逻辑完全正确,但设备重启后所有配置都回到了默认值。解决方法是:在分区表中为 NVS 分配固定大小,且升级固件时保持分区表不变。如果你用 PlatformIO,可以在partitions.csv中显式配置:
nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1F0000, app1, app, ota_1, 0x200000, 0x1F0000,6.4 上电瞬间继电器误动作
这个问题在"硬件设计"一节提过,这里再说一下我最终的解法:硬件下拉 + 软件延时控制。GPIO 初始化为输入模式并启用内部下拉电阻,上电 2 秒后再切换为输出模式,这样即使在启动阶段 GPIO 被随机置高,也不会驱动继电器。实测零误动作。
7. 代码架构与后续扩展方向
当前项目的目录结构:
esp32-smart-plug/ ├── include/ │ ├── config.h # Wi-Fi 配置、NTP 服务器地址 │ ├── pins.h # GPIO 定义 │ └── version.h # 固件版本号 ├── src/ │ ├── main.cpp # 入口,初始化各模块 │ ├── web_server.cpp # HTTP API 实现 │ ├── websocket.cpp # WebSocket 状态推送 │ ├── relay.cpp # 继电器控制(含防抖、状态机) │ ├── power_monitor.cpp # HLW8032 数据读取与处理 │ ├── scheduler.cpp # 定时任务 │ └── nvs_manager.cpp # 配置存储 ├── data/ │ ├── index.html # Web 上位机页面 │ ├── app.js │ └── style.css └── platformio.ini这种模块化结构的好处是:每个文件只负责一件事,替换硬件(比如换成 BL0942 计量芯片)时只需要修改power_monitor.cpp,其他模块完全不用动。
后续我计划扩展的方向:
- 多设备联动:通过 ESP-NOW 协议让多个插座之间互相通信,实现"主卧插座开 -> 客厅插座关"的场景联动。
- Home Assistant 接入:通过 MQTT 接入 HA,实现语音控制、自动化场景。
- 加密通信:在 Web 服务器上加 HTTPS(使用 ESP32 的 mbedTLS),解决局域网内隐私问题。
- 更完善的 Web 界面:加入多设备管理、功耗统计报表等功能。
如果你也正在做类似的智能家居小项目,我的建议是:先把基础架构做稳,再追求功能的丰富。一个稳定运行三个月不重启的系统,比一个功能多但每周都要重新上电的系统有价值得多。
最后分享一个调试小技巧:给 ESP32 的串口接一个 USB 转 TTL,但不要只用它看日志,在platformio.ini里开启monitor_filters = esp32_exception_decoder,当系统崩溃时,串口输出的堆栈信息会被自动解析成可读的函数名和行号,排查 BUG 的效率直接翻倍。