微信小程序低功耗蓝牙(BLE)连接第三方设备

403 阅读5分钟

1. 蓝牙工作模式与通信协议

1.1 角色/工作模式

微信官方文档这个官方的文档一定要看

蓝牙低功耗协议给设备定义了若干角色,或称工作模式,我这里是手机作为中心设备/主机 (Central),第三方设备作为外围设备/从机 (Peripheral)

  • 中心设备扫描外围设备,与外围设备建立连接,使用外围设备提供的服务(Service)
  • 外围设备一直处于广播状态,等待中心设备的连接,外围设备不可主动发起搜索

1.2 通信协议

小程序蓝牙通信协议文档

主要的概念:

  1. 配置文件 (Profile),Profile 是被蓝牙标准预先定义的一些 Service 的集合;

  2. 服务 (Service),蓝牙设备对外提供的服务,可以使多个。wx.getBLEDeviceServicesapi获取;

  3. 特征 (Characteristic),每个 Service 包含 0 至多个 Characteristic。wx.getBLEDeviceCharacteristicsapi获取;

  4. 描述符 (Descriptor),Descriptor 是描述特征值的已定义属性,每个 Descriptor 由一个 UUID 唯一标识。下面代码中的可写特征writeCharacteristicId、可读特征readCharacteristicId、可监听通知特征notifyCharacteristicId,这3个就是

总结:每个蓝牙设备可能提供多个 Service,每个 Service 可能有多个 Characteristic,我们根据蓝牙设备的协议对对应 Characteristic 的值进行读写即可达到与其通信的目的

2. 前置准备

  1. 手机要打开蓝牙功能;

  2. android6.0以上版本,需要在手机上打开位置信息(GPS)功能,否则无法进行设备搜索;

  3. 小程序隐私协议中要开通蓝牙功能,并且说明使用蓝牙的目的(本地开发不需要,提交审核发版后需要);

  4. 在隐私协议中开通蓝牙功能后,然后在小程序中才可以使用wx.openBluetoothAdapter等api;

  5. 目标设备的蓝牙名称要知道,就和要连接蓝牙耳机一样,不然怎么连接。

3. 部分代码片段

3.1 检查蓝牙是否已打开并授权

可以在页面onLoad、onShow中调用

async function handleCheckBluetooth() {
  try {
    // 考虑到蓝牙功能可以间接进行定位,安卓 6.0 及以上版本,无定位权限或定位开关未打开时,无法进行设备搜索
    const systemInfo = await wx.getSystemInfo();

    if(!systemInfo) return Promise.reject();
    const {platform, bluetoothEnabled, locationEnabled} = systemInfo;

    if(!bluetoothEnabled) {
      platform === "android" 
        ? wx.showModal({
          title: "提示",
          content: "请打开手机蓝牙开关,否则无法搜索蓝牙设备,点击确定,前去打开(部分安卓机如果蓝牙开关已打开,则点击“允许新连接”)",
          success: (res) => {
            if(res.confirm) {
              // 安卓机直接跳转蓝牙设置页
              wx.openSystemBluetoothSetting();

            } else if(res.cancel) {
              wx.showToast({
                title: "已取消打开手机蓝牙",
                icon: "none"
              });
            }
          }
        })
        : wx.showModal({
            title: "提示",
            content: "请打开手机的蓝牙开关,否则无法搜索蓝牙设备",
            showCancel: false
          });
      
      return Promise.reject();
    }

    if(platform === "android" && !locationEnabled) {
      wx.showModal({
        title: "提示",
        content: "请打开手机的位置(GPS)开关,否则无法搜索蓝牙设备,请自行到手机系统设置中打开",
        showCancel: false
      });

      return Promise.reject();
    }

    const settingRes = await wx.getSetting();

    if (!settingRes.authSetting['scope.bluetooth']) {
      // 一旦用户明确同意或拒绝过授权,其授权关系会记录在后台,直到用户主动删除小程序。
      const authorizeRes = await wx.authorize({
        scope: 'scope.bluetooth'
      });

      console.log("authorizeRes:", authorizeRes);
      if(authorizeRes.errMsg && authorizeRes.endsWith("ok")) {
        return Promise.resolve();
      }
    } else {
      return Promise.resolve();
    }

  } catch (error) {
    console.error(error);

    if(error && error.errMsg && error.errMsg.endsWith("deny")) {
      wx.showModal({
        title: "提示",
        content: "当前小程序未授权使用蓝牙,点击确定,前往设置页面允许小程序使用蓝牙",
        success: (res) => {
          if(res.confirm) {
            wx.openSetting();
          }

          if(res.cancel) {
            wx.showToast({
              title: "您已拒绝蓝牙授权,无法使用蓝牙功能",
              icon: "none"
            });
          }
        }
      });
    }

    return Promise.reject();
  }
}

3.2 连接目标蓝牙设备

const targetDevicesName = "xxx"; // 目标蓝牙设备名称
const TARGET_DEVICE_SERVICES_UUID_55535343 = "55535343"; // 目标蓝牙设备某服务uuid的起始部分
let targetDeviceId = ""; // 目标蓝牙设备id
let timer = null; // 连接成功后向目标设备发送心跳包的定时器
let tryReconnectNum = 3; // 连接成功后,蓝牙突然断开,重连次数
let targetDevicesServices = []; // 目标蓝牙设备所有服务 + 特征

function sleep(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}

3.2.1 蓝牙关闭连接、停止搜索

退出页面,销毁时使用

// 销毁
async function handleDestroy() {
  timer && clearTimeout(timer);
  timer = null;
  
  wx.stopBluetoothDevicesDiscovery();
  if(targetDeviceId) {
    wx.closeBLEConnection({
      deviceId: targetDeviceId
    });
  }
  
  wx.closeBluetoothAdapter();
}

3.2.2 初始化蓝牙模块

async function handleOpenBluetoothAdapter() {
  try {
    const systemInfo = await wx.getSystemInfo();
      console.log(systemInfo);
      systemInfo.platform === "ios"
        ? await wx.openBluetoothAdapter({
          mode: "central"
        })
        : await wx.openBluetoothAdapter();

      return Promise.resolve();
  } catch (error) {
    console.error(error);
    if(error && error.errMsg === "openBluetoothAdapter:fail already opened") {
      return Promise.resolve();
    }

    return Promise.reject(error);
  }
}

3.2.3 扫描发现目标蓝牙设备

// 默认30s内未发现目标蓝牙设备,就停止扫描
function handleSerchTargetDevices() {
  let flag = true;

  return async (maxWaitTime = 30) => {
    if (maxWaitTime <= 0) {
      flag = false; 
      return "";
    } 

    console.log(`蓝牙搜索还剩${maxWaitTime}s`)
    
    if(flag) {
      const bluetoothDeviceRes = await wx.getBluetoothDevices();

      const bluetoothDeviceList = bluetoothDeviceRes.devices || [];
      if(Array.isArray(bluetoothDeviceList) && bluetoothDeviceList.length > 0) {
        const r = bluetoothDeviceList.find(item => item.name === targetDevicesName);

        if(r) {
          // 一旦发现目标设备,就停止扫描
          await wx.stopBluetoothDevicesDiscovery();
          
          flag = false;

          return r.deviceId;
        } else {
          await sleep(1000);
          maxWaitTime--;
          return await handleSerchTargetDevices()(maxWaitTime);
        }
      } else {
        await sleep(1000);
        maxWaitTime--;
        return await handleSerchTargetDevices()(maxWaitTime);
      }
    }
  }
}

3.2.4 连接目标蓝牙设备

async handleConnectBluetooth() {
  try {
    await handleDestroy();
    await handleOpenBluetoothAdapter();

    // 监听蓝牙适配器状态变化事件
    wx.onBluetoothAdapterStateChange((adapterStateRes) => {
      console.log('adapterState changed, now is', adapterStateRes);

      if(!adapterStateRes.available) {
        wx.showToast({
          title: "蓝牙被关闭,请打开蓝牙",
          icon: "none",
          duration: 3500,
        });

        timer && clearTimeout(timer);
      }
    });

    const systemInfo = await wx.getSystemInfo();
    const bluetoothAdapterStateRes = await wx.getBluetoothAdapterState();

    const f = systemInfo.platform === "devtools"
      ? (!bluetoothAdapterStateRes.adapterState.discovering && bluetoothAdapterStateRes.adapterState.available)
      : (!bluetoothAdapterStateRes.discovering && bluetoothAdapterStateRes.available);
    if(f) {
      const bluetoothDevicesDiscoveryRes =  await wx.startBluetoothDevicesDiscovery(); // 开始搜寻附近的
    }

    // 扫描发现目标蓝牙设备
    const deviceId = await handleSerchTargetDevices()(30);
    
    if(!deviceId) {
      wx.showToast({
        title: "未搜索到目标设备",
        icon: "none"
      });
      await handleDestroy();
      return Promise.reject("未搜索到目标设备");
    }

    targetDeviceId = deviceId;
    
    // 进行连接
    const BLEConnectionRes = await wx.createBLEConnection({
      deviceId: targetDeviceId,
    });
    console.log("BLEConnectionRes:", BLEConnectionRes);

    if(!BLEConnectionRes || (BLEConnectionRes && BLEConnectionRes.errCode !== 0)) {
      wx.showToast({
        title: "蓝牙设备连接失败",
        icon: "none"
      });
      
      return;
    } else {
      console.log("蓝牙连接成功");

      // 蓝牙断开后重新连接成功
      if(this.tryReconnectState === 1) {
        tryReconnectNum = 3;

        await handleSendConnectOrder(TARGET_DEVICE_SERVICES_UUID_55535343);
      }

      await handleGetBLEDeviceRSSI();
    }

    // 监听蓝牙连接状态
    wx.onBLEConnectionStateChange(async (res) => {
      if(!res.connected) {
        if(tryReconnectNum < 0) {
          wx.showToast({
            title: "蓝牙断开",
            icon: "error"
          });
          tryReconnectNum = 3;

          timer && clearTimeout(timer);
          wx.offBLEConnectionStateChange();
        } else {
          tryReconnectNum-=1;
          console.warn("蓝牙断开,正在尝试重新连接...");
          await handleConnectBluetooth();

        }
      }

      await handleGetBLEDeviceRSSI();
    });
  } catch (error) {
    console.error(error);
    return Promise.reject(error);
  }
}

3.3 获取目标设备蓝牙信号强度

async function handleGetBLEDeviceRSSI() {
  // https://developers.weixin.qq.com/community/develop/doc/0004a223bd8aa0b945aa302fc5b400?highLine=wx.getBLEDeviceRSSI
  // 单位是dbm,取值范围是[-127, 126]
  if(!!!targetDeviceId) return;
  try {
    const BLEDeviceRSSIRes = await wx.getBLEDeviceRSSI({
      deviceId: targetDeviceId,
    });
    const deviceRSSI = BLEDeviceRSSIRes.RSSI;
    // ...
  } catch (error) {
    console.error(error);
  }
}

3.4 获取目标蓝牙低功耗设备所有服务 (service)

async function handleGetBLEServices() {
  const BLEDeviceServicesRes = await wx.getBLEDeviceServices({
    deviceId: targetDeviceId,
  });
  console.log("目标设备所有服务", BLEDeviceServicesRes);
  if(Array.isArray(BLEDeviceServicesRes.services)) {
    targetDevicesServices = BLEDeviceServicesRes.services.map(item => ({
      uuid: item.uuid,
      characteristics: [] // 目标服务所有特征,见下面
    }));
  }
  
}

3.5 获取服务中的所有特征 (characteristic)

async function handleCharacteristic() {
  if(!Array.isArray(targetDevicesServices) || targetDevicesServices.length<= 0) return Promise.resolve();

  const iterator = targetDevicesServices[Symbol.iterator]();

  const run = async function() {
    const {value, done} = iterator.next();
    if(done) {
      return Promise.resolve();
    }

    const BLEDeviceServiceCharacteristicsRes = await wx.getBLEDeviceCharacteristics({
      deviceId: targetDeviceId,
      serviceId: value.uuid
    });

    const index = targetDevicesServices.findIndex(item => item.uuid === value.uuid);
    // 将每个服务下的特征添加到对应的服务下,方便页面中展示
    targetDevicesServices.splice(index, 1, {
      ...targetDevicesServices[index],
      characteristics: BLEDeviceServiceCharacteristicsRes.characteristics.map(item => item)
    });

    await run();
  }.bind(this);

  return await run();
}

3.6 向目标设备发送指令(根据你的实际业务修改)

这里是可以向目标设备发送操控指令的,且只有目标设备指令解析成功后,才可以下发下一个指令,不能连续发送指令,否则会导致指令解析失败

// 心跳指令
const heartUint8Array = new Uint8Array([0xFD, ..., 0x1E]);
// 下发数据指令
const sendDataUint8Array = new Uint8Array([0xFD, ..., 0xBB]);
// 下发开始指令
const sendDataStartUint8Array = new Uint8Array([0xFD, ..., 0xBA]);
// 下发停止指令
const sendDataStopUint8Array = new Uint8Array([0xFD, ..., 0xB9]);
// 发送连接指令
const sendDataConnectUint8Array = new Uint8Array([0xFC, ..., 0xBE]);

const deviceState = null; // 当前操作的指令

const ORDER_CONF = {
  connect: "connect", // 连接
  heart: "heart", // 心跳
  start: "start", // 开始
  stop: "stop", // 停止
  data: "data", // 下发数据
}

// ArrayBuffer转16进制字符串示例
function ab2hex(buffer) {
  let hexArr = Array.prototype.map.call(new Uint8Array(buffer), function(bit) {
    return ('00' + bit.toString(16)).slice(-2);
  });

  return hexArr.join('');
}

// 10进制转16进制
function num2Hex(num) {
  const r = Number(num).toString(16).padStart(2, "0").toUpperCase();

  return Number(`0x${r}`);
}

// 16进制异或值计算
function hexXor(arr) {
  const crc = arr.reduce((prev, cur) => prev ^ cur, 0);
  return crc.toString(16).padStart(2, '0').toUpperCase();
}
import chunk from "lodash/chunk";

async handleSendConnectOrder(TARGET_DEVICE_SERVICES_UUID = TARGET_DEVICE_SERVICES_UUID_55535343) {
  // 根据uuid获取目标服务
  const obj = targetDevicesServices.find(item => item.uuid.startsWith(TARGET_DEVICE_SERVICES_UUID));

  const {uuid: targetServiceUUID, characteristics} = obj;

  if(!Array.isArray(characteristics) || (Array.isArray(characteristics) && characteristics.length <= 0)) {
    console.log("该服务暂无特征可处理")
    return;
  }

  // 可写特征
  const writeCharacteristicId = characteristics.find(({properties}) => properties.write && !properties.read && !properties.notify).uuid;
  // 可读特征
  const readCharacteristicId = characteristics.find(({properties}) => !properties.write && properties.read).uuid;
  // 可监听特征
  const notifyCharacteristicId = characteristics.find(({properties}) => !properties.write && (properties.notify || properties.indicate)).uuid;

  await wx.notifyBLECharacteristicValueChange({
    deviceId: targetDeviceId,
    serviceId: targetServiceUUID,
    characteristicId: notifyCharacteristicId,
    state: true,
    success:(res)=> {
      console.log("订阅监听特征值:", res.errMsg);
    },
  });

  // 必须在这里的回调才能获取
  wx.onBLECharacteristicValueChange(async (res) => {
    console.log(`characteristic ${res.characteristicId} has changed, now is:`, ab2hex(res.value));

    const val = ab2hex(res.value);
    if(res.characteristicId === readCharacteristicId) {
      switch(val) {
        case "fc xxx 41":
          console.log("[连接指令]设备解析成功,准备执行下发数据指令");

          await sleep(1500);
          // 下发数据指令
          await wx.writeBLECharacteristicValue({
            deviceId: targetDeviceId,
            serviceId: targetServiceUUID,
            characteristicId: writeCharacteristicId,
            value: sendDataUint8Array.buffer,
            success: (res) => {
              console.log("[下发数据指令]发送成功:", res);
              deviceState = ORDER_CONF.data;
            },
            fail:(e) => {
              console.error("[下发数据指令]发送失败:", e);
            }
          });

          timer && clearTimeout(timer);
          timer = setTimeout(() => {
            // heartUint8Array
            wx.writeBLECharacteristicValue({
              deviceId: targetDeviceId,
              serviceId: targetServiceUUID,
              characteristicId: writeCharacteristicId,
              value: heartUint8Array.buffer,
              success: (res) => {
                console.log("[心跳指令]发送成功:", res);
                deviceState = ORDER_CONF.heart;
                handleGetBLEDeviceRSSI();
              },
              fail:(e) => {
                console.error("[心跳指令]发送失败:", e);
                wx.showToast({
                  title: "蓝牙连接中断,请检查",
                  icon: "none"
                });
              }
            });
          }, 30 * 1000);

          break;
        case "fd xxx fe":
          switch(deviceState) {
            case ORDER_CONF.data:
              console.log(`[下发数据指令]设备解析成功`);

              // wx.writeBLECharacteristicValue({
              //   deviceId: targetDeviceId,
              //   serviceId: targetServiceUUID,
              //   characteristicId: writeCharacteristicId,
              //   value: sendDataStartUint8Array.buffer,
              //   success: (res) => {
              //     console.log("[下发开始指令]发送成功:", res);
              //     deviceState = ORDER_CONF.start;
              //   },
              //   fail:(e) => {
              //     console.error("[下发开始指令]发送失败:", e);
              //   }
              // });
              break;
            case ORDER_CONF.start:
              console.log(`[下发开始指令]设备解析成功`);
              break;
            
            case ORDER_CONF.stop:
              console.log(`[下发停止指令]设备解析成功`);
              break;
            case ORDER_CONF.heart:
              console.log(`[心跳指令]设备解析成功`);
              timer && clearTimeout(timer);
              timer = setTimeout(() => {
                wx.writeBLECharacteristicValue({
                  deviceId: targetDeviceId,
                  serviceId: targetServiceUUID,
                  characteristicId: writeCharacteristicId,
                  value: heartUint8Array.buffer,
                  success: (res) => {
                    console.log("[心跳指令]发送成功:", res);
                    deviceState = ORDER_CONF.heart;
                    handleGetBLEDeviceRSSI();
                  },
                  fail:(e) => {
                    console.error("[心跳指令]发送失败:", e);
                  }
                });
              }, 30 * 1000);
              break;
            default:
              break;
          }
          break;
        default:
          // 检测设备当前返回的是不是历史记录
          if(val.startsWith("fd") && val.length > 8) {
            // chunk
            const arr = chunk(val.split(""), 2);

            if(arr[8] && arr[8].join("") === "03") {
              console.log("[设备返回历史记录]", val);
              // 对设备返回的数据进行解析...
              return;
            }
          }

          console.warn(`[${deviceState}]指令设备解析失败`);
          // ...
          break;
      }
    }
  });

  // 读取设备特征值
  await wx.readBLECharacteristicValue({
    deviceId: targetDeviceId,
    serviceId: targetServiceUUID,
    characteristicId: readCharacteristicId,
    success:(res) => {
      console.log('readBLECharacteristicValue==>',res) 
    }, 
    fail:(err)=> {
      console.error('readBLECharacteristicValue err==>',err)
    }
  });

  await sleep(1500);

  // 发送连接指令
  await wx.writeBLECharacteristicValue({
    deviceId: targetDeviceId,
    serviceId: targetServiceUUID,
    characteristicId: writeCharacteristicId,
    value: sendDataConnectUint8Array.buffer,
    success: (res) => {
      console.log("[连接指令]发送成功:", res);
      deviceState = ORDER_CONF.connect;
    },
    fail:(e) => {
      console.error("[连接指令]发送失败:", e);
    }
  });
  
}

3.7 初始化调用顺序

async init() {
try {
  await handleCheckBluetooth();
  await handleConnectBluetooth();
  await handleGetBLEServices();
  await handleCharacteristic();
  await handleSendConnectOrder(TARGET_DEVICE_SERVICES_UUID_55535343);
  // await handleSendConnectOrder(TARGET_DEVICE_SERVICES_UUID_55535344);
} catch (error) {
  console.error(error);
}
}

4. 参考资料

小程序蓝牙 (Bluetooth)文档

5. 最后

如果文章对您有帮助,可以关注我的个人公众号半个柠檬2020,偶尔也会在公众号上面更新一些自己的学习笔记。