ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32智能插座Web上位机实战:从硬件选型到固件架构全解析

ESP32智能插座Web上位机实战:从硬件选型到固件架构全解析 先交代一下背景这个项目我从硬件选型到 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 典型值 10mAESP32 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-PM015V/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() { StaticJsonDocument256 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 的效率直接翻倍。
返回列表