11.11. L2CAP チャネル#

GATT はキー/値モデルです。GATT が提供する操作(読み取り、書き込み、notify、indicate)は一度に1つの短い値を移動させるもので、単一のペイロードとして運べる最大サイズはネゴシエートされた MTU が許す範囲、せいぜい数百バイトです。これはセンサーの読み取り値、コマンドレジスタ、ステータスフラグにはうまく機能しますが、数キロバイトや数メガバイトになると破綻します。長いブロブを数百回の小さな書き込みに分割すると、無線がはるかに高速に処理できるはずのラウンドトリップにコストがかかってしまうからです。

大量データのフロー、たとえばカメラが電話機にストリーミングするキャプチャ済みフレーム、無線経由のアップデートイメージ、計測値のバッチエクスポートなどには、BLE は別の経路を提供します。それがLogical Link Control and Adaptation Protocol、すなわち L2CAP です。L2CAP はリンク層と GATT の間に位置し、アプリケーションが同じ無線リンクの上に自前のコネクション指向チャネルを確保できるようにします。このチャネルは、はるかに大きなパケットごとの MTU を持ち、途中に GATT のフレーミングが入らない、クレジットフロー制御されたバイト経路です。

11.11.1. L2CAP を使うべきとき#

L2CAP チャネルは、次のような場合に適したツールです。

  • 転送量が数百バイトを超える場合。

  • 両端ともに L2CAP チャネルを使用することを把握している場合(これはアドバタイズのペイロードでは公開されません。クライアントはチャネルのprotocol/service multiplexer、すなわち PSM 番号を帯域外で知っている必要があります)。

  • アプリケーションが GATT の便利機能を手放してもよいと考えている場合(UUID による組み込みのアドレス指定なし、標準アプリによるクライアントからの発見可能性なし、通知なし)。

aioble ベースのアプリケーションで最も一般的なケースは、PSM の取り決めを互いに把握している2つのソフトウェアの間でバイナリブロブを移動させることです。たとえば、カスタムのカメラ-電話機間プロトコル、互いに通信する2台の openmv カメラ、ペリフェラルの GATT サービスの下にある内部ファームウェアアップデート経路などです。

それ以外のすべてには GATT を使い続けてください。短いステータス、制御レジスタ、センサーの読み取り値、これらはすべてキャラクタリスティックに属します。

11.11.2. チャネルの確立#

L2CAP は既存の aioble.DeviceConnection上で動作するため、GAP 側のディスカバリ / アドバタイズ / 接続のフローは GATT の場合とまったく同じです。両側が接続を保持したら、一方が PSM 上でリッスンし、もう一方がそこに接続します。

PSM は単なる小さな整数です。Bluetooth SIG は範囲の下位を標準化された用途のために予約しています(0x00010x007F)。アプリケーション固有のチャネルには動的範囲の番号を使用してください(固定 PSM には 0x00800x00FF0x0040 以降は通常カスタム用途として空いています)。両側は事前にその値について合意しておく必要があります。

L2CAP チャネルの MTU は、いずれかの側が1回の send() で送り出す最大の単一 SDU(Service Data Unit)であり、BLE リンクの MTU ではありません。Aioble はより大きなペイロードを自動的に断片化します。カメラの BLE ホストは L2CAP MTU を 1017 バイトに制限しています。512 は、RAM を浪費せずに両側に余裕を残す、妥当なデフォルト値です。

リッスン側(例:ペリフェラルとしてのカメラ)では:

async def serve_l2cap(connection, image_bytes):
    channel = await connection.l2cap_accept(psm=0x80, mtu=512)
    async with channel:
        # image_bytes is a bytearray -- e.g. csi0.snapshot().bytearray()
        # or a compressed JPEG buffer. send() fragments into MTU-sized
        # chunks automatically and awaits flow-control credits between.
        await channel.send(image_bytes)
        await channel.flush()

接続側(例:電話機やセントラル)では:

async def open_l2cap(connection, total_bytes):
    channel = await connection.l2cap_connect(psm=0x80, mtu=512)
    async with channel:
        image_bytes = bytearray(total_bytes)
        view = memoryview(image_bytes)
        received = 0
        while received < total_bytes:
            n = await channel.recvinto(view[received:])
            if n == 0:
                break
            received += n
        return image_bytes

l2cap_accept() はピアが接続するまで(または timeout_ms が発火するまで)ブロックします。l2cap_connect() はリスナーが受け入れるまで(または失敗するまで)ブロックします。どちらも aioble.L2CAPChannel を返します。これ自体が非同期コンテキストマネージャであり、終了時にチャネルをクローズします。

11.11.3. 送信と受信#

チャネル上の2つの主な操作は send()(ピアにバイトを書き込む)と recvinto()(事前に確保したバッファに読み込む)です。どちらもコルーチンです。

  • send() はバッファを MTU サイズのチャンクに断片化し、それらの間でリンク層のフロー制御クレジットを待ちます。長い送信はアプリケーションから見れば1つの await ですが、内部では多数のパケットをキューに入れ、ピアの受信クレジットが尽きるたびに一時停止することがあります。

  • recvinto() は、利用可能なものを(チャネルの MTU まで)渡されたバッファに満たし、そのバイト数を返します。利用可能なものが何もなければ待機します。

  • available() は、バッファされた準備済みデータがある場合に同期的に True を返します。サスペンドせずにポーリングするのに便利です。

  • flush() は、未送信の送信がコントローラへ完全に送出されるまで待機します。

L2CAP チャネルは、バイトが順序どおりに損失なく到着するという意味ではストリームに似ていますが、単一の send の境界は保持されます。各 SDU は1回の recvinto で取り出されます。これは TCP とは異なります。TCP では1回の send() の境界が複数の recv() 呼び出しにまたがって崩れることがあります。

11.11.4. 切断の処理#

チャネルは次の3つの条件で失われます。いずれかの側が disconnect() を呼び出した場合、基盤となる GAP 接続が切断された場合、または L2CAP レベルの切断が到着した場合です。アクティブな操作は aioble.L2CAPDisconnectedError を送出します。GATT 側と同様に、これは待機していたコルーチン内の例外として表面化し、async with channel ブロックは正常に終了します。

GAP レベルの切断によってチャネルが到達不能になった場合、アプリケーションは GATT の切断の場合と同じようにアドバタイズまたはスキャンへとループバックします。

11.11.5. メモリのコスト#

より大きな MTU やより長いキューは、両側でより多くの RAM を使用します。512 バイトの MTU にチャネルごとの受信バッファを加えると、チャネルあたり約 1 KB になります。複数のチャネルが同時に開いている場合、小型カメラでは無視できないコストです。接続ごとに1チャネルにとどめ、想定されるメッセージサイズに合った MTU を選んでください。DeviceConnection ごとに1つの L2CAPChannel というデフォルトで、ほとんどのアプリケーションには十分です。

L2CAP は BLE の安全弁です。ほぼすべてのアプリケーションがまず GATT に手を伸ばし、このセクションの残りのセントラル / ペリフェラルの例も GATT にとどまっています。チャネル風の API は、アプリケーションがキー/値モデルでは収まりきらなくなったときの答えです。