无线与 USB 拓展 / 通信实践发布 内容更新

ESP32-S3 原生 USB HID 按钮:实体使能、单键按下与释放

固定TinyUSB版本实现F13单键,处理启动按住、去抖、忙端点、重连先释放与原生USB占用;主机效果明确待测。

读完能做什么
构建默认不发键、必须实体ARM的单键原型,分别验证本地状态、USB报告、OS枚举和应用键盘效果。
开始之前
先完成所列前置,能用桥接串口下载IDF工程、断电追线;本页仅指定低压台架。
所需器材
官方S3 v1.1 N8R8、同电脑两根USB数据线、万用表、10kΩ±5%两只、低压SPST ARM、常开BUTTON、短线、WP7113ID和330Ω±5%≥0.25W;可用安全本地键盘观察窗口的电脑
版本组合
固定板:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8框架 / Python:ESP-IDF5.5.5 / Python3.12.13实际交叉编译器:xtensa-esp-elf-gcc14.2.0 / esp-14.2.0_20260121官方USB组件:esp_tinyusb2.2.1 / esp-usb8e779566ef71d43928cbf7e125e8eb54bab3f542底层TinyUSB:0.19.0~3 / ab4a1817907ed865b10a712b5e03e5e0a9902df5报告 / 回调:单8B键盘/F13 usage0x68;SUSPEND与RESUME_CALLBACK=yLED:WP7113ID V.14A / 330Ω串联
适用范围

仅官方S3 DevKitC1 v1.1 N8R8 / IDF5.5.5 / esp_tinyusb2.2.1 + tinyusb0.19.0~3;原生USB19/20占用,桥UART0日志;未真实HID枚举或主机按键。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/ble-usb-sources.json、ble-usb.md;具体章节已读取并核对本机SDK/managed。版本:固定IDF / 子模块 / 厂家资料验证日期:
构建 / 配置已通过
docs/esp32-evidence/ble-usb-builds.json;实际完整原生构建Exit0,输入/配置/固件与原始log SHA留存。对象:ESP32-S3 / N8R8版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/ble-usb-host.json;纯C应用模型和格式检查,不运行物理BLE/USB。对象:macOS arm64同源主机夹具版本:Apple clang21 ASan/UBSan / Python3.12.13验证日期:
本站实板待验证
examples/esp32/esp32-s3-usb-hid-button/observations.csv仅表头;未接实板,不声称手机配对/无线接收/键盘效果。对象:指定板/模块/手机或USB主机

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

01 / 通信实践

先认原生USB口,再给按钮接独立上拉

USB HID从ESP32-S3原生USB-OTG输出,使用GPIO19 D−/J3-20、GPIO20 D+/J3-19;桥USB-to-UART经GPIO43/44做下载与UART0日志,不会替主控发HID。此固件占原生USB,不能同时当另一套USB CDC、USB-Serial/JTAG或其他USB设备栈;这两GPIO也不再外接按钮。

ARM用GPIO5/J1-5,BUTTON用GPIO6/J1-6,各经独立10kΩ上拉3V3,开关/按钮接地LOW有效。GPIO4经330Ω接WP7113ID阳极、阴极共地,仅作本地armed指示。默认ARM开路关闭、按钮松开,全部断电追线后再上电。

固定v1.1官方文档允许两个USB作为同一供电组,本例可两数据线接同一电脑:桥口日志、原生口HID;不再经5V/3V3针注入另一电源。此许可不能外推匿名克隆板。无实际枚举/键盘结果,空CSV等待真实观察。

资源用途不要混用
GPIO5 / 6ARM / BUTTON独立10k上拉,LOW有效不短接两信号
GPIO4330Ω→WP7113ID→GND命令指示,不是USB反馈
GPIO19 / 20原生USB D−/D+不再外接GPIO负载
GPIO43 / 44桥UART0 TX/RX 115200桥口不是HID出口

参考:[1] 官方固定板:原生USB/桥口/排针/供电组[2] IDF5.5.5 USB Device文档:S3 USB资源[14] Kingbright WP7113ID V.14A

02 / 通信实践

两层官方USB组件都锁到实际解析的版本

锁esp_tinyusb2.2.1、tinyusb0.19.0~3与IDF5.5.5,dependencies.lock随包。官方IDF示例的^2.0.0仅作为API路线参考,没有沿用浮动范围,也没复制自动鼠标/键盘demo。底层版本的~3是注册版本的一部分;裸0.19.0查询失败不等于该精确注册版本不存在。

实际组件分别对应esp-usb提交8e779566ef71d43928cbf7e125e8eb54bab3f542与tinyusb提交ab4a1817907ed865b10a712b5e03e5e0a9902df5,两包CHECKSUMS共2224文件无差异,16个关键官方固定文件与managed逐字一致。使用已有SDK/Python3.12.13,不重复安装整套工具。

HID_COUNT1、CDC关闭、console为桥UART0,SUSPEND与RESUME_CALLBACK必须在最终sdkconfig为y。首轮先解析组件后才补defaults,旧配置仍off,虽构建Exit0也未满足目标;已保留历史并显式改配置实际重编Exit0,最终单任务修正appbin208928B。应用只用组件device_event,不重复定义其强suspend/resume函数。

代码 / 配置
idf.py --version
python --version
idf.py -B build -D SDKCONFIG=sdkconfig.local build
# 核实际 sdkconfig.local 中两个 CALLBACK=y,再从桥口上传
idf.py -B build -D SDKCONFIG=sdkconfig.local -p /dev/cu.YOUR_BRIDGE_PORT flash monitor

参考:[3] IDF5.5.5官方HID示例组件范围(仅参考)[4] esp_tinyusb2.2.1官方注册页[5] TinyUSB0.19.0~3官方注册页[6] 固定esp_tinyusb README:默认描述符与可选回调[7] 固定esp_tinyusb:配置/事件API[8] 固定esp_tinyusb:强mount/suspend/resume回调

03 / 通信实践

只定义一个8字节键盘接口,不自动输入文本

配置描述符34B包含一个HID键盘接口、IN端点0x81、最大包8B、10ms轮询参数,配置宣告100mA且无remote wakeup;100mA是声明,不是测得整板电流。报告描述符65B无reportID,键数组usage/logical最大255,可以表示F13 usage0x68。本站从最终ELF提取并检查字节,未因此声称OS已枚举。

按下报告00 00 68 00 00 00 00 00,释放报告八个0;无宏、无文本、无鼠标。GET_REPORT返回本地命令快照,host键盘LED输出不能使能。报告排队成功或端点再次ready都不是应用动作ACK。

设备字符串Hixinwu Lab / W07 F13 Button (ARM required),固定实验serial W07-LAB-PROTOTYPE不是唯一身份;默认组件实验VID/PID为303A:4004,不代表本站量产VID授权/认证。OS可能缓存相同身份的旧接口,做变更实验时保留具体版本/端口记录。

字段值能证明什么
HID键F13 / 0x68报告可表示单键;实际映射待测
输入报告8B / 无reportID精确按下或全释放字节
端点0x81 / 8B / 10ms描述符参数,非应用时延
USB配置100mA / 无remote wakeup声明,不是功耗/唤醒实测

参考:[9] 固定描述符控制:自定义FS配置与string[10] 固定默认设备描述符/VID PID算法[11] 固定TinyUSB HID键盘报告宏[12] 固定TinyUSB HID usages:F13=0x68[13] 固定TinyUSB HID ready/report实现

04 / 通信实践

启动按住不发键,恢复后要重新实体使能

TinyUSB任务的SOF事件回调按完整64位时钟节流到至少10ms,ARM/BUTTON各稳定30ms才采纳,调度影响实际边沿。每次启动、重连或组件USB事件,先全释放报告并等端点ready,再接受ARM;ARM从启动就ON不会自动启用,要先OFF稳定再ON,BUTTON也必须先松开。

使能后一次新按下经去抖发F13,本地按下命令到50ms请求ZERO,端点忙可延后,不承诺电脑恰好按住50ms。一直按住不连发;新按下遇端点忙直接丢弃而非缓存,避免恢复时突然发键。ARM关闭后去抖撤使能并请求释放;队列拒绝需要新ARM周期。

USB栈已处理attach/detach/suspend/resume时,在同一USB任务回调同步撤本地按下/使能;GPIO/model tick和tud_hid_report都在该任务SOF回调,事件处理不能跨任务夹在决策与提交之间。ATTACHED重新启用SOF,app_main只初始化后返回。可用SOF回调中检查待发送超过250ms仍未ready则本地取消;SOF停止时等事件或下一次回调再检查,250ms不是离线墙钟保证。下一次ready先ZERO,完整64位处理长运行。

事件处理前已经提交的USB transfer不能靠本地reset撤回,主机恢复后仍可能取走;保证是事件处理后不会按旧armed提交新的PRESS,不是主机绝不会再见到旧按键。桥口仍供电时拔原生线,本电路没有独立VBUS检测,不能保证即时拔线识别;只响应栈已报告状态/事件或待发送超时。线断期间送不出释放,不保证主机清键。只靠原生口供电时拔线为整板断电,重插走启动防护,两组必须分开验收。

做到这里,你应该看到
能说明本地取消、USB发送释放、主机清除键状态是三件不同的事。

参考:[1] 官方固定板:原生USB/桥口/排针/供电组[8] 固定esp_tinyusb:强mount/suspend/resume回调[13] 固定TinyUSB HID ready/report实现[15] IDF5.5.5完整64位esp_timer时钟

05 / 通信实践

完整主程序:默认关闭、一次按下、一次释放

main.c与下载包逐字一致,hid_model.c/.h和CMake/锁/分区/许可/host工具一并提供。USB事件、SOF内GPIO采样/纯C模型和报告提交由同一TinyUSB任务串行处理,纯C模型统一决定命令;未调用remote wakeup、未等待用户串口打开,也没有开机自发键路径。

GPIO4指示state.armed而非“主机收到”;若要确认动作,必须另看主机键盘事件。失败不会排一个永久重试的按键序列。更换其他usage、布局或USB复合设备已超出本页固定范围,应重新检查描述符和安全启动流程。

代码 / 配置
/* SPDX-License-Identifier: MIT */
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "driver/gpio.h"
#include "tinyusb.h"
#include "tinyusb_default_config.h"
#include "class/hid/hid_device.h"
#include "hid_model.h"
static const char *TAG="W07";
/* After initialization, this state is owned only by the TinyUSB task. */
static w07_state_t state;static bool current_report_pressed;static uint64_t last_poll_ms;static bool polled;
_Static_assert(HID_KEY_F13==0x68,"Locked HID F13 usage must match production model");
const uint8_t w07_report_descriptor[]={TUD_HID_REPORT_DESC_KEYBOARD()};
#define CONFIG_DESC_LEN (TUD_CONFIG_DESC_LEN+TUD_HID_DESC_LEN)
const uint8_t w07_configuration_descriptor[]={
 TUD_CONFIG_DESCRIPTOR(1,1,0,CONFIG_DESC_LEN,0,100),
 TUD_HID_DESCRIPTOR(0,4,HID_ITF_PROTOCOL_KEYBOARD,sizeof(w07_report_descriptor),0x81,8,10)
};
static const char *strings[]={(const char[]){0x09,0x04},"Hixinwu Lab","W07 F13 Button (ARM required)","W07-LAB-PROTOTYPE","Single keyboard interface"};
uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance){(void)instance;return w07_report_descriptor;}
uint16_t tud_hid_get_report_cb(uint8_t instance,uint8_t report_id,hid_report_type_t type,uint8_t *buffer,uint16_t requested){
 (void)instance;if(report_id!=0||type!=HID_REPORT_TYPE_INPUT)return 0;
 uint8_t bytes[8];w07_report_bytes(current_report_pressed,bytes);uint16_t n=requested<sizeof(bytes)?requested:sizeof(bytes);memcpy(buffer,bytes,n);return n;
}
void tud_hid_set_report_cb(uint8_t instance,uint8_t report_id,hid_report_type_t type,uint8_t const *buffer,uint16_t size){(void)instance;(void)report_id;(void)type;(void)buffer;(void)size; /* Host keyboard LED output does not arm this device. */}
static void device_event(tinyusb_event_t *event,void *arg){
 (void)arg;w07_reset_link(&state);current_report_pressed=false;gpio_set_level(GPIO_NUM_4,0);
 /* bus_reset clears TinyUSB's sof_consumer; enable only after configuration. */
 if(event->id==TINYUSB_EVENT_ATTACHED)tud_sof_cb_enable(true);
 ESP_LOGI(TAG,"USB event=%d; require zero report and fresh physical ARM cycle; submitted transfers cannot be recalled",event->id);
}
/* TinyUSB dispatches SOF and device_event in the same task, not in the ISR.
 * No task can process a device_event between model decision and report enqueue.
 * Physical/queued events not yet dispatched, and already queued USB transfers,
 * are outside that guarantee; a disconnected host cannot be sent a release.
 */
void tud_sof_cb(uint32_t frame_count){
 (void)frame_count;const uint64_t now=(uint64_t)esp_timer_get_time()/1000ULL;
 if(polled&&now>=last_poll_ms&&now-last_poll_ms<10)return;
 polled=true;last_poll_ms=now;
 const bool usb=tud_mounted()&&!tud_suspended();const bool ready=tud_hid_ready();
 w07_command_t command=w07_tick(&state,now,usb,ready,gpio_get_level(GPIO_NUM_5)==0,gpio_get_level(GPIO_NUM_6)==0);
 if(command!=W07_NONE){uint8_t report[8];const bool press=command==W07_PRESS;w07_report_bytes(press,report);bool accepted=tud_hid_report(0,report,sizeof(report));
  if(accepted){w07_queued(&state,command,now);current_report_pressed=press;}else{w07_failed(&state);current_report_pressed=false;}
  ESP_LOGI(TAG,"REPORT %s queued=%u; USB queue acceptance is not application effect",press?"F13_PRESS":"ALL_RELEASED",accepted);
 }
 gpio_set_level(GPIO_NUM_4,state.armed);
}
void app_main(void){
 gpio_config_t inputs={.pin_bit_mask=(1ULL<<5)|(1ULL<<6),.mode=GPIO_MODE_INPUT};ESP_ERROR_CHECK(gpio_config(&inputs));
 gpio_config_t led={.pin_bit_mask=1ULL<<4,.mode=GPIO_MODE_OUTPUT};ESP_ERROR_CHECK(gpio_config(&led));gpio_set_level(GPIO_NUM_4,0);
 w07_reset_link(&state); /* Complete before driver creates the USB task. */
 tinyusb_config_t cfg=TINYUSB_DEFAULT_CONFIG(device_event);cfg.descriptor.string=strings;cfg.descriptor.string_count=sizeof(strings)/sizeof(strings[0]);cfg.descriptor.full_speed_config=w07_configuration_descriptor;
 esp_err_t e=tinyusb_driver_install(&cfg);if(e!=ESP_OK){ESP_LOGE(TAG,"TinyUSB install=%s; no key reports",esp_err_to_name(e));return;}
 ESP_LOGI(TAG,"ARM GPIO5 and BUTTON GPIO6 activeLOW external10k; nativeUSB19/20, UART0 bridge; USB task owns polling/reports; no automatic key at boot");
}

参考:[6] 固定esp_tinyusb README:默认描述符与可选回调[7] 固定esp_tinyusb:配置/事件API[8] 固定esp_tinyusb:强mount/suspend/resume回调[11] 固定TinyUSB HID键盘报告宏[12] 固定TinyUSB HID usages:F13=0x68[13] 固定TinyUSB HID ready/report实现[15] IDF5.5.5完整64位esp_timer时钟

06 / 通信实践

在安全观察窗口逐项做枚举、按下、keyup和拔插

尚未执行真实电脑键盘实验。先ARM关、按钮松开,桥口刷入并保留日志,再把原生USB接同一电脑。OS USB设备树/设备管理器要确认产品字符串、303A:4004和键盘接口,不能以出现一个串口作为HID成功依据。

包内host/key-observer.html仅监听有焦点时isTrusted键盘事件,不访问USB、不注入按键;用本地http.server打开。F13可能被系统或快捷键工具截获,未观察到事件先核焦点/系统映射,不改成会在编辑器输入文本的自动宏。操作前移开终端/编辑器/远程会话等敏感输入焦点。

  1. 工程根运行python -m http.server 8766 --bind 127.0.0.1 --directory host,浏览器打开127.0.0.1:8766/key-observer.html并点击观察按钮。
  2. ARM关稳定→开稳定,按一次BUTTON;分别记录UART PRESS/ZERO与实际F13 keydown/keyup,不把queued行当浏览器事件。
  3. 保持BUTTON10秒检查不自动重复,松开再按检查下一组;另做快速抖动、ARM关闭按键、启动ARM已开、启动BUTTON已按住。
  4. 按下期间ARM关;分开做电脑挂起/恢复、桥口供电下拔原生线、仅原生供电断电重插,记录实际USB事件与主机键状态。
  5. 重连先ZERO且无自动按键,重新OFF→ON才试下一键;结束关ARM、拔原生线,所有失败行保留。

参考:[1] 官方固定板:原生USB/桥口/排针/供电组[8] 固定esp_tinyusb:强mount/suspend/resume回调[13] 固定TinyUSB HID ready/report实现

07 / 通信实践

原生口被占用时,桥UART0仍是恢复入口

只插桥口能下载却无HID是预期,检查第二根原生数据线和接口。旧固件占用USB或枚举失败时,不依赖已经不可用的HID口救援:ARM关、拔原生线、桥口供电,按住BOOT、点RESET、松BOOT,经桥口刷回本例或已知良好固件。USB供电组不再叠针脚输入。

启动/重连不发键先核ZERO首报、ARM fresh cycle与按钮先松开,这是保护条件。若真实观察出现残留键,先取消ARM/拔HID,必要时用实体键盘释放或重开观察应用,记录故障;固件不能隔空送释放到已断线主机。

组件callbacks默认更改后无事件,先查实际sdkconfig而非只读defaults;本例最终两个y,应用不定义重复强回调。无法及时获得端点ready的按键不是等网络恢复后补发,用户需要明确重新使能和操作。

现象检查恢复
有串口无HID桥/原生口与数据线ARM关后确认原生口/OS设备树
旧USB固件阻塞USB资源占用/日志口桥BOOT+RESET刷回
恢复不发键ZERO、ARM off/on、BUTTON已松开按保护顺序重做
失联/缺keyup真实主机事件与USB状态取消ARM/断HID,保留故障,不承诺远端释放

参考:[1] 官方固定板:原生USB/桥口/排针/供电组[2] IDF5.5.5 USB Device文档:S3 USB资源[6] 固定esp_tinyusb README:默认描述符与可选回调[8] 固定esp_tinyusb:强mount/suspend/resume回调

08 / 通信实践

同源状态机与ELF检查都完成,物理USB仍待验收

本轮同源ASan/UBSan夹具通过启动按住、30ms稳定、实体使能、一次F13/50ms释放意图、长按不重复、ARM关闭、忙端点丢按键、队列失败、重连先零、250ms及完整64位时间。另用显式host USB/GPIO桩编译实际main.c,按真实回调顺序注入SUSPEND+RESUME/重连,核已处理事件后同步撤ARM、先ZERO和新实体周期;占位描述符宏不替代ELF检查。它不运行真实TinyUSB栈、不枚举USB,也不向电脑产生真实键盘事件。

最终ELF内65B/34B描述符检查留在ble-usb-hid-descriptors.json,实际组件解析、首轮callbacks-off与最终callbacks-on构建日志/配置/产物SHA分开记录。空observations.csv保留USB枚举、原生拔插、启动按住、挂起/恢复和主机键盘结果的待测位置。全部物理项仍pending,不能把“编译通过”改成“已能控制电脑”。

参考:[6] 固定esp_tinyusb README:默认描述符与可选回调[8] 固定esp_tinyusb:强mount/suspend/resume回调[11] 固定TinyUSB HID键盘报告宏[13] 固定TinyUSB HID ready/report实现

BACK TO THE SOURCE

资料来源与版本说明

按固定官方实现、厂家引脚和实际构建核查;主机夹具仅证明软件边界,指定板/手机/USB实体运行仍未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 官方固定板:原生USB/桥口/排针/供电组
  2. IDF5.5.5 USB Device文档:S3 USB资源
  3. IDF5.5.5官方HID示例组件范围(仅参考)
  4. esp_tinyusb2.2.1官方注册页
  5. TinyUSB0.19.0~3官方注册页
  6. 固定esp_tinyusb README:默认描述符与可选回调
  7. 固定esp_tinyusb:配置/事件API
  8. 固定esp_tinyusb:强mount/suspend/resume回调
  9. 固定描述符控制:自定义FS配置与string
  10. 固定默认设备描述符/VID PID算法
  11. 固定TinyUSB HID键盘报告宏
  12. 固定TinyUSB HID usages:F13=0x68
  13. 固定TinyUSB HID ready/report实现
  14. Kingbright WP7113ID V.14A
  15. IDF5.5.5完整64位esp_timer时钟