一周实验交付的是可解释的记录
L03验证一次采样后睡,L06验证批传/ACK,L07验证电池电压。本篇把它们变成可交接实验:每约600s醒来,核电压/采SHT、持久保存、在允许条件下上传;断网、低压、传感器错误和维护不能被删成“运行正常”。目标是至少七天原始记录、明确中断、可复算预算与接班人能操作的恢复流程。
本次没有电池或板卡运行,一周观察尚未开始。工具里的七天日期是SYNTHETIC输入,不能写成实测完成。PID258原厂要求使用/充电有人照看,因而这是具备值守/交接条件的台面实验;无人接班就停止并记中断,不能为了连续7天数字做无人看守DIY LiPo部署。
这块是经典ESP32,先核实际供电网
固定PID5400 Feather V2 PCB RevF / ESP32-PICO-MINI-02,8MB Flash/2MB物理PSRAM,本例关闭PSRAM、IDF target esp32、DIO80。固定board全局F而schematic全局E;直接核F板的网:SDA22、SCL20、GPIO2 QT/Neo支路使能、GPIO0 Neo DIN、GPIO13红LED、ADC35/ADC1_CH7。不能复制S3的8/9与OPI参数。
PID4885直接STEMMA QT四线接板口,0x44/100kHz;GPIO27→SPST→GND,外部10kΩ到本板3V3,内部数字/RTC拉均关闭。GPIO27是经典ESP32 RTC脚,可EXT0 LOW。PID258接原厂极性JST;USB会供电且可能充电,不能在电池观察窗偷偷插USB看串口。
参考:[1] Feather V2固定PCB F布线/器件值[2] 同commit schematic全局E:分压/充电/供电[3] Feather V2官方引脚/QT/USB与电池边界[8] IDF5.5.5睡眠/EXT0 RTC脚范围[12] PID4885模块连接/电气
低压是运维策略,电压不是剩余百分比
R1/R3各200k,C5=1µF;ADC35输入专用、等效源阻抗100k,理想RC约0.1s。固件QT/RF关后等1s,12bit/12dB oneshot,要求eFuse TP或Vref校准,缺失ADC_UNKNOWN而不猜1100mV;丢4次后64次/10ms逐点校准平均×2。等1s和平均只是策略,不保证内部采样窗/电阻容差/漏电精度。按L07用DMM同时量BAT/ADC35填误差表,USB/负载/温度条件要一致。
≤3550mV锁LOW,≥3700mV解除;未知ADC保留锁且停自动RF。它们是名义分压读数上的运维阈值,不是安全切断或SOC。known_ADC不证明电池存在、USB没接或充满;USB无电池也可能有读数。MCP73831 R4=5.1k的约196mA仅手册标称条件,不能当本站充电实测。
SHT每次释放GPIO2 hold→开QT→等20ms策略等待→新建I2C→FD/等10ms/读6B核CRC;删device/bus→SDA22/SCL20/NeoDIN0输入无拉→RTC GPIO2 LOW保持。需实际测QT VCC/信号无反供电;AP2112不因“可使能”就等于反向隔离/QOD,20ms也不是实测稳定时间。
参考:[1] Feather V2固定PCB F布线/器件值[2] 同commit schematic全局E:分压/充电/供电[5] SHT4x启动/FD转换/CRC/转换公式[7] IDF5.5.5经典ESP32 ADC line fitting/eFuse[10] MCP73831 DS20001984H原厂手册
保存、收端ACK和本地删除分开提交
NVS保存3136B带CRC/版本blob,16条未ACK行。每次候选set_blob+commit成功才替换RAM,重启读回CRC/结构;STORE_INVALID时停止业务并保留原始NVS,不自动erase。满队列保旧丢新,generated/seq继续增、drop_new累计,新样本丢失是可见事实,不宣称离线无限缓存。
行v2含journal/seq/cycle/reset/wake、sensor有效位与masked数值、ADC有效位/名义mV、LOW/ADC_UNKNOWN/SENSOR_ERROR/MAINT_HELD flags及drop/CRC。正常健康电压才自动RF;受信任LAN MQTT QoS1后,还要等主机SQLite FULL事务提交的精确应用ACK。节点仅接受当前head/inflight完整非retained ACK;删除也要本地NVS commit成功。任意写/commit报错都令本boot停止后续业务变更,RAM不前移;Flash结果可能不确定。下次启动读取并核验NVS后再决定队列,可能重发,也可能该删除已落盘;接收器同内容去重、冲突拒绝,不假称失败即Flash回滚。
没有采样UTC:seq/cycle是次序,first_received_utc仅服务器首次提交时间。积压恢复会同时到达,不能用接收跨度证明无缺采样。节点ACK删除后DUMP仅有最多16条未ACK,完整周历史必须由主机数据库、每日一致性备份/导出、原UART与值守日志保留。
参考:[6] IDF5.5.5 NVS blob/commit/错误[9] 固定ESP-MQTT分片/retain/timeout/ACK事件
先让接班人能从安全默认状态开始
下载包锁IDF5.5.5、经典esp32、分区、完整源码与许可;NVS0x9000/0x40000、factory0x50000/3MiB,无OTA。先USB开发,不接/测试电池能量窗,查物理板/串口与SHT接线。credentials.c默认SSID/password/broker为空,只离线入队/满丢新,不能显示上传成功。
在自有受信任LAN部署broker/接收器,再填mqtt://真实LAN地址,给每个新建或擦除NVS后的部署不同非零deployment_id并记录SHA/ID。现有有效NVS继续使用旧ID,不因改凭据偷偷换命名空间。串口UART0 115200走本板USB桥,RX7/TX8是另一组串口引脚。
idf.py --version
idf.py -B build -D IDF_TARGET=esp32 build
idf.py -B build -p YOUR_FEATHER_PORT flash monitor- 做到这里,你应该看到
- 默认固件已实际编译,ELF含真实网络/NVS/ADC路径;没有实板首次上电记录。
完整节点与同源journal模型
这套代码把健康队列/低压滞回/传感器错误/维护与radio-skip写入状态,不拿上一帧冒充本次样本。每约600s只是app-start计时策略,含启动/时钟误差,实际周期须从记录与仪器核验。
网络最多20次publish尝试,WiFi等待8s、MQTT订阅等待5s、每应用ACK等1.5s;另有SDK同步publish/stop开销,不宣称硬实时截止。维护是300s命令入口窗,已经执行的UPLOAD可延长退出,不能误说物理上恰好300s结束。
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <cstring>
#include <inttypes.h>
#include <atomic>
#include "esp_sleep.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_mac.h"
#include "esp_wifi.h"
#include "esp_wifi_default.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "mqtt_client.h"
#include "driver/uart.h"
#include "freertos/event_groups.h"
#include "freertos/queue.h"
#include "WeekJournal.h"
#include "Hardware.h"
#include "credentials.h"
static L10Journal journal;static nvs_handle_t store;static bool healthy=false;static EventGroupHandle_t flags;static QueueHandle_t acks;static std::atomic<int> subscription{-1};static char rowTopic[80],ackTopic[80];static constexpr int IP_OK=1,FAIL=2,MQTT_READY=4;
RTC_DATA_ATTR static uint32_t heldSeen=0,heldInverse=UINT32_MAX;
struct Ack{uint64_t journal,sequence;};
static bool persist(const L10Journal& candidate){
if(!healthy||!l10Valid(candidate)){puts("STORE_INVALID: refused commit");return false;}
esp_err_t e=nvs_set_blob(store,"journal",&candidate,sizeof candidate);if(e==ESP_OK)e=nvs_commit(store);
if(!l10StoreResult(healthy,e==ESP_OK)){printf("STORE_WRITE_ERROR=%s; persisted state uncertain, business blocked this boot; RAM not advanced. Preserve raw NVS before recovery.\n",esp_err_to_name(e));return false;}
journal=candidate;return true;
}
static bool openJournal(){
esp_err_t e=nvs_flash_init();if(e!=ESP_OK){printf("NVS_INIT_ERROR=%s; no automatic erase\n",esp_err_to_name(e));return false;}
e=nvs_open("l10",NVS_READWRITE,&store);if(e!=ESP_OK){printf("NVS_OPEN_ERROR=%s\n",esp_err_to_name(e));return false;}
size_t size=sizeof journal;e=nvs_get_blob(store,"journal",&journal,&size);
if(e==ESP_ERR_NVS_NOT_FOUND){healthy=true;auto fresh=l10Fresh(l10_deployment_id());if(!persist(fresh)){healthy=false;return false;}puts("NEW_NVS_JOURNAL: record deployment ID; never reuse after erase");return true;}
if(e!=ESP_OK||size!=sizeof journal||!l10Valid(journal)){printf("JOURNAL_INVALID error=%s bytes=%u; preserve raw NVS before recovery\n",esp_err_to_name(e),unsigned(size));return false;}healthy=true;return true;
}
static void wifiEvent(void*,esp_event_base_t base,int32_t id,void*){
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){if(esp_wifi_connect()!=ESP_OK)xEventGroupSetBits(flags,FAIL);}
if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){xEventGroupClearBits(flags,IP_OK);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* event=static_cast<esp_mqtt_event_handle_t>(data);
if(id==MQTT_EVENT_CONNECTED){int n=esp_mqtt_client_subscribe(event->client,ackTopic,1);subscription.store(n);if(n<0)xEventGroupSetBits(flags,FAIL);}
if(id==MQTT_EVENT_SUBSCRIBED&&event->msg_id==subscription.load())xEventGroupSetBits(flags,MQTT_READY);
if(id==MQTT_EVENT_DISCONNECTED){xEventGroupClearBits(flags,MQTT_READY);xEventGroupSetBits(flags,FAIL);}
if(id==MQTT_EVENT_DATA&&event->topic_len==int(strlen(ackTopic))&&!memcmp(event->topic,ackTopic,size_t(event->topic_len))&&!event->retain&&event->current_data_offset==0&&event->data_len==event->total_data_len){Ack ack{};if(l10Ack(event->data,size_t(event->data_len),ack.journal,ack.sequence))xQueueSend(acks,&ack,0);}
}
static void upload(bool operatorOverride){
if(!healthy||!journal.count){puts("UPLOAD: no healthy queued rows");return;}
if(!l10_ssid()[0]||!l10_broker()[0]){puts("CONFIG_DISABLED: rows retained; no network success");return;}
if(strlen(l10_ssid())>31||strlen(l10_password())>63||strncmp(l10_broker(),"mqtt://",7)){puts("CONFIG_INVALID: trusted LAN mqtt://, SSID<=31 password<=63");return;}
if(operatorOverride)puts("OPERATOR_UPLOAD: requires physically confirmed stable USB supply; software cannot detect USB/battery presence");
static bool networkStack=false;if(!networkStack){ESP_ERROR_CHECK(esp_netif_init());networkStack=true;}
xEventGroupClearBits(flags,0xff);xQueueReset(acks);subscription.store(-1);ESP_ERROR_CHECK(esp_event_loop_create_default());auto* sta=esp_netif_create_default_wifi_sta();if(!sta){puts("NETIF_ALLOC_ERROR");esp_event_loop_delete_default();return;}
esp_event_handler_instance_t wifiHandler,ipHandler;ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifiEvent,nullptr,&wifiHandler));ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,IP_EVENT_STA_GOT_IP,wifiEvent,nullptr,&ipHandler));
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 config{};strlcpy(reinterpret_cast<char*>(config.sta.ssid),l10_ssid(),sizeof config.sta.ssid);strlcpy(reinterpret_cast<char*>(config.sta.password),l10_password(),sizeof config.sta.password);ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&config));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 config{};config.broker.address.uri=l10_broker();config.network.disable_auto_reconnect=true;config.network.timeout_ms=2000;config.outbox.limit=4096;
auto client=esp_mqtt_client_init(&config);
if(client){ESP_ERROR_CHECK(esp_mqtt_client_register_event(client,MQTT_EVENT_ANY,mqttEvent,nullptr));esp_err_t e=esp_mqtt_client_start(client);
if(e==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<20&&healthy&&journal.count;++attempt){
const uint64_t sequence=l10Head(journal);const char* row=journal.rows[journal.head];int id=esp_mqtt_client_publish(client,rowTopic,row,0,1,0);printf("PUBLISH seq=%" PRIu64 " sdk_id=%d; PUBACK is not app commit\n",sequence,id);if(id<0){vTaskDelay(pdMS_TO_TICKS(100));continue;}
int64_t start=esp_timer_get_time();bool acknowledged=false;
while(esp_timer_get_time()-start<1500000){Ack ack{};if(xQueueReceive(acks,&ack,pdMS_TO_TICKS(20))==pdTRUE){auto candidate=journal;if(l10Pop(candidate,ack.journal,ack.sequence,sequence)){if(persist(candidate)){printf("APP_ACK_COMMITTED seq=%" PRIu64 " remaining=%" PRIu32 "\n",sequence,journal.count);acknowledged=true;}else puts("ACK_SEEN_BUT_NVS_COMMIT_FAILED: storage uncertain; stop business, inspect after restart");break;}}}
if(!acknowledged)puts("APP_ACK_NOT_COMMITTED: retained current head");
}}else puts("MQTT_TIMEOUT: data retained");
ESP_ERROR_CHECK(esp_mqtt_client_stop(client));
}else printf("MQTT_START_ERROR=%s\n",esp_err_to_name(e));
ESP_ERROR_CHECK(esp_mqtt_client_destroy(client));
}else puts("MQTT_ALLOC_ERROR");
}else puts("WIFI_TIMEOUT_OR_FAIL: retained queue");
ESP_ERROR_CHECK(esp_wifi_stop());ESP_ERROR_CHECK(esp_wifi_deinit());ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT,ESP_EVENT_ANY_ID,wifiHandler));ESP_ERROR_CHECK(esp_event_handler_instance_unregister(IP_EVENT,IP_EVENT_STA_GOT_IP,ipHandler));esp_netif_destroy_default_wifi(sta);ESP_ERROR_CHECK(esp_event_loop_delete_default());
}
static void status(){if(!healthy){puts("STATE STORE_INVALID: read raw NVS/repair before deployment; no automatic erase");return;}printf("STATUS journal=%016" PRIx64 " generated=%" PRIu64 " queued=%" PRIu32 " drop_new=%" PRIu64 " low_latched=%" PRIu32 " head=%" PRIu64 "; voltage not SOC, no acquired UTC\n",journal.journal,journal.generated,journal.count,journal.dropped,journal.lowLatched,l10Head(journal));}
static void maintenance(){
puts("MAINTENANCE 300s command-entry window: STATUS / DUMP / UPLOAD / EXIT; in-progress UPLOAD may extend exit time. No erase command; USB condition is operator responsibility.");
int64_t start=esp_timer_get_time(),last=0;char line[64]{};size_t length=0;bool discarding=false,sawCR=false;
while(esp_timer_get_time()-start<300000000){
uint8_t ch;int n=uart_read_bytes(UART_NUM_0,&ch,1,pdMS_TO_TICKS(50));int64_t now=esp_timer_get_time();
if((length||sawCR)&&!discarding&&now-last>2000000){length=0;sawCR=false;discarding=true;puts("INPUT_TIMEOUT: draining through next LF");}
if(n!=1){continue;}
last=now;
if(ch=='\n'){
if(discarding){puts("INPUT_DISCARDED: overflow/control/timeout drained through LF");}else{line[length]=0;if(!strcmp(line,"STATUS"))status();else if(!strcmp(line,"DUMP")){status();if(healthy){puts("BEGIN_UNACKED_CSV_V2");for(unsigned i=0;i<journal.count;++i)fputs(journal.rows[(journal.head+i)%L10_SLOTS],stdout);puts("END_UNACKED_CSV_V2");}}else if(!strcmp(line,"UPLOAD")){if(gpio_get_level(GPIO_NUM_27)==0)upload(true);else puts("PHYSICAL_MAINTENANCE_CLOSED: rejected UPLOAD");}else if(!strcmp(line,"EXIT"))break;else puts("UNKNOWN_COMMAND");}
length=0;discarding=false;sawCR=false;continue;
}
if(discarding){continue;}
if(sawCR){length=0;sawCR=false;discarding=true;continue;}
if(ch=='\r'){sawCR=true;continue;}// Only one trailing CR immediately before LF is accepted.
if(ch<0x20||ch>0x7e||length==sizeof line-1){discarding=true;length=0;}else if(!discarding)line[length++]=char(ch);
}
puts("MAINTENANCE_END; held switch will not immediately wake again");
}
static void sleepNow(uint64_t delay){
l10PowerOff();bool released=gpio_get_level(GPIO_NUM_27)!=0;vTaskDelay(pdMS_TO_TICKS(30));released=released&&gpio_get_level(GPIO_NUM_27)!=0;
ESP_ERROR_CHECK(esp_sleep_enable_timer_wakeup(delay));
if(released){heldSeen=0;heldInverse=UINT32_MAX;ESP_ERROR_CHECK(esp_sleep_enable_ext0_wakeup(GPIO_NUM_27,0));puts("WAKE timer+EXT0 GPIO27 LOW armed after release");}
else{heldSeen=1;heldInverse=~heldSeen;puts("MAINT_HELD: timer-only sleep; EXT0 disabled to avoid repeated immediate wake");}
fflush(stdout);ESP_ERROR_CHECK(uart_wait_tx_idle_polling(UART_NUM_0));esp_deep_sleep_start();
}
extern "C" void app_main(){
int64_t start=esp_timer_get_time();ESP_ERROR_CHECK(rtc_gpio_hold_dis(GPIO_NUM_2));ESP_ERROR_CHECK(rtc_gpio_deinit(GPIO_NUM_2));gpio_config_t power{};power.pin_bit_mask=1ULL<<2;power.mode=GPIO_MODE_OUTPUT;ESP_ERROR_CHECK(gpio_config(&power));gpio_set_level(GPIO_NUM_2,0);l10PowerOff();
gpio_config_t input{};input.pin_bit_mask=1ULL<<27;input.mode=GPIO_MODE_INPUT;ESP_ERROR_CHECK(gpio_config(&input));ESP_ERROR_CHECK(gpio_set_pull_mode(GPIO_NUM_27,GPIO_FLOATING));ESP_ERROR_CHECK(rtc_gpio_pullup_dis(GPIO_NUM_27));ESP_ERROR_CHECK(rtc_gpio_pulldown_dis(GPIO_NUM_27));gpio_config_t led{};led.pin_bit_mask=1ULL<<13;led.mode=GPIO_MODE_OUTPUT;ESP_ERROR_CHECK(gpio_config(&led));gpio_set_level(GPIO_NUM_13,0);
ESP_ERROR_CHECK(uart_driver_install(UART_NUM_0,512,0,0,nullptr,0));
if(esp_reset_reason()!=ESP_RST_DEEPSLEEP||(heldSeen^heldInverse)!=UINT32_MAX){heldSeen=0;heldInverse=UINT32_MAX;}
vTaskDelay(pdMS_TO_TICKS(30));const bool physical=gpio_get_level(GPIO_NUM_27)==0;const bool enterMaintenance=physical&&!heldSeen;if(physical){heldSeen=1;heldInverse=~heldSeen;}
flags=xEventGroupCreate();acks=xQueueCreate(4,sizeof(Ack));if(!flags||!acks){puts("ALLOC_ERROR: no radio result");sleepNow(600000000);}
uint8_t mac[6];ESP_ERROR_CHECK(esp_read_mac(mac,ESP_MAC_WIFI_STA));snprintf(rowTopic,sizeof rowTopic,"hixinwu/l10/%02x%02x%02x%02x%02x%02x/row",mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]);snprintf(ackTopic,sizeof ackTopic,"hixinwu/l10/%02x%02x%02x%02x%02x%02x/ack",mac[0],mac[1],mac[2],mac[3],mac[4],mac[5]);
printf("L10 classicESP32 FeatherV2 PCB F reset=%d wake=%d maintenance=%u held=%u; no tested week/battery result\n",esp_reset_reason(),esp_sleep_get_wakeup_cause(),enterMaintenance,physical);
if(openJournal()){
auto battery=l10Battery();printf("BATTERY_NODE known_ADC=%u nominal_mV=%" PRIu32 " raw_mean=%d efuse=%d error=%s; presence/USB/SOC NOT detected\n",battery.known,battery.nominalmV,battery.raw,battery.efuse,esp_err_to_name(battery.error));
esp_err_t error=ESP_FAIL;auto sample=l10Sensor(error);printf("SHT40 valid=%u result=%s; invalid values masked\n",sample.valid,esp_err_to_name(error));
auto candidate=journal;candidate.lowLatched=l10Low(journal.lowLatched,battery.known,battery.nominalmV);l10Seal(candidate);L10Row row{};row.reset=uint32_t(esp_reset_reason());row.wake=uint32_t(esp_sleep_get_wakeup_cause());row.sensor=sample.valid;row.t=sample.valid?sample.t_mC:0;row.rh=sample.valid?sample.rh_raw_mPct:0;row.battery=battery.known;row.mV=battery.known?battery.nominalmV:0;row.flags=(candidate.lowLatched?L10_LOW:0)|(!battery.known?L10_ADC_UNKNOWN:0)|(!sample.valid?L10_SENSOR_ERROR:0)|(physical?L10_MAINT_HELD:0);
if(l10Append(candidate,row)&&persist(candidate)){status();if(!enterMaintenance&&battery.known&&!journal.lowLatched)upload(false);else puts("RADIO_SKIPPED: maintenance/low-latch/ADC-unknown; sample retained or explicitly drop-new counted");}else puts("SAMPLE_NOT_COMMITTED: RAM not advanced; write failure blocks business, preserve raw NVS");
}
if(enterMaintenance)maintenance();
const int64_t active=esp_timer_get_time()-start;printf("END active_app_us=%" PRId64 "; sleep timer adjusted toward600s app-start cycle; boot overhead/clock drift measured separately\n",active);sleepNow(active<600000000?uint64_t(600000000-active):1000000);
}
// ---- main/Hardware.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include "driver/gpio.h"
#include "driver/rtc_io.h"
#include "driver/i2c_master.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "ShtSample.h"
struct L10Battery{bool known=false;int raw=0;uint32_t nominalmV=0;int efuse=-1;esp_err_t error=ESP_FAIL;};
inline void l10Release(gpio_num_t pin){ESP_ERROR_CHECK(gpio_reset_pin(pin));ESP_ERROR_CHECK(gpio_set_direction(pin,GPIO_MODE_INPUT));ESP_ERROR_CHECK(gpio_set_pull_mode(pin,GPIO_FLOATING));}
inline void l10PowerOff(){
// Sensor bus must already have been removed. Release BOTH signals before VIN.
l10Release(GPIO_NUM_22);l10Release(GPIO_NUM_20);l10Release(GPIO_NUM_0);// NeoPixel DIN shares the switched rail.
ESP_ERROR_CHECK(rtc_gpio_init(GPIO_NUM_2));ESP_ERROR_CHECK(rtc_gpio_set_direction(GPIO_NUM_2,RTC_GPIO_MODE_OUTPUT_ONLY));ESP_ERROR_CHECK(rtc_gpio_set_level(GPIO_NUM_2,0));ESP_ERROR_CHECK(rtc_gpio_hold_en(GPIO_NUM_2));
}
inline ShtSample l10Sensor(esp_err_t& error){
ShtSample sample{};ESP_ERROR_CHECK(rtc_gpio_hold_dis(GPIO_NUM_2));ESP_ERROR_CHECK(rtc_gpio_deinit(GPIO_NUM_2));gpio_config_t power{};power.pin_bit_mask=1ULL<<2;power.mode=GPIO_MODE_OUTPUT;ESP_ERROR_CHECK(gpio_config(&power));gpio_set_level(GPIO_NUM_2,1);vTaskDelay(pdMS_TO_TICKS(20));
i2c_master_bus_handle_t bus=nullptr;i2c_master_dev_handle_t device=nullptr;i2c_master_bus_config_t config{};config.i2c_port=I2C_NUM_0;config.sda_io_num=GPIO_NUM_22;config.scl_io_num=GPIO_NUM_20;config.clk_source=I2C_CLK_SRC_DEFAULT;config.glitch_ignore_cnt=7;config.flags.enable_internal_pullup=false;
error=i2c_new_master_bus(&config,&bus);
if(error==ESP_OK){i2c_device_config_t d{};d.dev_addr_length=I2C_ADDR_BIT_LEN_7;d.device_address=0x44;d.scl_speed_hz=100000;error=i2c_master_bus_add_device(bus,&d,&device);}
if(error==ESP_OK){uint8_t cmd=0xfd,bytes[6];error=i2c_master_transmit(device,&cmd,1,100);if(error==ESP_OK){vTaskDelay(pdMS_TO_TICKS(10));error=i2c_master_receive(device,bytes,6,100);if(error==ESP_OK){sample=decodeSht(bytes);if(!sample.valid)error=ESP_ERR_INVALID_CRC;}}}
if(device){ESP_ERROR_CHECK(i2c_master_bus_rm_device(device));}if(bus){ESP_ERROR_CHECK(i2c_del_master_bus(bus));}l10PowerOff();return sample;
}
inline L10Battery l10Battery(){
L10Battery b;vTaskDelay(pdMS_TO_TICKS(1000));// Read before sensor/radio load; C5 and divider source condition explicitly documented.
adc_oneshot_unit_handle_t unit=nullptr;adc_cali_handle_t calibration=nullptr;adc_oneshot_unit_init_cfg_t init{};init.unit_id=ADC_UNIT_1;init.ulp_mode=ADC_ULP_MODE_DISABLE;b.error=adc_oneshot_new_unit(&init,&unit);if(b.error!=ESP_OK)return b;
adc_oneshot_chan_cfg_t channel{};channel.atten=ADC_ATTEN_DB_12;channel.bitwidth=ADC_BITWIDTH_12;b.error=adc_oneshot_config_channel(unit,ADC_CHANNEL_7,&channel);
adc_cali_line_fitting_efuse_val_t efuse=ADC_CALI_LINE_FITTING_EFUSE_VAL_DEFAULT_VREF;if(b.error==ESP_OK)b.error=adc_cali_scheme_line_fitting_check_efuse(&efuse);b.efuse=int(efuse);
if(b.error==ESP_OK&&efuse!=ADC_CALI_LINE_FITTING_EFUSE_VAL_DEFAULT_VREF){adc_cali_line_fitting_config_t c{};c.unit_id=ADC_UNIT_1;c.atten=ADC_ATTEN_DB_12;c.bitwidth=ADC_BITWIDTH_12;c.default_vref=0;b.error=adc_cali_create_scheme_line_fitting(&c,&calibration);}
else if(b.error==ESP_OK){b.error=ESP_ERR_NOT_SUPPORTED;}
int64_t sumRaw=0,sumMv=0;
if(b.error==ESP_OK){for(unsigned i=0;i<68;++i){int raw=0,mV=0;b.error=adc_oneshot_read(unit,ADC_CHANNEL_7,&raw);if(b.error!=ESP_OK||raw<=0||raw>=4095){b.error=ESP_ERR_INVALID_RESPONSE;break;}b.error=adc_cali_raw_to_voltage(calibration,raw,&mV);if(b.error!=ESP_OK)break;if(i>=4){sumRaw+=raw;sumMv+=mV;}vTaskDelay(pdMS_TO_TICKS(10));}
if(b.error==ESP_OK){b.raw=int(sumRaw/64);uint32_t estimated=uint32_t((sumMv*2+32)/64);if(estimated>=2000&&estimated<=5000){b.nominalmV=estimated;b.known=true;}else b.error=ESP_ERR_INVALID_RESPONSE;}
}
if(calibration){ESP_ERROR_CHECK(adc_cali_delete_scheme_line_fitting(calibration));}ESP_ERROR_CHECK(adc_oneshot_del_unit(unit));return b;
}
// ---- main/WeekJournal.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <cstddef>
#include <cstring>
#include <cstdio>
#include <inttypes.h>
constexpr unsigned L10_SLOTS=16,L10_ROW=192;
constexpr uint32_t L10_LOW=1,L10_ADC_UNKNOWN=2,L10_SENSOR_ERROR=4,L10_MAINT_HELD=8;
inline uint32_t l10Crc(const void* ptr,size_t n){auto* b=static_cast<const uint8_t*>(ptr);uint32_t crc=UINT32_MAX;for(size_t i=0;i<n;++i){crc^=b[i];for(unsigned bit=0;bit<8;++bit)crc=(crc>>1)^((crc&1)?0xedb88320U:0);}return ~crc;}
struct L10Row {uint64_t journal=0,seq=0,cycle=0;uint32_t reset=0,wake=0;bool sensor=false;int32_t t=0,rh=0;bool battery=false;uint32_t mV=0,flags=0;uint64_t dropped=0;};
inline bool l10Encode(const L10Row& q,char* out,size_t capacity){
if(!q.journal||!q.seq||!q.cycle||q.dropped>=q.seq||q.reset>32||q.wake>32||q.flags>15||(!q.sensor&&(q.t||q.rh))||(q.sensor&&(q.t< -45000||q.t>130000||q.rh< -6000||q.rh>119000))||(!q.battery&&q.mV)||(q.battery&&(q.mV<2000||q.mV>5000))||bool(q.flags&L10_ADC_UNKNOWN)==q.battery||bool(q.flags&L10_SENSOR_ERROR)==q.sensor){return false;}
int n=snprintf(out,capacity,"2,%016" PRIx64 ",%" PRIu64 ",%" PRIu64 ",%" PRIu32 ",%" PRIu32 ",%u,%" PRId32 ",%" PRId32 ",%u,%" PRIu32 ",%" PRIu32 ",%" PRIu64,q.journal,q.seq,q.cycle,q.reset,q.wake,q.sensor,q.t,q.rh,q.battery,q.mV,q.flags,q.dropped);
if(n<=0||size_t(n)+11>capacity){return false;}uint32_t crc=l10Crc(out,size_t(n));int end=snprintf(out+n,capacity-size_t(n),",%08" PRIx32 "\n",crc);return end==10;
}
inline bool l10Decode(const char* bytes,size_t n,L10Row& q){
if(!bytes||n<35||n>=L10_ROW||bytes[n-1]!='\n'||std::memchr(bytes,0,n)){return false;}char input[L10_ROW]{};memcpy(input,bytes,n);unsigned valid=0,battery=0;uint32_t crc=0;int end=0;L10Row parsed;
int count=sscanf(input,"2,%16" SCNx64 ",%" SCNu64 ",%" SCNu64 ",%" SCNu32 ",%" SCNu32 ",%u,%" SCNd32 ",%" SCNd32 ",%u,%" SCNu32 ",%" SCNu32 ",%" SCNu64 ",%8" SCNx32 "\n%n",&parsed.journal,&parsed.seq,&parsed.cycle,&parsed.reset,&parsed.wake,&valid,&parsed.t,&parsed.rh,&battery,&parsed.mV,&parsed.flags,&parsed.dropped,&crc,&end);
if(count!=13||end!=int(n)||valid>1||battery>1){return false;}parsed.sensor=valid;parsed.battery=battery;char canonical[L10_ROW]{};if(!l10Encode(parsed,canonical,sizeof canonical)||strlen(canonical)!=n||memcmp(canonical,bytes,n)){return false;}q=parsed;return true;
}
struct L10Journal {
uint32_t magic=0,version=0;uint64_t journal=0,generated=0,dropped=0,cycle=0;uint32_t head=0,count=0,lowLatched=0,reserved=0;char rows[L10_SLOTS][L10_ROW]{};uint32_t crc=0,reserved2=0;
};
static_assert(offsetof(L10Journal,crc)==3128&&sizeof(L10Journal)==3136,"fixed NVS blob layout; migration must be explicit");
inline void l10Seal(L10Journal& j){j.crc=l10Crc(&j,offsetof(L10Journal,crc));}
inline L10Journal l10Fresh(uint64_t id){L10Journal j{};j.magic=0x4c31304a;j.version=1;j.journal=id;l10Seal(j);return j;}
inline bool l10Valid(const L10Journal& j){
if(j.magic!=0x4c31304a||j.version!=1||!j.journal||j.count>L10_SLOTS||j.head>=L10_SLOTS||j.lowLatched>1||j.reserved||j.reserved2||j.crc!=l10Crc(&j,offsetof(L10Journal,crc))||j.dropped>j.generated||j.generated!=j.cycle){return false;}
uint64_t previous=0;for(unsigned i=0;i<j.count;++i){const char* line=j.rows[(j.head+i)%L10_SLOTS];size_t n=strnlen(line,L10_ROW);L10Row row;if(!l10Decode(line,n,row)||row.journal!=j.journal||row.seq<=previous||row.seq>j.generated||row.cycle!=row.seq||row.dropped>j.dropped){return false;}previous=row.seq;}return true;
}
inline bool l10Append(L10Journal& j,L10Row row){
if(!l10Valid(j)||j.generated==UINT64_MAX||j.dropped==UINT64_MAX){return false;}
row.journal=j.journal;row.seq=j.generated+1;row.cycle=j.cycle+1;row.dropped=j.dropped;
char encoded[L10_ROW]{};if(!l10Encode(row,encoded,sizeof encoded)){return false;}
++j.generated;++j.cycle;
if(j.count==L10_SLOTS){++j.dropped;l10Seal(j);return true;}// Keep old unacknowledged rows, drop new sample explicitly.
unsigned slot=(j.head+j.count)%L10_SLOTS;memset(j.rows[slot],0,L10_ROW);memcpy(j.rows[slot],encoded,strlen(encoded)+1);++j.count;l10Seal(j);return true;
}
inline uint64_t l10Head(const L10Journal& j){if(!j.count)return 0;L10Row row;const char* text=j.rows[j.head];return l10Decode(text,strnlen(text,L10_ROW),row)?row.seq:0;}
inline bool l10Pop(L10Journal& j,uint64_t jid,uint64_t seq,uint64_t inflight){if(!l10Valid(j)||!j.count||jid!=j.journal||seq!=inflight||seq!=l10Head(j)){return false;}memset(j.rows[j.head],0,L10_ROW);j.head=(j.head+1)%L10_SLOTS;--j.count;l10Seal(j);return true;}
inline bool l10Low(bool before,bool adcKnown,uint32_t nominalmV){if(!adcKnown)return before;if(nominalmV<=3550)return true;if(nominalmV>=3700)return false;return before;}
inline bool l10Ack(const char* bytes,size_t n,uint64_t& journal,uint64_t& seq){
if(!bytes||n<18||n>37||std::memchr(bytes,0,n)){return false;}char copy[40]{};memcpy(copy,bytes,n);int end=0;uint64_t j=0,s=0;if(sscanf(copy,"%16" SCNx64 ",%" SCNu64 "%n",&j,&s,&end)!=2||end!=int(n)||!j||!s){return false;}char encoded[40];int size=snprintf(encoded,sizeof encoded,"%016" PRIx64 ",%" PRIu64,j,s);if(size!=int(n)||memcmp(encoded,bytes,n)){return false;}journal=j;seq=s;return true;
}
// A failed write/commit makes persisted state uncertain. Only a fresh restart/inspection
// can establish health again; a later success must not silently rearm this boot.
inline bool l10StoreResult(bool& healthy,bool success){if(!healthy)return false;if(!success)healthy=false;return healthy;}
// ---- main/ShtSample.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
struct ShtSample { bool valid=false;int t_mC=0;int rh_raw_mPct=0;int rh_mPct=0; };
inline uint8_t shtCrc(const uint8_t* b){uint8_t c=0xff;for(int k=0;k<2;++k){c^=b[k];for(int j=0;j<8;++j)c=(c&0x80)?uint8_t((c<<1)^0x31):uint8_t(c<<1);}return c;}
inline ShtSample decodeSht(const uint8_t b[6]){ShtSample s;if(shtCrc(b)!=b[2]||shtCrc(b+3)!=b[5])return s;uint16_t t=(uint16_t(b[0])<<8)|b[1],h=(uint16_t(b[3])<<8)|b[4];s.valid=true;s.t_mC=-45000+int(175000LL*t/65535);s.rh_raw_mPct=-6000+int(125000LL*h/65535);s.rh_mPct=s.rh_raw_mPct<0?0:(s.rh_raw_mPct>100000?100000:s.rh_raw_mPct);return s;}
// ---- main/credentials.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
const char* l10_ssid(void);
const char* l10_password(void);
const char* l10_broker(void);
uint64_t l10_deployment_id(void);
#ifdef __cplusplus
}
#endif
// ---- main/credentials.c ----
// SPDX-License-Identifier: MIT
#include "credentials.h"
// Empty networking configuration is safe offline operation, not successful upload.
const char* l10_ssid(void){return "";}
const char* l10_password(void){return "";}
const char* l10_broker(void){return "";}// trusted LAN mqtt:// only; do not expose to Internet.
// Public deployment namespace, not a secret. Change for EVERY erased/new journal
// and record it in the handoff. Existing valid NVS journal keeps its original ID.
uint64_t l10_deployment_id(void){return UINT64_C(0x4c31304649454c44);}
先做短时故障演练,再开始观察周
主机Python3.12+Mosquitto2.0.20 CLI;receiver严格LF/规范数字/CRC,SQLite WAL+synchronous FULL,提交后才发布ACK。同key同payload只ACK不增行,冲突不ACK。包中broker.conf只监听localhost给合成fixture,板上实验必须另配置受控LAN监听/权限;节点不能用127.0.0.1连电脑。
逐项演练并保存原日志:断电移除SHT→错误masked而非旧值;停broker→未ACK留NVS;receiver同DB重启→重复不增行;节点普通重启→旧journal/队列保留;离线超过16→drop_new;恢复→按head推进;ADC校准缺失→停RF。用合规可控供电/DMM验证LOW滞回,不能短接/过放LiPo造故障。板上Flash断电写入另做物理测试,主机candidate克隆不代替。
python3 host/receiver.py receive --host YOUR_BROKER --port 1883 --db week.db --client-dir /path/to/mosquitto/client
python3 host/receiver.py export --db week.db --output week-export.csv- 做到这里,你应该看到
- 导出独占新文件,备份先停receiver或用SQLite backup API;只拷运行中WAL主文件不是一致性备份。
维护必须有物理入口,也要防持续电平重触发
先标记结束电池观察区间,按厂商要求处理电池/USB,再闭合GPIO27。第一次LOW进入命令窗,每条用完整LF或CRLF发送STATUS、DUMP、UPLOAD、EXIT;CR只允许末尾紧接LF。63B ASCII上限,非法控制/超长/半行2s超时均持续丢到真正LF,空闲不重新武装,也不重复打印超时;该LF只恢复边界,之后的新完整命令才可执行,无远程擦除。UPLOAD要求开关仍LOW,操作者另行确认稳定USB,程序不会检测USB/电池存在。
本版本gpio_config内部先对RTC脚调用rtc_gpio_deinit,醒来GPIO27由此恢复数字输入。睡前两次HIGH间隔30ms才重新武装EXT0 LOW;持续LOW只定时醒,RTC heldSeen/反码阻止重复进维护。松开后下一周期重武装;普通reset清RTC guard但不清NVS。命令终端可用IDF环境的python -m serial.tools.miniterm YOUR_PORT 115200 --eol LF(pyserial3.5);先发UP停3s再发LOAD+LF应丢弃,接着新UPLOAD+LF才执行。电池窗内接USB、充电、换电或故障重刷都记INTERRUPTION,不能把维护供电算进电池续航。
STORE_INVALID先记录固件/配置SHA、备份0x9000起0x40000原NVS,再诊断版本/CRC/写失败。确需擦除时停试验、保留备份、明确丢弃旧队列并选新deployment_id;禁止自动erase-flash隐藏故障。具体esptool读回和已知固件重刷命令在README,先核端口/版本。
参考:[6] IDF5.5.5 NVS blob/commit/错误[8] IDF5.5.5睡眠/EXT0 RTC脚范围[13] IDF5.5.5 gpio_config恢复RTC脚到数字GPIO
能量预算、真实观察和交接各自留证据
先捕获完整正常周期与离线重试周期的电池输入端I/V trace,包含启动、ADC、SHT、NVS、RF失败和深睡;USB/充电与其他供电断开,记录仪器量程/压降/带宽。PPK2若作Ampere Meter是外部源VIN→测量→VOUT到DUT、0.8–5V/1A连续限制,不能套L08的5V Source接板。没有合适电池串联线/仪器条件就保持待测,不能把电流档并联电池。
budget按实际周期积分做7天理想重复预算,再列标签3.7V×1200mAh名义能量作参考,不输出保证续航;实际可用容量、低温/老化/截止/自放电和调压变化都需另测。所有空表仅表头,示例命令的采样gap/周期填写实值。
值守人填deployment-ledger与voltage-checks,START/CHECK/END/INTERRUPTION要真实UTC、USB/充电/电池编号、故障、责任人。12h检查间隔只是工具审计策略,不替代厂商有人照看要求。每日一致性备份与新名导出、SHA;交接表记录DB/export/UART SHA、generated/queued/drop_new/LOW、下一值守UTC。结尾保存STATUS和未ACK DUMP。
week_audit分别报告操作窗口≥7天与否、接收跨度、序号孔洞、drop、reset、flags与中断,不认证物理连续性。至少真实满7天原始记录后才写观察结论;本次尚未开始,合成日历/能量测试不填入观察表。
python3 tools/budget.py ACTUAL-CYCLE.csv --period-s 600 --days 7 --nominal-voltage 3.7 --rated-capacity-mAh 1200 --max-gap-s 0.001
python3 tools/week_audit.py week-export.csv deployment-ledger.csv接班人拿到的是完整包与未完成项
实际classic ESP32最终固件Exit0,bin/ELF/map/config/CPP与精确输入SHA保全;同源journal经ASan/UBSan,20组规范解析红门与20组合成周审计/能量检查运行;真实本机Mosquitto/SQLite验证丢ACK重发、进程重启去重、冲突拒收与导出。同源写错误门禁还核本boot失败后不能被后续成功重新武装;另以完整实际maintenance函数体注入UART/时钟桩,12组LF/CRLF/超长/控制字节/部分超时/CR超时/恢复与不重复打印均通过;它们不证明射频、ADC、SHT电气、NVS断电安全或七天电池运行。
交接时带BOM/板修订、源码与锁/许可、固件SHA、实际供电/校准误差、DB与原日志、故障状态/恢复命令和全部中断。前置L04–L07各解决一个环节;本页独立增加持久NVS、服务器完整历史与值守交接,不把L06的RTC缓存说成跨所有复位保障。
资料来源与版本说明
固定源码逐字核对安装输入并真实构建;主机服务与合成数学夹具明确限于软件。没有本站物理量/无线/电池或一周实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。
- Feather V2固定PCB F布线/器件值
- 同commit schematic全局E:分压/充电/供电
- Feather V2官方引脚/QT/USB与电池边界
- PID258原厂要求/标称电池规格
- SHT4x启动/FD转换/CRC/转换公式
- IDF5.5.5 NVS blob/commit/错误
- IDF5.5.5经典ESP32 ADC line fitting/eFuse
- IDF5.5.5睡眠/EXT0 RTC脚范围
- 固定ESP-MQTT分片/retain/timeout/ACK事件
- MCP73831 DS20001984H原厂手册
- PPK2外部电源Ampere Meter接线
- PID4885模块连接/电气
- IDF5.5.5 gpio_config恢复RTC脚到数字GPIO