Adafruit学习系统前几天放出了一套蓝牙HID键盘控制器的完整教程,从硬件选型到CircuitPython代码全都有。这个东西说白了就是让你用带蓝牙模块的开发板,直接模拟成一个无线键盘,往电脑、手机、平板上敲键。我第一时间按着教程做了一套,今天把整个思路、原理、实操和踩坑记录整理出来,给想入坑蓝牙HID项目的朋友一个参考。
1. 这个项目到底是什么
1.1 蓝牙HID键盘控制器不是普通蓝牙键盘
先厘清一个概念:平时我们买的那种蓝牙键盘,是厂商做好了的成品,固件、协议栈、按键扫描全都在内部搞定,用户拿回来配对就能用。而Adafruit这套方案,是让你自己做"键盘本体"——用开发板跑代码,通过蓝牙HID协议向主机发送按键信号。
HID(Human Interface Device)是人体学输入设备协议的缩写,USB时代键盘鼠标都走这个协议。蓝牙HID就是把同一套HID逻辑搬到蓝牙传输层上,主机端看到的依然是一个标准键盘,不需要装额外驱动。这套设计的精妙之处在于:硬件上只要支持BLE(低功耗蓝牙),就能通过软件变成键盘,完全绕开USB物理接口的限制。
1.2 为什么选Adafruit这套方案
市面上的蓝牙HID方案其实不少,比如ESP32配Bluedroid、nRF52840配Zephyr、甚至一些国产芯片自带BLE协议栈。但Adafruit这套有它特有的优势。
Adafruit Learning System发布的教程,默认使用的是nRF52840芯片的开发板(比如Bluefruit nRF52840 Feather或CLUE),配合CircuitPython固件。CircuitPython是一个对新手极其友好的Python运行时,写HID逻辑就像写普通Python脚本一样,不需要折腾复杂的嵌入式编译链。
我做这个项目之前也试过ESP32的方案,配置文件、蓝牙协议栈初始化、HID描述符注册这些步骤非常繁琐,而且不同版本的ESP-IDF接口变化很大,网上找的例程经常编译不过。Adafruit的CircuitPython把所有底层细节封装好了,adafruit_ble库和adafruit_hid库直接提供现成的类,几行代码就能注册一个蓝牙键盘。
这套方案特别适合这几类人:想给树莓派或平板做一个专用快捷键键盘的玩家,做辅助输入设备(比如给残障人士定制的单一按键输入器)的开发者,以及想搞懂BLE HID协议但不想一上来就啃协议栈的学生。
2. 核心原理:HID协议、蓝牙协议栈与固件之间的关系
2.1 从USB HID到蓝牙HID
要理解蓝牙HID,先得知道USB HID是怎么工作的。USB HID设备内部有一个HID描述符(HID Descriptor),里面定义了设备是键盘、鼠标还是游戏手柄,以及每个报告(Report)的格式。键盘的报告格式一般是8个字节:第1字节是修饰键(Ctrl、Shift、Alt等),第2字节是保留位,后面6个字节是按键码,最多同时按6个键。
蓝牙HID的协议栈其实复用了这套逻辑,BLE的HID over GATT Profile定义了一个叫做HID Service的服务(UUID为0x1812),服务下面有多个特征值(Characteristic),其中最重要的两个是Report Map(报告映射)和Report(报告)。Report Map本质上就是USB HID描述符的二进制数据,Report就是实际发送的键值数据。
所以一个蓝牙HID键盘的工作流程就是:设备通过BLE广播自己的HID服务,主机(电脑/手机)发现并配对后,读取设备的Report Map,知道这是一个键盘;之后用户按键,设备通过Report特征值把8字节报告发过去,主机解析后执行按键动作。
2.2 nRF52840芯片与CircuitPython的角色
nRF52840是Nordic公司的一款Cortex-M4F内核芯片,最大亮点是内置了完整的BLE 5.0协议栈,射频性能强,功耗也低。Adafruit把这块芯片做成了各种开发板,并移植了CircuitPython固件,等于在芯片上跑了一个Python解释器。
这里有个关键区分:CircuitPython本身只是一个应用层运行时,底层BLE协议栈仍然是Nordic的SoftDevice闭源代码。CircuitPython通过一组C API调用SoftDevice,再把这些API封装成Python层的_bleio模块。adafruit_ble库里的BLEConnection、BLECharacteristic等对象,最终都会落到_bleio的底层调用上。
用CircuitPython写HID代码时,你要理解的就是:你写的Python代码决定了设备的行为逻辑(比如按下什么键、什么时候发报告),而蓝牙协议栈本身已经被CircuitPython的C代码启动好了。运行时不需要关心配对握手、链路层加密这些细节,调用start_advertising()就开始广播,主机连接后in_connection变为True,就可以通过keyboard.send()发送按键了。
2.3 键盘描述符和报告速率
Adafruit的adafruit_hid.keyboard.Keyboard类自带一份标准的键盘HID描述符,这份描述符定义了键盘的报告格式和用法。如果你想做多媒体键盘(带音量键、播放暂停键),就需要在CircuitPython中加载一个扩展描述符,比如adafruit_hid.consumer_control.ConsumerControl对应Consumer Control设备,可以发送媒体控制命令。
报告速率方面,BLE传输单包数据一般20个字节(ATT MTU默认23字节减3字节头),一个键盘报告只有8个字节,绰绰有余。但BLE的传输延迟通常比USB高不少,USB键盘轮询频率一般是125Hz到1000Hz,BLE的connection interval如果设置成7.5ms到15ms,整体延迟感受在15到30毫秒之间。对于打字来说完全没问题,但如果你打算用蓝牙HID做游戏键位,这个延迟需要认真评估。
3. 实操:从零搭建一个蓝牙HID键盘控制器
3.1 硬件清单与选型说明
我按Adafruit教程的思路做了一套,硬件清单如下:
- 主控板:Adafruit Bluefruit nRF52840 Feather(最省事的选择,板载天线、电池管理、RGB LED)
- 备用板:Adafruit CLUE(带屏幕和传感器,适合做带显示的状态反馈键盘)
- 按键:6x6轻触开关若干,或者直接买现成的矩阵键盘模块
- 连接线:杜邦线若干
- 电池:3.7V锂电池(Feather板带JST接口和充电电路,用着方便)
如果你手头没有Adafruit的板子,只要是nRF52840且能跑CircuitPython的板子都可以,比如Arduino Nano 33 BLE、Particle Xenon(已停产但还有库存)等。ESP32理论上也能跑CircuitPython,但BLE HID支持的稳定度不如nRF52840,我实测会有断连问题,不建议入门用。
注意:购买nRF52840开发板时,优先选择板载USB-C接口的版本,因为CircuitPython的刷机、串口输出、磁盘挂载都依赖USB连接,Type-C线材兼容性比Micro-USB好很多,我在老Micro-USB板上被劣质数据线折腾过两次,直接劝退。
3.2 固件准备与开发环境搭建
CircuitPython的刷机流程分三步。第一步去circuitpython.org下载对应板卡的UF2固件文件。第二步,按住开发板上的BOOT按钮插USB线,会出现一个名为FTHR840BOOT(Feather板)或CLUEBOOT(CLUE板)的U盘。第三步,把UF2固件文件拖进去,板子自动重启,U盘变成CIRCUITPY。
开发环境方面,我用的是Mu编辑器(Adafruit官方推荐),它自带串口监视器、Plotter和REPL面板,对新手非常友好。如果你习惯VSCode,装上CircuitPython插件也一样,但串口交互还是Mu方便,直接在REPL里敲print("hello")就能看到输出。
刷完固件后,CIRCUITPY盘里会有code.py、boot.py、lib等目录。Adafruit官方学习系统的教程要求安装最新的Adafruit CircuitPython Library Bundle,我建议直接把整个lib目录拷贝到CIRCUITPY里,省去逐个找库的麻烦。整个Bundle解压后大概几十MB,而CIRCUITPY盘有2MB可用空间,所以不能全放进去,只需要挑需要的库——adafruit_ble、adafruit_hid以及它们的依赖库。
一个容易踩的坑:CircuitPython固件版本和库版本必须匹配。老固件用新库,或者反过来,都会出现ImportError。下载固件时注意日期版本号,库Bundle也要选相同日期的版本。
3.3 核心代码实现
先写一个最简版本的蓝牙HID键盘,代码量出乎意料地少:
import time import board import digitalio from adafruit_ble import BLERadio from adafruit_ble.advertising.standard import ProvideServicesAdvertisement from adafruit_ble.services.standard.hid import HIDService from adafruit_hid.keyboard import Keyboard from adafruit_hid.keycode import Keycode ble = BLERadio() hid = HIDService() advertisement = ProvideServicesAdvertisement(hid) advertisement.appearance = 961 # Keyboard appearance advertisement.complete_name = "Adafruit BLE Keyboard" key_pin = digitalio.DigitalInOut(board.D5) key_pin.direction = digitalio.Direction.INPUT key_pin.pull = digitalio.Pull.UP keyboard = Keyboard(hid.devices) while True: ble.start_advertising(advertisement) while not ble.connected: pass print("connected") while ble.connected: if not key_pin.value: # 按键按下,引脚被拉低 keyboard.press(Keycode.A) keyboard.release_all() time.sleep(0.2) else: time.sleep(0.01)这段代码的逻辑是:上电后广播蓝牙服务,等待主机连接,连接成功后检测D5引脚是否拉低(按键按下),如果按下就发送字母A的按键报告,然后释放,等待200毫秒防止重复触发。
这里有个细节值得展开:keyboard.press()之后必须调用keyboard.release_all(),否则主机会一直认为这个键被按住,表现为长按无限重复输入。我第一次写代码就漏了release_all(),结果连接笔记本之后,按一下输出一整行AAAA,排查了半天才发现是释放事件没发出去。
如果你的按键数量多,不要一个个写if判断,可以用扫描方式:
import board import digitalio import time rows_pins = [board.D5, board.D6, board.D7] cols_pins = [board.D9, board.D10] rows = [] cols = [] for pin in rows_pins: row = digitalio.DigitalInOut(pin) row.direction = digitalio.Direction.OUTPUT row.value = True rows.append(row) for pin in cols_pins: col = digitalio.DigitalInOut(pin) col.direction = digitalio.Direction.INPUT col.pull = digitalio.Pull.UP cols.append(col) key_map = [ [Keycode.ONE, Keycode.TWO], [Keycode.THREE, Keycode.FOUR], [Keycode.FIVE, Keycode.SIX], ] def scan(): for r, row in enumerate(rows): row.value = False for c, col in enumerate(cols): if not col.value: yield key_map[r][c] row.value = True矩阵扫描的原理很简单:逐行拉低电平,然后检测每一列是否为低电平,如果两相交点为低,说明这个键被按下。这样做的好处是大幅节省GPIO引脚,比如8x8矩阵只需要16个引脚就能控制64个按键。
3.4 配置与连接
在电脑上配对最简单。Windows的蓝牙设置里,搜索到的设备名就是代码里设置的complete_name——我设置的"Adafruit BLE Keyboard",点击配对后,系统会提示这是一款键盘,直接进入配对流程。macOS和Android也是类似,打开蓝牙设置搜索链接即可。
连接成功后,CircuitPython的REPL会打印connected,板载RGB LED也会变蓝。此时在任意文本编辑器里按一下板上的按键,就能看到字母"a"被输出。
有一个体验优化点:如果你是做桌面快捷键键盘,建议在代码里加一个状态机处理按键组合,而不是单键直通。比如同时按F1和F2触发"复制"、"粘贴":
if combo_enabled and not copy_pin.value: keyboard.send(Keycode.CONTROL, Keycode.C)keyboard.send()是press()加release_all()的组合调用,可以一次发送多个按键组合。
4. 常见问题与排查技巧
4.1 蓝牙搜索不到设备
这是新手最容易碰到的问题。先看代码是否在while not ble.connected:循环里不断调用start_advertising()。理论上Adafruit的BLE库会持续广播,但广播是有间隔的(默认100ms),如果手机刚好在广播间隙扫描,可能错过,稍等秒再扫一次就有了。
如果多次搜索都看不到,要把排查重点放在硬件上。检查板子的电源指示灯是否亮,代码是否报错——打开Mu的串口监视器,看有没有红色异常输出。常见问题是adafruit_ble库没有装全,导入时报ModuleNotFoundError,导致代码在启动时崩溃,广播函数根本没执行。
还有一种隐蔽情况:你之前配对过这个设备,然后在系统的蓝牙设备列表里点了"忘记",但设备端还在向之前的配对信息发送广播。此时进入配对模式之前,先给开发板断电重启,让它重新进入广播状态。
4.2 Windows端I2C HID设备感叹号问题
Windows设备管理器里有个常见条目叫"人体学输入设备 I2C HID设备",有时会带黄色感叹号。这个和蓝牙HID关系不大,它对应的是触摸板、指纹传感器这类走I2C总线的人体学输入设备。
但如果你在装完驱动之后,发现蓝牙HID键盘连上却打不出字,设备管理器的蓝牙部分出现感叹号,那大概率是蓝牙HID服务注册表损坏或者驱动冲突。解决方法不复杂:先把设备管理器中所有带感叹号的蓝牙设备卸载,然后重启电脑,让系统重新枚举蓝牙适配器和HID设备。实测此方法能解决90%的Windows蓝牙HID异常。
需要注意的是,Windows对蓝牙键盘有额外的安全策略,首次配对后必须点击Windows右下角弹出的配对确认框,否则即使设备显示已连接,也无法输入。我在Windows 11上就吃过这个亏,连上后键盘一点反应都没有,后来发现是配对确认框被我忽略掉了。
4.3 Linux下如何测试HID设备
Linux下调试蓝牙HID设备的方法和Windows完全不同。首先需要安装bluez工具集,然后用bluetoothctl命令完成配对:
sudo apt install bluez bluetoothctl power on agent on scan on # 等待看到 Adafruit BLE Keyboard 设备 pair 设备的MAC地址 trust 设备的MAC地址 connect 设备的MAC地址连接成功后,用cat /proc/bus/input/devices查看系统是否识别到了键盘设备,此时应该能看到一个名为"Adafruit BLE Keyboard"的输入设备。随后可以安装evtest工具,实时查看按键事件:
sudo evtest它会列出所有输入设备,选择你的蓝牙键盘后,按一下板载按键,终端里会输出类似type 4 (EV_MSC), code 4 (MSC_SCAN), value 70004的事件信息。如果能看到这些输出,说明HID报告已经正确到达内核。
想验证HID描述符的内容,可以用hidrd-convert工具把报告描述符转成可读文本:
sudo hidrd-convert /sys/class/hidraw/hidraw1/device/report_descriptor这样你能直观看到设备声明了哪些用法,比如键盘按键、Consumer Control按键等。这一步对排查"连上了但某些键没反应"特别有用,很多情况下是描述符里忘了声明对应的Usage Page。
4.4 延迟、按键重复等体验问题
蓝牙HID的延迟虽然可以接受,但在需要快速输入的场景下,能明显感觉到比有线键盘慢半拍。最有效的优化是缩短BLE连接间隔。在CircuitPython里,可以通过adafruit_ble库调整conn_interval_min和conn_interval_max参数:
from adafruit_ble.connections import Connection # ... 获取connection对象后 connection = ble.connections[0] connection.conn_interval_min = 6 # 7.5ms connection.conn_interval_max = 12 # 15ms但要注意,连接间隔设置得太短会增加功耗和射频占用。如果项目是电池供电的无线键盘,不建议低于10ms,否则待机时间会大幅缩短。
按键重复问题通常出现在代码逻辑里。很多人会在循环里直接调用keyboard.press()而不加延时或去抖,导致一次物理按下触发多次输出。解决方案是:要么像我在最简版代码里那样加time.sleep(0.2),把有效按下间隔拉长;要么做键状态锁存,用pressed变量记录上一次按键状态,只有检测到"从没按下变到按下"的沿变化才发送:
prev_state = True while ble.connected: cur_state = key_pin.value if not cur_state and prev_state: keyboard.press(Keycode.A) keyboard.release_all() prev_state = cur_state time.sleep(0.01)这种边沿检测方式比单纯延时更可靠,既能防止按键抖动造成的重复,又不会因为延时过长导致快速连打失效。
5. 衍生玩法与扩展思路
做完最基础的蓝牙HID键盘之后,Adafruit这套方案还能往很多方向扩展。比如接一个旋转编码器(Rotary Encoder)做音量旋钮,用的是adafruit_hid.consumer_control库:
from adafruit_hid.consumer_control import ConsumerControl from adafruit_hid.consumer_control_code import ConsumerControlCode cc = ConsumerControl(hid.devices) # 顺时针旋转时 cc.send(ConsumerControlCode.VOLUME_INCREMENT) # 逆时针旋转时 cc.send(ConsumerControlCode.VOLUME_DECREMENT)在Windows和macOS上,这种音量控制会被系统原生识别,不需要额外软件。我自己做了一个三键加一个旋钮的小盒子,放在视频剪辑工作站旁边,剪辑时控制音量、播放暂停、前进后退,效率比鼠标点按钮高一截。
另一个实用方向是做一个"蓝牙GPS数据注入器"。这个场景比较特殊:部分户外软件或测绘工具需要一个串口GPS数据源,但电脑没有串口,而蓝牙串口设备(SPP)在Windows新版系统里驱动支持很烂。用蓝牙HID方案反而可以做键盘输入式GPS坐标注入,通过模拟键盘把NMEA语句"打"进焦点文本框,绕开串口驱动问题。虽然延迟和输入方式很笨拙,但在某些封闭软件环境里反而可行。
如果你想做更复杂的宏键盘,adafruit_ble库还支持同时注册多个HID设备,比如一个键盘加一个媒体控制设备,甚至再加一个鼠标。这样你可以在同一个板子上实现键盘、音量轮、鼠标滚轮三合一输入器。
还有一个值得提的是bootloader保留问题。如果开发板装了CircuitPython,又想回到Arduino/Zephyr开发环境,需要按住BOOT按钮重新刷UF2固件。Adafruit的板子几乎都是双阶段bootloader设计,随时可以刷回其他固件,这点比ESP32折腾多了,也是我推荐它做原型开发的重要原因。
6. 我的一点个人体会
整套项目做下来,最深的感受是:Adafruit Learning System这套教程的真正价值,不是给你一个现成的代码,而是把蓝牙HID的协议脉络理得很清楚。在跟着教程走一遍之前,我对HID描述符、Report Map只有模糊的概念,做一遍之后才明白主机端和设备端是怎么协商出"这个设备是一个键盘"这件事的。
建议后来者不要只抄代码就跑,抽时间把adafruit_ble/services/standard/hid.py和adafruit_hid/keyboard.py的源码翻出来读一遍,你会发现底层做了很多你已经用得上但不自知的事情,比如自动生成Report Map、处理Boot模式切换等。
最后分享一个小技巧:CircuitPython的REPL是一个调试神器。当你遇到"代码跑起来但设备连不上"这种问题,可以先在REPL里手动执行import adafruit_ble,然后用dir()查看库的可用方法,逐步确认BLE广播是否能正常启动。这种交互式调试方式,比起反复拔插USB线改代码、刷固件,效率高太多了。