面向:要把真实 BLE 健康设备(血压计 / 血糖仪 / 血脂仪)接进 App 的开发者。
平台层基建(uni-app 在鸿蒙没有蓝牙 API、UTS 插件 + 同名垫片、产物一致性、监听与写队列)已在系列第一篇 uni-app 跑上鸿蒙,发现蓝牙全瞎 讲完,本篇不重复,只在需要处回指(文中「第一篇 §x」指该文小节)。
文中厂商名、包名、接入标识、服务/特征 UUID、命令码与域名均为代称或占位符;设备统一称「BP 设备」(一款上臂式电子血压计)。
TL;DR
- 厂商 SDK 是纯 JS 的,蓝牙全靠注入的宿主对象------第一篇补的垫片正好当这个宿主,协议逻辑一行没移植。
- 真正花时间的三件事都不在 BLE:接入标识用错值只回一句「鉴权失败」;广播数据的语义在鸿蒙与安卓不同,导致 MAC 取错;读数包是三个包合成的,少收一个就字段不全。
- 一次完整测量的包序是可枚举的:握手 → 设置时间 → 开始测量 → 归零 → N 个过程包 → 读数 → 关机。把它当成状态机来写 UI,比把 BLE 当文件读写要靠谱得多。
1. 分工:SDK 管协议,我们管宿主
先明确谁做什么,否则很容易在「该改 SDK 还是改我们代码」之间反复横跳:
| 事项 | 归属 |
|---|---|
| 广播过滤、GATT 建连、服务发现、订阅、读写 | 我们(垫片 + 原生插件,见第一篇) |
| 命令字节拼装、包解析、字段换算、厂商云签名 | 厂商 SDK |
接入标识(appKey / 接入 ID)与设备 MAC 的组合 |
我们提供 → SDK 使用 |
| 测量流程 UI、字段回填、范围校验、入库 | 我们 |
SDK 对外暴露的是一个「适配器」对象(按协议族区分,我们工程里有三种设备各对应一个),核心方法只有三个:
ts
adapter.createBLEConnection(deviceId, callback, host, timeout, authParams);
adapter.parseData(res); // BLE 通知回调原样传进去,返回 { code, msg, data }
adapter.disconnect(...) / 其它生命周期方法
第三个参数是宿主 ------BLE API 的提供方。安卓/iOS 传 uni,鸿蒙传第一篇那个同名垫片:
ts
// 平台间接层:#ifdef APP-HARMONY → harmonyBle,否则 → uni
adapter.createBLEConnection(device.deviceId, onConnectCb, bleApi, 20000, authParams);
改这一个参数,SDK 内部所有 createBLEConnection / getBLEDeviceServices / notifyBLECharacteristicValueChange / writeBLECharacteristicValue 就都落到原生插件上了。这是当初选「补齐 API 而不是移植协议」的最大收益点。
2. 开工前先修的两条地基
2.1 SDK 里的 wx 在 App 端不存在
厂商 SDK 的 HTTP 层写死了小程序形态:
js
// SDK 内部(示意)
wx.request({ url: '<vendor-iot>/api/.../sdk/authentication-v2', ... });
// H5 分支才走 fetch
App 端没有 wx,于是鉴权请求直接不发------表现是连接「成功」但握手永远等不到回包。我们的处理是在 composable 顶部做一次全局兜底:
ts
// SDK 内部使用 wx.request,在 APP 环境中 wx 不存在,需要 polyfill 为 uni
if (typeof (globalThis as any).wx === 'undefined') {
(globalThis as any).wx = uni;
}
一行代码,但它属于「不加上就完全看不懂为什么会失败」的那类问题。接第三方小程序 SDK 到 App 时,先全文搜一遍 wx. 和 my.。
2.2 多设备要各自一份适配器状态
同一个协议适配器是带内部状态的模块对象 (缓存 authParams、testResult、分包数组)。同时连两台设备时若共用,B 设备的读数会覆盖 A 设备未完成的状态机。所以实例化时做深拷贝,并把 SDK 依赖的状态字段重置:
ts
adapter = deepCloneAdapter(BaseAdapter); // 复制自身属性 + 原型方法
adapter.array = []; // 分包缓冲
adapter.authParams = {};
adapter.systemInfo = {};
deepCloneAdapter 里两个细节:函数直接赋值(无状态),以及必须把原型链挂回去 (Object.setPrototypeOf),否则 SDK 的方法全丢。深拷贝失败还有两级降级(Object.create + 重置 → 直接共享并打警告),保证至少能连上。
3. 鉴权:一句「鉴权失败」背后有三个原因
SDK 在建连前后会向厂商 IoT 平台要一个签名,请求体形如:
json
{ "appIdentifier": "<接入标识>", "nonce": "<随机串>", "timestamp": 1730000000000,
"terminalType": 3, "sign": "<按参数排序 + key 拼接后的签名>" }
authParams 由我们提供:
ts
const authParams = { ...VENDOR_AUTH, deviceMac: device.macAddr }; // appKey / 接入ID / 设备MAC
踩到的三个坑,按「浪费时间」排序:
坑 1:接入标识不是你以为的那个。
appIdentifier 必须是厂商后台登记的值。我们的包里 AppID 是鸿蒙形态的新 ID,直接拿它去请求,厂商回:
json
{ "code": <VENDOR_ERR>, "msg": "App Identifier不正确" }
而 SDK 把这个响应统一压成了 { code: 1, msg: "鉴权失败" }。文案不具指向性 ,看上去像密钥错了,于是会去反复核对 appKey------白折腾。
通用规则:第三方平台的接入标识只能用对方后台登记的,不能因为「都是 AppID」就复用当前包名/ID。跨端项目尤其注意:同一款 App 在鸿蒙可能是另一个 ID。
坑 2:看不到请求体就没法排。
既然 SDK 会把响应改写,就绕开它直接取证------临时包一层 uni.request 打原始响应:
ts
// 诊断用:monkey-patch,破案后必须移除
const orig = uni.request;
uni.request = function (opts: any) {
const s = opts.success;
opts.success = (res: any) => {
console.log('[DIAG resp] statusCode=', res.statusCode, 'raw=', JSON.stringify(res.data));
s?.(res);
};
return orig.call(uni, opts);
};
这一层包patch 是整套排障里性价比最高的一招:它把「鉴权失败」这种黑盒结论换成了真实的业务码与真实请求体。注意它是临时诊断代码,定位完就删------我们删得晚了一次,导致后来有人以为线上真有这个日志。
坑 3:MAC 既是数据字段又是鉴权入参。
很多人以为广播里的 MAC 只用来展示。这里它是 sign 的参与项,取错=鉴权错。而取 MAC 的函数是基于安卓语义的启发式:
ts
export function extractMacAddr(advertisData: ArrayBuffer, bleName: string): string {
const hex = ab2hex(advertisData).toUpperCase().replace(/(.{2})/g, '$1:');
// 特定型号(身高体重秤)走单独分支:取倒数 21~3 字符再反转
return hex.substring(hex.length - 18, hex.length - 1); // 默认:取末尾 6 字节
}
「取末尾 6 字节」的前提是 advertisData 等于厂商自定义数据段 。第一篇 §7 提过跨桥丢 byteLength,修好之后又露出第二个语义差:
| 平台 | advertisData 内容 |
|---|---|
| Android / iOS(uni 语义) | 只给 AD type=0xFF 的厂商段(companyId 2 字节 LE + 私有 payload) |
HarmonyOS(原生 ScanResult.data) |
整段原始广播,多个 AD 结构拼接 |
于是鸿蒙下「取末尾 6 字节」取到的是广播尾部垃圾,MAC 错 → 鉴权错 → 连不上,三件事看起来毫无关联。修法是在垫片层把 AD 结构解析出来,只回传厂商段,语义与安卓对齐:
ts
// 遍历 AD 结构:[len][type][payload...]
while (i < bytes.length) {
const adLen = bytes[i];
if (adLen === 0) break;
if (bytes[i + 1] === 0xff) {
// 厂商自定义段:companyId(2B LE) + payload
const payload = bytes.slice(i + 2, i + 1 + adLen);
return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength);
}
i += adLen + 1;
}
这条经验值得单独记:跨端补齐 API 时,「能回调」只是及格线,字段语义是否与原平台一致要逐个对齐。
4. 一次测量的完整时序
BP 设备的 GATT 拓扑很典型:一个主服务 + 一个通知特征 + 两个写特征(不同命令走不同特征,SDK 内部按标志位选择)。下面用 S / N / W1 / W2 代称。
扫描过滤(广播名含设备名前缀)→ 提取 MAC → 建连(GATT)
→ getServices(S) → getCharacteristics(S) → 订阅 notify(N)
→ 写握手命令(W?) ← SDK:anxSentOrder,失败 1200ms 自重发
→ 设备回「设置时间成功」 ← 时间同步走的是广播写,无响应
→ 设备回「开始测量」 ← 用户按下测量键 / 设备自动开始
→ 「归零结束」
→ 过程数据 ×N ← 压力值逐包上报,画充气/放气曲线
→ 测量日期 + 测量时间 + 读数字段 ← 三个包在 SDK 内合成一个结果
→ 抛出 code=<读数>,字段齐全
→ 「关机」 ← 设备测完自动断开
三个容易误判的点:
- 握手是幂等重试的 。SDK 发握手命令后会自等回包,超时 1200ms 重发一次。如果底层没有写队列,这里的重发会和首次写并发 → 第一篇 §9.3 的
The operation is busy。也就是说:SDK 的健壮性设计会放大底层的问题,别只盯着 SDK 看。 - 通知要同时支持 notify 与 indicate。协议里两类特征都有;插件侧若给每个特征各注册一次原生监听,同一条通知会被派发多次(第一篇 §9.2)。
- 写完不等响应。「设置时间」这类命令走无响应写,SDK 不会为它等待确认,所以它可能和下一条命令挤在同一时刻。
5. 包语义:把 parseData 的输出当成事件流
adapter.parseData(res) 返回 { code, msg, data }。code 是设备命令字节的十六进制表示,BP 设备用到的这批(按我们页面的处理):
code |
含义 | 页面处理 |
|---|---|---|
b1 |
开始测量 | toast「开始测量」 |
b4 |
归零结束 | toast「归零完成」 |
b5 / bb |
电量 / 低电量 | 低电量提示 |
b7 |
过程数据 | 取包内固定偏移的压力值,刷新曲线 |
bd |
测量日期 | SDK 内部缓存,不抛给页面 |
be |
测量时间 | SDK 内部缓存,不抛给页面 |
bc |
设置时间成功 | 静默 |
04 |
测量结果 | 回填高压/低压/脉搏 |
b9 |
测量错误 | 直接展示 data.result 文案 |
ba |
设备关机 | toast「已关机」 |
5.1 过程数据要收,哪怕你只关心结果
b7 每包带一个压力值,一次测量会有几十包。它有两个用处:一是 UI 上「正在充气/正在放气」的实时反馈(否则用户以为 App 卡死);二是排障时的链路证据------过程包能收到,说明订阅、通知派发、跨桥二进制都是通的。
实测一轮完整测量的日志形状(数值与包序):
29 包 code=b7,压力值 45 → 150 → 61(充气到峰值再放气归零)
1 包 code=04:高压 101 / 低压 66 / 脉搏 74 / 房颤标志 0
parseData 共调用 34 次,其中 4 次返回非对象
5.2 读数包是「三包合一」
04 之所以字段齐全,是因为 SDK 把前面收到的 bd(日期)和 be(时间)缓存在适配器里,等读数包到达时合成 time / formatTime,再把 code 改写成 04 抛出。结构是:
ts
data = {
type: 'BP',
result: { bloodMeasureHigh, bloodMeasureLow, checkHeartRate, isAfib },
formatTime,
};
两个推论:
- 适配器实例的状态不能共享(§2.2),否则 A 设备的日期会拼进 B 设备的读数。
- 房颤标志位不是单独一个字节,而是从状态字节里位移取出来的(形如
b[3] >> 6 & 1)。自己解包时别按整字节比对。
5.3 parseData 返回非对象是正常事件
那 4 次非对象返回全部出现在尾包(电量、关机、以及不完整的收尾包)。所以监听里必须先判型再走业务:
ts
const parsed = info.adapter.parseData(res);
if (parsed && typeof parsed === 'object') {
dataReceivedCallback.value?.(parsed, info.productCode);
} else {
// 尾包/无关包:记录后丢弃,不能当成解析失败报错
}
如果不判型,你会在每次测量结束时看到一串「解析失败」,然后花半天去怀疑二进制跨桥丢字节------这个问题我们已经替大家踩过了(第一篇 §7 才是真的丢字节,症状不同:长度不对、内容多尾)。
5.4 错误包要把厂商文案透出来
b9 的 data.result 是 SDK 内置的中文错误表,例如气袋没绑好、测量结果数值有误、进入超压保护、测量中干扰过大、气管弯折或气路堵塞、归零失败或传感器异常等。
ts
if (data.code === 'b9') {
uni.showToast({ title: `血压计: ${data.data?.result || '测量出错'}`, icon: 'none', duration: 3000 });
}
这些提示绝大多数是操作问题,不是 bug。把它们原样透出来,客服工单量会明显下降;换成「测量失败请重试」则是给自己制造现场复现的工作量。
6. 自动关机是正常事件,不是断连故障
BP 设备测完会主动断开并关机,同时抛 ba 与一次 GATT 断连。这件事在第一篇 §9.1 里是「幽灵失败」的导火索(连接成功的调用被后续的断连事件反噬)。在设备侧还要补两条:
- 状态机要显式承认「测量完成」与「连接断开」是两个维度。测量完成后连接断开是正常的,UI 应停在「本次数据已回填」,而不是回退到「未连接、请重试」。
- 重连只对手动路径开启 。我们给自动重连加了
silent参数:onShow走静默分支(不扫描、不弹提示),点按钮才允许「直连失败 → 扫一轮拿新鲜地址 → 重连一次」那条兜底链(第一篇 §10)。否则用户进一次页面就触发一轮 10 秒扫描 + 若干 toast。
血压计还有个细节:设备会在广播里轮换地址。同一台 BP 设备一天内出现过 4 种地址形态(随机静态 / public / RPA 各一),所以「绑定后直连」在鸿蒙必须配地址刷新兜底------这是设备行为决定的,不是框架缺陷。
7. 从读数到入库:页面层要做的四件事
回填不是 formData.xxx = data.xxx 一行,完整的最小闭环是:
ts
// 1) 设备 → 表单项 的映射(多设备共存时必须)
const deviceSupportMap: Record<string, string[]> = {
'<PC_BP>': ['bloodPressureHigh', 'bloodPressureLow', 'heartRate'],
'<PC_LIPID>': ['totalCholesterol', 'triglycerides', 'lowDensityLipoprotein', 'highDensityLipoprotein'],
'<PC_GLUCOSE>': ['bloodGlucose', 'uricAcid'],
};
// 2) 值归一:空值与设备占位符 "----" 转空串,去掉比较符
const processValue = (v: any): string =>
v === undefined || v === null || v === '' || v === '----' ? '' : String(v).replace(/[<>]/g, '');
// 3) 回填后可编辑 + 范围校验(用户可怀疑设备,必须允许改)
bloodPressureHigh: { min: 40, max: 250, label: '血压高压' },
bloodPressureLow: { min: 20, max: 150, label: '血压低压' },
// 4) 提交时按必填项校验,再入库
四条各自的理由:
- 映射表决定「哪些字段该被这台设备填」,也是 UI 上「这个输入项旁边要不要显示设备图标」的依据。
"----"是设备对「未测出/超量程」的表达,直接塞进输入框会得到字面量的----;而带比较符的><是超量程标记(血脂常见),入库前要去掉,否则数值字段非数字。- 允许人工修改是健康类数据的底线:设备会出错,用户有权改;改完仍要过范围校验。
- 多设备互斥 :点「重新连接」时要先把同类设备的旧连接逐个断开,并用一个
isAutoConnecting标志把手动/自动两条连接流互斥掉。只靠isConnecting挡不住------每轮结束它都会复位,而循环里还有 await 空窗,够两条流程并发跑(两个看门狗 + 两次扫描互相踩状态)。
血糖/血脂类设备还多一套「滴血流程」弹窗(03 请开始检测 / 0a 检测中 / 0b 设备关机),BP 设备不需要弹窗,toast 足够。别为了统一体验把弹窗套到所有设备------血压计的测量时长和交互节奏完全不同。
8. 一个容易忽略的副作用:SDK 会往厂商云上传
排查请求时才发现,除了鉴权,SDK 在读数产生后还会 POST 一份检测记录到厂商平台的上传接口(签名结构同上)。这带来两个必须决策的问题:
- 重复入库:如果我们自己也要把读数写进后端,数据就在厂商云和业务库各存一份。要不要保留厂商云那份、以哪份为准,需要产品/数据侧确认。
- 合规 :测量数据属于个人健康信息,上传到第三方平台需要隐私政策覆盖并取得相应授权。这属于「技术能做但要先问一句」的事,别等合规回头找。
我们的处理是:保持 SDK 默认行为不变(厂商侧数据用于设备追溯),业务库以页面提交为准,并在隐私政策中说明第三方设备数据上传。是否禁用 SDK 的上传分支,留给后续按厂商协议确认。
9. 怎么判断「链路通了」而不是「界面没报错」
界面显示成功可能是假象(第一篇 §9.1 的幽灵失败就是反面例子:先成功后误报)。设备侧我建议按这张表验收,每一行都要在日志里看到实物:
| 检查点 | 期望日志 | 断在哪 |
|---|---|---|
| 广播过滤命中 | 发现设备: <name>, MAC: <非空且格式正确> |
前缀匹配 / AD 语义(§3 坑 3) |
| 建连 | BLEConnectionStateChange state=2 → connect success |
地址失效(第一篇 §10) |
| 服务与特征 | getServices ok count=N / getCharacteristics ok |
GATT 未就绪就查 |
| 订阅 | notifyCharacteristic ok enable=true |
通知监听成对性(第一篇 §9.2) |
| 握手 | 设备回 bc/b1,无 busy、无重发 |
写队列缺失(第一篇 §9.3) |
| 过程 | b7 连续多包、压力值有升有降 |
通知丢包 / 二进制跨桥(第一篇 §7) |
| 读数 | 单个 04,高压/低压/脉搏/时间齐全 |
bd/be 状态被共享污染(§2.2) |
| 收尾 | ba + 一次断开,不弹失败 |
长事件未解注(第一篇 §9.1) |
| 业务 | 表单三格已填、可编辑、保存成功 | 映射/归一/校验(§7) |
最后一行才算通。这张表也正好是「BLE 基建 → 设备协议 → 业务闭环」的三层验收顺序,出问题时按行二分,比从头翻日志快得多。
10. 换一款设备要改哪些地方
这套结构的可复用性还可以再验证一次。新增一款 BLE 设备,需要动的清单:
- 设备表加一条:
productCode、广播名前缀、协议码、图标。 - 协议码 → SDK 适配器映射加一条。
deviceSupportMap加「这台设备能填哪些表单项」。handleDeviceData里补这款设备的code分支(状态提示 + 读数结构 + 错误文案)。- 表单字段的范围校验补新增项。
不需要动:原生插件、垫片、连接与重连逻辑、写队列、扫描兜底、日志与验收表。
这份「不需要动的清单」就是第一篇那套基建的全部意义。
11. 小结
设备侧对接的难点,其实一条都不是 BLE 的:
- 鉴权黑盒:接入标识用错、错误文案不指向真因、MAC 参与签名------三者叠在一起时症状是「连不上」,让人误查蓝牙。解法是包一层请求取证,把黑盒换成语义明确的原始响应。
- 语义对齐:跨端补齐 API 时,字段语义要与原平台一致;否则上层那段基于旧语义写成的启发式代码会莫名出错。
- SDK 的内部状态:读数由多包合成,适配器实例必须独占,跨设备共用会串数据。
- 交互设计要尊重设备行为:过程包要显示、厂商错误文案要透出、关机断开不能当失败、读数允许人工修改并再校验。
BLE 的坑在第一篇,设备的「人话」在这一篇。两篇合起来的结论是:硬件对接里最贵的成本不是解析协议,而是把「不报错」和「真的通了」区分开来。
附:本文脱敏说明(发布前删除本节)
| 真实值 | 文中处理 |
|---|---|
| 厂商名、SDK 包名、设备型号与商品名 | 统一称「厂商 SDK」「BP 设备」;协议适配器族名不出现 |
| 服务/特征 UUID(4 个) | 代称 S / N / W1 / W2,只说明拓扑与数量关系 |
| 命令码 | 保留 code 十六进制值(页面逻辑依赖它),但不给出原始命令字节与完整码表 |
appKey / 接入标识 / 平台错误码 / IoT 域名与接口路径 |
改为 <VENDOR_AUTH>、<接入标识>、<VENDOR_ERR>、<vendor-iot> 与省略路径 |
productCode(3 个数字编码) |
<PC_BP> / <PC_LIPID> / <PC_GLUCOSE> |
| 广播名前缀、真实 MAC / deviceId | 未出现;设备名字段写为 <name> |
| 实测读数(高压/低压/脉搏/房颤) | 保留为自测样本示例,发布时请标注「自测数据,非医疗建议」 |
| 内部模块与文件路径 | 只写相对路径(src/...),不含工程名与仓库名 |
另外提醒一次:接入密钥目前硬编码在前端配置文件中并已进入版本库,对外发稿时不要贴该文件截图;是否改为服务端下发或本地不跟踪,建议单独排期处理。
发布前全文检索:厂商域名、UUID 完整串、productCode 数字、接入 ID、包名、C:\Users\<你> 路径。