4、GATT服务与特征:Service/Characteristic/Descriptor模型、UUID定义、属性权限
好,咱们今天聊聊GATT。这是蓝牙协议栈里最核心的部分,没有之一。你想想看,两个蓝牙设备连上了,到底要交换什么数据?怎么交换?谁说了算?这些问题的答案,全都在GATT里。
我个人习惯把GATT理解成「蓝牙世界的数据库」。一个设备提供数据,另一个设备读取或写入数据。就这么简单。但具体怎么组织这些数据,就得靠Service、Characteristic和Descriptor这三层模型了。
4.1 Service/Characteristic/Descriptor 三层模型
说白了,这就是一个树形结构。最顶层是Service,中间是Characteristic,最下面是Descriptor。我刚开始接触时也觉得绕,后来画了个图就清楚了。
核心模型:
- Service(服务):逻辑功能的集合。比如「电池服务」、「心率服务」、「设备信息服务」。一个设备可以有多个Service。
- Characteristic(特征):具体的数据点。比如「电池电量百分比」、「心率值」、「设备序列号」。每个Characteristic包含一个值和若干属性。
- Descriptor(描述符):对Characteristic的补充说明。比如「客户端特征配置描述符(CCCD)」,用来开启通知或指示。
举个例子。一个心率监测器,它的Service是「心率服务」(0x180D)。这个Service下面有一个Characteristic叫「心率测量」(0x2A37)。这个Characteristic的值就是实时心率数据。而它的Descriptor里,会有一个CCCD,告诉客户端:「你可以订阅我的通知,心率变了我会主动告诉你」。
我在项目中遇到过一个问题:有人把多个不相关的数据塞进同一个Characteristic里。结果客户端解析时一头雾水。我的建议是:一个Characteristic只表达一个数据点。别偷懒,该拆就拆。
4.2 UUID定义
UUID是啥?就是每个Service、Characteristic、Descriptor的唯一标识符。蓝牙SIG(特别兴趣小组)定义了一批标准UUID,比如:
| 类型 | 标准UUID | 说明 |
|---|---|---|
| 电池服务 | 0x180F | Battery Service |
| 电池电量 | 0x2A19 | Battery Level |
| 设备信息服务 | 0x180A | Device Information Service |
| 心率服务 | 0x180D | Heart Rate Service |
| 心率测量 | 0x2A37 | Heart Rate Measurement |
标准UUID是16位的,但实际传输时会扩展成128位。比如0x180F会变成0000180F-0000-1000-8000-00805F9B34FB。嗯,这里要注意:如果你用的是自定义服务,就得自己生成128位UUID。别随便写一个,万一跟别人的冲突了,调试起来很痛苦。
我记得有一次,一个客户用了网上随便找的UUID,结果跟某个标准服务撞了。设备连上手机后,手机自动识别成了别的服务,数据全乱了。后来我让他用UUID生成器重新生成一个,问题就解决了。
我的建议:
能用标准UUID就用标准UUID。实在需要自定义,去 uuidgenerator.net 生成一个,别自己瞎编。
4.3 属性权限
每个Characteristic都有属性权限。说白了,就是「谁能读、谁能写、谁能通知」。这些权限在GATT协议里用位掩码表示:
| 权限 | 值 | 说明 |
|---|---|---|
| 读 | 0x02 | 客户端可以读取这个Characteristic的值 |
| 写 | 0x08 | 客户端可以写入数据 |
| 通知 | 0x10 | 服务端主动推送数据,不需要客户端确认 |
| 指示 | 0x20 | 服务端主动推送数据,需要客户端确认 |
你可能会问:「通知和指示有啥区别?」
通知是「发出去就不管了」,速度快但不可靠。指示是「发出去后等对方确认」,可靠但慢一点。我一般这么选:
- 实时性要求高、丢一两个数据没关系 → 用通知
- 数据必须到达、不能丢 → 用指示
举个例子。心率数据用通知就够了,丢一两个心跳值不影响整体趋势。但如果是血糖仪,每次测量结果都很重要,那就得用指示。
避坑指南:
我曾经在一个项目里,把通知和指示同时开启。结果手机端收到了重复数据,处理逻辑全乱了。记住:同一个Characteristic,通知和指示只能选一个。别两个都开。
4.4 实战:用Python定义一个GATT服务
好了,理论说完了,咱们来点实际的。下面是一个用Python(基于bleak库)定义GATT服务的例子:
import asyncio
from bleak import BleakServer
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.descriptor import BleakGATTDescriptor
from bleak.backends.service import BleakGATTService
# 定义UUID
BATTERY_SERVICE_UUID = "0000180F-0000-1000-8000-00805F9B34FB"
BATTERY_LEVEL_UUID = "00002A19-0000-1000-8000-00805F9B34FB"
# 创建服务
battery_service = BleakGATTService(BATTERY_SERVICE_UUID, is_primary=True)
# 创建特征
battery_level_char = BleakGATTCharacteristic(
BATTERY_LEVEL_UUID,
properties=["read", "notify"], # 可读,可通知
value=b"50" # 初始电量50%
)
# 创建描述符(CCCD)
cccd_descriptor = BleakGATTDescriptor(
"00002902-0000-1000-8000-00805F9B34FB", # CCCD的标准UUID
value=b"\x00\x00" # 初始关闭通知
)
# 把描述符挂到特征上
battery_level_char.descriptors.append(cccd_descriptor)
# 把特征挂到服务上
battery_service.characteristics.append(battery_level_char)
print("GATT服务定义完成!")
print(f"服务UUID: {battery_service.uuid}")
print(f"特征UUID: {battery_level_char.uuid}")
print(f"属性权限: {battery_level_char.properties}")
这段代码定义了一个电池服务,包含一个电量特征。这个特征可读、可通知,并且带了一个CCCD描述符。你想想看,如果手机连上这个设备,它就能读到电量,还能订阅电量变化通知。
嗯,这里要注意:实际项目中,你还需要处理连接、断开、数据更新等事件。但核心的GATT模型,就是上面这个结构。把Service、Characteristic、Descriptor的关系理清了,剩下的就是填数据了。
我的习惯:
在写代码之前,先用表格把所有的Service、Characteristic、Descriptor列出来。包括UUID、属性权限、初始值。这样写代码时思路清晰,不容易漏。
好了,这一章就到这里。GATT模型是蓝牙开发的基础,一定要吃透。下一章咱们聊聊「多连接场景下的GATT并发处理」,那才是真正考验功底的地方。