微信小程序蓝牙功能开发与问题记录

一、蓝牙支持情况

1. 微信小程序对蓝牙的支持情况

目前普遍使用的蓝牙规格:经典蓝牙和蓝牙低功耗。

经典蓝牙(蓝牙基础率/增强数据率):常用在对数据传输带宽有一定要求的大数据量传输场景上,比如需要传输音频数据的蓝牙音箱、蓝牙耳机等;

蓝牙低功耗 (Bluetooth Low Energy, BLE): 从蓝牙 4.0 起支持的协议,特点就是功耗极低、传输速度更快,常用在对续航要求较高且只需小数据量传输的各种智能电子产品中,比如智能穿戴设备、智能家电、传感器等。

IOS 安卓

基础库

(当前开发基础库2.23+)

经典蓝牙 不支持 不支持,规划中 /
蓝牙低功耗 主机模式(手机作为客户端,主动连接) 微信客户端6.5.6及以上 微信客户端6.5.7及以上 1.1.0及以上
从机模式(手机作为服务端,被动连接) 支持 支持 2.10.3及以上
蓝牙信标(持续广播,但不建立连接) 支持 支持 1.2.0及以上

2. IOS和安卓设备对蓝牙低功耗的支持情况

由于项目所使用的设备是低功耗蓝牙,故对此做调研:

IOS

安卓

连接设备数量 20个 6-8个
连接速度 正常 部分安卓机经常出现连接速度慢、连接超时的现象
传输数据量(MTU)  20字节  20字节
设备搜索 支持 6.0及以上版本需要打开定位权限

注意点:1)数据量超过 MTU  (20 字节)会导致错误,需要根据蓝牙设备协议进行分片传输。其中安卓分片之间的传输需要做延迟 250ms。

2)由于Android 从微信 6.5.7 开始支持,iOS 从微信 6.5.6 开始支持,因此小程序中需要做好版本检测(wx.getSystemInfoSync 获取系统信息)。

二、基本需求

1. 添加页面:开启蓝牙搜索,选择设备并输入识别码(蓝牙连接后发送识别码,匹配成功则与设备正式建立了连接)。

2. 首页:可进行蓝牙连接、切换、断开、消息监听与数据发送(识别码匹配)。

三、蓝牙API的基本使用

整理上述涉及蓝牙API的使用:

1. 添加页面(搜索蓝牙逻辑)

// 添加页
// 检查蓝牙是否开启
checkBluetoothOn(){
    let sucCallback = this.startDiscovering
    let errCallback = () => {
        // 蓝牙未开启逻辑处理
    } 
    // 如果蓝牙开启状态,就去搜索设备
    this.getBlueState(errCallback, sucCallback)
},
// 判断手机蓝牙是否打开
getBlueState (errCallback, sucCallback) {
    this.$getBlueState(errCallback).then(res=>{
        if(res.errno === 0){
            sucCallback ? sucCallback() : ''
        } else {}
    });
},
// 蓝牙搜索逻辑 15s关闭
startDiscovering(){
    // 开始搜寻附近设备
    this.$discoveryBlue(this.found)
    setTimeout(()=>{
        this.handleStopDiscovery()
    }, 15000)
},
// 找到新设备就触发该方法 处理数据逻辑
found(res) {
    var devices = res.devices;
    devices.map(async item => {
        // 对设备信息处理
    })
},
handleStopDiscovery(){
    let stopLoading = () => {
        this.loading = false
        // 其它关闭逻辑
        
    }
    this.$stopDiscoveryBlue(stopLoading, stopLoading)
},

2. 首页(连接逻辑)

onShow() {
    // 第一次进来如果没有连接 去自动连接
    let sucCallback = (this.blueDeviceList?.length && this.isAutoConnect) ? 
           this.autoConnect : this.isConnnected
    let errCallback = () => {
        // 未开启逻辑
    }
    // 如果蓝牙开启状态 就去连接
    this.getBlueState(errCallback, sucCallback)
    this.isAutoConnect = false
},
methods: {
    async autoConnect(){
        this.isFound = false
        this.$discoveryBlue(this.found)
        .catch((err) => {
            // 查找失败逻辑处理
        })
         // 15s后未找到数据
        setTimeout(()=>{
            if(!this.isFound){
                this.notFound()
            }
        }, 15000)
    },
    // 找到新设备就触发该方法 处理数据逻辑
    found(res) {
        var devices = res.devices;
        devices.map(async item => {
            // 以 deviceId 为唯一标识,过滤重复设备
            if(item.deviceId == this.connectingDevice.deviceId) {
                this.isFound = true
                this.handleConnect(this.connectingDevice)
                wx.offBluetoothDeviceFound(this.found) // 防止回调函数重复执行导致重复连接
            }
        })
    },
    notFound(){
        let stopLoading = () => {
            // 其它逻辑
        }
        this.$stopDiscoveryBlue(stopLoading, stopLoading)
    },
    async handleConnect(deviceInfo){
        let { deviceId, idCode } = deviceInfo
        this.$connectBlue(deviceId).then(async (res)=>{
            const {notifyServiceId, writeServiceId, notifyCharacteristicId, writeCharacteristicId} = await this.getBasicIds(deviceId)
            let listenValueChange = () => {
                this.$listenCharacteristicValueChange(this.getInfoFromBluetooth)
            }
            this.$notifyBlue(deviceId, listenValueChange, notifyServiceId, notifyCharacteristicId)
            .then(()=>{
                let { buffer } = this.$getBuffer({
                  body: idCode, 
                  length: 11, // 帧长度
                  command, // 绑定命令字
                })
                this.$sendBlue(deviceId, buffer, writeServiceId, writeCharacteristicId )
                .catch(async(err) => {
                    await this.stopConnect(deviceInfo.deviceId)
                    // 其它逻辑
                })
            }).catch(async()=>{
                await this.stopConnect(deviceInfo.deviceId)
                // 其它逻辑
            })

        }).catch(()=>{
            // 逻辑处理
        })
    },
    // 获取蓝牙传输过来的数据处理
    getInfoFromBluetooth(res){
        
    },
    stopConnect(deviceId){
        return new Promise((resolve) => {
            let connectedDevice = this.getStorageInfo('connectedDevice', {})
            let connectedDeviceId = deviceId || connectedDevice?.deviceId 
            if(!connectedDeviceId) return resolve()
            this.$closeConnection(connectedDeviceId).then((res) => {
                // 逻辑处理
                resolve()
            }).catch((err)=>{
                // 断连失败逻辑处理
            })
        })
    },
    isConnnected(){
        const { connected } = this.connectStatus
        let connectedDevice = this.blueDeviceList.find(item => item.status == connected)
        if(connectedDevice){
            this.$getServicesBlue(connectedDevice.deviceId)
            .then(async()=>{
                // 如果连接状态,需要重新建立消息通道
                const {notifyServiceId, notifyCharacteristicId} = await this.getBasicIds(connectedDevice.deviceId)
                let listenValueChange = () => {
                    this.$listenCharacteristicValueChange(this.getInfoFromBluetooth)
                }
                this.$notifyBlue(connectedDevice.deviceId, listenValueChange, notifyServiceId, notifyCharacteristicId)
                .catch(async()=>{
                    await this.stopConnect(deviceInfo.deviceId)
                    // 其它逻辑
                })
            })
            .catch(err => {
                // 连接已断开
                if(err.errCode == 10006){
                    // 其它逻辑
                }
            })
        }
    },
    getBasicIds(deviceId){
        return new Promise(async(resolve, reject) => {
            let {notifyServiceId, writeServiceId} = await this.$getServicesBlue(deviceId)
            let notifyCharacteristicId = await this.$getCharacteristicsBlue(deviceId, notifyServiceId)
            let writeCharacteristicId = await this.$getCharacteristicsBlue(deviceId, writeServiceId)
            resolve({notifyServiceId, writeServiceId, notifyCharacteristicId, writeCharacteristicId})
        })
    },
}

3. 公共方法

// 公共方法封装小程序蓝牙api
function $getBlueState(errCallback) {
    return new Promise((resolve, reject) => {
        $initBlue().then(res=>{
            resolve(res)
        }).catch(err=> {
            if(err.errCode === 10001){
                return errCallback ? errCallback() :
            }
        })
    })
}
function $initBlue() {
    return new Promise((resolve, reject) => {
        uni.openBluetoothAdapter({
            success(res) {
                resolve(res)
            },
            fail(err) {
                reject(err)
            }
        })
    })
}
function $discoveryBlue(callback) {
    return new Promise((resolve, reject) => {
        uni.startBluetoothDevicesDiscovery({
            services: mainServiceIds,
            allowDuplicatesKey: true,
            success(res) {
                uni.onBluetoothDeviceFound(callback)
            },
            fail(err) {
                console.error(err)
                reject(err)
            }
        })
    })
}
function $stopDiscoveryBlue(sucCallback, errCallback) {
    uni.stopBluetoothDevicesDiscovery({
        success(res) {
            console.log('停止设备搜索')
            sucCallback ? sucCallback() : ''
        },
        fail(err) {
            console.log('停止搜索设备失败')
            console.error(err)
            errCallback ? errCallback() : ''
        }
    })
}
function $getServicesBlue(deviceId) {
    return new Promise((resolve, reject) => {
        uni.getBLEDeviceServices({
            deviceId,
            success(res) {
                // 逻辑(根据硬件给的协议取对应服务ID)
                resolve({
                    notifyServiceId,
                    writeServiceId
                })
            },
            fail(err) {
                console.error(err)
                reject(err)
            }
        })
    })
}
function $getCharacteristicsBlue(deviceId, serviceId) {
    return new Promise((resolve, reject) => {
        uni.getBLEDeviceCharacteristics({
            deviceId,
            serviceId,
            success(res) {
                const characteristicId = res.characteristics[0].uuid
                resolve(characteristicId)
            },
            fail(err) {
                console.error(err)
            }
        })
    })
}
function $notifyBlue(deviceId, callback, serviceId, characteristicId) {
    return new Promise((resolve, reject) => {
        uni.notifyBLECharacteristicValueChange({
            state: true, // 启用 notify 功能
            deviceId, // 设备id
            serviceId: serviceId, // 监听指定的服务
            characteristicId: characteristicId, // 监听对应的特征值
            success(res) {
                callback()
                resolve()
            },
            fail(err) {
                console.log(serialDataChannel.serviceId, serialDataChannel.characteristicId)
                console.error(err)
                reject()
            }
        })
    })
}
function $sendBlue(deviceId, buffer, serviceId, characteristicId) {
    return new Promise((resolve, reject) => {
        uni.writeBLECharacteristicValue({
            deviceId,
            serviceId: serviceId,
            characteristicId: characteristicId,
            value: buffer,
            success(res) {
                resolve(res)
            },
            fail(err) {
                console.error(err)
                reject()
            }
        })
    })
}
function $listenCharacteristicValueChange(callback) {
    uni.onBLECharacteristicValueChange(res => {
        callback(res)
    })
}

四、问题记录

1. 手机开启了蓝牙,但是api openBluetoothAdapter 仍然调用失败?

检查是否给微信授权了蓝牙功能。

2. onBluetoothDeviceFound 搜索到设备以后需要建立连接。接口持续搜索会导致重复连接。

防止回调函数重复执行导致重复连接,需要调用 wx.offBluetoothDeviceFound(this.found) 。 

3. 设备Id、特征值Id、服务Id是否是唯一?
设备Id:唯一。

微信小程序蓝牙功能开发与问题记录_第1张图片
特征值Id和服务Id不唯一:不同蓝牙的服务Id和特征值Id可能是一样的;同一蓝牙设备的服务Id和特征值Id是固定的。

4. 既然蓝牙的特征值Id和服务Id是固定的,那是否可以写死,直接调用读写api( notifyBLECharacteristicValueChange 和 writeBLECharacteristicValue)?

不能。api会调用失败。需要先调用 getBLEDeviceServices 和 getBLEDeviceCharacteristics 。

5. 蓝牙读写通信的数据如何转化?

通信过程涉及到的转化包括:10进制和16进制互相转化、16进制和 ArrayBuffer 互相转化。

1)10进制转16进制:

let deci = 172;
console.log(deci.toString(16))

2)16进制转10进制:

let hex = '0xAC'
console.log(parseInt(hex, 16))

3)16进制转字符串:

let hexToString = (hex) => {
  let str = '';
  for (let i = 0; i < hex.length; i += 2) {
    let v = parseInt(hex.substr(i, 2), 16);
    if (v) str += String.fromCharCode(v);
  }
  return str;
}
hexToString('68656c6c6f')

4)字符串转16进制:

let stringToHex = (str) => {
  let val= "";
  for(let i = 0; i < str.length; i++){
    if(val == "")
      val = str.charCodeAt(i).toString(16);
    else
      val += "," + str.charCodeAt(i).toString(16);
  }
  return val;
}
stringToHex('hello')

5)字符串转16进制转 ArrayBuffer:

let info = 'hello'
const buffer = new ArrayBuffer(info.length)
const dataView = new DataView(buffer)
for (var i = 0; i < info.length; i++) {
  dataView.setUint8(i, info.charAt(i).charCodeAt())
}
wx.writeBLECharacteristicValue({
  deviceId,
  serviceId,
  characteristicId,
  value: buffer,
  success (res) {
    console.log('writeBLECharacteristicValue success', res)
  }
})

6)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('');
}
wx.onBLECharacteristicValueChange((res) => {
  console.log(ab2hex(res.value))
})


6. 蓝牙帧发送出现分包的情况?

有时蓝牙设备传来的帧会有不完整的情况,需要做拼接处理。此处逻辑为:在监听函数中,获取到不完整的帧时,如果帧头正确,保存并等下一帧,否则舍弃。帧头正确并获取到下一帧后进行拼接。当获取到的帧符合我们期待的长度时,进行之后的帧校验与业务逻辑处理。如果指定时间没有接受到有效帧数据,则断连。

7. 超时间没有获取到接收有效帧数据的断连逻辑?

本项目的协议上规定,状态帧每30s上传一次。如果接收到有效状态帧,则更新状态,并对关闭连接进行延迟;如果没有获取到有效状态帧,则2min后会断开连接。具体如下:

// 有效帧获取到后执行
this.delayStopConnect(this.connectedDevice.deviceId)

// 对 delayStopConnect 进行 debounce,状态帧超过2分钟没有回复则断连
delayStopConnect: utils.debounce(async function(deviceId){
    await this.stopConnect(deviceId)
    uni.hideLoading();
    this.showModalTips('设备连接异常')
}, 120000),

// debounce 函数
function debounce(fn, delay, isImmediate) {
    var timer = null;  //初始化timer,作为计时清除依据
    return function() {
      var context = this;  //获取函数所在作用域this
      var args = arguments;  //取得传入参数
      clearTimeout(timer);
      if(isImmediate && timer === null) {
          //时间间隔外立即执行
          fn.apply(context,args);
        timer = 0;
        return;
      }
      timer = setTimeout(function() {
        fn.apply(context,args);
        timer = null;
      }, delay);
    }
}

8. 如何保证帧的有效性(帧校验)?

一般来说,硬件会在协议上说明。一般是拿到回复帧后,取其中某几段进行和校验。如果校验得到的值和回复帧的值相同,则校验成功。例如:一个回复帧包含帧头+帧长度+命令字+识别码+校验码+帧尾。校验码等于命令字与识别码的和。假设命令字是 0xAB,识别码是 0x01,则正确的校验码是 0xAC 。把它跟蓝牙传来的回复帧的校验码进行比较即可。

9. 保证一次连接一个设备的逻辑处理?

由于当前项目的需求是,一个手机只能连接一个蓝牙设备(小程序做处理),但是实际手机是支持连接多个蓝牙设备的,所以如果用户一次性点了很多个设备,需要做相关处理。我的思路是创建一个数组堆,记录连接的设备。如果连接上则 push(),如果发现数组长度大于1,则 shift() 掉最先连接的。

10. 小程序能否主动监听到蓝牙开启与关闭?
可以通过 onBLEConnectionStateChange 监听到蓝牙断开;蓝牙开启监听不到,除非手动定时请求api判断(如果官方有相关监听api,欢迎指正)。

11. 切换页面,能否持续收到监听数据,是否要重新建立监听?
在页面A建立消息通道(notifyBLECharacteristicValueChange)以后,跳转到页面B,仍然可以监听到数据(不论是 navigateTo 还是 navigateBack)。但如果其它页面此时再次调用此api,会覆盖掉之前的消息通道。

12. 设备A连接后,断开连接。设备B立刻连接,连接不上,需要过1分钟左右(安卓机尤其明显)?
因为程序做了连接超时则请求失败的处理。经排查发现,连接超时的api是设备搜索(startBluetoothDevicesDiscovery)和设备连接(getConnectedBluetoothDevices)。
一个不完美,但管用的解决方法:这两个 api 不设置 services 参数。

13. 关闭再次打开小程序,蓝牙连接是否会中断?
理论上是,实际上有时不会。
首先,2023.23.3 官方回复目前暂时不支持后台下的蓝牙功能(https://developers.weixin.qq.com/community/develop/doc/0008a409c889c0f8d76fd1ec356400?highLine=%25E8%2593%259D%25E7%2589%2599%25E5%2590%258E%25E5%258F%25B0%25E5%259C%25BA%25E6%2599%25AF)
其次,小程序的运行机制是(https://developers.weixin.qq.com/miniprogram/dev/framework/runtime/operating-mechanism.html):
小程序切换后台(包括息屏)5s,微信会停止小程序js线程执行,在小程序再次进入前台时事件和接口回调会触发;小程序切换后台30分钟,小程序销毁。
二者结合,理论上蓝牙应该在30分钟后被断开。但事实上,发现有时候ios和安卓都没有断开。因为其它手机搜索不到相应设备。

14. 一些 ios 和安卓的 API 兼容问题

1)关于断开连接(closeBLEConnection):ios 设备在没有连接蓝牙设备时,调用断开接口,显示调用成功;但是安卓机会得到错误码 10006 (当前设备已断开连接)。

2)关于搜索设备(startBluetoothDevicesDiscovery):安卓机搜索一次以后,再次调用该接口,刚才已经搜索出来的设备搜索不到了,除非加上参数:allowDuplicatesKey。ios 加不加这个参数都可以正常搜索到设备。

你可能感兴趣的:(前端,小程序,微信小程序,蓝牙)