目录
- 项目介绍
- 硬件介绍
- 项目设计
- 开发环境及工程参考
- 总体流程图
- 硬件基本配置
- 应用初始化
- 按键中断回调
- 定时器回调
- 按键响应任务
- 蓝牙事件回调
- BLE HID
- Report Map及报文
- 键盘设备
- 鼠标设备
- 复合设备
- 发送字符串
- 上/下滚动
- 功能展示
- 项目总结
👉 【Funpack3-1】基于XG24-EK2703A的BLE HID蓝牙键盘+鼠标复合设备
👉 Github: EmbeddedCamerata/XG24_ble_hid_keymouse
项目介绍
本项目基于Silicon Labs XG24-EK2703A开发板,通过HID协议实现了一个蓝牙键盘+鼠标复合设备,可通过按键实现上下翻页、发送字符功能。使用板载两个按键,当BTN0按下,向上翻页;当BTN1按下,向下翻页;当两按键同时按下2s后,向主机依次发送字符“EETREE.CN”。
👉 Simplicity Studio 5
硬件介绍
XG24-EK2703A是一款基于EFR32MG24片上系统的开发套件,具备超低成本、低功耗和小巧的特点。该套件支持2.4GHz无线通信,兼容蓝牙LE、蓝牙mesh、Zigbee、Thread和Matter协议,为无线物联网产品的开发和原型制作提供了极大的便利。包含:
- 一个USB接口
- 一个板载SEGGER J-Link 调试器,支持SWD
- 两个LED和两个按钮
- 虚拟COM端口
- 数据包跟踪接口(PTI)
- 一个支持外部硬件连接的mikroBus插座和一个Qwiic连接器
- 32 位 ARM Cortex-M33,78 MHz最高工作频率
- 1536 kB 闪存和 256 kB RAM
项目设计
开发环境及工程参考
本项目使用Silicon Labs官方的IDE Simplicity Studio 5开发,使用Gecko SDK v4.4.0,GNU ARM Toolchain 12.2。工程目录上,按照Bluetooth - SoC Empty 空白示例的代码组织形式即可。主要的业务代码写在 app.c
及 app.h
内,外设、驱动及蓝牙部分通过 .slcp
文件配置。
👉 本工程参考SiliconLabs蓝牙应用示例:bluetooth_hid_keyboard
总体流程图
所使用的系统外设:两个按键、两个LED及蓝牙栈。
- 在按键中断回调中,根据不同按键按下,置位或清除各按键按下的事件。
- 使用FreeRTOS操作系统,创建按键响应任务,用以实现两个按键按下的响应服务:循环读取按键按下事件,当按键单独按下时,则用一枚举变量
km_status
记录:- 当BTN0按下,置
KM_SCROLL_UP
- 当BTN1按下,置
KM_SCROLL_DOWN
- 当同时按下,且无定时器在运行,则开启2s定时器,该定时器绑定一回调函数,在该回调内:置
km_status
为KM_SEND_STRING
,同时反转两LED状态(便于观察现象) - 最后,都向蓝牙栈发送外部事件信号
- 当BTN0按下,置
- 在蓝牙事件回调中,当接收到外部事件信号后,根据
km_status
值进行相应操作。从而实现上/下翻页、发送字符的功能。
硬件基本配置
在基于 “Bluetooth - Soc Empty” 空白示例的基础上,打开 .slcp
文件,在 SOFTWARE COMPONENTS 选项卡下安装如下组件:
- [Platform] → [Driver] → [Button] → [Simple Button],例化 btn0 与 btn1,对应开发板上两个按键,均设置为中断模式
- [Platform] → [Driver] → [LED] → [Simple LED],例化 led0 与 led1,对应开发板上两个 LED
- [Services] → [IO Stream] → [IO Stream: USART],保持默认配置即可
- [Application] → [Utility] → [Timer for FreeRTOS]
- [Application] → [Utility] → [Log]
并且,参考SiliconLabs蓝牙应用示例:bluetooth_hid_keyboard,使用该示例提供的 GATT 配置,导入到自己的工程中:
- 打开项目中
.slcp
文件 - 在 CONFIGURATION TOOLS 选项卡下找到 Bluetooth GATT Configurator
- 导入
config/btconf/gatt_configuration.btconf
文件 - 保存 GATT 配置
后续还会进行一定程度的修改。
应用初始化
在 app.h
内,定义四种按键按下的枚举类型,分别表示:未按下、发送字符(两按键同时按下)、上翻页(BTN0按下)及下翻页(BTN1按下):
typedef enum
{
KM_IDLE = 0U,
KM_SEND_STRING = 1U,
KM_SCROLL_UP = 2U,
KM_SCROLL_DOWN = 3U,
} km_status_t;
在初始化阶段,先创建按键按下事件组、按键响应任务。
#define KM_BTN_TASK_NAME "keymouse_btn"
#define KM_BTN_TASK_STACK_SIZE 1024
#define KM_BTN_TASK_STATIC 0
TaskHandle_t km_btn_task_handle = NULL;
static EventGroupHandle_t xbtn_events = NULL;
static km_status_t km_status = KM_IDLE;
SL_WEAK void app_init(void)
{
xbtn_events = xEventGroupCreate();
if (xbtn_events == NULL) {
app_log_error("BTN events create failed\r\n");
}
xTaskCreate(km_btn_task,
KM_BTN_TASK_NAME,
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY,
&km_btn_task_handle);
}
按键中断回调
按键中断回调定义在 void sl_button_on_change(const sl_button_t *handle)
内,可参考示例修改。在此,根据触发中断的句柄判断是哪个按键按下或释放,相应地置位或清除事件位 xbtn_events
。
#include "sl_simple_button_instances.h"
#define BTN0_PRESSED (1 << 0)
#define BTN1_PRESSED (1 << 1)
#define BTN_NONE_PRESSED 0
#define BTN_BOTH_PRESSED (BTN0_PRESSED | BTN1_PRESSED)
void sl_button_on_change(const sl_button_t *handle)
{
BaseType_t xHigherPriorityTaskWoken;
if (&sl_button_btn0 == handle) {
if (sl_button_get_state(handle) == SL_SIMPLE_BUTTON_PRESSED) {
xEventGroupSetBitsFromISR(xbtn_events, BTN0_PRESSED, &xHigherPriorityTaskWoken);
}
else {
xEventGroupClearBitsFromISR(xbtn_events, BTN0_PRESSED);
}
}
if (&sl_button_btn1 == handle) {
if (sl_button_get_state(handle) == SL_SIMPLE_BUTTON_PRESSED) {
xEventGroupSetBitsFromISR(xbtn_events, BTN1_PRESSED, &xHigherPriorityTaskWoken);
}
else {
xEventGroupClearBitsFromISR(xbtn_events, BTN1_PRESSED);
}
}
}
定时器回调
该回调函数被捆绑在2s不自动重载定时器上,由于定时器是在两按键同时按下并持续2s后才结束,因此在回调内,需清除两个按键按下事件,最后发送给蓝牙栈外部事件信号。
static void btn_press_timer_cb(app_timer_t *timer, void *data)
{
(void)data;
(void)timer;
BaseType_t xResult;
xResult = xEventGroupClearBitsFromISR(xbtn_events, BTN_BOTH_PRESSED);
if (xResult == pdFAIL) {
app_log_error("Clear BTN_BOTH_PRESSED event failed\r\n");
}
km_status = KM_SEND_STRING;
sl_led_toggle(&sl_led_led0);
sl_led_toggle(&sl_led_led1);
sl_bt_external_signal(1);
}
按键响应任务
主体为一循环。在循环内,通过 xEventGroupGetBits
读取按键事件,并做出不同响应。该事件在应用初始化时创建。用一bool型变量 is_running
记录定时器是否在运行,从而避免在两按键一直按下时反复重启定时器。由于可能出现先两按键按下,再释放一个或两个按键的情况,因此在其他情况下,都关闭定时器。
static void km_btn_task(void *p_arg)
{
app_timer_t btn_press_timer;
bool is_running = false;
EventBits_t btn_events;
while (1) {
btn_events = xEventGroupGetBits(xbtn_events);
switch (btn_events) {
case (BTN_BOTH_PRESSED):
if (!is_running) {
app_timer_start(&btn_press_timer, 2000, btn_press_timer_cb, NULL, false);
is_running = true;
}
case (BTN0_PRESSED):
app_timer_stop(&btn_press_timer);
km_status = KM_SCROLL_UP; // scroll up
sl_bt_external_signal(1);
break;
case (BTN1_PRESSED):
app_timer_stop(&btn_press_timer);
km_status = KM_SCROLL_DOWN; // scroll down
sl_bt_external_signal(1);
break;
default:
app_timer_stop(&btn_press_timer);
is_running = false;
break;
}
vTaskDelay(pdMS_TO_TICKS(50));
}
vTaskDelete(NULL);
}
蓝牙事件回调
参考SiliconLabs蓝牙应用示例: bluetooth_hid_keyboard,修改蓝牙事件回调中当 MSG_ID 为 sl_bt_evt_system_external_signal_id
时的部分代码:根据 km_status
状态分别实现上/下翻页、发送字符,且这几个功能分别用函数封装。最后,置 km_status = KM_IDLE
。
...
case sl_bt_evt_system_external_signal_id:
if (notification_enabled == 1 && km_status != KM_IDLE) {
if (km_status == KM_SEND_STRING) {
send_eetree_string();
}
else if (km_status == KM_SCROLL_UP) {
scroll_with_distance(0x01);
}
else { // KM_SCROLL_DOWN
scroll_with_distance(0xFF);
}
app_log_info("Key report %d was sent\r\n", km_status);
km_status = KM_IDLE;
}
break;
...
BLE HID
HID(Human Interface Device)人体学接口设备,是生活中常见的输入设备,比如键盘、鼠标等。早期的HID是设备大部分都是通过USB接口来实现,蓝牙技术出现后,通过蓝牙作为传输层,实现了无线HID设备。通过低功耗蓝牙实现的HID功能一般简称为HOGP(HID over Gatt Profile)。BLE HID 规范以 USB HID 规范为基础,因此具体含义仍需参照USB HID文档。
👉 参考:【BLE】HID设备的实现(蓝牙自拍杆、蓝牙键盘、蓝牙鼠标、HID复合设备)
Report Map及报文
键盘设备
👉 参考:DIY蓝牙键盘(1) - 理解键盘报文
Report Map用十六进制数据,描述HID设备的基本信息,例如,按键数量,数据的最大最小值,功能等。为了实现鼠标+键盘复合设备,参考SiliconLabs蓝牙应用示例: bluetooth_hid_keyboard所给出的一个键盘设备的报告映射,并加入Report ID条目:
值 | 项目 |
---|---|
0x05, 0x01 | Usage Page (Generic Desktop) |
0x09, 0x06 | Usage (Keyboard) |
0xa1, 0x01 | Collection (Application) |
0x85, 0x01 | Report Id (1) |
0x75, 0x01 | Report Size (1) |
0x95, 0x08 | Report Count (8) |
0x05, 0x07 | Usage Page (Keyboard) |
0x19, 0xe0 | Usage Minimum (Keyboard LeftControl) |
0x29, 0xe7 | Usage Maximum (Keyboard Right GUI) |
0x15, 0x00 | Logical Minimum (0) |
0x25, 0x01 | Logical Maximum (1) |
0x75, 0x01 | Report Size (1) |
0x95, 0x08 | Report Count (8) |
0x81, 0x02 | Input (Data, Variable, Absolute) Modifier byte |
0x95, 0x01 | Report Count (1) |
0x75, 0x08 | Report Size (8) |
0x81, 0x01 | Input (Constant) Reserved byte |
0x95, 0x06 | Report Count (6) |
0x75, 0x08 | Report Size (8) |
0x15, 0x00 | Logical Minimum (0) |
0x25, 0x65 | Logical Maximum (101) |
0x05, 0x07 | Usage Page (Key Codes) |
0x05, 0x01 | Usage Minimum (Reserved (no event indicated)) |
0x05, 0x01 | Usage Maximum (Keyboard Application) |
0x05, 0x01 | Input (Data,Array) Key arrays (6 bytes) |
0xc0 | End Collection |
这样描述的键盘设备具有通用键盘的基本功能,将按键与释放键信息通过输入报告发送到主机。同时,使用常见的键盘报文结构,其中包含保留字节、修饰符字节与6个键码字节(可以描述最多6个按键同时按下)。键盘报文格式如下所列:
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 |
---|---|---|---|---|---|---|---|
Modifier byte | Reserved byte | Key code 1 | Key code 2 | Key code 3 | Key code 4 | Key code 5 | Key code 6 |
其中,第一个字节从LSB开始依次表示:
Bit 0 | Bit 1 | Bit 2 | Bit 3 | Bit 4 | Bit 5 | Bit 6 | Bit 7 |
---|---|---|---|---|---|---|---|
L Ctrl | L Shift | L Alt | L GUI | R Ctrl | R Shift | R Alt | R GUI |
第二个字节保留(默认为0)。后面6个字节的每个字节都可以表示一个按键的状态,可以同时有多个按键按下。在手册《HID Usage Tables For Universal Serial Bus (USB)》中,规定了键码与按键的对应关系,例如:
Usage ID | Usage Name |
---|---|
0x04 | Keyboard a & A |
0x05 | Keyboard b & B |
… | … |
0x1D | Keyboard z & Z |
… | … |
0x37 | Keyboard . & > |
例如,下述两个报文分别表示a与A(同时按下左Shift + a):
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 | 含义 |
---|---|---|---|---|---|---|---|---|
0x00 | 0x00 | 0x04 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | a |
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 | 含义 |
---|---|---|---|---|---|---|---|---|
0x04 | 0x00 | 0x04 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | A |
此外,在发送按下按键的信息后,还需发送释放按键的报文,否则键盘将一直按住。
Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 | Byte 8 | 含义 |
---|---|---|---|---|---|---|---|---|
0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 释放 |
鼠标设备
鼠标设备的报告映射如下所列:
值 | 项目 |
---|---|
0x05, 0x01 | Usage Page (Generic Desktop) |
0x09, 0x02 | Usage (Mouse) |
0xa1, 0x01 | Collection (Application) |
0x85, 0x02 | Report Id (2) |
0x75, 0x01 | Report Size (1) |
0x95, 0x08 | Report Count (8) |
0x09, 0x01 | Usage (Pointer) |
0xa1, 0x00 | Collection (Physical) |
0x05, 0x09 | Usage Page (Buttons) |
0x19, 0x01 | Logical Minimum (1) |
0x29, 0x03 | Logical Maximum (3) |
0x15, 0x00 | Logical Minimum (0) |
0x25, 0x01 | Logical Maximum (1) |
0x95, 0x03 | Report Count (3) |
0x75, 0x01 | Report Count (1) |
0x81, 0x02 | Input(Data, Variable, Absolute); 3 button bits |
0x95, 0x01 | Report Count (1) |
0x75, 0x05 | Report Size (5) |
0x81, 0x03 | Input(Constant); 5 bits padding |
0x05, 0x01 | Usage Page (Generic Desktop) |
0x09, 0x30 | Usage (X) |
0x09, 0x31 | Usage (Y) |
0x09, 0x38 | Usage (Wheel) |
0x15, 0x81 | Logical Minimum (-127) |
0x25, 0x7F | Logical Maximum (127) |
0x75, 0x08 | Report Size (8) |
0x95, 0x03 | Report Count (3) |
0x81, 0x06 | Input(Data, Variable, Relative); 3 position bytes (X,Y,Wheel) |
0xc0 | End Collection |
0xc0 | End Collection |
对于鼠标,上报的数据我们定义了4个字节。其中Byte 0 的bit 0~2分别表示鼠标左键、右键与中键,后4位由设备定义(默认为0)。Byte 1 表示鼠标指针X轴移动,Byte 2 表示鼠标指针Y轴移动(有符号数,具体数值与移动距离的关系可实际测试),Byte 3 表示滚轮移动。鼠标的报文格式如下所列:
Byte 0 | Byte 1 | Byte 2 | Byte 3 |
---|---|---|---|
bit 0~2 左、右、中键 | 指针X方向移动 | 指针Y方向移动 | 滚轮移动 |
复合设备
对于两个或以上的HID复合设备来说,需要额外用Report ID描述,在此键盘Report ID为1,鼠标为2。同时,设备的报文前需额外一个字节表示Report ID。将上述两个设备的报告映射写在一起,即可描述键盘+鼠标的复合设备。如图所示,将这个长字节配置至 Bluetooth GATT Configurator 内。
发送字符串
通过键盘设备发送字符串“EETREE.CN”,各字符码表为:0x08(e)、0x17(t)、0x15®、0x37(.)、0x06©、0x11(n)。要实现大写,还需要修饰键按下左Shift。例如,发送“E”,并结合键盘的Report ID(0x01):
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 | Byte 8 | 含义 |
---|---|---|---|---|---|---|---|---|---|
0x01 | 0x04 | 0x00 | 0x08 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | E |
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | Byte 7 | Byte 8 | 含义 |
---|---|---|---|---|---|---|---|---|---|
0x01 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 释放 |
发送一个字符后,再发送一次全0报文,表示按键释放。两各按键前后最好间隔几十毫秒。由于所发送字符串“EETREE.CN”有连续的字符,因此不方便在一次报文中发送(如此两个“EE”将仅表达一次“E”按下),且“.”无需修饰符,因此索性每个字符都单次发送。
#define REPORT_ID_INDEX 0
#define KB_REPORT_ID 0x01
#define MODIFIER_INDEX 1
#define DATA_INDEX 3
#define LSHIFT_KEY_OFF 0x00
#define LSHIFT_KEY_ON 0x02
static uint8_t kb_report_data[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0 };
void send_keyboard(uint8_t caps_key, uint8_t c)
{
sl_status_t sc;
memset(kb_report_data, 0, sizeof(kb_report_data));
kb_report_data[REPORT_ID_INDEX] = KB_REPORT_ID;
kb_report_data[MODIFIER_INDEX] = caps_key;
kb_report_data[DATA_INDEX] = c;
sc = sl_bt_gatt_server_notify_all(gattdb_report,
sizeof(kb_report_data),
kb_report_data);
app_assert_status(sc);
memset(kb_report_data, 0, sizeof(kb_report_data));
kb_report_data[REPORT_ID_INDEX] = KB_REPORT_ID;
sc = sl_bt_gatt_server_notify_all(gattdb_report,
sizeof(kb_report_data),
kb_report_data);
app_assert_status(sc);
sl_sleeptimer_delay_millisecond(20);
}
void send_eetree_string()
{
send_keyboard(LSHIFT_KEY_ON, 0x08); // E
send_keyboard(LSHIFT_KEY_ON, 0x08); // E
send_keyboard(LSHIFT_KEY_ON, 0x17); // T
send_keyboard(LSHIFT_KEY_ON, 0x15); // R
send_keyboard(LSHIFT_KEY_ON, 0x08); // E
send_keyboard(LSHIFT_KEY_ON, 0x08); // E
send_keyboard(LSHIFT_KEY_OFF,0x37); // .
send_keyboard(LSHIFT_KEY_ON, 0x06); // C
send_keyboard(LSHIFT_KEY_ON, 0x11); // N
}
上/下滚动
通过鼠标设备实现上下滚动,并结合鼠标的Report ID(0x02):
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | 含义 |
---|---|---|---|---|---|
0x02 | 0x00 | 0x00 | 0x00 | 0x01 | 上滚 |
Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | 含义 |
---|---|---|---|---|---|
0x02 | 0x00 | 0x00 | 0x00 | 0xFF | 下滚 |
#define MOUSE_REPORT_ID 0x02
#define WHEEL_INDEX 4
static uint8_t mouse_report_data[] = { 0, 0, 0, 0, 0 };
void scroll_with_distance(uint8_t distance)
{
sl_status_t sc;
memset(mouse_report_data, 0, sizeof(mouse_report_data));
mouse_report_data[REPORT_ID_INDEX] = MOUSE_REPORT_ID;
mouse_report_data[WHEEL_INDEX] = distance;
sc = sl_bt_gatt_server_notify_all(gattdb_report,
sizeof(mouse_report_data),
mouse_report_data);
app_assert_status(sc);
}
功能展示
开发板连接PC并配对蓝牙后,可以看到XG24 KeyMouse设备已连接,且电量为100%。功能演示参见视频。
👉 详细展示参见:B站:基于XG24-EK2703A的BLE HID蓝牙键盘+鼠标复合设备功能开发
项目总结
本次项目通过BLE HID协议,实现了键盘+鼠标复合设备,使用两个按键实现上/下翻页、发送字符串的功能。Silicon Labs的IDE总体感觉还不错,直接在IDE内把GSDK、编译工具链都给安装好。对于配置开发板的外设、IO口、驱动、蓝牙GATT配置等有图形化界面,上手较为容易。