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)引入的。

基本步骤:

  1. 获取BluetoothHidDevice实例
  2. 注册HID应用,传入报告描述符
  3. 连接主机(通常是手机或平板)
  4. 发送输入报告

来看核心代码:

// 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开发知识结构。你照着这个思路走,基本不会乱。

HID over GATT 报告描述符 输入/输出通道 Android设备实现 Usage Page/Usage Report Size/Count Notify模式 Write模式 注册HID应用 发送报告 核心:描述符定义数据格式,通道决定传输方式 设备实现 = 描述符 + 通道 + 事件处理

这张图把HoG开发拆成了三块:描述符怎么写、通道怎么配、设备怎么实现。你把这三点吃透了,剩下的就是调细节。

好了,关于HID over GATT的内容,我就讲到这里。记住:报告描述符是地基,输入输出通道是管道,Android API是工具。地基稳了,管道通了,工具顺手了,一个蓝牙键盘/鼠标的实现,其实没那么复杂。


公众号:蓝海资料掘金营,微信deep3321