HID 键盘
低延迟 HID -- 键盘 — SLE Low Latency 通用 TX/RX 模式
学习目标
- 理解 SLE 低延迟通用 TX/RX 模式与 Mouse 专用模式的区别:通用模式采用 TLV 格式,可灵活适配键盘、手柄、自定义 HID 等多种设备
- 掌握 TX 端初始化流程:
sle_low_latency_tx_enable()使能 +sle_low_latency_tx_register_callbacks()注册 TX 数据回调 - 掌握 RX 端接收流程:
sle_low_latency_rx_enable()使能 +sle_low_latency_rx_register_callbacks()注册接收回调 - 理解键盘 N-KRO 按键位图 Report 格式和手柄多轴/多按键 TLV 打包机制
- 能够在 WS63 上实现低延迟无线键盘(或游戏手柄),经 Dongle 上报 PC
基本概念
典型使用场景
SLE 低延迟通用 TX/RX 模式适用于需要灵活 Report 格式的 HID 设备。相比 Mouse 专用模式(固定 button/xy/wheel 结构体),通用模式提供 TLV 自定义数据格式,覆盖以下场景:
- 无线键盘:多按键同时按下场景(如 Ctrl+Shift+Esc),通过按键位图支持 N-Key Rollover。通用键盘最大 6-KRO,SLE 键盘可达 N-KRO(8 字节位图支持 64 键全无冲)
- 游戏手柄:多个模拟轴(X/Y/Z/RX/RY/RZ)+ 16 个以上数字按键 + 方向键,Report 格式复杂。通用模式允许一包同时携带按键 TLV 和轴值 TLV
- 自定义 HID:传感器遥控器(加速度计 + 陀螺仪)、MIDI 控制器(音符/力度/弯音轮)、触控板(多点坐标 + 手势)-- 任何非标准 HID 设备
通用模式 vs Mouse 模式
两者的核心区别在于数据获取机制:
| Mouse 专用模式 | 通用 TX/RX 模式 | |
|---|---|---|
| 回调接口 | set_value_cb(button, x, y, wheel) 固定参数 |
low_latency_tx_cb(*len) 传入缓冲区最大值,返回 TLV 数据指针 |
| 数据格式 | 协议栈定义的结构体 | 应用层自定义 TLV 格式 |
| 灵活性 | 低(只能填鼠标数据) | 高(任意数据任意格式) |
| 开发难度 | 低(填充结构体即可) | 中(需手动打包/解析 TLV) |
| 适用设备 | 鼠标 | 键盘、手柄、自定义 HID |
选择依据:如果设备恰好是鼠标,用 Mouse 模式;如果是其他任何 HID 设备,用通用模式。
TLV 数据格式
TLV(Tag-Length-Value)是通用模式的数据封装格式,每包可包含 1 个或多个 TLV 单元:
- Tag(1 字节):数据类型标识,如 0x01 = 按键位图、0x02 = 轴值
- Length(1 字节):Value 部分的数据长度(字节数),不含 Tag 和 Length 自身
- Value(N 字节):实际数据载荷
键盘 Report 示例(单 TLV):
8 字节位图共 64 位,每个 bit 对应一个物理按键。bit=1 表示按下,bit=0 表示释放。
手柄 Report 示例(双 TLV,同包携带):
Tag=0x01 | Length=0x02 | Buttons[0..1] (16 位按键位图)
Tag=0x02 | Length=0x08 | X_lo X_hi Y_lo Y_hi Z_lo Z_hi RZ_lo RZ_hi (4 个 int16 轴值)
多 TLV 同包发送时,各 TLV 按 Tag 顺序排列,总长度不超过
low_latency_tx_cb传入的*len上限。
通信流程: 通用 HID 发送与接收
sequenceDiagram
participant TX as TX 端 -- 键盘/手柄
participant Air as 无线链路
participant RX as RX 端 -- Dongle
participant PC as PC Host
Note over TX: sle_low_latency_tx_enable
Note over TX: sle_low_latency_tx_register_callbacks
Note over TX: sle_low_latency_set -- 1KHz
Note over RX: sle_low_latency_rx_enable
Note over RX: sle_low_latency_rx_register_callbacks
Note over RX: sle_low_latency_set -- 1KHz
loop 每 1ms 调度周期
TX->>TX: low_latency_tx_cb -- 请求数据
Note over TX: 扫描 GPIO 矩阵<br/>获取按键状态
Note over TX: 打包 TLV 数据
TX-->>Air: TLV 数据包
Air-->>RX: 无线接收
RX->>RX: low_latency_rx_cb -- 收到数据
Note over RX: 解析 TLV
RX->>PC: USB HID Report
end
协议栈按固定时隙(1KHz 为 1ms)主动调用 TX 回调获取数据。TX 端无需维护定时器,只需在回调中返回当前状态即可。
涉及 API
| API | 谁调用 | 用途 |
|---|---|---|
sle_low_latency_tx_enable() |
键盘/手柄 | 使能低延迟 TX 模式 |
sle_low_latency_tx_register_callbacks(&tx_cbk) |
键盘/手柄 | 注册 TX 数据回调(核心是 low_latency_tx_cb) |
sle_low_latency_rx_enable() |
Dongle | 使能低延迟 RX 模式 |
sle_low_latency_rx_register_callbacks(&rx_cbk) |
Dongle | 注册 RX 接收回调(核心是 low_latency_rx_cb) |
sle_low_latency_set(conn_id, enable, rate) |
双方 | 配置低延迟调度参数(使能状态 + 调度速率) |
前置 API 来自 hello-connect(
sle_announce/sle_seek/sle_connect_remote_device)。
案例说明
做什么
WS63 作为键盘端(或手柄端),扫描 GPIO 矩阵获取按键/轴状态,打包为 TLV 格式,通过低延迟调度周期发送至 Dongle。Dongle 接收后解析 TLV,转换为 USB HID Report 上报 PC,PC 识别为标准 HID 键盘(或 Gamepad)。
子场景切换
通过 Kconfig 切换键盘模式和手柄模式:
| 模式 | Kconfig 选项 | TX Report 内容 |
|---|---|---|
| 键盘模式 | SLE_LL_TX_KEYBOARD |
单 TLV:Tag=0x01, 8 字节按键位图 |
| 手柄模式 | SLE_LL_TX_GAMEPAD |
双 TLV:Tag=0x01 按键位图 + Tag=0x02 轴值 |
需要另一块 WS63 烧录 Dongle 固件(SLE Client 端 + RX 模式),通过 USB 连接 PC。
案例流程
flowchart LR
subgraph Keyboard[键盘端 WS63]
GPIO[GPIO 按键矩阵]
Scanner[按键扫描]
TLV_Pack[TLV 打包器]
LL_TX[SLE Low Latency TX]
end
subgraph Dongle[Dongle 端 WS63]
LL_RX[SLE Low Latency RX]
TLV_Parse[TLV 解析器]
USB_HID[USB HID 设备]
end
subgraph PC[PC]
Driver[HID 驱动]
OS[操作系统]
end
GPIO --> Scanner
Scanner --> TLV_Pack
TLV_Pack --> LL_TX
LL_TX -->|SLE 无线| LL_RX
LL_RX --> TLV_Parse
TLV_Parse --> USB_HID
USB_HID -->|USB| Driver
Driver --> OS
程序运行流程:
- 键盘端初始化 GPIO 矩阵、使能低延迟 TX 模式
- Dongle 端初始化 USB HID、使能低延迟 RX 模式
- 建立 SLE 连接后,双方调用
sle_low_latency_set()启用调度 - 每 1ms(1KHz)协议栈调用
low_latency_tx_cb,键盘端扫描 GPIO、打包 TLV、返回数据 - Dongle 端
low_latency_rx_cb收到 TLV 数据,解析后通过 USB HID 上报 PC
案例操作指导
第一步:编译键盘端固件
Top → Application → Samples → BT → SLE → Verticals → [*] HID Keyboard Sample
└─ Low Latency Mode ─> (X) TX (Keyboard/Gamepad)
└─ Device Type ─> [Keyboard] or [Gamepad]
第二步:编译 Dongle 固件
Top → Application → Samples → BT → SLE → Verticals → [*] HID Dongle Sample
└─ Low Latency Mode ─> (X) RX (Dongle)
第三步:烧录并运行
先给 Dongle 上电并插入 PC USB,再给键盘端上电。键盘端预期串口输出:
[keyboard] init ok, gpio matrix scanned
[keyboard] ll tx enabled
[keyboard] connected, conn_id=0x01
[keyboard] ll scheduling started, rate=1000Hz
[keyboard] keys pressed: 0x00000004 (key 2)
[keyboard] keys released: clear
Dongle 端预期串口输出:
[dongle] init ok, usb hid ready
[dongle] ll rx enabled
[dongle] connected, conn_id=0x01
[dongle] usb report sent: keyboard, 8 bytes
第四步:验证
- 键盘模式:Dongle 插入 PC USB 后,PC 设备管理器中应出现 HID Keyboard Device。按下键盘端连接的按键,PC 应输出对应字符
- 手柄模式:PC 识别为 Gamepad,在 Windows 游戏控制器属性中可看到摇杆和按键状态的实时变化
关键配置
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 键盘调度速率 | 125Hz(8ms) | 打字场景 8ms 延迟完全可接受。1KHz 会带来不必要的功耗增加,对键盘体验无实质提升 |
| 手柄调度速率 | 1KHz(1ms) | 游戏需要 1ms 响应延迟。低于 500Hz 时玩家可感知输入延迟 |
| 按键位图大小 | 8 字节(64 键) | 64 键覆盖标准键盘主键区。如需更多键可扩展至 16 字节 |
| 按键扫描去抖 | 5ms | 机械按键抖动典型 2~5ms,去抖窗口应大于抖动时间 |
| 手柄轴精度 | int16(-32767~+32767) | 16 位精度覆盖游戏引擎要求的全部轴值范围 |
| TLV Tag 定义 | 0x01=按键, 0x02=轴 | 自定义 Tag 值,需 TX/RX 双方保持一致 |
| 按键报告模式 | N-KRO(无按键冲突) | 位图方式天然支持 N-KRO,优于传统 6-KRO |
代码详解
TX 端初始化
键盘端使能低延迟 TX 模式并注册回调。
static sle_low_latency_tx_callbacks_t g_tx_cbk = {
.low_latency_tx_cb = keyboard_tx_data_cb,
};
static void init_keyboard_tx(void)
{
errcode_t ret;
ret = sle_low_latency_tx_enable();
if (ret != ERRCODE_SLE_SUCCESS) {
osal_printk("[keyboard] tx enable failed, ret=%d\r\n", ret);
return;
}
ret = sle_low_latency_tx_register_callbacks(&g_tx_cbk);
if (ret != ERRCODE_SLE_SUCCESS) {
osal_printk("[keyboard] register tx cb failed, ret=%d\r\n", ret);
return;
}
osal_printk("[keyboard] tx init ok\r\n");
}
回调注册在使能之后进行。建连成功后调用
sle_low_latency_set(conn_id, 1, SLE_LOW_LATENCY_1K)启动调度。
low_latency_tx_cb 回调实现 -- 键盘模式
协议栈定时调用此回调获取键盘数据。返回 TLV 格式的按键位图。
#define KB_TAG_KEYBOARD 0x01
#define KB_BITMAP_BYTES 8 /* 64 键位图 */
static uint8_t *keyboard_tx_data_cb(uint16_t *len)
{
static uint8_t tlv_buf[1 + 1 + KB_BITMAP_BYTES]; /* Tag + Len + Value */
uint8_t bitmap[KB_BITMAP_BYTES] = {0};
/* 扫描 GPIO 矩阵,获取所有按下按键的位图 */
gpio_matrix_scan(bitmap, KB_BITMAP_BYTES);
/* 检查是否有按键按下 */
bool any_pressed = false;
for (int i = 0; i < KB_BITMAP_BYTES; i++) {
if (bitmap[i] != 0) {
any_pressed = true;
break;
}
}
if (!any_pressed) {
*len = 0;
return NULL; /* 无按键,跳过本次发送 */
}
/* 打包 TLV:Tag + Length + Value */
uint8_t *p = tlv_buf;
*p++ = KB_TAG_KEYBOARD;
*p++ = KB_BITMAP_BYTES;
memcpy(p, bitmap, KB_BITMAP_BYTES);
*len = 2 + KB_BITMAP_BYTES; /* Tag(1) + Len(1) + Value(8) = 10 */
return tlv_buf;
}
无按键时返回 NULL 跳过发送,减少空口占用功耗。协议栈在此情况下不会在调度时隙中发送空包。
多按键同时按下的 N-KRO 处理
按键位图中每个 bit 对应一个按键,多键同时按下时对应位全部置 1,实现无冲突检测:
/* 按键位图 bit 映射示例 */
#define KEY_A (1 << 0) /* bitmap[0] bit 0 */
#define KEY_B (1 << 1) /* bitmap[0] bit 1 */
#define KEY_CTRL (1 << 0) /* bitmap[1] bit 0(修饰键区) */
/* Ctrl+A+B 同时按下时 bitmap 内容: */
/* bitmap[0] = 0x03 (bit0=1 A, bit1=1 B) */
/* bitmap[1] = 0x01 (bit0=1 Ctrl) */
位图方式天然支持所有按键同时按下(全键无冲),优于传统 USB HID 6-KRO 限制。
TX 回调实现 -- 手柄模式(多 TLV 同包)
手柄模式在一包内同时携带按键和轴值两个 TLV。
#define GP_TAG_BUTTONS 0x01
#define GP_TAG_AXES 0x02
static uint8_t *gamepad_tx_data_cb(uint16_t *len)
{
static uint8_t tlv_buf[1+1+2 + 1+1+8]; /* Buttons TLV + Axes TLV */
uint8_t *p = tlv_buf;
/* -- Buttons TLV -- */
uint16_t buttons = gp_read_buttons(); /* 读取 16 位按键状态 */
*p++ = GP_TAG_BUTTONS;
*p++ = sizeof(buttons);
*p++ = (uint8_t)(buttons & 0xFF);
*p++ = (uint8_t)((buttons >> 8) & 0xFF);
/* -- Axes TLV: X, Y, Z, RZ (各 int16) -- */
int16_t axes[4];
axes[0] = gp_read_axis_x();
axes[1] = gp_read_axis_y();
axes[2] = gp_read_axis_z();
axes[3] = gp_read_axis_rz();
*p++ = GP_TAG_AXES;
*p++ = sizeof(axes);
memcpy(p, axes, sizeof(axes));
p += sizeof(axes);
*len = p - tlv_buf;
return tlv_buf;
}
手柄模式多个 TLV 依次打包,总长度不超过
*len上限。按键 TLV 和轴值 TLV 的 Tag 不同,RX 端可据此区分处理。
RX 端接收与 USB HID 上报
Dongle 端收到 TLV 数据后解析,转换为 USB HID Report 上报 PC。
static void dongle_rx_cb(uint16_t conn_id, uint8_t *data, uint16_t len)
{
uint8_t *p = data;
uint8_t *end = data + len;
while (p < end) {
uint8_t tag = *p++;
uint8_t val_len = *p++;
uint8_t *value = p;
p += val_len;
switch (tag) {
case KB_TAG_KEYBOARD: {
/* 将 8 字节位图转换为 USB HID keyboard report */
uint8_t hid_report[8] = {0};
hid_report[0] = value[0]; /* 修饰键 */
hid_report[1] = 0x00; /* 保留 */
/* bitmap 中按下的键映射到 hid_report[2..7] 的 keycode */
convert_bitmap_to_hid_keys(value, hid_report);
usb_hid_send_report(hid_report, sizeof(hid_report));
break;
}
case GP_TAG_BUTTONS: {
uint16_t buttons = value[0] | (value[1] << 8);
/* 转换为 USB Gamepad Report */
usb_gamepad_send_buttons(buttons);
break;
}
case GP_TAG_AXES: {
int16_t *axes = (int16_t *)value;
usb_gamepad_send_axes(axes[0], axes[1], axes[2], axes[3]);
break;
}
default:
osal_printk("[dongle] unknown TLV tag: 0x%02X\r\n", tag);
break;
}
}
}
RX 端按 TLV 格式逐单元解析。Tag 和 Length 组成变长边界,无需预先知道包内结构。USB HID Report 格式需与 PC 端 HID Report Descriptor 匹配。
按键扫描与去抖
GPIO 矩阵按键扫描需要加入去抖逻辑,避免一次按下触发多次报告:
#define DEBOUNCE_MS 5
static void gpio_matrix_scan(uint8_t *bitmap, uint8_t bytes)
{
static uint8_t prev_bitmap[8] = {0};
static uint32_t last_change_tick = 0;
uint8_t raw_bitmap[8] = {0};
gpio_read_matrix(raw_bitmap); /* 读取原始电平 */
if (memcmp(raw_bitmap, prev_bitmap, bytes) == 0) {
memcpy(bitmap, raw_bitmap, bytes); /* 状态稳定,直接返回 */
return;
}
/* 状态变化,检查去抖时间 */
uint32_t now = osal_get_tick();
if (now - last_change_tick < osal_ms_to_tick(DEBOUNCE_MS)) {
memcpy(bitmap, prev_bitmap, bytes); /* 未过去抖时间,保持旧状态 */
return;
}
/* 去抖通过,更新状态 */
last_change_tick = now;
memcpy(bitmap, raw_bitmap, bytes);
memcpy(prev_bitmap, raw_bitmap, bytes);
}
5ms 去抖窗口覆盖绝大多数机械按键的抖动时间。去抖期间的按键变化被忽略,直到稳定后才更新。