视觉与机电作品 / 有限动作与分层验收发布 内容更新

做一台桌面小车:无线请求、状态反馈与失联COAST

用两块S3与指定DRV8833双轮架空台,把握持意图、新序号租期、重放不续期和独立动力恢复做成可检查的完整工程。

读完能做什么
建立两角色可复建工程,区分动作目标、最近成功API请求和实际轮子;新seq有限续租,重复帧不延长,失联/故障有可操作架空检查。
开始之前
先完成V07指定单电机架空台与W02 boot/seq/应用ACK;能核自己的板修订、焊接、低压限流和仪表地参考。
所需器材
2×官方S3 DevKitC-1 v1.1 N8R8、两桥USB数据线、Pololu2130 md17a/0J3762载板,排针可靠焊接;2×Pololu992 LP6V无编码器,端线焊牢/绝缘、3mm D轴轮/适配电机支架、稳定固定架台,轮子离桌、独立稳压5.0V≥2A台式限流源、额定合适低压VIN总开关,首次合计限流0.8A、常开按钮/10k电阻、470µF≥10V电解、100nF陶瓷/电机端≥25V、短线和合适仪表
版本组合
框架:ESP-IDF v5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4f板与输出:S3 DevKitC1 v1.1 N8R8,DIO8MiB80/PSRAMoff/PMoff;20kHz10bit最大256/1024请求执行器:Pololu2130 md17a/0J3762,TI SLVSAR1E;2×992 LP6V/100.37:1,Rev6.2实际工具:Python3.12.13 / GCC14.2.0 esp-14.2.0_20260121 / esptool4.12.0 / CMake3.30.2 / Ninja1.12.1
适用范围

两块官方S3 DevKitC-1 v1.1 N8R8 / IDF5.5.5,8MiB DIO80/noPSRAM/noPM;Pololu2130 md17a与2×992 LP6V,固定5V限流源、底盘固定且轮子架空。无编码器,不提供地面行驶/认证遥控/硬实时停车/电池实测;全部RF/电气/机械/复位均pending。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/wireless-rover-sources.json 与 wireless-rover.md,固定API/原厂手册/电路照片实际核对。验证日期:
构建 / 配置已通过
docs/esp32-evidence/wireless-rover-builds.json:四真实最终角色均Exit0,76宽输入/4实际main C单元/108产品与COW逐SHA;原首次Werror失败另存,未烧板。对象:esp32s3 /8MiB DIO80/noPSRAM/noPM版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/wireless-rover-host.json:44组合成/同源/实际main函数体;不模拟RF、电机、PWM或调度。对象:macOS arm64 ASan/UBSan + explicit HAL/clock/queue stubs验证日期:
本站实板待验证
无两板RF、实际电机/驱动/电源/波形/停止/复位或电池测量;observations.csv仅表头。

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

01 / 有限动作与分层验收

把“能遥控”改成一次可以到期的动作

这次做的是两块板、两个电机和一套明确的许可状态:控制端新握持产生意图,车端接受绝对目标,应用ACK返回已接受的序号和软件输出状态;信号不再更新时,车端在自己的时钟上撤去输出请求。每一个环节都能单独验收。

V07已经处理单桥与负载,W02已经区分MAC投递和应用确认。本篇新增的是两桥方向约束、租期、控制端撤许可、重放不延长动作和架空综合台,不再重复写一个无线开关灯。前置两篇的下载仍可独立复建。

完整工程固定在官方S3 DevKitC-1 v1.1 N8R8/IDF5.5.5。本站无这套台架;编译、同源主机桩和图示有各自证据,轮子、射频、电流、停止时间、复位和电池结果均待测。表中“停车”只在验证后才能指机械轴停,代码中的STOP实际是00高阻COAST请求。

做到这里,你应该看到
先写清三件可观察的事:车端接受了哪个seq、最近哪些LEDC调用成功、轴是否实际停止。前两项不能填第三项。

参考:[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[16] 本站W02:应用ACK、boot/seq与原样PairButton基础

02 / 有限动作与分层验收

两台电机必须是同一个绕组型号

两块板都锁官方S3 v1.1 N8R8;固件8MiB DIO80、PSRAM/PM关闭、UART0桥115200。模块内部GPIO35/36/37不外接,也不用板载RGB38、原生USB19/20或BOOT0。本例从两块现有参考板起步,不换成未知小板。

动力固定Pololu #2130 DRV8833载板,背面md17a/0J3762标识,配两台#992 LP6V、100.37:1、无编码器。0J3762是官方照片中的PCB标记;电路图片编号是0J3872。载板排针、电机引线及抑噪电容必须可靠焊接和绝缘,不能松插裸排针当连接。轮子适配3mm D轴,电机有支架,整个底盘固定、两轮离开桌面。

#992厂商6V空载130rpm/40mA与理论堵转0.36A不适用于直接填写本站5V/25%PWM测量。不能换成外形相同但更高堵转电流的HP/HPCB。厂家Rev6.2有短时间堵转也会损坏的说明;0.8A总源限流不是每台电机绕组保护。

部件固定选择/边界
主控2×ESP32-S3-DevKitC-1 v1.1 / N8R8
执行器2×Pololu992 LP6V / 100.37:1 / 无编码器
驱动Pololu2130 md17a/0J3762,两桥独立,不并联
首次动力独立稳压5.0V,额定≥2A,合计限流初设0.8A
架台可靠焊接/支架/3mm D轴轮/稳定固定,轮子不接触桌面

参考:[1] 官方DevKitC1 v1.1:GPIO资源和互斥主板供电[2] Pololu2130载板:VIN/VMM、SLP、FLT与默认sense接地[5] Pololu992:LP6V/100.37:1/无编码器的条件规格[6] 原厂Micro Metal Gearmotors 2026-02 Rev6.2:绕组/堵转/负载边界[17] Pololu载板背面原照:md17a / 0J3762丝印与端子

03 / 有限动作与分层验收

共地的是参考,不能共接外部动力正端

两块主板各用自己的USB-to-UART桥供电。车端外部5V正端经过独立低压总开关到驱动VIN,负端直接到驱动GND;另外用短线把车端GND接到这个参考点。电机回流直接回电源负端,不穿过开发板的细地线。控制端与车端无线相连,无需拉跨板地线。

外部正端不接主板5V/3V3,VMM留空。VMM是载板反接FET之后的节点,不是第二路独立电源。本例主板只选USB供电,不把官方互斥供电方案并接。驱动VIN/GND附近470µF≥10V电解并100nF;每电机端子间100nF≥25V陶瓷,不接金属壳,核电解极性。

车端16/17接AIN1/AIN2,18/21接BIN1/BIN2,每条各10k下拉。GPIO4读开漏FLT、外接10k到3V3;GPIO10为本地STOP常开到GND、10k到3V3。AIN/BIN高门限2V可接3.3V逻辑。SLP留原47k上拉,AISEN/BISEN仍原厂接地,未启用斩波电流设置。

控制端GPIO4/5/6/7分别前/后/左/右,GPIO10握持许可,11静止重放测试;六个常开到GND,各自10k上拉到控制端3V3。内部上下拉都关闭。两角色GPIO号复用不代表要互连。软件正极性不保证镜像安装轮子的实际前进方向;断电后换该电机两根输出线核对,不能靠带电倒插改向。

做到这里,你应该看到
所有电源断开,逐项查共地/正端隔离/四下拉/两端子只接绕组/焊接和架空固定,再开桥USB。

参考:[1] 官方DevKitC1 v1.1:GPIO资源和互斥主板供电[2] Pololu2130载板:VIN/VMM、SLP、FLT与默认sense接地[3] Pololu原理图0J3872:反接FET、SLP47k与sense短接[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[15] 固定GPIO接口:输出/电平与关闭内部上拉下拉[17] Pololu载板背面原照:md17a / 0J3762丝印与端子

04 / 有限动作与分层验收

默认工程先关无线,只读取真实STA地址

下载目录包含所有main头文件、CMake/Kconfig、显式分区、四角色配置、测试、空表和许可。用现有IDF5.5.5环境,先确认idf.py --version。原生IDF无Arduino FQBN,也没有额外组件;固定SDK commit和source-lock.json,不伪造无组件的dependencies.lock。

先动力OFF,默认工程烧两块板;peer全零时初始化车端四输出请求LOW,打印自己真实STA后返回radio CLOSED,不启动Wi-Fi/ESP-NOW。依次记录桥串口对应的板和MAC。这个关闭结果是预期检查点。

把两个compile-only.defaults各复制为同角色local.defaults,仅填另一块板的真实STA MAC。02:00…值是编译路径占位,不能当实物地址。两端用相同自有允许频道(默认6,限制1..11并核SDK country范围),不连接AP,不自动换频道。每一角色都用fresh B目录,避免旧sdkconfig覆盖defaults。

代码 / 配置
idf.py -B build-closed -D IDF_TARGET=esp32s3 -D SDKCONFIG=build-closed/sdkconfig -D SDKCONFIG_DEFAULTS=sdkconfig.defaults reconfigure
idf.py -B build-closed build
idf.py -B build-closed -p /dev/你的桥串口 flash monitor

# 以下两份local文件须先按README填入真实对端MAC;分别操作对应串口。
idf.py -B build-controller -D IDF_TARGET=esp32s3 -D SDKCONFIG=build-controller/sdkconfig "-D SDKCONFIG_DEFAULTS=sdkconfig.defaults;config/controller.local.defaults" reconfigure
idf.py -B build-controller build
idf.py -B build-controller -p /dev/控制端桥串口 flash monitor
idf.py -B build-rover -D IDF_TARGET=esp32s3 -D SDKCONFIG=build-rover/sdkconfig "-D SDKCONFIG_DEFAULTS=sdkconfig.defaults;config/rover.local.defaults" reconfigure
idf.py -B build-rover build
idf.py -B build-rover -p /dev/车端桥串口 flash monitor

参考:[1] 官方DevKitC1 v1.1:GPIO资源和互斥主板供电[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[9] IDF5.5.5 Wi-Fi:STA、PS_NONE与固定频道前提

05 / 有限动作与分层验收

接收模型:只有新接受序号才获得租期

V8/v1固定48字节显式大端帧,含两端非零boot、32位seq、左右绝对目标、状态/API反馈、剩余毫秒、保留位和CRC32。绝不发送内存结构体。boot在Wi-Fi开启后生成;车端本boot绑定首个成功STOP的sender,换控制端须动力OFF、全松并RESET两端。V08帧与W02不兼容,复用的是确认思想。

新非零目标要求先新STOP武装,并且全零后至少200ms。目标±256/1024,名义20kHz/10bit,最大25%占空请求而非有限RPM保证;每50ms最多增加16,零目标立即COAST。租期从RX回调到达算500ms,回调到owner超过100ms拒绝,连续动作另有3s帽,新心跳不能重置3s。

DUP只返回当前状态,不写输出、accepted不增长、deadline不变;STALE/CONFLICT/SESSION拒绝。到期后DUP仍可回答但armed=0/剩余0,不能复活动作。超过UINT32_MAX不回绕。这个RAM模型不提供跨重启事务/恰好一次保证,boot也不是认证;先前未到过的更高seq若迟到仍可能获得新租期。

代码 / 配置
// ---- main/rover_model.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#define V8_WIRE_BYTES 48
#define V8_MAX_DUTY 256
#define V8_LEASE_US UINT64_C(500000)
#define V8_OWNER_GAP_US UINT64_C(100000)
#define V8_RUN_LIMIT_US UINT64_C(3000000)
#define V8_COAST_GAP_US UINT64_C(200000)
enum { V8_HELLO=1,V8_COMMAND=2,V8_ACK=3 };
enum { V8_ACCEPTED=0,V8_DUPLICATE=1,V8_SESSION=2,V8_STALE=3,V8_CONFLICT=4,V8_STOP_REQUIRED=5,V8_FAULT=6,V8_TOO_OLD=7 };
enum { V8_BOOT=0,V8_EXPLICIT_STOP=1,V8_LEASE_EXPIRED=2,V8_RUN_LIMIT=3,V8_OWNER_LATE=4,V8_DRIVER_FAULT=5,V8_LOCAL_STOP=6,V8_API_FAULT=7,V8_CLOCK_FAULT=8,V8_RADIO_FAULT=9 };
enum { V8_FLAG_API_KNOWN=1,V8_FLAG_ARMED=2,V8_FLAG_FAULT=4 };
typedef struct {
 uint8_t kind,status,flags,reason;
 uint64_t sender,receiver;
 uint32_t seq,last_seq;
 int16_t left,right,api_left,api_right;
 uint16_t remaining_ms;
} V8Frame;
static inline uint32_t v8_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 v8_put(uint8_t *p,uint64_t v,unsigned n){for(unsigned i=0;i<n;i++)p[i]=(uint8_t)(v>>(8*(n-1-i)));}
static inline uint64_t v8_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 int16_t v8_i16(uint16_t u){return u<=INT16_MAX?(int16_t)u:(int16_t)(-(int32_t)(UINT32_C(65536)-u));}
static inline bool v8_duty(int16_t x){return x>=-V8_MAX_DUTY&&x<=V8_MAX_DUTY;}
static inline void v8_encode(const V8Frame *f,uint8_t b[V8_WIRE_BYTES]){
 memset(b,0,V8_WIRE_BYTES);b[0]='V';b[1]='8';b[2]=1;b[3]=f->kind;b[4]=f->status;b[5]=f->flags;b[6]=f->reason;
 v8_put(b+8,f->sender,8);v8_put(b+16,f->receiver,8);v8_put(b+24,f->seq,4);v8_put(b+28,f->last_seq,4);
 v8_put(b+32,(uint16_t)f->left,2);v8_put(b+34,(uint16_t)f->right,2);v8_put(b+36,(uint16_t)f->api_left,2);v8_put(b+38,(uint16_t)f->api_right,2);
 v8_put(b+40,f->remaining_ms,2);v8_put(b+44,v8_crc(b,44),4);
}
static inline bool v8_decode(const uint8_t *b,size_t n,V8Frame *out){
 if(!b||!out||n!=V8_WIRE_BYTES||b[0]!='V'||b[1]!='8'||b[2]!=1||b[7]||b[42]||b[43]||v8_get(b+44,4)!=v8_crc(b,44))return false;
 V8Frame f={.kind=b[3],.status=b[4],.flags=b[5],.reason=b[6],.sender=v8_get(b+8,8),.receiver=v8_get(b+16,8),.seq=(uint32_t)v8_get(b+24,4),.last_seq=(uint32_t)v8_get(b+28,4),.left=v8_i16((uint16_t)v8_get(b+32,2)),.right=v8_i16((uint16_t)v8_get(b+34,2)),.api_left=v8_i16((uint16_t)v8_get(b+36,2)),.api_right=v8_i16((uint16_t)v8_get(b+38,2)),.remaining_ms=(uint16_t)v8_get(b+40,2)};
 if(!f.receiver||f.flags>7||f.reason>V8_RADIO_FAULT||f.status>V8_TOO_OLD||f.remaining_ms>500||!v8_duty(f.left)||!v8_duty(f.right)||!v8_duty(f.api_left)||!v8_duty(f.api_right))return false;
 if(f.kind==V8_HELLO){if(f.seq||f.status||f.left||f.right)return false;}
 else if(f.kind==V8_COMMAND){if(!f.sender||!f.seq||f.status||f.flags||f.reason||f.last_seq||f.api_left||f.api_right||f.remaining_ms)return false;}
 else if(f.kind==V8_ACK){if(!f.sender||!f.seq)return false;}
 else return false;
 *out=f;return true;
}
typedef struct {
 uint64_t own,bound_sender,last_tick,deadline,run_started,stopped_at,ramp_at;
 uint32_t last_seq;
 int16_t last_left,last_right,target_left,target_right,duty_left,duty_right;
 int8_t direction_left,direction_right;
 bool armed,fault;
 uint8_t reason;
 uint64_t accepted;
} V8Rover;
static inline bool v8_moving(const V8Rover *r){return r->target_left||r->target_right||r->duty_left||r->duty_right;}
static inline void v8_coast(V8Rover *r,uint64_t now,uint8_t reason){
 if(v8_moving(r))r->stopped_at=now;
 r->target_left=r->target_right=r->duty_left=r->duty_right=0;r->armed=false;r->deadline=0;r->run_started=0;r->reason=reason;
}
static inline void v8_latch(V8Rover *r,uint64_t now,uint8_t reason){v8_coast(r,now,reason);r->fault=true;}
static inline void v8_rover_init(V8Rover *r,uint64_t boot,uint64_t now){*r=(V8Rover){.own=boot,.last_tick=now,.stopped_at=now,.ramp_at=now,.reason=V8_BOOT};}
static inline int16_t v8_step(int16_t current,int16_t target){
 if(!target)return 0;
 if(current<target)return target-current>16?(int16_t)(current+16):target;
 if(current>target)return current-target>16?(int16_t)(current-16):target;
 return current;
}
/* Call before processing network requests. A late owner can only act when it
   resumes; neither this model nor PWM peripherals provide a hardware E-stop. */
static inline void v8_tick(V8Rover *r,uint64_t now,bool driver_fault,bool local_stop){
 if(now<r->last_tick){v8_latch(r,now,V8_CLOCK_FAULT);r->last_tick=now;return;}
 bool late=now-r->last_tick>V8_OWNER_GAP_US;r->last_tick=now;
 if(driver_fault){v8_latch(r,now,V8_DRIVER_FAULT);return;}
 if(local_stop){v8_latch(r,now,V8_LOCAL_STOP);return;}
 if(r->fault)return;
 if(late){v8_coast(r,now,V8_OWNER_LATE);r->stopped_at=now;return;}
 if(v8_moving(r)&&r->deadline&&now>=r->deadline){v8_coast(r,now,V8_LEASE_EXPIRED);return;}
 if(v8_moving(r)&&now-r->run_started>=V8_RUN_LIMIT_US){v8_coast(r,now,V8_RUN_LIMIT);return;}
 if(now>=r->ramp_at&&now-r->ramp_at>=50000){r->ramp_at=now;r->duty_left=v8_step(r->duty_left,r->target_left);r->duty_right=v8_step(r->duty_right,r->target_right);}
}
/* Pure candidate preparation. Owner commits candidate only after its bounded
   output update succeeds. Duplicate/rejected input never mutates state/lease. */
static inline uint8_t v8_prepare(const V8Rover *r,const V8Frame *f,uint64_t arrived,uint64_t now,V8Rover *next){
 if(f->kind!=V8_COMMAND||!r->own||f->receiver!=r->own||!f->sender||!f->seq||(r->bound_sender&&r->bound_sender!=f->sender))return V8_SESSION;
 if(!v8_duty(f->left)||!v8_duty(f->right))return V8_CONFLICT;
 if(f->seq<r->last_seq)return V8_STALE;
 if(f->seq==r->last_seq)return f->left==r->last_left&&f->right==r->last_right?V8_DUPLICATE:V8_CONFLICT;
 if(r->fault)return V8_FAULT;
 if(now<arrived||now-arrived>100000||arrived>UINT64_MAX-V8_LEASE_US)return V8_TOO_OLD;
 bool drive=f->left||f->right;
 if(drive){
  if(!r->armed)return V8_STOP_REQUIRED;
  if(!v8_moving(r)&&(now<r->stopped_at||now-r->stopped_at<V8_COAST_GAP_US))return V8_STOP_REQUIRED;
  if((r->direction_left>0&&f->left<0)||(r->direction_left<0&&f->left>0)||(r->direction_right>0&&f->right<0)||(r->direction_right<0&&f->right>0))return V8_STOP_REQUIRED;
 }
 *next=*r;next->bound_sender=f->sender;next->last_seq=f->seq;next->last_left=f->left;next->last_right=f->right;next->accepted++;
 if(!drive){v8_coast(next,now,V8_EXPLICIT_STOP);next->armed=true;next->direction_left=next->direction_right=0;next->ramp_at=now;return V8_ACCEPTED;}
 if(!v8_moving(r))next->run_started=now;
 if(f->left)next->direction_left=f->left>0?1:-1;
 if(f->right)next->direction_right=f->right>0?1:-1;
 next->target_left=f->left;next->target_right=f->right;next->deadline=arrived+V8_LEASE_US;
 /* Component zero requests coast that bridge immediately. */
 if(!f->left)next->duty_left=0;
 if(!f->right)next->duty_right=0;
 return V8_ACCEPTED;
}
static inline uint16_t v8_remaining(const V8Rover *r,uint64_t now){return !r->deadline||now>=r->deadline?0:(uint16_t)((r->deadline-now+999)/1000);}

参考:[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[12] esp_timer:每boot64位微秒单调时间[13] esp_random约束:Wi-Fi开启后再生成boot身份[16] 本站W02:应用ACK、boot/seq与原样PairButton基础

06 / 有限动作与分层验收

控制端:释放和新按压比重试优先

原样复用W02 PairButton的50ms稳定释放/新按压,启动时按住不会启动。一个方向已稳定50ms后新按握持,先排新STOP、等待其应用ACK,再等250ms才发非零。握持中原始释放、方向任何变化/冲突直接撤意图;不等释放消抖50ms才停。

正常动作每100ms新序号续租,但只有当前命令已收到应用ACK才发下一个。未确认帧每120ms同序号重发、最多4次;ACK_TIMEOUT表示执行不确定,撤许可并有界准备一个更新STOP,不能改成“从未动作”。必须匹配两端boot、seq、两目标,last_seq须为本seq且API_KNOWN/ARMED/FAULT/剩余状态符合要求,MAC送达不是这个确认。

接收端boot变更取消旧pending和保存的重放帧;握持没释放不会自动恢复。停止优先取消旧pending,但已经送入Wi-Fi的旧帧无法撤回,STOP也可能丢失。GPIO11只在静止/无pending/无紧急STOP时重发保存的原非零帧,不增序号;若重放被STALE拒绝,普通拒绝路径可另发一个更新STOP,但重放非零帧本身仍为旧seq。为零命令保留最后一个序号,不能序号回绕。

代码 / 配置
// ---- main/w02_pair.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/controller_model.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include "rover_model.h"
#include "w02_pair.h"
#define V8_RETRY_US UINT64_C(120000)
#define V8_FRESH_US UINT64_C(100000)
#define V8_HELLO_AGE_US UINT64_C(3000000)
typedef struct {
 uint64_t own,remote,hello_at,ready_at,next_fresh,retry_at,direction_at,last_tick;
 uint32_t sequence;
 uint8_t attempts,direction_candidate,direction_stable,held_direction;
 bool initialized,peer_blocked,run_latch,stop_requested,stop_ready,pending,have_last_motion,clock_fault;
 PairButton deadman;
 V8Frame command,last_motion;
} V8Controller;
static inline bool v8_one_direction(uint8_t d){return d==1||d==2||d==4||d==8;}
static inline void v8_control_stop(V8Controller *c,bool send_stop){
 c->run_latch=false;c->stop_ready=false;c->pending=false;
 c->stop_requested=send_stop&&c->remote&&!c->peer_blocked;
 /* Existing press never becomes a new intent after a protocol failure. */
 c->deadman.armed=false;
}
static inline void v8_control_init(V8Controller *c,uint64_t boot,bool pressed,uint64_t now){
 *c=(V8Controller){.own=boot,.initialized=true,.last_tick=now,.direction_at=now,.deadman={.candidate=pressed,.stable=true,.since=now}};
}
static inline bool v8_control_recent(const V8Controller *c,uint64_t now){return c->remote&&!c->peer_blocked&&!c->clock_fault&&now>=c->hello_at&&now-c->hello_at<=V8_HELLO_AGE_US;}
static inline void v8_control_hello(V8Controller *c,const V8Frame *f,uint64_t now){
 if(f->kind!=V8_HELLO||!f->receiver)return;
 if(c->remote!=f->receiver){v8_control_stop(c,false);c->have_last_motion=false;c->remote=f->receiver;}
 c->hello_at=now;c->peer_blocked=(f->sender&&f->sender!=c->own)||(f->flags&V8_FLAG_FAULT);
 if(c->peer_blocked)v8_control_stop(c,false);
 else if(c->run_latch&&!(f->flags&V8_FLAG_ARMED))v8_control_stop(c,true);
}
static inline void v8_control_inputs(V8Controller *c,uint8_t direction,bool pressed,uint64_t now){
 if(now<c->last_tick){c->clock_fault=true;v8_control_stop(c,true);c->last_tick=now;return;}
 if(now-c->last_tick>V8_OWNER_GAP_US)v8_control_stop(c,true);
 c->last_tick=now;
 if(direction!=c->direction_candidate){c->direction_candidate=direction;c->direction_at=now;}
 if(now>=c->direction_at&&now-c->direction_at>=50000)c->direction_stable=direction;
 bool edge=pair_button(&c->deadman,pressed,now);
 if(c->run_latch&&(!pressed||direction!=c->held_direction))v8_control_stop(c,true);
 if(c->run_latch&&!v8_control_recent(c,now))v8_control_stop(c,true);
 if(edge&&v8_one_direction(direction)&&direction==c->direction_stable&&v8_control_recent(c,now)){
  c->run_latch=true;c->held_direction=direction;c->pending=false;c->stop_ready=false;c->stop_requested=true;
 }
}
static inline bool v8_control_queue(V8Controller *c,int16_t left,int16_t right,uint64_t now){
 if(!c->remote||c->peer_blocked||c->clock_fault||c->sequence==UINT32_MAX||!v8_duty(left)||!v8_duty(right))return false;
 /* Reserve the last sequence for a zero command; never roll back to one. */
 if((left||right)&&c->sequence>=UINT32_MAX-1){v8_control_stop(c,true);return false;}
 c->command=(V8Frame){.kind=V8_COMMAND,.sender=c->own,.receiver=c->remote,.seq=++c->sequence,.left=left,.right=right};
 c->pending=true;c->attempts=0;c->retry_at=now;
 if(left||right){c->last_motion=c->command;c->have_last_motion=true;c->next_fresh=now+V8_FRESH_US;}
 return true;
}
static inline bool v8_control_ack(V8Controller *c,const V8Frame *f,uint64_t now){
 if(!c->pending||f->kind!=V8_ACK||f->sender!=c->command.sender||f->receiver!=c->command.receiver||f->seq!=c->command.seq||f->left!=c->command.left||f->right!=c->command.right)return false;
 bool motion=c->command.left||c->command.right;c->pending=false;
 if(f->status>V8_DUPLICATE||f->last_seq!=c->command.seq||(f->flags&V8_FLAG_FAULT)||!(f->flags&V8_FLAG_API_KNOWN)||!(f->flags&V8_FLAG_ARMED)||(motion&&!f->remaining_ms)){
  v8_control_stop(c,motion);return true;
 }
 if(!motion){c->stop_ready=true;c->ready_at=now;}
 return true;
}
/* Returns0none,1attempt,2bounded application-ACK timeout. Call only after
   current physical inputs and peer events have been consumed by the owner. */
static inline unsigned v8_control_action(V8Controller *c,uint64_t now,bool radio_free){
 if(!radio_free)return 0;
 if(c->stop_requested&&!c->peer_blocked){
  c->stop_requested=false;
  if(!v8_control_queue(c,0,0,now))return 0;
 }
 if(!c->pending&&c->run_latch&&c->stop_ready&&v8_control_recent(c,now)&&now>=c->ready_at&&now-c->ready_at>=250000&&now>=c->next_fresh){
  int16_t left=0,right=0;
  if(c->held_direction==1)left=right=V8_MAX_DUTY;
  else if(c->held_direction==2)left=right=-V8_MAX_DUTY;
  else if(c->held_direction==4)right=V8_MAX_DUTY;
  else if(c->held_direction==8)left=V8_MAX_DUTY;
  if(!v8_control_queue(c,left,right,now))return 0;
 }
 if(!c->pending||now<c->retry_at)return 0;
 if(c->attempts>=4){bool motion=c->command.left||c->command.right;v8_control_stop(c,motion);return 2;}
 return 1;
}
static inline void v8_control_attempt(V8Controller *c,uint64_t now){c->attempts++;c->retry_at=now+V8_RETRY_US;}
/* Bench-only exact replay, not a new motion intent. No heartbeat/lease reset.
   Physical release remains required before another intentional run. */
static inline bool v8_control_replay(V8Controller *c,uint64_t now){
 if(c->run_latch||c->pending||c->stop_requested||!c->have_last_motion||c->last_motion.receiver!=c->remote||!v8_control_recent(c,now))return false;
 c->command=c->last_motion;c->pending=true;c->attempts=0;c->retry_at=now;return true;
}
做到这里,你应该看到
台架记录真实松手/新STOP接受;不能拿控制端“发送成功”当车端输出清零。

参考:[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[12] esp_timer:每boot64位微秒单调时间[16] 本站W02:应用ACK、boot/seq与原样PairButton基础

07 / 有限动作与分层验收

一次LEDC调用失败,不能继续相信旧输出值

车端只有主owner写四路LEDC,先令相反输入LOW,再set/update目标通道,左右桥依次更新而非原子同时。00依据TI表为高阻COAST,不使用11短刹。零请求会尝试四次stop,即使前一个报错也继续尝试其余三路。

候选状态先纯计算,立即输出更新成功后才提交本次seq。缓升的后续tick仍可能失败:尝试四通道COAST、模型归零并锁故障、API_KNOWN清0。反馈保留最近成功API请求以便审计,不能把该旧值或部分失败后的模型0当实际引脚状态。

LEDC设置频率/返回值只证明配置或调用;主机故障注入明确是HAL桩,没有在实板故意造API错误,更不通过短桥/堵轮验证保护。FLT任一低样本与本地STOP任一低样本都锁闭到人工排查RESET,松开不会自恢复。

代码 / 配置
// ---- main/rover_output.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include "driver/ledc.h"
#include "rover_model.h"
/* All four calls are attempted even after one error. This requests low logic;
   success does not prove bridge voltage or physical shaft stop. */
static inline esp_err_t v8_output_coast(void){
 esp_err_t first=ESP_OK;
 for(unsigned i=0;i<4;i++){esp_err_t e=ledc_stop(LEDC_LOW_SPEED_MODE,(ledc_channel_t)i,0);if(first==ESP_OK&&e!=ESP_OK)first=e;}
 return first;
}
static inline esp_err_t v8_output_bridge(unsigned base,int16_t value){
 if(base>2||(base&1)||!v8_duty(value))return ESP_ERR_INVALID_ARG;
 if(!value){esp_err_t a=ledc_stop(LEDC_LOW_SPEED_MODE,(ledc_channel_t)base,0);esp_err_t b=ledc_stop(LEDC_LOW_SPEED_MODE,(ledc_channel_t)(base+1),0);return a==ESP_OK?b:a;}
 unsigned selected=base+(value<0?1:0),opposite=base+(value<0?0:1);
 esp_err_t e=ledc_stop(LEDC_LOW_SPEED_MODE,(ledc_channel_t)opposite,0);
 if(e==ESP_OK)e=ledc_set_duty(LEDC_LOW_SPEED_MODE,(ledc_channel_t)selected,(uint32_t)(value<0?-value:value));
 return e==ESP_OK?ledc_update_duty(LEDC_LOW_SPEED_MODE,(ledc_channel_t)selected):e;
}
static inline esp_err_t v8_output_pair(int16_t left,int16_t right){
 if(!v8_duty(left)||!v8_duty(right))return ESP_ERR_INVALID_ARG;
 esp_err_t e=v8_output_bridge(0,left);
 return e==ESP_OK?v8_output_bridge(2,right):e;
}

// ---- main/rover_owner.h ----
/* SPDX-License-Identifier: MIT */
#pragma once
#include "rover_output.h"
typedef struct { bool known; int16_t left,right; } V8ApiOutput;
/* Sole owner uses this exact helper for tick and candidate commits. Last
   successful API request is retained after failure, with known=false. */
static inline bool v8_owner_write(V8Rover *r,V8ApiOutput *io,int16_t left,int16_t right,uint64_t now){
 if(!io->known){(void)v8_output_coast();return false;}
 if(left==io->left&&right==io->right)return true;
 esp_err_t e=left||right?v8_output_pair(left,right):v8_output_coast();
 if(e!=ESP_OK){(void)v8_output_coast();io->known=false;v8_latch(r,now,V8_API_FAULT);return false;}
 io->left=left;io->right=right;return true;
}
static inline void v8_owner_tick(V8Rover *r,V8ApiOutput *io,uint64_t now,bool fault,bool stop){
 v8_tick(r,now,fault,stop);
 (void)v8_owner_write(r,io,r->duty_left,r->duty_right,now);
}
static inline uint8_t v8_owner_command(V8Rover *r,V8ApiOutput *io,const V8Frame *f,uint64_t at,uint64_t now){
 V8Rover candidate;uint8_t status=v8_prepare(r,f,at,now,&candidate);
 if(status!=V8_ACCEPTED)return status;
 /* Do not commit seq/target until this immediate output update succeeds. */
 if(!v8_owner_write(r,io,candidate.duty_left,candidate.duty_right,now))return V8_FAULT;
 *r=candidate;return V8_ACCEPTED;
}
static inline V8Frame v8_owner_feedback(const V8Rover *r,const V8ApiOutput *io,const V8Frame *command,uint8_t status,uint64_t now){
 V8Frame f={.kind=command?V8_ACK:V8_HELLO,.status=command?status:0,
  .flags=(io->known?V8_FLAG_API_KNOWN:0)|(r->armed?V8_FLAG_ARMED:0)|(r->fault?V8_FLAG_FAULT:0),
  .reason=r->reason,.sender=command?command->sender:r->bound_sender,.receiver=r->own,
  .seq=command?command->seq:0,.last_seq=r->last_seq,.left=command?command->left:0,.right=command?command->right:0,
  .api_left=io->left,.api_right=io->right,.remaining_ms=v8_remaining(r,now)};
 return f;
}

参考:[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[10] IDF5.5.5 LEDC API:10bit/set/update/stop[11] 固定LEDC实现:stop输出关闭及update恢复输出

08 / 有限动作与分层验收

完整入口:回调复制,owner检查,确认后再发下一帧

下面main.c与下载原文件一致,依赖上一节各完整头及下载配置。接收/发送回调先核对端MAC,复制固定事件到16项队列,不调用电机API、日志或重试。每轮最多处理16项,出队后才读now,避免另核回调刚入队却被旧now判成未来。

每次发射只保留一个MAC层inflight;缺callback超过2s、队列溢出或发送API错误锁闭到RESET。普通MAC层失败不冒充应用拒绝;应用ACK/租期负责不确定性。车端ACK真正发送时重新取软件状态,HELLO每秒补当前boot/状态;接收端并非通过网页/手机或路由器控制。

owner每次恢复都检查间隔、nFAULT、本地STOP和deadline。若CPU/任务卡住,硬件PWM可以继续输出;间隔>100ms仅在owner恢复调度时请求COAST。10ms nominal轮询加UART和调度开销,没有硬实时停止保证。保留独立VIN开关,软件不替代断电。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <inttypes.h>
#include <string.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 "driver/gpio.h"
#include "rover_owner.h"
#include "controller_model.h"
#ifdef CONFIG_V8_CONTROLLER
#define CONTROLLER 1
#else
#define CONTROLLER 0
#endif
#ifndef CONFIG_V8_DROP_FIRST_ACK
#define CONFIG_V8_DROP_FIRST_ACK 0
#endif
static const char *TAG="V08";
typedef struct {bool tx;int status;uint64_t at;uint8_t data[V8_WIRE_BYTES];} V8Event;
static QueueHandle_t events;
static uint8_t peer[6];
static portMUX_TYPE drops_lock=portMUX_INITIALIZER_UNLOCKED;
static unsigned drops;
/* The high-priority Wi-Fi callbacks only whitelist and copy bounded data.
   No motor API, printf, retries, model or user input is handled here. */
static void post(const V8Event *e){
 if(xQueueSend(events,e,0)!=pdTRUE){portENTER_CRITICAL(&drops_lock);drops++;portEXIT_CRITICAL(&drops_lock);}
}
static void received(const esp_now_recv_info_t *i,const uint8_t *d,int n){
 if(!i||!i->src_addr||!d||n!=V8_WIRE_BYTES||memcmp(i->src_addr,peer,6))return;
 V8Event e={.at=(uint64_t)esp_timer_get_time()};memcpy(e.data,d,sizeof e.data);post(&e);
}
static void sent(const esp_now_send_info_t *i,esp_now_send_status_t status){
 if(!i||!i->des_addr||memcmp(i->des_addr,peer,6))return;
 V8Event e={.tx=true,.status=status,.at=(uint64_t)esp_timer_get_time()};post(&e);
}
static unsigned queue_drops(void){portENTER_CRITICAL(&drops_lock);unsigned n=drops;portEXIT_CRITICAL(&drops_lock);return n;}
static bool parse_mac(const char *s,uint8_t mac[6]){
 if(!s||strlen(s)!=17)return false;
 unsigned any=0;
 for(unsigned k=0;k<6;k++){
  unsigned x=0;
  for(unsigned j=0;j<2;j++){
   unsigned char c=(unsigned char)s[k*3+j];unsigned v;
   if(c>='0'&&c<='9')v=c-'0';else if(c>='a'&&c<='f')v=c-'a'+10;else if(c>='A'&&c<='F')v=c-'A'+10;else return false;
   x=x*16+v;
  }
  if(k<5&&s[k*3+2]!=':')return false;
  mac[k]=(uint8_t)x;any|=x;
 }
 return any&&!(mac[0]&1);
}
static esp_err_t inputs(uint64_t pins){gpio_config_t c={.pin_bit_mask=pins,.mode=GPIO_MODE_INPUT,.pull_up_en=GPIO_PULLUP_DISABLE,.pull_down_en=GPIO_PULLDOWN_DISABLE,.intr_type=GPIO_INTR_DISABLE};return gpio_config(&c);}
static esp_err_t hardware_init(void){
 if(CONTROLLER)return inputs((1ULL<<4)|(1ULL<<5)|(1ULL<<6)|(1ULL<<7)|(1ULL<<10)|(1ULL<<11));
 const int pins[4]={16,17,18,21};uint64_t mask=0;
 for(unsigned k=0;k<4;k++){esp_err_t e=gpio_set_level(pins[k],0);if(e!=ESP_OK)return e;mask|=1ULL<<pins[k];}
 gpio_config_t c={.pin_bit_mask=mask,.mode=GPIO_MODE_OUTPUT,.pull_up_en=GPIO_PULLUP_DISABLE,.pull_down_en=GPIO_PULLDOWN_DISABLE};
 esp_err_t e=gpio_config(&c);if(e!=ESP_OK)return e;
 e=inputs((1ULL<<4)|(1ULL<<10));if(e!=ESP_OK)return e;
 ledc_timer_config_t t={.speed_mode=LEDC_LOW_SPEED_MODE,.duty_resolution=LEDC_TIMER_10_BIT,.timer_num=LEDC_TIMER_0,.freq_hz=20000,.clk_cfg=LEDC_USE_APB_CLK};
 e=ledc_timer_config(&t);if(e!=ESP_OK)return e;
 for(unsigned k=0;k<4;k++){
  ledc_channel_config_t ch={.gpio_num=pins[k],.speed_mode=LEDC_LOW_SPEED_MODE,.channel=(ledc_channel_t)k,.timer_sel=LEDC_TIMER_0,.duty=0,.hpoint=0};
  e=ledc_channel_config(&ch);if(e!=ESP_OK){(void)v8_output_coast();return e;}
 }
 return v8_output_coast();
}
static esp_err_t radio_init(void){
 esp_err_t e=nvs_flash_init(); /* No automatic NVS erase on version/full errors. */
 if(e!=ESP_OK)return e;
 e=esp_netif_init();if(e!=ESP_OK)return e;
 e=esp_event_loop_create_default();if(e!=ESP_OK)return e;
 wifi_init_config_t cfg=WIFI_INIT_CONFIG_DEFAULT();
 e=esp_wifi_init(&cfg);if(e!=ESP_OK)return e;
 e=esp_wifi_set_storage(WIFI_STORAGE_RAM);if(e!=ESP_OK)return e;
 e=esp_wifi_set_mode(WIFI_MODE_STA);if(e!=ESP_OK)return e;
 e=esp_wifi_start();if(e!=ESP_OK)return e;
 e=esp_wifi_set_ps(WIFI_PS_NONE);if(e!=ESP_OK)return e;
 wifi_country_t country={0};e=esp_wifi_get_country(&country);if(e!=ESP_OK)return e;
 if(CONFIG_V8_CHANNEL<country.schan||CONFIG_V8_CHANNEL>=country.schan+country.nchan)return ESP_ERR_INVALID_ARG;
 e=esp_wifi_set_channel(CONFIG_V8_CHANNEL,WIFI_SECOND_CHAN_NONE);if(e!=ESP_OK)return e;
 events=xQueueCreate(16,sizeof(V8Event));if(!events)return ESP_ERR_NO_MEM;
 e=esp_now_init();if(e!=ESP_OK)return e;
 e=esp_now_register_recv_cb(received);if(e!=ESP_OK)return e;
 e=esp_now_register_send_cb(sent);if(e!=ESP_OK)return e;
 esp_now_peer_info_t p={.channel=0,.ifidx=WIFI_IF_STA,.encrypt=false};memcpy(p.peer_addr,peer,6);
 return esp_now_add_peer(&p);
}
static uint64_t nonce(void){uint64_t n=0;do{esp_fill_random(&n,sizeof n);}while(!n);return n;}
static esp_err_t transmit(const V8Frame *f,bool *busy,uint64_t *since){
 uint8_t bytes[V8_WIRE_BYTES];v8_encode(f,bytes);
 esp_err_t e=esp_now_send(peer,bytes,sizeof bytes);
 if(e==ESP_OK){*busy=true;*since=(uint64_t)esp_timer_get_time();}
 return e;
}
static void print_feedback(const V8Frame *f){
 ESP_LOGI(TAG,"FEEDBACK kind=%u seq=%"PRIu32" status=%u last_seq=%"PRIu32" flags=%u reason=%u api_last=%d,%d remaining_ms=%u; NOT shaft/RPM",f->kind,f->seq,f->status,f->last_seq,f->flags,f->reason,f->api_left,f->api_right,f->remaining_ms);
}
static void rover_transition(const V8Rover *r,const V8ApiOutput *io){
 static bool have;static V8Rover last;static V8ApiOutput previous;
 if(have&&last.target_left==r->target_left&&last.target_right==r->target_right&&last.reason==r->reason&&last.armed==r->armed&&last.fault==r->fault&&previous.left==io->left&&previous.right==io->right&&previous.known==io->known)return;
 /* Sampled after synchronous output helper returns: software observation,
    neither exact GPIO-edge timestamp nor physical shaft-stop time. */
 ESP_LOGI(TAG,"OUTPUT_STATE mono_us=%"PRIu64" target=%d,%d api_last=%d,%d known=%u arm=%u fault=%u reason=%u deadline_us=%"PRIu64,(uint64_t)esp_timer_get_time(),r->target_left,r->target_right,io->left,io->right,io->known,r->armed,r->fault,r->reason,r->deadline);
 last=*r;previous=*io;have=true;
}
void app_main(void){
 uint8_t own[6]={0};esp_err_t e=hardware_init();
 if(e!=ESP_OK){ESP_LOGE(TAG,"HW_INIT=%s; keep motor VIN switched OFF; inspect, RESET",esp_err_to_name(e));return;}
 e=esp_read_mac(own,ESP_MAC_WIFI_STA);if(e!=ESP_OK){ESP_LOGE(TAG,"MAC=%s; radio CLOSED",esp_err_to_name(e));return;}
 ESP_LOGI(TAG,"STA="MACSTR" role=%s GPIO map is role-specific; physical=UNTESTED",MAC2STR(own),CONTROLLER?"controller":"rover");
 if(!parse_mac(CONFIG_V8_PEER_MAC,peer)||!memcmp(peer,own,6)){
  ESP_LOGW(TAG,"PEER missing/invalid/self: radio CLOSED, outputs requested0/0; configure actual other STA MAC in a NEW build directory");return;
 }
 e=radio_init();if(e!=ESP_OK){if(!CONTROLLER)(void)v8_output_coast();ESP_LOGE(TAG,"RADIO_INIT=%s; diagnose then RESET; NVS was NOT erased",esp_err_to_name(e));return;}
 uint64_t now=(uint64_t)esp_timer_get_time(),boot=nonce(),radio_at=0,hello_at=now,report_at=now;
 V8Rover rover;v8_rover_init(&rover,boot,now);V8ApiOutput io={.known=true};
 V8Controller control;v8_control_init(&control,boot,gpio_get_level(GPIO_NUM_10)==0,now);
 PairButton replay={.candidate=CONTROLLER&&gpio_get_level(GPIO_NUM_11)==0,.stable=true,.since=now};
 bool busy=false,radio_fault=false,ack_pending=false,dropped_ack=false;V8Frame ack_command={0};uint8_t ack_status=0;
 ESP_LOGI(TAG,"BOOT=%016"PRIx64" channel=%d, unencrypted bench; CRC/MAC are NOT authentication. Lease500ms/ownerGap100ms/runCap3s",boot,CONFIG_V8_CHANNEL);
 for(;;){
  now=(uint64_t)esp_timer_get_time();
  if(queue_drops()||(busy&&now>=radio_at&&now-radio_at>2000000))radio_fault=true;
  if(!CONTROLLER){if(radio_fault)v8_latch(&rover,now,V8_RADIO_FAULT);v8_owner_tick(&rover,&io,now,gpio_get_level(GPIO_NUM_4)==0,gpio_get_level(GPIO_NUM_10)==0);rover_transition(&rover,&io);}
  /* Finite work each turn. Dequeue BEFORE reading now; callback timestamps may
     otherwise be in the apparent future after a cross-core interleaving. */
  for(unsigned handled=0;handled<16;handled++){
   V8Event ev;if(xQueueReceive(events,&ev,0)!=pdTRUE)break;now=(uint64_t)esp_timer_get_time();
   if(ev.tx){busy=false;ESP_LOGD(TAG,"MAC delivery=%d, not application ACK",ev.status);continue;}
   V8Frame f;if(now<ev.at||now-ev.at>100000||!v8_decode(ev.data,sizeof ev.data,&f))continue;
   if(CONTROLLER){
    if(f.kind==V8_HELLO){v8_control_hello(&control,&f,ev.at);print_feedback(&f);}
    else if(f.kind==V8_ACK){if(v8_control_ack(&control,&f,now))print_feedback(&f);}
   }else if(f.kind==V8_COMMAND){
    v8_owner_tick(&rover,&io,now,gpio_get_level(GPIO_NUM_4)==0,gpio_get_level(GPIO_NUM_10)==0);
    uint8_t status=v8_owner_command(&rover,&io,&f,ev.at,now);
    rover_transition(&rover,&io);
    ESP_LOGI(TAG,"REQUEST seq=%"PRIu32" target=%d,%d status=%u accepted=%"PRIu64" deadline_us=%"PRIu64"",f.seq,f.left,f.right,status,rover.accepted,rover.deadline);
    if(CONFIG_V8_DROP_FIRST_ACK&&!dropped_ack&&status==V8_ACCEPTED&&(f.left||f.right)){
     dropped_ack=true;ESP_LOGW(TAG,"BENCH omit first accepted motion APP_ACK; duplicates must NOT rewrite or renew");
    }else{ack_command=f;ack_status=status;ack_pending=true;}
   }
  }
  now=(uint64_t)esp_timer_get_time();
  if(CONTROLLER){
   uint8_t d=(gpio_get_level(GPIO_NUM_4)==0?1:0)|(gpio_get_level(GPIO_NUM_5)==0?2:0)|(gpio_get_level(GPIO_NUM_6)==0?4:0)|(gpio_get_level(GPIO_NUM_7)==0?8:0);
   v8_control_inputs(&control,d,gpio_get_level(GPIO_NUM_10)==0,now);
   if(radio_fault){control.clock_fault=true;v8_control_stop(&control,false);}
   if(pair_button(&replay,gpio_get_level(GPIO_NUM_11)==0,now))ESP_LOGI(TAG,"BENCH replay accepted=%u; never new sequence",v8_control_replay(&control,now));
   unsigned action=v8_control_action(&control,now,!busy&&!radio_fault);
   if(action==1){
    e=transmit(&control.command,&busy,&radio_at);v8_control_attempt(&control,now);
    ESP_LOGI(TAG,"SEND seq=%"PRIu32" target=%d,%d attempt=%u err=%s",control.command.seq,control.command.left,control.command.right,control.attempts,esp_err_to_name(e));
    if(e!=ESP_OK)radio_fault=true;
   }else if(action==2)ESP_LOGW(TAG,"APP_ACK_TIMEOUT: execution uncertain; disarmed, bounded STOP queued if applicable");
  }else if(!busy&&!radio_fault){
   if(ack_pending){V8Frame f=v8_owner_feedback(&rover,&io,&ack_command,ack_status,now);e=transmit(&f,&busy,&radio_at);if(e==ESP_OK)ack_pending=false;else radio_fault=true;}
   else if(now>=hello_at){V8Frame f=v8_owner_feedback(&rover,&io,NULL,0,now);e=transmit(&f,&busy,&radio_at);hello_at=now+1000000;if(e!=ESP_OK)radio_fault=true;}
  }
  if(now>=report_at){
   report_at=now+1000000;
   if(CONTROLLER)ESP_LOGI(TAG,"STATE controller boot=%016"PRIx64" peer=%016"PRIx64" intent=%u pending=%u radio_fault=%u drops=%u",boot,control.remote,control.run_latch,control.pending,radio_fault,queue_drops());
   else ESP_LOGI(TAG,"STATE rover boot=%016"PRIx64" target=%d,%d api_last=%d,%d known=%u arm=%u fault=%u reason=%u lease_ms=%u",boot,rover.target_left,rover.target_right,io.left,io.right,io.known,rover.armed,rover.fault,rover.reason,v8_remaining(&rover,now));
  }
  vTaskDelay(pdMS_TO_TICKS(10));
 }
}

参考:[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[9] IDF5.5.5 Wi-Fi:STA、PS_NONE与固定频道前提[12] esp_timer:每boot64位微秒单调时间[13] esp_random约束:Wi-Fi开启后再生成boot身份[14] 固定FreeRTOS队列实现:复制有界事件而非借回调指针[15] 固定GPIO接口:输出/电平与关闭内部上拉下拉

09 / 有限动作与分层验收

先断开驱动看协议,再在架空台给动力

第一轮仅看日志:所有电源关断时断开车端到驱动的AIN/BIN/FLT信号,GPIO4保留独立3.3V外上拉,驱动/动力不接入;再给两板USB。这样不靠未知的未供电输入行为检验协议。全释放启动,选前稳定50ms,再新握持,看到STOP应用ACK后才有新非零seq;释放有更新STOP。这一轮不能填电机动作。

全部断电后接回信号和电机/去耦,固定底盘与架空轮子。先只开USB,核0/0;再开限流5V动力,记录载板实际VIN。首次短于1s,手边保留VIN总开关,若不转/异响/复位/热立即先切动力。无需、不得手堵转来检查OCP。

分别核左、右桥实际极性与轮子安装方向,再做前后与简化差速。左只给右桥正目标,右只给左桥正目标,不承诺角度。反向前必须先全松、观察轴停、换方向并新按;200ms软件间隔不是机械停转实测。

  1. 动力OFF并断开驱动信号,验证两个真实MAC/boot、HELLO、STOP ACK和撤许可。
  2. 全断电复查线束和固定台,接回驱动;USB启动必须目标/API请求0/0。
  3. 开限流源短于1s观察一方向;每次异常先断VIN,再诊断和RESET。
  4. 分桥核实际正反、波形、电流和轴停止,再填写空CSV;未测字段写原因,不写0。
做到这里,你应该看到
两个桥输出只跨接对应电机绕组,任何输出端都不接GND;不会把控制端GPIO号误接到车端按钮。

参考:[1] 官方DevKitC1 v1.1:GPIO资源和互斥主板供电[2] Pololu2130载板:VIN/VMM、SLP、FLT与默认sense接地[3] Pololu原理图0J3872:反接FET、SLP47k与sense短接[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[5] Pololu992:LP6V/100.37:1/无编码器的条件规格[6] 原厂Micro Metal Gearmotors 2026-02 Rev6.2:绕组/堵转/负载边界

10 / 有限动作与分层验收

把丢ACK、断控制端、重放和重启分开做

ACK丢失配置只省略首次新非零的应用ACK,不是RF丢包仿真。用fresh B编译rover-drop-ack.local.defaults(复制正确MAC并加DROP_FIRST_ACK=y),观察首条REQUEST status0、重试相同seq status1;accepted和deadline_us相同。后续新seq续租是另一件事,不能把两者日志混在一起判延时。

恢复普通车端固件后,在架空动作时关闭控制端USB,保持车端运行。车端下次观察最后新seq的500ms截止应请求四线0;OUTPUT_STATE mono_us是在同步输出helper返回后读出的本机软件状态观察时间,不是引脚沿/轴停时间;状态变化立即记录,不能拿每秒STATE估算到期。记录软件请求和实际轴停分别发生何时。静止/无pending后GPIO11重放原非零:已有更新STOP则STALE;只有到期且未发更高seq时可DUP,但仍不武装/不续期。连续握持超过3s同样撤动作,必须新释放/新按。

所有复位试验先关动力。上电按住无意图;车端新boot不接旧receiver命令;控制端新boot被仍绑定旧sender的车端拒绝,需要两端全松RESET。故障输入测试先全部断电、隔离真实FLT,再通过1k/开关将GPIO4到地并恢复USB,不把推挽信号注入开漏线,不故意短接电机。主机HAL失败与实物故障注入要分栏。

用例应核日志/状态不据此宣称
省首应用ACK同seq DUP,accepted/deadline不变无线丢包率/轮子行程
控制端USB关闭最后新seq截止后COAST请求、armed0轴500ms内已停止
旧非零重放STALE或到期DUP,不新写/续期跨重启永久幂等
3s持续握持RUN_LIMIT后撤动作,需新按热安全或允许堵转3s
新boot/FLT/本地STOP取消意图或故障锁闭、人工恢复RESET已经机械安全

参考:[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[16] 本站W02:应用ACK、boot/seq与原样PairButton基础

11 / 有限动作与分层验收

先切动力恢复,再判断是电源、故障还是协议

radio CLOSED先看默认零MAC/角色/自指与fresh B配置;无HELLO查实际STA/同频道/对端供电。SESSION查两端boot,不通过自动清NVS“重配对”。ACK_TIMEOUT只说明没确认,执行仍可能已发生;先撤许可并按台架步骤检查。STOP_REQUIRED来自上电、到期/3s、迟调度或反转门禁,需要全松重建有意动作。

API_KNOWN=0时部分LEDC可能已变,保留原日志、切动力排查后RESET;不让日志旧值冒充波形。FLT低/板重启/电机不转先看VIN负载跌落和峰值、共地/焊接、型号、限流与卡轴,不能关欠压检测或用自动重启掩盖原因。TI RevE §7.3.5.3写UVLO拉低nFAULT,但Table3同条件写None;本例仅将实际读低锁故障,不把FLT高当VIN正常。

独立电源只是本工程指定方案,未选择/验证电池包。操作者实际用台式源时在表填source_kind=bench、battery_model_or_not_used=not_used。若改电池,先另锁厂商包/修订、满充最高/带载最低、再生尖峰、持续/峰值、保护、线束、开关及充电器;同时满足电机6V附近用途和驱动2.7..10.8V及瞬态裕量,还要核电容/开关/线束中最窄额定;本次≥10V电解为5V台架选择,不能只看驱动10.8V就批准整个系统跑该电压,不将标称电压或电池容量换成SOC/本站续航。

#992厂家通常建议有刷电机工作电流≤堵转电流25%,不是占空25%即可保证。载板约1.2A连续/2A短峰依散热,PWM有附加热,两桥不能把电流随意相加为单桥额定。记录实际PWM/两电机电流/表面温升/VIN最小和峰值,0.8A总限流不证明单电机被保护。

接地示波器参考只接系统GND,不把地夹接任何H桥输出;测电机差分需额定合适差分探头或已核允许的双通道方法。串电流表/换线先断电,记录量程、压降、采样率和限流状态。两个独立串口的本地时间不能直接相减成无线延时;用共同参考或同步采集。要测握持释放到四路逻辑归零,用适合3.3V、至少5信号通道的逻辑分析仪同钟采集控制端GPIO10与车端16/17/18/21;全断电接线,核USB/仪表地参考,地只接系统GND不接电机输出。量释放高沿到四输入持续0/0(窗口跨多个实测PWM周期);这仍不是轴停,轴需另有共同时间参考的光学/编码器观察。空表只填真实观察,未测写原因。

做到这里,你应该看到
恢复顺序始终是VIN关→等轴停→保留日志/排查→USB RESET全松核0/0→重新开VIN,不靠松故障按钮自动继续。

参考:[2] Pololu2130载板:VIN/VMM、SLP、FLT与默认sense接地[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[5] Pololu992:LP6V/100.37:1/无编码器的条件规格[6] 原厂Micro Metal Gearmotors 2026-02 Rev6.2:绕组/堵转/负载边界

12 / 有限动作与分层验收

把44组主机输入和架空测量分成两份证据

下载host_check.c运行16接收策略组,host_controller.c为10控制端组,tests/output_owner.c直接包含生产owner/output、10个LEDC桩组;tests/actual_main_body.py从实际main提取五个完整解析/回调函数,6组明确时钟/队列桩;protocol_loop.c把生产帧编码/解码与两端模型串起,2组合成应用ACK丢失、续租、松按和重启。合计44组,不运行Wi-Fi驱动、并发任务或电机。

构建应逐角色保存命令/生成配置、真实C编译输入SHA、bin/ELF/map/锁和原日志;编译路径里占位MAC不代表实际连接。源码/图示、软件构建、主机逻辑与物理记录各有独立验证状态,观察表只有表头。本站没有轮子/电流/电池/距离的结果,不能把PASS复制进这些字段。

完成自己的架空记录后,再回V07看功率/方向条件,回W02看应用ACK与不确定性。若将来增加编码器,需要另定义实际速度反馈、停转判据和故障策略;本篇不能因为日志已有api_last就声称完成闭环调速。

本站四最终原生构建均已实际Exit0:默认关闭687520B、控制端679952B、车端687520B、省首应用ACK687792B;每条生成配置与产品SHA均已保护。首次默认工程曾因MAC解析一行缩进触发GCC Werror,未产有效app,旧源码快照/日志/21产品完整保留,拆行后新B通过;没有关闭警告、修改SDK或抹去失败。以上是编译结果,未执行烧录。

代码 / 配置
clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -fno-omit-frame-pointer host_check.c -o /tmp/v08-rover-host
/tmp/v08-rover-host
clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -fno-omit-frame-pointer host_controller.c -o /tmp/v08-controller-host
/tmp/v08-controller-host
clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -fno-omit-frame-pointer -Imain -Itests/stubs tests/output_owner.c -o /tmp/v08-output-host
/tmp/v08-output-host
python3 tests/actual_main_body.py /tmp/v08-main-body
clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -fno-omit-frame-pointer -Imain -Itests/stubs tests/protocol_loop.c -o /tmp/v08-loop-host
/tmp/v08-loop-host

参考:[4] TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义[7] 固定IDF5.5.5 ESP-NOW接口与新回调类型[8] 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调[16] 本站W02:应用ACK、boot/seq与原样PairButton基础

BACK TO THE SOURCE

资料来源与版本说明

使用agent-reach的gh/Jina与Web读取固定主源,12个IDF固定文件逐字匹配安装SDK,实看md17a/0J3762背照与0J3872电路;原厂6V条件/UVLO文本表格差异明示,本站物理量不补造。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 官方DevKitC1 v1.1:GPIO资源和互斥主板供电
  2. Pololu2130载板:VIN/VMM、SLP、FLT与默认sense接地
  3. Pololu原理图0J3872:反接FET、SLP47k与sense短接
  4. TI DRV8833 SLVSAR1E RevE:电平/00COAST/保护与UVLO歧义
  5. Pololu992:LP6V/100.37:1/无编码器的条件规格
  6. 原厂Micro Metal Gearmotors 2026-02 Rev6.2:绕组/堵转/负载边界
  7. 固定IDF5.5.5 ESP-NOW接口与新回调类型
  8. 固定ESP-NOW文档源码:MAC结果/应用ACK/高优先级回调
  9. IDF5.5.5 Wi-Fi:STA、PS_NONE与固定频道前提
  10. IDF5.5.5 LEDC API:10bit/set/update/stop
  11. 固定LEDC实现:stop输出关闭及update恢复输出
  12. esp_timer:每boot64位微秒单调时间
  13. esp_random约束:Wi-Fi开启后再生成boot身份
  14. 固定FreeRTOS队列实现:复制有界事件而非借回调指针
  15. 固定GPIO接口:输出/电平与关闭内部上拉下拉
  16. 本站W02:应用ACK、boot/seq与原样PairButton基础
  17. Pololu载板背面原照:md17a / 0J3762丝印与端子