11.14. 总结

你已经从无线电波层一路走到了用于驱动它的 Python API,完整地了解了蓝牙低功耗:

  • 动机 —— 当摄像头希望与近旁的某个设备通信而它们之间不需要任何基础设施时,BLE 就是答案。同一房间里的手机、手腕上的可穿戴设备、墙上的信标。短距离、无需加入网络、几乎不耗电。

  • 无线电波 —— 2.4 GHz,40 个信道:三个用于广播,37 个用于连接数据,以伪随机序列跳频并自适应地避开噪声信道。简短的数据包、大部分时间处于休眠状态的无线电。

  • 链路层 —— 数据包成帧、寻址、连接调度、重传以及链路层加密。这些都不是从 Python 配置的;它们全都通过连接参数和 MTU 表现出来。

  • 通用访问规范(GAP) —— 发现与连接管理。四种角色:peripheral 和 broadcaster(广播)、central 和 observer(扫描)。广播载荷携带本地名称、服务 UUID、外观以及厂商专属数据——31 字节,外加一个可选的 31 字节扫描响应。连接间隔、peripheral 延迟和监督超时决定了一个开放连接给人的感受。

  • 通用属性规范(GATT) —— 一棵由服务组成的树,每个服务持有若干特征,每个特征可选地持有若干描述符,它们都由 UUID 标识(Bluetooth-SIG 标准用 16 位,自定义的用 128 位)。五种操作:readwrite(拉取,由客户端发起)、notifyindicate(推送,由服务端发起,通过客户端特征配置描述符订阅)。载荷大小受协商出的 MTU 限制。

  • Python API —— aioble 将每一种 BLE 模式都变成一个 asyncio 协程。一个 peripheral 就是 aioble.advertise() 循环处理连接,其 Service / Characteristic 对象一次性构建并通过 aioble.register_services() 提交。一个 central 则用 aioble.scan() 找到对端,用 connect() 打开链路,用 service()characteristic() 遍历远程 GATT 树,然后用 read() / write() / subscribe() / notified() 处理实际数据。断开连接会在正在等待的协程内部以 aioble.DeviceDisconnectedError 的形式浮现。

  • L2CAP 信道 —— 用于那些不适合 GATT 键/值模型的批量字节流的应急出口。aioble.DeviceConnection.l2cap_accept() / l2cap_connect() 在 GAP 连接之上打开一个每应用独立的信道,具备信用流控的发送/接收以及比 GATT 所能承载更大的 MTU。

  • 配对与加密 —— BLE 链路默认是公开的。aioble.DeviceConnection.pair() 发起一次密钥交换,产生一个加密链路;bond=True(默认值)会持久化密钥,使后续连接跳过握手。如果没有 mitm=True 和一个可用的 IO 能力,加密只能防护被动窃听者,而无法防护原始配对期间的主动重定向。

这就足以编写出这样的摄像头应用:作为 peripheral 发布状态、作为 central 读取传感器数据、通过 BLE 将实时数值推送到手机、用一个配对加绑定的步骤来保护链路,以及——对于罕见的批量传输场景——从 GATT 切换到一个 L2CAP 信道。

11.14.1. 故障排查

BLE 故障大多是双方预期之间的不匹配,而手机端的检查工具是看出谁的预期出错的最快方式。标准工具是 nRF Connect for Mobile(Nordic Semiconductor,在 Android 和 iOS 上免费):它可以扫描、连接、遍历 GATT 数据库、读写特征并订阅通知——因此可以在不编写任何配套应用的情况下,单独测试摄像头一侧的行为。

常见的故障模式:

  • “我的设备出现在扫描器里但无法连接。” 最常见的原因是广播包带有 connectable=False(broadcaster 模式),或者前一个连接仍处于打开状态而摄像头已经越过了 aioble.advertise()。在 advertise 调用前后添加 print 语句以确认。

  • “exchange_mtu(512) 运行了,但我的通知仍被限制在 20 字节。” 协商出的 MTU 是 min(local, peer)——手机或 central 库可能没有在它那一侧请求更大的 MTU,在这种情况下连接会停留在 23。在 exchange_mtu() 返回后检查 mtu。另外请注意,exchange_mtu() 每个连接只生效一次;请在第一次大型操作之前调用它。

  • “配对失败并报一个笼统的错误。” 两个常见的罪魁祸首:IO 能力不匹配(在一个声明了 io=3 / 无输入无输出的摄像头上要求 mitm=True——没有办法确认数字代码,所以配对引擎放弃了),以及当对端要求时摄像头上严重错误的挂钟时间。在第一次配对尝试之前用 ntptime.settime() 设置时钟。

  • “通知从未到达客户端。” 按顺序检查两件事:(a) 该特征声明时是否带了 notify=True?——属性位必须在服务端一侧设置;(b) 客户端是否调用了 subscribe()?——如果不写入客户端特征配置描述符(CCCD),服务端会被告知没有客户端想要通知,于是默默地丢弃它们。

  • “广播的名称被截断或缺失。” 广播载荷为 31 字节,而 flags + 服务 UUID + 外观这几个字段各自会从顶部占去一些字节。一个很长的 name= 再加上若干服务 UUID 就会溢出。要么缩短名称,要么使用主动扫描,让扫描响应(另外 31 字节)来承载溢出部分。nRF Connect 会分别显示两个部分,使这种拆分一目了然。

  • “L2CAP connect 立即抛出异常。” 通常是 PSM 不匹配——双方必须在带外就同一个 PSM 编号达成一致。L2CAPConnectionError 会将蓝牙状态码作为它的第一个参数携带;状态 2(“PSM not supported”)就是明显的线索。

  • “绑定过的连接在每次重连时仍然触发完整的配对握手。” 启动时没有调用 aioble.security.load_secrets()。没有它,保存的密钥虽在闪存上,却从未加载进内存,所以对端的身份未知,每次都从头运行配对。

当所有办法都失败时,更底层的 bluetooth 模块暴露了一个 IRQ 回调,它会为每个底层事件触发;短暂地订阅它并打印这些事件,相当于对摄像头一侧做一次 Wireshark 抓包。

11.14.2. 日后使用本参考

请把蓝牙各章节当作参考资料;为查阅某个 peripheral 广播载荷的确切布局,或 central 的扫描与订阅流程而回头翻阅,正是其预期用途。当问题仅仅是“这个调用的确切名称是什么”时,aioble --- 异步 BLEbluetooth --- 底层蓝牙 参考页面会把每个方法、标志和常量集中列在一处。