ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用ESP32-C3模拟蓝牙HID触控板,实现Android自动化手势操作

用ESP32-C3模拟蓝牙HID触控板,实现Android自动化手势操作 搞安卓自动化测试的人基本都绕不开几个坑一是USB调试线常年被占手机插上电脑后想顺手充个电都别扭二是很多自动化框架所谓的“模拟触控”底层还是靠ADB注入搞不定那些需要连续滑动、多点手势的复杂场景三是一些商业方案价格离谱为做个实验室自动化工具掏几千块实在不值。所以我一直在寻找一个更轻量、更可控的替代方案最后把目光落在了ESP32-C3蓝牙HID模拟触控与手势操作这条路上——用一块十几块钱的开发板通过蓝牙把手机模拟成可编程的“触控屏”想点哪里点哪里想滑动就滑动整套方案跑通之后实测稳定性和灵活性都远超我的预期。这篇就把完整的实现思路、核心代码和踩坑记录整理出来给同样在做Android自动化、外设模拟或辅助工具开发的朋友做个参考。1. 先说清楚这套方案到底在解决什么问题1.1 传统Android自动化方案的三个痛点做Android自动化的人对ADB肯定是又爱又恨。ADB稳但那是建立在USB连接或者WiFi调试的基础上。USB模式下手机被线绑着摆放位置受限WiFi调试稍微好点但局域网环境不可控固件升级、网络切换都会让调试突然断开。而且ADB的注入方式说到底还是走系统内部的InputManager很多App加了防自动化检测之后这种注入很容易被识别出来。第二个痛点是手势操作没法精细控制。adb shell input swipe确实能模拟滑动但它是按帧一次一次发送的频率和坐标路径都不可控。你想做一个从屏幕边缘缓慢拉出的侧边栏手势或者模拟多指捏合缩放ADB要么做不了要么写出来的代码特别别扭最后效果还很差像是在“瞬移”而不是在“滑动”。第三个痛点是成本。商业的触控模拟硬件比如一些USB触控板模拟器动辄几百上千对个人开发者和中小团队来说并不友好。相比之下ESP32-C3模块不到二十块钱普通的节点板也就在二十到四十元区间折腾坏了不心疼而且因为蓝牙方案不需要接线手机放在测试台架上就能直接操作。1.2 为什么是ESP32-C3和蓝牙HIDESP32-C3是乐鑫推出的一款单核RISC-V Wi-Fi/蓝牙双模芯片价格低、功耗控制好关键是有成熟的双模蓝牙协议栈。虽然它没有ESP32那样的双核性能但跑一个HID外设固件绰绰有余。更重要的是Arduino-ESP32和ESP-IDF都对它支持得很完善底层API可以直接用不需要自己啃蓝牙协议栈。蓝牙HID听起来玄乎本质就是把你这个板子伪装成一个标准的蓝牙输入设备。手机蓝牙配对后系统会把它识别成一个类似触摸板的外设所有坐标、按键、手势都通过HID报告Report发过去。Android系统对标准HID设备的兼容性做得不错鼠标、键盘、触摸板这些都是原生支持的不需要装任何驱动。有人会问那我为什么不用现成的蓝牙键鼠呢因为现成的设备只能按它的逻辑用你没法让它“在按下这那一刻走到这个坐标再触发触摸事件”。而ESP32-C3的好处是整个HID报告内容完全由你控制你可以把坐标值、触摸状态、输入频率都定义成自己想要的等于把输入设备的控制权拿到了自己手里。2. 硬件准备与开发环境搭建2.1 需要准备的材料清单做这个项目不需要复杂的物料清单非常简单ESP32-C3开发板一块。建议买带板载天线的版本那种“超级迷你”的NodeMCU也行USB-C接口供电方便。注意不要买成ESP32-C2或ESP8266前者蓝牙支持不全后者根本没有蓝牙BLE外设模式。带蓝牙Android手机一台。Android 9以上兼容性最好越低版本对HID触摸板的识别越差。USB转TTL或者直接开发板自带USB口都行用于烧录和串口日志。杜邦线若干主要是方便调试备用如果你买的板子没焊排针可能还要准备烙铁。接线其实没什么好说的开发板插USB就完事了。真正的“接线”发生在蓝牙空中接口所以整个项目对硬件动手能力要求很低门槛基本在软件和协议理解上。2.2 开发环境配置要点我推荐用Arduino IDE搭配esp32开发板包原因就两个字省事。ESP-IDF功能更强但配置工程、管理组件、处理编译链接这些步骤对新手不太友好除非你后续要上量产固件否则没必要一开始就上IDF。安装esp32开发板包的时候有几个版本坑需要提前规避。Arduino IDE建议用2.x版本开发板管理器里搜索esp32安装Espressif官方维护的esp32包。我用的时候是3.x版本已经支持ESP32-C3的RISC-V内核。如果你用的是老版本的1.0.6或2.0.x编译C3代码可能会报riscv32-unknown-elf-gcc相关的错升级包版本就能解决。蓝牙HID这边我推荐直接用NimBLE-Arduino库。它比传统的BLE_device库更轻量内存占用低而且封装了HID Device服务省去手动创建服务特征值的麻烦。在库管理器搜索“NimBLE-Arduino”安装作者是h2zero版本我用的1.4.x。提示ESP32-C3只有约400KB RAM选择库时优先考虑NimBLE这种资源占用小的方案。如果你用IDF原生的Bluedroid协议栈也能跑但内存余量会紧一些后面加手势缓冲时会束手束脚。3. 核心代码蓝牙HID触控模拟的实现拆解3.1 HID报告描述符设计先让手机认出你是“触摸板”整个项目的关键不在硬件而在HID报告描述符。这块描述符定义了设备对外“长什么样”是鼠标、键盘还是触摸板。Android系统会根据这个描述符决定如何处理你发过来的数据。标准的触摸板Touch Pad和触摸屏Touch Screen在HID Usage Table里都属于Digitizers数字化仪类别Usage Page是0x0DDigitizers触摸板用的是Usage0x05Touch Pad触摸屏用的是Usage0x04Touch Screen实际联调时我发现Android 10以上的版本对“Touch Screen”类型的蓝牙HID外设识别并不可靠某些ROM会直接忽略这个设备反而不如模拟成“Touch Pad”系统会把它识别为类似蓝牙鼠标的外设但带有绝对坐标信息这在多数Android设备上都能正常弹出鼠标指针。我最终用的报告描述符核心结构大致是这样的// HID Report Map模拟触摸板绝对坐标单点 static const uint8_t hidReportDescriptor[] { 0x05, 0x0D, // Usage Page (Digitizers) 0x09, 0x05, // Usage (Touch Pad) 0xA1, 0x01, // Collection (Application) // 第一个字段Tip Switch 触摸状态 0x09, 0x42, // Usage (Tip Switch) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x01, // Report Count (1) 0x81, 0x02, // Input (Data, Var, Abs) // 保留位填充到字节对齐 0x09, 0x32, // Usage (In Range) 0x75, 0x01, // Report Size (1) 0x95, 0x01, // Report Count (1) 0x81, 0x02, // Input (Data, Var, Abs) 0x75, 0x06, // Report Size (6) 0x95, 0x01, // Report Count (1) 0x81, 0x03, // Input (Const, Var, Abs) // 坐标X16位绝对坐标 0x09, 0x30, // Usage (X) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x7F, // Logical Maximum (32767) 0x75, 0x10, // Report Size (16) 0x95, 0x01, // Report Count (1) 0x81, 0x02, // Input (Data, Var, Abs) // 坐标Y16位绝对坐标 0x09, 0x31, // Usage (Y) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x7F, // Logical Maximum (32767) 0x75, 0x10, // Report Size (16) 0x95, 0x01, // Report Count (1) 0x81, 0x02, // Input (Data, Var, Abs) 0xC0 // End Collection };这段描述符看起来不长但每个字节都不能错。Report Size、Report Count、Logical Maximum三者之间是严格耦合的只要有一个字段差一位Android端接收到的数据就会错位表现就是鼠标乱飞或者干脆没反应。3.2 触控板绝对坐标事件发送有了报告描述符接下来就是怎么发坐标。Android设备屏幕分辨率各不相同比如1080x2400和1440x3200差异很大。为了让同一套固件兼容不同手机HID坐标采用归一化方式坐标范围固定映射到0到32767由手机端自动缩放到实际屏幕像素。这样做的原理很简单HID协议把物理设备抽象成了独立于分辨率的输入空间Android的输入子系统拿到绝对坐标后会根据当前屏幕参数做等比例缩放。所以固件里只需要做一次线性映射换算#define HID_COORD_MAX 32767 // 全局屏幕尺寸可在连接时通过串口或BLE配置下发 int screenWidth 1080; int screenHeight 2400; uint16_t mapX(int screenX) { return (uint16_t)((float)screenX / screenWidth * HID_COORD_MAX); } uint16_t mapY(int screenY) { return (uint16_t)((float)screenY / screenHeight * HID_COORD_MAX); }发送触控事件的函数核心就是把“是否按下”和“坐标X/Y”打包成一帧Input Reportvoid sendTouchPoint(bool tip, int screenX, int screenY) { uint8_t report[5]; report[0] tip ? 0x01 : 0x00; // Tip Switch In Range report[1] 0x00; // 填充位保留 uint16_t hidX mapX(screenX); uint16_t hidY mapY(screenY); report[2] (hidX 8) 0xFF; report[3] hidX 0xFF; report[4] (hidY 8) 0xFF; report[5] hidY 0xFF; hidDevice-inputReport(1, report, sizeof(report)); }注意这里report数组没写完整字节数实际代码里根据你的描述符定义调整。我这里用的是NimBLE-Arduino的inputReport接口第一个参数是Report ID因为我们只有一个Input Report所以传1。有个细节非常影响体验手指“按下”和“抬起”之间一定要维持连续的坐标上报。如果只发一次按下然后停住Android系统的触摸事件可能会超时自动判定为抬起结果就是你明明想长按系统却认为你点了一下。所以后面做手势时会在触摸保持期间周期性发送坐标。3.3 手势操作算法单击、滑动、长按最基础的就是单击按下、延时几十毫秒、抬起。这个流程比较简单。难点在滑动和长按尤其是滑动路径的平滑度。滑动算法本质上就是“在按下状态下分步移动坐标”。最简单的方式是线性插值从起点到终点分成若干步每步延时固定毫秒void performSwipe(int x1, int y1, int x2, int y2, int durationMs) { const int steps 30; int delayPerStep durationMs / steps; sendTouchPoint(true, x1, y1); vTaskDelay(pdMS_TO_TICKS(20)); for (int i 1; i steps; i) { float t (float)i / steps; int x x1 (int)((x2 - x1) * t); int y y1 (int)((y2 - y1) * t); sendTouchPoint(true, x, y); vTaskDelay(pdMS_TO_TICKS(delayPerStep)); } sendTouchPoint(false, x2, y2); vTaskDelay(pdMS_TO_TICKS(10)); }步数为什么取30而不是越多越好因为HID上报频率和Android系统的事件采样频率是有上限的Android触摸系统大约在60Hz到120Hz之间采样。如果你步数太多、每步延时太短数据报到系统时会积压反而造成卡顿。30步到60步之间是个合理区间既能保证平滑又不会超出系统处理上限。长按更容易踩坑。按下以后不仅要保持Tip Switch为true还要不断发送当前坐标哪怕坐标没变也要维持一个“事件在持续”的错觉。实测中每100ms重发一次当前坐标长按1秒以上稳定可靠void performLongPress(int x, int y, int holdMs) { sendTouchPoint(true, x, y); int elapsed 0; while (elapsed holdMs) { vTaskDelay(pdMS_TO_TICKS(100)); sendTouchPoint(true, x, y); // 保持坐标刷新 elapsed 100; } sendTouchPoint(false, x, y); }双指捏合这种高级手势也可以在单点报告的基础上做扩展但HID报告描述符需要改成支持多点触摸的结构也就是在报告里放多个Contact集合。这部分改动比较大如果只是做普通自动化先把单点手势做扎实更实际。4. 联调实录Android端连接与坐标映射4.1 配对与连接流程固件烧录好之后第一件事不是写自动化脚本而是先让手机认到设备。把开发板上电打开手机蓝牙扫描设备列表会看到一个以ESP32_HID之类的名称广播的蓝牙设备。点击配对手机会弹出配对确认框确认后设备类型如果显示“鼠标”或者“触控板”说明HID服务已经正常。这里有个容易让人误解的点很多教程会说Android识别成“鼠标”就失败了。但我实际测试下来识别成鼠标并不意味着坐标不可控。关键是看它是否支持绝对坐标。有些Android ROM版本在识别成鼠标后默认走相对坐标模式表现为鼠标指针靠移动累积这种情况才需要去改报告描述符里关于X/Y坐标的设计或者换个配对协议。如果你的设备列表里连设备图标都没有优先检查BLE广播是不是被Android的省电模式过滤了。ESP32-C3的BLE广播功率可以调高一点同时保证电池供电充足有些劣质USB电源会把蓝牙模块电压拉低导致广播不稳定。4.2 屏幕坐标与HID坐标的映射计算Android屏幕坐标系原点在左上角X轴向右Y轴向下。HID触摸板坐标也是左上角为原点X/Y方向一致所以映射关系是直接的等比缩放不需要做镜像处理。举个例子我的测试机是Redmi Note 12分辨率1080x2400HID坐标范围0到32767。要点击屏幕坐标5401200这个中心点实际发送的HID坐标为HID_X 540 / 1080 * 32767 16383 HID_Y 1200 / 2400 * 32767 16383两个都是16383正好是最大坐标的一半对应屏幕物理中心。反过来说如果屏幕上有什么控件只有你手头设备才有你也可以通过HID坐标反推出屏幕像素坐标从而确定点击目标。不同手机的分辨率不同但好在绝大部分手机的屏幕逻辑分辨率可以通过DisplayMetrics获取你只需要在测试前把目标手机的宽高填进固件配置。更优雅的做法是用一个串口命令动态设置或者通过BLE自定义特征值下发这样同一个固件就不用反复烧录了。4.3 延迟与稳定性实测延迟是这类方案最受关注的数据。我用示波器配合Android的systrace抓过事件时间线从ESP32发出HID报告到Android上层收到MotionEvent链路大致是ESP32通过BLE发送报告约2到5ms。手机蓝牙控制器收到数据经过协议栈解析约5到15ms。InputDispatcher把事件派发给应用约1到5ms。整体链路延迟大概在10到30ms之间视觉上几乎没有可感知的延迟。但这是在连接间隔Connection Interval设置到7.5ms的情况下测的。如果你用默认的30ms连接间隔延迟会跳到40到60ms滑动时能明显感觉钝。调整连接间隔在NimBLE-Arduino里可以这样设置// 建立连接后在onConnect回调里更新 void onConnect(NimBLEConnInfo connInfo) { connInfo.getConnHandle(); // 请求更短的连接间隔单位1.25ms7.5ms即参数6 NimBLEConnectionParams params connInfo.getConnParams(); params.setMinInterval(6); params.setMaxInterval(6); params.setLatency(0); params.setTimeout(400); NimBLEDevice::updateConnParams(connInfo.getConnHandle(), params); }注意直接改连接间隔不一定每次都生效最终还是要看设备协议栈是否接受。真机测试中7.5ms间隔在Redmi和三星手机上都能生效但部分老华为机型会强制回到较高间隔这是设备侧限制固件层面无能为力。另外一个影响稳定性的因素是BLE Notification频率。NimBLE-Arduino的inputReport底层用的是GATT Handle Value Notification单帧数据最多20字节我们一帧只有6字节远没到上限但如果你同时开了很多其他BLE服务比如串口透传GATT操作会排队延迟就会变高。建议在正式使用的固件里只保留HID服务把调试用的串口服务在发布版中关掉。5. 常见问题与避坑经验速查表5.1 连上就断、频繁掉线这个问题我一开始差点放弃。表现是手机能搜到设备配对也成功但连接后几秒钟就自己断开再连又断。后来排查下来是NimBLE-Arduino默认没有把设备设为可绑定Bonding而Android侧一配对成功就要求加密连接两边一协商失败连接就断了。解决办法是在初始化的时候显式开启BLE bondingNimBLEDevice::init(ESP32_HID); NimBLEDevice::setSecurityAuth(true, true, true); // bonding, MITM, secure connection还有一个坑是ESP32-C3在连接后如果长时间没有数据交互部分Android ROM会判断设备空闲主动断开省电。解决办法就是在手势空闲时也周期性发送一个坐标保持事件让系统认为设备一直在活跃。这个和长按的原理一样相当于给蓝牙连接加了“心跳”包。5.2 点击准确但滑动像“抽风”如果你发现单击没问题但一滑动就抖动、轨迹歪歪扭扭问题基本出在坐标增量上。两个原因第一滑动的步数太少每次坐标跳变太大Android系统插值跟不上第二两次报告之间的延时不稳定NimBLE底层有缓存你明明发了10帧它可能三帧合并发出去。我的经验是步数控制在30到60之间延时不要用delay()改用vTaskDelay(pdMS_TO_TICKS(n))让FreeRTOS调度器来保证时间精度。delay()在忙等状态下会占用CPU但ESP32-C3单核跑的时候BLE协议栈线程和你主程序线程是并发的一个忙等线程会压榨掉BLE的回调时间导致上报不实时。另外滑动路线如果是折线路径比如从A经过B再到C不要在中间断开触摸状态。正确做法是保持Tip Switch为true连续更新坐标路径让Android认为这是一个连续的笔画。5.3 手势结束容易被系统当成“误触”这个问题很隐蔽。当你在屏幕上做向上滑动手势手指抬起瞬间如果坐标还在屏幕边缘Android的手势导航会触发“从底部上滑回桌面”或“侧滑返回”。于是明明你只是想滑动一个列表结果却退出了App。解决办法是手势结束时做一个“抬起到安全区”的动作在真正抬起前把坐标移动到屏幕中间区域再发一个相对短时间的保持帧最后再抬起。看起来的变化就是手指从屏幕边缘收回后再离开而不是直接原地离开系统就不会触发边缘手势误判。这个细节在我们自动化测试里特别重要因为测试页面经常是全屏App底部上滑误触的发生率很高加了这个收尾动作之后误触率从原来的百分之十几降到几乎为零。5.4 一套固件适配多款手机的最佳实践不同手机的分辨率、导航栏高度、刘海区域都不同如果每台手机都改分辨率然后重新烧录固件维护成本会高到劝退。我这里建议用BLE自定义特征值在运行时动态下发屏幕宽高。具体做法是在HID服务之外再建一个0xFF00自定义服务里面放一个写特征值。手机端通过一个简单的App或者脚本连接这个服务把1920,1080这样的字符串写进去固件端解析后更新screenWidth和screenHeight变量。这样一台测试机第一次连的时候下发一次参数就行后面固件都不用动。这个方法唯一的代价是多了一个服务特征值BLE广播包里会多占用几个字节但对连接本身的稳定性没有影响。实测下来这种“固件只管发送、参数由主机下发”的架构才是做多设备自动化测试最省心的形态。6. 后续还能往哪个方向扩展这套方案跑通后我把它接到了我的自动化脚本框架里。ESP32-C3通过串口接受PC端的指令PC端自定义测试流程ESP32负责把指令翻译成触摸事件手机完全不需要USB线也不用装什么Agent整个测试链路清爽了很多。而且因为ESP32-C3本身支持Wi-Fi后续还可以把串口换成Wi-Fi UDP指令通道彻底无线化。如果你对多点触控感兴趣值得说一下方向。HID协议里多点触摸需要把报告描述符改为包含多个物理触点集合每个触点要有独立的Contact ID和坐标字段报告长度会相应增加。Android系统对多点HID触摸板设备的支持也是比较完善的你要做两指缩放、旋转这些操作完全可以在物理协议层面实现只是手势计算逻辑会复杂不少。还有一个扩展方向是低功耗运行。ESP32-C3在BLE外设模式下功耗可以压到很低的水平配合一块500mAh锂电池做一个小巧的遥控器或者辅助输入设备续航几天不是问题。我目前已经在测试这个方向目标是做一个带旋钮的Android触控辅助盒子给无障碍场景使用。最后分享一个实际操作中的体会这类蓝牙HID方案难点从头到尾都不在代码量而在HID协议规范和Android系统兼容性的理解上。遇到问题不要急着改代码先把报告描述符打印出来对照HID Usage Table逐字节核对一遍能解决九成以上的“为什么发出去没反应”。把协议吃透这套思路往键盘模拟、游戏手柄映射、媒体控制这些方向迁移基本上就是改改报告描述符的事。
返回列表