IoT之低功耗蓝牙

IoT之低功耗蓝牙

Aron Lv3

低功耗蓝牙简介

低功耗蓝牙(Bluetooth Low Energy,简称 BLE)是一种短距离无线通信技术,它与经典蓝牙的主要区别在于,低功耗蓝牙传输距离短,传输速度快,每次传输的数据量小,功耗低,适合用于电池供电的设备,而经典蓝牙适合用于传输文件,功耗较大。
蓝牙和 Wi-Fi 调用在本质上都是一样的,本文基于React Native来示例说明。
低功耗蓝牙的使用流程是:

  1. 扫描设备列表
  2. 连接设备,这一步获得设备 ID
  3. 扫描设备服务,这一步获得服务 ID
  4. 扫描设备特征,这一步获得特征 ID
  5. 读写设备特征,不同的特征扮演不同的角色,有些负责读数据,有些负责写数据,有些负责做广播通知
  6. 断开连接

简单来说,蓝牙通信就是给某个特征收发消息,可类比为给某个小区的某一栋的某个人沟通。
这其中最主要的是要知道哪个特征负责什么任务,这一步往往需要跟硬件工程师沟通,拟定协议。

代码实现

请求权限

本质上安卓和 iOS 都需要权限。

iOS

Info.plist中添加NSBluetoothAlwaysUsageDescription字段,这是你向用户请求蓝牙权限的说明。

iOS 无需手动请求权限,权限弹窗会在你使用蓝牙 API 的时候自动弹出。

Android

AndroidManifest.xml文件中添加以下权限内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Android >= 12 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android < 12 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- 通用 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- 如果你的应用需要持续使用蓝牙,添加此行:
https://developer.android.com/guide/topics/connectivity/bluetooth-le.html#permissions
-->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

安卓需要手动请求权限,利用PermissionAndroid请求对应的权限:

1
2
3
4
5
const result = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
]);

需要注意的是,蓝牙扫描权限和位置权限密不可分。

安装与配置

1
npx expo install react-native-ble-plx

app.json中添加 config plugin 配置,详见官方 README,以下是示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"expo": {
"plugins": [
[
"react-native-ble-plx",
{
"isBackgroundEnabled": true,
"modes": ["peripheral", "central"],
"bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to bluetooth devices"
}
]
]
}
}

初始化

1
2
3
import { BleManager } from "react-native-ble-plx";

const manager = new BleManager();

某些情况下,用户可能没有打开蓝牙,你可能会有提醒用户打开蓝牙的需求。
可以利用manager.state()方法获取当前蓝牙状态,所有的功能方法,包括扫描,连接等,都必须在蓝牙状态为PoweredOn时才能调用。
对于安卓平台,可以使用manager.enable()方法手动打开蓝牙功能,对于 iOS 平台,你只能手动引导用户去打开蓝牙。
打开后,通过manager.onStateChange()方法监听蓝牙打开状态:

1
2
3
4
5
6
const subscription = manager.onStateChange((state) => {
if (state === "PoweredOn") {
this.scanAndConnect();
subscription.remove();
}
}, true);

扫描周边设备列表

使用manager.startDeviceScan()方法扫描周边的设备列表,该方法会在每次扫描到一个新设备后进行回调。并返回你一个Device对象。

1
2
3
4
5
6
7
8
9
10
11
12
function scanAndConnect() {
manager.startDeviceScan(null, null, (error, device) => {
if (error) {
// Handle error (scanning will be stopped automatically)
return;
}

if (device.name === "TI BLE Sensor Tag" || device.name === "SensorTag") {
manager.stopDeviceScan();
}
});
}

至此,已经拿到了自己需要的设备信息,我们可以连接设备,扫描设备服务和特征等。
需要注意的是,device.id在安卓上对应 MAC 地址,在 iOS 上对应 UUID。

连接设备

一定要先连接蓝牙,才能进行后续的读取、写入操作。拿到 Device 对象后,调用connect()方法即可连接设备。

1
2
3
4
5
6
7
8
9
10
11
12
device
.connect()
.then((device) => {
/// 识别、发现该设备上的所有服务和特征
return device.discoverAllServicesAndCharacteristics();
})
.then((device) => {
return device.services();
})
.catch((error) => {
// Handle errors
});

至此,我们连接设备,并拿到了该设备上所有支持的服务和特征。特征信息保存在Service类里面,可以进行读写数据了!

消息读写

写入

通常来说,这一步需要跟硬件工程师协商,我们要知道每个特征具体负责什么任务,传递什么样的数据,这个数据协议往往要跟硬件工程师一起设计。
比如Shelly 电表文档中直接写出,向b0a7e40f-2b87-49db-801c-eb3686a24bdb这个特征写入1即可重置设备。
假设你真的无法跟硬件工程师沟通,你可以通过遍历service.characteristics(),拿到所有的特征列表,通过判断 UUID,以及可读写状态,来确定你需要的特征。

注意

低功耗蓝牙传输中,必须传递的是二进制数组,不能直接传递字符串。但是react-native-ble-plx提供的方法,只能传递 Base64,所以应当先拿到二进制数组,再转化成 Base64 字符串。
低功耗蓝牙传输中,每次传输的数据长度有限制。ATT MTU(Attribute Protocol Maximum Transmission Unit)是 BLE 通信中一次可以传输的最大字节数。默认情况下它是 23 字节。但是协议头会占据 3 字节,所以默认情况下每次传输的数据长度不能超过 20 字节。
好在 BLE 可以通过 MTU 协商,来增大这个值,在大多数现代设备上,MTU 可以协商到 247 字节甚至更高!
但是总的来说,MTU 协商是不可靠的,不同厂商的设备支持的 MTU 值可能不同,基于 MTU 协商的数据传输不能保证每次都能传输成功,所以我们往往需要对数据进行切片,多次传输。
具体还是要看设备,有些设备不需要协商,本身就支持很大的 MTU 长度。

大小端序

大小端序(Endianness)是指在多字节数据在内存中的存储顺序。
通常来说,我们在处理多字节数据时,需要知道数据在内存中的存储顺序,才能正确的解析数据。
比如,一个 32 位整数0x12345678,在内存中的存储顺序可能是:

  • 大端序(Big Endian):0x12 0x34 0x56 0x78
  • 小端序(Little Endian):0x78 0x56 0x34 0x12

以伪代码日常口语举例,1234(一千二百三十四),在内存中的存储顺序可能是:

  • 大端序(Big Endian):0x01 0x02 0x03 0x04
  • 小端序(Little Endian):0x04 0x03 0x02 0x01

为什么单独拿出来说明,因为计算机中的存储顺序往往是小端序(Little Endian),那么硬件同学往往也会使用小端序来读写数据,这中间涉及到数据的字节序转换问题,以及软硬件同学的理解偏差问题,不可不察。

以常规的配网流程为例,用户拿到设备后,首先要跟设备进行配对,然后设置该设备的基本信息,比如网络连接等。

1
2
3
4
5
6
7
8
9
10
11
12
import { Buffer } from "buffer";

const text = "A2";
const bytes = Buffer.from(text, "utf-8"); // 先转为二进制(UTF-8 编码)
const base64Data = bytes.toString("base64"); // 再转为 Base64

// 写入 BLE
device.writeCharacteristicWithResponseForDevice(
serviceUUID,
characteristicUUID,
base64Data
);

考虑到 BLE 的传输长度限制,实际业务中往往需要先下发字节长度,再下发实际的数据,具体要看跟硬件同学怎么定。
比如,假设你要发送[0x00]为消息体,那么你可以把消息总长度拼接在消息体之前,比如[0x01, 0x00, 0x00],前两个字节是小端序的整数0x0001,表示消息体长度为 1 字节,最后的0x00是消息体的内容。

关于有响应写入和无响应写入

writeCharacteristicWithResponseForDevicewriteCharacteristicWithoutResponseForDevice的区别:
WithResponse:需要等待设备返回确认,才能继续写入下一个数据。可能耗时较长。
WithoutResponse:不需要等待设备返回确认,就可以继续写入下一个数据。可能会丢数据。
两者的传输模式有区别,需要跟硬件同学协商,如果某特征只支持WithoutResponse,那么就不能使用WithResponse方法去写入,会写入失败。

关于蓝牙传输的数据本质

问:蓝牙传输过程中,二进制,十进制,十六进制,这些词到处出现,那么这些东西到底是什么?蓝牙传输的到底是什么进制?
答:蓝牙传输的是二进制数据流,本质上就是0-255之间的整数,是蓝牙底层的传输处理方式,通常来说我们需要接触到的是十进制或者十六进制。
简单来说,同样的数据,展示方式可能不同,比如:

  • 二进制:10101010
  • 十进制:170
  • 十六进制:0xAA
    app 客户端在处理的时候可能接触的是十进制,下发的时候传递的可能是AA,但是最终到了蓝牙硬件层,都是通过二进制数据流来传输的。
    假设你得到一组十进制数据(Hello 的 ASCII 码)[72, 101, 108, 108, 111],如果你不考虑加密等问题,你可以直接用String.fromCharCode()方法拿到字符串,或者也可以利用Buffer.from()方法进行转码。

监听

客户端有时需要实时监听来自设备的回信。例如,在 Wi-Fi mesh 组网之后,设备通过蓝牙告知手机,mesh 中存在多少台设备,或者配网之后,上报给手机,通知连接 Wi-Fi 成功。

1
2
3
4
5
6
7
8
9
10
11
12
device.monitorCharacteristicForService(
serviceUUID,
characteristicUUID,
(error, characteristic) => {
if (error) {
// Handle error
return;
}

const data = characteristic.value;
}
);

手动读取

1
2
3
4
const result = await device.readCharacteristicForService(
serviceUUID,
characteristicUUID
);

蓝牙加密

在实际业务中,或者出于合规要求,我们往往需要对蓝牙传输的数据内容进行加密。但需要记住,蓝牙传输的是二进制流的本质是不变的,加密只是对文字内容的加密。
其中,认证标签的长度需要前后端进行约定。
本文基于对称加密算法 AES 128 GCM 举例说明。
加密流程如下:

  • 加密端生成随机的 12 字节的初始化向量(IV/Nonce)
  • 加密端确定加密密钥,通常为 16 字节
  • 加密端对数据进行加密,得到加密后的二进制流
  • 加密端将加密后的密文,和 Key 以及 IV 一起发送给解密端
  • 解密端:拆分出 Key、IV、密文,使用与加密端同样的 Key 和 IV,进行解密

传输过程中,将 IV,密文,Key 的字节数组拼接在一起,一起发送给对方。
加解密代码举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
encryptAES128GCM(
plainText: string,
keyHex: string,
): {
text: string;
bytes: number[];
tag: number[];
iv: number[];
} {
const key = Buffer.from(keyHex, 'hex');
/// 随机生成12字节的向量
const iv = QuickCrypto.randomBytes(12).toString('hex');

const cipher = QuickCrypto.createCipheriv('aes-128-gcm', key, iv);

const encrypted = Buffer.concat([
cipher.update(plainText, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();

const encryptedInfo = {
text: encrypted.toString('hex'),
bytes: encrypted.toJSON().data,
tag: tag.toJSON().data,
iv: iv.toJSON().data,
};

return encryptedInfo;
}

const encryptedContent = this.encryptAES128GCM(content, key, iv);
/// 最终拼接在一起传递给设备
return [
...encryptedContent.iv,
...encryptedContent.bytes,
...encryptedContent.tag,
];

解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
decryptContent(byteArray: number[], key: string): string {
/// 加密的时候按照IV、密文、Tag的顺序拼接的,解密的时候就按照这个顺序拆分
try {
const content = Buffer.from(byteArray).toString('hex');
const ivHex = content.substring(0, 24); // 前24字符
const ciphertextHex = content.substring(24, content.length - 32); // 中间部分
const tagHex = content.substring(content.length - 32); // 最后32字符
const decryptedContent = this.decryptAES128GCM(ciphertextHex, key, ivHex, tagHex);
return decryptedContent;
} catch (e) {
return '';
}
},
decryptAES128GCM(
encryptedTextHex: string,
keyHex: string,
ivHex: string,
tagHex: string
): string {
const key = Buffer.from(keyHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const decipher = QuickCrypto.createDecipheriv('aes-128-gcm', key, iv, {
authTagLength: 16,
});
decipher.setAuthTag(tag);
try {
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedTextHex, 'hex')),
decipher.final(), // 验证标签失败会在此抛出异常
]);
return decrypted.toString('utf8');
} catch (error) {
console.error('解密失败', error);
return '';
}
},

注意事项

平台特性

  • 在安卓上,我们可以手动控制蓝牙的开启状态,而在iOS上,你只能引导用户在控制中心中开启蓝牙。注意是控制中心,而不是系统设置。
  • iOS系统没有提供协商MTU的API,它是自动的,而安卓需要手动需要requestMTU协商。
  • 接上条,iOS虽然无法手动协商MTU,但是它似乎可以获取当系统给你协商好的MTU值,只是可能需要等待发送消息之后,才能去获取。
  • 标题: IoT之低功耗蓝牙
  • 作者: Aron
  • 创建于 : 2025-06-29 20:26:55
  • 更新于 : 2025-10-28 19:06:59
  • 链接: https://likeso.github.io/2025/06/29/iot-ble/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论