无线与 USB 拓展 / 多节点网关发布 内容更新

多个无线传感器汇总到 MQTT:做一个 ESP-NOW 网关

三块固定S3板、两个SHT40节点与自有MQTT接收端;让MAC/boot/seq贯穿重试、独立缓存与SQLite应用ACK,明确失联和broker恢复的边界。

读完能做什么
得到网关/节点A/节点B三个可独立配置角色和真实SQLite接收器,能按同一端到端样本键验收两节点并发、重复、失联和broker恢复。
开始之前
会读实际STA MAC、构建IDF、操作自有AP与LAN broker;先完成W02/W03单链路与N07。
所需器材
三块官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8、三条桥口USB线、两块Adafruit SHT40 PID4885,排针已可靠焊接、短线、自有固定信道6/20MHz的2.4GHz WPA2 AP、可互访LAN电脑、Mosquitto2.0.20
版本组合
ESP-IDF:5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4fSDK ESP-MQTT:0e9ec171b448db2101ea537300976b7ffe595941主机:Python3.12.13 / paho-mqtt1.6.1 / Mosquitto2.0.20传感器依据:SHT4x datasheet V7.3 June2026;Adafruit PID4885
适用范围

三块官方S3 DevKitC-1 v1.1 N8R8,IDF5.5.5;8MiB DIO80、PSRAM关闭、桥口UART115200。节点SHT40 SDA8/SCL9、0x44/100kHz;固定信道6可信隔离实验台,未加密ESP-NOW与明文LAN MQTT,不作生产安全系统。

这篇验证到了哪一步

资料核对已通过
固定主源、组件映射与安装输入逐字核对;docs/esp32-evidence/cloud-gateway-sources.json版本:ESP-IDF5.5.5验证日期:
构建 / 配置已通过
w04-default、w04-gateway、w04-node-a、w04-node-b:实际原生构建,完整命令/日志/输入与产品SHA见cloud-gateway-builds.json;不等于设备运行。对象:esp32s3版本:5.5.5验证日期:
仿真已通过
生产模型14组ASan/UBSan、跨语言SQLite9组、本机真实broker/接收器9组;样本全合成;仅同源主机逻辑/本机服务,不是ESP32电气或射频仿真。验证日期:
本站实板待验证
没有实板、射频、传感器、手机或公共云运行记录;观察CSV只有表头。

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

01 / 多节点网关

从一条无线链路走到两来源的可核对数据

W03处理AP改信道后的发现,本篇保留端到端样本身份,解决两个传感器来源的独立缓存、去重、应用确认与故障恢复。三个角色共用一份原生工程:ROLE0网关,ROLE1节点A,ROLE2节点B;两个节点各接真实SHT40的执行步骤完整给出,本站尚无实体结果。

为集中验收数据链,自己的AP固定2.4GHz信道6/20MHz,不运行自动发现。网关STA关联后由AP决定信道,不手动set_channel;节点不连AP、按实际国家许可范围设置6,peer.channel=0沿本机当前信道。当地不允许时停止,不改国家设置绕过限制。

做到这里,你应该看到
任务单写明三块实际板、两个独立SHT、同一受控AP与自己的LAN接收端。

参考:[4] IDF5.5.5 ESP-NOW精确API/回调参数[5] IDF5.5.5 ESP-NOW:信道、回调与应用ACK边界[8] 固定Wi-Fi API:channel/country/STA

02 / 多节点网关

三板各自供电、两条独立 I2C

固定官方DevKitC-1 v1.1 / WROOM-1-N8R8,8MiB DIO80、PSRAM软件关闭、桥口UART115200。三板分别只接USB-to-UART单路供电,不跨板连接3V3。每个PID4885只接本节点,网关不接I2C或LED。

使用已可靠焊接排针的PID4885;未焊先由有条件者完成,不能松插当连接。断电按下表接短线。模块VIN供3.3V,0x44和板载10k上拉对应本例,内部上拉关闭;不要用另一款裸传感器的供电范围代替模块说明。

PID4885模块节点A/B各自板端程序
VIN/GND3V3 J1-1或2 / GND J1-22同一节点供电和逻辑地
SDAGPIO8/J1-12I2C0 SDA
SCLGPIO9/J1-15I2C0 SCL,100kHz
做到这里,你应该看到
每块SHT只连自己的板;GPIO19/20和43/44未占用。

参考:[1] DevKitC-1 v1.1 固定用户指南:J1与供电选项[2] Adafruit PID4885引脚:VIN/0x44/电平/10k上拉[3] Sensirion SHT4x V7.3/2026-06,第10–13页:时序、CRC、命令、换算

03 / 多节点网关

先抄真实 MAC,再建立三个配置

默认三个MAC全零,程序先打印本板ACTUAL_STA,随后CONFIG_CLOSED,不初始化无线应用。先分别刷默认配置读取三块板的STA地址;SoftAP或BLE地址不能替代。

为网关/节点A/节点B分别用新的树外B目录和SDKCONFIG,在menuconfig填相同的三实际STA MAC、小写冒号格式、互不相同,并选本机正确ROLE。只有网关填自己的SSID、8..63字节WPA2密码和LAN mqtt://IP:1883。源码把当前板MAC与配置角色比对,错角色会关闭应用。公开编译配置的02:...与192.0.2.1仅为合成链接分支,不是可以刷实体的地址。

在工程根执行python "$IDF_PATH/tools/idf.py" -B <自己的树外目录> -D IDF_TARGET=esp32s3 -D SDKCONFIG=<该目录/sdkconfig> menuconfig,再同参数build。核板/串口与备份后才flash monitor;不在填完私有配置后重新set-target。完整命令及恢复边界在下载README。

做到这里,你应该看到
三板均能打印与自己ROLE相符的地址;私有sdkconfig不进入下载或Git。
如果卡住了
CONFIG_CLOSED先查角色/三MAC/本机地址/网关字段,不用重试无线掩盖配置错。NVS失败停启动,不自动擦除。

参考:[1] DevKitC-1 v1.1 固定用户指南:J1与供电选项[4] IDF5.5.5 ESP-NOW精确API/回调参数[8] 固定Wi-Fi API:channel/country/STA

04 / 多节点网关

一次真实测量尝试对应一个序号

节点每5秒计划一次SHT40 0xFD高重复性测量,等待20ms再读6字节;两个I2C调用各100ms超时。固定手册最大测量时间8.3ms是器件条件,不是本站总循环耗时,20ms是代码等待选择。温度和湿度两字各验CRC8,初值FF、多项式31;按手册换算为m°C/m%并裁RH到0..100%。

温度超工作范围或API/CRC失败,仍产生本次seq的valid=0、温湿度清零记录,不复用上一值。seq只按实际尝试推进;错过的5秒计划另报skipped_plan,不补造旧时间样本。seq耗尽停止新采样,不回绕;已经排队的样本仍可重试。

做到这里,你应该看到
传感器失效与无线掉线分开计数;0/0只有valid=0时表示无效,不能当作真实0℃/0%RH。

参考:[3] Sensirion SHT4x V7.3/2026-06,第10–13页:时序、CRC、命令、换算[7] 固定I2C master API与超时

05 / 多节点网关

44字节身份贯穿到数据库,不换一个网关序号

帧用显式大端编码:W4/version/kind/valid/reserved、node MAC6、非零boot64、seq32、节点uptime_ms64、温度32、湿度32、CRC32。不是直接发送C结构体。MQTT是完整44字节的88个小写hex字符,无换行或JSON再包装;应用ACK回发原样DATA文本。

数据库主键(node,boot,seq)区分两个相同seq的来源。boot为每次启动的非零64位随机量,碰撞不能数学排除,不是持久计数器或认证凭据。节点uptime不是UTC,不能与电脑墙钟相减估单向时延。CRC和MAC白名单只查错/筛选,不防伪造;无线encrypt=false,限定自己的隔离可信实验台。

做到这里,你应该看到
同一真实样本在节点、网关、接收器与ACK中保留相同node/boot/seq及内容。

参考:[4] IDF5.5.5 ESP-NOW精确API/回调参数[5] IDF5.5.5 ESP-NOW:信道、回调与应用ACK边界[6] 实际SDK ESP-MQTT固定submodule:分片、enqueue与outbox

06 / 多节点网关

生产同源模型:有界缓存和完整匹配 ACK

节点RAM4条、网关每节点独立RAM4条,满时保旧丢新并累加drop。只传队列头;同身份同数据的pending重复不占第二格,同身份不同内容是冲突。只有完整匹配头样本的应用ACK能出队,后来的ACK不能越过未确认的旧头。

ACK可能分片;第一片必须带正确topic、总长88,后续只接受准确offset/长度。错topic、retained、断线旧尾或不规范hex重置拼装。IP状态也用同一短锁取得online/lost快照;lost后同轮已GOT_IP不主动拆刚恢复的关联,快照之后的新事件由下一轮处理。

代码 / 配置
// ---- main/gateway_model.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#define W4_BYTES 44
#define W4_HEX_BYTES 88
#define W4_CAPACITY 4
#define W4_DATA 1
#define W4_ACK 2
typedef struct {uint8_t node[6];uint64_t boot,mono_ms;uint32_t seq;int32_t t_milli;uint32_t rh_milli;bool valid;} W4Sample;
typedef struct {W4Sample rows[W4_CAPACITY];unsigned head,count;uint64_t dropped;} W4Queue;
typedef enum {W4_GOT_IP,W4_LOST_IP,W4_DISCONNECTED} W4LinkEvent;
typedef struct {bool online,lost_ip;} W4Link;
/* Event loop and owner call these only while holding the same short lock. */
static inline void w4_link_notice(W4Link*s,W4LinkEvent event){s->online=event==W4_GOT_IP;if(event==W4_LOST_IP)s->lost_ip=true;}
static inline W4Link w4_link_take(W4Link*s){W4Link out=*s;s->lost_ip=false;return out;}
static inline bool w4_link_needs_disconnect(W4Link s){return s.lost_ip&&!s.online;}

static inline bool w4_recent(bool seen,uint64_t callback_at,uint64_t now){return seen&&now>=callback_at&&now-callback_at<=15000;}
static inline uint32_t w4_crc(const uint8_t*p,size_t n){uint32_t c=UINT32_MAX;for(size_t i=0;i<n;i++){c^=p[i];for(unsigned j=0;j<8;j++)c=(c>>1)^((c&1)?UINT32_C(0xedb88320):0);}return ~c;}
static inline void w4_put(uint8_t*p,uint64_t v,unsigned n){for(unsigned i=0;i<n;i++)p[i]=(uint8_t)(v>>(8*(n-i-1)));}
static inline uint64_t w4_get(const uint8_t*p,unsigned n){uint64_t v=0;for(unsigned i=0;i<n;i++)v=(v<<8)|p[i];return v;}
static inline bool w4_mac_valid(const uint8_t m[6]){unsigned sum=0;for(unsigned i=0;i<6;i++)sum|=m[i];return sum&&!(m[0]&1);}
static inline bool w4_sample_valid(const W4Sample*s){return w4_mac_valid(s->node)&&s->boot&&s->seq&&s->mono_ms<=INT64_MAX&&(s->valid?(s->t_milli>=-40000&&s->t_milli<=125000&&s->rh_milli<=100000):(s->t_milli==0&&s->rh_milli==0));}
static inline void w4_encode(const W4Sample*s,uint8_t kind,uint8_t b[W4_BYTES]){memset(b,0,W4_BYTES);b[0]='W';b[1]='4';b[2]=1;b[3]=kind;b[4]=s->valid;memcpy(b+6,s->node,6);w4_put(b+12,s->boot,8);w4_put(b+20,s->seq,4);w4_put(b+24,s->mono_ms,8);w4_put(b+32,(uint32_t)s->t_milli,4);w4_put(b+36,s->rh_milli,4);w4_put(b+40,w4_crc(b,40),4);}
static inline bool w4_decode(const uint8_t*b,size_t n,W4Sample*out,uint8_t*kind){
 if(!b||!out||!kind||n!=W4_BYTES||b[0]!='W'||b[1]!='4'||b[2]!=1||(b[3]!=W4_DATA&&b[3]!=W4_ACK)||b[4]>1||b[5]||w4_get(b+40,4)!=w4_crc(b,40))return false;
 W4Sample s={.boot=w4_get(b+12,8),.seq=(uint32_t)w4_get(b+20,4),.mono_ms=w4_get(b+24,8),.rh_milli=(uint32_t)w4_get(b+36,4),.valid=b[4]};memcpy(s.node,b+6,6);int64_t t=(int64_t)w4_get(b+32,4);if(t>INT32_MAX)t-=INT64_C(4294967296);s.t_milli=(int32_t)t;if(!w4_sample_valid(&s))return false;*out=s;*kind=b[3];return true;
}
static inline bool w4_same_id(const W4Sample*a,const W4Sample*b){return !memcmp(a->node,b->node,6)&&a->boot==b->boot&&a->seq==b->seq;}
static inline bool w4_equal(const W4Sample*a,const W4Sample*b){return w4_same_id(a,b)&&a->mono_ms==b->mono_ms&&a->valid==b->valid&&a->t_milli==b->t_milli&&a->rh_milli==b->rh_milli;}
/* 1=new, 2=identical pending duplicate, -1=conflicting identity, 0=full/drop-new. */
static inline int w4_push(W4Queue*q,W4Sample s){for(unsigned i=0;i<q->count;i++){W4Sample*old=&q->rows[(q->head+i)%W4_CAPACITY];if(w4_same_id(old,&s))return w4_equal(old,&s)?2:-1;}if(q->count==W4_CAPACITY){q->dropped++;return 0;}q->rows[(q->head+q->count)%W4_CAPACITY]=s;q->count++;return 1;}
static inline const W4Sample*w4_head(const W4Queue*q){return q->count?&q->rows[q->head]:NULL;}
static inline bool w4_pop_ack(W4Queue*q,const W4Sample*ack){const W4Sample*h=w4_head(q);if(!h||!w4_equal(h,ack))return false;q->head=(q->head+1)%W4_CAPACITY;q->count--;return true;}
static inline void w4_hex(const uint8_t b[W4_BYTES],char out[W4_HEX_BYTES+1]){const char*h="0123456789abcdef";for(unsigned i=0;i<W4_BYTES;i++){out[2*i]=h[b[i]>>4];out[2*i+1]=h[b[i]&15];}out[W4_HEX_BYTES]=0;}
static inline int w4_nibble(char c){return c>='0'&&c<='9'?c-'0':c>='a'&&c<='f'?c-'a'+10:-1;}
static inline bool w4_unhex(const char*text,size_t n,uint8_t out[W4_BYTES]){if(n!=W4_HEX_BYTES)return false;for(unsigned i=0;i<W4_BYTES;i++){int a=w4_nibble(text[2*i]),b=w4_nibble(text[2*i+1]);if(a<0||b<0)return false;out[i]=(uint8_t)(a*16+b);}return true;}
typedef struct {char text[W4_HEX_BYTES];unsigned used;bool active;} W4Assembly;
/* -1=discard, 0=bounded partial, 1=complete. First piece must name the exact
   topic; continuations may omit topic. Reset state on MQTT disconnect too. */
static inline int w4_ack_piece(W4Assembly*a,int offset,int total,const char*data,int len,bool topic_present,bool topic_right,bool retained,uint8_t out[W4_BYTES]){
 if(offset==0){a->active=false;a->used=0;}
 if(retained||offset<0||total!=W4_HEX_BYTES||len<=0||!data||offset>W4_HEX_BYTES||len>W4_HEX_BYTES-offset||(topic_present&&!topic_right)||(offset==0&&(!topic_present||!topic_right))){a->active=false;a->used=0;return -1;}
 if(offset==0)a->active=true;
 if(!a->active||(unsigned)offset!=a->used){a->active=false;a->used=0;return -1;}
 memcpy(a->text+a->used,data,(size_t)len);a->used+=(unsigned)len;
 if(a->used<W4_HEX_BYTES)return 0;
 a->active=false;a->used=0;return w4_unhex(a->text,W4_HEX_BYTES,out)?1:-1;
}
static inline uint8_t w4_sht_crc(const uint8_t*p){uint8_t c=0xff;for(unsigned i=0;i<2;i++){c^=p[i];for(unsigned j=0;j<8;j++)c=(uint8_t)((c<<1)^((c&0x80)?0x31:0));}return c;}
static inline bool w4_sht_decode(const uint8_t b[6],int32_t*t,uint32_t*rh){if(w4_sht_crc(b)!=b[2]||w4_sht_crc(b+3)!=b[5])return false;int64_t tv=-45000+(INT64_C(175000)*w4_get(b,2)+32767)/65535;int64_t hv=-6000+(INT64_C(125000)*w4_get(b+3,2)+32767)/65535;if(tv<-40000||tv>125000)return false;if(hv<0)hv=0;if(hv>100000)hv=100000;*t=(int32_t)tv;*rh=(uint32_t)hv;return true;}
做到这里,你应该看到
主机14组只证明该生产模型逻辑,不能当作底层Wi-Fi或MQTT任务仿真。

参考:[4] IDF5.5.5 ESP-NOW精确API/回调参数[6] 实际SDK ESP-MQTT固定submodule:分片、enqueue与outbox

07 / 多节点网关

完整三角色主程序:回调只复制,owner处理业务

ESP-NOW接收与发送回调只投递到16项FIFO,满时丢新计callback_drops;传感器、日志、队列、MQTT提交和RF发送均在owner执行。无线一次仅一笔在途,2秒仍没被owner观察到发送回调便停发到手动RESET,保留“不确定是否已到达”的事实。

网关每来源只发布队列头,每2秒一次请求,SDK outbox上限4096B。SQLite应用ACK到达后网关出队、保待发无线ACK槽,节点再据完整匹配ACK出队。ACK丢失时重发同ID,接收器可再次确认;没有任何地方用MAC_CALLBACK或MQTT message_id代替业务落库。

网关15秒CALLBACK_TRAFFIC_RECENT/STALE只表明有效DATA回调近期出现;旧头重试也算通信流量,不等于新采样、新鲜数据或物理在场。所有年龄用64位回调时间,不在完整32位周期后复活旧状态。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_netif.h"
#include "esp_now.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#include "mqtt_client.h"
#include "driver/i2c_master.h"
#include "sdkconfig.h"
#include "gateway_model.h"

#define CHANNEL 6
static const char *TAG="W04";
static const bool gateway=CONFIG_W04_ROLE==0;
static uint8_t addresses[3][6];
static atomic_bool mqtt_online;
static portMUX_TYPE link_lock=portMUX_INITIALIZER_UNLOCKED;
static W4Link link_state;
static atomic_uint_fast32_t callback_drops;
static esp_mqtt_client_handle_t mqtt;
static char data_topic[64],ack_topic[64];
static W4Assembly assembly;
typedef enum {RADIO_RX,RADIO_TX,MQTT_ACK} EventKind;
typedef struct {EventKind kind;uint8_t mac[6],bytes[W4_BYTES],channel;int status;uint64_t at_ms;} Event;
static QueueHandle_t events;
static i2c_master_bus_handle_t bus;
static i2c_master_dev_handle_t sht;
static uint64_t ms(void){return (uint64_t)esp_timer_get_time()/1000;}
static void require(esp_err_t e,const char*operation){if(e!=ESP_OK){ESP_LOGE(TAG,"%s=%s",operation,esp_err_to_name(e));abort();}}
static bool parse_mac(const char*text,uint8_t out[6]){
    if(strlen(text)!=17)return false;
    for(unsigned i=0;i<6;i++){
        int a=w4_nibble(text[3*i]),b=w4_nibble(text[3*i+1]);
        if(a<0||b<0||(i<5&&text[3*i+2]!=':'))return false;
        out[i]=(uint8_t)(a*16+b);
    }
    return w4_mac_valid(out);
}
static void mac_text(const uint8_t m[6],char out[13]){snprintf(out,13,"%02x%02x%02x%02x%02x%02x",m[0],m[1],m[2],m[3],m[4],m[5]);}
static bool configuration(const uint8_t own[6]){
    if(!parse_mac(CONFIG_W04_GATEWAY_MAC,addresses[0])||!parse_mac(CONFIG_W04_NODE_A_MAC,addresses[1])||!parse_mac(CONFIG_W04_NODE_B_MAC,addresses[2]))return false;
    if(!memcmp(addresses[0],addresses[1],6)||!memcmp(addresses[0],addresses[2],6)||!memcmp(addresses[1],addresses[2],6)||memcmp(own,addresses[CONFIG_W04_ROLE],6))return false;
    if(gateway&&(strstr(CONFIG_W04_SSID,"REPLACE_")||strstr(CONFIG_W04_PASSWORD,"REPLACE_")||strstr(CONFIG_W04_BROKER,"REPLACE_")||!strlen(CONFIG_W04_SSID)||strlen(CONFIG_W04_SSID)>32||strlen(CONFIG_W04_PASSWORD)<8||strlen(CONFIG_W04_PASSWORD)>63||strncmp(CONFIG_W04_BROKER,"mqtt://",7)))return false;
    return true;
}
static void post(const Event*e){if(xQueueSend(events,e,0)!=pdTRUE)atomic_fetch_add(&callback_drops,1);}
static void link_notice(W4LinkEvent event){portENTER_CRITICAL(&link_lock);w4_link_notice(&link_state,event);portEXIT_CRITICAL(&link_lock);}
static W4Link link_take(void){portENTER_CRITICAL(&link_lock);W4Link out=w4_link_take(&link_state);portEXIT_CRITICAL(&link_lock);return out;}
static void wifi_event(void*unused,esp_event_base_t base,int32_t id,void*data){
    (void)unused;
    if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP){link_notice(W4_GOT_IP);ESP_LOGI(TAG,"GOT_IP: not MQTT/app delivery");}
    else if(base==IP_EVENT&&id==IP_EVENT_STA_LOST_IP){link_notice(W4_LOST_IP);}
    else if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){link_notice(W4_DISCONNECTED);const wifi_event_sta_disconnected_t*d=data;ESP_LOGW(TAG,"AP_DISCONNECTED reason=%u",d->reason);}
}
static void recv_cb(const esp_now_recv_info_t*i,const uint8_t*data,int n){
    if(!i||!i->src_addr||!i->rx_ctrl||!data||n!=W4_BYTES)return;
    bool known=gateway?(!memcmp(i->src_addr,addresses[1],6)||!memcmp(i->src_addr,addresses[2],6)):!memcmp(i->src_addr,addresses[0],6);
    if(!known)return;
    Event e={.kind=RADIO_RX,.channel=i->rx_ctrl->channel,.at_ms=ms()};memcpy(e.mac,i->src_addr,6);memcpy(e.bytes,data,W4_BYTES);post(&e);
}
static void sent_cb(const esp_now_send_info_t*i,esp_now_send_status_t status){if(i&&i->des_addr){Event e={.kind=RADIO_TX,.status=status};memcpy(e.mac,i->des_addr,6);post(&e);}}
static void mqtt_event(void*unused,esp_event_base_t base,int32_t id,void*data){
    (void)unused;(void)base;esp_mqtt_event_handle_t e=data;
    if(id==MQTT_EVENT_CONNECTED){atomic_store(&mqtt_online,true);int mid=esp_mqtt_client_subscribe(mqtt,ack_topic,1);ESP_LOGI(TAG,"MQTT_CONNECTED subscribe_request=%d; validate actual ACK separately",mid);}
    else if(id==MQTT_EVENT_DISCONNECTED){atomic_store(&mqtt_online,false);assembly=(W4Assembly){0};}
    else if(id==MQTT_EVENT_DATA){
        Event event={.kind=MQTT_ACK};bool present=e->topic_len>0;bool right=present&&e->topic_len==(int)strlen(ack_topic)&&!memcmp(e->topic,ack_topic,(size_t)e->topic_len);
        if(w4_ack_piece(&assembly,e->current_data_offset,e->total_data_len,e->data,e->data_len,present,right,e->retain,event.bytes)==1)post(&event);
    }
}
static void start_network(void){
    require(esp_netif_init(),"netif_init");require(esp_event_loop_create_default(),"event_loop");
    if(gateway){if(!esp_netif_create_default_wifi_sta())abort();require(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL),"wifi_handler");require(esp_event_handler_register(IP_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL),"ip_handler");}
    wifi_init_config_t init=WIFI_INIT_CONFIG_DEFAULT();require(esp_wifi_init(&init),"wifi_init");require(esp_wifi_set_storage(WIFI_STORAGE_RAM),"wifi_RAM");require(esp_wifi_set_mode(WIFI_MODE_STA),"wifi_STA");
    if(gateway){wifi_config_t cfg={0};memcpy(cfg.sta.ssid,CONFIG_W04_SSID,strlen(CONFIG_W04_SSID));memcpy(cfg.sta.password,CONFIG_W04_PASSWORD,strlen(CONFIG_W04_PASSWORD));cfg.sta.threshold.authmode=WIFI_AUTH_WPA2_PSK;require(esp_wifi_set_config(WIFI_IF_STA,&cfg),"wifi_config");}
    require(esp_wifi_start(),"wifi_start");require(esp_wifi_set_ps(WIFI_PS_NONE),"wifi_PS_NONE");
    if(!gateway){wifi_country_t country;require(esp_wifi_get_country(&country),"country");if(CHANNEL<country.schan||CHANNEL>=country.schan+country.nchan){ESP_LOGE(TAG,"channel6 outside actual country range; stopped");abort();}require(esp_wifi_set_channel(CHANNEL,WIFI_SECOND_CHAN_NONE),"node_channel");}
    require(esp_now_init(),"espnow_init");require(esp_now_register_recv_cb(recv_cb),"espnow_recv");require(esp_now_register_send_cb(sent_cb),"espnow_sent");
    for(unsigned i=gateway?1:0;i<(gateway?3:1);i++){esp_now_peer_info_t peer={.channel=0,.ifidx=WIFI_IF_STA,.encrypt=false};memcpy(peer.peer_addr,addresses[i],6);require(esp_now_add_peer(&peer),"add_peer");}
    if(gateway){
        esp_mqtt_client_config_t cfg={.broker.address.uri=CONFIG_W04_BROKER,.network.reconnect_timeout_ms=2000,.outbox.limit=4096};mqtt=esp_mqtt_client_init(&cfg);if(!mqtt)abort();require(esp_mqtt_client_register_event(mqtt,ESP_EVENT_ANY_ID,mqtt_event,NULL),"mqtt_event");require(esp_mqtt_client_start(mqtt),"mqtt_start");
    }
}
static void start_sensor(void){
    i2c_master_bus_config_t cfg={.i2c_port=I2C_NUM_0,.sda_io_num=8,.scl_io_num=9,.clk_source=I2C_CLK_SRC_DEFAULT,.glitch_ignore_cnt=7,.flags.enable_internal_pullup=false};require(i2c_new_master_bus(&cfg,&bus),"i2c_bus");i2c_device_config_t dev={.dev_addr_length=I2C_ADDR_BIT_LEN_7,.device_address=0x44,.scl_speed_hz=100000};require(i2c_master_bus_add_device(bus,&dev,&sht),"SHT40_device");
}
static W4Sample sample(const uint8_t own[6],uint64_t boot,uint32_t seq){
    W4Sample s={.boot=boot,.seq=seq,.mono_ms=ms()};memcpy(s.node,own,6);const uint8_t cmd=0xfd;uint8_t raw[6];esp_err_t e=i2c_master_transmit(sht,&cmd,1,100);if(e==ESP_OK){vTaskDelay(pdMS_TO_TICKS(20));e=i2c_master_receive(sht,raw,6,100);}
    if(e==ESP_OK)s.valid=w4_sht_decode(raw,&s.t_milli,&s.rh_milli);
    if(!s.valid)ESP_LOGW(TAG,"SHT_INVALID seq=%"PRIu32" api=%s; explicit invalid sample, not old value",seq,esp_err_to_name(e));
    return s;
}
static void log_id(const char*event,const W4Sample*s){char node[13];mac_text(s->node,node);ESP_LOGI(TAG,"%s node=%s boot=%016"PRIx64" seq=%"PRIu32" sample_valid=%d",event,node,s->boot,s->seq,s->valid);}
static bool radio_send(const uint8_t peer[6],const W4Sample*s,uint8_t kind){uint8_t raw[W4_BYTES];w4_encode(s,kind,raw);esp_err_t e=esp_now_send(peer,raw,W4_BYTES);if(e!=ESP_OK)ESP_LOGW(TAG,"ESP_NOW_SEND_REQUEST=%s; not application ACK",esp_err_to_name(e));return e==ESP_OK;}
void app_main(void){
    uint8_t own[6];require(esp_read_mac(own,ESP_MAC_WIFI_STA),"read_STA_MAC");ESP_LOGI(TAG,"ACTUAL_STA="MACSTR" role=%d; configure this board's matching role",MAC2STR(own),CONFIG_W04_ROLE);
    if(!configuration(own)){ESP_LOGW(TAG,"CONFIG_CLOSED: exact owned three STA MACs and gateway LAN config needed; no radio application initialized");return;}
    esp_err_t nvs=nvs_flash_init();if(nvs!=ESP_OK){ESP_LOGE(TAG,"NVS=%s; preserve/backup, no automatic erase",esp_err_to_name(nvs));return;}
    char gateway_id[13];mac_text(addresses[0],gateway_id);snprintf(data_topic,sizeof(data_topic),"hixinwu/w04/%s/row",gateway_id);snprintf(ack_topic,sizeof(ack_topic),"hixinwu/w04/%s/ack",gateway_id);
    events=xQueueCreate(16,sizeof(Event));if(!events)abort();start_network();if(!gateway)start_sensor();
    W4Queue pending[2]={0};W4Sample committed[2]={0};bool ack_pending[2]={false,false};uint64_t publish_at[2]={0,0};
    bool seen[2]={false,false},was_recent[2]={false,false};uint64_t seen_at[2]={0,0};
    uint64_t boot=0;esp_fill_random(&boot,sizeof(boot));if(!boot)boot=1;
    uint64_t next_sample=ms(),next_radio=0,next_connect=0,next_status=ms(),next_channel=0;uint32_t seq=0;unsigned radio_turn=0,last_drops=0;bool radio_busy=false,radio_stalled=false,channel_ok=!gateway;uint8_t busy_peer[6]={0};uint64_t busy_at=0,missed=0;
    for(;;){
        Event e;
        while(xQueueReceive(events,&e,0)==pdTRUE){
            if(e.kind==RADIO_TX){if(radio_busy&&!memcmp(busy_peer,e.mac,6)){radio_busy=false;ESP_LOGI(TAG,"MAC_CALLBACK=%d; not receiver SQLite/applicationACK",e.status);}continue;}
            W4Sample s;uint8_t kind;if(!w4_decode(e.bytes,W4_BYTES,&s,&kind))continue;
            if(e.kind==MQTT_ACK){
                if(!gateway||kind!=W4_DATA)continue;
                for(unsigned i=0;i<2;i++)if(w4_pop_ack(&pending[i],&s)){committed[i]=s;ack_pending[i]=true;publish_at[i]=0;log_id("APP_COMMIT_ACK",&s);}
            }else if(e.channel==CHANNEL){
                if(gateway&&kind==W4_DATA&&!memcmp(e.mac,s.node,6)){
                    unsigned i=!memcmp(e.mac,addresses[1],6)?0:1;seen[i]=true;seen_at[i]=e.at_ms;int result=w4_push(&pending[i],s);log_id(result==1?"GATEWAY_QUEUED":result==2?"GATEWAY_PENDING_DUPLICATE":result==0?"GATEWAY_FULL_DROP_NEW":"GATEWAY_ID_CONFLICT",&s);
                }else if(!gateway&&kind==W4_ACK&&w4_pop_ack(&pending[0],&s)){next_radio=0;log_id("NODE_END_TO_END_ACK",&s);}
            }
        }
        const uint64_t now=ms();
        if(radio_busy&&now-busy_at>=2000&&!radio_stalled){radio_stalled=true;ESP_LOGE(TAG,"MAC_CALLBACK_TIMEOUT: radio stopped until manual reset; delivery may already have happened");}
        if(gateway){
            for(unsigned i=0;i<2;i++){bool recent=w4_recent(seen[i],seen_at[i],now);if(recent!=was_recent[i]){was_recent[i]=recent;ESP_LOGI(TAG,"NODE_%c CALLBACK_TRAFFIC_%s; not fresh sample/physical presence proof",'A'+(int)i,recent?"RECENT":"STALE");}}
            W4Link link=link_take();bool online=link.online;
            if(w4_link_needs_disconnect(link)){(void)esp_wifi_disconnect();next_connect=now+2000;}
            if(!online&&now>=next_connect){next_connect=now+5000;esp_err_t rc=esp_wifi_connect();ESP_LOGI(TAG,"CONNECT_REQUEST=%s",esp_err_to_name(rc));}
            if(now>=next_channel){next_channel=now+1000;uint8_t c=0;wifi_second_chan_t secondary;channel_ok=online&&esp_wifi_get_channel(&c,&secondary)==ESP_OK&&c==CHANNEL;ESP_LOGI(TAG,"GATEWAY_CHANNEL actual=%u qualified=%d required=6; no set_channel while associated",c,channel_ok);}
            for(unsigned i=0;i<2;i++){
                const W4Sample*s=w4_head(&pending[i]);
                if(s&&online&&atomic_load(&mqtt_online)&&now>=publish_at[i]){uint8_t raw[W4_BYTES];char hex[W4_HEX_BYTES+1];w4_encode(s,W4_DATA,raw);w4_hex(raw,hex);int mid=esp_mqtt_client_enqueue(mqtt,data_topic,hex,W4_HEX_BYTES,1,0,true);publish_at[i]=now+2000;log_id(mid>=0?"MQTT_ENQUEUE_ACCEPTED":"MQTT_ENQUEUE_REJECTED",s);}
            }
            if(channel_ok&&!radio_busy&&!radio_stalled){
                for(unsigned n=0;n<2;n++){unsigned i=(radio_turn+n)%2;if(!ack_pending[i])continue;if(radio_send(addresses[i+1],&committed[i],W4_ACK)){radio_busy=true;busy_at=now;memcpy(busy_peer,addresses[i+1],6);ack_pending[i]=false;radio_turn=(i+1)%2;}break;}
            }
        }else{
            if(now>=next_sample){uint64_t due=(now-next_sample)/5000+1;next_sample+=due*5000;missed+=due-1;if(seq==UINT32_MAX)ESP_LOGE(TAG,"SEQ_EXHAUSTED: no wrap/new samples; preserve logs and reset deliberately");else{W4Sample s=sample(own,boot,++seq);log_id(w4_push(&pending[0],s)==1?"SAMPLED_QUEUED":"NODE_FULL_DROP_NEW",&s);}}
            const W4Sample*s=w4_head(&pending[0]);
            if(s&&!radio_busy&&!radio_stalled&&ms()>=next_radio){next_radio=ms()+2000;if(radio_send(addresses[0],s,W4_DATA)){radio_busy=true;busy_at=ms();memcpy(busy_peer,addresses[0],6);}}
        }
        if(now>=next_status){next_status=now+5000;unsigned drops=(unsigned)atomic_load(&callback_drops);if(drops!=last_drops){last_drops=drops;ESP_LOGW(TAG,"CALLBACK_DROP_NEW=%u",drops);}ESP_LOGI(TAG,"QUEUE countA=%u countB=%u dropA=%"PRIu64" dropB=%"PRIu64" skipped_plan=%"PRIu64" RAM_ONLY; reset/power loss may lose unACKed samples",pending[0].count,pending[1].count,pending[0].dropped,pending[1].dropped,missed);}
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}
做到这里,你应该看到
日志能同时区分采样、MAC回调、enqueue、APP_COMMIT_ACK和NODE_END_TO_END_ACK。

参考:[4] IDF5.5.5 ESP-NOW精确API/回调参数[5] IDF5.5.5 ESP-NOW:信道、回调与应用ACK边界[6] 实际SDK ESP-MQTT固定submodule:分片、enqueue与outbox[7] 固定I2C master API与超时[8] 固定Wi-Fi API:channel/country/STA

08 / 多节点网关

电脑端先验证完整负载

Python解码器和C编码共享同一明确布局,严格88小写hex、CRC、字段和范围;高位boot以16位hex文本存SQLite,避免有符号整数溢出。它只接受DATA,ACK文本不重新生成其他值。

代码 / 配置
# ---- host/wire.py ----
"""SPDX-License-Identifier: MIT. W04 exact44-byte DATA in lowercase88-byte hex."""
from dataclasses import dataclass
import binascii,re,struct
FORMAT=struct.Struct('!2sBBBB6sQIQiII')
MAC=re.compile(r'[0-9a-f]{12}\Z')
def mac_valid(node):
 return bool(MAC.fullmatch(node)) and int(node,16)!=0 and not(int(node[:2],16)&1)
@dataclass(frozen=True)
class Sample:
 node:str
 boot:str
 seq:int
 mono_ms:int
 valid:bool
 t_milli:int
 rh_milli:int
 payload:str
def decode(payload):
 if not isinstance(payload,bytes)or len(payload)!=88 or not re.fullmatch(rb'[0-9a-f]{88}',payload):raise ValueError('exact lowercase88 ASCII bytes required')
 raw=bytes.fromhex(payload.decode('ascii'))
 magic,version,kind,valid,reserved,node,boot,seq,mono,t,rh,crc=FORMAT.unpack(raw)
 if magic!=b'W4'or version!=1 or kind!=1 or valid>1 or reserved or binascii.crc32(raw[:40])!=crc:raise ValueError('frame header/CRC')
 node=node.hex()
 if not mac_valid(node)or not boot or not seq or mono>9223372036854775807:raise ValueError('identity/time range')
 if (valid and not(-40000<=t<=125000 and rh<=100000))or(not valid and(t!=0 or rh!=0)):raise ValueError('sample range/invalid marker')
 return Sample(node,f'{boot:016x}',seq,mono,bool(valid),t,rh,payload.decode('ascii'))
做到这里,你应该看到
默认观测表为空;wire_vectors产生的高位boot和负温度都只标为合成数据。
09 / 多节点网关

真正的应用确认点:SQLite提交后才 ACK

在自己的LAN电脑运行固定Mosquitto2.0.20,限制访问到自有实验网。Python3.12安装host/requirements.txt的paho-mqtt1.6.1;运行receiver.py,填真实网关12位小写STA MAC和两次--node,以及私有现有目录内的DB路径。OBSERVER_READY要求成功SUBACK,只证明接收器订阅,不证明板在线。

接收器WAL/FULL commit后才回ACK。同键同payload不增行但再ACK,同键冲突/错节点/坏帧/retained不ACK。publish成功只是主机客户端接受ACK;设备仍可能没收到。实际SQLite错误会使接收器停止/断开,不在落库失败后发成功确认。

代码 / 配置
# ---- host/receiver.py ----
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Own LAN receiver: SQLite FULL commit before explicit application ACK."""
from pathlib import Path
import argparse,sqlite3,time,json,uuid
from wire import decode,mac_valid
class Store:
 def __init__(self,path):
  self.db=sqlite3.connect(path);self.db.execute('PRAGMA journal_mode=WAL');self.db.execute('PRAGMA synchronous=FULL')
  self.db.execute('CREATE TABLE IF NOT EXISTS samples(node TEXT,boot TEXT,seq INTEGER,mono_ms INTEGER,valid INTEGER,t_milli INTEGER,rh_milli INTEGER,payload TEXT,receiver_session TEXT,received_wall REAL,PRIMARY KEY(node,boot,seq))')
  self.db.execute('CREATE TABLE IF NOT EXISTS counters(name TEXT PRIMARY KEY,n INTEGER NOT NULL)');self.db.commit();self.session=uuid.uuid4().hex
 def counter(self,name):
  self.db.execute('INSERT INTO counters VALUES(?,1) ON CONFLICT(name)DO UPDATE SET n=n+1',(name,));self.db.commit()
 def ingest(self,payload,allowed):
  try:s=decode(payload)
  except ValueError:self.counter('invalid');return 'INVALID',None
  if s.node not in allowed:self.counter('unknown_node');return 'UNKNOWN_NODE',None
  old=self.db.execute('SELECT payload FROM samples WHERE node=? AND boot=? AND seq=?',(s.node,s.boot,s.seq)).fetchone()
  if old is not None:
   result='DUPLICATE'if old[0]==s.payload else'CONFLICT';self.counter(result.lower());return result,s if result=='DUPLICATE'else None
  self.db.execute('INSERT INTO samples VALUES(?,?,?,?,?,?,?,?,?,?)',(s.node,s.boot,s.seq,s.mono_ms,int(s.valid),s.t_milli,s.rh_milli,s.payload,self.session,time.time()));self.db.commit();return 'COMMITTED',s
 def close(self):self.db.close()
def main():
 import paho.mqtt.client as mqtt
 p=argparse.ArgumentParser();p.add_argument('--host',required=True);p.add_argument('--port',type=int,default=1883);p.add_argument('--gateway',required=True);p.add_argument('--node',action='append',required=True);p.add_argument('--db',required=True);a=p.parse_args()
 if not mac_valid(a.gateway)or len(a.node)!=2 or len(set(a.node))!=2 or any(not mac_valid(n)for n in a.node)or a.gateway in a.node:p.error('exactly two distinct owned node STA MACs and one different gateway STA MAC, lowercase12hex')
 topic='hixinwu/w04/'+a.gateway;store=Store(Path(a.db));client=mqtt.Client(client_id='w04-receiver-'+store.session[:12],clean_session=True)
 def connected(c,u,f,rc):
  if rc!=0:raise RuntimeError('broker connection refused')
  result,mid=c.subscribe(topic+'/row',1)
  if result!=mqtt.MQTT_ERR_SUCCESS:raise RuntimeError('subscription request rejected')
 def subscribed(c,u,mid,qos):
  if not qos or any(q==128 for q in qos):raise RuntimeError('subscription denied')
  print('OBSERVER_READY subscribed to one owned gateway topic',flush=True)
 def message(c,u,msg):
  if msg.topic!=topic+'/row':return
  try:
   if msg.retain:store.counter('retained_rejected');print('RETAINED_REJECTED no applicationACK',flush=True);return
   result,s=store.ingest(msg.payload,set(a.node))
  except sqlite3.Error:c.disconnect();raise
  print(json.dumps({'event':result,'id':None if s is None else[s.node,s.boot,s.seq],'sample_valid':None if s is None else s.valid}),flush=True)
  if s is not None:
   # ACK is the exact DATA text, never an SDK message ID. Duplicate also ACKs.
   info=c.publish(topic+'/ack',s.payload,qos=1,retain=False)
   if info.rc!=mqtt.MQTT_ERR_SUCCESS:print('APP_ACK_NOT_ACCEPTED_BY_CLIENT; node/gateway retry same sample',flush=True)
 client.on_connect=connected;client.on_subscribe=subscribed;client.on_message=message;client.connect(a.host,a.port,30)
 try:client.loop_forever()
 except KeyboardInterrupt:pass
 finally:client.disconnect();store.close()
if __name__=='__main__':main()
做到这里,你应该看到
COMMITTED与重复计数可以在真实DB核对;PUBACK不是这里的应用确认。

参考:[6] 实际SDK ESP-MQTT固定submodule:分片、enqueue与outbox

10 / 多节点网关

两节点并发、失联、重试和 broker 故障怎么验收

先接收器,再网关,确认实际GOT_IP/MQTT与信道6,再启动A/B。对每个node/boot/seq顺序记录SAMPLED_QUEUED→GATEWAY_QUEUED→COMMITTED→APP_COMMIT_ACK→NODE_END_TO_END_ACK。两节点同时各留至少20次真实尝试,含失效样本和缺口;本站没有这些实板记录。

分别停止A供电,观察A通信窗口STALE且B继续;重新上电A有新的随机boot。另轮保持broker、Ctrl+C停接收器,新数据可能有PUBACK却无应用ACK。用同一DB恢复,旧ID重复只ACK不增行。再单独停止/恢复broker,核断线、旧头重试、队列满drop;不要把清空RAM当作无丢样恢复。

重启节点或网关会丢RAM未ACK数据;电脑已提交的记录仍在,不等于设备有持久待发日志或跨复位恰好一次。需要持久日志/批传时继续读L06/S10,不把本例有限RAM扩写成离线永不丢。

  1. 两节点同时跑,逐样本对齐两份串口、主机日志与DB。
  2. 仅A失联/恢复,观察B与A通信状态独立。
  3. 接收器缺席/同DB重启,核重复和不增行。
  4. broker断线/恢复,保留原ID、drop和完整故障时段。
做到这里,你应该看到
实体送达、采样、电气、RF和恢复时间都填真实记录;尚未执行保持pending。
11 / 多节点网关

从最早没有证据的一层定位

节点无ACK先核网关实际信道6、MAC白名单与callback丢弃,再查接收器SUBACK/topic/允许节点和DB可写。SHT_INVALID先查本地I2C、供电/CRC;不要将它误诊为MQTT离线。MQTT在线但无APP_COMMIT_ACK时,查retained/conflict/接收进程,不用SDK确认代替提交。

MAC_CALLBACK_TIMEOUT先保留双方日志再RESET,旧头可能已经送达,是否新增由数据库身份判断。AP被改信道时恢复受控6并核实际值,本篇不自动发现。SQLite错误保留DB/WAL/错误,修磁盘后重开;活跃数据库不能只复制db主文件。

host/export_csv.py以只读连接和单个SQLite读快照导出到新CSV,保留valid、完整payload、receiver_session与received_wall。墙钟回拨可能影响received_wall,不能拿它和节点uptime计算单向时延,也不能从已送达CSV推算丢样总数。

做到这里,你应该看到
保留physical-observations.csv空表;合成host导出另放SYNTHETIC路径,不混进实测。
12 / 多节点网关

可复跑的主机证据与后续路线

生产模型14组ASan/UBSan检查有界队列、两身份、CRC、分片和IP恢复快照;跨语言9组用原C编码器产生合成行,真实Python解码/SQLite检查冲突、重开和写失败。另有9组真实本机Mosquitto/receiver/SQLite检查,包括进程缺席、retained和broker重启。它们没有运行ESP-NOW射频、节点I2C或MCU回调。

四配置实际IDF构建通过,完整源码/锁/README/许可/记录表在下载包,原构建命令和SHA在站点证据。全部实体三板、AP、传感器与恢复比例仍pending。先把同一任务的送达身份做清楚,再读能量比较和电池长期维护,不由编译体积推断续航。

BACK TO THE SOURCE

资料来源与版本说明

固定官方SDK/API与SHT4x V7.3及具体PID4885核对;实际native构建、同源host、本机broker/SQLite与实体三板分别记录。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. DevKitC-1 v1.1 固定用户指南:J1与供电选项
  2. Adafruit PID4885引脚:VIN/0x44/电平/10k上拉
  3. Sensirion SHT4x V7.3/2026-06,第10–13页:时序、CRC、命令、换算
  4. IDF5.5.5 ESP-NOW精确API/回调参数
  5. IDF5.5.5 ESP-NOW:信道、回调与应用ACK边界
  6. 实际SDK ESP-MQTT固定submodule:分片、enqueue与outbox
  7. 固定I2C master API与超时
  8. 固定Wi-Fi API:channel/country/STA