一. 前言。
蓝牙功能在我们日常软件中的使用率还是蛮高的----譬如各类共享单车/电单车。正因此,我们开发中接触蓝牙功能也是日渐增长。对于很多从未开发过蓝牙功能的童鞋来说,当PM小姐姐扔过来一个蓝牙协议要你接入时,简直一头雾水(我是谁?我在哪?)。只能一翻度娘和AI,可是网上文章大多水准参差不齐,技术五花八门,没法真正地让你从无到有掌握蓝牙功能/协议对接。
二. 说明。
本文就基于uni-app框架结合微信和支付宝小程序为例,来讲述蓝牙功能在各类型小程序中的整体开发流程和如何“优雅”高效的封装蓝牙功能模块。本文使用到的主要技术栈和环境有:
- uni-app
- JavaScript
- AES加解密
- 微信小程序
- 支付宝小程序
三. 蓝牙流程图。
正所谓“知己知彼,百战不殆”,所以在讲述蓝牙模块如何在小程序中开发和封装之前,我们先要了解蓝牙功能模块是如何在小程序中“走向”的,各API是如何交互通讯的。为了让大家看得清楚,学的明白----这里简明扼要地梳理了一份蓝牙核心API流程图(去除了非必要的逻辑走向,只展示了实际开发中最重要的步骤和交互)。
- uni-app: 蓝牙API
- 微信小程序:蓝牙API
- 支付宝小程序:蓝牙API
- 核心API流程图(注:每家厂商的小程序API大同小异,uni-app的基本通用,具体明细详见各厂商开发文档):
四. 蓝牙协议。
了解完开发所需的API后,就需要根据实际开发场景中所对接的硬件和其厂家提供的蓝牙对接协议来结合上述的API来编写代码了。每家厂商的蓝牙协议是不一样的,不过“万变不离其宗”。只要知道其中的规则,真正看懂一家,那换其他家的也是可以看懂的。本文以下述协议(蓝牙寻车+蓝牙开锁)为例解释下。
1. 寻车:
- 协议内容:
- 解读:
根据上述图文的描述,我们可以知道想要开启蓝牙锁,那么必须先通过寻车蓝牙指令(7B5B01610060 或 7B5B01610160)写入,然后根据蓝牙响应的信息功能体和错误码判断响应是否正确,如正确,那么就拿到此时的随机数,后根据协议规定对该随机数做相应的处理,最后将处理后得到的结果用于组装开锁的蓝牙写入指令。
- 案例代码:
2. 开锁:
- 协议内容:
- 解读:
根据上述图文的描述,我们可以知道开锁的写入指令是需要自己组装的,组装规则为:7B5B(数据头) 1B(信息体长度) 62(信息功能) 00(秘钥索引)018106053735(补1位0的电话号码)4B大端的时间戳 寻车拿到的随机码补8位0后经AES加密组合得到的16B数据 00(校验码);所以开锁写入的数据就是这种(案例:7B5B1B6200018106053735XXXXXXXXXXXXXXXXXXXX)。响应的话,也是根据信息功能体和错误码来判断开锁失败(9201)还是成功(9200)。
- 案例代码:
五.代码编写。
这里为了提高蓝牙模块的代码耦合度,我们会把业务层和蓝牙模块层分离出来----也就是会把蓝牙整体流程交互封装成一个蓝牙模块js,然后根据业务形态,在各个业务层面上通过传参的形式来区分每个组件的蓝牙功能。
1. 业务层:
- 核心代码:
//引入封装好的蓝牙功能JS模块核心方法函数
import { operateBluetoothYws } from '@/utils/bluetoothYws.js';
//调用蓝牙功能
blueTooth() {
//初始化蓝牙模块,所有的蓝牙API都需要在此步成功后才能调用
uni.openBluetoothAdapter({
success(res) {
console.log('初始化蓝牙成功res', res);
let mac = 'FF8956DEDA29';
let key = 'oYQMt8LFavXZR6sB';
operateBluetoothYws('open', mac, key, flag => {
if (flag) {
console.log('flag存在回调函数--蓝牙成功,可以执行后续步骤了', flag);
} else {
console.log('flag不存在回调函数--蓝牙成功,可以执行后续步骤了', flag);
}
})
},
fail(err) {
console.log('初始化蓝牙失败err', err);
}
})
},
- 解读:
这里是我们具体业务层需要的写法,一开始就是引入我们封装好的蓝牙JS模块核心方法函数(operateBluetoothYws),然后启用uni.openBluetoothAdapter这个蓝牙功能启动前提,成功后在其success内执行operateBluetoothYws方法,此时的参数根据实际开发业务和相对应的蓝牙协议而定(这里以指令参数、设备编号和AES加密秘钥为例),实际中每个mac和key是数据库一一匹配的,我们按后端童鞋提供的接口获取即可(这里为了直观直接写死)。
2. 蓝牙模块层:
- 核心代码:
let CryptoJS = require('./crypto-js.min.js'); //引入AES加密
let callBack = null; //回调函数,用于与业务层交互
let curOrder; //指令(开锁还是关锁后取锁的状态)
let curMac; //当前扫码的车辆编码对应的设备mac
let curKey; //当前扫码的车辆编码对应的秘钥secret(用于AES加密)
let curDeviceId; //当前扫码的车辆编码对应的设备的 id
let curServiceId; //蓝牙服务 uuid,需要使用 getBLEDeviceServices 获取
let curCharacteristicRead; //当前设备读的uuid值
let curCharacteristicWrite; //当前设备写的uuid值
//蓝牙调用核心方法(order: 指令;mac:车辆编码;key:秘钥secret;cb:回调)
function operateBluetoothYws(order,mac, key, cb) {
curOrder = order;
curMac = mac;
curKey = key;
callBack = cb
searchBluetooth();
}
//第一步(uni.startBluetoothDevicesDiscovery(OBJECT),开始搜寻附近的蓝牙外围设备。)
function searchBluetooth() {
uni.startBluetoothDevicesDiscovery({
services: ['00000001-0000-1000-8000-00805F9B34FB', '00000002-0000-1000-8000-00805F9B34FB'],
success(res) {
console.log('第一步蓝牙startBluetoothDevicesDiscovery搜索成功res', res)
watchBluetoothFound();
},
fail(err) {
console.log('第一步蓝牙startBluetoothDevicesDiscovery搜索失败err', err)
callBack && callBack(false)
}
})
}
//第二步(uni.onBluetoothDeviceFound(CALLBACK),监听寻找到新设备的事件。)
function watchBluetoothFound() {
uni.onBluetoothDeviceFound(function(res) {
curDeviceId = res.devices.filter(i => i.localName.includes(curMac))[0].deviceId;
stopSearchBluetooth()
connectBluetooth()
})
}
//第三步(uni.createBLEConnection(OBJECT),连接低功耗蓝牙设备。)
function connectBluetooth() {
if (curDeviceId.length > 0) {
// #ifdef MP-WEIXIN
uni.createBLEConnection({
deviceId: curDeviceId,
timeout: 5000,
success: (res) => {
console.log('第三步通过deviceId连接蓝牙设备成功res', res);
getBluetoothServers()
},
fail: (err) => {
console.log('第三步通过deviceId连接蓝牙设备失败err', err);
callBack && callBack(false)
}
});
// #endif
// #ifdef MP-ALIPAY
my.connectBLEDevice({
deviceId: curDeviceId,
timeout: 5000,
success: (res) => {
console.log('第三步通过deviceId连接蓝牙设备成功res', res);
getBluetoothServers()
},
fail: (err) => {
console.log('第三步通过deviceId连接蓝牙设备失败err', err);
callBack && callBack(false)
}
});
// #endif
}
}
//第四步(uni.stopBluetoothDevicesDiscovery(OBJECT),停止搜寻附近的蓝牙外围设备。)
function stopSearchBluetooth() {
uni.stopBluetoothDevicesDiscovery({
success: (res) => {
console.log('第四步停止搜寻附近的蓝牙外围设备成功res', res);
},
fail: (err) => {
console.log('第四步停止搜寻附近的蓝牙外围设备失败err', err);
}
})
}
//第五步(uni.getBLEDeviceServices(OBJECT),获取蓝牙设备所有服务(service)。)
function getBluetoothServers() {
uni.getBLEDeviceServices({
deviceId: curDeviceId,
success(res) {
console.log('第五步获取蓝牙设备所有服务成功res', res);
//这里取res.services中的哪个,这是硬件产商配置好的,不同产商不同,具体看对接协议
if (res.services && res.services.length > 1) {
curServiceId = res.services[1].uuid
getBluetoothCharacteristics()
}
},
fail(err) {
console.log('第五步获取蓝牙设备所有服务失败err', err);
callBack && callBack(false)
}
})
}
//第六步(uni.getBLEDeviceCharacteristics(OBJECT),获取蓝牙设备某个服务中所有特征值(characteristic)。)
function getBluetoothCharacteristics() {
// #ifdef MP-WEIXIN
uni.getBLEDeviceCharacteristics({
deviceId: curDeviceId,
serviceId: curServiceId,
success: (res) => {
console.log('第六步获取蓝牙设备某个服务中所有特征值成功res', res);
curCharacteristicWrite = res.characteristics.filter(item => item && item.uuid.includes('0002'))[
0].uuid
curCharacteristicRead = res.characteristics.filter(item => item && item.uuid.includes('0003'))[
0].uuid
notifyBluetoothCharacteristicValueChange()
},
fail: (err) => {
console.log('第六步获取蓝牙设备某个服务中所有特征值失败err', err);
callBack && callBack(false)
}
});
// #endif
// #ifdef MP-ALIPAY
my.getBLEDeviceCharacteristics({
deviceId: curDeviceId,
serviceId: curServiceId,
success: (res) => {
console.log('第六步获取蓝牙设备某个服务中所有特征值成功res', res);
curCharacteristicWrite = res.characteristics.filter(item => item && item.characteristicId.includes('0002'))[
0].characteristicId
curCharacteristicRead = res.characteristics.filter(item => item && item.characteristicId.includes('0003'))[
0].characteristicId
notifyBluetoothCharacteristicValueChange()
},
fail: (err) => {
console.log('第六步获取蓝牙设备某个服务中所有特征值失败err', err);
callBack && callBack(false)
}
});
// #endif
}
//第七步(uni.notifyBLECharacteristicValueChange(OBJECT),启用低功耗蓝牙设备特征值变化时的 notify 功能,订阅特征值。)
function notifyBluetoothCharacteristicValueChange() {
uni.notifyBLECharacteristicValueChange({
deviceId: curDeviceId,
serviceId: curServiceId,
characteristicId: curCharacteristicRead,
state: true,
success(res) {
console.log('第七步启用低功耗蓝牙设备特征值变化时的 notify 功能,订阅特征值成功res', res);
if(curOrder == 'open'){
//寻车指令
getRandomCode();
}else if(curOrder == 'close'){
//查看锁状态指令
getLockStatus();
}else{
}
//第八步(监听)(uni.onBLECharacteristicValueChange(CALLBACK),监听低功耗蓝牙设备的特征值变化事件。),含下发指令后的上行回应接受
//这里会一直监听设备上行,所以日志等需清除
uni.onBLECharacteristicValueChange((characteristic) => {
// #ifdef MP-WEIXIN
//完整的蓝牙回应数据
let ciphertext = ab2hex(characteristic.value);
//蓝牙回应数据的信息功能体和错误码
let curFeature = ab2hex(characteristic.value).slice(6, 10);
//蓝牙回应数据的错误码
let errCode = ab2hex(characteristic.value).slice(8, 10);
// #endif
// #ifdef MP-ALIPAY
//完整的蓝牙回应数据
let ciphertext = characteristic.value;
//蓝牙回应数据的信息功能体和错误码
let curFeature = characteristic.value.slice(6, 10);
//蓝牙回应数据的错误码
let errCode = characteristic.value.slice(8, 10);
// #endif
if (curFeature.startsWith('91')) { //寻车响应,拿到随机码
//用于给开锁的随机码
getUnlockData(ciphertext)
} else if (curFeature.startsWith('9200')) { //开锁响应(成功)
callBack && callBack(true)
} else if (curFeature.startsWith('98')) { //关锁后APP主动读取后的响应,查看是否已关锁
if (curFeature == '9801') { //关锁成功
callBack && callBack(true)
} else { //关锁失败
callBack && callBack(false)
}
} else {
}
})
},
fail(err) {
console.log('第七步启用低功耗蓝牙设备特征值变化时的 notify 功能,订阅特征值失败err', err);
callBack && callBack(false)
}
})
}
// ArrayBuffer转16进度字符串示例
function ab2hex(buffer) {
const hexArr = Array.prototype.map.call(
new Uint8Array(buffer),
function (bit) {
return ('00' + bit.toString(16)).slice(-2)
}
)
return hexArr.join('')
}
//寻车指令,用于拿到开锁所需的随机码
function getRandomCode() {
let str = '7B5B01610060';
writeBLE(str)
}
//开锁指令,获取到开锁所需的数据
function getUnlockData(ciphertext) {
if (ciphertext.length > 16) { //确保寻车后蓝牙响应内容有用于开锁的随机码
//开锁头(固定值)
let headData = '7B5B1B6200';
//用户手机号
let userPhone = '018106053735';
//4B大端秒级时间戳
let timestamp = convertLettersToUpperCase(decimalToHex(getSecondsTimestamp()));
//随机码 + 8个‘0’
let randomVal = convertToLower(ciphertext.slice(16, 24)) + '00000000';
//AES加密后的前32位密文
let aesResult = aesEncrypt(randomVal,curKey).slice(0,32)
//校验码
let checkCode = '00';
//最后用于发指令的内容
let result = headData + userPhone + timestamp + aesResult + checkCode;
writeBLE(result)
} else {
getRandomCode();
}
}
//查看锁状态指令,用于验证用户手工关锁后查询是否真的已关锁
function getLockStatus() {
let str = '7B5B006868';
writeBLE(str)
}
//AES的ECB方式加密,以hex格式(转大写)输出;参数一:明文数据,参数二:秘钥
function aesEncrypt(encryptString, key) {
let aeskey = CryptoJS.enc.Utf8.parse(key);
let aesData = CryptoJS.enc.Utf8.parse(encryptString);
let encrypted = CryptoJS.AES.encrypt(aesData, aeskey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
//将base64格式转为hex格式并转换成大写
let password = encrypted.ciphertext.toString().toUpperCase()
return password;
}
//处理写入数据
function writeBLE(str) {
//如果大于20个字节则分包发送
if (str.length > 20) {
let curArr = splitString(str,20);
// #ifdef MP-WEIXIN
curArr.map(i => writeBLECharacter(hexStringToArrayBuffer(i)))
// #endif
// #ifdef MP-ALIPAY
curArr.map(i => writeBLECharacter(i))
// #endif
} else {
// #ifdef MP-WEIXIN
writeBLECharacter(hexStringToArrayBuffer(str));
// #endif
// #ifdef MP-ALIPAY
writeBLECharacter(str);
// #endif
}
}
//第八步(写入)(uni.writeBLECharacteristicValue(OBJECT),向低功耗蓝牙设备特征值中写入二进制数据。)
function writeBLECharacter(bufferValue){
uni.writeBLECharacteristicValue({
deviceId: curDeviceId,
serviceId: curServiceId,
characteristicId: curCharacteristicWrite,
value: bufferValue,
success(res) {
console.log('第八步(写入)向低功耗蓝牙设备特征值中写入二进制数据成功res', res);
},
fail(err) {
console.log('第八步(写入)向低功耗蓝牙设备特征值中写入二进制数据失败err', err);
callBack && callBack(false)
}
})
}
//将字符串以每length位分割为数组
function splitString(str, length) {
var result = [];
var index = 0;
while (index < str.length) {
result.push(str.substring(index, index + length));
index += length;
}
return result;
}
//字符转ArrayBuffer
function hexStringToArrayBuffer(str) {
// 将16进制转化为ArrayBuffer
return new Uint8Array(str.match(/[\da-f]{2}/gi).map(function(h) {
return parseInt(h, 16)
})).buffer
}
//对字符串中的英文大写转小写
function convertToLower(str) {
var result = '';
for (var i = 0; i < str.length; i++) {
if (/[a-zA-Z]/.test(str[i])) {
result += str[i].toLowerCase();
} else {
result += str[i];
}
}
return result;
}
//对字符串中的英文小写转大写
function convertLettersToUpperCase(str) {
var result = str.toUpperCase(); // 将字符串中的字母转换为大写
return result;
}
//获取秒级时间戳(十进制)
function getSecondsTimestamp() {
var timestamp = Math.floor(Date.now() / 1000); // 获取当前时间戳(单位为秒)
return timestamp;
}
//将十进制时间戳转成十六进制
function decimalToHex(timestamp) {
var hex = timestamp.toString(16); // 将十进制时间戳转换为十六进制字符串
return hex;
}
//抛出蓝牙核心方法
module.exports = {
operateBluetoothYws
};
- 解读:
这里的步骤和上面流程图中的步骤走向是一样的,不过里面的详情,笔者还是想每一步都拆开来对着实际案例讲述为好,详见下文(这里主要是为了照顾小白,大佬勿怪)。
六. 蓝牙模块层各步骤详解。
- 蓝牙功能调用核心方法的定义和导出(operateBluetoothYws)
operateBluetoothYws 这里没啥好特别的,就是将业务层传进来的参数做个中转处理,为后续步骤的api所调用,详见上文代码及其注释。
- 第一步(uni.startBluetoothDevicesDiscovery(OBJECT))
uni.startBluetoothDevicesDiscovery 这里主要注意的是services这个参数,这个参数会由硬件厂家提供,一般在其提供的蓝牙协议文档中会标注,作用是要搜索的蓝牙设备主 service 的 uuid 列表。某些蓝牙设备会广播自己的主 service 的 uuid。如果设置此参数,则只搜索广播包有对应 uuid 的主服务的蓝牙设备。建议主要通过该参数过滤掉周边不需要处理的其他蓝牙设备。
- 第二步(uni.onBluetoothDeviceFound(CALLBACK))
uni.onBluetoothDeviceFound 这一步用来确定目标设备id,即后续步骤所需的参数deviceId。 这里主要注意的是其回调函数的devices结果,我们要根据厂家或其提供的蓝牙对接协议规定和我们业务层传进来的mac来匹配筛选出目标设备(因为这里会监听到第一步同样的uuid的每一台设备)(这里我就一台设备测试,所以回调函数的devices结果数组中内容就一个;然后之所以用localName.includes(curMac) 来匹配目标设备,这是根据厂商协议文档来做的,每家厂商和每种设备不一样,这里要按实际情况处理,不过万变不离其宗)。
- 第三步(uni.createBLEConnection(OBJECT))
uni.createBLEConnection 这里没啥特别的,主要就是用到第二步中得到的deviceId去连接低功耗蓝牙目标设备。需要注意的是这里支付宝小程序的API不一致,为my.connectBLEDevice。
- 第四步(uni.stopBluetoothDevicesDiscovery(OBJECT))
uni.stopBluetoothDevicesDiscovery 这一步主要是为了节省电量和资源,在第三步连接目标设备成功后给停止搜寻附近的蓝牙外围设备。
- 第五步(uni.getBLEDeviceServices(OBJECT))
uni.getBLEDeviceServices 这里通过第二步中得到的deviceId用来获取蓝牙目标设备的所有服务并确定后续步骤所需用的蓝牙服务uuid(serviceId)。这里取res.services中的哪个,这是硬件厂商定好的,不同厂商不同,具体看对接协议(案例中的是固定放在第2个,所以是通过curServiceId = res.services[1].uuid得到)。
- 第六步(uni.getBLEDeviceCharacteristics(OBJECT))
uni.getBLEDeviceCharacteristics 这里通过第二步获取的目标设备IddeviceId和第五步获取的蓝牙服务IdserviceId来得到目标设备的写的uuid和读的uuid。这里取characteristics的哪一个也是要根据厂商和其提供的蓝牙协议文档来决定的(案例以笔者这的协议文档为主,所以是这样获取的:curCharacteristicWrite = res.characteristics.filter(item => item && item.uuid.includes('0002'))[0].uuid 和 curCharacteristicRead = res.characteristics.filter(item => item && item.uuid.includes('0003'))[0].uuid)。需要注意的是这里支付宝小程序的API不一致,为my.getBLEDeviceCharacteristics,其res返回值也不一样,curCharacteristicWrite = res.characteristics.filter(item => item && item.characteristicId.includes('0002'))[0].characteristicId 和 curCharacteristicRead = res.characteristics.filter(item => item && item.characteristicId.includes('0003'))[0].characteristicId。
- 第七步(uni.notifyBLECharacteristicValueChange(OBJECT))
uni.notifyBLECharacteristicValueChange 这里就是开启低功耗蓝牙设备特征值变化时的 notify 功能,订阅特征值。可以在其的success内执行一些写入操作和执行uni.onBLECharacteristicValueChange(CALLBACK)来监听低功耗蓝牙设备的特征值变化事件了。
9.第八步(写入)(uni.writeBLECharacteristicValue(OBJECT))
uni.writeBLECharacteristicValue 这里特别要注意的是参数value必须为二进制值(这里需用注意的是支付宝小程序的参数value可以不为二进制值,可直接传入,详见支付宝小程序开发文档);并且单次写入不得超过20字节,超过了需分段写入。
- 第八步(监听)(uni.onBLECharacteristicValueChange(CALLBACK))
uni.onBLECharacteristicValueChange 这里需根据实际开发的业务场景对CALLBACK 返回参数转16进度字符串后自行处理(支付宝小程序如果写入时未转换,那么这里读取时也不需要转换)(本文以寻车--开锁--检测锁状态为例)。
七. 总结。
以上就是本文的所有内容,主要分为2部分----业务层和蓝牙模块层(封装)。业务层只需要关注目标设备和其对应的密钥(不同厂家和设备不同);蓝牙模块层主要是按蓝牙各API拿到以下四要素并按流程图一步步执行即可。
- 蓝牙设备Id:deviceId
- 蓝牙服务uuid:serviceId
- 蓝牙写操作的uuid
- 蓝牙读操作的uuid
至此,如何在小程序中优雅地封装蓝牙模块并高效使用就已经完结了,当然本文只是以最简而易学的案例来讲述蓝牙模块开发,大多只处理了success的后续,至于fail后续可以根据大家实际业务处理。相信看到这,你已经对小程序开发蓝牙功能,对接各种蓝牙协议已经有一定的认识了,再也不虚PM小姐姐的蓝牙需求了。完结撒花~ 码文不易,还请各位大佬三连鼓励(如发现错别之处,还请联系笔者修正)。