基于uni-app的BLE蓝牙协议通信
基于uni-app的BLE蓝牙协议通信
这里的 BLE 是低功耗蓝牙的英文缩写(Bluetooth Low Energy)。
最近在做一个电表蓝牙模块通信的功能,需要使用移动端 APP 去连接蓝牙模块通信。之前还没接触过这块,稍微花了点时间去研究了下这个 BLE (低功耗蓝牙) 协议,同时使用 Uniapp 的接口封装了一版低功耗蓝牙协议的工具。
主要从以下几个点总结下:
经典蓝牙协议的概念
BLE 低功耗蓝牙协议的概念
Uni-app 开发低功耗蓝牙功能流程
Uni-app 低功耗蓝牙代码
避坑点
1. 经典蓝牙协议
对于蓝牙这个功能,在生活中应该可以看到很多的案例。比如在智能手机上,蓝牙基本就是一个标配的功能。
通过蓝牙可以快速地在两部手机之间传输照片、文档等文件。也可以在没有数据线的情况下,就能在手机和电脑之间传输文件。
包括像一些蓝牙鼠标、蓝牙键盘等等,也很多是使用的经典蓝牙协议。而且蓝牙协议从 90年代被爱立信公司发明出来,已经迭代了很多个版本。发明它的目的是为了取代 RS-232 数据线。
蓝牙历史版本特性:
- 蓝牙1.0 (1999): 首次发布,问题多,速率1Mbps。
- 蓝牙1.2 (2003): 改善抗干扰,提高语音质量。
- 蓝牙2.0+EDR (2004): 速率提升至3Mbps,降低功耗。
- 蓝牙2.1+EDR (2007): 引入安全简单配对(SSP)。
- 蓝牙3.0+HS (2009): 通过Wi-Fi达到24Mbps高速传输。
- 蓝牙4.0 (2010): 引入低功耗蓝牙(BLE),里程碑式版本。
- 蓝牙4.1 (2013): 改善与4G/LTE共存能力。
- 蓝牙4.2 (2014): 提高传输速度和安全性。
- 蓝牙5.0 (2016): 大幅提升速度和范围。
- 蓝牙5.1 (2019) 和 5.2 (2020): 主要针对BLE改进。
有了上面的版本参照,需要指出经典蓝牙是指的蓝牙 4.0 版本之前的所有蓝牙技术,以及 4.0 及以后版本中保留的传统蓝牙功能。
经典蓝牙的主要特点:
- 传输速度比较快。
- 可以长时间保持连接。
- 适合大量数据传输。
- 功耗相对较高。
记住这几个特点就容易理解跟 BLE 蓝牙协议的区别了。 从蓝牙1.0到蓝牙5.0及以后,经典蓝牙的核心功能一直存在,只是后续版本主要聚焦于 BLE 蓝牙的改进。
经典蓝牙有自己特定的协议栈,包括L2CAP、RFCOMM等协议,这里不深入分析,还是有挺多东西的里面。
2. BLE 低功耗蓝牙协议
引入低功耗蓝牙(Bluetooth Low Energy)协议是为了解决经典蓝牙在某些应用场景中的不足的地方。
低功耗蓝牙的主要特点:
- 传输速度更慢。
- 适合小数量量传输。
- 功耗比较低。使用小电池运行数月甚至数年。
- 芯片更简单、更便宜。
这几个特点加起来: 省电+连接快+成本低 ,这就让低功耗蓝牙很容易有市场。
但是低功耗蓝牙也有缺点,比如只适合小数据量传输、周期性传输。但是经典蓝牙可以大数据量传输、持续传输。
这些都是在性能、功耗、复杂度之间的一个平衡出来的结果,没有哪个更好或者更不好,只有适不适合。
3. 低功耗蓝牙的一些核心概念
概念主要是从百科上抽取总结,还参考了 Android dev 文档,上面有比较详细的介绍。
注意下面的这些俗语尽量理解英文术语,中文翻译过来的不一定合适。
3.1 角色
BLE设备角色主要分为两种角色,中心设备 Central 跟外围设备 Peripheral ,中心设备跟外围设备建立连接之后才能相互收发数据。
- 中心设备 Central :扫描并连接外围设备,比如智能手机、电脑等。
- 外围设备 Peripheral :主动广播、处理中心设备的连接请求,比如智能手环等。
中心设备可以发起对外围设备的扫描以及连接,但是外围设备只能发送广播等待中心设备连接。
不过这里需要注意的是,蓝牙协议本身不会指定某个设备的角色。一个设备可以同时是中心设备,也可以同时是外围设备。就比如我们的手机可以去连接别人手机蓝牙,也可以被别人手机蓝牙连接。
另外还有 2 种角色,分别是还有观察者 Observer 和广播者 Broadcaster ,不过一般不常用,感兴趣可以单独搜索资料。
3.2 GATT
GATT 是 Generic Attribute Profile 的缩写,翻译过来是【通用属性配置文件】。GATT 描述的是设备建立连接后怎么去传输数据。
又会引发出下面几个概念:
3.2.1 服务 Service
所谓的服务就是一组相关特性的集合,用来描述设备的功能。
比如以智能电表为例,一个电表的通信模块可能会提供下面的服务:
- 读取电量的服务。假设 UUID 是 0x1800 ,它会提供当前的实时电量以及历史电量数据。 这里的实时电量以及历史电量是 2个不同的数据,所以分别是 2 个特征 Characteristic。也就是说这个服务下有 2 个特征。
- 修改电表参数的服务。 假设 UUID 是 0x1801,它会提供修改当前电表的时间、修改电表每度电的价格等等。 这里的【修改当前电表的时间】、【修改电表每度电的价格】 就是服务下的 2 个特征。
- 读取电表信息的服务。 假设 UUID 是 0x1802,它会提供读取设备序列号、制造商名称、固件版本等等信息。这个服务下就有 3个特征。
可以把服务想象成一个文件夹,每个服务里面会包含一组特征值。
3.2.2 特征值 Characteristic
特征值就是服务中的数据单元,会包含一个或多个值和描述。
比如上面的电量读取服务下的 2 个特征值:
- 当前电量数据,返回的是 uint32 类型的千瓦时值。
- 历史用电量数据,返回的是 uint32 类型的千瓦时值。
3.2.3 描述符 Descriptor
每个特征值可能又会有描述符的概念,比如【当前电量数据】特征值可能有下面的描述符:
- 单位描述符。值是 Wh 瓦时。
- 有效范围描述符。这个描述符指定读书的有效范围,比如最小值 = 0,最大值 = 999999。
3.2.4 属性 Attribute
属性是 GATT 中用于表示数据的最基本单元,每个服务、特征和描述符都是一个属性。
3.3 广播
广播就是外围设备每隔一段时间就会发送一次广播数据包 (Advertising Packets)。这个时间间隔就是广播间隔,动作叫做广播事件。只有在外围设备处于广播状态时,中心设备才能搜索到该外围设备。
广播包里面包含设备的基本信息和可用服务。
核心点:
- 外围设备是定时发送广播包,所以有时候你会发现扫描不到设备就是这个原因。
- 外围设备处于广播状态时才能被搜索到。
3.4 连接与配对
连接 Connection :连接是在蓝牙设备之间建立稳定的双向通信通道的过程。一个设备(通常是中心设备)发起连接请求,另一个设备(通常是外围设备)接受请求。连接期间双方协商连接参数,如传输速率和连接间隔。( 这里注意下,写 uniapp 连接代码时,有些操作就在这个过程 )
配对 Pairing :配对是在蓝牙设备之间建立安全连接的过程。设备交换安全密钥,然后验证双方身份,最后建立加密通道。
绑定 Bonding :绑定是将配对信息永久存储的过程,便于设备将来重新连接。比如我们平时手机连接了蓝牙耳机,下次一般都是可以直接连接上的。
3.5 数据包与MTU
数据包 Packet 就是 BLE通信中传输的基本的数据单元,包含头部和有效载荷。 跟 TCP 协议中套路差不多。
MTU 就是 Maximum Transmission Unit 的缩写,一次传输中允许的最大数据量,影响数据传输效率。
MTU 可以稍微注意下, Uni-app 接口中也有涉及到这个参数。
3.6 Attribute Protocol 属性协议
缩写是 ATT 。 主要是定义了如何在BLE设备之间传输数据,特别是如何访问和操作属性 Attributes。
核心的几个操作以及例子讲解,例子还是以智能电表为例:
读操作(Read Request/Response):作用是手机App读取电表当前数据。首先是App发送读取请求,电表蓝牙模块回复包含当前用电量的响应。应用场景如用户查看自己的电表实时电量读数。
写操作(Write Request/Response):作用是手机App向电表写入新的设置。首先是App发送包含新参数的写入请求,电表执行设置更新并确认。应用场景比如远程调整每度电的价格或一天可以用多少电。
通知(Notify):作用是电表主动推送用电数据更新无需确认。首先是 App 启用通知功能,电表后续自动发送定期的用电量更新。应用场景就是电表可以通过蓝牙模块主动推送剩余电量等信息到 APP。
指示(Indicate):作用是电表推送重要事件并确保接收。首先是 App 启用指示功能,电表发送关键事件通知并等待 App 确认。应用场景如发送电量超出每天可用的最大电量的警报信息,这种需要确认的。
可以看出,通知跟指示的区别就在于要不要确认。
4. Uni-app 开发低功耗蓝牙功能流程
根据 uni-app 官网的文档,开发一个低功耗蓝牙功能大致流程如下:
大概分为下面几个步骤,自行在下面的流程中插入特殊的业务逻辑。
- 初始化蓝牙模块,调用
uni.openBluetoothAdapter
初始化蓝牙模块。 - 搜索蓝牙设备,使用
uni.startBluetoothDevicesDiscovery
开始搜索周围的蓝牙设备。 - 监听发现的设备,使用
uni.onBluetoothDeviceFound
监听新发现的蓝牙设备。 - 连接到设备,选择要连接的设备,使用
uni.createBLEConnection
连接到该设备。 - 获取设备服务,连接成功后,使用
uni.getBLEDeviceServices
获取该设备提供的服务列表。 - 获取特征值,使用
uni.getBLEDeviceCharacteristics
获取该服务的特征值列表。 - 读取或写入数据
- 读取数据:使用
uni.readBLECharacteristicValue
读取特征值。 - 写入数据:使用
uni.writeBLECharacteristicValue
向特征值写入数据。
- 读取数据:使用
- 监听数据变化
- 使用
uni.notifyBLECharacteristicValueChange
启用通知。 - 使用
uni.onBLECharacteristicValueChange
监听数据变化。
- 使用
- 断开连接
- 使用完毕后,调用
uni.closeBLEConnection
断开与设备的连接。
- 使用完毕后,调用
5. Uni-app 低功耗蓝牙代码
单独写一个 bluetooth.js 文件来封装这些操作:
const N58_SERVICE_ID = '0000FFE0-0000-1000-8000-00805F9B89F5';
let findBleDeviceList = [];
const findIndexInDeviceList = (arr, key, val) => {
return arr.findIndex(item => item[key] === val);
};
const logger = {
error: (message) => console.error(`[错误] ${message}`),
warn: (message) => console.warn(`[警告] ${message}`),
info: (message) => console.info(`[信息] ${message}`)
};
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
export const openDeviceBluetooth = () => {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success: resolve,
fail: reject
});
});
};
export const scanBleList = async () => {
findBleDeviceList = [];
try {
await openDeviceBluetooth();
await startBluetoothDevicesDiscovery();
return await addBleDeviceListener();
} catch (error) {
logger.error(`扫描蓝牙列表失败: ${JSON.stringify(error)}`);
throw error;
}
};
const addBleDeviceListener = () => {
return new Promise((resolve, reject) => {
uni.onBluetoothDeviceFound((devices) => {
const device = devices.devices[0];
const idx = findIndexInDeviceList(findBleDeviceList, 'deviceId', device.deviceId);
if (idx === -1 && (device.name || device.localName)) {
device.name = device.name || device.localName;
if (device.name !== '未知设备') {
findBleDeviceList.push(device);
}
}
});
setTimeout(async () => {
try {
const res = await findDeviceList();
onBLEConnectionStateChange();
resolve(res);
} catch (err) {
reject(err);
}
}, 500);
});
};
const startBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
success: resolve,
fail: (err) => {
logger.error(`开始发现蓝牙设备失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
export const findDeviceList = () => {
stopBluetoothDevicesDiscovery();
return new Promise((resolve, reject) => {
uni.getBluetoothDevices({
success: resolve,
fail: (err) => {
logger.error(`获取蓝牙设备列表失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
const stopBluetoothDevicesDiscovery = () => {
uni.stopBluetoothDevicesDiscovery({
success: () => logger.info('停止蓝牙设备搜索')
});
};
export const closeBle = (deviceId) => {
return new Promise((resolve, reject) => {
if (!deviceId) {
reject(new Error('设备ID不能为空'));
return;
}
uni.closeBLEConnection({
deviceId,
success: () => {
uni.removeStorageSync('RESCODE');
uni.removeStorageSync('BLECONNID');
}
});
uni.closeBluetoothAdapter({
success: resolve,
fail: (err) => {
logger.error(`关闭蓝牙适配器失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
export const connect = (deviceId) => {
return new Promise((resolve, reject) => {
uni.createBLEConnection({
deviceId,
timeout: 10000,
success: resolve,
fail: (err) => {
logger.error(`创建BLE连接失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
export const connectAndNotify = async (deviceId, serviceId) => {
try {
await connect(deviceId);
await sleep(1500);
const services = await getAllDeviceBleServicesByDeviceId(deviceId);
const targetService = await getTargetBleServiceByServiceUuid(services.services, serviceId);
const characteristics = await getAllCharacteristicsByServiceId(deviceId, serviceId);
const [readId, writeId] = await Promise.all([
matchReadAndNotifyCharacteristics(characteristics.characteristics),
matchWriterCharacteristics(characteristics.characteristics)
]);
await notify(deviceId, serviceId, readId);
return { readId, writeId };
} catch (error) {
logger.error(`连接和通知失败: ${JSON.stringify(error)}`);
throw error;
}
};
const onBLEConnectionStateChange = () => {
uni.onBLEConnectionStateChange((res) => {
const eventValue = res.connected ? 'connected' : 'disconnected';
log.info(`连接状态变化: ${eventValue}`)
});
};
const getAllDeviceBleServicesByDeviceId = (deviceId) => {
return new Promise((resolve, reject) => {
uni.getBLEDeviceServices({
deviceId,
success: resolve,
fail: (err) => {
logger.error(`获取BLE设备服务失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
const getTargetBleServiceByServiceUuid = (services, uuid) => {
return new Promise((resolve, reject) => {
const targetService = services.find(e => e.isPrimary && e.uuid === uuid);
if (!targetService) {
reject(new Error(`未找到匹配的服务,UUID: ${uuid}`));
} else {
resolve(targetService);
}
});
};
const getAllCharacteristicsByServiceId = (deviceId, serviceId) => {
return new Promise((resolve, reject) => {
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success: resolve,
fail: (err) => {
logger.error(`获取BLE设备特征值失败: ${JSON.stringify(err)}`);
reject(err);
}
});
});
};
const matchReadAndNotifyCharacteristics = (characteristics) => {
return new Promise((resolve, reject) => {
const notifyCharacteristic = characteristics.find(e => e.properties && e.properties.notify);
if (!notifyCharacteristic) {
reject(new Error('没有支持通知的特征值'));
} else {
resolve(notifyCharacteristic.uuid);
}
});
};
const matchWriterCharacteristics = (characteristics) => {
return new Promise((resolve, reject) => {
const writeCharacteristic = characteristics.find(e => e.properties && e.properties.write);
if (!writeCharacteristic) {
reject(new Error('没有支持写入的特征值'));
} else {
resolve(writeCharacteristic.uuid);
}
});
};
export const notify = (deviceId, serviceId, characteristicId) => {
return new Promise((resolve, reject) => {
uni.notifyBLECharacteristicValueChange({
state: true,
deviceId,
serviceId,
characteristicId,
success: () => {
uni.onBLECharacteristicValueChange((res) => {
try {
const hexCode = ab2hex(res.value);
log.info(`收到消息 ${hexCode}`)
} catch (e) {
logger.error(`BLE特征值变化处理错误: ${e}`);
}
});
resolve();
},
fail: reject
});
});
};
const ab2hex = (buffer) => {
return Array.prototype.map.call(new Uint8Array(buffer), bit => ('00' + bit.toString(16)).slice(-2)).join('');
};
export const writeBLE = async (deviceId, serviceId, characteristicId, sendData) => {
if (!deviceId || !serviceId || !characteristicId) {
throw new Error('deviceId、serviceId和characteristicId不能为空');
}
sendData = sendData.replace(/\s+/g, "");
const buffer = toArrayBuffer(sendData);
const chunkSize = 20;
for (let i = 0; i < buffer.byteLength; i += chunkSize) {
const chunk = buffer.slice(i, Math.min(i + chunkSize, buffer.byteLength));
await write(deviceId, serviceId, characteristicId, chunk);
await sleep(100);
}
};
const write = (deviceId, serviceId, characteristicId, value) => {
return new Promise((resolve, reject) => {
uni.writeBLECharacteristicValue({
deviceId,
serviceId,
characteristicId,
value,
success: resolve,
fail: reject
});
});
};
const toArrayBuffer = (str) => {
if (!str) return new ArrayBuffer(0);
const buffer = new ArrayBuffer(str.length / 2);
const dataView = new DataView(buffer);
for (let i = 0; i < str.length; i += 2) {
dataView.setUint8(i / 2, parseInt(str.substr(i, 2), 16));
}
return buffer;
};
这样去调用:
import * as BLE from './bluetooth.js';
// 搜索设备
async function searchDevices() {
try {
await BLE.scanBleList();
console.log('搜索到的设备:', findBleDeviceList);
} catch (error) {
console.error('搜索设备失败:', error);
}
}
// 连接并开启通知
async function connectAndNotify(deviceId, serviceId) {
try {
const { readId, writeId } = await BLE.connectAndNotify(deviceId, serviceId);
console.log('成功连接并开启通知,读特征值ID:', readId, '写特征值ID:', writeId);
return { readId, writeId };
} catch (error) {
console.error('连接并开启通知失败:', error);
}
}
// 写数据
async function writeData(deviceId, serviceId, characteristicId, data) {
try {
await BLE.writeBLE(deviceId, serviceId, characteristicId, data);
console.log('数据写入成功');
} catch (error) {
console.error('数据写入失败:', error);
}
}
// 关闭连接
async function closeConnection(deviceId) {
try {
await BLE.closeBle(deviceId);
console.log('成功关闭连接');
} catch (error) {
console.error('关闭连接失败:', error);
}
}