您现在的位置是:首页 >技术杂谈 >项目总结:微信小程序端实现蓝牙通信功能网站首页技术杂谈

项目总结:微信小程序端实现蓝牙通信功能

Sophie_U 2024-10-13 12:01:04
简介项目总结:微信小程序端实现蓝牙通信功能

前言

最近开发的小程序新增加了蓝牙通信功能,用于与一款蓝牙跳绳实现数据通信。主要功能点包括:

  1. 打开蓝牙并扫描设备
  2. 通过设备deviceId连接对应设备
  3. 小程序端下发指令,设置蓝牙跳绳模式(计时,计数,自由跳)
  4. 实时获取蓝牙广播的数据并进行解析

一、关于蓝牙协议

蓝牙(英语:Bluetooth),一种无线通讯技术标准,用来让固定与移动设备,在短距离间交换资料,以形成个人局域网(PAN)。

  • 蓝牙协议分为三种模式:

    • 高速蓝牙:主要用于数据交换与传输
    • 传统蓝牙:以基础信息沟通,设备连接为重点
    • 低功耗蓝牙:以不占用过多带宽的设备连接为主。在低功耗模式条件下,Bluetooth 4.0协议以上的蓝牙设备,传输距离可提升到100米以上(BLE)
  • 蓝牙服务UUID:

    • 通过蓝牙的UUID来标识 蓝牙服务与通讯访问的属性,不同的蓝牙服务和属性使用的是不同的方法,所以在获取到蓝牙服务时需要保持服务一致才能通信
    • 蓝牙的read,write,notification特征属性,都有对应的特征服务字段(同样是UUID)。
    • 厂商可以自定义蓝牙服务以及特征字段,因此实现蓝牙通信的前提是拿到确定的服务特征值
  • 蓝牙广播:

    • 蓝牙数据通过广播的形式实现通信,格式如长度+类型+内容,基内容可变,类型固定,长度由内容确定

二、关于微信小程序蓝牙模块API

官方文档地址:设备-蓝牙

主要用到API如下:

  1. 打开蓝牙适配器:wx.openBluetoothAdapter,后续所有蓝牙模块功能都需要先打开适配器才能进行
  2. 搜寻蓝牙设备:
    2.1 开始搜寻:wx.startBluetoothDevicesDiscovery,此功能比较消耗性能,如果搜索到特定设备可即时停止
    2.2 发现设备事件:wx.onBluetoothDeviceFound,在这儿添加实时更新设备列表业务代码
    2.3 停止扫描:wx.onBluetoothDeviceFound,停止扫描新的蓝牙设备,当蓝牙扫描到指令设备时,需要即时关闭扫描保证性能
    2.4 关闭发现设备事件监听:wx.offBluetoothDeviceFound
  3. 连接蓝牙设备: wx.createBLEConnection,通过传入蓝牙设备deviceId进行设备直连。这里的deviceId可通过上面扫描时wx.onBluetoothDeviceFound响应值获取
  4. 监听蓝牙设备连接状态:wx.onBLEConnectionStateChange: 包括开发者主动连接或断开连接,设备丢失,连接异常断开等等
  5. 获取蓝牙服务
    5.1 获取蓝牙低功耗设备所有服务: wx.getBLEDeviceServices,通过
    5.2 根据特定服务UUID查询所有特征:[wx.getBLEDeviceCharacteristics](wx.getBLEDeviceCharacteristics),
  6. 监听蓝牙数据(实时获取蓝牙跳绳回传的电量,跳绳数量等信息)
    6.1 订阅特征变化:wx.notifyBLECharacteristicValueChange,开启订阅后续才能监听到蓝牙数据变化
    6.2 监听特征值变化:wx.onBLECharacteristicValueChange,通过监听事件触发数据解析业务
  7. 发送数据(向蓝牙跳绳下发指令)
    7.1 下发指令:wx.writeBLECharacteristicValue,通过向蓝牙特定服务的对应特征值写入数据,完成交互。注意:要对应支持“write"属性的特征值
  8. 关闭蓝牙活动
    8.1 wx.stopBluetoothDevicesDiscovery(): 停止扫描新设备
    8.2 wx.offBluetoothDeviceFound():关闭扫描新设备监听事件
    8.3 wx.offBLECharacteristicValueChange():关闭特征值变化监听(数据监听)
    8.4 wx.offBLEConnectionStateChange():移除蓝牙低功耗连接状态改变事件的监听函数
    8.5 wx.closeBLEConnection: 断开蓝牙连接
    8.6 wx.closeBluetoothAdapter():关闭蓝牙适配器

三、蓝牙业务模块封装

因本小程序中的蓝牙跳绳应用场景有:扫描绑定,连接查询详情,实时跳绳数据传输等,所以实现蓝牙基类封装后,通过继承的方式来实现不同场景的使用需求。功能实现包括以下部分:

  1. 蓝牙模块封装:包括打开适配器,扫描,连接,实时数据传输
  2. 蓝牙业务中的工具处理函数:
    • 扫描到新设备时去重添加
    • 微信设备deviceId在安卓和ios下值格式是不同的(需求要展示为统一mac地址)所以需要特征处理
    • 蓝牙通信数据处理:16进制数据之前的互相转换

代码如下,篇幅有限进行了部分省略。

3.1 蓝牙基类

/**
 * 蓝牙跳绳基类,用于蓝牙相关通信均可继承
 */
import {
  inArray,	// 展示搜索到的设备列表时去重
  uuid2Mac, // 用于统一ios与android设备端展示的deviceId格式为:xx:xx:xx:xx:xx 
  deviceNameFilter,
  utf8to16,
  hexToString,
  ab2hex,
  str2ab,
} from "@/utils/util-BLE.js";

class BLEController {
  // 自动关闭定时器
  findTimer = null;
  // 蓝牙适配器开启状态
  static adapterOpend = false;
  // 扫描设备状态
  startDiscovery = false;
  // 蓝牙连接状态
  connectStatus = false;
  // 蓝牙扫描自动结束时间2min
  #timeout = 2 * 60 * 1000;
  // 蓝牙通信超时时间5min
  #notifyTimeout = 5 * 60 * 1000;
  // 蓝牙搜索是否超时
  deviceDiscoveryTimeout = false;
  // 蓝牙设备ID,注意:ios设置对应deviceId为设备uuid,安卓及开发者工具上连接的蓝牙为mac地址
  deviceId;
  deviceName;
  // 设备mac地址(统一编码处理)
  deviceMac;
  // 设备列表, [{deviceMac:设备mac地址, deviceId:设备ID,deviceName:设备名称,...}]
  deviceList = [];
  // 蓝牙服务特征队列
  characteristicStack = [];
  // 蓝牙消息队列
  msgStack = [];
  // 蓝牙通信serviceId
  serviceId = "";
  constructor(context) {
    if (context) {
      this.deviceId = context.deviceId;
      this.deviceMac = context.deviceMac;
      this.connectStatus = false;
    }
  }
  /**
   *  1.初始化蓝牙模块
   */
  openBluetoothAdapter() {
    const _this = this;
    if (BLEController.adapterOpend) {
      console.log("蓝牙适配器已打开,请勿重复操作------》");
      return;
    }
    wx.openBluetoothAdapter({
      mode: "central",
      success(res) {
        BLEController.adapterOpend = true;
        console.log("蓝牙适配器打开成功-----------------》");
      },
      fail(res) {
        BLEController.adapterOpend = false;
        _this.BLEFail(res);
        console.log("蓝牙适配器打开失败-----------------》", res.errMsg);
      },
    });
  }
  /**
   *  2.扫描蓝牙设备(绑定蓝牙,连接蓝牙通用)
   * @param {Array} options.keywords 蓝牙名称筛选关键字
   * @param {string} options.deviceId 可选参数,蓝牙设备id,连接用
   */
  startBluetoothDevicesDiscovery(options) {
    // ---------省略---------------》
    if (this.startDiscovery) {
      console.log("已开启蓝牙扫描,勿重复开启-----------》");
      return;
    } else {
      this.startDiscovery = true;
      wx.startBluetoothDevicesDiscovery({
        allowDuplicatesKey: false,
        success: (res) => {
          this.onBluetoothDeviceFound(options);
          console.log("开始扫描蓝牙设备-----------------》");
        },
        fail: (res) => {
          this.startDiscovery = false;
        },
      });
    }
  }
  /**
   *  2.1 监听搜索到新设备
   * @param {Array} options.keywords 蓝牙名称筛选关键字,来自startBluetoothDevicesDiscovery调用
   * @param {string} options.deviceId
   */
  onBluetoothDeviceFound(options) {
    let { keywords } = options;
    // 超时自动结束
    this.findTimer = setTimeout(() => {
      clearTimeout(this.findTimer);
      if (!this.connectStatus) {
        console.log("蓝牙扫描超时,自动关闭任务-------------》");
        this.deviceDiscoveryTimeout = true;
        this.startDiscovery = false;
        wx.stopBluetoothDevicesDiscovery();
      }
    }, this.#timeout);
    // 监听扫描
    wx.onBluetoothDeviceFound((res) => {
      let devices = res.devices;
      devices.forEach((device) => {
        if (!device.name && !device.localName) {
          return;
        }
        // 获取设备MAC地址,并根据关键字过滤
        let systemInfo = wx.getSystemInfoSync();
        let iosDevice = systemInfo.system.toLowerCase().indexOf("ios") > -1;
        let deviceMac = iosDevice
          ? uuid2Mac(device.advertisData)
          : device.deviceId;
        if (keywords && keywords.length > 0) {
          if (deviceNameFilter(device.name, keywords)) {
            const foundDevices = this.deviceList;

            const idx = inArray(foundDevices, "deviceMac", deviceMac);
            let devicesInner = [...this.deviceList];
            if (idx === -1) {
              device.deviceMac = deviceMac;
              devicesInner[foundDevices.length] = device;
              this.deviceList = devicesInner;
              console.log("发现蓝牙设备并更新列表-----------》", deviceMac);
            }
          }
        }
      });
    });
  }
  /**
   * 3. 直接连接蓝牙
   * @param {string} options.deviceId 蓝牙设备id,连接用
   */
  createBLEConnection(options) {
    let { deviceId } = options;
    this.deviceId = deviceId;
    // 如果开启扫描时适配器还没启动,轮询等待
    const _this = this;
    if (!BLEController.adapterOpend) {
      // ---------省略---------------》
    }
    if (this.connectStatus) {
      wx.closeBLEConnection({
        deviceId: deviceId,
      });
    }
    let timeout = this.#timeout;
    console.log("开始连接蓝牙------------》", deviceId);
    this.stopBLEDevicesTask();
    wx.createBLEConnection({
      deviceId: deviceId,
      timeout: timeout,
      success: (res) => {
        console.log("蓝牙连接成功----------》", deviceId);
        this.connectStatus = true;
        this.onBLEConnectionStateChange();
        this.getBLEDeviceServices();
      },
      fail: (res) => {
        this.connectStatus = false;
        console.log("连接失败-------》", res.errMsg);
      },
    });
  }
  /**
   * 3.1 获取蓝牙低功耗设备所有服务
   * @param {string} deviceId 蓝牙设备Id,来自createBLEConnection调用
   */
  getBLEDeviceServices() {
    wx.getBLEDeviceServices({
      deviceId: this.deviceId,
      success: (res) => {
        /**
         * 16 位 UUID 从对接文档中获取(注意都是0000开头,接着的4位数字为16进制的uuid,所有服务只有4位uuid不一样)
         * 跳绳主服务 0000xxxx-0000-1000-8000-00805f9b34fb (0xXXXX)
         */
        //  注意有多个服务,不同服务的操作不一样,单个服务只能执行单个操作,所以这里需要建立多个连接
        for (let i = 0; i < res.services.length; i++) {
          // 注意uuid的大小写
          if (
            res.services[i].isPrimary &&
            res.services[i].uuid == "0000xxxx-0000-1000-8000-00805F9B34FB"
          ) {
            this.getBLEDeviceCharacteristics(res.services[i].uuid);
            return;
          }
        }
      },
      fail: (res) => {
        console.log("服务获取失败------------->", res.errMsg);
      },
    });
  }
  /**
   * 3.2 获取蓝牙低功耗设备某个服务中所有特征 (characteristic)
   * @param {string} uuid 服务UUID,来自getBLEDeviceServices调用
   */
  getBLEDeviceCharacteristics(serviceId) {
    wx.getBLEDeviceCharacteristics({
      deviceId: this.deviceId,
      serviceId,
      success: (res) => {
        // 设备特征列表
        let characteristics = res.characteristics;
        for (let i = 0; i < characteristics.length; i++) {
          let item = characteristics[i];
          // 该特征是否支持 read 操作
          if (item.properties.read) {
            wx.readBLECharacteristicValue({
              deviceId: this.deviceId,
              serviceId,
              characteristicId: item.uuid,
            });
          }
          // 该特征是否支持 write 操作
          if (item.properties.write) {
            this.serviceId = serviceId;
            this.characteristicId = item.uuid;
            this.onConnectSuccess();
          }
          // 该特征是否支持 notify ,indicate操作 ,开启监听订阅特征消息
          if (item.properties.notify || item.properties.indicate) {
            wx.notifyBLECharacteristicValueChange({
              deviceId: this.deviceId,
              serviceId,
              characteristicId: item.uuid,
              state: true,
            });
            this.onBLECharacteristicValueChange();
          }
        }
      },
    });
  }

  /**
   * 3.4 监听蓝牙数据
   * @param
   */
  onBLECharacteristicValueChange() {
    wx.onBLECharacteristicValueChange((characteristic) => {
      let { characteristicId, value } = characteristic;
      const idx = inArray(this.characteristicStack, "uuid", characteristicId);
      let formatedValue = hexToString(ab2hex(value));

      if (
        this.msgStack.indexOf(formatedValue) < 0 ||
        this.msgStack.length > 2
      ) {
        this.msgStack.push(formatedValue);
        if (formatedValue.indexOf("#") > -1) {
          var dataValue = this.msgStack.join("");
          this.msgStack = [];
          // 消息事件
          this.onMsgValueChange(dataValue);
        }
      }
      if (idx === -1) {
        this.characteristicStack.push({
          uuid: characteristic.characteristicId,
          value: value,
        });
      } else {
        this.characteristicStack.splice(idx, 1, {
          uuid: characteristic.characteristicId,
          value: value,
        });
      }
    });
  }

  /**
   * 3.6 蓝牙状态变化监听
   */
  onBLEConnectionStateChange() {
    wx.onBLEConnectionStateChange((res) => {
      let { deviceId, connected } = res;
      this.connectStatus = connected;
      console.log("蓝牙状态变化 -------------》", connected, deviceId);
    });
  }
  /**
   * 4. 发送蓝牙指令。蓝牙指令超出20字符时需要截断多次发送
   * @param {string} cmdStr 蓝牙指令
   * @param {string} cmdName 蓝牙指令名称——可选用于打印调试
   */
  writeBLECharacteristicValue(cmdStr, cmdName) {
    console.log("发送蓝牙指令------------》", cmdStr, cmdName);
    var byteLen = cmdStr.length;
    var pos = 0;
    let loopCount = 0;
    // 消息超长分批处理
    for (let i = 0; i < byteLen; i += 20) {
      let buffer = str2ab(cmdStr.slice(pos, pos + 20));
      pos += 20;
      loopCount += 1;
      let param = {
        deviceId: this.deviceId,
        serviceId: this.serviceId,
        characteristicId: this.characteristicId,
        value: buffer,
      };
      console.log(`cyy:第${loopCount}次发送指令${cmdName}:`, param);
      wx.writeBLECharacteristicValue({
        ...param,
        success: function (res) {
          console.log("发送指令成功", cmdName);
        },
        fail: function (res) {
          console.warn("发送指令失败", cmdName, res);
        },
      });
    }
  }
  /**
   * 蓝牙错误拦截
   */
  BLEFail(res) {
    wx.hideLoading();
    if (res.errno == 103) {
      uni.showModal({
        title: "提示",
        content: "请先开启蓝牙权限",
        showCancel: false,
        success: function () {
          wx.openSetting();
        },
      });
    } else if (res.errCode === 10001) {
      uni.showModal({
        title: "提示",
        content: "请先打开手机蓝牙!",
        showCancel: false,
      });
    } else {
      console.log("cyy: 蓝牙错误---------》", res.errCode);
    }
    this.errorHandler();
  }
  /**
   * 停止蓝牙通信活动及监听
   */
  stopBLEDevicesTask() {
    // 停止扫描设备
    this.startDiscovery = false;
    wx.stopBluetoothDevicesDiscovery();
    // 关闭扫描新设备监听
    wx.offBluetoothDeviceFound();
    // 关闭数据监听
    wx.offBLECharacteristicValueChange();
    // 移除蓝牙低功耗连接状态改变事件的监听函数
    wx.offBLEConnectionStateChange();
  }
  /**
   * 停止所有蓝牙活动
   */
  closeBLE() {
    // 关闭线程
    if (this.findTimer) {
      clearTimeout(this.findTimer);
    }
    // 停止扫描
    this.stopBLEDevicesTask();

    // 断开连接
    if (this.deviceId) {
      wx.closeBLEConnection({
        deviceId: this.deviceId,
      });
      this.connectStatus = false;
    }
    // 关闭适配器
    BLEController.adapterOpend = false;
    wx.closeBluetoothAdapter();
  }
  /**
   * @override 蓝牙报错后处理
   */
  errorHandler() {}
  /**
   * @override 3.5 消息监听通知——实例化时重写
   */
  onMsgValueChange(dataValue) {}
  /**
   * @override 3.3连接成功处理函数——可通过继承重写
   */
  onConnectSuccess() {}
}

export default BLEController;

3.2 工具函数

以下以几个典型工具为例

3.2.1 uuid2Mac 统一安卓与IOS端deviceId展示

  • 在安卓设备中,获取到的 deviceId 为设备 MAC 地址,iOS 上则为设备 uuid,因此为了展示一致需要将ios的展示进行输入(当然IOS的连接还是得用获取到的uuid)
export function uuid2Mac(advertisData) {
  if (advertisData) {
    let bf = advertisData.slice(3, 9);
    let mac = Array.prototype.map
      .call(new Uint8Array(bf), (x) => ("00" + x.toString(16)).slice(-2))
      .join(":");
    mac = mac.toUpperCase();
    return mac;
  }
}

3.2.2 新设备去重

export function inArray(arr, key, val) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i][key] === val) {
      return i;
    }
  }
  return -1;
}

3.2.3 字符串转ArrayBuffer

因通信数据是以arrayBuffer格式传输的,所以在小程序端向设备端下发数据时,需要进行指令处理

// 将字符串转为 ArrayBuffer
export function str2ab(str) {
  var buf = new ArrayBuffer(str.length);
  var bufView = new Uint8Array(buf);
  for (var i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i);
  }
  return buf;
}

3.2.4 arrayBuffer转换为字符串

  • 同样设备向小程序传输的数据也是arrayBuffer类型,所以需要解码。
// ArrayBuffer转16进度字符串
export function ab2hex(buffer) {
  var hexArr = Array.prototype.map.call(new Uint8Array(buffer), function (bit) {
    return ("00" + bit.toString(16)).slice(-2);
  });
  return hexArr.join("");
}

// 将16进制转为 字符串
export function hexToString(str) {
  var val = "",
    len = str.length / 2;
  for (var i = 0; i < len; i++) {
    val += String.fromCharCode(parseInt(str.substr(i * 2, 2), 16));
  }
  return utf8to16(val);
}

3.3 实例化应用

以上只是蓝牙基类模块的封装,具体业务实现需要下发指令(写入特征值),处理回传的数据等,不同的厂商实现不一样,服务与特征值也不一样,所以在此基础之上做扩展即可

  • 以蓝牙扫描为例(基于uniapp实现的小程序,所以还是vue2代码)
// 引入 
import BLEController from "../controllers/BLE-controller"
export default {
	data(){
		return {
			BLE:null
		}
	},
	 created() {
	    this.BLE = new BLEController()
	    this.BLE.openBluetoothAdapter()
	     this.BLE.startBluetoothDevicesDiscovery({keywords:['筛选蓝牙名(自己定)']})
	  },
	  onUnload() {
	    this.BLE && this.BLE.closeBLE()
	  },
}
  • html部分
<div class="device-list" v-if="BLE&&BLE.deviceList.length > 0">
      <div class="device-item flex-h-between" v-for="(device, index) in BLE.deviceList" :key="device.id">
        <div class="left-info flex-h">
          <image src="/static/sub-device/icon-rope.png" mode="widthFix" class="device-icon" />
          <div class="device-info flex-v">
            <div class="device-name">{{ device.name }}</div>
            <div class="device-mac">{{ device.deviceMac }}</div>
          </div>
        </div>
        <div class="right-control">
          <button :disabled="bindingId == device.deviceId"
            :class="['bind-btn', bindingId == device.deviceId && 'disabled']" :data-key="index" @click="bindThis">
            <u-loading-icon mode="semicircle" color="#fff" size="12" v-if="bindingId == device.deviceId"></u-loading-icon>
            <text>绑定</text>
          </button>
        </div>
      </div>
    </div>

四、写在最后

以上代码并不完整,直接复制可能无法用,参考即可

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。