一、蓝牙支持情况
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:唯一。
特征值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 加不加这个参数都可以正常搜索到设备。