22. HID over GATT:蓝牙键盘/鼠标实现、报告描述符、输入输出通道
好,咱们今天聊一个硬核话题——HID over GATT。说白了,就是让蓝牙键盘、鼠标、游戏手柄这些设备,通过BLE协议跟手机或电脑通信。
我最早接触这个,是在做一个蓝牙机械键盘的项目。当时以为就是发按键码,结果被报告描述符折腾了整整一周。嗯,这里面的坑,我替你们踩过了。
22.1 HID over GATT 是什么?
HID over GATT,简称HoG。它是蓝牙SIG定义的一个标准Profile。
传统蓝牙有HID Profile,但那是基于RFCOMM的。BLE这边,我们走的是GATT协议栈。你想想看,BLE功耗低、配对快,用在键盘鼠标上再合适不过了。
HoG的核心思路:把HID的报告(Report)通过GATT的特征值(Characteristic)来传输。键盘按下一个键,就是一个输入报告;手机想点亮键盘的背光,就是一个输出报告。
核心概念:HID设备 = 报告生产者 + 报告消费者。键盘产生输入报告,主机消费它。主机产生输出报告(比如LED状态),键盘消费它。
22.2 报告描述符——HID的灵魂
报告描述符(Report Descriptor)是HID设备最核心的东西。它不传输数据,它描述数据长什么样。
我打个比方:报告描述符就像是一张数据结构的蓝图。主机拿到这张蓝图,才知道你发过来的8个字节里,哪个bit是左Ctrl键,哪个bit是A键。
报告描述符是用一种叫“HID协议”的字节码写的。常见的Item类型有:
- Input/Output/Feature:声明这是一个输入、输出还是特性报告
- Usage Page / Usage:指定用途,比如键盘、鼠标、消费者控制
- Logical Minimum / Maximum:数据的取值范围
- Report Size / Report Count:每个字段的位数和个数
来看一个最简单的键盘报告描述符:
// 键盘输入报告:8字节
// 字节0:修饰键(Ctrl, Shift, Alt, Win)
// 字节1:保留
// 字节2-7:同时按下的6个按键
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x05, 0x07, // Usage Page (Keyboard/Keypad)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x75, 0x08, // Report Size (8)
0x95, 0x06, // Report Count (6)
0x81, 0x00, // Input (Data,Array,Abs)
0xC0 // End Collection
这段描述符定义了一个标准的键盘输入报告。前8个bit是修饰键,后面6个字节是按键码。我刚开始看这个的时候,觉得这简直像在写汇编。但写多了你会发现,它其实很有规律。
我的经验:写报告描述符时,建议先用HID Descriptor Tool(HID DT)画出来,再转成字节码。直接手写容易漏掉Usage Page的切换,导致主机解析错误。
22.3 输入输出通道——数据怎么走?
HoG定义了三个核心特征值:
| 特征值 | UUID | 方向 | 用途 |
|---|---|---|---|
| 报告映射 | 0x2A4B | 设备→主机 | 存放报告描述符 |
| 输入报告 | 0x2A4D | 设备→主机 | 按键、鼠标移动等数据 |
| 输出报告 | 0x2A4E | 主机→设备 | LED状态、反馈等 |
输入报告通常配置为通知(Notify)模式。为什么?因为键盘按键是事件驱动的。你按一下,我发一次。如果用轮询,功耗就上去了。
输出报告则用写入(Write)模式。主机想点亮键盘的CapsLock灯,就写一个字节过来。
我曾经在一个项目中,把输入报告配成了Read模式。结果每次按键,主机都要主动读一次,延迟直接飙到100ms以上。嗯,这个坑你们别踩。
22.4 实战:Android端实现蓝牙键盘
在Android上实现HID设备,需要用到BluetoothHidDevice这个API。它是Android 10(API 29)引入的。
基本步骤:
- 获取
BluetoothHidDevice实例 - 注册HID应用,传入报告描述符
- 连接主机(通常是手机或平板)
- 发送输入报告
来看核心代码:
// 1. 获取HID设备服务
val hidDevice = bluetoothAdapter.getProfileService(
context, BluetoothHidDevice::class.java
)
// 2. 注册HID应用
val reportDesc = byteArrayOf(
0x05, 0x01, 0x09, 0x06, 0xA1, 0x01,
// ... 上面那段描述符
0xC0
)
val app = BluetoothHidDeviceApp(
"My Keyboard", // 名称
"MyCompany", // 厂商
"1.0", // 版本
reportDesc, // 报告描述符
"00:11:22:33:44:55" // 设备地址
)
hidDevice.registerApp(app)
// 3. 发送按键
fun sendKey(modifier: Byte, keyCode: Byte) {
val report = byteArrayOf(
modifier, // 修饰键
0x00, // 保留
keyCode, // 按键码
0x00, 0x00, 0x00, 0x00, 0x00
)
hidDevice.sendReport(
device,
BluetoothHidDevice.REPORT_TYPE_INPUT,
report
)
}
这里有个细节:发送按键时,要先发按下,再发松开。也就是先发带键码的报告,再发全零的报告。否则主机认为你一直按着不放。
注意:Android的HID设备API在模拟器上不支持。必须在真机上调试。我当初在模拟器上折腾了两天,最后发现是模拟器的问题。
22.5 避坑指南
做HID over GATT开发,有几个常见的坑:
- 报告描述符写错:主机解析失败,设备不工作。建议用HID DT验证。
- 通知使能没配:输入报告发不出去。记得在GATT连接后,使能CCCD。
- 按键重复触发:没发松开报告。记住:按下→发送→松开→发送。
- 多按键冲突:报告描述符里Report Count设小了。标准键盘支持6键无冲。
我曾经遇到一个诡异的问题:键盘连上手机后,按A键出来的是B。查了半天,发现是报告描述符里Usage的起始值写错了。偏移了一位,所有按键都错位了。
22.6 知识体系总览
下面这张图,是我自己总结的HoG开发知识结构。你照着这个思路走,基本不会乱。
这张图把HoG开发拆成了三块:描述符怎么写、通道怎么配、设备怎么实现。你把这三点吃透了,剩下的就是调细节。
好了,关于HID over GATT的内容,我就讲到这里。记住:报告描述符是地基,输入输出通道是管道,Android API是工具。地基稳了,管道通了,工具顺手了,一个蓝牙键盘/鼠标的实现,其实没那么复杂。
公众号:蓝海资料掘金营,微信deep3321