11.9. ペリフェラルとして動作する

カメラ側で最も一般的な BLE のパターンは、ペリフェラル として動作することです。つまり、小さな GATT データベースを公開し、その存在をアドバタイズし、スマートフォンやコンパニオンデバイスからの接続を受け入れ、相手側にいる誰に対しても値をストリーミングします。

11.9.1. GATT データベースを構築する

ペリフェラルが起動時に最初に行うこと(無線をオンにする前ですら)は、公開しようとするデータベースを構築し、各サービスとキャラクタリスティックのオブジェクトを作成し、それらをまとめて登録することです:

import aioble
import bluetooth

ENV_SERVICE = bluetooth.UUID(0x181A)              # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)                # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F)               # Humidity

env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
    env, TEMP_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
    env, HUMID_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)

aioble.register_services(env)

それぞれの aioble.Characteristic は、コンストラクタの第一引数にサービスを渡すだけで、そのサービスに紐付けられます。ブール型のキーワード引数(readwritewrite_no_responsenotifyindicate)は、クライアントが実行を許可される GATT 操作を選択します。False(既定値)を渡すと、そのプロパティビットは設定されません。

aioble.register_services() は、組み立てたツリーを GATT サーバーにコミットします。これは aioble.advertise() を開始する前に一度だけ呼び出す必要があります。再度呼び出すと、以前のデータベースが置き換えられます。

11.9.2. アドバタイズ

データベースが整ったら、アドバタイズは接続を待つ単一のコルーチン呼び出しになります:

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

キーワード引数は、アドバタイジングペイロードのフィールドに直接対応します。name はローカル名フィールドです。services はデバイスがホストするサービス UUID のリストで(スマートフォン側のスキャナーはこれでフィルタリングできます)。appearance は標準の 16 ビット appearance 値からのヒントで、これによってセントラルが適切なアイコンを表示できます。製造元固有のデータは manufacturer=(company_id, data_bytes) で渡します。

残りのアドバタイジングフラグ空間をカバーする、あまり一般的でないキーワードがいくつかあります:

  • connectable=False -- ブロードキャスト専用モード(接続を一切受け入れない)。ビーコン形式のペイロードに適した選択肢です。

  • limited_disc=True -- general discoverable の代わりに limited discoverable フラグを使用します。一部のオペレーティングシステムは、ペアリング UI でこの 2 つを異なる方法で扱います。

  • adv_data / resp_data -- アプリケーションがレイアウトを完全に制御する必要がある場合の生のバイト列です。

  • timeout_ms -- 一定時間後に諦めます。既定では永遠にアドバタイズし続けます。

セントラルが接続すると、aioble.advertise() は結果として得られる aioble.DeviceConnection を返します。この時点でペリフェラルはアドバタイズを停止します。

11.9.3. 1 つのクライアントに対応する

ペリフェラルのメインループは通常、次のようになります:

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        print("connected:", connection.device.addr_hex())
        async with connection:
            await connection.disconnected()
        print("disconnected; advertising again")

asyncio.run(serve())

async with connection により、切断時のクリーンアップが自動的に行われます。disconnected() はコルーチンで、いずれかの側が接続を終了するまでサスペンドします。これは、セントラルが離れるまでペリフェラルがサービスを継続し、その後アドバタイズへループバックして次のラウンドを開始する、すっきりとした方法です。

11.9.4. キャラクタリスティックを更新する

ペリフェラルは aioble.Characteristic.write() を使ってローカルの GATT データベースを更新します:

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

これにより、任意のクライアントが次に read したときに返される値が変わります。ただし、これだけでは新しい値を プッシュしません。サブスクライブしているクライアントは、クライアント自身がポーリングするか、ペリフェラルが明示的に通知を送るまで、何も見ることはありません。

プッシュ側は、同じ呼び出しに対する単一のキーワードです:

temp_char.write(temp_bytes, send_update=True)

send_update=True は、このキャラクタリスティックをサブスクライブしているすべてのクライアントに通知(または指示)します。ほとんどのセンサー形式のコードは、接続ごとのタスクの中に置かれ、センサーを読み取って send_update=True で 1 秒ほどごとに値を書き込むループを回します:

async def stream_temperature(connection):
    while connection.is_connected():
        temp_char.write(encode_temperature(read_sensor()), send_update=True)
        await asyncio.sleep(1)

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

サブスクライブしているセット全体ではなく、特定の 1 つのクライアントに通知を向けたい場合(たとえば、そのクライアントのコマンドに対する接続固有の応答など)、aioble.Characteristic.notify()indicate()DeviceConnection 引数とオプションのペイロードを取ります。

11.9.5. 書き込みを受信する

逆方向、つまりクライアントがキャラクタリスティックに 書き込む ことは、キャラクタリスティックを write=True または write_no_response=True で構築したときに利用可能になります。ペリフェラルは aioble.Characteristic.written() で次の書き込みを待機します:

cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)

async def handle_commands():
    while True:
        connection, data = await cmd_char.written()
        print("command from", connection.device.addr_hex(), "=", data)

capture=True を指定しない場合、written() は書き込んだ接続だけを返します。新しい値はキャラクタリスティックのバッキングバッファに格納され、アプリケーションは read() でそれを取得します。アプリケーションが最初の値を読む前に 2 回目の書き込みが届いた場合、2 番目の値がバッファ内の最初の値を 上書き し、元の値は失われます。written() は依然としてアプリケーションを起こしますが、書き込みごとに 1 回ではなく、「新しいものがある」ごとに 1 回だけです。

capture=True キーワードはこれを解決します。届いた書き込みはそれぞれモジュール全体のキューに追加され、written() は個々の書き込みごとに (connection, data) タプルを返します。アプリケーションループは、それぞれを到着順に正確に 1 回ずつ見ることになります。実用上の帰結が 2 つあります:

  • キューには上限があり、デバイス上の capture を有効にしたすべてのキャラクタリスティックで共有されます。連続した書き込みの短いバーストは許容されますが、持続的なオーバーラン(アプリケーションが処理する速度よりも速く書き込みが到着する状態)は、キューに入っている 最も古い エントリを暗黙のうちに破棄します。また、1 つのキャラクタリスティックでのバースト的なトラフィックが、別のキャラクタリスティックの保留中のエントリを追い出すこともあります。

  • すべての値が重要なコマンド形式の書き込みには capture=True を選択してください。最新の値だけが関心の対象である状態形式のキャラクタリスティックでは、オフのままにします。

クライアントからの読み取りに対して、静的な値ではなくオンデマンドで実行されるコードで応答すべき場合は、on_read() をオーバーライドします。このメソッドは、読み取りが届いたときに同期的に呼び出されます。読み取りを許可するには 0 を返し(write() による現在の値が送信されます)、拒否するには 0 以外の ATT エラーコードを返します:

import time

_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000)            # at most once per second

class TempChar(aioble.Characteristic):
    _last_read_ms = 0

    def on_read(self, connection):
        now = time.ticks_ms()
        if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
            return _ATT_ERR_READ_NOT_PERMITTED
        self._last_read_ms = now
        self.write(encode_temperature(read_sensor()))
        return 0

temp_char = TempChar(env, TEMP_UUID, read=True)

このコールバックは、GATT スタックが読み取りに応答する直前にセンサーをサンプリングしてキャラクタリスティックの値を更新するため、クライアントは常に最新のデータを見ることができます。レートリミットは、クライアントがサンプリング可能な速度を超えてセンサーを酷使するのを防ぎます。1 秒のクールダウン内の読み取りは、古い値ではなく Read Not Permitted ATT エラーとして跳ね返されます。

11.9.5.1. より大きなバッキングバッファ -- BufferedCharacteristic

通常の Characteristic のバッキングバッファ幅は 20 バイトです。これは既定の 23 バイト MTU における実用的な上限です。通常のキャラクタリスティックにそれを超える値を書き込んだクライアントは、その値が切り詰められます。より大きな受信値の場合や、アプリケーションループが後から追いつくために連続した書き込みをキューに入れる場合は、キャラクタリスティックを BufferedCharacteristic として宣言し、バッファサイズをあらかじめ選択します:

blob = aioble.BufferedCharacteristic(
    service, BLOB_UUID,
    max_len=512, append=True,
    write=True, capture=True,
)

async def receive_blob():
    while True:
        connection, chunk = await blob.written()
        handle_chunk(connection, chunk)

通常の Characteristic と区別する 2 つのつまみがあります:

  • max_len はバッキングバッファのバイト単位のサイズです。クライアントが行うと想定される最大の単一書き込み(MTU ネゴシエーション後)に合わせて選択してください。

  • append=True は、連続した書き込みを上書きするのではなくバッファに 追記 させます。これは、複数の書き込みにわたって届く値(ファームウェア更新のチャンク、ログ行)を受信するのに便利です。append=False の場合、バッファは単に幅が広いだけの通常のキャラクタリスティックと同じように動作します。

他のすべてのコンストラクタフラグ(readwritenotifyindicatecaptureinitial)は、基盤となるキャラクタリスティックにそのまま転送されます。

11.9.6. 標準サービスと SIG 割り当ての UUID

割り当て済み番号の UUID(バッテリーサービスは 0x180F、環境センシングは 0x181A、心拍数は 0x180D など)に従うことで、スマートフォンの汎用 Bluetooth メニューやサードパーティのスキャナーアプリが、カスタムのクライアントコードなしでデバイスの目的を識別できます。各標準キャラクタリスティック内のバイトレイアウトも仕様で固定されています。バッテリーレベル(0x2A19)は 0〜100 の単一バイト、温度(0x2A6E)は 0.01 度 C 単位のリトルエンディアン sint16 です。標準サービスに 当てはまらない アプリケーションでは、128 ビット UUID を一度生成し、デバイスのサービスとキャラクタリスティック全体でそれを使用します。

カスタム UUID だけを公開するペリフェラルでも問題ありません。それらの UUID を知っているカスタムクライアントアプリが必要になるだけです。

注釈

BLE の値は どこでもリトルエンディアン です。GATT 仕様、すべての標準キャラクタリスティック、すべてのアドバタイジングフィールドがそうです。マルチバイトの整数は、下位バイトから先にワイヤー上に送られます。struct のフォーマット文字列の < 接頭辞が、エンコード/デコードに使うべきものです("<h""<H""<I" など)。リトルエンディアンの MCU 上で既定のネイティブバイトオーダーを使うと、今のところたまたま動作しますが、< を明示的に書くのが安全な習慣です。

11.9.7. すべての背後にある無線

無線は、最初の aioble コルーチンがそれに触れた瞬間にオンになります。セントラルが接続されるまで、ペリフェラルは短いアドバタイジングバーストとスリープを切り替えて時間を過ごします。接続後は、ネゴシエートされた接続間隔に従います。ペリフェラルはアドバタイズメントごとにわずかな電力コストを支払うため、aioble.advertise()interval_us の選択が、ペリフェラルが発見レイテンシとバッテリー寿命のトレードオフを調整するうえで最も直接的なつまみとなります。