传感器与可信数据 / 项目实战发布 内容更新

连续室内环境站:把有效读数、CSV和断网补传接成闭环

用SHT40、OLED和SD建立连续环境站,记录时间资格和样本身份;SQLite提交后应用ACK,故障停写、原行重传和人工恢复都有边界。

读完能做什么
得到完整IDF工程和本机接收/导出/坏尾检查工具,能区分未采到、未记录、未送达和时钟未知,按空表执行24小时与恢复实验。
开始之前
先完成所列前置,能检查低压接线、串口与工具版本;准备独立台架和备份。
所需器材
官方S3 DevKitC-1 v1.1 N8R8、桥USB数据线、Adafruit PID4885 SHT40、PID326 2020+自动复位OLED、Adafruit PID254 SD模块、FAT32 SDHC 4–32GB、短线、可信隔离实验LAN、本机Mosquitto2.0.20、Python3.12、可备份磁盘
版本组合
开发框架:ESP-IDF5.5.5ESP-IDF:5.5.5板卡:DevKitC-1 v1.1 / WROOM-1-N8R8
适用范围

仅官方S3 v1.1 N8R8/IDF5.5.5;指定SHT40/OLED/SD模块;60秒计划采样、512KiB应用上限;未实板、未24小时运行。

这篇验证到了哪一步

资料核对已通过
作者逐章核查及固定源码比对见 docs/esp32-evidence/home-station.md 与 home-station-sources.json;不是独立复核结论。验证日期:
构建 / 配置已通过
实际 config/固件编译原日志与当前源/生成CPP/固件SHA见 docs/esp32-evidence/home-station-builds.json。对象:esp32s3 / N8R8版本:ESP-IDF5.5.5 / IDF5.5.5验证日期:
仿真已通过
同源主机模型及明确范围内的本机服务/模板夹具通过;不是芯片仿真、HA引擎或硬件,见 home-station-host.json。对象:macOS arm64 host fixture验证日期:
本站实板待验证
尚未完成指定板、模块、电源、HA、OTA或长时间现场实验;空观察表保留待填。

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

01 / 项目实战

一条曲线背后,至少有四个不同的成功

室内环境站不能只显示一个温度数字。本例把“传感器读到了有效值”“写入本地记录”“网络接收机提交了记录”和“UTC可信到什么程度”分别表示。这样断网时还能保存原始行,卡失效时也不会把屏幕数字误认为已经记录。

系统持续供电,每60秒计划一次SHT40读取,OLED/串口显示资格状态,SD保存有身份和CRC的CSV;接收端SQLite提交后返回应用ACK。它与深睡攒批节点用途不同:这里优先连续记录、外部介质恢复和24小时观察。本轮实际完成构建/同源主机/本机服务夹具,指定板、真实卡和24小时现场表均为空。

参考:[3] Sensirion驱动1.1.0固定提交与BSD许可[6] IDF5.5.5 FAT VFS挂载和format_if_mount_failed[7] IDF5.5.5系统时间与SNTP[9] IDF5.5.5 NVS blob与commit语义

02 / 项目实战

先固定两个I²C模块和一块带转换的SD板

板卡限定官方ESP32-S3-DevKitC-1 v1.1/WROOM-1-N8R8,桥USB为唯一电源。SHT40 PID4885 VIN/GND接3V3/GND、SDA8/SCL9、0x44,3V是输出留空。OLED限定PID326 128×64的2020+STEMMA QT/自动复位版,同总线100kHz、默认0x3D、VIN3V3,RST留空;旧复位版不在范围。

指定PID254是带稳压和电平转换的microSD模块,5V输入来自J1-21;CLK12、DI11、DO13、CS10,SPI10MHz,GND共地。只用FAT32的4–32GB SDHC卡,不拿裸SD卡、exFAT或另一块转换板套接。所有接线、插卡与更换介质都在断电时完成。

OLED用IDF内置SSD1306的1bpp页缓冲,原创3×5 ASCII字形,不依赖中文字体。初始化/事务失败只报显示故障,传感器/SD不跟着伪造失败;真实像素、镜像方向和供电下陷仍待实板。

主控端模块端注意
3V3 / GNDPID4885 VIN/GND;PID326 VIN/GNDPID4885 3V不接输入
GPIO8/J1-12、GPIO9/J1-15两模块SDA、SCL100kHz,0x44与0x3D
GPIO12/J1-18、GPIO11/J1-17SD CLK、DIMOSI是主控→卡
GPIO13/J1-19、GPIO10/J1-16SD DO、CSDO是卡→主控
5V/J1-21、GND/J1-22PID254 5V、GND桥USB供电,不并外部5V

参考:[1] 官方v1.1板卡:引脚、模块与单电源入口[2] SHT40 PID4885供电与引脚[4] Adafruit PID326 2020复位与默认I2C地址[5] Adafruit PID254 microSD供电、电平与SPI接线[8] IDF5.5.5 SSD1306驱动页缓冲/镜像/初始化

03 / 项目实战

解压整个工程,改本机配置再构建

下载包包含main、原样Sensirion驱动、CMake/组件锁、分区、Python接收/修复工具和README。必须解压整个目录,不能只复制main.c。固定IDF5.5.5、esp32s3、8MB flash/OPI PSRAM,factory分区3MiB且没有OTA;本例用桥USB恢复固件。

menuconfig→Demo configuration填真实2.4GHz SSID/密码和隔离LAN的MQTT URI;默认192.0.2.10只是文档地址。另选可达NTP源。凭据只保存在本机sdkconfig。构建工具输出DIO启动头与QIO配置分别留证,不能据此手改成另一个未验证参数。NVS初始化失败只报错,不自动erase。

代码 / 配置
. /PATH/TO/esp-idf-v5.5.5/export.sh
idf.py --version
idf.py set-target esp32s3
idf.py menuconfig
idf.py build
idf.py -p /dev/cu.REPLACE_WITH_BRIDGE flash monitor
做到这里,你应该看到
串口有S10 START与SCAN/STATUS;看到SD或MQTT异常先按对应状态排查,不能把build成功当作板已运行。

参考:[1] 官方v1.1板卡:引脚、模块与单电源入口[3] Sensirion驱动1.1.0固定提交与BSD许可[6] IDF5.5.5 FAT VFS挂载和format_if_mount_failed

04 / 项目实战

CSV的时间和有效位,不靠看起来合理的数字

每行含version、journal、seq、uptime_ms、unix_s、time_valid、sample_valid、t_milli、rh_raw_milli和CRC32。journal为随机64位,seq连续从1开始,CRC为IEEE且覆盖最后逗号前的字节;身份不是密码学全球唯一保证。原始RH保留,OLED才夹到0–100%;无效采样写valid=0、两个数值为0,不偷用上一有效值。

只有本次启动已收到SNTP、UTC在2025–2099、且距最后同步不超过24小时才time_valid=1;否则unix_s=0。OLED的FRESH<=1小时/HOLDOVER<=24小时是应用策略,不承诺时钟精度。校时可能跳变,按journal+seq排序,uptime只在本次启动内单调;断网旧行不回填猜测的UTC。

采样下一期限是当前时间加60秒,I²C/SD和调度可能延迟;不是硬实时60秒。读取失败仍生成带无效位的行;缺卡/写失败则没有持久行,只增加本次启动的RAM LOST计数。

现象记录/显示不能推断
SNTP未成功/超24htime_valid=0 / unix_s=0 / UNKNOWN不能把系统默认日历当UTC
CRC/读取失败sample_valid=0 / SENSOR INVALID0不是实际温湿度
卡未挂载/写入停止NOT_RECORDED / LOST RAM屏幕有读数不代表已保存
MQTT离线CSV继续、ACK不前进QoS1不替代应用提交证明

参考:[3] Sensirion驱动1.1.0固定提交与BSD许可[7] IDF5.5.5系统时间与SNTP[12] IDF5.5.5 64位单调计时

05 / 项目实战

先提交数据库,再推进设备游标

设备把原CSV行发布到hixinwu/s10/<12hexMAC>/row,QoS1且retain=false,一个in-flight行,每10秒重试。接收机用Mosquitto2.0.20的-F %j接收转义JSON,检查topic、payloadlen、完整规范行、CRC和retain标记;SQLite事务COMMIT后才发/ack,内容journal,seq。设备只接完整非retained、当前journal和下一顺序的ACK。

主机主键(device,journal,seq)同内容重复只ACK,不重复插入;同身份不同内容拒绝并不ACK。断网、丢ACK或重启都可能重发;broker PUBACK或enqueue成功不等于接收机数据库已提交。SQLite FULL/WAL依赖真实磁盘与电源,软件顺序也不是介质抗掉电保证。

ACK每16条写有CRC的28字节NVS检查点。掉电可能重放最近行;检查点写失败报CP ERR,RAM仍前进,重启可能重放更多。若NVS点超过卡上有效末尾则停读写,要求人工对照服务器,不能偷偷倒退/重用序号。

参考:[9] IDF5.5.5 NVS blob与commit语义[10] Mosquitto2.0.20 %j转义JSON与payloadlen[11] Mosquitto2.0.20订阅输出格式和retain语义[12] IDF5.5.5 64位单调计时

06 / 项目实战

先跑本机服务夹具,再接隔离LAN

主机依赖Python3.12标准库和Mosquitto2.0.20;完整命令在README。broker-loopback.conf只绑定127.0.0.1:18889,不能供ESP32连接。真板实验另复制LAN配置,改实际私有网卡IP、固件URI、receiver地址并限制DUT/接收机来源;默认无认证/TLS,只用于隔离可信台架,不能暴露公网。

receiver.py先提交再ACK,导出使用独占新文件。retained历史投递会被拒绝,但实时retained发布可能由broker转为retain=false,不能把此检查当授权安全策略。没有离线未落盘样本的无限缓存。接收端唯一行与SD行的身份/CRC相符,才算这一条的应用送达证据。

代码 / 配置
mosquitto -c broker-loopback.conf
python3.12 host/receiver.py receive --db ./receiver.sqlite --host 127.0.0.1 --port 18889 --client-dir /PATH/TO/CLIENTS
python3.12 host/receiver.py export --db ./receiver.sqlite --output /NEW/receiver-export.csv

参考:[10] Mosquitto2.0.20 %j转义JSON与payloadlen[11] Mosquitto2.0.20订阅输出格式和retain语义

07 / 项目实战

卡满、坏尾与重启,恢复时保留原件

应用上限512KiB与真实ENOSPC/卡满分开。扫描只接受完整CRC、连续序号、同journal的前缀;坏尾停写。write/fflush/fsync/fclose全部API成功才推进应用序号,失败可能留下半行,也可能留下整行。不会自动格式化、删尾或覆盖旧记录。

先停机断电,备份原卡/CSV与接收端一致的SQLite/WAL/SHM;repair_csv.py只检查备份,指定--output则独占创建一个验证前缀的新副本,原始文件不改。若卡有效末尾比已确认ACK短,先用服务器备份对照,不删NVS掩盖问题。容量到顶时确认全部行已送达并导出,归档原room.csv后人工换空卡/移走原文件开始新journal;旧数据库继续保存。

缺卡或写失败仍可看当前传感器/串口,但LOST只有本次RAM计数,复位后消失。不能热拔卡;重新插卡须停电、检查、重新启动。新行真正追加且ACK增长后才恢复记录资格;不把数据库导出含device列的CSV直接放回设备。

代码 / 配置
python3.12 host/repair_csv.py /BACKUP/room.csv
python3.12 host/repair_csv.py /BACKUP/room.csv --output /NEW/PREFIX-room.csv

参考:[6] IDF5.5.5 FAT VFS挂载和format_if_mount_failed[9] IDF5.5.5 NVS blob与commit语义

08 / 项目实战

1440合成行通过,24小时现场仍要按表做

本轮真实IDF编译Exit0;生产C模型/日志层用ASan+UBSan测试CRC、时间过期、顺序ACK、检查点、1440合成行、重扫/seek、应用容量、ENOSPC/短写/flush/sync/close注入。实际本机Mosquitto+SQLite测试丢首ACK重发、receiver重启、冲突/NUL/坏CRC/retained历史拒绝;坏尾副本工具也验证不改原件且拒绝覆盖。这些不是芯片、SD、RF或24小时运行。

24小时空表在下载包:记录供电/卡/版本→正常一小时→断实验AP30分钟但保持供电→恢复核同journal/seq补传和接收端唯一行→计划RESET看重扫/NVS→只在备份做坏尾/应用满故障→安全恢复→满24小时比较CSV、唯一行、UTC有效率和丢失。真实卡满/断电介质实验另记,不能被主机注入替代。失败与恢复原日志保留,所有物理列pending。

代码 / 配置
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <inttypes.h>
#include <errno.h>
#include <time.h>
#include <stdatomic.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/event_groups.h"
#include "driver/spi_common.h"
#include "driver/sdspi_host.h"
#include "sdmmc_cmd.h"
#include "esp_vfs_fat.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_random.h"
#include "esp_mac.h"
#include "esp_netif_sntp.h"
#include "mqtt_client.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "sdkconfig.h"
#include "net.h"
#include "sensirion_i2c_hal.h"
#include "sensirion_hal_idf.h"
#include "sht4x_i2c.h"
#include "station_model.h"
#include "station_journal.h"
#include "station_display.h"
static const char *TAG="S10",*PATH="/sd/room.csv";
static QueueHandle_t acks;static EventGroupHandle_t mqtt_flags;
static char row_topic[64],ack_topic[64],availability_topic[64],client_id[32];static int subscription_id;
static atomic_bool time_synced=false;
static atomic_uint_fast64_t last_sync_ms=0;
static station_scan_t scan;static station_pump_t pump;
static bool storage_ready,write_stopped,read_stopped,nvs_ready,checkpoint_fault;
static long next_offset=-1,pending_end=-1;static char pending_line[STATION_LINE_MAX];
static uint64_t lost_samples=0;static unsigned uncheckpointed=0;
typedef struct{uint64_t journal,sequence;} app_ack_t;
static void sync_notice(struct timeval *time){(void)time;atomic_store(&last_sync_ms,(uint64_t)esp_timer_get_time()/1000ULL);atomic_store(&time_synced,true);}
static esp_err_t mount_sd(void){
 sdmmc_host_t host=SDSPI_HOST_DEFAULT();host.max_freq_khz=10000;
 spi_bus_config_t bus={.mosi_io_num=11,.miso_io_num=13,.sclk_io_num=12,.quadwp_io_num=-1,.quadhd_io_num=-1,.max_transfer_sz=4096};
 esp_err_t e=spi_bus_initialize(host.slot,&bus,SDSPI_DEFAULT_DMA);if(e!=ESP_OK)return e;
 sdspi_device_config_t dev=SDSPI_DEVICE_CONFIG_DEFAULT();dev.gpio_cs=10;dev.host_id=host.slot;
 esp_vfs_fat_sdmmc_mount_config_t cfg={.format_if_mount_failed=false,.max_files=4,.allocation_unit_size=16*1024};
 sdmmc_card_t *card=NULL;e=esp_vfs_fat_sdspi_mount("/sd",&host,&dev,&cfg,&card);if(e!=ESP_OK)spi_bus_free(host.slot);return e;
}
static void load_checkpoint(void){
 uint64_t journal=0,sequence=0;nvs_handle_t h;if(!nvs_ready||nvs_open("room_station",NVS_READONLY,&h)!=ESP_OK)return;
 uint8_t bytes[28];size_t size=sizeof(bytes);esp_err_t e=nvs_get_blob(h,"ack",bytes,&size);nvs_close(h);
 if(e!=ESP_OK||!station_checkpoint_decode(bytes,size,&journal,&sequence)||journal!=pump.journal)return;
 if(sequence>scan.last_sequence){read_stopped=true;write_stopped=true;ESP_LOGE(TAG,"CHECKPOINT_AHEAD: possible lost media tail; preserve card and database; no silent reset");return;}
 pump.acked=sequence;
}
static void checkpoint(void){
 if(!nvs_ready){checkpoint_fault=true;return;}uint8_t bytes[28];station_checkpoint_encode(pump.journal,pump.acked,bytes);
 nvs_handle_t h;esp_err_t e=nvs_open("room_station",NVS_READWRITE,&h);
 if(e==ESP_OK){e=nvs_set_blob(h,"ack",bytes,sizeof(bytes));if(e==ESP_OK)e=nvs_commit(h);nvs_close(h);}
 checkpoint_fault=e!=ESP_OK;if(!checkpoint_fault)uncheckpointed=0;
 ESP_LOGI(TAG,"CHECKPOINT ack=%"PRIu64" result=%s; a failed checkpoint may replay duplicates",pump.acked,esp_err_to_name(e));
}
static void inspect_media(void){
 FILE *file=fopen(PATH,"r");station_result_t result=STATION_OK;
 if(!file){if(errno!=ENOENT)result=STATION_IO_ERROR;}
 else{result=station_scan(file,&scan);if(fclose(file)!=0)result=STATION_IO_ERROR;}
 write_stopped=result!=STATION_OK;read_stopped=result==STATION_IO_ERROR;
 pump.journal=scan.journal;load_checkpoint();
 if(scan.good_bytes)next_offset=station_offset_after(PATH,pump.acked);
 ESP_LOGI(TAG,"SCAN result=%d validated_prefix_bytes=%u last_seq=%"PRIu64" ack=%"PRIu64"; no automatic truncate/format",result,(unsigned)scan.good_bytes,scan.last_sequence,pump.acked);
}
static void mqtt_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){subscription_id=esp_mqtt_client_subscribe(e->client,ack_topic,1);ESP_LOGI(TAG,"MQTT connected; waiting for ACK topic SUBACK");}
 else if(id==MQTT_EVENT_SUBSCRIBED&&e->msg_id==subscription_id){xEventGroupSetBits(mqtt_flags,BIT0);esp_mqtt_client_publish(e->client,availability_topic,"online",0,1,1);}
 else if(id==MQTT_EVENT_DISCONNECTED){xEventGroupClearBits(mqtt_flags,BIT0);}
 else if(id==MQTT_EVENT_DATA&&station_ack_frame(e->topic,e->topic_len,ack_topic,e->current_data_offset,e->total_data_len,e->data_len,e->retain)){
  app_ack_t ack;if(station_ack_decode(e->data,e->data_len,&ack.journal,&ack.sequence)&&xQueueSend(acks,&ack,0)!=pdTRUE)ESP_LOGW(TAG,"ACK queue full: retry/dedup will recover; no cursor advanced");
 }
}
static void pump_rows(esp_mqtt_client_handle_t client,uint64_t now){
 app_ack_t ack;
 while(xQueueReceive(acks,&ack,0)==pdTRUE)if(station_pump_ack(&pump,ack.journal,ack.sequence)){
  next_offset=pending_end;pending_line[0]=0;if(++uncheckpointed>=16&&!checkpoint_fault)checkpoint();
 }
 if(!storage_ready||read_stopped||pump.acked>=scan.last_sequence||!(xEventGroupGetBits(mqtt_flags)&BIT0)||!station_pump_due(&pump,now))return;
 if(!pump.waiting){
  if(next_offset<0)next_offset=station_offset_after(PATH,pump.acked);
  if(next_offset<0||!station_next(PATH,next_offset,pending_line,&pending_end)){read_stopped=true;ESP_LOGE(TAG,"BACKFILL_READ: preserve card; no cursor advanced");return;}
  station_record_t row;if(!station_record_decode(pending_line,&row)||row.journal!=pump.journal||row.sequence!=pump.acked+1){read_stopped=true;return;}
 }
 station_pump_attempt(&pump,pump.acked+1,now);
 int queued=esp_mqtt_client_enqueue(client,row_topic,pending_line,0,1,0,true);
 ESP_LOGI(TAG,"BACKFILL seq=%"PRIu64" queued=%d; broker QoS1 is not receiver database ACK",pump.inflight,queued);
}
void app_main(void){
 esp_err_t e=nvs_flash_init();nvs_ready=e==ESP_OK;if(!nvs_ready)ESP_LOGW(TAG,"NVS init=%s; no automatic erase",esp_err_to_name(e));
 e=mount_sd();storage_ready=e==ESP_OK;if(storage_ready)inspect_media();else ESP_LOGE(TAG,"SD mount=%s; sample/display continue without durable rows",esp_err_to_name(e));
 sensirion_i2c_hal_init();sht4x_init(0x44);bool screen=station_display_init();
 if(!screen)ESP_LOGW(TAG,"OLED init path failed; serial/SD continue, no pixel claim");
 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(client_id,sizeof(client_id),"hixinwu-s10-%s",device);snprintf(row_topic,sizeof(row_topic),"hixinwu/s10/%s/row",device);snprintf(ack_topic,sizeof(ack_topic),"hixinwu/s10/%s/ack",device);snprintf(availability_topic,sizeof(availability_topic),"hixinwu/s10/%s/availability",device);
 acks=xQueueCreate(8,sizeof(app_ack_t));mqtt_flags=xEventGroupCreate();if(!acks||!mqtt_flags){ESP_LOGE(TAG,"queue/event allocation failed");return;}
 net_start();
 esp_mqtt_client_config_t cfg={.broker.address.uri=CONFIG_DEMO_BROKER_URL,.credentials.client_id=client_id,.session.last_will={.topic=availability_topic,.msg="offline",.qos=1,.retain=1},.network={.timeout_ms=3000,.reconnect_timeout_ms=3000},.outbox.limit=2048};
 esp_mqtt_client_handle_t client=esp_mqtt_client_init(&cfg);if(!client){ESP_LOGE(TAG,"MQTT init failed");return;}ESP_ERROR_CHECK(esp_mqtt_client_register_event(client,ESP_EVENT_ANY_ID,mqtt_event,NULL));
 bool mqtt_started=false,sntp_started=false,last_valid=false;uint64_t sample_at=0,display_at=0,last_good=0;int32_t last_t=0,last_rh=0;unsigned failures=0;
 ESP_LOGI(TAG,"S10 START device=%s; SD512KiB cap, sample60s, full64 time; no physical/24h run claimed",device);
 for(;;){
  uint64_t now=(uint64_t)esp_timer_get_time()/1000ULL;
  if(net_online()&&!mqtt_started){e=esp_mqtt_client_start(client);mqtt_started=e==ESP_OK;}
  if(net_online()&&!sntp_started){esp_sntp_config_t time_cfg=ESP_NETIF_SNTP_DEFAULT_CONFIG(CONFIG_STATION_NTP_SERVER);time_cfg.sync_cb=sync_notice;time_cfg.wait_for_sync=false;e=esp_netif_sntp_init(&time_cfg);sntp_started=e==ESP_OK;}
  if(now>=sample_at){
   sample_at=now+60000;int32_t t=0,rh=0;int16_t error=demo_i2c_ready()?sht4x_measure_high_precision(&t,&rh):-1;last_valid=error==0;
   if(last_valid){last_t=t;last_rh=rh;last_good=now;failures=0;}else{if(++failures%3==0&&demo_i2c_ready())sht4x_soft_reset();ESP_LOGW(TAG,"SHT40 error=%d; invalid row fields, no old value as current",error);}
   station_record_t row={.journal=pump.journal,.sequence=scan.last_sequence+1,.uptime=(uint64_t)esp_timer_get_time()/1000ULL,.unix_s=(int64_t)time(NULL),.sample_valid=last_valid,.t_milli=last_valid?t:0,.rh_milli=last_valid?rh:0};
   row.time_valid=station_clock_recent(atomic_load(&time_synced),atomic_load(&last_sync_ms),row.uptime,row.unix_s);if(!row.time_valid)row.unix_s=0;
   if(!row.journal){row.journal=((uint64_t)esp_random()<<32)|esp_random();if(!row.journal)row.journal=1;}
   char line[STATION_LINE_MAX];station_result_t result=STATION_IO_ERROR;
   if(storage_ready&&!write_stopped&&scan.last_sequence<UINT64_MAX&&station_record_encode(&row,line,sizeof(line)))result=station_append(PATH,&scan,line,&station_default_io);
   if(result==STATION_OK){if(!scan.good_bytes)scan.good_bytes=strlen(STATION_CSV_HEADER);scan.good_bytes+=strlen(line);scan.last_sequence=row.sequence;scan.journal=pump.journal=row.journal;ESP_LOGI(TAG,"APPEND_API_SUCCESS %s",line);}
   else{++lost_samples;if(storage_ready)write_stopped=true;ESP_LOGW(TAG,"NOT_RECORDED result=%d lost_this_boot=%"PRIu64"; cap and true I/O errors are separate",result,lost_samples);}
  }
  if(mqtt_started)pump_rows(client,(uint64_t)esp_timer_get_time()/1000ULL);
  now=(uint64_t)esp_timer_get_time()/1000ULL;
  if(now>=display_at){
   display_at=now+1000;char text[8][33];const char *lines[8];for(unsigned i=0;i<8;i++)lines[i]=text[i];
   snprintf(text[0],33,"S10 ROOM STATION");
   if(last_valid&&now>=last_good&&now-last_good<=70000)snprintf(text[1],33,"T %.1f H %.1f",last_t/1000.0,(last_rh<0?0:last_rh>100000?100000:last_rh)/1000.0);else snprintf(text[1],33,"SENSOR INVALID");
   time_t utc=time(NULL);uint64_t sync_at=atomic_load(&last_sync_ms);bool clock_ok=station_clock_recent(atomic_load(&time_synced),sync_at,now,utc);snprintf(text[2],33,"UTC %s",clock_ok?(now-sync_at<=3600000ULL?"FRESH":"HOLDOVER"):"UNKNOWN");
   snprintf(text[3],33,"SD %s S %"PRIu64,!storage_ready?"MISSING":write_stopped?"STOP":"OK",scan.last_sequence);
   snprintf(text[4],33,"MQTT %s",xEventGroupGetBits(mqtt_flags)&BIT0?"ACK READY":"OFFLINE");
   snprintf(text[5],33,"ACK %"PRIu64,pump.acked);snprintf(text[6],33,"LOST %"PRIu64" RAM",lost_samples);snprintf(text[7],33,"CP %s READ %s",checkpoint_fault?"ERR":"OK",read_stopped?"STOP":"OK");
   if(screen&&!station_display_lines(lines))ESP_LOGW(TAG,"OLED transaction failed; pixel state unverified");
   ESP_LOGI(TAG,"STATUS seq=%"PRIu64" ack=%"PRIu64" clock=%s SD=%u write_stop=%u read_stop=%u lost=%"PRIu64,scan.last_sequence,pump.acked,text[2],storage_ready,write_stopped,read_stopped,lost_samples);
  }
  vTaskDelay(pdMS_TO_TICKS(100));
 }
}
做到这里,你应该看到
分别给出源/构建/主机与实板证据,不把合成1440行改称24小时通过。

参考:[3] Sensirion驱动1.1.0固定提交与BSD许可[6] IDF5.5.5 FAT VFS挂载和format_if_mount_failed[7] IDF5.5.5系统时间与SNTP[9] IDF5.5.5 NVS blob与commit语义[10] Mosquitto2.0.20 %j转义JSON与payloadlen[12] IDF5.5.5 64位单调计时

BACK TO THE SOURCE

资料来源与版本说明

已实际阅读文末固定版本的关键实现与指定厂商资料,来源/构建/主机夹具分别留证。具体板卡、固件和依赖范围见版本记录;指定实板、HA与现场结果尚未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 官方v1.1板卡:引脚、模块与单电源入口
  2. SHT40 PID4885供电与引脚
  3. Sensirion驱动1.1.0固定提交与BSD许可
  4. Adafruit PID326 2020复位与默认I2C地址
  5. Adafruit PID254 microSD供电、电平与SPI接线
  6. IDF5.5.5 FAT VFS挂载和format_if_mount_failed
  7. IDF5.5.5系统时间与SNTP
  8. IDF5.5.5 SSD1306驱动页缓冲/镜像/初始化
  9. IDF5.5.5 NVS blob与commit语义
  10. Mosquitto2.0.20 %j转义JSON与payloadlen
  11. Mosquitto2.0.20订阅输出格式和retain语义
  12. IDF5.5.5 64位单调计时