蓝牙接入(第 5 讲):HarmonyOS 通知订阅与短帧解码【共创稿事节】
八个字节能装下一个样本,也能装下一次难以察觉的误读。温度用无符号数读取,负值会变成很大的正值;大小端弄反,数值仍然可能落在看似正常的范围。与其等通知界面出现奇怪数字再猜测,本篇先把 BleWorkbench 的短帧定义成可单独验证的协议,再把蓝牙通知接到同一解析器上。

先给八个字节一个确定含义
继续使用前四篇的自定义服务及样本特征值。外设必须按本篇约定提供数据:第一字节为帧标记,第二字节为版本,第三字节为样本序号,第四字节为有效标志;随后两个字节存储带符号的小端温度,单位为百分之一摄氏度;第七字节存储整数湿度,最后一字节为前七字节异或值。这是本工程的短帧约定,不是系统预定义的温湿度服务格式。
序号使用一个字节,允许在二百五十五之后回到零;它便于观察样本变化,却不能单独证明所有通知连续送达。有效标志当前只接受数值一,其他取值全部拒绝,以免将未来版本新增的含义静默当成当前格式。温度范围约定为负四十至八十五摄氏度,湿度为零至一百,范围来自本项目协议约束,不代表某个传感器的精度指标。
异或校验只是本地数据格式检查,不能证明消息来自可信设备,也不能防止有意篡改。当前工程面向自有测试外设,不把这一个字节描述为安全认证。若产品需要认证设备或保护敏感数据,应采用相应的配对、加密与业务认证设计,不能因为校验通过就宣称数据可信。
先把解析器放入 common/SampleFrame.ets。它只接收 ArrayBuffer,不依赖页面、设备地址或者蓝牙权限,因此固定向量与真实通知可以使用完全相同的判断路径。长度检查先于任何字段读取,失败时抛出明确原因,调用方决定如何展示,解析器本身不修改界面。
// common/SampleFrame.ets
export interface SampleFrame {
sequence: number;
temperature: number;
humidity: number;
}
export function decodeFrame(buffer: ArrayBuffer): SampleFrame {
if (buffer.byteLength !== 8) throw new Error('帧长必须是 8 字节');
const bytes = new Uint8Array(buffer);
if (bytes[0] !== 0xA5 || bytes[1] !== 1) throw new Error('帧标记或版本不匹配');
if (bytes[3] !== 1) throw new Error('样本无效或包含未知标志');
let check = 0;
for (let i = 0; i < 7; i++) check ^= bytes[i];
if (check !== bytes[7]) throw new Error('帧校验不通过');
const temperature = new DataView(buffer).getInt16(4, true) / 100;
const humidity = bytes[6];
if (temperature < -40 || temperature > 85 || humidity > 100) {
throw new Error('样本超出协议范围');
}
return { sequence: bytes[2], temperature: temperature, humidity: humidity };
}
export function fixtureBuffer(): ArrayBuffer {
return new Uint8Array([0xA5, 1, 7, 1, 0x29, 0x09, 46, 0xAC]).buffer;
}
测试向量 A5 01 07 01 29 09 2E AC 中,温度部分按小端组合为十进制二千三百四十五,再除以一百得到二十三点四五;湿度字节为四十六,序号为七。最后一个字节由前七字节异或得到。图中的这些值是固定输入的计算结果,和附近有没有外设、是否真的收到通知没有关系。
DataView 的有符号读取使负温度保持正确符号,显式传入 true 固定小端顺序。不要为了让当前正值样本看起来正确,就把两个字节直接相加,也不要先将字节转成字符串再解析。二进制协议应先确定字节偏移、位宽与有无符号,再把结果转换成面向用户的文字。
固定向量还应该有一份独立计算过程。把温度、湿度和序号写在纸上,再按字段顺序组装字节,最后单独计算异或值,能避免生成器与解析器共享同一个错误。若两端都错误地使用大端,往返看起来仍然正常,只有拿明确规定的小端向量比较,才能真正验证协议约定。
序号暂时只展示,不参与丢包统计。接收两个相同序号可能是重复样本,也可能跨越了完整的回绕区间;仅凭这个字段无法推断准确丢失数量。想做可靠统计,需要采集周期、会话标识、回绕规则和重传协议等额外条件。本篇不会将简单的序号差值写成无线链路丢包率。
通知订阅有两层操作
BLE 接口参考区分远端通知使能与本地特征变化监听。setCharacteristicChangeNotification 请求开启目标特征值的通知能力,BLECharacteristicChange 则把之后的事件送到应用。两者都必须有相应的关闭路径,不能只解除页面回调就认定外设通知已经停止。
下面的 SampleFeed 使用第四篇筛选出的特征值对象,等待通知使能完成后注册本地监听。它不承诺使能瞬间的每一个样本都被保留,适用于展示后续最新样本的工具。若业务要求无遗漏地接收整个采集区间,需要另行设计历史读取、序号补偿或确认机制,不能用这个简单订阅接口作出保证。
// common/SampleFeed.ets
import { ble } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { SERVICE_UUID, SAMPLE_UUID } from './Protocol';
import { SampleFrame, decodeFrame } from './SampleFrame';
export class SampleFeed {
private client: ble.GattClientDevice | undefined = undefined;
private sample: ble.BLECharacteristic | undefined = undefined;
private opening: Promise<void> | undefined = undefined;
private closing: Promise<void> | undefined = undefined;
private active: boolean = false;
private enabled: boolean = false;
private listening: boolean = false;
private listener: ((value: ble.BLECharacteristic) => void) | undefined = undefined;
private epoch: number = 0;
private publish: (frame: SampleFrame) => void;
private report: (message: string) => void;
private changed = (value: ble.BLECharacteristic): void => {
if (!this.active) return;
if (value.serviceUuid.toLowerCase() !== SERVICE_UUID ||
value.characteristicUuid.toLowerCase() !== SAMPLE_UUID) return;
try { this.publish(decodeFrame(value.characteristicValue)); }
catch (error) { this.report((error as Error).message); }
};
constructor(publish: (frame: SampleFrame) => void, report: (message: string) => void) {
this.publish = publish;
this.report = report;
}
private async prepare(client: ble.GattClientDevice,
sample: ble.BLECharacteristic, generation: number): Promise<void> {
await client.setCharacteristicChangeNotification(sample, true);
this.enabled = true;
if (generation !== this.epoch) return;
const listener = (value: ble.BLECharacteristic): void => {
if (generation === this.epoch && client === this.client) this.changed(value);
};
this.listener = listener;
client.on('BLECharacteristicChange', listener);
this.listening = true;
this.active = true;
this.report('通知已启用,等待样本');
}
async start(client: ble.GattClientDevice, sample: ble.BLECharacteristic,
current: () => boolean): Promise<void> {
await this.stop();
if (!current()) return;
const generation = ++this.epoch;
this.client = client;
this.sample = sample;
const pending = this.prepare(client, sample, generation);
this.opening = pending;
try { await pending; }
finally { if (this.opening === pending) this.opening = undefined; }
}
async stop(): Promise<void> {
if (this.closing) return this.closing;
const pending = this.cleanup();
this.closing = pending;
try { await pending; }
finally { if (this.closing === pending) this.closing = undefined; }
}
forgetClosedClient(): void {
this.active = false; this.epoch++;
this.enabled = false; this.listening = false; this.listener = undefined;
this.client = undefined; this.sample = undefined;
}
private async cleanup(): Promise<void> {
this.active = false;
this.epoch++;
const pending = this.opening;
if (pending) {
try { await pending; }
catch (error) { console.error(`enable ${(error as BusinessError).code}`); }
}
const client = this.client;
const sample = this.sample;
if (!client || !sample) return;
let failure: Error | undefined = undefined;
if (this.listening && this.listener) {
try { client.off('BLECharacteristicChange', this.listener); this.listening = false; this.listener = undefined; }
catch (error) { failure = error as Error; }
}
if (this.enabled) {
try { await client.setCharacteristicChangeNotification(sample, false); this.enabled = false; }
catch (error) { failure = error as Error; }
}
if (failure) throw failure;
this.client = undefined;
this.sample = undefined;
}
}

opening 保存仍在进行的使能操作。停止请求先撤销接收资格和代次,再等待该操作结束,最后按真实持有的资源状态解除监听并关闭远端通知。否则,停止时看到 enabled 还是 false 就提前返回,使能 Promise 稍后成功,通知可能又被打开。这个竞态与帧格式无关,却决定订阅能否被可靠收尾。
回调同时核对服务 UUID 与特征值 UUID,避免其他特征事件进入当前解析器。解析失败只报告格式问题,不把异常样本写成零度、零湿度。界面保留上一份有效结果,并同时给出错误信息;如果产品要求显示数据过期,还应增加明确的时间与有效性规则,不能靠一个零值表示所有失败。
此处没有把通知、分包和字节流混成一个概念。当前协议要求每个特征值通知承载一个完整八字节样本,解析器不缓存半帧,也不把两个通知自动拼接。若外设协议改成分段传输,就必须增加序号、总长和组装规则,先修改协议契约,再修改解析器,而不是在长度错误后任意凑够八字节。
close 的调用方必须等通知清理阶段结束,再归还客户端。SampleFeed 将并发停止合并到同一 Promise,页面隐藏与组件销毁相继到来时,不会重复发起两次异步关闭。只有客户端 close 已成功,才能调用 forgetClosedClient 清除残留的本地引用;这个方法不是跳过清理的快捷方式,而是承认底层客户端已经不再存在后的收尾步骤。
若关闭通知时设备已经断开,关闭请求可能失败,但客户端仍应由连接拥有者继续归还。两种失败需要分别保留:远端关闭没有确认,和本地客户端没有释放,并不是同一件事。页面结束方法使用 finally 确保后一步仍然尝试;如果客户端关闭本身失败,引用保持在原拥有者中,后续可以再次请求清理。
页面明确区分数据来源
用下面的入口页连接外设,发现服务后订阅样本。保留前文所有公共模块,再加入 SampleFeed 与 SampleFrame。页面还提供“校验固定测试帧”按钮,即使暂时没有外设,也能观察固定输入对应的格式化结果;结果卡片会明确显示固定测试向量,不把它画成刚从无线链路收到的测量值。
// pages/Index.ets
import { common } from '@kit.AbilityKit';
import { ensureBle } from '../common/BleGate';
import { BleLink } from '../common/BleLink';
import { selectSample } from '../common/ServiceContract';
import { SampleFeed } from '../common/SampleFeed';
import { SampleFrame, decodeFrame, fixtureBuffer } from '../common/SampleFrame';
@Entry
@Component
struct Index {
@State address: string = '';
@State status: string = '尚未订阅';
@State temperature: string = '--';
@State humidity: string = '--';
@State sequence: string = '--';
@State source: string = '暂无样本';
@State connected: boolean = false;
@State busy: boolean = false;
private alive: boolean = true;
private visible: boolean = true;
private serial: number = 0;
private closing: Promise<void> | undefined = undefined;
private releaseError: string = '';
private link: BleLink = new BleLink((status: string) => {
if (this.alive && this.visible) {
this.status = status; this.connected = status === '已连接';
}
});
private feed: SampleFeed = new SampleFeed(
(frame: SampleFrame) => { this.show(frame, '蓝牙通知'); },
(message: string) => { if (this.alive && this.visible) this.status = message; }
);
private show(frame: SampleFrame, source: string): void {
if (!this.alive || !this.visible) return;
this.temperature = `${frame.temperature.toFixed(2)} °C`;
this.humidity = `${frame.humidity} %`;
this.sequence = `${frame.sequence}`;
this.source = source;
}
private async release(): Promise<void> {
try { await this.feed.stop(); }
finally { this.link.close(); this.feed.forgetClosedClient(); }
}
private async cleanup(): Promise<void> {
if (this.closing) return this.closing;
const job = this.release();
this.closing = job;
try { await job; this.releaseError = ''; }
catch (error) { this.releaseError = (error as Error).message; throw error; }
finally { if (this.closing === job) this.closing = undefined; }
}
private async end(): Promise<void> {
this.serial++;
await this.cleanup();
}
private async finish(): Promise<void> {
this.busy = true; this.connected = false; this.status = '正在结束';
const job = this.end();
const ticket = this.serial;
try {
await job;
if (this.alive && this.visible && ticket === this.serial) this.status = '已结束';
} catch (error) {
if (this.alive && this.visible && ticket === this.serial) {
this.status = `清理待重试:${(error as Error).message}`;
}
} finally {
if (this.alive && this.visible && ticket === this.serial) this.busy = false;
}
}
onPageHide(): void {
this.visible = false;
this.end().catch((error: Error) => console.error(error.message));
}
onPageShow(): void {
this.visible = true; this.connected = false;
if (this.closing) { this.finish(); }
else {
this.busy = false;
this.status = this.releaseError ? `清理待重试:${this.releaseError}` : '尚未订阅';
}
}
aboutToDisappear(): void {
this.alive = false;
this.end().catch((error: Error) => console.error(error.message));
}
private async connect(): Promise<void> {
if (this.busy) return;
const ticket = ++this.serial;
const target = this.address.trim();
this.busy = true;
try {
await this.cleanup();
if (!this.alive || !this.visible || ticket !== this.serial) return;
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const gate = await ensureBle(context);
if (!this.alive || !this.visible || ticket !== this.serial) return;
if (!gate.ready) { this.status = `${gate.permission} · ${gate.radio}`; return; }
this.link.connect(target);
} catch (error) {
if (this.alive && this.visible && ticket === this.serial) this.status = (error as Error).message;
} finally {
if (this.alive && this.visible && ticket === this.serial) this.busy = false;
}
}
private async subscribe(): Promise<void> {
if (this.busy || !this.connected) return;
const ticket = ++this.serial;
this.busy = true;
try {
const client = this.link.device();
const services = await client.getServices();
if (!this.alive || !this.visible || ticket !== this.serial) return;
if (client !== this.link.device()) return;
await this.feed.start(client, selectSample(services),
() => this.alive && this.visible && ticket === this.serial);
} catch (error) {
if (this.alive && this.visible && ticket === this.serial) this.status = (error as Error).message;
} finally {
if (this.alive && this.visible && ticket === this.serial) this.busy = false;
}
}
build() {
Column({ space: 14 }) {
Text('通知与短帧').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#3A304F')
TextInput({ text: this.address, placeholder: '当前设备地址' }).height(44)
.onChange((value: string) => { this.address = value; })
Row({ space: 12 }) {
Button('连接').backgroundColor('#7854A8')
.enabled(!this.busy && !this.connected && this.status !== '连接中' && this.address.trim().length > 0)
.onClick(() => { this.connect(); })
Button('订阅样本').backgroundColor('#7854A8').enabled(!this.busy && this.connected)
.onClick(() => { this.subscribe(); })
}
Column({ space: 12 }) {
Text(this.source).fontSize(14).fontColor('#796A8F')
Text(this.temperature).fontSize(32).fontColor('#7854A8')
Text(`湿度 ${this.humidity}`).fontSize(20)
Text(`样本序号 ${this.sequence}`).fontSize(16).fontColor('#796A8F')
}.padding(22).width('100%').backgroundColor(Color.White).borderRadius(20)
Button('校验固定测试帧').width('100%').height(42).backgroundColor('#E8E0F2').fontColor('#3A304F')
.onClick(() => { this.show(decodeFrame(fixtureBuffer()), '固定测试向量'); })
Text(this.status).fontSize(14).fontColor('#7854A8')
Button('停止并断开').width('100%').height(42).backgroundColor('#7854A8')
.onClick(() => { this.finish(); })
}.width('100%').height('100%').padding({ top: 36, left: 24, right: 24 })
.backgroundColor('#F7F2FB')
}
}
读取卡片时要同时看来源与状态。点击测试按钮只改变样本卡片,不会把订阅状态从尚未订阅变成已订阅,也不会创建 GATT 客户端。真实通知到达后,来源改成蓝牙通知。把这两条路径显式分开,能避免调试时残留的默认数值被当成设备工作正常的证据。
样本显示保留两位小数,是协议单位换算后的表达选择,不增加传感器的测量精度。比如输入是一个百分之一摄氏度单位的整数,格式化为两位小数只是保留该粒度。设备精度、校准误差以及采样周期需要设备资料与真实验证支持,应用不能从小数位数倒推出这些性质。
停止顺序也在页面中明确:先等待 SampleFeed 结束,再由 BleLink 关闭连接。页面把正在执行的清理合并为同一个 Promise,重新连接也先等待它结束;按钮的完成提示检查操作序号,旧停止请求不会覆盖新连接。页面隐藏或销毁时调用同一流程,销毁前撤销 alive,异步清理完成后不再写状态。完整的 Ability 前后台归属将在下一篇统一收口,当前片段的重点是通知拥有者与连接拥有者之间的先后关系。
用坏输入检验真正的边界
固定向量通过后,依次尝试七字节与九字节输入、错误帧标记、未知版本、无效标志和错误校验值。每种输入都应在相应检查处失败,不能产生半份业务结果。再构造负温度以及边界湿度,确认有符号读取、范围判断和小数格式化各自承担独立职责。
检验校验算法时,一次只改变一个条件。比如修改温度字节后不更新校验,应得到校验错误;修改温度并重新计算校验,再使数值超出范围,才是在验证范围检查。否则一份输入同时违反多个条件,只能说明最先执行的条件生效,不能证明后面的校验逻辑已经覆盖。
订阅路径则检查开始后立即停止、使能仍在等待时离开页面、重复点击订阅以及设备主动断开。停止期间 active 已经为假,迟到通知不会更新页面;待使能操作结束以后再完成通知关闭。真正无法关闭的情况应保留失败结果供上层处理,不伪装成一次干净的正常结束。

到这里,数据传输与数据解释已经分开:BLE 层交付一个属于目标特征值的字节数组,解析层决定它是否符合当前版本,页面负责展示来源与结果。最后一篇将这些模块接入统一的前台会话,让用户停止、页面离开、应用进入后台和重新连接遵守同一套资源归还顺序。
通知回调不适合直接承担复杂统计、文件写入和网络上传。这里的回调只筛选目标、解析短帧、发布普通结果,使执行路径保持短小。将来如果样本频率显著提高,应先测量页面更新负担,再选择节流或队列策略;不能仅凭蓝牙通知具有异步形式,就认定所有处理都已经离开界面线程。
最后检查数据显示中的失败含义。格式错误不能转换为数值零,连接断开也不能自动擦掉来源标签,让保留的旧样本看起来像刚刚收到。当前页面同时保留订阅状态与样本来源,适合联调观察;产品化时可以增加最近更新时间和过期提示,但这些字段必须来自实际事件或明确的规则,不能凭空填写。
参考资料
更多推荐

所有评论(0)