先拆开四类消息,而不是承诺“永不丢”
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存储
固定板子、传感器和普通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/GND | 3V3/GND | 3Vo不接 |
| PID4885 SDA/SCL | GPIO8/GPIO9 | 100kHz,0x44 |
| WP7113ID | GPIO4→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:极性与器件范围
去重标识覆盖哪一个时间范围?
命令固定为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与重连
先构建,再配置私有网络和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路径,不是可用网络。
- 安装或切换ESP-IDF正式v5.5.5,并在新终端运行export.sh或使用EIM对应环境;idf.py --version应为v5.5.5。
- 完整解压示例,进入含顶层CMakeLists.txt的目录;先用idf.py -B build build核默认配置。
- 使用README的私有sdkconfig路径进入menuconfig;不要把真实Wi-Fi、PoP或AP口令加入版本库/公开日志。
- 按指定模块断电接线,确认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
完整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存储
分别验收掉网、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
主机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
失败后恢复到可辨认的状态
只有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
资料来源与版本说明
按固定官方源码核对;实际构建、同源主机检查、本机服务和实体硬件分别记录。未把预期实板步骤写成已运行结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- 固定ESP-MQTT配置/事件:会话、遗嘱、outbox限制
- 固定ESP-MQTT实现:enqueue、重传、PUBACK与重连
- 固定ESP-MQTT outbox:过期删除与RAM存储
- Mosquitto2.0.20配置原文:listener、persistence、allow_anonymous
- 官方 ESP32-S3-DevKitC-1 v1.1 用户指南与 N8R8 模组配置
- Adafruit SHT40 PID4885:VIN/3Vo、3.3V逻辑与0x44
- Sensirion C driver1.1.0:高精度测量、CRC与milli单位
- Kingbright WP7113ID 红LED:极性与器件范围