凌晨三点我盯着智能插座回传的功率曲线上的空洞发呆。冰箱的用电数据在3:00到3:17之间整个断掉路由器日志显示3:02它自动重启了一次。插座本身一直有电冰箱也一直在制冷但云端的用电曲线就是少了一段。这种“有电但没数据”的情况做智能插座久了总会遇到Wi-Fi随机抖动、路由器升级、MQTT代理重启任何一环出错定时上报的数据就会永久丢失。为了把用电数据真正做成连续曲线我给这套基于ESP-01s的开源智能插座加了完整的本地存储与断点续传机制。项目没有用复杂的文件系统也没有换成带大容量Flash的高端模组就在ESP-01s的引脚限制下用一块I2C EEPROM解决了“断网几天不丢数据”的问题。如果你在做智能家居DIY、低成本用电监测或者任何需要“离线缓存恢复补传”的采集设备这篇实战记录应该能帮你少走不少弯路。1. 一次“有电但没数据”的事故为什么本地存储是刚需1.1 功率曲线断档影响的不只是图表好看做用电监测的人最怕的就是数据曲线有缺口。表面上看缺口只是图上少了一段线但深层影响是数据的可用性日用电量统计偏小峰谷判断失真如果再用这些数据做设备状态识别冰箱这类周期性负载的启停特征会被误判为设备停机。我最初用的方案和别人差不多MCU每30秒读一次计量芯片的数据通过MQTT定时上报到服务器服务器直接入库。这种架构在网络上没有任何保护一旦链路出现超过重试窗口的抖动数据就丢了。而且定时上报通常是一锤子买卖上报失败的逻辑往往只是“这次算了下次再说”长时间断网时设备端连重试的机会都没有。后来我意识到问题的本质是我们把数据的最终一致性押在了网络实时性上。而智能插座恰恰是断网概率最高的设备类型之一——它插在墙上工作在家庭路由器边缘随时可能因为信号弱、路由器重启、运营商出问题而离线。1.2 本地存储是“复盘”的唯一凭据所以设备端必须有一块“非易失备忘区”把采集到的数据先落盘而不是直接发往云端。这听起来很简单但真正实现时有一堆细节掉电写坏了怎么办、EEPROM寿命够不够、恢复上传时怎么去重、时间戳怎么对齐。这些不是堆代码能解决的需要从存储介质、数据帧、写入时序、上传协议几个层面一起设计。1.3 这套机制适合谁参考如果你只想做一个“能显示实时功率”的玩具插座本文的方案对你来说过度设计了。但如果有以下需求之一就值得继续看需要做连续用电曲线分析、设备识别或异常报警设备部署位置网络不稳比如出租屋、地下室、阳台边缘希望断电/断网一段时间后恢复的数据能无缝补回数据库想在ESP8266这类低成本、低引脚数的模组上验证一套可复用的离线缓存方案。2. ESP-01s的存储家底与硬件选型逻辑2.1 先盘一盘ESP-01s到底有什么ESP-01s是ESP8266系列里最便宜的模组之一但做带本地存储的智能插座它的资源相当紧资源数量对项目的影响片内Flash1MB或2MB其中程序区为主剩余空间做文件系统会挤压OTA区域SRAM可用约50KB实际堆栈紧张MQTT缓冲区、Wi-Fi缓冲都要精打细算引出GPIOGPIO0、GPIO2I2C、继电器、按键基本要靠复用UART1组TXD/RXD计量芯片占用后调试输出需要绕路深度睡眠支持本项目常供电用不上很多人拿到ESP-01s第一反应是“2MB Flash能存很多数据”实际上这不现实片内Flash要放固件还要留OTA升级空间硬挤文件系统虽然能跑但一旦分区调整不当升级固件可能导致文件系统数据被抹掉。更麻烦的是ESP8266的Flash擦写和无线共存时长时间写入会引入不确定性。所以在这个项目里我直接把片内Flash排除在“记录数据的存储区”之外。2.2 本地存储介质三个候选方案怎么选本地存储候选有三类外挂EEPROM、外挂SPI Flash、片内Flash文件系统。在ESP-01s的引脚限制下三者差异很明显存储方案容量接口掉电可靠性实现复杂度ESP-01s适用性AT24C256 EEPROM32KBI2C两根线字节写掉电不易损坏低操作像数组推荐W25Q32 SPI Flash4MBSPI至少4根线需要页擦除掉电可能丢页高要坏块管理引脚不够不推荐片内Flash LittleFS1MB左右可用模组内置文件系统层有磨损均衡中但分区配置繁琐谨慎OTA冲突风险高我选AT24C256的原因很实际I2C只需要GPIO0和GPIO2恰好是ESP-01s引出的两个引脚容量32KB按5分钟一条聚合记录来算能存3天以上而且EEPROM的写入逻辑非常直接没有文件系统的扇区、页表、坏块概念对掉电场景更加从容。2.3 计量链路和引脚分配怎么做计量芯片用的是HLW8032它有一个很讨喜的特点数据是主动通过UART向外发的不需要主机反复查询。每两个电网周期输出一帧包含电压有效值、电流有效值、有功功率、功率因数、频率等信息主机只需要被动接收。所以引脚分配可以这样规划引脚功能说明GPIO0I2C SDA接EEPROM和PCF8574注意启动电平见踩坑部分GPIO2I2C SCL板载上拉较稳RXDHLW8032 TXD输入接收计量芯片数据帧TXD调试日志输出Debug Only调试时接USB转TTL正式运行断开PCF8574 P0继电器控制通过I2C扩展IO口控制插座通断PCF8574是I2C转8路GPIO的扩展芯片地址默认0x20和EEPROM的0x50不冲突。很多“ESP-01s控制继电器”的教程是直接用GPIO0控制继电器但那样I2C就废了所以这里必须扩展。HLW8032的数据是瞬时值不能直接当一分钟平均值用。固件里要做的是维护一个10秒的聚合窗口每窗口结束时求平均电压、平均电流、平均功率累加电量增量然后生成一条聚合记录。3. 本地存储层细节数据帧、环形缓冲与掉电保护3.1 一条记录里放什么字节怎么省设计数据帧时我给自己定了一条标准单条记录必须在16字节以内。因为AT24C256的页大小是32字节记录槽位对齐到16字节可以避免跨页写入的地址回卷问题这个坑后面会详细说。实际使用的结构体长这样#pragma pack(push, 1) typedef struct { uint32_t timestamp; // Unix时间戳秒4字节 uint16_t voltage; // 电压0.1V单位2200表示220.0V2字节 uint16_t current; // 电流mA单位350表示0.35A2字节 uint16_t power; // 功率0.1W单位3560表示356.0W2字节 uint16_t energy_delta; // 10秒聚合电量增量0.01Wh单位2字节 uint8_t flags; // bit0有效位bit1是否需要补传1字节 uint16_t crc16; // 对前面所有字节做CRC162字节 } DataRecord; #pragma pack(pop)总长度15字节加上1字节填充每个记录槽位占16字节。注意energy_delta用0.01Wh做单位10秒内一个100W的设备增量约0.28Wh最大能表示655.35Wh完全够用。如果将来需要更大跨度可以把填充字节扩展成高字节或者降低分辨率到0.1Wh。AT24C256容量32KB按16字节槽位划分正好1024条记录。5分钟一条聚合记录能存约85小时也就是3.5天。如果对离线时长有更高要求可以换AT24C51264KB或者把落盘间隔从5分钟拉长到10分钟容量需求直接减半。3.2 用环形缓冲区管理EEPROM而不是文件系统EEPROM没有文件系统最简单可靠的用法是环形缓冲区。整个存储区划分成两大部分头部区保存写指针、存储区起始偏移、魔数、校验信息各存两份备份数据区按16字节固定槽位划分写指针循环递增写满后覆盖最旧记录。初始化流程要考虑“设备可能在任何时刻掉电”因此不能用“头指针一定有效”这个假设读头部区两份备份如果两份都完好选其中有效的那份如果头部损坏就全盘扫描数据区根据每槽位的CRC16和flags找到最后一条合法记录重建写指针。写入一条记录的代码思路bool storage_write(DataRecord *rec) { uint16_t slot_index write_pos % SLOT_COUNT; rec-crc16 calc_crc16((uint8_t*)rec, offsetof(DataRecord, crc16)); eeprom_write_buffer(SLOT_ADDR(slot_index), (uint8_t*)rec, SLOT_SIZE); // 写入后读回校验 DataRecord verify; eeprom_read_buffer(SLOT_ADDR(slot_index), (uint8_t*)verify, SLOT_SIZE); if (verify.crc16 ! rec-crc16) { return false; // 写失败下轮重试同一槽位 } write_pos; save_headers(); return true; }这里有个关键点必须先写入数据并校验成功再更新写指针。如果反过来写指针已经指向新位置但数据还没写成功下一次开机就会把空槽位当成有效记录或者覆盖还没写成功的槽位。3.3 掉电保护先写数据还是先动指针掉电保护的核心原则是任何时刻掉电系统都能通过扫描找到“最近一条完整写入”的记录。我为此设计了三级冗余每条记录自带CRC16即使写入过程被打断也能识别出坏记录并跳过写指针保存两份且各自带校验避免指针本身损坏导致找不到任何数据数据区全盘扫描作为最后兜底就算头部区完全损坏也能通过逐槽CRC扫描恢复到断点。实际测试时我们人为在不同阶段断电发现最坏情况是最新一条记录写了一半启动后扫描时被跳过写指针因为还没更新仍然指向上一条合法记录所以系统能正常继续写不会死循环。另外为了把断电瞬间内存里的最后几条记录保存下来我在电源输入端加了两只2200uF电解电容并联。实测在100mA负载下断电后3.3V能维持约30ms足够写入3条EEPROM记录。这个电容容值是根据能量公式估出来的电容维持时间约等于 C × ΔV / I。2.2mF × 1.5V / 0.1A ≈ 33ms两只并联时间翻倍。3.4 EEPROM寿命预算别把存储当无限用AT24C系列标称擦写寿命约100万次这个数字看起来很够但要看写入频率。如果每10秒写一条一天就是8640次一年约315万次几个月就报废。所以我绝对不会把数据直接落到EEPROM上而是采用“内存聚合 周期落盘”的分层策略。落盘频率每日写入次数年写入次数理论寿命可用存储时长32KB10秒8640315万约0.3年3.7小时1分钟144052.6万约1.9年1.5天5分钟28810.5万约9.5年3.5天15分钟963.5万约28年10.7天最终选择5分钟落盘一次同时保留最近10秒粒度的内存数据用于实时上报。这样断网后本地有5分钟粒度的完整备份实时曲线则依赖内存缓冲。断电时靠电容把最后几条内存记录也塞进EEPROM实际数据损失窗口可以控制在几秒以内。4. 断点续传协议设计状态机、去重与批量回放节奏4.1 整洁的状态机在线、离线、回放、追平断点续传不能只是“断网时写在线时发”需要一个清晰的状态机否则数据和实时数据混在一起容易乱。状态进入条件动作ONLINEMQTT连接成功实时发布当前数据同时检查缓存区OFFLINEMQTT连接断开聚合数据写入本地存储尝试重连REPLAY检测到缓存区非空按时间顺序批量回放历史记录CATCH_UP回放进度追到最新切换回实时发布清空缓存区主循环的伪代码void loop() { uint32_t now millis(); if (mqtt.connected()) { if (storage_has_cached()) { replay_next_batch(); } else { publish_realtime_record(); } } else { storage_write(current_aggregate_record()); mqtt_reconnect_async(now); } }这里最容易犯的错是回放数据期间又产生新数据结果回放完又把新数据当成历史数据重复补传。所以回放时要记住当前“回放光标”的位置并且保证实时数据走另一个逻辑分支。更稳妥的做法是回放数据发布到专门的history主题实时数据发布到live主题服务器端两个通道都接收。4.2 去重为什么“记录到最后一条已确认序号”不够很多初版设计会在服务器端记录“已收到的最大的连续序号”设备端从序号1开始补传。这个方案在设备一直在线的情况下没问题但一旦设备重启内存里的序号就丢了如果序号没有持久化补传时可能重发旧数据也可能漏掉中间一段。更稳妥的方案是服务器端用设备ID加时间戳做主键做幂等入库设备端只管按批上传服务器对重复记录做忽略或覆盖处理。协议上我定义了一个简单的批量确认机制设备端把一批记录发给服务器服务器收到后返回一个batch ack包含起始时间和结束时间设备收到ack后从本地缓存中删除这批记录如果没收到ack下一轮重发相同批次。ack格式用一个JSON即可{ batch_id: 1024, ack_from: 1718000000, ack_to: 1718000480, accepted: 97 }服务器入库SQL用INSERT ... ON DUPLICATE KEY UPDATE以(device_id, ts)为唯一索引这样就算设备重复上传也不会产生脏数据。4.3 MQTT QoS级别怎么选别迷信QoS 2MQTT的QoS经常被误解成“数据的最终可靠性保证”其实它只负责客户端与代理之间的投递语义不保证业务服务器一定处理成功。在断点续传场景下QoS级别语义适用场景注意点QoS 0最多一次实时高频数据可能丢消息QoS 1至少一次回放补传数据会重复需要业务层去重QoS 2恰好一次少量关键指令ESP8266客户端支持不完善容易阻塞我的选择是实时数据用QoS 0历史补传用QoS 1。原因是补传数据允许重复但绝不允许丢QoS 1配合服务器唯一键去重后效果上等同于恰好一次。QoS 2看似完美但PubSubClient库对QoS 2的握手流程支持不完整在弱网环境容易造成消息在代理端堆积实测多次出现重连后消息积压导致内存溢出。4.4 突发回放不能把服务器打崩断网几小时后缓存区里可能攒了几百上千条记录。如果恢复的一瞬间全量推送路由器队列、MQTT代理、服务器数据库大概率会被冲垮。我的做法是批量限速回放每批最多50条记录批间间隔初始200ms根据服务器ack响应时间动态调整连续3批ack都在100ms内批次加大到100条批间间隔降到100ms出现超时则批次减半。算一笔账断网72小时5分钟一条积压约864条。按50条一批需要18批批间200ms总耗时不到1分钟。这个回放速度不会对网络造成明显影响用户体验上恢复后一分钟内历史曲线就补全了完全可以接受。4.5 回放期间的实时数据通道要独立回放补传会占住主循环如果实时数据还走同一个MQTT连接可能出现“历史数据慢慢补、当下用电情况反而看不到”的怪现象。我在回放开始前会先发一条最新实时帧相当于告诉服务器“设备已恢复在线”然后再慢慢回放。服务器收到最新帧后前端曲线会先看到当前值随后历史缺口被逐渐填上。体验上很像“直播先恢复再补录像”。5. 断网14小时实测恢复曲线与踩坑记录5.1 测试环境与方法硬件列表ESP-01s模组、HLW8032计量芯片、AT24C256 EEPROM、PCF8574 IO扩展、5V继电器模块给一台冰箱供电。固件逻辑每10秒聚合一窗口数据内存保留最新实时记录每5分钟向EEPROM落盘一条聚合数据MQTT代理用本网内的一台树莓派上的Mosquitto。测试方法是人为断开Wi-Fi关掉路由器无线功能14小时插座继续给冰箱供电。期间验证三件事本地是否能持续写入、恢复后补传是否完整、服务器端曲线是否无缺口无重复。5.2 恢复过程时间线时间点事件14小时前关闭路由器Wi-Fi设备进入离线状态离线期间每5分钟一条记录写入EEPROM累计约168条T0秒重新打开Wi-FiESP-01s自动探测到APT5秒与AP关联成功DHCP获取IPT7秒MQTT连接建立T8秒发送第一条实时帧服务器端显示设备在线T11秒回放开始第一批50条T15秒全部4批回放完成服务器端曲线缺口补全实测下来14小时的缺口在15秒左右补完这个速度对于家用分析场景完全够用。当时路由器如果性能更差回放时间也就慢一两秒不影响全局。5.3 踩坑记录一UART被HLW8032长期占用调试变困难ESP-01s只有一组UARTHLW8032的TXD接了RXD之后串口监视器还能通过TXD输出这在大多数时候够用。但问题是如果你用Arduino IDE的串口监视器它会默认占用USB转TTL的RX而你可能同时把USB转TTL的TX也接到了ESP的RX这样就等于和HLW8032抢RXD线。我最后的解决方式是串口彻底让给计量芯片调试日志走网络。固件里加了一个轻量的UDP日志上报函数把调试信息发到局域网指定IP的特定端口用Wireshark或简单UDP监听工具看。这样既不影响HLW8032的数据接收又能保留完整的调试链路。5.4 踩坑记录二AT24C256跨页写入导致数据错乱AT24C系列页大小是32字节如果写操作跨过了页边界地址会自动回卷到页首导致写出来的数据覆盖掉本页开头的字节。我的原始记录结构体是15字节如果槽位不固定一条记录可能从地址0x1F开始写到0x2D正好跨页整个数据就乱了。这个坑不是每次必现只有当时写指针恰好落在页边界附近才会发生排查起来非常隐蔽。解决方案就是前面说的槽位固定16字节按16字节对齐。因为页大小32字节16字节对齐的地址不可能产生跨页写。如果你用更大的记录记得槽位长度也按页大小的因数对齐。5.5 踩坑记录三GPIO0被外部器件拉低导致启动失败ESP-01s启动时GPIO0必须保持高电平否则模块会进入UART下载模式程序不跑。我的I2C SDA挂在GPIO0上如果在上电瞬间EEPROM或PCF8574把SDA拉低模块就无法正常启动。AT24C系列EEPROM上电后处于高阻态一般不主动拉低但PCF8574某些批次的默认输出状态不可控我就遇到过一次启动黑屏。最后把I2C上拉电阻从2.2k换成了4.7k并且在PCF8574的复位脚上做了一点延时上拉处理故障消失。经验是在ESP-01s上做I2C外设扩展上拉电阻不要小于4.7k。太强的上拉会加剧GPIO0启动电平被外部设备影响的风险太弱的又会造成I2C波形沿过缓通讯不稳定。5.6 踩坑记录四断网期间的时间戳漂移ESP-01s没有板载RTC时间只能靠NTP同步。断网期间如果还用“最后一次NTP同步时间”加millis()偏移来算时间戳短期内没问题但一旦设备重启millis()归零系统就彻底失去时间基准。我的处理策略分三层联网时每12小时同步一次NTP记录base_unix和base_millis断网期间生成时间戳用base_unix (millis() - base_millis) / 1000ESP8266的晶振短期精度够用24小时误差通常在几秒以内如果设备重启后还没成功同步NTP记录先放在内存环形缓冲里时间戳字段留空等NTP同步成功后再统一回填并写入EEPROM。这个方案不完美但能够把绝大多数场景的时间误差控制在一分钟内。如果你做的项目对时间精度非常敏感建议直接加一颗DS3231硬件RTC代价是额外占用I2C地址和几行代码。6. 多设备场景下的时间对齐与方案边界6.1 多台插座同时采集时时钟对齐怎么做如果家里有多个智能插座同时采集冰箱、空调、热水器等负载合并曲线时最怕的是各设备时钟不一致。某个设备快30秒另一个慢10秒画出来的功率曲线会显得“毛刺”很多。我的做法是设备端联网状态下每6小时同步一次NTP并上报自己的“时间基准偏移量”。服务器端入库后不按到达时间排序而是按设备时间戳排序同时对同一时刻的多设备数据做重采样取同一时间窗内的总和或平均值。这样即使各设备有几秒的偏差最终合并曲线仍是平滑的。6.2 云端合并策略与去重索引服务器端不能只做“收到就插入”一定要考虑重复上报。数据库表结构里device_id和ts必须建立唯一索引。批量写入用INSERT ... ON DUPLICATE KEY UPDATE重复记录只更新时间戳不改变已有值。Redis里的ZSET也能做类似的事score用时间戳但数据量大时还是关系型数据库的唯一索引更直观。6.3 这套设计的边界什么时候别硬抄本地存储加断点续传并非万能有几个场景需要额外评估高频采样如果采集粒度是秒级甚至毫秒级EEPROM完全不适合要么换SD卡要么用SPI Flash加坏块管理批量大文件MQTT适合小消息如果离线期间生成了几百KB的日志建议用HTTP POST分片上传而不是硬塞进MQTT实时控制如果插座本身承担着“秒级断电保护”之类的控制职责回放补传不能占用主循环太久否则会延误控制响应低功耗电池设备EEPROM写入和MQTT回放都是耗电大户需要拉长回放节奏或者只在设定时间窗内补传。6.4 关于这套方案我的个人体会做了这轮完整设计之后最大的收获不是代码而是一句话先定义清楚“能容忍丢多少数据”和“恢复后多久补完”再决定用什么存储介质和协议。家用智能插座场景5分钟粒度、3天本地容量、恢复后一分钟内补完性能和成本平衡得很好。如果以后有朋友照这篇文章做类似的设备我会额外建议三点。第一EEPROM的写寿命远比想象中容易耗尽能用15分钟落盘就不要用5分钟。第二模拟断网测试时不要直接关总闸用程序控制Wi-Fi断开更可控也能避开电容放电的不确定性。第三调试串口被外设占用后优先考虑UDP日志而不是继续去抢那唯一的一组UART。这些经验都是拿实际运行时间和几块写坏的芯片换来的照着做能少交点学费。