11.7. GATT 操作

一个特征(characteristic)在 GATT 数据库中只是作为一个命名的值存在。真正让它有用的,是客户端可以对它执行的那一小组定义良好的操作。每个特征都通过一个 属性位掩码(property bitmask) 声明它支持哪些操作——没有内容要暴露的服务端可以发布一个只读值,控制寄存器可能是只写的,而流式推送更新的传感器则会设置 notify 位。客户端在发现过程中获取该位掩码并加以遵守。

这五种操作是 读(read)写(write)无响应写(write without response)通知(notify)指示(indicate)。它们分为两组——拉取(客户端发起请求)和推送(服务端主动发送)。

11.7.1. 拉取:读和写

这两种是最简单的,看起来就和函数调用一模一样。

  • 读。 客户端请求当前值,服务端将其回送。一次往返,客户端拿到服务端为该特征设置的字节内容,而服务端不会得知是谁读取了它。

  • 写。 客户端发送新字节,服务端将其存储(并可选地对新值运行应用逻辑)。存在两种形式:

    • 带响应写 ——服务端进行确认,并在状态非零时引发相应的应用错误。可靠,一次往返。

    • 无响应写 ——服务端静默地存储这些字节;客户端完全不会收到任何确认。更快(无需等待确认的往返),适用于流式传输,代价是只能通过旁路回读才能发现错误。

aioble 中,客户端侧 API 将这一选择隐藏在单一的 aioble.ClientCharacteristic.write() 方法之后,通过一个 response 关键字控制(True / False / None,根据对端所通告的能力自动选择)。

11.7.2. 推送:通知和指示

拉取模型对传感器数据是不合适的。一条心率带如果需要手机每秒轮询一次,就会在上百次无用的无线电事件上耗尽电量;而只在有了新读数时才推送数值的方式,正是 BLE 一开始的意义所在。

GATT 用 服务端发起 的操作解决了这个问题。客户端 订阅(subscribe) 一个特征;自此之后,每当服务端更新该值,新值就会通过链路推送给客户端。有两种形式:

  • 通知。 发后即忘。服务端将一条通知排入队列,链路层在下一个连接事件期间将其发送,客户端予以接收。在 GATT 层没有任何确认;链路层正常的重传机制会处理无线电侧的丢包,但应用程序不会看到该值已被处理的任何确认。

  • 指示。 服务端发送一条通知,并且 在发送下一条之前等待客户端在 GATT 层的确认。每次只能有一条指示在途。当服务端需要确知客户端确实看到了该值时使用——例如一个关键报警特征、一次配置确认。

Two side-by-side diagrams of a server and a client. On the left, the client sends "read", the server replies with the value. Three reads in a row, each pair of arrows. On the right, the client sends a single "subscribe", then the server pushes three "notify" packets at the times it chooses, without any client request in between.

拉取(读)与推送(通知)的对比。使用通知时,客户端只需订阅一次,服务端便会在数值变化时随时推送新值。

订阅是通过向附加在特征上的一个描述符写入数据来完成的——即 客户端特征配置描述符(Client Characteristic Configuration Descriptor)(CCCD,0x2902)。写入 0x0001 启用通知,写入 0x0002 启用指示,写入 0x0000 则两者都禁用。aioble.ClientCharacteristic.subscribe() 方法会替你执行这次写入,并提供 notify=Trueindicate=True 关键字标志。

订阅之后,客户端使用 notified()indicated() 等待传入的推送——两者都是异步协程,会一直挂起直到下一次推送到达。

11.7.3. MTU 决定有效载荷大小

每一种操作都受到连接在链路建立时敲定的协商 MTU 的约束。默认 MTU 为 23 字节,在扣除 GATT 头部之后留给特征值字节的只有 20 字节。任何大于此的内容要么必须装入一个更大的 MTU(通过 aioble.DeviceConnection.exchange_mtu() 向上协商,在摄像头上最高可达 512 字节),要么必须拆分到多个特征或多条通知中。

由客户端发起的、对大于 MTU 的值的读写,会由 GATT 的 长(long) 流程在幕后处理(Read Long / Prepare-Write + Execute-Write);aioble 透明地运行这些流程,因此以超大值调用 read() / write() 只是会多花一些往返而已。由服务端发起的通知和指示则 不会 被分片——一次推送以 MTU 为上限,应用程序需自行将任何更大的内容拆分为多条通知,或者干脆不使用 GATT。

对于真正的大数据传输——一帧捕获的图像、一批测量数据、一个固件 blob——正确的做法通常是彻底脱离 GATT,转而使用 L2CAP 信道(参见 L2CAP 通道)。

11.7.4. 两侧一览

这五种操作在连接的每一侧暴露方式各不相同: