先确定这一步能证明什么
目标是让按钮产生一条明确的SET命令,另一板接收后请求GPIO6输出指定电平。没有Wi-Fi路由器、应用ACK、自动重试或去重。它是后续可靠传输的对照基线,不能由MAC_CALLBACK成功推导“对方LED已亮”。
参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制
参数、原理和文件
两板都是STA接口,固定同一信道6,不关联AP;W01_CHANNEL可在本地实际国家许可范围内选1..11,两端必须相同。peer.channel=0表示使用当前接口信道,不是“自动跨信道查找”。Wi-Fi和ESP-NOW初始化顺序见main/main.c。
16字节线格式是W1、版本1、目标电平0/1、64位启动随机标识、32位序号,均手工按大端写入,不直接发送C结构体。长度、版本、非零标识必须匹配。此篇接收端不保存序号去重,重复SET会再次调用GPIO;SET绝对电平避免“每重发一次又翻转一次”。发送端每次按钮事件本地翻转desired,但没收到应用确认时它不代表接收端当前状态。
main/pair_policy.h是字节/按钮纯逻辑,main/main.c才调用IDF。接收/发送回调运行于高优先级Wi-Fi任务,只复制定长数据/MAC到16项队列,零等待;不保留info/src_addr指针、不延时或刷屏。主循环处理GPIO/日志;溢出打印CALLBACK_QUEUE_DROPS。只允许一笔底层发送未完成,2秒未见回调锁闭至重启;按钮忙时打印忽略,不无限排队。
参考:[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供电选项
两角色操作
按下列具体步骤操作,并保存对应原始记录。
- 接收板用build-receiver:W01_SENDER关闭,peer=发送板实际STA,信道6。发送板用build-sender:W01_SENDER开启,peer=接收板实际STA,信道6。先按公共步骤读各板MAC,再更改配置重刷。
- 确认两板STA/role/channel日志和外设电路;稳定松开后按一次。发送端应看到SEND_API seq递增、err;成功排入后才等待MAC_CALLBACK。
- 接收端看到GPIO_SET boot/seq/level,观察LED并写表;再按一次应请求相反电平。长期按住只产生一次事件,启动按住需先释放再按。
- 断电接收板再按发送按钮,记录API与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供电选项
诊断顺序
按下列具体步骤操作,并保存对应原始记录。
| 现象 | 检查 | 恢复 |
|---|---|---|
| CLOSED | peer是否仍是零、组播、错角色/本机地址 | 重新核对STA,menuconfig后构建刷对应板 |
| API返回ESP_ERR_ESPNOW_NOT_FOUND | 是否已成功加peer、接口一致 | 查启动错误,修配置后重启 |
| API成功、MAC失败 | 对端电源、同信道、地址、距离和干扰 | 近距离重试,分别记录,不假定LED状态 |
| MAC成功、无GPIO_SET | 对端固件、帧版本长度、peer过滤、队列丢弃 | 查对端串口/版本及队列计数 |
| GPIO_SET但LED不亮 | GPIO6实际针脚、电阻、A/K、回路 | 断电重接,必要时测电平,不改软件掩盖接线 |
| 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供电选项
手工观察表与通过条件
observations.csv初始只有表头。每个按钮事件记发送/接收日志、同信道、seq、电平和真实光学现象;成功项要求指定板上同一命令链可关联。至少测试ON/OFF各一次、启动按住、忙时按钮、接收板断电。计数和距离只有真实执行后才填写,不把8组主机测试当8次无线送达。
参考:[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供电选项
版本源入口
按下列具体步骤操作,并保存对应原始记录。
- IDF5.5.5 esp_now.h:新send-info回调、peer.channel和数据生命周期。
- 官方ESP-NOW章节:发送回调只到MAC层、建议应用ACK/重发/序号以及回调任务限制。
- 后续W02增加应用ACK/有限重试/同启动会话去重,W03处理AP共存信道。
参考:[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 <stdio.h>
#include <assert.h>
#include <inttypes.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 "pair_policy.h"
#ifdef CONFIG_W01_SENDER
#define SENDER true
#else
#define SENDER false
#endif
#define BUTTON GPIO_NUM_5
#define LED GPIO_NUM_6
static const char *TAG="W01";
typedef struct {bool tx;uint8_t mac[6],data[PAIR_BYTES];int size,status;} Event;
static QueueHandle_t events;
static portMUX_TYPE drop_mux=portMUX_INITIALIZER_UNLOCKED;
static uint32_t drops;
static void post(const Event *e){
if(xQueueSend(events,e,0)!=pdTRUE){portENTER_CRITICAL(&drop_mux);drops++;portEXIT_CRITICAL(&drop_mux);}
}
static void recv_cb(const esp_now_recv_info_t *info,const uint8_t *data,int size){
if(!info||!info->src_addr||!data||size!=PAIR_BYTES)return;
Event e={.size=size};memcpy(e.mac,info->src_addr,6);memcpy(e.data,data,PAIR_BYTES);post(&e);
}
static void sent_cb(const esp_now_send_info_t *info,esp_now_send_status_t status){
if(!info||!info->des_addr)return;
Event e={.tx=true,.status=status};memcpy(e.mac,info->des_addr,6);post(&e);
}
void app_main(void){
gpio_config_t out={.pin_bit_mask=1ULL<<LED,.mode=GPIO_MODE_OUTPUT};ESP_ERROR_CHECK(gpio_config(&out));ESP_ERROR_CHECK(gpio_set_level(LED,0));
gpio_config_t in={.pin_bit_mask=1ULL<<BUTTON,.mode=GPIO_MODE_INPUT,.pull_up_en=GPIO_PULLUP_DISABLE,.pull_down_en=GPIO_PULLDOWN_DISABLE};ESP_ERROR_CHECK(gpio_config(&in));
/* Do not silently erase unrelated NVS on an initialization failure. */
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_W01_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; USB UART0 115200",MAC2STR(own),SENDER?"sender":"receiver",CONFIG_W01_CHANNEL);
if(!pair_mac(CONFIG_W01_PEER_MAC,peer)||!memcmp(own,peer,6)){
ESP_LOGE(TAG,"CLOSED: set other board's actual STA MAC in menuconfig; LED stays OFF");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));
/* Unencrypted bench exercise: fixed source address is a filter, not authentication. */
uint64_t boot;esp_fill_random(&boot,sizeof(boot));if(!boot)boot=1;
uint32_t seq=0,last_drops=0;bool desired=false,inflight=false,radio_stalled=false;uint64_t send_at=0;
PairButton button={.candidate=gpio_get_level(BUTTON)==0,.stable=true,.since=(uint64_t)esp_timer_get_time()};
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){inflight=false;ESP_LOGI(TAG,"MAC_CALLBACK status=%d; NOT application acknowledgement",e.status);continue;}
uint64_t remote_boot;uint32_t remote_seq;bool on;
if(!SENDER&&pair_decode(e.data,e.size,&remote_boot,&remote_seq,&on)){
ESP_ERROR_CHECK(gpio_set_level(LED,on));ESP_LOGI(TAG,"GPIO_SET boot=%016" PRIx64 " seq=%" PRIu32 " level=%d; optical result unmeasured",remote_boot,remote_seq,on);
}
}
bool pressed=pair_button(&button,gpio_get_level(BUTTON)==0,now);
if(SENDER&&pressed){
if(inflight||radio_stalled){ESP_LOGW(TAG,"BUTTON_IGNORED: one radio send pending/stalled");}
else if(seq==UINT32_MAX){ESP_LOGE(TAG,"SEQ_EXHAUSTED: restart pair; no wrap reuse");}
else{
uint8_t bytes[PAIR_BYTES];desired=!desired;pair_encode(bytes,boot,++seq,desired);esp_err_t err=esp_now_send(peer,bytes,sizeof(bytes));
ESP_LOGI(TAG,"SEND_API seq=%" PRIu32 " level=%d err=%s",seq,desired,esp_err_to_name(err));
if(err==ESP_OK){inflight=true;send_at=now;}
}
}
if(inflight&&now-send_at>2000000&&!radio_stalled){radio_stalled=true;ESP_LOGE(TAG,"SEND_CALLBACK_TIMEOUT: radio closed until restart; no unbounded sends");}
portENTER_CRITICAL(&drop_mux);uint32_t count=drops;portEXIT_CRITICAL(&drop_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;
}参考:[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 "W01 fixed ESP-NOW bench pair"
config W01_SENDER
bool "Sender role (off=receiver)"
default n
config W01_PEER_MAC
string "Other board STA MAC; zero placeholder keeps radio application closed"
default "00:00:00:00:00:00"
config W01_CHANNEL
int "Common fixed 2.4 GHz channel (bench subset1..11)"
range 1 11
default 6
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效果数据。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。