
蓝牙耳机不肯给按键接口,我从抓包里挖出了它的触控帧
手机在口袋里,手上正忙着做饭或者搬东西,这时候想让它记一句话,最省事的动作是抬手按一下耳机。可惜大多数耳机把这个按键留在自己的固件里,对外只报成媒体键,或者干脆只喂给自己那家的 App。官方没开放接口,第三方应用就收不到。
我手上这副豆包 Ola Friend 也是这样。后来我在手机的系统抓包里找到了它和自己的 App 之间的那条私有蓝牙通道,一次触控就是 12 个字节,手势和左右耳都写在里面。下面是完整的挖掘过程,包括我猜错的地方。

封面:从耳机到被放大镜照着的一串字节
第一步,先找到那条链路
这副耳机走的是经典蓝牙的 RFCOMM,不是低功耗那套 BLE,配对完成就能建 Socket。所以问题不是”能不能连”,而是”连到哪个服务上”。
手机侧拿日志的办法很土但好用:打开开发者选项里的蓝牙 HCI 日志,把耳机的动作复现一遍,然后导出一份系统诊断包。包里有个 btsnoop 格式的文件,后缀常常是 .cfa,手机和耳机之间所有 HCI 包都在里面。
直接读是读不懂的,得先拆两层:HCI 上面的 L2CAP,L2CAP 上面的 RFCOMM。我写了个两百来行的 Python 脚本,按 btsnoop 的记录头逐条解包,把两个方向的数据摊成一行一条。跑完之后先看 SDP 应答,那里会列出耳机声明的所有服务。
结果在这段字节里:
1 | 09 00 04 35 0c 35 03 19 01 00 35 05 19 00 03 08 1c |
服务名那一栏是 ASCII 的 Dora,服务类 UUID 是 00002499-0000-1000-8000-00805f9b34fb。前面那串协议描述符说得更直白:19 01 00 是 L2CAP 的 PSM 0x0003,也就是 RFCOMM;35 05 19 00 03 08 1c 再往下是 RFCOMM 的通道号 0x1c,十进制 28。
怎么验证找对了?不用猜。Android 上用这个 UUID 建 Socket:
1 | val socket = device.createRfcommSocketToServiceRecord( |
连上以后再看 RFCOMM 头,地址字段右移两位得到 DLCI 56,正好是 28 的两倍。通道号对上了,这条链路就是它。
第二步,切帧:先信长度,别猜校验
数据流到了应用层还是一串字节,没有消息边界,得自己切。这套协议的结构很简单:
1 | 0xA5 | 长度(2 字节,大端) | 数据 | 校验(2 字节) |
看到 0xA5 就是一个新帧的开头,后面两个字节告诉你数据有多长,加起来就是整帧长度。触控帧的数据固定 7 个字节,所以整帧 12 个字节。
我在这份日志里一共切出 422 帧,数据区最长 112 个字节,全部落在 7 分半钟的窗口里。这个量很小,一条链路安静的时候一秒都不到一个包。
尾巴那 2 个字节是什么,我一开始不知道。当时的处理是:当它透明,只按长度切,不校验。理由是反过来做更亏。先猜一个多项式,猜不中就会把合法帧当垃圾丢掉,而且丢得没有声音。
后来我把 422 帧全捞出来,试了几种常见的 CRC 变体:多项式 0x8005 反射、初值 0、结果不异或,也就是 CRC-16/ARC。422 帧全中,一个不差。这下才敢说尾巴是校验,不是凑数的。
顺序很重要。先按长度切,再回头验证校验;反过来先猜算法,会把调试时间全耗在”到底是算法错了还是我切错了”上。

图 1:从连续字节流里剪出完整帧,再放大看内容
第三步,7 个字节里有什么
触控帧的数据区是这样:
1 | 00 02 04 11 02 [手势] [耳侧] |
前 5 个字节是固定前缀,后面两个才是内容。手势码:0x02 单击、0x01 双击、0x04 长按。耳侧码:0x01 左耳、0x02 右耳。
137 秒里我按了 37 次,切出来的分布是单击 13、双击 9、长按 15,右耳 25、左耳 12。这些数字不是拿日志自己推的,是照着手指的动作一笔一笔对出来的。
这里有一句丑话必须说:光看日志,你分不清哪一帧是左耳还是右耳。日志里只有 01 和 02 两个值,谁是左谁是右,只能靠人一边按一边看记录。我第一次就是按顺序猜的,把单击和双击整个搞反了,后来重做一遍才纠正。

图 2:同一副耳机发来的两路信号,最后落到两个不同的格子里
第四步,把解析写成代码
真正难的不是认字段,是处理字节流的毛边。
一次 read() 可能只给你半个帧,也可能一口气给你三帧。所以要有一个缓冲区:每次读到数据就追加进去,然后从缓冲区里反复找 0xA5,找到就试着按长度切;长度字段不合法(小于等于 0,或者大得离谱)说明这是假魔数,往前挪一个字节继续找;帧还没收齐就停手,把尾巴留着等下一包。
这几条写在纸上很清楚,写进代码容易漏两处:
一是缓冲区满了要丢最旧的数据,不能直接清零,否则一个正在拼的长帧永远拼不完。二是每轮消费完要把剩下的字节搬到前面,不搬就会越滚越大。
还有一条经验:解析器里一定要把整帧的原始十六进制存下来。固件一升级,码表可能就变了,有原始字节在手,五分钟就能重新对上;没有的话,只能从头再抓一次包。
踩过的四个坑
反射通道不一定开。SDP 查询失败时可以退一步,用隐藏接口直接连 28 通道,但这个方法在部分系统上被限制。所以正确写法是先走 SDP,失败再反射,两步都包 try/catch,失败日志里写清是哪一步挂的。
connect() 没有超时。它阻塞起来可以很久,得另开一个线程去连,主线程等 8 秒,等不到就 close()。close() 是唯一能把卡住的连接和卡住的 read() 一起叫醒的办法,Thread.interrupt() 对它们没有用。
单击和双击的区分,我没有靠猜。日志里相邻两条触控帧的间隔分布很碎:最近的两条只差 140 毫秒,最远的隔了 1.3 秒,中间 618 到 988 毫秒这一档出现了 9 次。这说明想用一个固定延时窗口去”等一等、看后面还有没有第二帧”,挑不出安全值——短了会把双击拆成两次误触发,长了单击就得等一秒才有反应。
所以我没在这个方向上继续调参数。现在代码里那个 250 毫秒的窗口照旧跑着,但它没有数据支撑。更要命的是我把双击绑成了”静音”:一次双击会先来一帧单击,250 毫秒后就被确认成单击,可能先把播报打断了,紧接着才是双击该干的事。这个坑我还没解,下一版要么把静音挪到别的手势上,要么做一轮真的时序标定再定窗口。
耳机端只吃一个连接。它自己的 App 在后台跑着的时候,你的 Socket 会连不上。现象是 SDP 查询直接失败,不是权限问题。先把它停掉。
现在它在我这儿干什么
我把这套解析接进了自己写的安卓语音客户端,用的就是上面那几个类。播报中点一下耳机,立刻打断,不用等它念完;收音中点一下,马上提交,不用等两秒静音;长按挂断。
事件分发走主线程单通道,因为解析在守护线程里做,手势回调如果在别的线程触发 UI,出问题很难查。用主线程的消息队列天然串行,省掉一把锁。
我还要再补一句边界:这是逆向出来的私有协议,没有官方承诺,固件一升级就可能变。它只对这一个型号、这一版固件成立。你要接自己的耳机,标定必须自己做一遍,别抄我的码表。
给想动手的人
最少需要三样:一台能打开蓝牙 HCI 日志的安卓手机、一副目标耳机、电脑上的 Python 3。
流程就是四步:开日志,反复单击双击长按,导出诊断包,用脚本把 btsnoop 拆到 RFCOMM 层。看到 0xA5 开头、长度对得上的短帧,基本就是触控了。把每次按键和日志时间戳对上,码表就出来了。
一句提醒:动手前先把耳机自己的 App 停掉,否则你连不上那条通道。
私有协议能读出来,靠的不是运气,是三件笨事:把字节摊开看、把每种可能都试一遍、把每次结论都拿手指动作对一次。前两件交给脚本,最后一件只能自己来。