先确定这一步能证明什么
在W01基线上区分“交给驱动”“MAC回调”“接收端GPIO API已成功”和“真实灯亮”。本例只对第三层返回应用ACK。LED使用绝对SET;重试沿用同一个身份和值,不能每重发一次翻转一次。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制
32字节协议与会话
W2/版本1/type/level/status/reserved占前8字节,随后sender_boot64、receiver_boot64、sequence32、CRC32IEEE,全部显式大端。HELLO=1只携带接收端启动nonce;COMMAND=2必须有双方非零启动标识与非零seq;ACK=3与原命令的双方nonce/seq/level完整相关。CRC用于发现格式破坏,不提供加密、身份认证或抗恶意重放。
接收端每约1秒单播HELLO,发送端只在最近3秒内处理过接收端HELLO时接收新按钮事件。此处hello_seen取主循环处理时刻,未记录射频接收时间;它是可见性门槛,不是严格的接收新鲜度证明。队列积压/日志阻塞会影响它,真实时序须台架检查。首次有效命令绑定一个sender_boot;同启动的更大seq执行GPIO后提交RAM状态,随后回ACK。相同seq+相同值只回DUPLICATE,不重复GPIO调用;同seq不同值/更小seq/不同sender_boot/错误receiver_boot分别拒绝。状态0=APPLIED,1=DUPLICATE,2=SESSION_REJECTED,3=STALE_REJECTED,4=CONFLICT_REJECTED。
去重状态只在RAM。接收端重启nonce改变,发送端取消尚未确认的旧命令且不自动重放;发送端重启必须也重启接收端(重建一对会话),否则新启动会被旧绑定拒绝。不能由此保证掉电后恰好执行一次。随机nonce碰撞概率很低但不是安全抗攻击协议。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
有限重试为何保留“不确定”
同一命令最多4次应用尝试,间隔300ms,约在第四次后的等待窗口结束时报APP_ACK_TIMEOUT;底层无线回调未结束时先等待,不堆积发送。即使esp_now_send返回错误,该次应用尝试也计入上限。底层2秒回调超时锁闭至重启;所有计时为esp_timer_get_time的64位微秒。
应用ACK丢失时可能“接收端已经设置GPIO,发送端却超时”。终态因此写failed OR executed-with-ACK-lost,不写“肯定没执行”。APP_ACK状态0/1只证明对端软件GPIO调用/去重结果,实际电平与LED仍需观察。MAC回调成功没有资格结束应用待确认状态。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
操作与故障注入
按下列具体步骤操作,并保存对应原始记录。
- 两板固定共同信道6、不连接AP。build-receiver关闭W02_SENDER,build-sender开启;双方填实际STA MAC。正常模式关闭W02_DROP_FIRST_ACK。
- 先起接收端,发送端稳定松开后按一次,关联SEND_API、MAC_CALLBACK、接收端RX_COMMAND application_count和发送端APP_ACK。每个新按钮事件seq递增,重复SET才复用身份。
- 为演示丢ACK,在接收端开启W02_DROP_FIRST_ACK重新构建/刷写,重启两板。第一条新执行命令只丢第一次应用ACK;接收端打印BENCH_DROP_FIRST_APP_ACK,发送端应在300ms后重试,接收端DUPLICATE且application_count仍为1。只是实验故障开关,验收后关闭重刷。
- 最后一笔已确认且没有待发时,在发送端UART monitor输入小写d。它重放最近同一receiver_boot/seq/值,不生成新seq;接收端应回重复ACK,application_count不增。串口使用O_NONBLOCK单字节read,设置失败只关闭串口实验入口;按钮仍可用。
- 切断接收端后发起新按钮事件,记录四次上限与不确定终态。接收端重新上线后不会自动重放旧命令;新按钮事件独立开始。单独重启发送端应被拒绝,随后重启接收端恢复成对会话。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
诊断与限制
observations.csv记录真实每笔命令的尝试数、ACK状态、application_count和LED。至少验收正常ON/OFF、丢首次ACK、d重复、对端断电、发送端单独重启与成对恢复。14组同源主机检查仅覆盖协议/状态模型,不代替这些台架行。
| 现象 | 判断 | 操作 |
|---|---|---|
| NO_RECENT_RECEIVER | 未见HELLO或3秒内不新鲜 | 先查对端电源/同信道/双方地址/版本 |
| ACK_NOT_QUEUED | 接收端底层发送忙,ACK没有无界排队 | 等有限重试;持续出现查队列/串口负荷 |
| status2 | 双启动标识不匹配或sender换启动 | 保存双方BOOT,按成对重启恢复 |
| status3/4 | 旧序号或同身份不同值 | 禁止改包复用seq;重新建立干净会话 |
| APP_ACK_TIMEOUT | 未得到有效应用结果,执行不确定 | 查接收端日志/观察;不得自动切换新身份补执行 |
| MAC_CALLBACK_TIMEOUT | 底层回调失联锁闭 | 保存日志、停止输入、查系统后手工重启 |
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
固定组合与器材
即使将LED压降按0V作保守上界,3.3V/1kΩ也只有3.3mA;这用于限流选值,不是实测亮度或GPIO总电流资格。GPIO6高电平只是软件请求;用眼睛/仪器观察LED再填写表,不能把日志当光学验证。
- 两块乐鑫 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8,8MiB Flash、8MiB Octal PSRAM。其他模组/修订未验证。
- ESP-IDF 5.5.5,Python 3.12.13,xtensa-esp-elf-gcc 14.2.0 / esp-14.2.0_20260121。CMake/SDK依赖随此IDF发行,未额外拉取第三方组件。不是Arduino旧版esp_now回调教程。
- 每块板各用USB-to-UART桥口、数据线连接电脑,UART0 115200。本例不占原生USB GPIO19/20。每块只选USB供电,不同时接外部5V或3V3;两板无需额外相连3V3、GPIO或GND,低压元件各接自己板的GND。
- 发送端/节点:常开瞬时按钮、外接10kΩ上拉、短杜邦线。接收端/网关:Kingbright WP7113ID 红色通孔LED、1kΩ串联电阻;焊接或插入可靠面包板连接,断电后检查。
| 所在板 | 接点 | 连接与理由 |
|---|---|---|
| 发送端/节点 | GPIO5 | 按钮一端;另一端GND;同点10kΩ到本板3V3。按下为LOW,代码未启内部拉阻。 |
| 接收端/网关 | GPIO6 | 1kΩ→LED阳极A;阴极K→GND。核对厂商脚图与平边/短脚,不能只凭线色。 |
| 两板 | GND | 仅各自按钮/LED回路返回本板;GPIO不得输入5V。 |
| 两板 | GPIO43/44 | 板载UART0桥使用,不接本例外设。 |
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
构建、先读MAC、再配对
安装并激活官方IDF5.5.5环境(Windows在ESP-IDF终端,macOS/Linux先运行安装路径下export脚本),解压本项目并进入项目根目录。下面使用SDKCONFIG独立目录,避免两角色配置相互污染;路径含分号的defaults参数必须完整加引号。
set-target应在新目录执行,再menuconfig;不要在已配置peer/凭据后重复set-target造成配置重置。角色、peer与信道/凭据都在该篇W0x菜单中。对两板分别执行,用后文角色目录替换build-local。
- 给每板贴角色标签,记下串口。先用公开默认零peer MAC构建/刷写,读取本板实际STA地址。零地址会打印CLOSED、LED保持OFF,不会开始ESP-NOW应用;W03在校验前直接读取芯片STA MAC,其余两篇先启动固定信道STA再关闭应用。不要拿SoftAP、蓝牙地址或示意MAC替代。
- 给接收端/网关的menuconfig填发送端/节点STA MAC,反向也填;只有完整单播、非零且不是本机的17字符地址才放行。公开config/*.compile-only.defaults中的02:00…均是人为本地管理地址,只为实际编译角色分支,不是实测地址,不可刷入当配对结果。
- 各角色使用下文的独立构建目录和menuconfig设置;先启动接收端/网关,再启动发送端/节点。开始前松开按钮至少50ms,上电时一直按住不会自动触发。
- monitor退出用Ctrl+]。更换角色时断开monitor并检查实际串口;不要同时向错误板刷写。默认不自动擦NVS,NVS初始化失败先看分区/版本,备份后按自己的恢复计划处理,不用无条件erase_flash掩盖错误。
# 两块板分别使用不同 build-* 目录;<PORT>换成本机串口
idf.py -B build-local -D SDKCONFIG=build-local/sdkconfig set-target esp32s3
idf.py -B build-local -D SDKCONFIG=build-local/sdkconfig menuconfig
idf.py -B build-local -D SDKCONFIG=build-local/sdkconfig build
idf.py -B build-local -D SDKCONFIG=build-local/sdkconfig -p <PORT> flash monitor参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
验证范围和复现
本项目的host_check.c直接包含固件同一策略头,在macOS arm64用Apple clang的AddressSanitizer/UndefinedBehaviorSanitizer实际运行;这只检查字节编解码、时序/状态决策,未运行ESP32射频、GPIO或真实AP。固件实际通过IDF两角色编译。记录见仓库docs/esp32-evidence/wireless-builds.json、wireless-host.json和原日志;空observations.csv只提供台架记录字段,不含模拟实测值。
两角色构建体积不同,公开编译夹具也不代表刷入后能配对。真实RF送达率、时延、距离、重启恢复、GPIO电平及LED现象仍待用户在指定台架执行。未宣称远程执行恰好一次、工业控制资格或安全通信。
cc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -fno-omit-frame-pointer -I main host_check.c -o /tmp/espnow-host-check
/tmp/espnow-host-check参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
许可与上游
本站应用代码、策略头、主机夹具、图文为MIT,见LICENSE。ESP-IDF及其组件按上游各自许可,未将SDK二进制放入下载包,见THIRD_PARTY.md。编译生成build-*、本地sdkconfig/凭据、固件和主机可执行文件不应提交或分享。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[5] 官方v1.1板:GPIO5/6与USB供电选项
实际构建的完整主程序与同源策略
以下各文件与完整下载工程对应;带文件名的注释用于分隔,构建时仍使用独立原文件。接线、Kconfig、CMake、默认配置、主机夹具和许可一并在ZIP中。
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <assert.h>
#include <stdio.h>
#include <inttypes.h>
#include <fcntl.h>
#include <unistd.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.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 "reliable_policy.h"
#ifdef CONFIG_W02_SENDER
#define SENDER true
#else
#define SENDER false
#endif
#ifdef CONFIG_W02_DROP_FIRST_ACK
#define DROP_FIRST true
#else
#define DROP_FIRST false
#endif
static const char *TAG="W02";
typedef struct {bool tx;uint8_t mac[6],data[WIRE_BYTES];int size,status;} Event;
static QueueHandle_t events;
static portMUX_TYPE mux=portMUX_INITIALIZER_UNLOCKED;static uint32_t drops;
static bool busy,stalled;static uint64_t radio_at;
static void post(const Event *e){if(xQueueSend(events,e,0)!=pdTRUE){portENTER_CRITICAL(&mux);drops++;portEXIT_CRITICAL(&mux);}}
static void recv_cb(const esp_now_recv_info_t *i,const uint8_t *d,int n){if(!i||!i->src_addr||!d||n!=WIRE_BYTES)return;Event e={.size=n};memcpy(e.mac,i->src_addr,6);memcpy(e.data,d,WIRE_BYTES);post(&e);}
static void sent_cb(const esp_now_send_info_t *i,esp_now_send_status_t s){if(!i||!i->des_addr)return;Event e={.tx=true,.status=s};memcpy(e.mac,i->des_addr,6);post(&e);}
static void send_frame(const uint8_t peer[6],const Frame *f,uint64_t now){
uint8_t bytes[WIRE_BYTES];wire_encode(f,bytes);esp_err_t e=esp_now_send(peer,bytes,sizeof(bytes));
ESP_LOGI(TAG,"SEND_API kind=%u seq=%"PRIu32" status=%u err=%s",f->kind,f->seq,f->status,esp_err_to_name(e));
if(e==ESP_OK){busy=true;radio_at=now;}
}
void app_main(void){
gpio_config_t output={.pin_bit_mask=1ULL<<GPIO_NUM_6,.mode=GPIO_MODE_OUTPUT};ESP_ERROR_CHECK(gpio_config(&output));ESP_ERROR_CHECK(gpio_set_level(GPIO_NUM_6,0));
gpio_config_t input={.pin_bit_mask=1ULL<<GPIO_NUM_5,.mode=GPIO_MODE_INPUT};ESP_ERROR_CHECK(gpio_config(&input));
ESP_ERROR_CHECK(nvs_flash_init());ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());wifi_init_config_t cfg=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&cfg));ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));ESP_ERROR_CHECK(esp_wifi_start());ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));ESP_ERROR_CHECK(esp_wifi_set_channel(CONFIG_W02_CHANNEL,WIFI_SECOND_CHAN_NONE));
uint8_t own[6],peer[6];ESP_ERROR_CHECK(esp_wifi_get_mac(WIFI_IF_STA,own));ESP_LOGI(TAG,"STA="MACSTR" role=%s channel=%d",MAC2STR(own),SENDER?"sender":"receiver",CONFIG_W02_CHANNEL);
if(!pair_mac(CONFIG_W02_PEER_MAC,peer)||!memcmp(own,peer,6)){ESP_LOGE(TAG,"CLOSED: replace peer with other board actual STA MAC");return;}
events=xQueueCreate(16,sizeof(Event));assert(events);ESP_ERROR_CHECK(esp_now_init());ESP_ERROR_CHECK(esp_now_register_recv_cb(recv_cb));ESP_ERROR_CHECK(esp_now_register_send_cb(sent_cb));esp_now_peer_info_t p={.channel=0,.ifidx=WIFI_IF_STA,.encrypt=false};memcpy(p.peer_addr,peer,6);ESP_ERROR_CHECK(esp_now_add_peer(&p));
uint64_t boot;esp_fill_random(&boot,sizeof(boot));if(!boot)boot=1;Receiver rx={.own=boot};Sender tx={0};
uint64_t remote_receiver=0,hello_seen=0,hello_at=0;uint32_t next_seq=0,last_drops=0;bool desired=false,drop_unused=DROP_FIRST,have_last=false;Frame last={0};
int flags=fcntl(STDIN_FILENO,F_GETFL,0);bool serial_ready=flags>=0&&fcntl(STDIN_FILENO,F_SETFL,flags|O_NONBLOCK)>=0;
if(!serial_ready)ESP_LOGW(TAG,"UART nonblocking setup unavailable: serial replay disabled; physical button remains usable");
PairButton button={.candidate=gpio_get_level(GPIO_NUM_5)==0,.stable=true,.since=(uint64_t)esp_timer_get_time()};
ESP_LOGI(TAG,"BOOT=%016"PRIx64"; receiver RAM session binds one sender boot; reset both after sender reboot. Unencrypted bench only.",boot);
for(;;){
uint64_t now=(uint64_t)esp_timer_get_time();Event e;
while(xQueueReceive(events,&e,0)==pdTRUE){
if(memcmp(e.mac,peer,6))continue;
if(e.tx){busy=false;ESP_LOGI(TAG,"MAC_CALLBACK status=%d; no application confirmation",e.status);continue;}
Frame f;if(!wire_decode(e.data,e.size,&f))continue;
if(SENDER&&f.kind==HELLO){
if(remote_receiver&&remote_receiver!=f.receiver){if(tx.pending)ESP_LOGW(TAG,"RECEIVER_REBOOT: cancel pending, execution uncertain; never auto replay into new boot");tx.pending=false;have_last=false;}
remote_receiver=f.receiver;hello_seen=(uint64_t)esp_timer_get_time();continue;
}
if(SENDER&&sender_match(&tx,&f)){
ESP_LOGI(TAG,"APP_ACK seq=%"PRIu32" level=%u status=%u (%s); GPIO/API result, optical unmeasured",f.seq,f.on,f.status,f.status<=DUPLICATE?"CONFIRMED":"REJECTED");tx.pending=false;continue;
}
if(!SENDER&&f.kind==COMMAND){
uint8_t status=receive_decide(&rx,&f);
if(status==APPLIED){ESP_ERROR_CHECK(gpio_set_level(GPIO_NUM_6,f.on));receive_commit(&rx,&f);}
ESP_LOGI(TAG,"RX_COMMAND seq=%"PRIu32" status=%u application_count=%"PRIu64,f.seq,status,rx.applications);
if(status==APPLIED&&drop_unused){drop_unused=false;ESP_LOGW(TAG,"BENCH_DROP_FIRST_APP_ACK; retry must return duplicate without GPIO reapply");continue;}
if(!busy&&!stalled){f.kind=ACK;f.status=status;send_frame(peer,&f,(uint64_t)esp_timer_get_time());}
else ESP_LOGW(TAG,"ACK_NOT_QUEUED: bounded single radio send; peer retry may recover");
}
}
now=(uint64_t)esp_timer_get_time();
if(busy&&now-radio_at>2000000&&!stalled){stalled=true;tx.pending=false;ESP_LOGE(TAG,"MAC_CALLBACK_TIMEOUT: stop radio until reset; execution uncertain");}
bool press=pair_button(&button,gpio_get_level(GPIO_NUM_5)==0,now);uint8_t key=0;bool replay=serial_ready&&read(STDIN_FILENO,&key,1)==1&&key=='d';
if(SENDER&&(press||replay)){
if(tx.pending||busy||stalled)ESP_LOGW(TAG,"INPUT_IGNORED: command/radio pending or stalled");
else if(!remote_receiver||now<hello_seen||now-hello_seen>3000000)ESP_LOGW(TAG,"NO_RECENT_RECEIVER: no command started");
else if(replay){if(have_last&&last.receiver==remote_receiver){sender_begin(&tx,last,now);ESP_LOGI(TAG,"BENCH_REPLAY same boot/seq/value");}else ESP_LOGW(TAG,"NO_COMMAND_TO_REPLAY");}
else if(next_seq==UINT32_MAX)ESP_LOGE(TAG,"SEQ_EXHAUSTED: reset pair; no sequence wrap");
else{desired=!desired;last=(Frame){.kind=COMMAND,.on=desired,.sender=boot,.receiver=remote_receiver,.seq=++next_seq};have_last=true;sender_begin(&tx,last,now);}
}
unsigned action=sender_action(&tx,now);
if(SENDER&&action&&!busy&&!stalled){
if(action==2){tx.pending=false;ESP_LOGE(TAG,"APP_ACK_TIMEOUT after4attempts: failed or executed-with-ACK-lost; seq=%"PRIu32,tx.command.seq);}
else{sender_attempt(&tx,now);send_frame(peer,&tx.command,now);}
}
if(!SENDER&&now>=hello_at&&!busy&&!stalled){hello_at=now+1000000;Frame f={.kind=HELLO,.receiver=boot};send_frame(peer,&f,now);}
portENTER_CRITICAL(&mux);uint32_t count=drops;portEXIT_CRITICAL(&mux);if(count!=last_drops){last_drops=count;ESP_LOGW(TAG,"CALLBACK_QUEUE_DROPS=%"PRIu32,count);}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
// ---- main/pair_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
/* Version1, explicit big-endian bytes; no native packed struct on wire. */
#define PAIR_BYTES 16
static inline void pair_encode(uint8_t b[PAIR_BYTES],uint64_t boot,uint32_t seq,bool on){
b[0]='W';b[1]='1';b[2]=1;b[3]=on?1:0;
for(unsigned i=0;i<8;i++)b[4+i]=(uint8_t)(boot>>(56-8*i));
for(unsigned i=0;i<4;i++)b[12+i]=(uint8_t)(seq>>(24-8*i));
}
static inline bool pair_decode(const uint8_t *b,size_t n,uint64_t *boot,uint32_t *seq,bool *on){
if(!b||!boot||!seq||!on||n!=PAIR_BYTES||b[0]!='W'||b[1]!='1'||b[2]!=1||b[3]>1)return false;
uint64_t x=0;uint32_t y=0;
for(unsigned i=0;i<8;i++)x=(x<<8)|b[4+i];
for(unsigned i=0;i<4;i++)y=(y<<8)|b[12+i];
if(!x||!y)return false;
*boot=x;*seq=y;*on=b[3]!=0;return true;
}
static inline int pair_hex(char c){if(c>='0'&&c<='9')return c-'0';if(c>='a'&&c<='f')return c-'a'+10;if(c>='A'&&c<='F')return c-'A'+10;return -1;}
static inline bool pair_mac(const char *s,uint8_t out[6]){
if(!s||strlen(s)!=17)return false;
uint8_t m[6];unsigned sum=0;
for(unsigned i=0;i<6;i++){
int a=pair_hex(s[3*i]),b=pair_hex(s[3*i+1]);if(a<0||b<0||(i<5&&s[3*i+2]!=':'))return false;
m[i]=(uint8_t)((a<<4)|b);sum|=m[i];
}
if(!sum||(m[0]&1))return false;
memcpy(out,m,6);return true;
}
/* Startup press is not an event; stable released level must first arm. */
typedef struct {bool candidate,stable,armed;uint64_t since;} PairButton;
static inline bool pair_button(PairButton *b,bool pressed,uint64_t now){
if(pressed!=b->candidate){b->candidate=pressed;b->since=now;}
if(now-b->since<50000)return false;
if(!pressed)b->armed=true;
if(pressed==b->stable)return false;
b->stable=pressed;
if(pressed&&b->armed){b->armed=false;return true;}
return false;
}
// ---- main/reliable_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include "pair_policy.h"
#define WIRE_BYTES 32
#define HELLO 1
#define COMMAND 2
#define ACK 3
#define APPLIED 0
#define DUPLICATE 1
#define SESSION_REJECTED 2
#define STALE_REJECTED 3
#define CONFLICT_REJECTED 4
typedef struct {uint8_t kind,status;bool on;uint64_t sender,receiver;uint32_t seq;} Frame;
static inline uint32_t wire_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 k=0;k<8;k++)c=(c>>1)^((c&1)?UINT32_C(0xedb88320):0);}return ~c;}
static inline void put_be(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 get_be(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 void wire_encode(const Frame *f,uint8_t b[WIRE_BYTES]){memset(b,0,WIRE_BYTES);b[0]='W';b[1]='2';b[2]=1;b[3]=f->kind;b[4]=f->on;b[5]=f->status;put_be(b+8,f->sender,8);put_be(b+16,f->receiver,8);put_be(b+24,f->seq,4);put_be(b+28,wire_crc(b,28),4);}
static inline bool wire_decode(const uint8_t *b,size_t n,Frame *f){
if(!b||!f||n!=WIRE_BYTES||b[0]!='W'||b[1]!='2'||b[2]!=1||b[4]>1||b[6]||b[7]||get_be(b+28,4)!=wire_crc(b,28))return false;
Frame x={.kind=b[3],.status=b[5],.on=b[4],.sender=get_be(b+8,8),.receiver=get_be(b+16,8),.seq=(uint32_t)get_be(b+24,4)};
if(!x.receiver)return false;
if(x.kind==HELLO){if(x.sender||x.seq||x.status||x.on)return false;}
else if(x.kind==COMMAND){if(!x.sender||!x.seq||x.status)return false;}
else if(x.kind==ACK){if(!x.sender||!x.seq||x.status>CONFLICT_REJECTED)return false;}
else return false;
*f=x;return true;
}
typedef struct {uint64_t own,bound_sender;uint32_t last;bool level;uint64_t applications;} Receiver;
/* Bind one sender boot until receiver resets. Explicit reset-pair recovery,
rather than silently accepting delayed commands from another boot. */
static inline uint8_t receive_decide(const Receiver *r,const Frame *f){
if(f->kind!=COMMAND||f->receiver!=r->own||(r->bound_sender&&f->sender!=r->bound_sender))return SESSION_REJECTED;
if(f->seq<r->last)return STALE_REJECTED;
if(f->seq==r->last)return f->on==r->level?DUPLICATE:CONFLICT_REJECTED;
return APPLIED;
}
/* Call only after gpio_set_level successfully returns. */
static inline void receive_commit(Receiver *r,const Frame *f){r->bound_sender=f->sender;r->last=f->seq;r->level=f->on;r->applications++;}
typedef struct {bool pending;uint8_t attempts;uint64_t next_at;Frame command;} Sender;
static inline void sender_begin(Sender *s,Frame f,uint64_t now){s->pending=true;s->attempts=0;s->next_at=now;s->command=f;}
static inline bool sender_due(const Sender *s,uint64_t now){return s->pending&&now>=s->next_at;}
static inline void sender_attempt(Sender *s,uint64_t now){s->attempts++;s->next_at=now+300000;}
static inline bool sender_match(const Sender *s,const Frame *f){return s->pending&&f->kind==ACK&&f->sender==s->command.sender&&f->receiver==s->command.receiver&&f->seq==s->command.seq&&f->on==s->command.on;}
/* 0=wait,1=attempt,2=finish without ACK. Shared with firmware and fixture. */
static inline unsigned sender_action(const Sender *s,uint64_t now){return !sender_due(s,now)?0:s->attempts<4?1:2;}参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[4] Wi-Fi元数据、国家许可范围与IP/连接事件[7] IDF5.5.5 UART VFS:非阻塞标志与读取[8] IDF5.5.5默认STA网络接口[9] IDF5.5.5事件注册和事件ID
默认关闭的配对配置与硬件范围
公开默认peer为零地址,W03另有AP凭据占位。先按步骤读取并手工填入实际STA地址;编译夹具只证明角色代码已链接,不是板卡运行配置或观察结果。
# ---- main/Kconfig.projbuild ----
menu "W02 bounded ESP-NOW application ACK"
config W02_SENDER
bool "Sender role (off=receiver)"
default n
config W02_PEER_MAC
string "Other board actual STA MAC, zero keeps application closed"
default "00:00:00:00:00:00"
config W02_CHANNEL
int "Fixed common channel1..11"
range 1 11
default 6
config W02_DROP_FIRST_ACK
bool "Receiver bench fault: omit first newly applied command ACK once"
default n
endmenu
# ---- sdkconfig.defaults ----
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
CONFIG_FREERTOS_HZ=1000参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[3] IDF5.5.5 Wi-Fi:设置/读取信道与关联时的限制[5] 官方v1.1板:GPIO5/6与USB供电选项
资料来源与版本说明
实际读取固定版本的API、官方示例与板卡针脚/供电章节,核对安装SDK字节;分别归档真实两角色构建和同源主机检查。未执行指定板台架,无本站无线送达率、距离或LED效果数据。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。