11.8. aioble 模块

Bluetooth Core 规范提供了一套术语,它们对应到两个 MicroPython 模块。

  • bluetooth——到 BLE 控制器的底层绑定。它是同步的,通过 IRQ 风格的回调进行事件驱动,并围绕字节缓冲区、句柄和裸 GATT 原语来组织。它将协议原样暴露出来,而不是以 Python 应用希望使用的方式呈现。

  • aioble——一个更高层的封装,用 Python 编写在 bluetooth 之上,它将每个远程操作变成一个 asyncio 协程,并把每个 BLE 对象(服务、特征、连接、扫描结果、L2CAP 通道)变成符合人体工学的 Python 类。扫描变成异步迭代器;连接变成异步上下文管理器;通知变成可等待对象。

11.8.1. 何时使用底层模块

在两种狭窄的情形下,bluetooth 仍然是正确的选择:

  • 你正在编写 aioble 自身所构建出来的那类代码——一种需要对协议进行 IRQ 级控制的新模式。

  • 你运行在一个没有提供 aioble 包的硬件平台上,而围绕控制器的一层薄垫片是唯一的选项。

对于每一个摄像头应用而言,aioble 都是正确的选择。

11.8.2. aioble 程序的组成部分

每个基于 aioble 的应用,无论扮演什么角色,都有一小组活动部件。

  • 一个长期运行的 asyncio 事件循环。aioble 中的一切都是协程,因此应用被组织为单个事件循环上的一个或多个任务。关于事件循环、任务和异常的详细说明,参见 Asyncio

  • 一个处于开启状态的无线电。aioble 在首次使用时会隐式激活 BLE 无线电,但也可以用 aioble.config() 显式控制(它在确保无线电已开启后转发给 bluetooth.BLE.config()),并用 aioble.stop() 关闭。

  • 同时进行的一个或多个角色。在外设侧:一组已注册的 GATT 服务(参见 aioble.register_services())和一个运行中的 aioble.advertise() 协程。在中心侧:一个运行中的 aioble.scan() 迭代器或一个待完成的 aioble.Device.connect()。无线电对这些工作进行多路复用;应用则把每个角色看作一个独立的任务。

11.8.3. 最小化的外设

最小但有用的 aioble 程序——一个广播单个只读特征的外设——非常简短:

import aioble
import asyncio
import bluetooth

SERVICE_UUID = bluetooth.UUID(0x181A)            # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)               # Temperature

service = aioble.Service(SERVICE_UUID)
temp = aioble.Characteristic(service, TEMP_UUID, read=True)
aioble.register_services(service)

async def main():
    while True:
        conn = await aioble.advertise(
            interval_us=250000,
            name="openmv-temp",
            services=[SERVICE_UUID],
        )
        async with conn:
            await conn.disconnected()

asyncio.run(main())

一个只做连接并读取一次的中心同样简短:

import aioble
import asyncio
import bluetooth

SERVICE_UUID = bluetooth.UUID(0x181A)
TEMP_UUID = bluetooth.UUID(0x2A6E)

async def main():
    device = None
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if SERVICE_UUID in result.services():
                device = result.device
                break
    if device is None:
        return

    async with await device.connect() as conn:
        service = await conn.service(SERVICE_UUID)
        char = await service.characteristic(TEMP_UUID)
        print(await char.read())

asyncio.run(main())

两个程序都只有约十五行,它们涵盖了从“无线电关闭”到“完成有用工作”的整个流程。

11.8.4. 关闭无线电

在电池供电的摄像头上,BLE 无线电是预算中最大的可自由支配的耗电项。有两个旋钮很重要。

第一个是隐式的:aioble 在首次使用时激活无线电,而无线电会在计划好的事件(广播突发、连接事件、扫描窗口)之间自动休眠。在 aioble.advertise() / aioble.scan() 上选择更长的间隔,并在 connect() 时商定更长的连接间隔,会按比例让无线电关闭的时间更长。广播与扫描 中的广播表格是这方面的实用指南。

第二个是显式关闭:

import aioble

await do_burst_of_ble_work()
aioble.stop()                             # radio deactivated; in-flight tasks unwound
await asyncio.sleep(60)                   # sleep with the radio off
# ... next aioble call brings the radio back up automatically

aioble.stop() 会停用底层的 BLE 无线电,并拆除所有进行中的活动——已打开的连接断开,扫描器和广播器取消,L2CAP 通道关闭。在这些操作上等待的协程会抛出它们各自的常规异常(DeviceDisconnectedError 等),这正是周围的 async with 代码块所设计的清理机制。此后再调用任何 aioble 协程都会从冷态重新激活无线电。

周期性电池供电传感器摄像头的典型模式是:

  • 按计划唤醒(定时器、运动传感器、按钮)。

  • 运行一阵 BLE 工作——广播、接受一个连接、推送数值、断开。

  • 调用 aioble.stop() 并休眠直到下一次唤醒。

11.8.5. aioble 不做什么

aioble 有意地覆盖了 GATT、GAP 和 L2CAP——应用所使用的那些层。有三块内容不在其范围之内:

  • 链路层以下的任何东西。 信道选择、跳频、数据包确认和链路层加密都发生在 BLE 端口和控制器硅片内部;aioble 不在该层暴露任何钩子。

  • 经典蓝牙。 aioble 仅支持 BLE。音频链路、RFCOMM、A2DP 以及其他经典规范特性都不属于该 API。

  • Bluetooth Mesh。 Bluetooth SIG 的网状网络层(建立在 BLE 广播之上的一个独立协议栈)在摄像头上未实现。摄像头可以广播和观测,但它无法参与网状网络的中继 / 朋友 / 代理角色。

11.8.6. 异常

aioble 会抛出四种异常类型。每种都来自某个在出错时正在等待某操作的协程;当它们传播时,async with 代码块会干净地展开退出。

  • aioble.DeviceDisconnectedError——在某个 GATT 操作(readwritenotifiedindicatedsubscribeexchange_mtu 等)进行过程中,到对端的 BLE 链路断开了。它在正在等待的那个协程内部抛出。这是迄今最常见的异常;在任何应在连接丢失时重连的代码中都要捕获它。

  • aioble.GattError——某个 GATT 操作到达了对端,但以非零的 ATT 状态完成(带响应的写入被拒绝、指示未被确认、读取不被允许等)。状态码在该异常的 _status 属性上。

  • aioble.L2CAPDisconnectedError——在某个 send()recvinto()flush() 进行过程中,L2CAP 通道断开了。可能是任一侧关闭了通道,或者底层的 GAP 连接消失了。

  • aioble.L2CAPConnectionError——当监听端拒绝或控制器未能完成通道建立时,由 l2cap_connect() 抛出。Bluetooth 状态码是第一个位置参数。

接受显式 timeout_ms 的操作(连接 / 发现 / 读 / 写 / 配对调用,以及作为包装器的 timeout())在操作完成前截止期限到期时,还会从 asyncio 抛出 asyncio.TimeoutError