跳转至

HID 键盘

低延迟 HID -- 键盘 — SLE Low Latency 通用 TX/RX 模式

前置阅读:Hello SLEHID 鼠标

学习目标

  • 理解 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):

Tag=0x01 | Length=0x08 | Bitmap[0..7]

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

程序运行流程:

  1. 键盘端初始化 GPIO 矩阵、使能低延迟 TX 模式
  2. Dongle 端初始化 USB HID、使能低延迟 RX 模式
  3. 建立 SLE 连接后,双方调用 sle_low_latency_set() 启用调度
  4. 每 1ms(1KHz)协议栈调用 low_latency_tx_cb,键盘端扫描 GPIO、打包 TLV、返回数据
  5. 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]
fbb build ws63-liteos-app -p menuconfig
fbb build ws63-liteos-app

第二步:编译 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 去抖窗口覆盖绝大多数机械按键的抖动时间。去抖期间的按键变化被忽略,直到稳定后才更新。