联网与数据交换 / 可靠消息发布 内容更新

MQTT断线后怎样恢复状态?遗嘱、保留消息和命令去重

用固定ESP-IDF工程分别记录连接、样本和LED命令;实际主机broker检查遗嘱、retain、QoS1重投与序号去重,解释断电及业务幂等的边界。

读完能做什么
得到带boot/sequence的有限可靠消息示例;能区分broker确认、应用写GPIO与实体灯亮。
开始之前
下载包为完整独立工程。先读列出的前置文章,或具备等价ESP-IDF、GPIO、局域网经验。
所需器材
指定S3板与USB数据线、Adafruit SHT40 PID4885、WP7113ID红LED、330Ω电阻、面包板短线、2.4GHz WPA2网络、可互访的电脑和私有Mosquitto2.0.20
版本组合
SDK:ESP-IDF5.5.5 / b774170ff46c参考板:DevKitC-1 v1.1 / WROOM-1-N8R8;8MB DIO80MHz;PSRAM不启用依赖:SDK内置esp-mqtt 0e9ec171b448;Sensirion C1.1.0
适用范围

官方ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8;桥接UART115200、USB单路供电、8MB DIO80MHz、不启用PSRAM;SHT40 SDA8/SCL9 100kHz/0x44,GPIO4普通LED;可信LAN内明文MQTT,不是公网控制服务。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/network-systems.md#n07;固定源码/器材与具体引用。验证日期:
构建 / 配置已通过
docs/esp32-evidence/network-systems-builds.json;实际构建Exit0和源/日志/产物SHA。对象:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8版本:ESP-IDF5.5.5验证日期:
仿真不适用
未执行ESP32或外设仿真。同源host与本机服务检查不冒充Wi-Fi、调度、NVS或电气结果。
本站实板待验证
无本站烧录/实体网络/传感器/灯运行记录;observations.csv仅表头,需按README逐项实测。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 可靠消息

先拆开四类消息,而不是承诺“永不丢”

N04已完成测量、传输和LED控制;本篇继续处理网络恢复后的语义。telemetry是不保留的样本,state是保留的应用快照,availability用保留online与遗嘱offline表达连接,command则必须是非保留的目标值。

QoS1允许至少一次交付,因此可能重复;PUBACK只证明这一跳MQTT确认,不证明云端业务、GPIO电压或灯亮。本例没有应用层离线采样补录,也没有断电保存已提交消息。

本站完成两份真实固件构建、同源策略输入与真实本机broker场景。后面断开实体Wi-Fi、重启路由器和观察LED是读者实板验收,本站没有这些结果。

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[3] 固定ESP-MQTT outbox:过期删除与RAM存储

02 / 可靠消息

固定板子、传感器和普通LED再接线

只使用上述v1.1/N8R8板与PID4885模块:VIN接3V3,GND共地,SDA接GPIO8,SCL接GPIO9,模块3Vo保持不接。不要把其他SHT40裸芯片或同名模块的供电直接套用。

GPIO4→330Ω→WP7113ID阳极,LED阴极→GND;断电确认极性。这里只写数字输出,未测光学亮度。板上RGB灯不参与。所有信号为3.3V。

broker必须是你管理的可信局域网端点,绑定电脑实际LAN地址并配置防火墙;下载包broker-loopback.conf只监听127.0.0.1,ESP32无法连电脑的127.0.0.1。公网或有不可信设备的网络应另做TLS、认证和授权。

器件标识主控连接限定
PID4885 VIN/GND3V3/GND3Vo不接
PID4885 SDA/SCLGPIO8/GPIO9100kHz,0x44
WP7113IDGPIO4→330Ω→A,K→GND不是板载RGB灯

参考:[5] 官方 ESP32-S3-DevKitC-1 v1.1 用户指南与 N8R8 模组配置[6] Adafruit SHT40 PID4885:VIN/3Vo、3.3V逻辑与0x44[7] Sensirion C driver1.1.0:高精度测量、CRC与milli单位[8] Kingbright WP7113ID 红LED:极性与器件范围

03 / 可靠消息

去重标识覆盖哪一个时间范围?

命令固定为8位大写十六进制boot、冒号、十进制sequence、冒号、ON或OFF,例如1234ABCD:1:ON。先读state里的本次boot,再从1递增。禁止TOGGLE这类累计动作;重复set目标值天然容易保持幂等。

设备只接受boot相同且sequence大于已执行序号的命令。callback只验证完整、非retain且不超过24字节的消息并复制入长度4队列;app_main单独拥有LED与applied_sequence,满队列拒收而不假装已执行,发送方须等待state对应序号。

boot是每次启动生成的32位随机标识,用于演示重启边界,不是密码、不可碰撞ID或授权凭据。序号不会绕回接受旧值,到UINT32_MAX应重启并读取新boot。RAM去重不提供跨重启的持久事务;涉及扣费或机械动作时需另设计持久请求账本。

retain标志只能拒绝broker作为历史保留消息交付的那一份;MQTT3.1.1对已订阅者转发新的retain发布时可清retain标志。因此发布者仍必须禁止retain命令。本例旧boot门槛避免设备重启后执行大多数历史命令,不将这个随机门槛升格认证。

代码 / 配置
// ---- main/reliability.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
typedef struct {uint32_t boot, sequence; bool on;} Command;
/* Canonical fixed-width uppercase hex boot, decimal sequence 1..UINT32_MAX, exact ON/OFF. */
static inline bool command_parse(const char *p,size_t n,Command *out) {
 if(!p||!out||n<13||n>24||memchr(p,0,n))return false;
 char text[25];memcpy(text,p,n);text[n]=0;
 uint32_t boot=0;for(unsigned i=0;i<8;i++){char c=text[i];unsigned v=c>='0'&&c<='9'?c-'0':c>='A'&&c<='F'?c-'A'+10:16;if(v>15)return false;boot=(boot<<4)|v;}
 if(text[8]!=':')return false;
 uint64_t seq=0;size_t i=9,start=i;while(i<n&&text[i]>='0'&&text[i]<='9'){seq=seq*10+(text[i++]-'0');if(seq>UINT32_MAX)return false;}
 if(i==start||text[start]=='0'||!seq||i>=n||text[i++]!=':')return false;
 bool on=!strcmp(text+i,"ON");if(!on&&strcmp(text+i,"OFF"))return false;
 *out=(Command){boot,(uint32_t)seq,on};return true;
}
static inline const char *command_reason(Command c,uint32_t boot,uint32_t last,bool retained){
 if(retained)return "RETAINED_REJECTED";
 if(c.boot!=boot)return "WRONG_BOOT";
 if(c.sequence<=last)return "DUPLICATE_OR_OLD";
 return "APPLY";
}

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连

04 / 可靠消息

先构建,再配置私有网络和broker

固定SDK已经包含esp-mqtt子模块,不在manifest里重复拉一个同名registry组件。dependencies.lock锁IDF5.5.5,library-lock.json额外记录SDK与MQTT gitlink和Sensirion来源。

默认Wi-Fi占位只编译,不发起连接。README给出私有sdkconfig的menuconfig、实际LAN broker地址和恢复步骤;公开COMPILE_ONLY配置只用于让链接器实际纳入Wi-Fi路径,不是可用网络。

  1. 安装或切换ESP-IDF正式v5.5.5,并在新终端运行export.sh或使用EIM对应环境;idf.py --version应为v5.5.5。
  2. 完整解压示例,进入含顶层CMakeLists.txt的目录;先用idf.py -B build build核默认配置。
  3. 使用README的私有sdkconfig路径进入menuconfig;不要把真实Wi-Fi、PoP或AP口令加入版本库/公开日志。
  4. 按指定模块断电接线,确认3.3V与GND,再执行idf.py -B build flash monitor;Flash/串口须对应这块板,缺实板时停在构建。
做到这里,你应该看到
实板首次应先出现ONLINE IP,再有MQTT connected/订阅ID;看到enqueue ID或PUBACK都不能跳过实体灯和state检查。

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[4] Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous

05 / 可靠消息

完整main:单一执行者、有限outbox与最新状态

完整下载包还含网络重连、固定SHT40驱动、HAL和构建文件。main代码在下方原样展示;每5秒采样,错误时发valid=false/null,不用旧温湿度填正常值。

本例固定topic只运行一台设备;多台须先为BASE分配各自命名空间。客户端ID由MAC派生,ID唯一并不使topic自动唯一;复制MAC或故意冲突仍会断线。clean session使用SDK默认true;连接事件重新订阅并请求发布最新state/online。keepalive10秒、重传间隔5秒、网络操作超时3秒。

outbox限制16384是SDK记录的消息字节预算,另有节点、协议缓冲和任务栈开销,并非整个MQTT堆上限;默认过期30秒会删除未完成消息,满时enqueue返回负值。本例每5秒重发最新state请求,失败保留dirty;telemetry失败只记录,不补录。

重传中的旧state仍可能晚到或覆盖broker最后一条retain。消费方应按boot及applied_sequence拒绝回退;新boot需显式接受新会话,不能用retain到达顺序当业务时间。online也可能在失联检测前暂时过期,不能当心跳时间戳或强实时在线证明。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
#include <stdatomic.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_mac.h"
#include "esp_timer.h"
#include "mqtt_client.h"
#include "sdkconfig.h"
#include "net.h"
#include "reliability.h"
#include "sensirion_hal_idf.h"
#include "sensirion_i2c_hal.h"
#include "sht4x_i2c.h"
static const char *TAG="N07";
#define BASE "hixinwu/n07"
static QueueHandle_t commands;
static atomic_bool connected, state_dirty;
static uint32_t boot, applied; /* app_main owns applied and output; callback never changes them */
static bool led;
static void event(void *arg,esp_event_base_t base,int32_t id,void *data) {
 (void)arg;(void)base;esp_mqtt_event_handle_t e=data;
 if(id==MQTT_EVENT_CONNECTED){
  atomic_store(&connected,true);atomic_store(&state_dirty,true);
  int sub=esp_mqtt_client_subscribe(e->client,BASE "/command",1);
  int pub=esp_mqtt_client_enqueue(e->client,BASE "/availability","online",0,1,1,true);
  ESP_LOGI(TAG,"connected subscribe_id=%d online_id=%d; not an application acknowledgement",sub,pub);
 }else if(id==MQTT_EVENT_DISCONNECTED){atomic_store(&connected,false);ESP_LOGW(TAG,"offline; SDK will reconnect");}
 else if(id==MQTT_EVENT_DATA){
  bool topic=e->topic&&e->topic_len==strlen(BASE "/command")&&!memcmp(e->topic,BASE "/command",e->topic_len);
  Command c;bool whole=e->current_data_offset==0&&e->data_len==e->total_data_len;
  if(!topic||!whole||e->retain||!command_parse(e->data,e->data_len,&c)){ESP_LOGW(TAG,"reject topic/fragment/retained/syntax");return;}
  if(xQueueSend(commands,&c,0)!=pdTRUE)ESP_LOGW(TAG,"COMMAND_QUEUE_FULL: not applied; sender must inspect applied sequence");
 }else if(id==MQTT_EVENT_PUBLISHED)ESP_LOGI(TAG,"PUBACK id=%d (broker hop, not physical LED proof)",e->msg_id);
 else if(id==MQTT_EVENT_ERROR)ESP_LOGW(TAG,"MQTT error; no credentials logged");
}
static int state(esp_mqtt_client_handle_t c) {
 char p[160];snprintf(p,sizeof(p),"{\"boot\":\"%08" PRIX32 "\",\"applied_sequence\":%" PRIu32 ",\"gpio4\":%s}",boot,applied,led?"true":"false");
 return esp_mqtt_client_enqueue(c,BASE "/state",p,0,1,1,true);
}
void app_main(void) {
 boot=esp_random();commands=xQueueCreate(4,sizeof(Command));if(!commands){ESP_LOGE(TAG,"queue allocation failed");return;}
 gpio_reset_pin(GPIO_NUM_4);gpio_set_direction(GPIO_NUM_4,GPIO_MODE_OUTPUT);gpio_set_level(GPIO_NUM_4,0);
 net_start();sensirion_i2c_hal_init();sht4x_init(0x44);
 uint8_t mac[6];ESP_ERROR_CHECK(esp_read_mac(mac,ESP_MAC_WIFI_STA));static char client_id[40];snprintf(client_id,sizeof(client_id),"hixinwu-n07-%02X%02X%02X%02X%02X%02X",mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]);
 esp_mqtt_client_config_t cfg={.broker.address.uri=CONFIG_DEMO_BROKER_URL,.credentials.client_id=client_id,
 .session={.keepalive=10,.message_retransmit_timeout=5000,.last_will={.topic=BASE "/availability",.msg="offline",.qos=1,.retain=1}},
 .network={.timeout_ms=3000,.reconnect_timeout_ms=3000},.buffer={.size=512,.out_size=512},.outbox.limit=16384};
 esp_mqtt_client_handle_t client=esp_mqtt_client_init(&cfg);if(!client){ESP_LOGE(TAG,"MQTT allocation failed");return;}
 ESP_ERROR_CHECK(esp_mqtt_client_register_event(client,ESP_EVENT_ANY_ID,event,NULL));
 bool started=false;uint32_t sample_sequence=0;int64_t next_sample=0;
 for(;;){
  if(!started&&net_online()){ESP_ERROR_CHECK(esp_mqtt_client_start(client));started=true;}
  Command c;while(xQueueReceive(commands,&c,0)==pdTRUE){
   const char *why=command_reason(c,boot,applied,false);
   if(!strcmp(why,"APPLY")){led=c.on;gpio_set_level(GPIO_NUM_4,led);applied=c.sequence;}
   ESP_LOGI(TAG,"command seq=%" PRIu32 " result=%s; digital write, inspect actual LED separately",c.sequence,why);
   atomic_store(&state_dirty,true);
  }
  if(atomic_load(&connected)&&atomic_exchange(&state_dirty,false)){
   if(state(client)<0){atomic_store(&state_dirty,true);ESP_LOGW(TAG,"STATE_ENQUEUE_FAILED: retry latest snapshot");}
  }
  int64_t now=esp_timer_get_time()/1000;
  if(now>=next_sample){
   next_sample=now+5000;atomic_store(&state_dirty,true);int32_t t=0,rh=0;int16_t err=demo_i2c_ready()?sht4x_measure_high_precision(&t,&rh):-1;
   char p[256];sample_sequence++;
   if(!err){int32_t clipped=rh<0?0:rh>100000?100000:rh;
    snprintf(p,sizeof(p),"{\"boot\":\"%08" PRIX32 "\",\"sequence\":%" PRIu32 ",\"valid\":true,\"temperature_c\":%.3f,\"humidity_percent\":%.3f}",boot,sample_sequence,t/1000.0,clipped/1000.0);
   }else snprintf(p,sizeof(p),"{\"boot\":\"%08" PRIX32 "\",\"sequence\":%" PRIu32 ",\"valid\":false,\"temperature_c\":null,\"humidity_percent\":null}",boot,sample_sequence);
   int msg=atomic_load(&connected)?esp_mqtt_client_enqueue(client,BASE "/telemetry",p,0,1,0,true):-1;
   ESP_LOGI(TAG,"sample seq=%" PRIu32 " sensor=%d enqueue=%d outbox=%d; no app backfill",sample_sequence,err,msg,esp_mqtt_client_get_outbox_size(client));
  }
  vTaskDelay(pdMS_TO_TICKS(50));
 }
}

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[3] 固定ESP-MQTT outbox:过期删除与RAM存储

06 / 可靠消息

分别验收掉网、broker重启、重复命令和设备重启

先发布一个新的非保留ON,观察state.applied_sequence与实体灯;再原样重复,日志应为DUPLICATE_OR_OLD,重复不会累计动作。把旧boot或前导零序号发过去应拒绝。

拔掉ESP32网络或断开AP,记录broker何时收到遗嘱offline。突然掉网通常要等传输/keepalive检测,10秒keepalive不是精确10秒倒计时。恢复后记录重新订阅、online和最新state;MQTT正常DISCONNECT不会自动触发遗嘱,应用若实现正常关机需先主动发布offline。

重启broker:本机配置persistence=false会丢失retain和会话;恢复依赖设备重连重发。若改成持久broker还须单独验证磁盘、重启和旧状态回收,不能沿用本例的结果。

设备重启后GPIO4从OFF开始、applied_sequence归零、boot变化;旧boot命令应拒绝,新控制端重新读state。保存的目标状态、执行后断电的事务确认和跨重启命令账本均不在本例。

做到这里,你应该看到
observations.csv分别记录故障触发、重连、broker确认、state序号及实体灯观察;没有硬件则actual留空。

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[3] 固定ESP-MQTT outbox:过期删除与RAM存储[4] Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous

07 / 可靠消息

主机broker真实运行,但不冒充ESP无线

下载包host_check.c直接包含固件的reliability.h;10组输入覆盖规范值、7种畸形批次、NUL、重复/旧序号、旧boot、retain与最大序号。

mqtt_loopback.py使用Mosquitto2.0.20和paho-mqtt1.6.1,8组实际本机场景覆盖late retain、重复ID送到同源策略、旧boot、retained交付、意外断开遗嘱、未PUBACK持久会话重连的真实QoS1 DUP重投、broker重启丢保留状态后重新发布,以及清除retain。

这套fixture的持久订阅端专为观察broker重投,和固件默认clean session不是同一会话配置。其“host fixture”标识清楚,不声称ESP32已触发重投或已跑通无线、I2C与实体灯。

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[3] 固定ESP-MQTT outbox:过期删除与RAM存储[4] Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous

08 / 可靠消息

失败后恢复到可辨认的状态

只有CONFIG_MISSING时先配置私有ssid/password;连不上broker时先核LAN地址、1883端口、防火墙和AP隔离,不改成公网匿名入口。连接回调对订阅和online各尝试一次,subscribe_id或online_id为负只记错误;周期dirty重试只覆盖state,不自动重试订阅/online。先排outbox/内存/连接,主动重新连接后重试,并用真实命令对应state序号验收,不能把连接日志或负ID当命令通道/online已就绪。使用MQTT客户端订阅hixinwu/n07/#,从state复制boot,再发非retain命令;完整命令见README。

传感器失败保留valid=false/null,断电复核VIN/GND/8/9和0x44,不能用假正常温度掩盖接线问题。outbox满或命令队列满要先减小发布频率/恢复消费者,并检查state实际applied序号;不要盲目高速重发。

保留命令可在私有broker中用空retain清除,随后确认新订阅者没有收到旧命令。更换板/版本或清Flash前备份自己的应用数据,NVS异常不自动整区擦除。下一篇把真实Wi-Fi从构建配置迁到安全配网入口。

参考:[1] 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制[2] 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连[4] Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous

BACK TO THE SOURCE

资料来源与版本说明

按固定官方源码核对;实际构建、同源主机检查、本机服务和实体硬件分别记录。未把预期实板步骤写成已运行结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制
  2. 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连
  3. 固定ESP-MQTT outbox:过期删除与RAM存储
  4. Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous
  5. 官方 ESP32-S3-DevKitC-1 v1.1 用户指南与 N8R8 模组配置
  6. Adafruit SHT40 PID4885:VIN/3Vo、3.3V逻辑与0x44
  7. Sensirion C driver1.1.0:高精度测量、CRC与milli单位
  8. Kingbright WP7113ID 红LED:极性与器件范围