把问题定成24个样本,而不是最低的睡眠电流
每采一次就关联AP、连MQTT、等确认再睡,和每4次采样才连一次,可能有不同的能量与时延。不能先假定后者一定更省:信号差、重试、缓存丢新与最后不满批都会改变完整任务。这里两种策略都生成24个样本、每次请求60秒深睡,外设固定为常供3V3的SHT40,不叠加L05电源切换。
这与S10连续环境站不同:本篇是短期、有明确丢失边界的RTC窗口实验,接收端有持久去重,节点没有SD或NVS应用日志。默认公开配置不联网;本机C策略和真实broker回环已执行,实板24次采样、无线连接、时延和电量仍留空。
8条保旧丢新,序号连续也可能丢过采样
RTC记录保存64位jid、最后分配的传输序号、生成总数、drop数、队头/长度、等待唤醒数、8条完整CSV行和CRC。仅深睡定时醒且结构/每行校验完整才继续。普通RESET、断电、非TIMER醒或损坏会清窗口、建立新jid;未知旧损失打印unknown,不伪造精确丢失条数。随机64位jid是有限会话标签,不能当认证或绝不碰撞的证明。
队列满8条时保留旧的未ACK数据、丢弃新采样并增加drop;丢新不占传输序号。因此连续seq只能说明已存行没有空档,不能证明24次都保住。传感器失败也生成invalid行,flag=0且值0不算有效送达。阈值1为EACH、4为BATCH;老队列等到8次唤醒或第24个样本后强制尝试尾批。生成满24后不再添样本,仍可按周期冲尾批。
只有接收端提交后的匹配ACK能删数据
本例与S10作者对齐并复制固定CSV v1/CRC/ACK模型,另用hixinwu/l06/<12位小写MAC>/row和/ack防止互相订阅。行包含jid、正uint64 seq、boot uptime、时钟有效标志、样本有效标志、milli单位及IEEE CRC32。这里不启SNTP,unix_s与time_valid都为0;uptime_ms每次深睡boot归零,绝不能拿它跨唤醒相减算数据年龄。
Python接收端先校验CRC/规范字段,再以(device,jid,seq)为SQLite主键做FULL提交,最后发无换行的jid,seq ACK。同键同内容重复只ACK不加行,同键不同内容拒收不ACK。节点先等ACK订阅SUBACK,只允许完整、非retain、正确topic/jid/当前inflight队头seq的ACK删除队头;publish消息ID和QoS1 PUBACK都不代表应用已经入库。
每次awake最多6次发布尝试、每次等app ACK约1.5秒,Wi-Fi和MQTT分别等8/5秒,SDK网络调用超时2秒;调度、断开与清理可能额外耗时,不称总耗时硬上限。ACK丢了,节点保留同字节重发,数据库去重;这不提供对恶意ACK的认证保护,也不保证跨节点断电的完整记录。
1,<jid16hex>,<seq>,<boot_uptime_ms>,0,0,<valid>,<t_milli>,<rh_raw_milli>,<crc32>\n
ACK: <jid16hex>,<seq> # 不retain,无换行参考:[2] SDK固定ESP-MQTT:发布/订阅/事件接口[3] MQTT3.1.1规范:QoS1/retain与会话
先跑自己的接收端,再给板子私有配置
下载host/receiver.py只需Python3.12 stdlib及Mosquitto2.0.20的sub/pub客户端。host/broker.conf只绑127.0.0.1:18893供同机夹具;实板必须用电脑真实LAN接口配置自己的broker、防火墙与授权,不能填板内localhost,也不能开放公网匿名端口来排错。接收器命令、独占新文件导出和本机夹具完整步骤在README。
项目默认SSID/BROKER为空,打印CONFIG_DISABLED,缓存会满并丢新,这不是网络失败实测。私下复制sdkconfig.private.example并填SSID(本例最多31字节)、口令(最多63字节)、mqtt://私有LAN地址。公开network-link.defaults只有COMPILE_ONLY及192.0.2.1供链接验证,不能当可运行服务器;超长/错误URI配置会拒绝尝试并保留缓存。私有文件不发布,但也不让Flash内容自动加密。
- 先确认broker SUBACK、receiver COMMITTED和客户端命令行可互通;RECEIVER started只证明进程启动。
- 接SHT VIN3V3/GND/SDA8/SCL9,可靠焊接,3V输出留空;不接屏幕/电源开关。
- 两策略用独立-B目录与SDKCONFIG。阈值4:sdkconfig.defaults;sdkconfig.private;阈值1:再加config/each.defaults。核生成CONFIG_HX_BATCH_SIZE,不靠改defaults覆盖旧sdkconfig。
- 各从物理RESET新jid开始相同24次采样;保留节点日志、receiver DB、导出及AP/仪表设置。
- 做到这里,你应该看到
- 同一行从row→唯一数据库行→APP_ACK→队列删除,四处身份一致。
完整节点与RTC窗口模型
主循环是RTC数据唯一写入者,MQTT回调只解析并放入4项ACK队列,满则丢ACK而不删记录;后续可安全重发同一行。下面两文件与下载同字节;station_model.c/h、SensorBus/ShtSample、接收端与完整构建配置均在独立下载工程,复用文件原SHA与修改范围另有锁。
// ---- main/BatchModel.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <limits>
extern "C" {
#include "station_model.h"
}
struct BatchRecord {
uint64_t journal,lastAllocated,generated,dropped;uint32_t count,head,wakesWaiting;
char rows[8][STATION_LINE_MAX];uint32_t checksum;
};
inline void seal(BatchRecord& b){b.checksum=station_crc32(&b,offsetof(BatchRecord,checksum));}
inline bool intact(const BatchRecord& b){
if(!b.journal||b.count>8||b.head>7||b.lastAllocated<b.count||b.checksum!=station_crc32(&b,offsetof(BatchRecord,checksum)))return false;
for(uint32_t i=0;i<b.count;i++){const char* p=b.rows[(b.head+i)%8];if(!memchr(p,0,STATION_LINE_MAX))return false;station_record_t row{};if(!station_record_decode(p,&row)||row.journal!=b.journal||row.sequence!=b.lastAllocated-b.count+i+1)return false;}return true;
}
inline bool resume(BatchRecord& b,bool timerDeepWake,uint64_t newJournal){bool continued=timerDeepWake&&intact(b);if(!continued){memset(&b,0,sizeof b);b.journal=newJournal?newJournal:1;seal(b);}return continued;}
inline void wakeAge(BatchRecord& b){if(b.count&&b.wakesWaiting<UINT32_MAX)++b.wakesWaiting;}
inline bool append(BatchRecord& b,uint64_t bootUptime,bool valid,int32_t t,int32_t rh){
if(b.generated<UINT64_MAX)++b.generated;
if(b.count==8||b.lastAllocated==UINT64_MAX){if(b.dropped<UINT64_MAX)++b.dropped;seal(b);return false;}
station_record_t row{};row.journal=b.journal;row.sequence=b.lastAllocated+1;row.uptime=bootUptime;row.sample_valid=valid;row.t_milli=valid?t:0;row.rh_milli=valid?rh:0;
char* dst=b.rows[(b.head+b.count)%8];memset(dst,0,STATION_LINE_MAX);if(!station_record_encode(&row,dst,STATION_LINE_MAX))return false;
++b.lastAllocated;++b.count;seal(b);return true;
}
inline bool uploadDue(const BatchRecord& b,unsigned threshold,bool finalTail){return b.count&&(b.count>=threshold||b.wakesWaiting>=8||finalTail);}
inline uint64_t headSequence(const BatchRecord& b){return b.count?b.lastAllocated-b.count+1:0;}
inline bool applicationAck(BatchRecord& b,uint64_t journal,uint64_t sequence,uint64_t inflight){
if(!b.count||!inflight||journal!=b.journal||sequence!=inflight||sequence!=headSequence(b))return false;
memset(b.rows[b.head],0,STATION_LINE_MAX);b.head=(b.head+1)%8;--b.count;if(!b.count)b.wakesWaiting=0;seal(b);return true;
}
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <cstring>
#include <atomic>
#include <inttypes.h>
#include "sdkconfig.h"
#include "esp_sleep.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_random.h"
#include "esp_mac.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "mqtt_client.h"
#include "driver/uart.h"
#include "SensorBus.h"
#include "BatchModel.h"
#include "freertos/event_groups.h"
#include "freertos/queue.h"
RTC_DATA_ATTR static BatchRecord batch{};
static EventGroupHandle_t flags;static QueueHandle_t acks;static constexpr int IP_OK=1,FAIL=2,MQTT_READY=4;
static char rowTopic[80],ackTopic[80];static std::atomic<int> subscriptionId{-1};
struct Ack {uint64_t journal,sequence;};
static void wifiEvent(void*,esp_event_base_t base,int32_t id,void*){
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START)esp_wifi_connect();
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED)xEventGroupSetBits(flags,FAIL);
if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP)xEventGroupSetBits(flags,IP_OK);
}
static void mqttEvent(void*,esp_event_base_t,int32_t id,void* data){
auto e=static_cast<esp_mqtt_event_handle_t>(data);
if(id==MQTT_EVENT_CONNECTED){int n=esp_mqtt_client_subscribe(e->client,ackTopic,1);subscriptionId.store(n);if(n<0)xEventGroupSetBits(flags,FAIL);}
if(id==MQTT_EVENT_SUBSCRIBED&&e->msg_id==subscriptionId.load())xEventGroupSetBits(flags,MQTT_READY);
if(id==MQTT_EVENT_DISCONNECTED){xEventGroupClearBits(flags,MQTT_READY);xEventGroupSetBits(flags,FAIL);}
if(id==MQTT_EVENT_DATA&&station_ack_frame(e->topic,e->topic_len,ackTopic,e->current_data_offset,e->total_data_len,e->data_len,e->retain)){
Ack a{};if(station_ack_decode(e->data,e->data_len,&a.journal,&a.sequence))xQueueSend(acks,&a,0); // full queue: no deletion; same row can be retried next wake
}
}
static void upload(){
if(!CONFIG_HX_SSID[0]||!CONFIG_HX_BROKER[0]){puts("CONFIG_DISABLED: RTC cache only, not connected or delivered");return;}
if(strlen(CONFIG_HX_SSID)>31||strlen(CONFIG_HX_PASSWORD)>63||strncmp(CONFIG_HX_BROKER,"mqtt://",7)){puts("CONFIG_INVALID: SSID<=31, password<=63, trusted LAN mqtt:// required; cache retained");return;}
flags=xEventGroupCreate();acks=xQueueCreate(4,sizeof(Ack));if(!flags||!acks){puts("ALLOC_ERROR cache retained");return;}
ESP_ERROR_CHECK(nvs_flash_init()); // no automatic erase on NVS error
ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifiEvent,nullptr));ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,wifiEvent,nullptr));
wifi_init_config_t init=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&init));ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));wifi_config_t cfg{};
strlcpy(reinterpret_cast<char*>(cfg.sta.ssid),CONFIG_HX_SSID,sizeof cfg.sta.ssid);strlcpy(reinterpret_cast<char*>(cfg.sta.password),CONFIG_HX_PASSWORD,sizeof cfg.sta.password);ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&cfg));ESP_ERROR_CHECK(esp_wifi_start());
auto bits=xEventGroupWaitBits(flags,IP_OK|FAIL,pdFALSE,pdFALSE,pdMS_TO_TICKS(8000));
if((bits&IP_OK)&&!(bits&FAIL)){
esp_mqtt_client_config_t m{};m.broker.address.uri=CONFIG_HX_BROKER;m.network.disable_auto_reconnect=true;m.network.timeout_ms=2000;m.outbox.limit=4096;
auto client=esp_mqtt_client_init(&m);if(client){ESP_ERROR_CHECK(esp_mqtt_client_register_event(client,MQTT_EVENT_ANY,mqttEvent,nullptr));auto started=esp_mqtt_client_start(client);
if(started==ESP_OK){bits=xEventGroupWaitBits(flags,MQTT_READY|FAIL,pdFALSE,pdFALSE,pdMS_TO_TICKS(5000));
if((bits&MQTT_READY)&&!(bits&FAIL))for(unsigned attempt=0;attempt<6&&batch.count;attempt++){
uint64_t sequence=headSequence(batch);const char* line=batch.rows[batch.head];int msg=esp_mqtt_client_publish(client,rowTopic,line,0,1,0);
printf("ATTEMPT seq=%" PRIu64 " sdk_msg_id=%d; PUBACK is not application ACK\n",sequence,msg);if(msg<0)continue;
int64_t began=esp_timer_get_time();while(esp_timer_get_time()-began<1500000){Ack ack{};if(xQueueReceive(acks,&ack,pdMS_TO_TICKS(20))==pdTRUE&&applicationAck(batch,ack.journal,ack.sequence,sequence)){printf("APP_ACK seq=%" PRIu64 " remaining=%" PRIu32 "\n",sequence,batch.count);break;}}
}
ESP_ERROR_CHECK(esp_mqtt_client_stop(client));
}
ESP_ERROR_CHECK(esp_mqtt_client_destroy(client));
}
}else puts("WIFI_TIMEOUT_OR_FAILURE: cache unchanged");
ESP_ERROR_CHECK(esp_wifi_stop());ESP_ERROR_CHECK(esp_wifi_deinit());
}
extern "C" void app_main(){
int64_t start=esp_timer_get_time();uint64_t jid=(uint64_t(esp_random())<<32)|esp_random();bool continued=resume(batch,esp_reset_reason()==ESP_RST_DEEPSLEEP&&esp_sleep_get_wakeup_cause()==ESP_SLEEP_WAKEUP_TIMER,jid);wakeAge(batch);seal(batch);
uint8_t mac[6];ESP_ERROR_CHECK(esp_read_mac(mac,ESP_MAC_WIFI_STA));char device[13];snprintf(device,sizeof device,"%02x%02x%02x%02x%02x%02x",mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]);snprintf(rowTopic,sizeof rowTopic,"hixinwu/l06/%s/row",device);snprintf(ackTopic,sizeof ackTopic,"hixinwu/l06/%s/ack",device);
printf("L06 %s journal=%016" PRIx64 " generated=%" PRIu64 " queued=%" PRIu32 " drops=%" PRIu64 " threshold=%d; unknown prior-session loss on reset\n",continued?"CONTINUED":"NEW",batch.journal,batch.generated,batch.count,batch.dropped,CONFIG_HX_BATCH_SIZE);
if(!continued)vTaskDelay(pdMS_TO_TICKS(5000));
if(batch.generated<CONFIG_HX_SAMPLE_LIMIT){i2c_master_bus_handle_t bus=nullptr;i2c_master_dev_handle_t dev=nullptr;ShtSample sample{};auto e=createBus(&bus);if(e==ESP_OK)e=addDevice(bus,0x44,&dev);if(e==ESP_OK)e=takeSample(dev,sample);
bool stored=append(batch,esp_timer_get_time()/1000,e==ESP_OK&&sample.valid,sample.t_mC,sample.rh_raw_mPct);printf("SAMPLE journal=%016" PRIx64 " seq=%" PRIu64 " generated=%" PRIu64 " stored=%u valid=%u drop_total=%" PRIu64 "\n",batch.journal,stored?batch.lastAllocated:0,batch.generated,stored,e==ESP_OK&&sample.valid,batch.dropped);
if(dev){ESP_ERROR_CHECK(i2c_master_bus_rm_device(dev));}
if(bus){ESP_ERROR_CHECK(i2c_del_master_bus(bus));}
}
if(uploadDue(batch,CONFIG_HX_BATCH_SIZE,batch.generated>=CONFIG_HX_SAMPLE_LIMIT)){upload();}
seal(batch);
printf("END active_app_us=%" PRId64 " queued=%" PRIu32 " drops=%" PRIu64 "; no measured current/latency\n",esp_timer_get_time()-start,batch.count,batch.dropped);
if(batch.generated>=CONFIG_HX_SAMPLE_LIMIT&&!batch.count){puts("DONE: target generated, all stored rows APP_ACKed; check dropped/valid counts separately; stop measurement here");return;}
ESP_ERROR_CHECK(esp_sleep_enable_timer_wakeup(60000000ULL));fflush(stdout);ESP_ERROR_CHECK(uart_wait_tx_idle_polling(UART_NUM_0));esp_deep_sleep_start();
}
断网、满缓存、丢ACK、RESET分别做
先验证阈值1每次尝试、阈值4第4条后尝试,再保持节点供电关闭AP或接收端。8条窗口满后,新采样应只增drop、旧队头不变;恢复后只随app ACK下降。故意让ACK未被节点观察,再发相同payload,接收端唯一行数不能增加,仍应重新ACK。不能只看MQTT_CONNECTED就勾送达。
单独按RESET测试新jid与seq重新1,旧ACK不得删除新会话。断电属于RTC承诺之外,不能把拔插电源当成功缓存恢复。第24次后DONE只表示所有存入行ACK完,要同时查drop和valid;若网络始终坏,尾批保留并周期重试,不会假报DONE。NVS错误不自动整片擦除,先备份诊断;难下载按BOOT+RESET恢复已知固件。
已实际运行同源12组策略检查和本机Mosquitto/receiver链路:未观察ACK后重发、重启接收进程仍去重、retained历史/坏CRC/NUL/尾随/同身份冲突拒绝,最终2条唯一合成记录。它验证主机合同与数据库事务,不是ESP32 MQTT C、Wi-Fi、Flash或24个真实周期。时延工具的默认集合包含invalid状态行;要报有效样本时延,按receiver导出sample_valid=1与jid/seq筛选后另列,不能把错误状态入库当有效温湿度。
参考:[2] SDK固定ESP-MQTT:发布/订阅/事件接口[3] MQTT3.1.1规范:QoS1/retain与会话[5] IDF5.5.5 Wi-Fi storage/start/stop
同时看有效送达、时延和完整能量
两模式相同24次生成、60秒睡眠请求、板/传感器/AP/距离/负载;至少各3轮并交换测试顺序。按L02供电边界断开DUT USB旁路,以PPK2 Source Meter5V/600mA从相同RESET/供电边沿(早于NEW日志)积分到END/DONE边界,包含ROM/启动、连接、重试、睡眠、尾批ACK及SDK断开清理;DONE后awake空转不算入。不要用更少有效送达换更低电量再宣称更省。
有效送达统计数据库唯一且sample_valid=1的行,重发不重复计数;另外记生成数、有效采样数、drop和未ACK。能量用真实I/V/时间梯形积分,每条有效提交能量=总E/有效唯一提交数;零送达时分母为0,应记null而不是0mJ。下载analyse_runs.py拒绝空表、不等采样量或矛盾计数,合成测试只验证算术。
延迟定义为样本可用到接收端提交,不拿ACK RTT替代。用共同外部时基记录SAMPLE日志的jid/seq(drop行seq=0)与COMMITTED,或独立DAQ;下载还提供capture_latency.py(锁pyserial3.5)启动接收器并给RX-only串口行加同台主机单调到达时间;接收端COMMIT_AT审计紧邻SQLite提交之后、ACK发布之前。analyse_latency.py关联jid/seq、保留未提交样本、拒绝倒序,不把重复ACK重计。这只是串口到达对提交审计的观察代理;UART/OS延迟未校准前仍不能称精确物理采样到提交时延,USB供电旁路也需排除。wire uptime每boot归零,不能用它作跨深睡时间轴。latency.csv与observations.csv只有表头,本站没有网络延迟、电流或批传收益结果。
参考:[1] IDF5.5.5睡眠与RTC保留定义[5] IDF5.5.5 Wi-Fi storage/start/stop
把收益与代价写在同一张结果表里
常见误判是只积分发送阶段、只比平均睡眠电流、把QoS1当业务成功、忽略最后3条永远不到阈值,或把drop后的连续seq当全量送达。本例给尾批、老队列等待和丢新计数明确策略,但没有替读者选择任何场景都最优的批大小。
服务端无COMMITTED先看SUBACK/topic/CRC/文件可写;有COMMITTED无APP_ACK查ACK topic/jid/seq/retain/队列与连接时间窗;RESET后丢缓存是声明过的边界。若任务要求掉电也不丢,应换持久日志与恢复方案,不在RTC上补一句“可靠”。下一篇L07换具体电池板,仅记录带条件的电压,仍不靠虚假的百分比推续航。
资料来源与版本说明
逐项核固定源码、原厂电路与实际构建;电压、能量、无线、ADC和电池结果仍待物理实验。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。