七的博客

基于uni-app的BLE蓝牙协议通信

网络通信JavaScript

基于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 官网的文档,开发一个低功耗蓝牙功能大致流程如下:

开发流程

大概分为下面几个步骤,自行在下面的流程中插入特殊的业务逻辑。

  1. 初始化蓝牙模块,调用 uni.openBluetoothAdapter 初始化蓝牙模块。
  2. 搜索蓝牙设备,使用 uni.startBluetoothDevicesDiscovery 开始搜索周围的蓝牙设备。
  3. 监听发现的设备,使用 uni.onBluetoothDeviceFound 监听新发现的蓝牙设备。
  4. 连接到设备,选择要连接的设备,使用 uni.createBLEConnection 连接到该设备。
  5. 获取设备服务,连接成功后,使用 uni.getBLEDeviceServices 获取该设备提供的服务列表。
  6. 获取特征值,使用 uni.getBLEDeviceCharacteristics 获取该服务的特征值列表。
  7. 读取或写入数据
    • 读取数据:使用 uni.readBLECharacteristicValue 读取特征值。
    • 写入数据:使用 uni.writeBLECharacteristicValue 向特征值写入数据。
  8. 监听数据变化
    • 使用 uni.notifyBLECharacteristicValueChange 启用通知。
    • 使用 uni.onBLECharacteristicValueChange 监听数据变化。
  9. 断开连接
    • 使用完毕后,调用 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);
    }
}

参考资料