11、BLE实战-温湿度传感器:解析环境传感服务(Environmental Sensing Service 0x181A)、数据格式转换(IEEE 11073)、定时轮询与通知模式对比
好,咱们今天聊点硬核的。温湿度传感器,这玩意儿在物联网项目里太常见了。你买个蓝牙温湿度计,连上手机,就能看到温度和湿度。但背后的数据是怎么来的?怎么解析的?我当年第一次做这个的时候,也踩了不少坑。
今天这一章,咱们就手把手把环境传感服务(0x181A)扒开来看。包括IEEE 11073这个奇怪的数据格式怎么转成正常人能看懂的数值,以及轮询和通知两种模式到底该怎么选。
11.1 环境传感服务(0x181A)长什么样?
蓝牙官方定义的环境传感服务,UUID是0x181A。它里面包含了一堆特征值,比如温度、湿度、气压、光照等等。咱们最常用的就是温度和湿度。
我习惯先看GATT结构。连上设备后,你会在服务列表里找到这个服务。它下面通常有两个特征值:
- 温度特征值:UUID =
0x2A6E(Temperature) - 湿度特征值:UUID =
0x2A6F(Humidity)
嗯,这里要注意。有些厂商会自定义UUID,但正规的蓝牙温湿度计都会遵循这个标准。我在项目里遇到过一款设备,它把温度和湿度合并到一个特征值里了,那就不符合规范,需要单独处理。
核心要点:环境传感服务(0x181A)是标准服务,温度和湿度特征值分别是0x2A6E和0x2A6F。如果设备不按这个来,你得找厂商要文档。
11.2 IEEE 11073数据格式转换
好,现在你从特征值里读到了一串字节。比如温度特征值返回了[0x00, 0x00, 0x00, 0x00, 0x16, 0x0A]。这啥意思?
这就是IEEE 11073格式。说白了,它是一种用于医疗和健康设备的浮点数表示法。蓝牙规范里规定,环境传感服务的数据必须用这个格式传输。
IEEE 11073-20601标准定义了一种叫做SFLOAT(短浮点数)的类型。它占用16位(2个字节),结构如下:
| 位 | 含义 |
|---|---|
| 15 | 符号位(0正1负) |
| 14-12 | 指数(3位,二进制补码) |
| 11-0 | 尾数(12位,二进制补码) |
计算公式:值 = 尾数 × 10^指数
举个例子。刚才的[0x16, 0x0A],咱们把它拼成16位:0x0A16(注意字节序,通常是低位在前)。
二进制:0000 1010 0001 0110
- 符号位:0(正数)
- 指数:
000(0) - 尾数:
1010 0001 0110= 0xA16 = 2582
计算结果:2582 × 10^0 = 2582。但温度单位是0.01摄氏度,所以实际温度是 25.82°C。
你看,其实不复杂。我刚开始做的时候,直接拿计算器按了半天,后来写了个工具类,一行代码搞定。
我的习惯:在Android里写一个IEEE11073Utils工具类,专门处理SFLOAT和FLOAT(32位)的转换。这样所有解析逻辑都集中在一起,好维护。
下面是我常用的转换代码:
public class IEEE11073Utils {
/**
* 将两个字节的SFLOAT转换为float
* @param data 字节数组,至少2字节
* @param offset 起始偏移
* @return 转换后的浮点值
*/
public static float sFloatToFloat(byte[] data, int offset) {
// 注意:蓝牙数据通常是小端字节序
int sfloat = (data[offset] & 0xFF) | ((data[offset + 1] & 0xFF) << 8);
int mantissa = sfloat & 0x0FFF; // 低12位是尾数
int exponent = (sfloat >> 12) & 0x0F; // 高4位是指数(含符号位)
// 处理符号位(第15位)
if ((sfloat & 0x8000) != 0) {
mantissa = mantissa - 4096; // 12位补码转负数
}
// 指数是4位补码,最高位是符号位
if ((exponent & 0x08) != 0) {
exponent = exponent - 16;
}
return (float) (mantissa * Math.pow(10, exponent));
}
/**
* 将4个字节的FLOAT(32位)转换为float
*/
public static float floatToFloat(byte[] data, int offset) {
// 类似处理,32位版本,这里省略
// 实际项目中我直接用了,原理一样
return 0.0f;
}
}
我曾经踩过的坑:字节序问题。有些设备用大端,有些用小端。我遇到过一款设备,文档写的是小端,实际发出来是大端。结果温度显示成了八千多度。排查了半天,最后发现是字节序反了。所以,拿到数据后,先打印原始字节,确认一下。
11.3 定时轮询 vs 通知模式
好,数据格式搞定了。接下来要考虑怎么获取数据。两种方式:轮询和通知。
你想想看,温湿度传感器这种数据,变化不快。温度可能几分钟才变0.1度。那你是每隔几秒去读一次,还是让设备主动告诉你?
咱们对比一下:
| 对比项 | 定时轮询 | 通知模式 |
|---|---|---|
| 实现复杂度 | 简单,定时器+读特征值 | 中等,需要开启通知+回调 |
| 功耗 | 高,每次读都需要交互 | 低,设备只在数据变化时发送 |
| 实时性 | 取决于轮询间隔 | 高,变化即通知 |
| 数据完整性 | 可能漏掉中间变化 | 不会漏,但可能丢包 |
| 适用场景 | 数据变化慢,对实时性要求不高 | 需要实时监控,或数据变化频繁 |
我个人习惯是:能用通知就用通知。为什么呢?
第一,省电。蓝牙设备通常电池容量小,轮询会让它频繁唤醒,电池撑不了多久。第二,实时性好。温度突然升高,通知能立刻告诉你,轮询可能要等好几秒。
但通知也有缺点。如果蓝牙连接不稳定,可能会丢包。我在一个仓库监控项目里遇到过,设备每隔5分钟上报一次温度,但有时候收不到。后来加了重试机制,才解决。
轮询也有它的用处。比如你只是想看一眼当前温度,不需要持续监控。那轮询一次就够了,简单粗暴。
下面是我实现通知模式的代码片段:
// 开启温度特征值的通知
BluetoothGattCharacteristic tempChar =
gattService.getCharacteristic(UUID.fromString("00002A6E-0000-1000-8000-00805F9B34FB"));
gatt.setCharacteristicNotification(tempChar, true);
// 同时需要配置CCC描述符(0x2902)
BluetoothGattDescriptor descriptor = tempChar.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805F9B34FB"));
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
// 在onCharacteristicChanged回调中接收数据
@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
byte[] data = characteristic.getValue();
float temperature = IEEE11073Utils.sFloatToFloat(data, 0);
// 更新UI
}
小技巧:开启通知后,记得在onDescriptorWrite回调里确认是否成功。我曾经遇到过某些设备对CCC描述符写入有延迟,需要等待一下才能收到通知。
11.4 知识体系总览
为了让你更直观地理解这一章的内容,我画了一张图。它展示了从连接设备到解析数据的完整流程。
从图上你可以看到,整个流程分三步:连接设备、发现服务、获取数据。获取数据又分两种方式,最终都要经过IEEE 11073转换,才能显示成我们看得懂的温度和湿度。
11.5 实战建议
最后,给你几个实战中的建议:
- 先读文档:拿到设备后,先看厂商提供的GATT服务表。确认特征值UUID和数据格式。别像我一样,上来就猜,结果猜错了。
- 打印原始数据:在解析之前,先把原始字节打印出来。用
Log.d("BLE", bytesToHex(data))。这样能快速定位问题。 - 处理异常值:IEEE 11073里有一些特殊值,比如
0x07FF表示“无效值”,0x0800表示“超出范围”。解析时要判断,别把异常数据显示给用户。 - 通知模式记得取消:页面销毁时,记得关闭通知。否则下次连接可能收不到数据。我遇到过这个问题,折腾了好久才发现是CCC描述符没重置。
一句话总结:环境传感服务(0x181A)是标准服务,数据用IEEE 11073 SFLOAT格式传输。推荐用通知模式获取数据,省电又实时。解析时注意字节序和异常值处理。
好了,这一章的内容就到这里。代码和思路都给你了,剩下的就是动手实践。找个温湿度传感器连上试试,你会发现,其实没那么难。