传输插件(Low-level)
仅当 WebUSB、React Native BLE 或官方原生参考无法覆盖你的宿主环境时,再考虑实现底层传输插件。选择这条路径后,设备枚举、连接生命周期、数据收发和消息重组都需要由你的应用负责。
底层传输插件的作用,是把你的原生通信能力适配成 SDK 能理解的 LowlevelTransportSharedPlugin 接口。这样即使应用使用 Swift、Kotlin、Flutter 或其他技术栈,也可以复用上层硬件 SDK 的请求、事件和返回值模型。
快览
BLE 标识表
| 角色 | UUID |
|---|---|
| 服务 UUID | 00000001-0000-1000-8000-00805f9b34fb |
| 写入特征 | 00000002-0000-1000-8000-00805f9b34fb |
| 通知特征 | 00000003-0000-1000-8000-00805f9b34fb |
BLE头部参考
下面是一段 BLE 通知数据的头部参考,SDK 需要从这里识别消息类型和载荷长度:
原始数据: 3F 23 23 00 04 00 00 00 0C 1A 0A ...
│ │ │ │ │ │ │ └─ Protobuf 载荷开始
│ │ │ │ │ └────────┴─ 载荷长度: 0x0000000C = 12 字节
│ │ │ └──┴─ 消息类型: 0x0004
└──┴──┴─ Magic: 0x3F 0x23 0x23 (固定标识)
判断头部包的关键规则:前 3 字节必须是 3F 23 23。
可以参考这些原生接入参考理解插件如何落地:
- Android 接入示例:在 WebView 中通过 JSBridge 对接 Nordic BLE。
- iOS 接入示例:WebView 侧经由 JSBridge 调用 CoreBluetooth。
- Flutter 接入示例:WebView 配合 platform channel 转发 BLE 能力。
插件接口定义(LowlevelTransportSharedPlugin)
export type LowLevelDevice = { id: string; name: string };
export type LowlevelTransportSharedPlugin = {
enumerate: () => Promise<LowLevelDevice[]>;
send: (uuid: string, data: string) => Promise<void>;
receive: () => Promise<string>;
connect: (uuid: string) => Promise<void>;
disconnect: (uuid: string) => Promise<void>;
init: () => Promise<void>;
version: string;
};
WebUSB/BLE差异
WebUSB 和 BLE 的帧处理方式并不一样。实现插件时最容易出错的地方,就是把两者当成同一种 64 字节分片协议处理。
| 对比项 | WebUSB (桌面) | BLE (原生移动端) |
|---|---|---|
| 数据包大小 | 固定 64 字节 | 可变长度,取决于 MTU |
| 每个包前缀 | 每个包都带 0x3F | 只有首包带 0x3F 0x23 0x23 |
| 填充 | 零填充到 64 字节 | 无需填充 |
receive() 返回 | 每次一个 64 字节帧 | 完整重组后的消息 |
WebUSB协议(桌面)
WebUSB 使用固定 64 字节帧。每一帧都有明确的前缀和填充规则,上层 SDK 会按照该格式完成消息组装。
Magic bytes 说明:
0x3F 0x23 0x23是协议固定标识,用于确认一个完整消息的起始位置。
首帧:
| Offset | Length | Content |
|---|---|---|
| 0 | 3 | Magic: 0x3F 0x23 0x23 |
| 3 | 2 | 消息类型(大端 u16) |
| 5 | 4 | 载荷长度(大端 u32) |
| 9 | 55 | 前 55 字节 protobuf(零填充) |
后续帧:
| Offset | Length | Content |
|---|---|---|
| 0 | 1 | Magic: 0x3F |
| 1 | 63 | 剩余 protobuf 字节(零填充) |
BLE协议(原生端)
BLE 通过通知回调返回可变长度数据块。插件侧必须先把多段通知重组为完整消息,再从 receive() 返回给 SDK。
包格式
头部块(每条消息的第一个包):
┌─────────┬──────────────┬──────────┬──────────────┬─────────────┐
│ Byte 0 │ Byte 1-2 │ Byte 3-4 │ Byte 5-8 │ Byte 9+ │
├─────────┼──────────────┼──────────┼──────────────┼─────────────┤
│ 0x3F │ 0x23 0x23 │ Type │ Length │ Payload... │
│ (Magic) │ (Magic) │ (大端u16)│ (大端u32) │ │
└─────────┴──────────────┴──────────┴──────────────┴─────────────┘
后续块: 只携带剩余载荷数据,不再带协议前缀。
头检
function hasSharedTransportHeader(data: Uint8Array): boolean {
return (
data.length >= 9 &&
data[0] === 0x3f && // ASCII '?'
data[1] === 0x23 && // ASCII '#'
data[2] === 0x23
); // ASCII '#' terminator
}
读载荷长(大端)
function readUint32FromHeader(buffer: Uint8Array, offset: number): number {
return (
((buffer[offset] << 24) |
(buffer[offset + 1] << 16) |
(buffer[offset + 2] << 8) |
buffer[offset + 3]) >>>
0
);
}
// 参考: payloadLength = readUint32BE(data, 5)
重组参考
class BleMessageAssembler {
private buffer: number[] = [];
private expectedLength = 0;
private resolve: ((hex: string) => void) | null = null;
onNotification(data: Uint8Array): void {
if (this.isHeader(data)) {
this.expectedLength = this.readUint32BE(data, 5);
this.buffer = [...data.subarray(3)]; // 略过 3F 23 23
} else {
this.buffer.push(...data);
}
// 检查是否收齐: buffer = Type(2) + Length(4) + Payload
if (this.buffer.length - 6 >= this.expectedLength) {
const hex = this.toHex(new Uint8Array(this.buffer));
this.buffer = [];
this.expectedLength = 0;
if (this.resolve) {
this.resolve(hex);
this.resolve = null;
}
}
}
receive(): Promise<string> {
return new Promise((resolve) => {
this.resolve = resolve;
});
}
private isHeader(d: Uint8Array): boolean {
return d.length >= 9 && d[0] === 0x3f && d[1] === 0x23 && d[2] === 0x23;
}
private readUint32BE(b: Uint8Array, o: number): number {
return ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
}
private toHex(arr: Uint8Array): string {
return Array.from(arr)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
}
异常参考
// 错误参考片段:又把 BLE 消息切回 64 字节帧。
receive() { return this.messageQueue.shift() }
// 建议做法:等待完整重组完成后再返回消息。
receive() { return this.completeMessagePromise }
// 尽量避免:频繁做字符串拼接会带来更高内存开销。
let hexBuffer = "";
hexBuffer += dataViewToHex(chunk);
// 更建议:先累计字节数组,收齐后再转成 hex。
let payloadBytes: number[] = [];
payloadBytes.push(...chunk);
初始化范例
import ukeySdk from "@ukeyfe/hardware-common-connect-sdk";
const plugin = {
enumerate: () => Promise.resolve([{ id: "foo", name: "bar" }]),
send: (uuid, data) => {
/* 写入十六进制数据 */
},
receive: () => {
/* WebUSB: 返回 64 字节帧 // BLE: 返回完整消息 */
},
connect: (uuid) => {
/* BLE Android: 记得先完成配对! */
},
disconnect: (uuid) => {
/* 释放连接资源 */
},
init: () => {
/* 初始化 BLE 相关能力 */
},
version: "UKey Wallet-1.0",
};
ukeySdk.init({ env: "lowlevel", debug: true }, undefined, plugin);
后续
- 安卓原生蓝牙 — 了解配对、延时和重试策略。
- iOS原生蓝牙 — 查看 CoreBluetooth 如何接入插件接口。
- Flutter原生蓝牙 — 参考 platform channel 与 BLE 的桥接方式。