11.12. 同時に複数のロールと複数の接続を扱う

ペリフェラルとセントラルのページでは、いずれも単一のロールが一度に単一の接続を処理する様子を示しています。しかし実際のアプリケーションがそこまで単純であることはまれです。カメラが電話機にセンサーサービスを公開すると同時に、心拍数センサーストラップから値を読み取ったり、同時にペアリングされた2台の電話機からの接続を受け入れたりすることもあります。aioble API はこのどちらのパターンもサポートします。無線が内部で多重化を行い、すべての操作がすでにコルーチンになっているためです。コルーチンを増やせば、その作業は1つのイベントループ上で並行して実行されます。

このページでは、よく登場するパターンをまとめます。

11.12.1. 1つのペリフェラルに複数のクライアントが接続する

ペリフェラルとして動作する のシンプルなペリフェラルループは、一度に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 付きで呼び出すと、通知はすべてのサブスクライブ済みクライアントに送られます。1つのクライアントにのみ届くべき指向プッシュには、特定の DeviceConnection 引数を指定して notify() / indicate() を使用します。

ファンアウトは小さく保ってください。保持している各接続は無線時間、RAM、そしてコントローラの接続テーブルのスロットを消費します。カメラは数十ものクライアントを束ねるハブとして設計されているわけではありません。2、3台のセントラル(電話機、タブレット、コンパニオンマイクロコントローラ)であれば十分に対応できますが、それ以上を必要とする設計は、カメラ上ではなく専用の 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())

無線は2つのロール間で時間を分割します。ここでスキャンウィンドウ、あちらでアドバタイズのバースト、そしてどちらかの側の接続が有効になったときには接続イベント、という具合です。無線は文字通り同時に2つのことを行えないため、両方が有効なときには各ロールのスループットは低下します。しかし BLE が想定して設計された低帯域幅のやり取りであれば、そのコストは通常目に見えないほどです。

心に留めておくべき実用的な点が2つあります。

  • 両方のロールはそれぞれ専用のコルーチン内に置く必要があります。 接続済みセントラルを処理する接続ごとのタスクの内部から aioble.scan() を呼び出すこと自体は機能しますが、スキャンが終わるまでそのクライアントの通知をブロックしてしまいます。代わりにスキャンは専用のタスクで実行してください。

  • スキャンは一度に1つしか実行できません。 2つの異なる場所からスキャンする必要がある場合は、スキャンイテレータを共有するか、アクセスを調整してください。2つの aioble.scan() コンテキストマネージャを並行して開始してはいけません。

11.12.3. 1つのタスクから複数の接続を調整する

複数の接続を1つの論理的な操作にまとめる必要がある場合、たとえばカメラが同時に2つのセンサーと通信し、両方が応答した後にのみ結果を報告するような場合には、標準の 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 コルーチンは、TCP のコルーチンとまったく同じように gather / wait_for / Event / Lock に組み込めます。

11.12.4. 一方のロールがサイクルごとに完了し、もう一方は完了しない場合

バッテリー駆動のカメラのサイクルは、次のような形になるかもしれません。

  • 起動する。

  • セントラルとして、ペアリング済みのセンサーストラップから最新の値を読み取る。

  • ペリフェラルとして、電話機がその日の計測値をダウンロードできるようアドバタイズする。

  • 両方がアイドル状態になったら、aioble.stop() を呼び出してスリープする。

この順序づけは、2つのタスクと1つの 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