八个字节能装下一个样本,也能装下一次难以察觉的误读。温度用无符号数读取,负值会变成很大的正值;大小端弄反,数值仍然可能落在看似正常的范围。与其等通知界面出现奇怪数字再猜测,本篇先把 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;
  }
}

DevEco Studio 中的短帧解析与测试向量结果

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 层交付一个属于目标特征值的字节数组,解析层决定它是否符合当前版本,页面负责展示来源与结果。最后一篇将这些模块接入统一的前台会话,让用户停止、页面离开、应用进入后台和重新连接遵守同一套资源归还顺序。

通知回调不适合直接承担复杂统计、文件写入和网络上传。这里的回调只筛选目标、解析短帧、发布普通结果,使执行路径保持短小。将来如果样本频率显著提高,应先测量页面更新负担,再选择节流或队列策略;不能仅凭蓝牙通知具有异步形式,就认定所有处理都已经离开界面线程。

最后检查数据显示中的失败含义。格式错误不能转换为数值零,连接断开也不能自动擦掉来源标签,让保留的旧样本看起来像刚刚收到。当前页面同时保留订阅状态与样本来源,适合联调观察;产品化时可以增加最近更新时间和过期提示,但这些字段必须来自实际事件或明确的规则,不能凭空填写。

参考资料

Logo

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

更多推荐