11.12. 并发角色与多重连接

外设和中心页面分别展示了单个角色在同一时刻服务于单个连接的情形。实际应用很少这么简单。一台摄像头可能在向手机发布传感器服务的同时,还在从心率带读取数值,或者同时接受两台已配对手机的连接。aioble API 同时支持这两种模式,因为底层会对无线电进行多路复用,而且每个操作本身已经是协程——运行更多协程,工作就会在单个事件循环上并行进行。

本页汇总了会遇到的各种模式。

11.12.1. 多个客户端连接到同一个外设

作为外围设备 中的简单外设循环每次只服务一个已连接的中心:

async def serve():
    while True:
        connection = await aioble.advertise(...)
        async with connection:
            await connection.disconnected()

让它能够接受多个客户端的模式是:为每个连接派生一个任务,然后立即循环回到 aioble.advertise(),这样下一个客户端也能连接进来:

async def handle_client(connection):
    async with connection:
        # ... per-client work: subscribe their CCCDs,
        # push notifications, await writes ...
        await connection.disconnected()

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        asyncio.create_task(handle_client(connection))

每个连接都在自己的任务中运行。GATT 数据库是共享的——所有客户端看到的是相同的服务和特征——但每个连接的状态保存在各自的任务内部。当调用 write() 并带上 send_update=True 时,通知会发送给每个已订阅的客户端;只想发给某一个客户端的定向推送,则使用 notify() / indicate() 并传入特定的 DeviceConnection 参数。

请控制扇出规模。每个保持的连接都会消耗无线电时间、RAM,以及控制器连接表中的一个槽位,而摄像头并非设计成服务数十个客户端的集线器。两三个中心(一部手机、一台平板、一个配套微控制器)完全在能力范围之内;需要更多连接的设计应交给专门的 BLE 网关,而不是放在摄像头上。

11.12.2. 同时充当外设和中心

一台摄像头可以在向手机广播自己的服务的同时,还作为中心连接一个可穿戴设备。aioble 没有“模式”切换开关——广播循环和扫描-连接循环只是两个相互独立的协程:

async def be_peripheral():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-hub",
            services=[ENV_SERVICE],
        )
        asyncio.create_task(handle_client(connection))

async def be_central():
    while True:
        sensor = await find_sensor()
        if sensor is None:
            await asyncio.sleep(5)
            continue
        try:
            async with await sensor.connect() as conn:
                await stream_from_sensor(conn)
        except aioble.DeviceDisconnectedError:
            pass

async def main():
    await asyncio.gather(be_peripheral(), be_central())

asyncio.run(main())

无线电在两个角色之间分时复用——这里一个扫描窗口,那里一个广播突发,当任一侧的连接处于活动状态时则有一个连接事件。两个角色都活动时,每个角色的吞吐量都会下降,因为无线电无法真正同时做两件事,但对于 BLE 所设计的低带宽通信而言,这种代价通常是察觉不到的。

有两个实用要点需要记住:

  • 两个角色都需要在各自的协程中运行。 在处理某个已连接中心的每客户端任务内部调用 aioble.scan() 虽然可行,但会阻塞该客户端的通知直到扫描完成——应改为在独立的任务上运行扫描。

  • 同一时刻只能运行一个扫描。 如果你需要从两个不同的地方进行扫描,请共享扫描迭代器或协调访问;不要并行进入两个 aioble.scan() 上下文管理器。

11.12.3. 从单个任务协调多个连接

当若干个连接需要合并为一个逻辑操作时——例如摄像头同时与两个传感器对话,并且只在两者都应答后才报告结果——标准的 asyncio 原语可以直接套用。asyncio.gather() 并发地运行每个连接的协程,并在全部完成后返回;asyncio.wait_for() 则增加一个截止期限。

async def read_pair():
    async with await sensor_a.connect() as a:
        async with await sensor_b.connect() as b:
            value_a, value_b = await asyncio.gather(
                read_value(a, A_SERVICE, A_CHAR),
                read_value(b, B_SERVICE, B_CHAR),
            )
            return value_a, value_b

这与 asyncio 章节(Asyncio)用于网络通信的模式相同——BLE 协程接入 gather / wait_for / Event / Lock 的方式与 TCP 协程完全一致。

11.12.4. 当一个角色每个周期都结束而另一个不结束时

电池供电的摄像头的一个工作周期可能是这样的:

  • 唤醒。

  • 作为中心,从已配对的传感器带读取最新数值。

  • 作为外设,进行广播以便手机下载当天的测量数据。

  • 当两者都空闲时,调用 aioble.stop() 并进入休眠。

用两个任务和一个 asyncio.Event 来安排时序非常直接:

phone_done = asyncio.Event()

async def serve_phone():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-hub",
        services=[ENV_SERVICE],
    )
    async with connection:
        await stream_measurements(connection)
    phone_done.set()

async def read_strap():
    async with await strap.connect() as conn:
        await pull_fresh_values(conn)

async def cycle():
    await asyncio.gather(read_strap(), serve_phone())
    aioble.stop()                              # radio off until next wake