项目仓库:https://atomgit.com/xiaohong-ai/XiaoHongKeyFlow

小鸿键盘的 v1 不是“用 Flutter 发一串文本命令”这么简单。它同时存在两条完全独立的链路:按键输入仍由 WS63 通过星闪发给 BS21E Dongle,再由 USB HID 进入电脑;配置工具则通过 HarmonyOS App 连接 WS63 的自定义 SSAP 服务。

这两个状态必须分开描述。App 显示“控制连接成功”,不等于无 Dongle 的系统级星闪键盘已经完成。

真机演示

小鸿键盘配置工具

配套演示视频约 107 秒,完整记录了设备扫描连接、键位选择、宏配置、参数同步以及设备端响应过程。静态封面用于快速查看最终界面,协议回读和物理按键结果则按后文的验收边界分别说明。

这段视频由实际设备操作录制,画面包含控制 App、键盘屏幕和完整操作过程。某一次灯光变化、某一次按键输入是否通过,还要和串口日志、App ACK 以及目标文本框结果对齐;107 秒的视频也不能替代 20 次重启持久化统计。

仓库怎么读才不会把两条链路混掉

XiaoHongKeyFlow/
|-- apps/xh_keyflow/lib/          Flutter 工作台和 NearLink 适配
|-- apps/xh_keyflow/rust/src/     FRB API、协议和本地状态
|-- apps/xh_keyflow/ohos/         HarmonyOS ArkTS 宿主
|-- firmware/ws63/                键盘端 WS63 固件
|-- firmware/releases/            经核验的发布固件
|-- docs/sle-keyboard-protocol.md SSAP 控制协议
|-- docs/REAL_DEVICE_E2E_VALIDATION.md
|                                  真机复验步骤和结论
`-- artifacts/validation/         日志、构建信息和逐项结果

apps/xh_keyflow/README.md 开头已经写得很明确:改键和屏幕设置走控制 App,快捷键输入仍经 USB 接收器。这个前提必须先读,否则后面很容易把 SSAP 通知误写成系统键盘输入。

四层架构,而不是一条 FFI

业务调用

MethodChannel

自定义 SSAP 0x2222 / 0x2323

编码命令

ACK / SETTINGS / KEY / HEARTBEAT

摄入控制行

星闪输入链路

USB HID

Flutter 改键工作台

FRB 生成绑定

Rust 协议 / 校验 / 配置仓库

HarmonyOS ArkTS NearLink

WS63 键盘固件

BS21E Dongle

HarmonyOS PC

FRB 没有直接操作 NearLink,因为扫描、配对和 SSAP 连接属于 HarmonyOS 系统能力,由 ArkTS 宿主更合适。Rust 的职责是协议和一致性:键位枚举、HID 宏、版本冲突、校验和、配置档案和设备事件。

FRB API 只暴露稳定语义

配置仍然是标准的三项:

rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust

Rust 初始化并维护统一设备状态:

#[flutter_rust_bridge::frb(init)]
pub fn init_app() {
    flutter_rust_bridge::setup_default_user_utils();
    store::ensure_initialized();
}

#[flutter_rust_bridge::frb(sync)]
pub fn encode_set_key_command(
    key_id: String,
    steps: Vec<KeyStep>,
) -> Result<String, String> {
    let pairs: Vec<_> = steps
        .iter()
        .map(|step| (step.modifier, step.keycode))
        .collect();
    crate::protocol::encode_set_key(&key_id, &pairs)
}

#[flutter_rust_bridge::frb(sync)]
pub fn ingest_sle_line(
    device_id: String,
    line: String,
) -> Option<DeviceEvent> {
    store::ingest_sle_line(&device_id, &line)
}

API 的输入是 key_id 和结构化 KeyStep,不是让 Dart 自己拼协议。以 O 键映射为 Ctrl+C、再输入 V 为例,Rust 会生成:

SET_KEY:2:2:01,06;00,19

具体编码来自真实实现:

pub fn encode_set_key(
    key_id: &str,
    steps: &[(u8, u8)],
) -> Result<String, String> {
    let ekey = id_to_ekey(key_id)
        .ok_or_else(|| format!("XHKB_INVALID_KEYMAP: 未知键位 {key_id}"))?;
    if steps.len() > MACRO_MAX_STEPS {
        return Err("XHKB_INVALID_KEYMAP: 超过 6 步宏".into());
    }
    let mut body = format!("SET_KEY:{ekey}:{}:", steps.len());
    for (index, (modifier, keycode)) in steps.iter().enumerate() {
        if index > 0 { body.push(';'); }
        body.push_str(&format!("{modifier:02X},{keycode:02X}"));
    }
    Ok(body)
}

最多 6 步、合法键位、保留键不可编辑等规则都在 Rust 校验,UI 无法绕过。

NearLink 请求必须先监听再写

设备回复可能很快。如果 write() 完成后才订阅事件,会偶发丢掉 ACK。Dart 侧的 NearlinkBridge.request 因此先建立订阅:

Future<String> request(
  String text, {
  required bool Function(String payload) matches,
  Duration timeout = const Duration(seconds: 2),
}) async {
  final completer = Completer<String>();
  late final StreamSubscription<String> subscription;
  subscription = events.listen((raw) {
    final payload = payloadFromEvent(raw);
    if (payload.startsWith('ERR:')) {
      if (!completer.isCompleted) {
        completer.completeError(StateError(payload));
      }
    } else if (matches(payload) && !completer.isCompleted) {
      completer.complete(payload);
    }
  });
  try {
    await write(text);
    return await completer.future.timeout(timeout);
  } finally {
    await subscription.cancel();
  }
}

matches 不能写成“任意 ACK 都算成功”。同一连接上可能还有心跳、按键通知或上一条命令的迟到响应,必须匹配本次命令、版本号和阶段。

一次改键为什么要做事务

若逐键写入 NV,第三个键失败时,设备上会留下半份新配置。项目采用 BEGIN / STAGE / COMMIT

NV Storage WS63 Firmware Rust Core Flutter App NV Storage WS63 Firmware Rust Core Flutter App loop [每个变更键] BEGIN_KEYMAP(expected_version) ACK BEGIN encodeSetKeyCommand(key, steps) SET_KEY... STAGE_KEY... ACK STAGE COMMIT_KEYMAP 校验整份配置并计算 checksum 一次持久化 ACK COMMIT(new_version, checksum) ingestSleKeymap(readback)

配置校验和使用 FNV-1a,把 schema、键位 ID、步数、modifier 和 keycode 全部纳入计算。提交前发现设备版本与编辑基线不同,就返回 XHKB_CONFLICT,而不是悄悄覆盖另一端的修改。

可以复现的实操路径

  1. 插入 BS21E Dongle,让输入链路保持原工作方式。
  2. 启动 HarmonyOS App,扫描并连接 XH-KB 控制服务。
  3. 读取 INFO、当前键位表和设置,确认协议主版本兼容。
  4. 在工作台选择可编辑的 O 键,配置最多 6 步 HID 宏。
  5. 同步配置,等待精确的事务 ACK,再回读版本和 checksum。
  6. 主动断开、重新扫描连接,确认控制通道恢复。

软件侧验证命令:

cd XiaoHongKeyFlow/apps/xh_keyflow
flutter pub get
flutter_rust_bridge_codegen generate
cargo test --manifest-path rust/Cargo.toml
flutter test

cd ../..
python3 tools/verify_ws63_releases.py

烧录属于另一个风险级别。只能给键盘本体 WS63 烧写已核验的 load-only 包,不能把 WS63 与 BS21E Dongle 混为一个目标,也不能用 all 包覆盖 NV、Bootloader、校准和配对数据。

把一次 O 键改键拆开看

假设当前配置版本是 2,要把 O 键改成两步宏。App 不能先在本地把卡片显示成“已同步”,正确顺序应该是:

1. GET_KEYMAP
   <- KM:START:2
   <- KM:KEY:...
   <- KM:END:<checksum>

2. BEGIN_KEYMAP:2
   <- ACK:BEGIN_KEYMAP:2

3. STAGE_KEY:2:2:01,06;00,19
   <- ACK:STAGE_KEY:2

4. COMMIT_KEYMAP
   <- ACK:COMMIT_KEYMAP:3

5. 再次 GET_KEYMAP
   <- 版本 3,内容和 checksum 与提交结果一致

这里至少有三次校验。第一次校验编辑基线没有落后;第二次校验每个 staged 键合法;第三次在提交后回读整表。只收到 ACK:STAGE_KEY 不能说明 NV 已写入,只收到 ACK:COMMIT_KEYMAP 也不应省掉回读。

如果两台 App 同时编辑,后提交的一台拿着旧版本 2 发 BEGIN_KEYMAP:2,设备应直接返回冲突。正确处理是刷新远端配置,让用户重新确认,而不是自动用新版本号重试并覆盖别人的修改。

真机联调按三路证据对时

硬件联调时,我会同时保留三类记录:

时间线 记录内容 用来回答的问题
App 日志 扫描、连接、发送命令、收到通知 HarmonyOS 控制端做了什么
WS63 串口 SSAP RX/TX、事务、NV 返回值、复位原因 固件是否收到并执行
物理画面/输入框 屏幕、灯珠、真实按键输出 最终效果是否发生

三路最好使用同一轮测试的时间戳或递增序号。否则很容易拿上一轮成功的串口日志去解释下一轮失败的画面。对于 PING/PONG,记录里已经做过连续 20 次逐序号匹配;对于改键事务,也应保存 BEGIN、STAGE、COMMIT 和回读的完整一组,而不是只截最后一行。

常见故障怎么定位

能扫描、不能连接。 先确认扫描到的是 XH-KB 控制广播且 connectable=true,再看配对状态。不要去系统设置里把它当标准键盘连接。

连接后收不到通知。 核对 Service 0x2222、Property 0x2323 和 CCCD 写入结果。服务发现成功不等于通知订阅成功。

事务一直超时。 检查请求监听是否在 write 前建立,并核对返回 ACK 的阶段和版本;心跳不能误完成事务的 Completer。

提交成功但重启丢失。 看 COMMIT 的 NV 返回值和重启后的完整回读。运行时配置生效并不等于持久化成功。

App 显示控制已连接但按键没输入。 转去检查 WS63 到 BS21E Dongle 再到 USB HID 的输入链路。继续重连 SSAP 对这个问题没有帮助。

新旧固件兼容不要靠猜

协议读回若包含 KM:START:<version>,App 才能启用 BEGIN/STAGE/COMMIT;旧固件只返回 KM:START 时,可以降级为旧的逐条写入,但页面必须明确提示“非原子模式”。不能因为第一条新命令超时,就盲目按新旧两套协议各发一次,那会产生重复写入和难以解释的 ACK。

握手阶段建议记录协议主版本、配置 schema、固件版本和能力位。主版本不兼容时直接阻止编辑;新增可选设置则通过能力位隐藏,而不是只比较版本字符串。这样将来加入更多灯光场景或宏动作,旧 App 不会把未知值解析成默认 0 后再写回设备。

固件升级后第一件事也不是修改配置,而是读取整份快照并验证 checksum。确认新固件能理解旧 NV schema 后,才允许进入编辑工作台;迁移失败则保留原始数据和诊断,不应自动写一份默认配置覆盖现场键位。

把 PASS 和 PENDING 放在同一张表

验收项 当前结论 说明
HAP 安装、扫描和连接 PASS 真机已连接产品控制服务
Service/Property 严格匹配 PASS 0x2222 / 0x2323
20/20 PING/PONG PASS 序号匹配,无丢失重复
实体按键通知门槛 PASS Probe 证据超过 20 个唯一事件
BEGIN/STAGE/COMMIT 改键 PASS 版本递增并回读一致
亮度、休眠、旋转、灯光设置回读 PASS 协议链路已验证
单次重启 NV 回读 PASS 只能证明这一轮
20 次重启保持 PENDING NFR-06 尚未执行
屏幕和灯珠最终物理效果 PENDING 需现场逐档肉眼确认
USB Dongle 实际打字 PENDING 需目标输入框计数
无 Dongle 系统 SLE HID NO-GO / v2 当前 SDK 缺少可核验能力

这张表看起来没有一句“全部完成”痛快,但它能保护验收:已经通过的内容有证据,未完成的内容也不会被一段演示视频悄悄盖过去。

为什么我一直强调这是两条链路

键盘项目最容易出现的误会,是看到 App 已连接就认为键盘已经可以输入。控制链路和输入链路虽然都与 WS63 有关,目标却完全不同。控制链路传的是配置、状态和 ACK,最终落到 HarmonyOS 应用;输入链路传的是按键报告,经过 BS21E Dongle 后以 USB HID 身份进入系统。前者成功,只能说明 App 能管理键盘,不能推出后者正常。

这种区分不只是文章措辞。界面状态也应分别展示“控制已连接”和“输入接收器状态”,日志中使用不同事件名称,验收表更要分开签字。否则现场出现“改键成功但按键没输出”时,所有人只会围绕一个绿色连接图标反复排查,真正的 Dongle、配对或 HID 问题反而被忽略。

无 Dongle 的系统级星闪 HID 是另一项能力。设备能在系统设置中被发现,也不等于它实现了标准键盘服务。自定义 SSAP 有自己的 UUID 和通知格式,系统不会因为设备名称里写了 Keyboard 就把它当成 HID。把这项标为 v2/No-Go,不是降低目标,而是避免在缺少厂商接口和标准报告链路时给出无法兑现的承诺。

原子事务解决的是“半份配置”问题

用户在页面上一次点击同步,心理预期是整套键位要么全部生效,要么保持原样。若 App 逐条发送,前几项写成功、后几项因断连失败,设备就处在页面没有展示过的混合状态。更麻烦的是,这个混合状态如果已经写入 NV,重启后仍会保留,很难判断该回滚到哪一版。

BEGIN 阶段先比较版本,确认编辑基线没有过期;STAGE 只写入临时区域并逐项校验;COMMIT 再一次性替换运行时配置和 NV 数据。事务期间任何错误都丢弃 staged 内容,旧配置继续工作。这样 App 重试时面对的是一个已知版本,而不是猜测前面究竟成功了几条。

checksum 的意义也不只是防传输损坏。回读时版本号可能正确,但内容若因固件缺陷或存储问题发生变化,checksum 能快速暴露不一致。它必须覆盖 schema、键 ID、步骤数量和每个 modifier/keycode,不能只对可见字符串做校验。App 与固件使用同一套规范,才能把“看起来一样”变成可比较的结果。

真机调试比纯软件多出来的麻烦

硬件链路有大量软件测试看不到的时间因素。扫描窗口太短可能偶尔找不到广播;刚连接就写属性,服务发现或通知订阅可能还没完成;设备复位后重新广播需要时间;串口输出过多又可能改变任务调度。一次操作成功不能说明时序已经稳定,所以 Probe 阶段才会做连续 PING、按键通知和断开重连。

调试时我更信任递增序号,而不是日志的先后位置。App、WS63 和系统日志来自不同缓冲区,最终导出后时间可能有轻微偏差。请求序号能把一次 write、一次固件处理和一次 notify 串起来。若收到迟到 ACK,必须根据序号和阶段判断归属,不能因为它恰好出现在当前等待窗口里就算作成功。

板端重启是另一个不能轻描淡写的问题。Probe 期间虽然功能门通过,但曾观察到无法解释的复位。只要复位原因还没定位,产品化稳定性就不能直接写 PASS。后续需要记录复位寄存器、看门狗状态、任务栈和发生前的通信负载,区分供电、内存越界、断言和看门狗超时。功能能跑通与设备能长期稳定运行,是两张不同的验收单。

从视频到验收记录,中间还差一步整理

演示视频适合让读者快速理解产品:选择键位、修改宏、同步设置,键盘端画面随操作变化。但正式验收不能要求观看者自己从视频里猜结论。每一项操作都应对应起止时间、配置版本、命令序号、ACK、回读值和最终物理观察。这样即使后来需要复核,也能准确定位到视频中的一段和日志中的一组记录。

对屏幕亮度和灯光场景,协议回读只能证明固件接受并保存了数值。屏幕实际亮度是否变化、灯珠颜色是否正确,还需要现场画面或测量。相反,画面看起来变化了,也不能替代重启后的 NV 回读。物理效果和数据持久化互为补充,任何一方都不能代表另一方。

USB Dongle 打字验收则应选择一个干净文本框,清空输入法组合状态,按固定键固定次数,保存最终文本和计数。若宏包含 Ctrl+C 等快捷键,还要准备可重复的源文本与目标文本,避免剪贴板旧内容干扰。验收步骤越具体,结果越容易复现,也越不容易在现场争论“刚才是不是按漏了”。

做硬件项目时,诚实标记未完成反而更省时间

把 PENDING 写出来并不意味着已有工作没有价值。当前控制通道、原子改键、设置回读和单次重启已经形成可用基础;剩余三项也因此能够明确安排,而不是重新判断整个系统到底做到哪。真正危险的是用一个“整体完成”标签覆盖所有细节,等验收人员随机抽到未测项才发现证据不存在。

对于后续版本,建议每次固件、HAP 或硬件连接方式变化都重新建立候选版本号和证据目录。不要把不同日期、不同固件的成功日志拼成一个结果。只有同一候选在同一轮中完成控制、输入、重启和物理效果,才能把相应 PENDING 逐项转成 PASS。这种做法看起来慢,却能减少最后阶段返工。

当前真实状态

现场参数不能脱离固件版本保存

键位配置虽然看起来只是几个按键和宏,实际上它与固件支持的键码表、最大步骤数、修饰键规则和校验算法绑定。如果 App 只保存一份没有 schema 信息的 JSON,升级固件后再下发旧配置,就可能遇到同一个数值已经代表不同含义的情况。配置文件应记录 schema 版本、目标设备型号、固件版本范围和生成时间;导入时先做兼容性检查,再允许进入 STAGE。

兼容不代表所有字段都必须完全一致。新增可选字段可以由旧配置采用默认值,删除或改义的字段则必须走显式迁移。迁移结果应先在页面展示差异,例如某个旧宏超过新固件限制、某个键码已停用,让用户确认后再形成新版本。直接静默截断最容易留下“同步成功但行为不对”的现场争议。

多台键盘同时调试时,还要把设备身份写进证据。广播名可以重复,连接地址也可能在重启后变化,验收记录至少需要稳定设备 ID、硬件批次和固件版本。一次 ACK 属于哪台设备、一次回读是否来自刚才配置的样机,都应能从日志中确认。否则两台设备摆在桌上时,很容易出现页面连着 A、操作人员却在按 B 的情况。

这也是为什么文章把控制链路和输入链路分开描述:配置事务保证发送给目标设备的数据完整,却不自动证明 USB Dongle 最终向电脑产生了正确键盘输入。正式验收仍需把设备身份、配置版本、回读校验和实际按键结果放在同一轮记录中,才能形成闭环。

仓库记录的 v1 状态是:HarmonyOS App 扫描连接、SSAP 控制、键位原子事务、亮度/休眠/旋转/灯光设置、主动断开与重连、单次重启配置回读均为 PASS。

仍为 PENDING 的项目包括 20 次重启持久化、屏幕和灯珠物理效果肉眼确认、USB Dongle 在目标输入框中的实际打字验收。无 Dongle 的系统级 SLE HID 属于 v2,当前受设备侧 HID 资料限制,不能因为系统设置里看到了设备名就写成已经实现。

这个项目里,FRB 最合适的位置并不是“接管所有平台能力”,而是守住协议边界。ArkTS 连接设备,Rust 判定一条配置是否有效,Flutter 负责让用户看懂两条链路的真实状态。这样出了问题,至少不会把“控制连上了”和“键盘能打字”混成一句模糊的成功提示。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐