跳转到正文

V1 蓝牙对接代码实现 ​

以下示例代码按实际接入流程组织,供小程序开发参考。

打开蓝牙适配器 ​

ts
async function openBluetooth(): Promise<void> {
  const setting = await wx.getSetting();
  if (setting.authSetting['scope.bluetooth'] === false) {
    wx.showModal({
      title: '需要蓝牙权限',
      content: '请在设置中允许使用蓝牙,以便搜索设备',
      confirmText: '去设置',
      success: (res) => { if (res.confirm) wx.openSetting(); },
    });
    throw new Error('蓝牙权限被拒绝');
  }
  await new Promise<void>((resolve, reject) => {
    wx.openBluetoothAdapter({
      success: () => resolve(),
      fail: (err) => reject(new Error(err.errMsg || '打开蓝牙失败')),
    });
  });
  wx.onBluetoothAdapterStateChange((res) => {
    if (!res.available) { /* 提示用户开启系统蓝牙 */ }
  });
}

扫描与过滤 ​

ts
function startScan(): Promise<void> {
  return new Promise((resolve, reject) => {
    wx.startBluetoothDevicesDiscovery({
      allowDuplicatesKey: false,
      success: () => resolve(),
      fail: (err) => reject(new Error(err.errMsg || '开始扫描失败')),
    });
  });
}

wx.onBluetoothDeviceFound((res) => {
  for (const d of res.devices || []) {
    const name = d.localName || d.name || '';
    if (!name.startsWith('DTXZ_')) continue;          // ① 广播名前缀
    if (!hasBlufiService(d.advertisData)) continue;   // ② 服务 UUID 0xFFFF
    const online = parseOnlineStatus(d.advertisData); // ③ 状态字节(用于分流,可选)
    // 加入列表展示:设备名(含 MAC 尾缀)、RSSI、联网状态
  }
});

// Service Data(AD type 0x16)= [0xFF, 0xFF, 状态字节](UUID 小端 + 状态)
function hasBlufiService(advertisData?: ArrayBuffer): boolean {
  if (!advertisData) return false;
  const bytes = new Uint8Array(advertisData);
  let i = 0;
  while (i + 1 < bytes.length) {
    const len = bytes[i];
    if (len === 0) break;
    const type = bytes[i + 1];
    // 128 位 UUID 列表(type 0x07)中的 0xFFFF 服务(小端)
    if (type === 0x07 && len >= 4) {
      const uuid = bytes[i + 2] | (bytes[i + 3] << 8);
      if (uuid === 0xffff) return true;
    }
    i += 1 + len;
  }
  return false;
}

function stopScan(): Promise<void> {
  return new Promise((resolve) => {
    wx.stopBluetoothDevicesDiscovery({ complete: () => resolve() });
  });
}

function parseOnlineStatus(advertisData?: ArrayBuffer): boolean | undefined {
  if (!advertisData) return undefined;
  const bytes = new Uint8Array(advertisData);
  let i = 0;
  while (i + 1 < bytes.length) {
    const len = bytes[i];
    if (len === 0) break;
    const type = bytes[i + 1];
    if (type === 0x16 && len >= 4) {
      const uuid = bytes[i + 2] | (bytes[i + 3] << 8); // 小端
      if (uuid === 0xffff) {
        const status = bytes[i + 4];
        if (status === 0x01) return true;  // 已联网:连接后约 1s 内回 AT+ID=
        if (status === 0x02) return false; // 配网模式:需先下发 WiFi 凭据
      }
    }
    i += 1 + len;
  }
  return undefined; // 未知:连接后用探测判定
}

连接、服务发现与订阅通知 ​

ts
async function connectAndSubscribe(deviceId: string) {
  await stopScan(); // 连接前先停扫描(部分平台连接中不允许扫描)

  await new Promise<void>((resolve, reject) => {
    wx.createBLEConnection({
      deviceId, timeout: 10000,
      success: () => resolve(),
      fail: (err) => reject(new Error(err.errMsg || '连接失败')),
    });
  });

  // 定位 BLUFI 服务/特征(UUID 大小写不敏感)
  const { services } = await wx.getBLEDeviceServices({ deviceId });
  const svc = services.find((s) => s.uuid.toUpperCase().includes('FFFF'));
  const { characteristics } = await wx.getBLEDeviceCharacteristics({
    deviceId, serviceId: svc.uuid,
  });
  const writeChar = characteristics.find((c) => c.uuid.toUpperCase().includes('FF01'));
  const notifyChar = characteristics.find((c) => c.uuid.toUpperCase().includes('FF02'));

  // Android 协商 MTU(iOS 自动协商,此调用会失败,可忽略)
  wx.setBLEMTU({ deviceId, mtu: 512, complete: () => {} });

  // 先订阅通知,再发命令
  await new Promise<void>((resolve, reject) => {
    wx.notifyBLECharacteristicValueChange({
      deviceId, serviceId: svc.uuid, characteristicId: notifyChar.uuid,
      state: true,
      success: () => resolve(),
      fail: (err) => reject(new Error(err.errMsg || '订阅通知失败')),
    });
  });

  wx.onBLECharacteristicValueChange((res) => {
    if (res.deviceId === deviceId && res.characteristicId === notifyChar.uuid) {
      onNotify(new Uint8Array(res.value)); // → 帧解析,见“探测设备与帧解析”
    }
  });
}

BLUFI 帧构造与写入 ​

帧格式:type(1) | fc(1) | seq(1) | data_len(1) | data。

  • type:高 6 位 subtype,低 2 位包类型(0x0 控制帧 / 0x1 数据帧);
  • fc:明文模式 ENC=0、CHECK=0;
  • seq:每次连接从 0 开始,每帧 +1(分片每片 +1),设备按序校验;
  • 单次 wx.writeBLECharacteristicValue 上限 = MTU - 3(默认 20 字节),超长必须分片。
ts
let seq = 0;
const PACKAGE_CTRL = 0x00, PACKAGE_DATA = 0x01;
const SUBTYPE_STA_SSID = 0x02, SUBTYPE_STA_PASSWD = 0x03;
const SUBTYPE_CONN_TO_AP = 0x03, SUBTYPE_CUSTOM_DATA = 0x13;
const FC_FRAG = 0x10;

function buildFrame(pkgType: number, subType: number, data: number[]): number[] {
  const type = (subType << 2) | pkgType;
  return [type, 0x00, seq++ & 0xff, data.length, ...data];
}

// 分片:首片及中间片 fc 置 FRAG,data 区前 2 字节为整帧数据总长(大端);
// 末片 FRAG=0、不带总长。packetLimit = MTU - 3。
async function writeData(conn, pkgType: number, subType: number, data: number[],
  packetLimit = 20): Promise<void> {
  const contentLimit = packetLimit - 4 - 2; // 帧头 4 字节 + 分片总长头 2 字节
  let offset = 0;
  while (true) {
    const remaining = data.length - offset;
    const frag = remaining > contentLimit;
    const piece = frag
      ? [(data.length >> 8) & 0xff, data.length & 0xff,
        ...data.slice(offset, offset + contentLimit)]
      : data.slice(offset);
    const frame = buildFrame(pkgType, subType, piece);
    if (frag) frame[1] |= FC_FRAG;
    await new Promise<void>((resolve, reject) => {
      wx.writeBLECharacteristicValue({
        deviceId: conn.deviceId, serviceId: conn.serviceId,
        characteristicId: conn.writeCharId, value: frame as unknown as ArrayBuffer,
        success: () => resolve(),
        fail: (err) => reject(new Error(err.errMsg || '写入失败')),
      });
    });
    offset += frag ? contentLimit : remaining;
    if (!frag) break;
  }
}

const ascii = (s: string) => Array.from(s, (c) => c.charCodeAt(0) & 0xff);

探测设备与帧解析 ​

ts
// 连接成功、订阅完成后:
// ① 状态字节 = 0x02 → 直接进入配网流程(见“WiFi 配网”)
// ② 否则发送 AT+GETID,等待 AT+ID=<device_id>
await writeData(conn, PACKAGE_DATA, SUBTYPE_CUSTOM_DATA, ascii('AT+GETID'));

// 2 秒未收到回复 → 判定配网模式,进入“WiFi 配网”(已联网设备连接后约 1s 内会自动上报一轮,
// 此处的 GETID 只是双保险)

收到通知后的帧解析:

ts
function onNotify(bytes: number[]): void {
  const type = bytes[0], fc = bytes[1], seq = bytes[2], len = bytes[3];
  const pkgType = type & 0x03, subType = (type >> 2) & 0x3f;
  let payload = bytes.slice(4, 4 + len);
  if ((fc & FC_FRAG) !== 0) payload = payload.slice(2); // 分片帧剥掉总长头(拼合略)
  if (pkgType === PACKAGE_DATA && subType === SUBTYPE_CUSTOM_DATA) {
    const text = String.fromCharCode(...payload);
    if (text.startsWith('AT+ID=')) {
      const deviceId = text.slice(6).trim();
      // → 进入“云端绑定与触发重启”
    }
  }
}

WiFi 配网 ​

ts
// 依次下发(SSID ≤31 字节、密码 ≤63 字节,按 UTF-8 字节计):
await writeData(conn, PACKAGE_DATA, SUBTYPE_STA_SSID, ascii(ssid));
await writeData(conn, PACKAGE_DATA, SUBTYPE_STA_PASSWD, ascii(password));
await writeData(conn, PACKAGE_CTRL, SUBTYPE_CONN_TO_AP, []); // 设备原地连接

// 设备联网(一般 2~10 秒)后自动授权并上报 AT+ID=;也可再发一次 AT+GETID 主动拉取。
// 失败无上报:设整体超时 30~40 秒,超时提示“请检查 WiFi 密码或信号后重新下发”。
// 注意:设备仅支持 2.4GHz WiFi,5G 热点无法连接。

云端绑定与触发重启 ​

ts
// ① 调云端绑定接口(携带 Bearer mac_auth_key + external_user_id)
await bindDevice(deviceId, clientKey, externalUserId);

// ② 通知设备立即重启:再次发送 AT+GETID
await writeData(conn, PACKAGE_DATA, SUBTYPE_CUSTOM_DATA, ascii('AT+GETID'));
// 设备检测到已绑定 → 自动重启 → BLE 断开(属正常现象,勿提示“连接失败”)

wx.showToast({ title: '设备正在重启,请稍候', icon: 'none' });
wx.closeBLEConnection({ deviceId });
// 设备重启后进主程序;即使未发通知,设备 60 秒轮询兜底也会重启

文档中心 · docs.jimitu.top