ESP32-S3+ESPNOW打造无需配对的无线空鼠方案 上一篇文章里我把无线空鼠的完整方案梳理了一遍从方案选型、IMU读取到USB HID模拟鼠标基本上把整条链路走通了但当时通信方式用的还是偏“开发板验证”的串口思路。这篇文章直接进正题把通信链路彻底换成ESPNOW做成一只不需要连WiFi路由器、不需要配对的“纯空鼠”。发送端是一块带六轴传感器的ESP32-S3小板戴在手腕上甩动接收端是另一块ESP32-S3插到电脑USB口上系统里直接识别成标准鼠标。这里没有蓝牙配对、没有驱动安装、没有路由器从传感器到光标就是一条很短的链路。适合手里有ESP32-S3开发板、想自己做低成本体感演示器、翻页笔或者无障碍辅助输入设备的人参考。就算没看过前一篇跟着这篇把初始化和代码骨架跑一遍也能把最基本的功能点亮。1. 为什么空鼠一定要选ESPNOW这种“轻量直连”先把结论放前面鼠标这一类输入设备对通信链路有几个硬指标决定了它跟常规WiFi通道合不来却跟ESPNOW是绝配。ESPNOW不是一个新的无线标准它跑在WiFi的2.4GHz物理层上相当于把两个WiFi设备变成“对讲机”不需要路由器中转也不需要建立连接。这句话里的每个字都是重点我一个个拆开说。鼠标要跟手端到端时延得在十几毫秒以内超过几十毫秒人就开始觉得“飘”上报频率要到100Hz以上手的微小抖动才能被平滑映射成光标准轨迹可靠性反而要求不高因为鼠标上报的是相对位移丢一两包无所谓下一帧补上就行。TCP那套握手、应答、重传在这里完全帮倒忙——重传一个过期点位光标反而会打转。UDP虽然快但必须依赖路由器和网络栈你把鼠标带到别人电脑上还要先问WiFi密码这场景想想就离谱。ESPNOW天然满足三条硬指标包长上限250字节对空鼠几十字节的数据帧绰绰有余没有连接生命周期也就没有连接管理开销一次发送在链路层就是一段很短的空中传输不像TCP还要来回确认。再加上ESP32-S3芯片自带WiFi收发两端用同一颗芯片就能把“采集、通信、HID上报”整条链路闭环成本压到很低。1.1 鼠标对通信链路的三个硬指标我调试时习惯把这三个指标分开测不然出问题你都不知道怪谁。第一是时延人眼对光标的连续感很敏感你用示波器或者高速摄像头测一下从手部动作到屏幕光标响应超过50ms基本就没法正常用了。第二是刷新率这个跟时延是两回事刷新率决定轨迹的平滑度100Hz是最低底线低于这个数你会看到光标一跳一跳像在走楼梯。第三才是可靠性的“够用就行”无线鼠标基本都不做重传丢了这一帧就丢这一帧光标只是少动一点用户根本感知不到。这三个指标定下来你回头看ESPNOW的机制就会非常舒服。它没有ACK机制发给对端就不管了这恰恰符合“增量上报”的语义。反过来说如果有人把ESPNOW拿来传文件那一定是个灾难因为丢包会导致文件损坏。协议本身没有善恶只有合不合适。1.2 ESPNOW协议特性与实际时延ESPNOW底层用的是WiFi的2.4GHz射频数据以802.11帧的形态在物理层传输。它有一条很重要的特性当两个ESP32在同一信道、知道对方MAC地址时可以直接往对方MAC发数据AP只是可选项。实际测下来一次ESPNOW发送的空中传输时间在几十到几百微秒量级加上ESP32内部协议栈的处理真正影响体感的往往不是通信而是IMU采样、滤波和USB枚举轮询这些周边的开销。从我的实测感受来说ESPNOW纯空鼠的体感和市面上两千元的商用演示器差距已经不明显了前提是你把后面的几个细节做对关掉WiFi的modem sleep、把发送频率拉到100Hz以上、USB描述符里的轮询间隔设成1ms。这几个都是容易漏掉的点后面会单独展开。1.3 什么时候不要用ESPNOWESPNOW也不是万能的。第一它没有标准重传和拥塞控制在2.4GHz干扰严重的环境比如办公室满屋子路由器会丢包极端情况下光标会顿挫。第二它是广播式的同信道的其他ESPNOW设备也能收到你的数据帧虽然你可以绑定对端MAC但也只是“不接收”空中还是有数据在飞。第三它的有效数据载荷是250字节不适合大包传输。所以如果你想把空鼠做成商品可能还得考虑加密、跳频、私有协议这些工程化问题但作为DIY项目、作为原型验证ESPNOW是目前用ESP32做空鼠最省心的方案。它不需要额外买2.4G私有模块模块成本就是ESP32-S3本身。这个项目我目标很明确不追求量产级优化先把“能甩、跟手、零配置”做出来。2. 整体架构与硬件准备虽然叫“纯空鼠”但系统天然分成两端发送端负责采集手的运动接收端负责把运动翻译成PC能认识的鼠标报告。中间那根看不见的线就是ESPNOW。这段先把架构图和数据流理清楚再列硬件清单和接线方式最后解释为什么接收端也必须是ESPNOW方案的S3。数据流是单向的这很重要。发送端的IMU拿到角速度经过零偏校准和滤波换算成这一帧的鼠标位移增量打包成ESPNOW帧发出去接收端收到后把位移增量填进USB HID鼠标报告通过USB口上报给PC。PC看到的就是一只最普通的鼠标没有驱动要装没有配对过程插上就能用。所有“智慧”都在发送端接收端只是一个透明的搬运工这跟商用无线鼠标的架构也一致。2.1 发送端与接收端的分工发送端的核心任务有两个半读IMU、算位移外加管按键。它要在一个很小的PCB上集成传感器、电池、按键和一个状态指示灯。接收端的任务只剩半个收包上报。接收端不需要做姿态解算不需要懂陀螺仪它只需要把收到的增量数据原封不动塞进HID报告。把解算全放在发送端有个额外好处接收端固件一旦写好就再也不用改灵敏度、死区、曲线这些手感参数全在发送端调调完空中就生效不用插拔接收端。这两个任务的算力需求都很低ESP32-S3的双核240MHz主频在这里甚至有点过剩。但正是这颗芯片让整个方案变得优雅发送端用它做计算和无线接收端用它做无线和USB模拟一颗芯片通吃两端开发环境、工具链、调试手段完全一致踩坑知识能复用。2.2 硬件清单、接线与供电实际的物料清单很朴素我不建议一上来就买各种模块堆料。发送端需要一块ESP32-S3开发板带USB口即可、一个I2C接口的六轴IMU模块MPU6050这类就行、两个微动按键、一颗几百毫安时的锂电池、一个3.3V稳压模块和一块比较小的洞洞板。接收端就是另一块ESP32-S3开发板直接用它的原生USB口连电脑不需要额外的串口芯片。线材方面准备几根杜邦线、一个USB-C数据线就够了。接线里唯一要上心的是IMU部分VCC接3.3VGND共地SCL和SDA分别接到S3的I2C引脚再在SCL、SDA各加一个4.7kΩ上拉电阻。很多模块板上自带上拉买模块时问一句就能省事。按键建议一端接GPIO、一端接GND用内部上拉读低电平有效这样省去外部上拉电阻。电池供电要在IMU和ESP32之间做好电源去耦至少加一个100uF电解电容和一个0.1uF陶瓷电容并联放在电源入口否则ESPNOW发送瞬间的大电流会把电压拉出一个毛刺传感器读数会跟着抖。2.3 为什么接收端也必须是ESP32-S3这个“必须”来自一个很硬的约束要模拟USB鼠标接收端必须拥有USB外设控制器。ESP32-S3的USB-OTG是它区别于普通ESP32系列最重要的外设之一可以直接枚举成鼠标、键盘这类HID设备不需要额外接一颗USB转HID芯片。你如果用普通ESP32当接收端就得外挂一颗USB协议芯片那这方案一下就失去了“一颗芯片通吃”的意义。另一个原因还是回到开发一致性。接收端和发送端跑在同样的芯片、同样的SDK上只是加载的应用固件不同。这意味着你手里只需要一种开发板出问题时换一块板子就能抓包排查。我后来把发送端和接收端的工程放在同一个解决方案里编译一次能产出两个固件维护起来省力非常多。3. ESPNOW通信层的搭建与数据帧设计这一章是整个项目的“发动机”。ESPNOW的API数量其实不多但初始化顺序和参数细节特别容易翻车。我先把初始化流程和两个关键坑讲透再给出一份适合空鼠场景的数据帧结构最后讨论发送节奏的把握。别小看“节奏”这件事它直接决定光标顺不顺。3.1 初始化顺序与两个容易被忽略的参数ESPNOW的初始化依赖WiFi先起来。你要先调esp_wifi_init把WiFi模式设成AP_STA再调esp_now_init。为什么是AP_STA而不是纯STA因为纯STA模式下不连路由器时信道容易被某些SDK版本重置导致两端信道对不上AP_STA模式下可以直接用esp_wifi_set_channel锁住信道调试省心。第二个关键参数是modem sleep也就是WiFi的省电模式。很多例程不关但如果你带着这个默认设置做空鼠会惊悚地发现光标每隔几秒卡顿一下——那是无线芯片在打盹。务必调用esp_wifi_set_ps(WIFI_PS_NONE)把省电关掉。虽然费一点电但输入设备的第一优先级是实时性。还有一个很多人不问但值得提的参数发射速率。ESP32默认会根据信号强度自动选择物理层速率这在高速移动环境是合理的但对我们这种近距离固定链路反而是负担。可以调用esp_wifi_config_80211_tx_rate把速率固定在一个较高档位比如54Mbps的OFDM速率减少单包在空中占用的时间。实际上空中时间本来就极短这个优化属于锦上添花但做出来确实能挤出一两毫秒的时延改善。初始化代码骨架长这样两端通用#include esp_wifi.h #include esp_now.h void wifi_and_espnow_init(uint8_t channel) { wifi_init_config_t cfg WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(cfg); esp_wifi_set_storage(WIFI_STORAGE_RAM); esp_wifi_set_mode(WIFI_MODE_AP_STA); esp_wifi_start(); esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); esp_wifi_set_ps(WIFI_PS_NONE); ESP_ERROR_CHECK(esp_now_init()); esp_now_peer_info_t peer { .peer_addr {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, .channel channel, .ifidx ESP_IF_WIFI_AP, .encrypt false, }; esp_now_add_peer(peer); }这里的对端地址用了广播地址FF:FF:FF:FF:FF:FF意思是“不指定接收者同信道所有ESPNOW设备都能收到”。用作原型验证非常方便两端不需要互相知道MAC。但广播也会让调试日志变得嘈杂如果附近有别的ESPNOW设备你的接收端会收到别人的帧所以我在数据帧里加了头部识别字段来做第一层过滤。阶段性调试完成之后建议你把广播地址换成接收端的真实MAC能少掉很多干扰。3.2 自定义空鼠数据帧ESPNOW单包最多250字节而空鼠一帧真正有用的数据不到10字节。我一开始贪心把姿态四元数、时间戳、电池电压全塞进去结果一帧40多字节反而带来序列号和时间戳的解析负担。后来砍成下面这个最小结构清爽很多typedef struct { uint8_t head; // 固定0xA5过滤无关包 uint8_t seq; // 包序号接收端可统计丢包率 int16_t dx; // X方向位移增量像素 int16_t dy; // Y方向位移增量像素 uint8_t btn; // bit0左键 bit1右键 bit2中键 uint8_t wheel; // 滚轮增量 uint8_t crc8; // 简单校验 } airmouse_frame_t;这个结构一共10字节塞在250字节的ESPNOW包里显得很空但“小即快”在实时通信里是铁律。包越小空中传输时间越短被802.11帧间隙卡住的概率越低。crc8我用了最朴素的查表法目的不是防黑客而是防同信道杂波的误帧。实际跑起来head和crc8两层过滤能把误码率压到一个可以忽略的水平。有一个很容易犯的错误在结构体里用int类型表示位移。int16_t就够了因为每帧位移增量上限就是±127再多要用多帧来表达否则光标会以肉眼可见的“飞”而非“滑”。HID报告里的位移字段本来就是signed 8-bit也就是±127int16_t纯粹是为了给自己留一点余量发送和接收两端要一致。3.3 发送节奏与防抖动策略发送频率我是从100Hz起步测试的后来稳定在150Hz没有用到200Hz。原因很简单USB HID的轮询周期通常就是1ms或几个ms发送端再快接收端也只能按HID周期上报而发送端太快的代价是功耗上升、丢包率升高。先把IMU的输出速率和ESPNOW发送速率对齐比如IMU内部采样500Hz做一阶低通滤波后以150Hz的节奏输出一帧就是一个比较平衡的配置。发送端主循环用FreeRTOS的vTaskDelay控制节奏不要在回调里做重活。ESPNOW的发送回调是在WiFi协议栈的上下文中执行的你要是在里面做I2C读取或者打印日志随时可能触发看门狗。正确姿势是主循环里组好帧、调用esp_now_send然后立刻进下一次采样发送结果用一个全局标志位接收调试时看看就好不要在关键路径上处理。帧序列号seq是个便宜但非常有效的工具。接收端收到帧时记录seq隔一段时间统计总帧数和重复帧数就能直接算出丢包率。我在接收端接了一个串口把丢包率实时打出来调到丢包率稳定低于0.5%之后再去调手感不然永远是稀里糊涂的手感。4. 从陀螺仪数据到鼠标位移的核心换算通信链路搭好以后真正的“手感玄学”才开始。很多人一上来就搬四元数、卡尔曼滤波其实在空鼠场景属于杀鸡用牛刀。空鼠不需要知道绝对的姿态它只需要知道手“正在往哪边转、转多快”然后换算成光标位移。这一章讲清楚为什么、怎么算、调哪些参数。4.1 为什么空鼠用的是“角速度增量”而不是角度商用空鼠和演示器几乎都是相对定位设备它不关心你手指向哪个方向只关心你从上一帧到这一帧转了多少。陀螺仪直接给出角速度单位是度每秒积分一次可以得到角度但角度的绝对值对鼠标来说没用还会引入积分的累积漂移。你想想鼠标如果真用了绝对坐标那光标会跟着手在空中停住的位置乱跑手一停光标就会因为传感器噪声漂走这种体验是完全不可接受的。所以核心映射关系就是角速度乘以一个系数得到这一帧的鼠标位移增量。向右转手腕陀螺仪的yaw轴输出正值映射成dx正值光标就向右走。方向不对没关系在代码里加一个负号翻转就行这是每个空鼠项目第一天都会碰到的调试点——你要么装反了传感器要么忘了映射系数带负号先别怀疑硬件检查符号很快。同样的物理量也可以映射成滚轮手腕的纵向翻转pitch角速度映射成滚轮滚动。做PPT翻页时这个映射比移动光标更方便。具体映射用哪些轴取决于你把IMU贴在手腕的哪个位置、哪个方向我自己的习惯是yaw轴做左右、pitch轴做上下翻页手势单独用一个按键配合pitch方向来触发。4.2 零偏校准与温度漂移陀螺仪最大的敌人是零偏也就是静止时读数不为0。我拿了几颗同一型号的IMU上电测静止时gyro读数有的偏2度每秒有的偏-1.5度每秒芯片和芯片之间差异肉眼可见不校根本没法用。校准的方法非常土但非常有效上电后让设备静止一两秒连续采100个样本取平均把平均值记录下来作为零偏常数后续每个读数都减掉它。这个零偏不只是出厂差异还会随温度漂移。手握住传感器几分钟体温会把读数慢慢顶起来。DIY项目没必要上温度补偿模型我的经验是做两级处理第一级是上电快速校准保证开机就能用第二级是在运行的第一个30秒内偷偷做“动态零偏修正”——当检测到读数持续低于某个阈值比如连续0.5秒都在±0.3度每秒以内时就认为手是静止的把这段时间的均值不断累加到零偏上。这个策略非常有效实际体验就是开机甩两下、停半秒光标就稳了。校准代码骨架float gyro_bias_x 0, gyro_bias_y 0; void calibrate_gyro(int samples) { long sum_x 0, sum_y 0; for (int i 0; i samples; i) { imu_read_gyro(gx, gy, gz); sum_x gx; sum_y gy; vTaskDelay(pdMS_TO_TICKS(10)); } gyro_bias_x (float)sum_x / samples; gyro_bias_y (float)sum_y / samples; }校准期间务必在固件里给一个状态指示LED闪起来告诉用户“别动”。我第一次做就翻过车上电后手还没放稳光标在桌面上疯狂画圈后来加了校准指示灯和“校准中禁止上报鼠标数据”的逻辑才消停。4.3 灵敏度、死区与加速曲线把校准后的角速度换算成位移核心公式就一行int16_t dx clamp((int16_t)(gx * GAIN), -127, 127);但参数GAIN怎么定决定了这个空鼠到底能用还是只能“演示”。我的调参经验是先定刷新率再定GAIN。以150Hz上报率为例如果手腕转动角速度30度每秒你希望光标移动大概多少像素每秒桌面办公场景大概在800到1500像素每秒比较合适也就是GAIN取值在0.2到0.3之间。如果你喜欢游戏里的高DPI手感那就翻倍。先固定一个值跑起来用起来再微调比在纸面上推算一百遍都有效率。死区是为了滤掉手抖。静止时候即使做了零偏校准传感器的白噪声也会有几度的瞬时毛刺如果没有死区光标会被这些毛刺推着走看起来就是“手停了光标还在微颤”。我试过0.5度每秒、1度每秒、2度每秒三档。0.5太小没意义2会明显觉得光标“黏手”不容易启动最终取1.2左右。死区处理不是简单一刀切我用的是“小于死区则清零大于死区则从原值中减去死区”的软化处理这样启动瞬间不会突兀。加速曲线是让手感“高级”的关键。如果全程用同一个GAIN小动作太慢、大幅甩动又容易失控。商用空鼠之所以好用几乎都做了加速——角速度小时低增益方便精准点选角速度大时高增益便于跨越整个屏幕。我用的两个参数版本float speed fabsf(gyro_dps); float factor 1.0f ACCEL_FACTOR * (speed / ACCEL_THRESHOLD); if (factor ACCEL_MAX) factor ACCEL_MAX; int16_t delta (int16_t)(gyro_dps * GAIN * factor);ACCEL_THRESHOLD我取60度每秒意思是手速超过这个值时增益开始放大ACCEL_FACTOR取1.5最后用ACCEL_MAX4封顶防止极端甩动时位移被人为截断。这套参数调完光标能从“死板地等比例移动”变成“指哪打哪”。调完还有一个容易被忽略的点Windows和macOS的鼠标设置里都有“提高指针精确度”/“加速度”选项这属于操作系统层面的二次加速。DIY空鼠建议把系统鼠标速度调成原生、关闭系统加速不然你的曲线和系统曲线会叠加手感就像油门和刹车同时踩。5. 接收端USB-HID实现接收端是整个项目里“看起来最简单、翻车率却不低”的部分。它要做的就三件事收ESPNOW包、更新HID报告、交给USB协议栈。但USB枚举的成功与否、HID轮询间隔的配置很多时候比发送端的姿态解算还让人头大。这一章把这三件事拆开讲清楚。5.1 标准鼠标描述符与轮询间隔USB HID设备靠描述符告诉PC“我是什么设备、我的报告长什么样”。标准鼠标描述符描述一个指针设备包含三个按钮、X轴位移、Y轴位移和滚轮X/Y用相对值。下面的描述符是经典写法也是TinyUSB和ESP-IDF里都能用的标准HID鼠标描述符static const uint8_t desc_mouse[] { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x02, // Usage (Mouse) 0xA1, 0x01, // Collection (Application) 0x09, 0x01, // Usage (Pointer) 0xA1, 0x00, // Collection (Physical) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (1) 0x29, 0x03, // Usage Maximum (3) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x95, 0x03, // Report Count (3) 0x75, 0x01, // Report Size (1) 0x81, 0x02, // Input (Data, Variable) 0x95, 0x01, // Report Count (1) 0x75, 0x05, // Report Size (5) 0x81, 0x03, // Input (Constant) 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x09, 0x38, // Usage (Wheel) 0x15, 0x81, // Logical Minimum (-127) 0x25, 0x7F, // Logical Maximum (127) 0x75, 0x08, // Report Size (8) 0x95, 0x03, // Report Count (3) 0x81, 0x06, // Input (Data, Variable, Relative) 0xC0, // End Collection 0xC0 // End Collection };大多数例程里被忽略的参数是HID接口的轮询间隔bInterval。默认配置可能是10ms甚至更长那空鼠手感会变成“慢动作回放”。我建议把HID接口描述符里的bInterval设为1也就是1ms轮询一次让接收端随时能把最新位移交给PC。这个参数改完以后光标的流畅度提升是这个项目里能直观感觉到最大的一次优化比GAIN调半天都明显。5.2 从ESPNOW回调到HID上报的衔接接收端有两个上下文ESPNOW收包在WiFi协议栈线程里执行USB上报在TinyUSB的轮询线程里执行。最忌讳的是在ESPNOW回调里直接调用USB上报函数——回调上下文非常受限一旦USB忙就可能导致协议栈卡顿。我的做法是设计一个极简的“最新帧寄存器”回调只负责把最新收到的帧拷贝到一个共享变量并置一个标志位主循环发现标志位之后再做HID上报。因为空鼠数据是增量的、无状态的所以“覆盖丢帧”完全不是问题——每一次上报都用最新的一帧中间被覆盖掉的旧帧就当它没发生。这个方案比用队列优雅得多。队列适合所有数据都必须处理的场景但鼠标不是那种场景你在主循环里处理上一帧时新来的这一帧直接覆盖上去效果反而等于“追帧”体感更跟手。static volatile airmouse_frame_t latest_frame; static volatile bool frame_pending false; void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data, int len) { if (len ! sizeof(airmouse_frame_t)) return; airmouse_frame_t *f (airmouse_frame_t *)data; if (f-head ! 0xA5) return; memcpy((void *)latest_frame, data, sizeof(latest_frame)); frame_pending true; } void usb_hid_task(void) { while (1) { if (frame_pending) { frame_pending false; hid_mouse_report_t report {0}; report.buttons latest_frame.btn; report.x (int8_t)latest_frame.dx; report.y (int8_t)latest_frame.dy; report.wheel latest_frame.wheel; tud_hid_report(HID_ITF_PROTOCOL_MOUSE, report, sizeof(report)); } // 让出CPU给其他任务 } }两个注意点latest_frame声明成volatile是因为它在两个线程上下文之间共享这是最朴素的原子化手段但如果数据宽度超过一个机器字理论上还是建议用临界区或者关中断拷贝。示意代码里用memcpy在回调里做了一次拷贝实际项目我用了一个带简单自旋锁的结构多花了几行代码但心里踏实。5.3 USB枚举阶段最容易踩的坑接收端插上电脑后PC要枚举它、加载HID驱动然后才开始轮询。我踩过的第一个坑是“枚举期间就开始上报鼠标数据”。开机瞬间传感器还在做零偏校准如果此时USB已经枚举完成、而校准数据还没准备好那发送端会发出“手正在狂甩”的假数据接收端照单全收Windows桌面上的光标就会在开机瞬间自己飞一圈。解决方式是发送端加一个“校准完成再上报”的使能位接收端只在收到使能帧之后才开始转发HID数据。另一个坑是USB枚举失败。检查点按顺序来USB线是不是数据线有些充电线只有正负极、开发板的USB-OTG口是不是原生的、TinyUSB有没有正确初始化。你做这个项目时千万要记住ESP32-S3有两个USB口还是只有一个取决于开发板设计插错口等于没插。还有一次我全程没跑通最后发现是板的USB D和D-两个引脚被杜邦线接反了——这种低级错误排查起来真能让人怀疑人生。6. 完整代码骨架前面各章都给了代码片段这里把它们拼成一个可以从零跑起来的最小工程。我没有贴完整的工程文件因为不同开发环境的差异很大贴骨架更通用。发送端和接收端的工程可以用同一份WiFi初始化代码只是主循环逻辑不同。整段代码量不大但我把它当“抄作业模板”来写每个关键调用都配了注释解释为什么这么写。6.1 发送端主逻辑发送端上电后的流程是初始化WiFi和ESPNOW初始化IMU进入校准流程LED闪烁提示静止校准完成后进入主循环。主循环每6.7ms跑一次对应150Hz做滤波、死区、增益映射组帧发送。// 发送端主循环 void sender_main(void) { calibrate_gyro(100); // 静止校准1秒期间不上报 uint8_t seq 0; while (1) { imu_read_gyro(gx, gy, gz); apply_highpass_bias(gx, gy, gz); // 动态零偏修正 lowpass_filter(gx, gy, 0.3f); // 一阶低通 int16_t dx gyro_to_delta(gx, 0.25f); // GAIN0.25 int16_t dy gyro_to_delta(gy, 0.25f); apply_deadzone_and_accel(dx, dy); btn_state read_buttons(); wheel_delta compute_wheel(gy); airmouse_frame_t frame { .head 0xA5, .seq seq, .dx dx, .dy dy, .btn btn_state, .wheel wheel_delta, }; frame.crc8 calc_crc8((uint8_t *)frame, sizeof(frame) - 1); esp_now_send(broadcast_mac, (uint8_t *)frame, sizeof(frame)); vTaskDelay(pdMS_TO_TICKS(6)); // 实际150Hz } }这段代码里最值得琢磨的是gyro_to_delta函数内部做了死区和加速曲线两条处理我建议你把公式单独抽成一个函数调参时只改函数不碰主循环。按键读取那行不是简单GPIO读要加10ms消抖防止机械抖动的毛刺变成鼠标点击的连发。滚轮计算则用纵向角速度累积成整数累积超过某个阈值才输出一个滚轮刻度这样滚动手感更像真实滚轮。6.2 接收端主逻辑接收端上电后初始化WiFi、ESPNOW、TinyUSB的HID设备等待发送端的数据。收到使能帧之前不上报收到之后就按前面usb_hid_task的逻辑运行。void receiver_main(void) { wifi_and_espnow_init(1); tinyusb_hid_init(desc_mouse, sizeof(desc_mouse)); bool enable false; while (1) { if (frame_pending) { frame_pending false; if (!enable) { if (latest_frame.btn 0x80) enable true; // 自定义使能位 continue; } hid_mouse_report_t report {0}; report.buttons latest_frame.btn 0x07; report.x (int8_t)latest_frame.dx; report.y (int8_t)latest_frame.dy; report.wheel latest_frame.wheel; tud_hid_report(HID_ITF_PROTOCOL_MOUSE, report, sizeof(report)); } } }这里的使能机制是我后来加的发送端校准完成后把btn字段的最高位置1发一帧接收端收到这个“校准完成”标志才开始转发数据。为什么不用一个独立字段因为最小改动最小风险。你要是觉得最高位被占用了别扭完全可以加一个enable字段代价是数据帧多一个字节通信效率几乎没影响。6.3 关键参数速查表把零散的调参数据汇总成一张表方便你复制到项目里对照。这是我调定的一版“桌面办公向”参数游戏向用户可以把GAIN翻倍、关掉部分加速。参数推荐值说明上报频率 REPORT_RATE150Hz与HID轮询对齐过高无益ESP-NOW 信道1避开办公环境常用的6/11信道GAIN0.2~0.35角速度(°/s)到像素增量的系数死区1.0~1.5 °/s小于此值的角速度清零ACCEL_THRESHOLD60 °/s超过该值开始加速ACCEL_FACTOR / MAX1.5 / 4.0加速幅度与封顶低通滤波 alpha0.3越大越跟手越小越稳HID bInterval1ms接收端USB轮询间隔WiFi modem sleep关闭必须关否则时延抖动这张表不是让你无脑抄的它给的是一个“起始值”。每个人手稳不稳、屏幕分辨率是多少、WiFi环境干不干扰都会让最优值偏移。我自己的习惯是先按表里的值跑起来确认链路稳定再花半天做手感微调。7. 常见问题与排查实录这个项目我前后折腾了好几轮把踩过的问题记成了一个小本子。这里挑最有代表性的几个写出来包括现象、排查思路和最终的解决办法。很多时候问题不在你怀疑的那一层养成按“物理层-协议层-应用层”的顺序排查的习惯能省很多时间。7.1 ESPNOW配对不上、收不到包最常见的现象是接收端串口日志里一个包都看不到。先别怀疑代码按顺序查这三样第一两端信道是否一致。AP_STA模式下用esp_wifi_set_channel强行锁信道确认两端都用同一个值。第二WiFi模式是否都是AP_STA。如果一端是STA且没连路由器信道可能被重置成默认值两端就不在一个频道上说话。第三ESP-NOW初始化后有没有把加密关掉。.encrypt false这行最容易复制代码时漏掉一旦某一端默认开了加密另一端没有对应PMK收到的包会被直接丢弃。还有一类隐蔽问题出在广播地址。广播地址发送的包接收端只有在添加了“广播对等方”之后才能收到。我在初始化代码里用esp_now_add_peer绑定了FF:FF:FF:FF:FF:FF这个广播MAC这是能收到广播包的前提。如果你用了单播就必须绑定对端的真实MAC。调试阶段宁可用广播把链路先跑通再切单播否则很容易陷入“明明没写错但就是收不到”的尴尬。7.2 光标乱飘、顿挫和漂移光标乱飘的元凶几乎都是零偏和死区没配合好。一开始我死区设置只有0.3度每秒结果传感器白噪声轻松超过这个值光标就像筛糠一样抖。把死区放大到1.2以后立刻安静了。漂移则是零偏校准不彻底——上电时手没放稳、或者运行中温度漂移累积。解决办法就是前面说的动态零偏修正检测到长时间静止时把静止期间的均值不断叠加进零偏。用上这个机制之后我再也没被“光标自己往右走”折磨过。顿挫则要分两类一类是丢包导致的“跳帧”现象是光标一卡一卡但卡的是位置不是速度。这种先去查丢包率看信道干扰必要时换信道。另一类是滤波过度导致的“粘滞”现象是光标反应慢半拍、运动轨迹平滑得像糖浆。这种把低通滤波的alpha从0.1往0.4调或者减少滤波级数。我遇到过最极端的情况是把alpha调到0.05然后觉得整个设备“油得要命”实际就是滤波把高频的手部意图全抹掉了。7.3 时延体感的测量与量化方法手感这东西光靠“感觉”不行得能量化。我做了一个非常土的测量方案发送端在发送某个特殊标记帧的同时翻转一个GPIO接收端收到该帧时翻转另一个GPIO用逻辑分析仪同时抓两个引脚测的就是“发送端发出到接收端收到”的空中时延。这个时延实测在几百微秒到一两毫秒之间。想测完整链路就用带帧序号的数据包反过来推算发送端每发1000帧在USB上报的数据里内置一个特殊位移值接收端记录收到时刻减去发送时刻就是覆盖“发送端软件发送→空中传输→接收端解析→HID上报”的全链路延迟。这套方法最有价值的地方不是得到某个具体数字而是让你能对比不同配置的影响。我把modem sleep关掉之后延迟稳定了、把HID bInterval改成1之后流畅度提升了——这些结论全是靠这套测量方法定量确认的不是靠“好像快了一点”的错觉。你也可以在Windows里用鼠标高速连点工具测每秒事件次数或者直接用高速摄像头拍屏幕上一根随机移动的竖线都能得到大概的端到端延迟区间。7.4 独家避坑清单最后把没地方归类但都很重要的碎碎念整理成清单接收端插电脑前先把发送端固件烧好并完成校准再插USB。上电顺序反了容易出现“系统识别了鼠标但光标不动”这种怪问题。USB连接线务必用数据线充电线会让设备反复枚举失败。发送端电源用锂电池时在系统入口加一个大电容ESPNOW发送瞬间电流冲击会导致IMU读数毛刺。I2C的时钟频率不要拉到最高IMU的I2C设400kHz就行高速模式下长线易出错。按键消抖时间至少10ms消抖不过关的表现为“单击变双击”。调参阶段把丢包率、零偏值、滤波后角速度这些内部变量全部通过调试串口打出来做成一个实时调参面板比闷头改代码有效率十倍。给接收端加一个LED收到包闪烁一下肉眼判断链路状态比任何日志都直观。如果发现接上USB后电脑鼠标“卡死”几秒才恢复大概率是枚举期间HID上报异常回到使能机制处理。8. 实操心得与下一步扩展项目做到后面我最大的感触是通信链路反而是整个系统里最不费心的部分真正费心的是“手感”。手感这个东西没有任何一个公式能一劳永逸地解决它和你握持的姿势、传感器安装的方向、屏幕的分辨率、甚至桌面的鼠标速度设置都耦合在一起。所以我的调参流程固定成一条线先把通信底子做干净、量化指标调明白再动照相机参数。具体到本项目的经验最值得分享的一个顺序是先让接收端在固定桌面上用串口模拟“假鼠标数据”跑起来确认USB HID链路完全正常再接发送端。这样你就能把“通信问题”和“HID问题”彻底隔离。我见过太多人把USB枚举失败、ESPNOW丢包、陀螺仪漂移三件事混在一起排查最后哪件事都没查明白。分层隔离一次只调一层是这个项目里最重要的方法论。这个项目后续能扩展的方向也不少。一是加按钮长按切换模式比如短按是鼠标、长按变成翻页笔。二是加加密传输ESPNOW本身支持AES加密配好PMK后安全性足够个人使用。三是加充电管理发送端做成一个带电池管理芯片的小手环。四是利用S3的蓝牙功能做双模需要连手机时走BLE需要低时延时走ESPNOW两者可以共存于同一颗芯片。甚至还可以在接收端加一个OLED屏显示连接状态和剩余电量这些都是很好的树莓派式小项目延伸。最后说一个我调这个项目过程中最奇妙的发现当光标真的完全跟手的那一刻你会忘记它是“空鼠”的你的注意力会回到内容本身而不是设备。这种感觉跟用有线鼠标几乎没有差别但你去拔掉接收端的USB线才发现原来手里那个小东西和电脑之间隔着一整段无线链路。能把这种“无感”做出来就是无线输入设备的全部追求。下一篇如果有机会我想把接收端做成一个通用的“ESP32-S3万能HID网关”让同一套接收端固件既能当鼠标、又能当键盘、还能当游戏手柄那样这个项目就从“一只有趣的鼠标”升级成了“一个通用的输入桥”。在那之前先把这只纯ESPNOW空鼠甩起来吧。