低功耗与电池 / 可复现实验与完整工程发布 内容更新

Wi-Fi、ESP-NOW 和 BLE 谁更省电?按同一个任务测

用六个完整角色、同一20B任务与应用ACK,建立包含失败和深睡的整周期能量对照;没有本站协议排名。

读完能做什么
用六个完整角色、同一20B任务与应用ACK,建立包含失败和深睡的整周期能量对照;没有本站协议排名。
开始之前
完成所列前置,能激活固定IDF、断电核对板图、保存原始日志,并按仪器手册接线。
所需器材
两块官方ESP32-S3-DevKitC-1 v1.1,WROOM-1-N8R8;可靠焊接排针。、PPK2 Source Meter5.0V/600mA、高阻DMM;可选合适3.3V高阻逻辑探头。、固定距离/天线姿态台面与独立接收器电源,不接外部传感器。
版本组合
ESP-IDF:5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4f工具链:GCC14.2.0 / esp-14.2.0_20260121;Python3.12.13NimBLE:IDF gitlink 685675c0128deafdd201c9eb82e61d227364646c角色锁:CMake L08_ROLE=1..6;dependencies.lock/main manifest;默认零peer禁用ESP-NOW
适用范围

ESP-IDF5.5.5;两块官方S3 v1.1 N8R8、QIO80/8MB、OPI PSRAM80。发送端每约60s冷启动链路,20B公开合成任务;接收端常开且能量另报。只比较此实现/边界,没有RF或电流实测。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/low-power-field.md#l08验证日期:
构建 / 配置已通过
docs/esp32-evidence/low-power-field-builds.json对象:实际固定目标构建Exit0;输入、生成配置、最终bin/ELF/map/CPP及原日志SHA可复查版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/low-power-field-hosts.json对象:同源逻辑与实际本机服务检查;不模拟射频、电气、电流或电池验证日期:
本站实板待验证
没有实板/仪器/无线/ADC/电池或七天运行,实际观察表仅表头。

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

01 / 可复现实验与完整工程

先把“省电”改成同一项可交付任务

L02教你量整板,L06比较每次上传与攒批。本篇换一个决策:同一块发送板每约60秒生成一个20B应用消息,收端确认接受后深睡,换三套无线链路,怎样比较每次有效送达付出的能量?读者最终交付三组原始trace、双板日志、失败统计和一个带边界的比较结果。

不增加真实传感器变量,值固定为23000+sequence%17,明确是合成负载。每周期先准备链路最多8s,再最多3次应用发送,每次等待ACK1s。发送成功回调、UDP send和ATT write response各自只说明链路的一段;只有完整匹配的20B应用回显计一次有效送达。

这三套是公开台面协议:Wi-Fi open SoftAP、未加密ESP-NOW、未配对GATT。接收器常开能量单独报告,不包含互联网网关/云/生产认证握手。因此结论只能写“这块板、这个周期、这三个实现与环境”,不能由一个峰值或配置getter宣布某协议永远最省。

做到这里,你应该看到
先填写任务、供电、接收器是否计入与安全策略;缺这些边界,暂不比较能量。

参考:[1] IDF5.5.5 Wi-Fi初始化/功率/PS接口[2] IDF5.5.5 ESP-NOW peer/channel/send回调边界[3] 固定NimBLE GAP扫描、连接与参数单位[4] 固定NimBLE GATT写回调、读值与服务器访问[5] 官方DevKitC-1 v1.1排针与供电[6] PPK2 Source Meter端子[7] PPK2最大DUT电流:Source600mA/Ampere1A

02 / 可复现实验与完整工程

三条链路共享的是业务口径

WireModel把20B逐字编码:4B头(L、8、版本1、DATA/ACK类型),run64小端、sequence32小端、合成值32小端;不直接发送带编译器填充的struct。接收器核长度/版本/run/规范值,保存最近64个序号;新消息改变RAM接受计数后回ACK,重复只回ACK,窗口外旧序号拒绝。

接收器复位会忘记RAM去重。发送器的RTC只用序号与反码跨正常TIMER深睡,不跨普通复位提供持久身份。双方run必须相同,每个新试验/普通复位/RTC失效/接收板重启都换新的非零公开run并重记trial。sequence达到UINT32_MAX停试验,不绕回1。

角色准备与确认必须另记的成本
1/2 Wi-Fi信道6/HT20 SoftAP→STA关联→UDP45678→应用回显PS_NONE、100ms beacon、DHCP/关联;不借现有LAN缓存路径
3/4 ESP-NOW固定信道6双向单播peer→工作队列→应用ACK全零MAC占位禁用;MAC层成功不是业务确认
5/6 BLE中央扫描/连接/发现→20B write response→read应用ACKMTU23、100ms广播,请求40ms连接间隔,记录协商实值

参考:[1] IDF5.5.5 Wi-Fi初始化/功率/PS接口[2] IDF5.5.5 ESP-NOW peer/channel/send回调边界[3] 固定NimBLE GAP扫描、连接与参数单位[4] 固定NimBLE GATT写回调、读值与服务器访问

03 / 可复现实验与完整工程

先功能验收,再切到仪器供电

下载工程含六个角色,main/CMakeLists只编当前radio TU。先在USB开发条件记双方STA MAC,在lab_config.c给双方新run;ESP-NOW填另一板MAC,默认全零不会广播退化。先刷接收角色再刷发送角色,查看接收端唯一效果数与发送端RESULT。

Wi-Fi接收器开HX-L08-LAB/192.168.4.1,发送器只接受192.168.4.1:45678来的ACK;ESP-NOW回调仅复制到8格队列,工作任务核验;BLE用服务36fb49a7-8c8a-4c92-9d55-6cf2b877d781、特征末尾d782,写成功后显式读回当前ACK。不要用手机扫描成功代替中央/外设这套应用验收。

  1. 在固定5.5.5环境从工程目录执行六条role构建,命令见下;每个build目录保持单一角色。
  2. 替换真实端口flash monitor,先确认正确run/MAC/角色,没有CONFIG_DISABLED。
  3. 关接收端观察LINK_TIMEOUT,恢复新试验;改错run应无应用ACK;同序号重发只加duplicates,不加唯一效果。
  4. 普通复位任一板后换run并开新trial,先功能核验再接仪器。
代码 / 配置
idf.py -B build-wifi-tx -D IDF_TARGET=esp32s3 -D L08_ROLE=1 build
idf.py -B build-wifi-rx -D IDF_TARGET=esp32s3 -D L08_ROLE=2 build
idf.py -B build-now-tx -D IDF_TARGET=esp32s3 -D L08_ROLE=3 build
idf.py -B build-now-rx -D IDF_TARGET=esp32s3 -D L08_ROLE=4 build
idf.py -B build-ble-tx -D IDF_TARGET=esp32s3 -D L08_ROLE=5 build
idf.py -B build-ble-rx -D IDF_TARGET=esp32s3 -D L08_ROLE=6 build
idf.py -B build-wifi-rx -p YOUR_RECEIVER_PORT flash monitor
做到这里,你应该看到
固件构建与主机逻辑已验证;上列无线检查需读者实板执行,本次尚未执行。
04 / 可复现实验与完整工程

完整周期入口与三个传输实现

下面各文件与下载原样一致。main把GPIO4作为无LED的周期标记,应用开始HIGH,停止无线/刷新UART后LOW并RTC保持;下一应用开始HIGH。相邻边缘间覆盖一次应用工作、深睡及下一次到标记前启动成本,不能只裁发送峰。

RESULT的app_ack_us从应用入口算,排除ROM启动之前,不是单向空口延迟。定时器按active_app_us补到名义60s,实际周期仍有启动/时钟误差。所有非零stop错误或STOP_TIMEOUT都使该轮测量资格无效,保留原日志并排查。

代码 / 配置
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <inttypes.h>
#include "esp_sleep.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_mac.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "driver/rtc_io.h"
#include "nvs_flash.h"
#include "freertos/task.h"
#include "Radio.h"
#include "lab_config.h"
QueueHandle_t l08Acks;L08Receiver l08Receiver;uint64_t l08Run;
RTC_DATA_ATTR static uint32_t cycle=0;
RTC_DATA_ATTR static uint32_t inverseCycle=UINT32_MAX;
extern "C" void app_main(){
 const int64_t start=esp_timer_get_time();const bool sender=(L08_ROLE%2)==1;
 if(sender){ESP_ERROR_CHECK(rtc_gpio_hold_dis(GPIO_NUM_4));ESP_ERROR_CHECK(rtc_gpio_deinit(GPIO_NUM_4));gpio_config_t marker{};marker.pin_bit_mask=1ULL<<4;marker.mode=GPIO_MODE_OUTPUT;ESP_ERROR_CHECK(gpio_config(&marker));gpio_set_level(GPIO_NUM_4,1);}
 uint8_t mac[6];ESP_ERROR_CHECK(esp_read_mac(mac,ESP_MAC_WIFI_STA));
 printf("L08 role=%d WiFiSTA=%02x:%02x:%02x:%02x:%02x:%02x reset=%d wake=%d\n",L08_ROLE,mac[0],mac[1],mac[2],mac[3],mac[4],mac[5],esp_reset_reason(),esp_sleep_get_wakeup_cause());
 ESP_ERROR_CHECK(nvs_flash_init());l08Run=l08_run_id();l08Receiver.run=l08Run;l08Acks=xQueueCreate(4,L08_BYTES);if(!l08Acks||!l08Run){puts("CONFIG_OR_ALLOC_ERROR; no delivery result");return;}
 l08Init(sender);
 if(!sender){puts("RECEIVER_RAM_ONLY: always-on receiver energy is outside sender measurement; reset forgets dedup");return;}
 if(esp_reset_reason()!=ESP_RST_DEEPSLEEP||esp_sleep_get_wakeup_cause()!=ESP_SLEEP_WAKEUP_TIMER){cycle=0;inverseCycle=UINT32_MAX;puts("COLD_START: begin new trial; do not reuse run ID with receiver history");}
 else if((cycle^inverseCycle)!=UINT32_MAX){puts("RTC_STATE_INVALID: stop measurement, change run ID/reset BOTH boards");l08Stop();return;}
 if(cycle==UINT32_MAX){puts("SEQUENCE_EXHAUSTED: change run ID on both boards; no wrap or silent reuse");l08Stop();return;}
 ++cycle;inverseCycle=~cycle;L08Packet message{1,l08Run,cycle,l08Sample(cycle)};uint8_t packet[L08_BYTES];l08Encode(message,packet);
 bool delivered=false;unsigned attempts=0;int64_t ackUs=-1;bool ready=l08Ready(8000);
 if(ready){for(unsigned i=0;i<3&&!delivered;++i){++attempts;bool queued=l08Send(packet);printf("TRY seq=%" PRIu32 " n=%u local_queue=%u; not application ACK\n",cycle,attempts,queued);if(!queued){vTaskDelay(pdMS_TO_TICKS(100));continue;}
   const int64_t began=esp_timer_get_time();while(esp_timer_get_time()-began<1000000){uint8_t ack[L08_BYTES];if(xQueueReceive(l08Acks,ack,pdMS_TO_TICKS(20))==pdTRUE&&l08Matches(ack,sizeof ack,message)){delivered=true;ackUs=esp_timer_get_time()-start;break;}}
 }}
 printf("RESULT run=%016" PRIx64 " seq=%" PRIu32 " payload=20 synthetic=1 ready=%u attempts=%u app_ack=%u app_ack_us=%" PRId64 "; receiver RAM acceptance only\n",l08Run,cycle,ready,attempts,delivered,ackUs);
 l08Stop();const int64_t active=esp_timer_get_time()-start;const uint64_t remaining=active<60000000?uint64_t(60000000-active):1000000;
 printf("END active_app_us=%" PRId64 " sleep_timer_us=%" PRIu64 "; nominal app-start cycle60s plus boot overhead, measure actual full period\n",active,remaining);ESP_ERROR_CHECK(esp_sleep_enable_timer_wakeup(remaining));fflush(stdout);ESP_ERROR_CHECK(uart_wait_tx_idle_polling(UART_NUM_0));gpio_set_level(GPIO_NUM_4,0);ESP_ERROR_CHECK(rtc_gpio_init(GPIO_NUM_4));ESP_ERROR_CHECK(rtc_gpio_set_direction(GPIO_NUM_4,RTC_GPIO_MODE_OUTPUT_ONLY));ESP_ERROR_CHECK(rtc_gpio_set_level(GPIO_NUM_4,0));ESP_ERROR_CHECK(rtc_gpio_hold_en(GPIO_NUM_4));esp_deep_sleep_start();
}

// ---- main/Radio.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include "WireModel.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
// Each compiled image selects exactly one radio and role. Receiver effects are
// a bounded RAM acceptance counter, not durable business commit.
extern QueueHandle_t l08Acks;
extern L08Receiver l08Receiver;
extern uint64_t l08Run;
void l08Init(bool sender);
bool l08Ready(uint32_t timeoutMs);
bool l08Send(const uint8_t packet[L08_BYTES]);
void l08Stop();

// ---- main/WireModel.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <cstdint>
#include <cstddef>
#include <cstring>
constexpr size_t L08_BYTES=20;
struct L08Packet {uint8_t kind=1;uint64_t run=0;uint32_t seq=0;int32_t value=0;};
inline int32_t l08Sample(uint32_t seq){return 23000+int32_t(seq%17);}// Deliberately synthetic, not a measured temperature.
inline void l08Put(uint8_t* b,uint64_t value,unsigned n){for(unsigned i=0;i<n;++i)b[i]=uint8_t(value>>(i*8));}
inline uint64_t l08Get(const uint8_t* b,unsigned n){uint64_t v=0;for(unsigned i=0;i<n;++i)v|=uint64_t(b[i])<<(i*8);return v;}
inline void l08Encode(const L08Packet& p,uint8_t out[L08_BYTES]){out[0]='L';out[1]=8;out[2]=1;out[3]=p.kind;l08Put(out+4,p.run,8);l08Put(out+12,p.seq,4);l08Put(out+16,uint32_t(p.value),4);}
inline bool l08Decode(const uint8_t* b,size_t n,L08Packet& p){
 if(!b||n!=L08_BYTES||b[0]!='L'||b[1]!=8||b[2]!=1||(b[3]!=1&&b[3]!=2)){return false;}
 uint32_t bits=uint32_t(l08Get(b+16,4));int32_t value=0;std::memcpy(&value,&bits,4);p={b[3],l08Get(b+4,8),uint32_t(l08Get(b+12,4)),value};
 return p.run!=0&&p.seq!=0&&p.value==l08Sample(p.seq);
}
inline bool l08Matches(const uint8_t* b,size_t n,const L08Packet& expected){L08Packet p;return l08Decode(b,n,p)&&p.kind==2&&p.run==expected.run&&p.seq==expected.seq&&p.value==expected.value;}
struct L08Receiver {
 uint64_t run=0;uint32_t highest=0,accepted=0,duplicates=0;uint32_t recent[64]{};unsigned cursor=0;
 enum Result{REJECT,NEW,DUPLICATE};
 Result accept(const uint8_t* bytes,size_t n,uint8_t ack[L08_BYTES]){
  L08Packet p;if(!l08Decode(bytes,n,p)||p.kind!=1||p.run!=run){return REJECT;}
  bool duplicate=false;for(auto seq:recent){if(seq==p.seq)duplicate=true;}
  if(!duplicate&&p.seq<=highest){return REJECT;}
  if(duplicate){++duplicates;}else{highest=p.seq;recent[cursor++%64]=p.seq;++accepted;}
  p.kind=2;l08Encode(p,ack);return duplicate?DUPLICATE:NEW;
 }
};

// ---- main/lab_config.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
uint64_t l08_run_id(void);
const uint8_t* l08_peer_mac(void);
#ifdef __cplusplus
}
#endif

// ---- main/lab_config.c ----
// SPDX-License-Identifier: MIT
#include "lab_config.h"
// Public lab run identifier, NOT an authentication key. Match both boards and
// change it when starting another trial. All payload values are synthetic.
uint64_t l08_run_id(void){return UINT64_C(0x4c30384c41423031);}
// ESP-NOW only: replace with the OTHER board's printed Wi-Fi STA MAC.
// Zero placeholder disables ESP-NOW exchange; it is never broadcast fallback.
const uint8_t* l08_peer_mac(void){static const uint8_t peer[6]={0,0,0,0,0,0};return peer;}

// ---- main/radio_wifi.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <inttypes.h>
#include <cstring>
#include <atomic>
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_event.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include "lwip/sockets.h"
#include "Radio.h"
static EventGroupHandle_t flags;static TaskHandle_t reader;static int fd=-1;static bool isSender=false,started=false;static constexpr int READY=1,FAIL=2,DONE=4;static std::atomic<bool> running{false};
static void event(void*,esp_event_base_t base,int32_t id,void*){
 if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){if(esp_wifi_connect()!=ESP_OK)xEventGroupSetBits(flags,FAIL);}
 if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP)xEventGroupSetBits(flags,READY);
 if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){xEventGroupClearBits(flags,READY);xEventGroupSetBits(flags,FAIL);}
}
static void receive(void*){
 while(running.load()){uint8_t bytes[64],ack[L08_BYTES];sockaddr_in source{};socklen_t n=sizeof source;int got=recvfrom(fd,bytes,sizeof bytes,0,reinterpret_cast<sockaddr*>(&source),&n);if(got!=int(L08_BYTES))continue;
  if(isSender){if(source.sin_addr.s_addr==inet_addr("192.168.4.1")&&source.sin_port==htons(45678)){xQueueSend(l08Acks,bytes,0);}}
  else{auto result=l08Receiver.accept(bytes,size_t(got),ack);if(result!=L08Receiver::REJECT){int sent=sendto(fd,ack,sizeof ack,0,reinterpret_cast<sockaddr*>(&source),sizeof source);printf("ACCEPT WiFi state=%u new_total=%" PRIu32 " duplicates=%" PRIu32 " ack_local=%d\n",unsigned(result),l08Receiver.accepted,l08Receiver.duplicates,sent);}}
 }
 xEventGroupSetBits(flags,DONE);vTaskDelete(nullptr);
}
void l08Init(bool sender){
 isSender=sender;flags=xEventGroupCreate();if(!flags)return;ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());
 if(sender)esp_netif_create_default_wifi_sta();else esp_netif_create_default_wifi_ap();ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,event,nullptr));ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,event,nullptr));
 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(sender?WIFI_MODE_STA:WIFI_MODE_AP));wifi_config_t config{};
 if(sender){strlcpy(reinterpret_cast<char*>(config.sta.ssid),"HX-L08-LAB",sizeof config.sta.ssid);ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&config));}
 else{strlcpy(reinterpret_cast<char*>(config.ap.ssid),"HX-L08-LAB",sizeof config.ap.ssid);config.ap.ssid_len=strlen("HX-L08-LAB");config.ap.channel=6;config.ap.authmode=WIFI_AUTH_OPEN;config.ap.max_connection=1;config.ap.beacon_interval=100;ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP,&config));}
 ESP_ERROR_CHECK(esp_wifi_start());started=true;ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));ESP_ERROR_CHECK(esp_wifi_set_bandwidth(sender?WIFI_IF_STA:WIFI_IF_AP,WIFI_BW_HT20));
 fd=socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);if(fd<0){xEventGroupSetBits(flags,FAIL);return;}timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof timeout);setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof timeout);sockaddr_in local{};local.sin_family=AF_INET;local.sin_addr.s_addr=htonl(INADDR_ANY);local.sin_port=htons(sender?45677:45678);
 running.store(true);if(bind(fd,reinterpret_cast<sockaddr*>(&local),sizeof local)<0||xTaskCreate(receive,"l08_udp",4096,nullptr,4,&reader)!=pdPASS){xEventGroupSetBits(flags,FAIL);return;}if(!sender)xEventGroupSetBits(flags,READY);
 int8_t power=0;ESP_ERROR_CHECK(esp_wifi_get_max_tx_power(&power));printf("WiFi LAB openAP channel6 HT20 PS_NONE max_tx_power_quarter_dBm=%d; not measured radiated power\n",power);
}
bool l08Ready(uint32_t timeout){if(!flags)return false;auto bits=xEventGroupWaitBits(flags,READY|FAIL,pdFALSE,pdFALSE,pdMS_TO_TICKS(timeout));return (bits&READY)&&!(bits&FAIL);}
bool l08Send(const uint8_t packet[L08_BYTES]){if(fd<0||!(xEventGroupGetBits(flags)&READY))return false;sockaddr_in peer{};peer.sin_family=AF_INET;peer.sin_port=htons(45678);peer.sin_addr.s_addr=inet_addr("192.168.4.1");return sendto(fd,packet,L08_BYTES,0,reinterpret_cast<sockaddr*>(&peer),sizeof peer)==int(L08_BYTES);}
void l08Stop(){if(reader){running.store(false);shutdown(fd,SHUT_RDWR);auto bits=xEventGroupWaitBits(flags,DONE,pdFALSE,pdFALSE,pdMS_TO_TICKS(1500));if(!(bits&DONE)){puts("UDP_STOP_TIMEOUT; stop evidence failed, discard this run");}reader=nullptr;}if(fd>=0){close(fd);fd=-1;}if(started){ESP_ERROR_CHECK(esp_wifi_stop());ESP_ERROR_CHECK(esp_wifi_deinit());started=false;}}

// ---- main/radio_now.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <cstring>
#include <inttypes.h>
#include "esp_wifi.h"
#include "esp_now.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "freertos/task.h"
#include "Radio.h"
#include "lab_config.h"
static bool isSender=false,started=false;static uint8_t peer[6];static QueueHandle_t incoming;static TaskHandle_t worker;
struct Frame{uint8_t data[L08_BYTES];};
static void received(const esp_now_recv_info_t* info,const uint8_t* data,int len){
 if(!info||!data||len!=int(L08_BYTES)||memcmp(info->src_addr,peer,6))return;
 Frame f{};memcpy(f.data,data,L08_BYTES);xQueueSend(incoming,&f,0);// Full queue means loss, never fabricated ACK.
}
static void consume(void*){for(;;){Frame f{};if(xQueueReceive(incoming,&f,portMAX_DELAY)!=pdTRUE)continue;if(isSender){xQueueSend(l08Acks,f.data,0);}else{uint8_t ack[L08_BYTES];auto result=l08Receiver.accept(f.data,sizeof f.data,ack);if(result!=L08Receiver::REJECT){esp_err_t e=esp_now_send(peer,ack,sizeof ack);printf("ACCEPT ESPNOW state=%u new_total=%" PRIu32 " duplicates=%" PRIu32 " ack_local=%s\n",unsigned(result),l08Receiver.accepted,l08Receiver.duplicates,esp_err_to_name(e));}}}}
void l08Init(bool sender){
 isSender=sender;memcpy(peer,l08_peer_mac(),6);bool nonzero=false;for(auto b:peer)nonzero|=b!=0;if(!nonzero||(peer[0]&1)){puts("CONFIG_DISABLED ESPNOW: set OTHER STA unicast MAC in lab_config.c; no broadcast fallback");return;}
 incoming=xQueueCreate(8,sizeof(Frame));if(!incoming)return;ESP_ERROR_CHECK(esp_netif_init());ESP_ERROR_CHECK(esp_event_loop_create_default());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));ESP_ERROR_CHECK(esp_wifi_start());ESP_ERROR_CHECK(esp_wifi_set_channel(6,WIFI_SECOND_CHAN_NONE));ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));ESP_ERROR_CHECK(esp_now_init());
 esp_now_peer_info_t p{};memcpy(p.peer_addr,peer,6);p.channel=6;p.ifidx=WIFI_IF_STA;p.encrypt=false;ESP_ERROR_CHECK(esp_now_add_peer(&p));ESP_ERROR_CHECK(esp_now_register_recv_cb(received));
 if(xTaskCreate(consume,"l08_now",4096,nullptr,4,&worker)!=pdPASS){puts("TASK_ALLOC_ERROR");return;}started=true;int8_t power=0;ESP_ERROR_CHECK(esp_wifi_get_max_tx_power(&power));printf("ESPNOW LAB channel6 PS_NONE unencrypted max_tx_power_quarter_dBm=%d; MAC callbacks are not application ACK\n",power);
}
bool l08Ready(uint32_t timeout){(void)timeout;return started;}
bool l08Send(const uint8_t packet[L08_BYTES]){return started&&esp_now_send(peer,packet,L08_BYTES)==ESP_OK;}
void l08Stop(){if(started){ESP_ERROR_CHECK(esp_now_unregister_recv_cb());ESP_ERROR_CHECK(esp_now_deinit());if(worker){vTaskDelete(worker);worker=nullptr;}ESP_ERROR_CHECK(esp_wifi_stop());ESP_ERROR_CHECK(esp_wifi_deinit());started=false;}}

// ---- main/radio_ble.cpp ----
// SPDX-License-Identifier: MIT
#include <cstdio>
#include <cstring>
#include <atomic>
#include <inttypes.h>
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_gatt.h"
#include "host/util/util.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "freertos/event_groups.h"
#include "Radio.h"
static bool isSender=false,started=false;static EventGroupHandle_t flags;static constexpr int READY=1,FAIL=2;static uint8_t ownType;static std::atomic<uint16_t> connection{BLE_HS_CONN_HANDLE_NONE},valueHandle{0};static uint16_t svcStart,svcEnd;static bool connecting=false;static uint8_t currentAck[L08_BYTES];static bool hasAck=false;
static const ble_uuid128_t service=BLE_UUID128_INIT(0x81,0xd7,0x77,0xb8,0xf2,0x6c,0x55,0x9d,0x92,0x4c,0x8a,0x8c,0xa7,0x49,0xfb,0x36);
static const ble_uuid128_t characteristic=BLE_UUID128_INIT(0x82,0xd7,0x77,0xb8,0xf2,0x6c,0x55,0x9d,0x92,0x4c,0x8a,0x8c,0xa7,0x49,0xfb,0x36);
static int gap(struct ble_gap_event*,void*);
static void advertise(){struct ble_hs_adv_fields f{};f.flags=BLE_HS_ADV_F_DISC_GEN|BLE_HS_ADV_F_BREDR_UNSUP;f.uuids128=const_cast<ble_uuid128_t*>(&service);f.num_uuids128=1;f.uuids128_is_complete=1;int rc=ble_gap_adv_set_fields(&f);if(rc){printf("BLE adv fields rc=%d\n",rc);return;}ble_gap_adv_params p{};p.conn_mode=BLE_GAP_CONN_MODE_UND;p.disc_mode=BLE_GAP_DISC_MODE_GEN;p.itvl_min=p.itvl_max=160;rc=ble_gap_adv_start(ownType,nullptr,BLE_HS_FOREVER,&p,gap,nullptr);printf("BLE peripheral advertising100ms nominal rc=%d; not application delivery\n",rc);}
static int access(uint16_t,uint16_t,struct ble_gatt_access_ctxt* ctxt,void*){
 if(ctxt->op==BLE_GATT_ACCESS_OP_READ_CHR){if(!hasAck)return BLE_ATT_ERR_UNLIKELY;return os_mbuf_append(ctxt->om,currentAck,sizeof currentAck)?BLE_ATT_ERR_INSUFFICIENT_RES:0;}
 if(ctxt->op!=BLE_GATT_ACCESS_OP_WRITE_CHR)return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
 if(OS_MBUF_PKTLEN(ctxt->om)!=L08_BYTES){return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;}
 uint8_t packet[L08_BYTES],ack[L08_BYTES];if(os_mbuf_copydata(ctxt->om,0,L08_BYTES,packet))return BLE_ATT_ERR_UNLIKELY;
 auto result=l08Receiver.accept(packet,sizeof packet,ack);if(result==L08Receiver::REJECT)return BLE_ATT_ERR_VALUE_NOT_ALLOWED;memcpy(currentAck,ack,sizeof ack);hasAck=true;
 printf("ACCEPT BLE state=%u new_total=%" PRIu32 " duplicates=%" PRIu32 "; ATT success is not sender-observed app ACK\n",unsigned(result),l08Receiver.accepted,l08Receiver.duplicates);return 0;
}
static const ble_gatt_chr_def characteristics[]={
 {.uuid=&characteristic.u,.access_cb=access,.flags=BLE_GATT_CHR_F_READ|BLE_GATT_CHR_F_WRITE}, {0}};
static const ble_gatt_svc_def services[]={
 {.type=BLE_GATT_SVC_TYPE_PRIMARY,.uuid=&service.u,.characteristics=characteristics},{0}};
static int readAck(uint16_t conn,const ble_gatt_error* error,ble_gatt_attr* attr,void*){
 if(conn==connection.load()&&error->status==0&&attr&&OS_MBUF_PKTLEN(attr->om)==L08_BYTES){uint8_t ack[L08_BYTES];if(!os_mbuf_copydata(attr->om,0,L08_BYTES,ack))xQueueSend(l08Acks,ack,0);}else printf("BLE appACK read status=%d\n",error->status);return 0;
}
static int written(uint16_t conn,const ble_gatt_error* error,ble_gatt_attr*,void*){if(conn==connection.load()&&!error->status){int rc=ble_gattc_read(conn,valueHandle.load(),readAck,nullptr);if(rc)printf("BLE appACK read init rc=%d\n",rc);}else printf("BLE write status=%d\n",error->status);return 0;}
static int foundCharacteristic(uint16_t,const ble_gatt_error* error,const ble_gatt_chr* chr,void*){if(!error->status&&chr){valueHandle.store(chr->val_handle);}else if(error->status==BLE_HS_EDONE&&valueHandle.load()){xEventGroupSetBits(flags,READY);}else{xEventGroupSetBits(flags,FAIL);}return 0;}
static int foundService(uint16_t conn,const ble_gatt_error* error,const ble_gatt_svc* svc,void*){if(!error->status&&svc){svcStart=svc->start_handle;svcEnd=svc->end_handle;}else if(error->status==BLE_HS_EDONE&&svcStart){int rc=ble_gattc_disc_chrs_by_uuid(conn,svcStart,svcEnd,&characteristic.u,foundCharacteristic,nullptr);if(rc)xEventGroupSetBits(flags,FAIL);}else{xEventGroupSetBits(flags,FAIL);}return 0;}
static void scan(){ble_gap_disc_params p{};p.passive=1;p.itvl=16;p.window=16;p.filter_duplicates=1;int rc=ble_gap_disc(ownType,7000,&p,gap,nullptr);if(rc)xEventGroupSetBits(flags,FAIL);}
static int gap(ble_gap_event* event,void*){
 switch(event->type){
 case BLE_GAP_EVENT_DISC:{ble_hs_adv_fields f{};if(ble_hs_adv_parse_fields(&f,event->disc.data,event->disc.length_data))return 0;bool match=false;for(unsigned i=0;i<f.num_uuids128;++i)if(!ble_uuid_cmp(&f.uuids128[i].u,&service.u))match=true;if(!match)return 0;connecting=true;ble_gap_disc_cancel();ble_gap_conn_params p{};p.scan_itvl=p.scan_window=16;p.itvl_min=p.itvl_max=32;p.latency=0;p.supervision_timeout=400;int rc=ble_gap_connect(ownType,&event->disc.addr,4000,&p,gap,nullptr);if(rc){connecting=false;xEventGroupSetBits(flags,FAIL);}return 0;}
 case BLE_GAP_EVENT_CONNECT:
  connecting=false;
  if(event->connect.status){xEventGroupSetBits(flags,FAIL);if(!isSender)advertise();return 0;}connection.store(event->connect.conn_handle);
  {ble_gap_conn_desc desc{};if(!ble_gap_conn_find(event->connect.conn_handle,&desc))printf("BLE actual_interval_units1.25ms=%u latency=%u supervision_units10ms=%u\n",desc.conn_itvl,desc.conn_latency,desc.supervision_timeout);}
  if(isSender){svcStart=svcEnd=0;valueHandle.store(0);int rc=ble_gattc_disc_svc_by_uuid(event->connect.conn_handle,&service.u,foundService,nullptr);if(rc)xEventGroupSetBits(flags,FAIL);}return 0;
 case BLE_GAP_EVENT_DISCONNECT:connection.store(BLE_HS_CONN_HANDLE_NONE);valueHandle.store(0);hasAck=false;xEventGroupClearBits(flags,READY);if(isSender)xEventGroupSetBits(flags,FAIL);else advertise();return 0;
 case BLE_GAP_EVENT_DISC_COMPLETE:if(isSender&&!connecting&&connection.load()==BLE_HS_CONN_HANDLE_NONE)xEventGroupSetBits(flags,FAIL);return 0;
 default:return 0;
 }
}
static void synchronizedHost(){int rc=ble_hs_util_ensure_addr(0);if(!rc)rc=ble_hs_id_infer_auto(0,&ownType);if(rc){xEventGroupSetBits(flags,FAIL);return;}if(isSender)scan();else advertise();}
static void reset(int reason){printf("BLE host reset=%d\n",reason);connection.store(BLE_HS_CONN_HANDLE_NONE);xEventGroupClearBits(flags,READY);xEventGroupSetBits(flags,FAIL);}
static void host(void*){nimble_port_run();nimble_port_freertos_deinit();}
void l08Init(bool sender){isSender=sender;flags=xEventGroupCreate();if(!flags)return;ESP_ERROR_CHECK(nimble_port_init());ble_hs_cfg.sync_cb=synchronizedHost;ble_hs_cfg.reset_cb=reset;ble_svc_gap_init();ble_svc_gatt_init();ble_svc_gap_device_name_set("HX-L08-LAB");if(!sender){int rc=ble_gatts_count_cfg(services);if(!rc)rc=ble_gatts_add_svcs(services);if(rc){printf("GATT setup rc=%d\n",rc);return;}}nimble_port_freertos_init(host);started=true;puts("BLE LAB public GATT no pairing; MTU23/20B payload, requested40ms interval, capture actual connection values");}
bool l08Ready(uint32_t timeout){if(!flags)return false;auto bits=xEventGroupWaitBits(flags,READY|FAIL,pdFALSE,pdFALSE,pdMS_TO_TICKS(timeout));return (bits&READY)&&!(bits&FAIL);}
bool l08Send(const uint8_t packet[L08_BYTES]){const auto conn=connection.load();const auto handle=valueHandle.load();return conn!=BLE_HS_CONN_HANDLE_NONE&&handle&&ble_gattc_write_flat(conn,handle,packet,L08_BYTES,written,nullptr)==0;}
void l08Stop(){if(started){int rc=nimble_port_stop();printf("BLE host stop rc=%d\n",rc);if(!rc)ESP_ERROR_CHECK(nimble_port_deinit());started=false;}}
05 / 可复现实验与完整工程

只留一条供电路径,失败也进入能量分子

先断电,拔被测发送板两路USB与其他电源,PPK2 Source Meter5.0V VOUT→J1-21、GND→J1-22。接收板独立USB,不互接两板供电。GPIO4/J1-4不接LED;若用独立高阻3.3V逻辑探头切片,记录探头漏电/地线影响,不给DUT回供电。

Source模式DUT最大600mA;>400mA时的仪器额外POWER供电建议并不提高这个限额。先确认峰值无削顶、供电无显著压降/复位、采样带宽够用。量程不足就更换合适仪器和说明边界,不能关闭欠压检测。5V输入整板结果包含板上稳压器、桥与LED等,与裸片手册睡眠电流不同。

同一发送板/距离/姿态/环境至少各30完整周期,交错三种协议次序。cycles.csv和trace-template.csv仅表头;保留仪器原始文件与导出设置,按真实列名/单位用normalize_csv显式转换;该转换器使用一个实测常电压假设,若电压明显变化,应保留逐点电压列供积分而非抹掉压降。每行附固件SHA、run/seq、真实周期、app_ack、attempts、失败原因、功率配置及接收器范围;配置错误先修,不计成一轮“节能成功”。

参考:[5] 官方DevKitC-1 v1.1排针与供电[6] PPK2 Source Meter端子[7] PPK2最大DUT电流:Source600mA/Ampere1A[8] PPK2供电与额外POWER限制

06 / 可复现实验与完整工程

分析器拒绝没有可比性的输入

compare.py用实际时间戳梯形积分I×V,失败周期的能量不丢掉;输出总能量、应用成功率、平均尝试数、中位应用ACK延迟与总能量÷有效送达。零送达给null。它拒绝空表、NaN、缺段、重复identity、缺协议组、任务/板/距离/供电不一致、周期偏差或三组数量不同;每协议窗口的run/固件/功率策略必须唯一,窗口内缺周期直接拒绝。

max-gap和period-tolerance由实际采样率/启动误差预先确定,下面只是命令形状,不能宣称这些设置已接仪器通过。tests/tools用明确SYNTHETIC电流/日期做算术红门,所得数字只验证算法,绝不写进实测表。

代码 / 配置
python3 tools/normalize_csv.py --help
python3 tools/compare.py cycles.csv --max-gap-s 0.001 --period-tolerance-s 0.5 --min-cycles 30
clang++ -std=c++17 -Wall -Wextra -Werror -fsanitize=address,undefined -I main tests/host.cpp -o /tmp/l08-host
/tmp/l08-host
python3 tests/tools.py
做到这里,你应该看到
没有有效trace时工具应拒绝,不输出空白协议胜负或0mJ。
07 / 可复现实验与完整工程

结果不对,先检查哪一层没成立

没有应用ACK先查角色/run与接收日志;ESP-NOW再查另一板STA MAC和信道;BLE查服务UUID/发现/显式读状态;Wi-Fi查固定AP/源端口。局部send返回值只帮助定位,不能填app_ack=1。链路准备错误、RTC_STATE_INVALID、SEQUENCE_EXHAUSTED、stop非零、仪器削顶或USB插回都要保留原日志,结束该trial。

断电恢复接线,重刷对应角色,重新给双方run并先做功能验收。不要靠反复擦NVS或关闭brownout隐藏故障。通过后再做一组完整周期;接收器能量、安全握手、距离改变或周期改变都构成新的试验边界。

如果卡住了
若三组名义60s但实际周期差异超容差,检查启动标记/裁片与准备超时,不强改CSV时间让结果通过。
08 / 可复现实验与完整工程

已有证据与下一步

本次真实构建六角色全部Exit0;保存各角色选中编译输入、生成配置、bin/ELF/map、预处理CPP和原始日志。WireModel同源ASan/UBSan与24组合成能量/输入检查已运行。没有实板无线、电流、距离或成功率结果,因此全部物理观察空表保留。

前置L02提供整板测流基础,W01/W05提供各无线层的边界;比较完成后,下一篇L09处理“设备必须一直在线”的任务,不能把本篇每周期重建链路的节奏直接拿去替代在线命令服务。

BACK TO THE SOURCE

资料来源与版本说明

固定源码逐字核对安装输入并真实构建;主机服务与合成数学夹具明确限于软件。没有本站物理量/无线/电池或一周实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. IDF5.5.5 Wi-Fi初始化/功率/PS接口
  2. IDF5.5.5 ESP-NOW peer/channel/send回调边界
  3. 固定NimBLE GAP扫描、连接与参数单位
  4. 固定NimBLE GATT写回调、读值与服务器访问
  5. 官方DevKitC-1 v1.1排针与供电
  6. PPK2 Source Meter端子
  7. PPK2最大DUT电流:Source600mA/Ampere1A
  8. PPK2供电与额外POWER限制
  9. 固定NimBLE stop/deinit生命周期
  10. IDF5.5.5定时深睡/RTC保持API