无线与 USB 拓展 / 完整例程与故障实验发布 内容更新

ESP-NOW一接Wi-Fi就失联:节点跟随网关的实际信道

网关连接受控AP,节点在许可子集中发现信道;处理信标新鲜度、同信道重连与新会话,避免旧命令自动重放。

读完能做什么
网关连接受控AP,节点在许可子集中发现信道;处理信标新鲜度、同信道重连与新会话,避免旧命令自动重放。
开始之前
能构建固定IDF工程、辨认自己两板STA MAC与低压接线,并先完成所列前置。
所需器材
两块官方S3-DevKitC-1 v1.1 / WROOM-1-N8R8、各自桥接USB数据线、常开按钮、10kΩ上拉、可靠面包板/短线、Kingbright WP7113ID LED、1kΩ串联电阻、自己管理的2.4GHz WPA2/20MHz AP,实际许可信道1..11
版本组合
SDK:ESP-IDF5.5.5 / 原生esp_now_send_info_t回调工具:Python3.12.13 / GCC14.2.0 esp-14.2.0_20260121硬件:DevKitC-1 v1.1 N8R8 / 8MiB QIO / OPI PSRAM80MHz
适用范围

仅官方ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8、IDF5.5.5、桥UART115200;8MiB QIO/OPI PSRAM80MHz;未加密个人LED台架。没有本站无线、GPIO、光学或AP实板结果。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/wireless-sources.json 与 wireless.md:固定官方源/安装SDK逐字比较、厂商极性核查。验证日期:
构建 / 配置已通过
docs/esp32-evidence/wireless-builds.json:两角色实际完整构建、原日志与输入/最终产物SHA;公开占位只用于编译。对象:esp32s3 / N8R8 two roles版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/wireless-host.json:14组合成同源检查;不是ESP32射频/GPIO/时延仿真。对象:macOS arm64 ASan/UBSan same-source policy fixture验证日期:
本站实板待验证
没有连接两块指定板、AP或光学/电气仪表;observations.csv仅表头,台架步骤待实际执行。

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

01 / 完整例程与故障实验

先确定这一步能证明什么

网关STA连接自己的受控2.4GHz WPA2 AP,ESP-NOW复用这台无线电当前信道;节点不连AP,轮流在许可子集监听网关HELLO。此篇解决共存时的发现与失联恢复,不是跨信道同时收发,也不是扫描附近网络的工具。

参考:[1] IDF5.5.5 ESP-NOW:回调签名、数据生命周期与peer.channel[2] 固定官方ESP-NOW章节:MAC结果、应用ACK与高优先级回调限制

02 / 完整例程与故障实验

信道归属与固定范围

网关关联AP后禁止esp_wifi_set_channel:实际信道由AP连接决定,代码通过esp_wifi_sta_get_ap_info和esp_wifi_get_channel读取。peer.channel=0使用当前无线电信道。本例只支持实际1..11,受控AP固定20MHz/单2.4GHz SSID,关闭自动信道和漫游引导;不强制改国家。节点读esp_wifi_get_country,把许可范围与1..11求交后依次驻留350ms;没有可用交集就拒绝启动。此子集不是全球统一法律许可,应以实际设备/地区配置为准。

网关取得IPv4后才每200ms发W3 HELLO。节点在回调执行时复制rx_ctrl.channel,并调用esp_timer_get_time记录64位入队时刻;250ms只约束回调到主循环的排队年龄,不含RF接收到回调之前的延迟,不保证真实空中包龄。仅当前监听信道、非零会话、排队年龄不超过250ms的HELLO可锁定,不能用出队时刻把旧包“刷新”。3秒没见有效HELLO取消待确认命令、回到发现;底层发送未回调时禁止跳信道。约3.85秒是11×350ms的驻留调度预算,不是发现成功时限或实测延迟,丢包和时序可能导致多轮扫描。

参考:[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供电选项

03 / 完整例程与故障实验

AP断线和同信道重连也必须换会话

WIFI_EVENT断开及IP_EVENT GOT_IP/LOST_IP同时维护IPv4资格和dirty位;主循环原子读取/清除dirty,每500ms检查实际信道。即使快速断开又重连到同一信道、主循环没来得及看到离线,也建立新gateway nonce、清空RAM去重绑定并请求LED OFF。主循环观察到资格撤销后不发合格HELLO、不接受新控制。异步网络事件到主循环处理有调度窗口,已经在飞或已进入处理的命令无法按真实AP断开的瞬间同步撤回,执行结果仍可能不确定;本例不是安全切断控制器。IP_LOST后等待fresh GOT_IP,不拿旧IP缓存继续宣称可用。

新网关epoch使节点取消旧待确认项,不把旧命令自动带入新会话。W3使用32字节W2相同布局但独立magic,拒收W2,沿用双方启动标识/seq/值关联、四次300ms尝试与同会话去重。节点重启后sender_boot改变,也需要网关手工重启或受控AP重新建epoch;否则旧绑定拒绝新启动。网关请求OFF是新epoch的软件动作,不代表物理断电安全状态。

参考:[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供电选项

04 / 完整例程与故障实验

配置和成功检查

按下列具体步骤操作,并保存对应原始记录。

  1. 用自己管理的AP设置2.4GHz信道6、20MHz、WPA2,记录实际设置。不要使用他人的SSID进行试验。准备节点/网关各自实际STA MAC。
  2. 网关build-gateway开启W03_GATEWAY,填节点STA和本地AP SSID/8..63字节密码;节点build-node关闭W03_GATEWAY,peer填网关STA。本地sdkconfig含密码,不能上传,公开默认REPLACE占位会CLOSED。
  3. 网关先启动,确认GOT_IP及GATEWAY_EPOCH ready=1 actual_channel=6,节点打印SCAN/LOCK实际channel=6与网关nonce。只有LOCK后新按钮才会开始应用命令,MAC成功不会替代应用ACK。
  4. 按按钮,关联节点seq/APP_ACK和网关RX_COMMAND epoch_apply_count;观察LED并记空表。正常期节点不继续跳信道,网关从不手工改已连接信道。
  5. 主动断开AP,记录AP_DISCONNECTED/IP_LOST、节点BEACON_TIMEOUT/DISCOVERY_RESTART和待确认“不确定”;恢复同一信道,应看到不同nonce,未自动重放旧命令。再按按钮验证新命令。
  6. 将受控AP关机/重新配置到信道1或11并重新上线。先看网关实际新信道,再看节点扫描/LOCK,最后按新按钮验证。不能只改路由器UI数值就假定网关已迁移。恢复原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供电选项

05 / 完整例程与故障实验

按顺序诊断

observations.csv把AP配置值、网关实际信道、节点扫描值、epoch、应用确认、真实观察分别保存。送达比例分母必须是同条件下实际发起的独立新命令数,分子为有效应用ACK确认的新命令数,重试包不重复计新命令;它仍不是光学执行率。当前只有14组同源合成检查和两角色实际构建,没有真实AP迁移、延迟、距离、干扰或送达率数据。

现象首查下一步
网关CLOSEDpeer占位、SSID占位、密码长度menuconfig本地填实值并构建重刷
AP_DISCONNECTED反复2.4GHz/WPA2/凭据/供电及reason先独立完成普通STA连网,不改ESP-NOW重试掩盖
网关ready=0IPv4资格和实际信道是否在1..11查DHCP/AP实际设置;不强行set_channel
节点一直SCAN实际信道是否在本节点许可子集、双方MAC、W3固件近距离查网关GOT_IP、HELLO与队列丢弃
LOCK后超时干扰、AP切换/离线、旧包/无线回调查双方时间线;失联后要求新用户命令
status2或单独重启节点失败网关绑定旧sender_boot按文中受控重启网关/建新epoch恢复

参考:[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供电选项

06 / 完整例程与故障实验

网络安全范围

ESP-NOW unicast encrypt=false,固定MAC只是过滤;AP的WPA2不会自动加密本例ESP-NOW载荷。CRC与随机epoch不提供消息认证。此处只连接隔离的个人台架、控制LED;需要生产安全先设计配对、密钥生命周期、加密与授权,不把本实验移植为门锁/继电器指令。

参考:[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供电选项

07 / 完整例程与故障实验

固定组合与器材

即使将LED压降按0V作保守上界,3.3V/1kΩ也只有3.3mA;这用于限流选值,不是实测亮度或GPIO总电流资格。GPIO6高电平只是软件请求;用眼睛/仪器观察LED再填写表,不能把日志当光学验证。

  1. 两块乐鑫 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8,8MiB Flash、8MiB Octal PSRAM。其他模组/修订未验证。
  2. 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回调教程。
  3. 每块板各用USB-to-UART桥口、数据线连接电脑,UART0 115200。本例不占原生USB GPIO19/20。每块只选USB供电,不同时接外部5V或3V3;两板无需额外相连3V3、GPIO或GND,低压元件各接自己板的GND。
  4. 发送端/节点:常开瞬时按钮、外接10kΩ上拉、短杜邦线。接收端/网关:Kingbright WP7113ID 红色通孔LED、1kΩ串联电阻;焊接或插入可靠面包板连接,断电后检查。
所在板接点连接与理由
发送端/节点GPIO5按钮一端;另一端GND;同点10kΩ到本板3V3。按下为LOW,代码未启内部拉阻。
接收端/网关GPIO61kΩ→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供电选项

08 / 完整例程与故障实验

构建、先读MAC、再配对

安装并激活官方IDF5.5.5环境(Windows在ESP-IDF终端,macOS/Linux先运行安装路径下export脚本),解压本项目并进入项目根目录。下面使用SDKCONFIG独立目录,避免两角色配置相互污染;路径含分号的defaults参数必须完整加引号。

set-target应在新目录执行,再menuconfig;不要在已配置peer/凭据后重复set-target造成配置重置。角色、peer与信道/凭据都在该篇W0x菜单中。对两板分别执行,用后文角色目录替换build-local。

  1. 给每板贴角色标签,记下串口。先用公开默认零peer MAC构建/刷写,读取本板实际STA地址。零地址会打印CLOSED、LED保持OFF,不会开始ESP-NOW应用;W03在校验前直接读取芯片STA MAC,其余两篇先启动固定信道STA再关闭应用。不要拿SoftAP、蓝牙地址或示意MAC替代。
  2. 给接收端/网关的menuconfig填发送端/节点STA MAC,反向也填;只有完整单播、非零且不是本机的17字符地址才放行。公开config/*.compile-only.defaults中的02:00…均是人为本地管理地址,只为实际编译角色分支,不是实测地址,不可刷入当配对结果。
  3. 各角色使用下文的独立构建目录和menuconfig设置;先启动接收端/网关,再启动发送端/节点。开始前松开按钮至少50ms,上电时一直按住不会自动触发。
  4. 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供电选项

09 / 完整例程与故障实验

验证范围和复现

本项目的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供电选项

10 / 完整例程与故障实验

许可与上游

本站应用代码、策略头、主机夹具、图文为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供电选项

11 / 完整例程与故障实验

实际构建的完整主程序与同源策略

以下各文件与完整下载工程对应;带文件名的注释用于分隔,构建时仍使用独立原文件。接线、Kconfig、CMake、默认配置、主机夹具和许可一并在ZIP中。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <assert.h>
#include <stdio.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/event_groups.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 "channel_policy.h"
#ifdef CONFIG_W03_GATEWAY
#define GATEWAY true
#else
#define GATEWAY false
#endif
static const char *TAG="W03";
static EventGroupHandle_t network;static esp_timer_handle_t retry_timer;
static void reconnect(void *arg){(void)arg;if(!(xEventGroupGetBits(network)&BIT0)){esp_err_t e=esp_wifi_connect();ESP_LOGI(TAG,"CONNECT_REQUEST=%s",esp_err_to_name(e));}}
static void wifi_event(void *arg,esp_event_base_t base,int32_t id,void *data){
 (void)arg;
 if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START)reconnect(NULL);
 else if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){wifi_event_sta_disconnected_t *e=data;xEventGroupClearBits(network,BIT0);xEventGroupSetBits(network,BIT1);ESP_LOGW(TAG,"AP_DISCONNECTED reason=%u; no qualified beacon/command",e->reason);esp_timer_stop(retry_timer);ESP_ERROR_CHECK(esp_timer_start_once(retry_timer,2000000));}
 else if(base==IP_EVENT&&id==IP_EVENT_STA_LOST_IP){xEventGroupClearBits(network,BIT0);xEventGroupSetBits(network,BIT1);ESP_LOGW(TAG,"IP_LOST: qualified gateway beacons/commands disabled until fresh GOT_IP");}
 else if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP){ip_event_got_ip_t *e=data;xEventGroupSetBits(network,BIT0|BIT1);ESP_LOGI(TAG,"GOT_IP="IPSTR,IP2STR(&e->ip_info.ip));}
}
static bool gateway_online(void){return network&&(xEventGroupGetBits(network)&BIT0);}
static bool credentials_ready(void){size_t s=strlen(CONFIG_W03_SSID),p=strlen(CONFIG_W03_PASSWORD);return s>0&&s<=32&&p>=8&&p<=63&&strcmp(CONFIG_W03_SSID,"REPLACE_WITH_2G4_SSID")&&strcmp(CONFIG_W03_PASSWORD,"REPLACE_WITH_PASSWORD");}
static void start_wifi(void){
 ESP_ERROR_CHECK(nvs_flash_init());ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());
 if(GATEWAY){assert(esp_netif_create_default_wifi_sta());network=xEventGroupCreate();assert(network);esp_timer_create_args_t t={.callback=reconnect,.name="ap_retry"};ESP_ERROR_CHECK(esp_timer_create(&t,&retry_timer));ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL));ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,ESP_EVENT_ANY_ID,wifi_event,NULL));}
 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));
 if(GATEWAY){wifi_config_t c={0};memcpy(c.sta.ssid,CONFIG_W03_SSID,strlen(CONFIG_W03_SSID));memcpy(c.sta.password,CONFIG_W03_PASSWORD,strlen(CONFIG_W03_PASSWORD));c.sta.threshold.authmode=WIFI_AUTH_WPA2_PSK;ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&c));}
 ESP_ERROR_CHECK(esp_wifi_start());ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
}
typedef struct {bool tx;uint8_t mac[6],data[WIRE_BYTES],channel;int size,status;uint64_t received_at;} 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||!i->rx_ctrl||!d||n!=WIRE_BYTES)return;Event e={.size=n,.channel=i->rx_ctrl->channel,.received_at=(uint64_t)esp_timer_get_time()};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 b[WIRE_BYTES];wire_encode(f,b);esp_err_t e=esp_now_send(peer,b,sizeof(b));ESP_LOGI(TAG,"SEND_API kind=%u seq=%"PRIu32" err=%s",f->kind,f->seq,esp_err_to_name(e));if(e==ESP_OK){busy=true;radio_at=now;}}
static uint64_t nonce(void){uint64_t x;esp_fill_random(&x,sizeof(x));return x?x:1;}
void app_main(void){
 gpio_config_t out={.pin_bit_mask=1ULL<<GPIO_NUM_6,.mode=GPIO_MODE_OUTPUT};ESP_ERROR_CHECK(gpio_config(&out));ESP_ERROR_CHECK(gpio_set_level(GPIO_NUM_6,0));gpio_config_t in={.pin_bit_mask=1ULL<<GPIO_NUM_5,.mode=GPIO_MODE_INPUT};ESP_ERROR_CHECK(gpio_config(&in));
 uint8_t own[6],peer[6];ESP_ERROR_CHECK(esp_read_mac(own,ESP_MAC_WIFI_STA));ESP_LOGI(TAG,"STA="MACSTR" role=%s",MAC2STR(own),GATEWAY?"gateway":"node");
 if(!pair_mac(CONFIG_W03_PEER_MAC,peer)||!memcmp(own,peer,6)||(GATEWAY&&!credentials_ready())){ESP_LOGE(TAG,"CLOSED: actual peer STA MAC and gateway AP credentials required; no radio application started");return;}
 start_wifi();ChannelScan scan={0};
 if(!GATEWAY){wifi_country_t country;ESP_ERROR_CHECK(esp_wifi_get_country(&country));assert(channel_scan_init(&scan,country.schan,country.nchan,(uint64_t)esp_timer_get_time()));ESP_ERROR_CHECK(esp_wifi_set_channel(scan.current,WIFI_SECOND_CHAN_NONE));ESP_LOGI(TAG,"SCAN permitted subset=%u..%u,350ms/channel; no AP scan/connect on node",scan.low,scan.high);}
 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 sender_boot=nonce(),poll_at=0,hello_at=0;ESP_LOGI(TAG,"NODE_BOOT=%016"PRIx64"; no automatic old-command replay across AP epoch",sender_boot);uint32_t sequence=0,last_drops=0;bool desired=false,gw_ready=false;uint8_t gw_channel=0;Receiver rx={0};Sender tx={0};PairButton button={.candidate=gpio_get_level(GPIO_NUM_5)==0,.stable=true,.since=(uint64_t)esp_timer_get_time()};
 for(;;){
  uint64_t now=(uint64_t)esp_timer_get_time();
  bool network_changed=GATEWAY&&network&&(xEventGroupClearBits(network,BIT1)&BIT1);
  if(GATEWAY&&(network_changed||now>=poll_at||(!gateway_online()&&gw_ready))){
   poll_at=now+500000;wifi_ap_record_t ap={0};uint8_t primary=0;wifi_second_chan_t second;bool ready=gateway_online()&&esp_wifi_sta_get_ap_info(&ap)==ESP_OK&&esp_wifi_get_channel(&primary,&second)==ESP_OK&&primary>=1&&primary<=11;
   if(gateway_new_epoch(gw_ready,gw_channel,ready,primary,network_changed)){
    rx=(Receiver){.own=ready?nonce():0};ESP_ERROR_CHECK(gpio_set_level(GPIO_NUM_6,0));hello_at=now;ESP_LOGI(TAG,"GATEWAY_EPOCH ready=%u actual_channel=%u nonce=%016"PRIx64"; LED command resetOFF",ready,primary,rx.own);
   }
   gw_ready=ready;gw_channel=primary;
  }
  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; not application ACK",e.status);continue;}
   Frame f;if(!wire_decode(e.data,e.size,&f))continue;
   if(!GATEWAY&&f.kind==HELLO){
    uint64_t previous=scan.receiver;
    if(channel_beacon(&scan,e.channel,f.receiver,e.received_at,(uint64_t)esp_timer_get_time())){
     if(previous!=scan.receiver){if(tx.pending)ESP_LOGW(TAG,"NEW_GATEWAY_EPOCH: cancel pending, execution uncertain");tx.pending=false;ESP_LOGI(TAG,"LOCK actual_rx_channel=%u gateway_nonce=%016"PRIx64,scan.current,scan.receiver);}
    }
   }else if(!GATEWAY&&sender_match(&tx,&f)){
    tx.pending=false;ESP_LOGI(TAG,"APP_ACK seq=%"PRIu32" status=%u on_channel=%u; GPIO/API result only",f.seq,f.status,scan.current);
    if(f.status>1)channel_invalidate(&scan,(uint64_t)esp_timer_get_time());
   }else if(GATEWAY&&f.kind==COMMAND&&gw_ready&&gateway_online()&&e.channel==gw_channel){
    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 epoch_apply_count=%"PRIu64,f.seq,status,rx.applications);
    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 radio send; peer may retry");
   }
  }
  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 manual reset; uncertain execution");}
  if(!GATEWAY){
   if(channel_expire(&scan,now)){if(tx.pending)ESP_LOGW(TAG,"BEACON_TIMEOUT: pending execution uncertain");tx.pending=false;ESP_LOGW(TAG,"DISCOVERY_RESTART; no automatic replay into new gateway epoch");}
   bool press=pair_button(&button,gpio_get_level(GPIO_NUM_5)==0,now);
   if(press){if(!scan.locked||tx.pending||busy||stalled)ESP_LOGW(TAG,"BUTTON_IGNORED: require locked channel/no pending radio");else if(sequence==UINT32_MAX)ESP_LOGE(TAG,"SEQ_EXHAUSTED: reset pair");else{desired=!desired;Frame f={.kind=COMMAND,.sender=sender_boot,.receiver=scan.receiver,.seq=++sequence,.on=desired};sender_begin(&tx,f,now);}}
   unsigned action=sender_action(&tx,now);
   if(action&&!busy&&!stalled){if(action==2){tx.pending=false;channel_invalidate(&scan,now);ESP_LOGW(TAG,"APP_ACK_TIMEOUT after4attempts; execution uncertain, rediscover");}else{sender_attempt(&tx,now);send_frame(peer,&tx.command,now);}}
   uint8_t next=channel_step(&scan,now,busy||stalled);if(next){ESP_ERROR_CHECK(esp_wifi_set_channel(next,WIFI_SECOND_CHAN_NONE));ESP_LOGI(TAG,"SCAN_CHANNEL=%u",next);}
  }else if(gw_ready&&gateway_online()&&now>=hello_at&&!busy&&!stalled){hello_at=now+200000;Frame f={.kind=HELLO,.receiver=rx.own};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]='3';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]!='3'||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;}

// ---- main/channel_policy.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include "reliable_policy.h"
typedef struct {uint8_t low,high,current;bool locked;uint64_t receiver,seen,next;} ChannelScan;
static inline bool channel_scan_init(ChannelScan *s,unsigned first,unsigned count,uint64_t now){
 if(first<1||first>14||!count||count>14||first+count-1>14)return false;
 unsigned last=count?first+count-1:0,low=first<1?1:first,high=last>11?11:last;
 if(!count||low>high)return false;
 *s=(ChannelScan){.low=(uint8_t)low,.high=(uint8_t)high,.current=(uint8_t)low,.next=now+350000};return true;
}
/* Callback metadata is copied while valid; a queued old-channel beacon cannot
   lock a new channel or reset age to the later processing timestamp. */
static inline bool channel_beacon(ChannelScan *s,uint8_t received_channel,uint64_t receiver,uint64_t received_at,uint64_t now){
 if(!receiver||received_channel!=s->current||received_channel<s->low||received_channel>s->high||now<received_at||now-received_at>250000)return false;
 s->locked=true;s->receiver=receiver;s->seen=received_at;return true;
}
static inline bool channel_expire(ChannelScan *s,uint64_t now){
 if(!s->locked||(now>=s->seen&&now-s->seen<=3000000))return false;
 s->locked=false;s->receiver=0;s->next=now;return true;
}
static inline void channel_invalidate(ChannelScan *s,uint64_t now){s->locked=false;s->receiver=0;s->next=now;}
static inline uint8_t channel_step(ChannelScan *s,uint64_t now,bool radio_busy){
 if(s->locked||radio_busy||now<s->next)return 0;
 s->current=s->current>=s->high?s->low:s->current+1;s->next=now+350000;return s->current;
}
static inline bool gateway_new_epoch(bool old_ready,uint8_t old_channel,bool ready,uint8_t channel,bool network_changed){return network_changed||old_ready!=ready||(ready&&old_channel!=channel);}

参考:[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

12 / 完整例程与故障实验

默认关闭的配对配置与硬件范围

公开默认peer为零地址,W03另有AP凭据占位。先按步骤读取并手工填入实际STA地址;编译夹具只证明角色代码已链接,不是板卡运行配置或观察结果。

代码 / 配置
# ---- main/Kconfig.projbuild ----
menu "W03 fixed-peer AP and ESP-NOW coexistence"
config W03_GATEWAY
    bool "Gateway joins the controlled AP (off=node scans)"
    default n
config W03_PEER_MAC
    string "Other board actual STA MAC (zero keeps application closed)"
    default "00:00:00:00:00:00"
config W03_SSID
    string "Gateway2.4GHz controlled AP SSID"
    default "REPLACE_WITH_2G4_SSID"
config W03_PASSWORD
    string "Gateway AP password8..63 bytes, local sdkconfig only"
    default "REPLACE_WITH_PASSWORD"
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供电选项

BACK TO THE SOURCE

资料来源与版本说明

实际读取固定版本的API、官方示例与板卡针脚/供电章节,核对安装SDK字节;分别归档真实两角色构建和同源主机检查。未执行指定板台架,无本站无线送达率、距离或LED效果数据。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  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. Kingbright WP7113ID V.14A:通孔LED极性与规格
  7. IDF5.5.5 UART VFS:非阻塞标志与读取
  8. IDF5.5.5默认STA网络接口
  9. IDF5.5.5事件注册和事件ID