ブログに戻る
Transport15 min read

BLE トランスポート設計 — メッセージとファイルダウンロード

本記事では、LAN も Wi-Fi Aware も利用できない場合に、PlainApp が Bluetooth Low Energy 経由でチャットメッセージをプッシュしファイルをダウンロードする方法を説明します。BLE は保証されたフォールバックです — 遅いものの、IP 接続性が一切なくても動作します。ワイヤーフォーマット、2 層チャンク設計、並行トラフィックの優先付け (とその限界)、そして各リクエスト後に接続を毎回破棄する理由を扱います。

このトランスポートを利用する広範なチャットアーキテクチャについては Chat Architecture を参照してください。 各 BLE ペイロードの暗号化に使われる共通 ChaCha20 鍵を 2 台のデバイスがどう取得するかについては Pairing Flow を参照してください。

目次

なぜ BLE トランスポートなのか {#why-a-ble-transport-at-all}

PlainApp は サーバーレスでオフラインファースト です。トランスポート層は順序付きフォールバックチェーンです: LAN → Wi-Fi Aware → BLE。LAN はハッピーパスです (Wi-Fi 上の HTTPS、~10 ms ラウンドトリップ)。Wi-Fi Aware は サブネットをまたぐピア (異なる SSID、ゲスト vs IoT VLAN) をカバーします。どちらも何らかの IP 接続性 を必要とします。BLE は唯一、次のような状況で動作するトランスポートです:

  • デバイスが同じ IP ネットワークに全くいない場合。
  • Wi-Fi がオフ、または機内モードの場合 (BLE 無線は別系統です)。
  • Wi-Fi Aware が非対応の場合 (Android 13 未満、PlainApp のすべての iOS バリアント)。

BLE は遅いです — 数十 KB/s、リクエストごとに数秒のレイテンシ — しかし、ペアリング済みの任意のピアに対して 保証済み です。必要なのはピアの clientId だけで、それは常に BLE スキャンレスポンスでブロードキャストされているからです。

Diagram 1
1

GATT サービス構成 {#gatt-service-layout}

PlainApp は 単一のカスタム GATT サービス を 2 つのキャラクタリスティックとともにアドバタイズします。登録された 16-bit UUID はなく、サービスは末尾バイトが ASCII で plpai\x01 にデコードされる 128-bit UUID を使います:

Diagram 2
2

なぜ 2 つのキャラクタリスティックなのか?

2 つのプロトコルは 全く異なる信頼モデルとペイロード形状 を持ちます:

  • NEARBY はペアリングメッセージを運びます。これらはピアがペアリングされる 前に 到着し (まだ共通鍵がない)、独自の Ed25519 署名付き JSON ペイロードと独自のプレフィックスルーティングを用います。ボディはプレーンな文字列です。
  • HTTP はペアリング後の全トラフィック (チャット、ファイル、プレゼンス) を運びます。常に共通鍵で ChaCha20 暗号化され、LAN の Ktor サーバーと同じ HttpRouteRegistry を使うため、ルートハンドラー (/peer_graphql/fs/peer_status) は一度書かれ、両トランスポートで再利用されます。

なぜ read ではなく notifications なのか?

BLE ATT プロトコルは単一属性の read を 512 バイト に制限します。GraphQL レスポンスや 16 KB ファイルチャンクはもっと大きくなり得ます。PlainApp は 実データに readCharacteristic を使わない ことでこれを回避します — サーバーの onCharacteristicReadRequestGATT_SUCCESS とともに空ペイロードを返します。代わりにクライアントはリクエストをキャラクタリスティックに書き込み、サーバーはクライアントが再構成する チャンク化された notifications のシーケンスを送信することで応答します。これは BleDeviceApi.requestAsyncBleServerProtocol.handleWriteAndroidBleGattServer.sendChunkedResponse でドキュメント化されています。

ピア識別: shortId であり MAC ではない

BLE アドバタイジングパケットは小さく (31 バイト)、BLE MAC アドレスは Android によって約 15 分ごとにランダム化 されるため、安定した識別子として使えません。PlainApp は代わりに 9 バイトの serviceData ペイロードをスキャンレスポンスでブロードキャストします:

Diagram 3
3

なぜ完全な clientId ではなく切り詰めたハッシュなのか?

13 文字の clientId は 13 バイトに収まりますが、PlainApp は 2 つの理由から 8 バイトの 切り詰めた SHA-256 を選びます:

  1. 安定したバイト予算。 9 バイト合計は、サービス UUID (16 バイト)、長さ、タイプフィールドと並んで 31 バイトのアドバタイジングペイロードに収まります (約 27 バイト使用、4 バイトの余裕)。
  2. プライバシー。 BLE をスキャンする受動的観測者は shortId (SHA-256 ハッシュの 8 バイトプレフィックス) から clientId を復元できません (実用上、不可逆です)。同じ shortId をアドバタイズしたピアを 認識 することしかできず、PlainApp ユーザーを列挙することはできません。

完全な clientId は、実際に GATT 経由で接続し DDiscoverReply を交換したピア — つまりユーザーが既にインタラクションを選んだピア — にのみ開示されます。

2 層チャンク設計

これは BLE トランスポートで最も繊細な部分です。2 つの層はサイズと目的が全く異なるため、両方を理解することが不可欠です:

Diagram 4
4

なぜ 380 文字なのか?

ネゴシエーション後の ATT MTU は Android では 517 バイト (requestMtu(517) — BLE 仕様の最大値)、iOS では ~185+ (CoreBluetooth が自動ネゴシエーション) です。ATT ヘッダー (~3 バイト) と BleSegmentData の JSON ラッパーオーバーヘッド ({"d":"...","s":N} で ~12 バイト追加) を引くと、380 文字のペイロードが両プラットフォームで単一の ATT MTU に余裕を持って収まります。この値は 対称的 です (クライアントのリクエストフラグメントもサーバーの notification フラグメントも 380 を使う) で、コードをシンプルに保ちます。

なぜファイルチャンクは 16 KiB なのか?

16 KiB のファイルチャンクは base64 で ~22 KiB の JSON にエンコードされ、~58 個の GATT notification セグメントにフラグメント化されます。各 requestAsync ラウンドトリップは BLE 上で数秒かかるため、大きく少ないチャンクほどチャンクごとのオーバーヘッドを減らせます。あまり大きくすると BLE RPC のタイムアウトにぶつかるリスクがあり、プログレスフィードバックも粗くなります (ユーザーが見る進捗更新はチャンクごとに 1 回)。16 KiB は経験的にチューニングされたスイートスポットです — スループットに十分な大きさ、レスポンシブなプログレス UI に十分な小ささです。

RPC プリミティブ: BleDeviceApi.requestAsync

すべての BLE チャットメッセージとすべてのファイルチャンクは 1 回の BleDeviceApi.requestAsync(service, requestData) 呼び出しです — BleResult を返す suspend 関数です。呼び出し側からは同期的に見えます: 1 リクエスト → 1 つの完全に再構成されたレスポンス、パイプライン化はありません。

Diagram 5
5

主要な不変条件

  1. 1 リクエスト → 1 レスポンス。 requestAsync は呼び出し側からは同期的です — 完全なレスポンスが再構成された後にのみ返ります。パイプライン化はありません。
  2. 呼び出しごとに notifications を有効化。 クライアントは各 requestAsync の開始時に CCCD を書き込み、終了時に無効化します。無駄 (呼び出しごとに 2 回の余分な GATT write) ですが、プロトコルをステートレスに保ちます — サーバーはどのクライアントが「リスニング中」かを追跡する必要がありません。
  3. RPC 内でリトライなし。 単一の writeCharacteristic がタイムアウト (5 秒) すると、RPC 全体が中断されます。ensureConnected のみがリトライします (接続失敗時に 3 試行)。粗いトランスポートレベルのバックオフは RPC 層ではなく PeerCircuitBreaker が提供します。

ワイヤーエンベロープフォーマット {#wire-envelope-format}

Layer A セグメント内のペイロードはネストされた JSON エンベロープです。フラグメンテーションを剥がすと、論理構造は次のようになります:

Diagram 6
6

レスポンス形状

レスポンスは同じ Layer A フラグメンテーションを逆方向に流れますが、内側の JSON は 3 つのフィールドを持つ BleHttpResponse です: s (HTTP ステータスコード)、h (レスポンスヘッダーマップ)、b (ボディ)。ボディは BleHttpCall.encodeResponse() によって 常に base64 エンコード されます — 空の場合でも同様です。レスポンスはバイナリの可能性があり (暗号化された GraphQL バイト、raw /fs ファイルバイト)、BLE トランスポートは文字列専用のため、同じ JSON エンベロープがテキストとバイナリの両方のペイロードを運びます。

メッセージ送信パス (エンドツーエンド) {#message-send-path-end-to-end}

すべてを組み合わせると — チャットメッセージが BLE 経由で送信されるとき何が起きるか:

Diagram 7
7

注目すべき設計選択

  • LAN と同じ鍵。 ペアリングの ChaCha20 共通鍵は BLE でも再利用されます — 別の BLE 鍵はありません。LanTransport が使う OkHttp 暗号インターセプターと BleTransportchaCha20Encrypt/chaCha20Decrypt は同じプリミティブで、起動方法が違うだけです。
  • LAN と同じルートハンドラー。 BleHttpRequestHttpRouteRegistry.matchRoute(path) 経由でディスパッチされます。これは Ktor LAN サーバーが使うのと同じレジストリです。そのため /peer_graphql/fs/peer_status などは一度だけ実装され、両トランスポートで同一に動作します。
  • 接続再利用なし。 finally { scanner.teardownConnection(client) } ブロックは常に実行されます。各メッセージは接続→サービスディスカバリ→MTU のフルコスト (数秒) を支払います。これは意図的なトレードオフです — 設計トレードオフ を参照してください。

ファイルダウンロードパス (エンドツーエンド) {#file-download-path-end-to-end}

BLE 経由のダウンロードは ストリーミング です — ファイルは 16 KiB チャンクで読まれ、到着したままテンポラリファイルに書き込まれるため、10 MB のファイルが 10 MB の RAM を必要としません。コツは、各チャンクの RPC が 別の requestAsync 呼び出しで、チャンクがコンシューマが並行で読む ByteChannel にプッシュされることです。

Diagram 8
8

なぜ 1 つの大きな RPC ではなくストリーミングなのか?

10 MB のファイルを単一の RPC で送ると、~280 000 の notification セグメントがすべて両側のメモリに保持され、レスポンスすら開始できないことになります — さらに転送全体が成功しないとプログレスが報告されません。さらに悪いことに、途中で 1 つの notification がドロップされると全体が破損します。

チャンク設計には 3 つの利点があります:

  1. 一定メモリ。 一度に 1 つの 16 KiB チャンクだけがインフライトです。
  2. リアルタイムプログレス。 DownloadQueue.notifyProgressUpdate() が毎秒発火し、UI がダウンロードバーを表示します。
  3. レジリエンス。 失敗したチャンクは独立してリトライできます (DownloadQueue はタスクレベルで一時停止/再開/リトライをサポートします。途中での失敗は不完全なテンポラリファイルを残しますが、現在はダウンローダーが失敗時にそれを削除します — トレードオフを参照)。

なぜ onClose がダウンロードジョブをキャンセルするのか

DownloadedResponse.onClose コールバックは downloadJob.cancel() を呼び出します。これは不可欠です。なぜならダウンロードループは子コルーチンで実行され、コンシューマがチャネルを早めに放棄した場合 (例: ユーザーが Pause をタップ)、さもないと永遠に実行され続けるからです。DownloadedResponseAutoCloseable 契約により、コンシューマの use { ... } ブロックは退出時に onClose を自動的に呼び出し、BLE ダウンロードコルーチンをキャンセルし、コルーチンの finally ブロックで GATT 接続を破棄します。

優先付け: 実運用でチャットがファイルに勝つ方法 {#prioritization-how-chat-beats-files-in-practice}

これはチャットアプリケーションにとって最も重要な問いです: 遅い BLE ファイルダウンロードが進行中のとき、新しいチャットメッセージはそれを追い越せるか?

正直な答え: 明示的な優先スキームは存在しない

BLE コードやダウンロードキューのどこにも 優先フィールド、優先キュー、preemption はありません。網羅的な grep で確認しました — shared/srcpriority の一致はログ優先レベルと EXIF メタデータだけで、メッセージ vs ダウンロードの順序付けに関するものはありません。

代わりに存在するのは、望ましい挙動を創発的特性として生み出す アーキテクチャ上の分離 のセットです:

Diagram 9
9

なぜ実運用で機能するのか

チャットを「優先されているように感じさせる」分離は構造的です:

  1. チャット送信は DownloadQueue を経由しない。 これらは PeerGraphQLClientPeerTransportRouterBleTransport.send から直接発行されます。そのためチャットメッセージがファイルダウンロードのキューの後ろに座ることはありません。
  2. BleTransport 呼び出しは独自の GATT 接続を開く。 長時間のダウンロードが 1 つの接続を保持していても、チャット送信が同じピアへの 2 つ目の接続を開くことを妨げません。Android は複数の同時 GATT 接続をサポートします。
  3. チャット RPC は短い。 単一のチャットメッセージは 1 回の requestAsync ラウンドトリップ (接続後 ~1 秒) です。無線がダウンロードでビジーでも、チャット送信は数秒以内に完了します。

設計が及ばないところ

「明示的優先なし」のトレードオフ:

  • 接続レイテンシ。 チャットもダウンロードも接続→ディスカバリ→MTU コスト (数秒) を毎回支払います。接続が再利用されないためです。ダウンロード中に到着したチャットメッセージはダウンロードの既存接続に便乗できず — 新しい接続を開きます。
  • Android での静的キュー。 AndroidBleGattClient のプロセス全体の operationQueue は、すべてのピア・すべての接続にわたって GATT 操作を直列化します。そのため 2 つの GATT 接続は共存できても、それらの write/read/notify 操作 はキューレベルでインターリーブされます。実運用では (各操作は ~ms なので) 問題ありませんが、高並行下では微妙なグローバルなボトルネックです。
  • preemption なし。 進行中のダウンロードを一時停止してチャットメッセージを通すことはできません。チャット送信は単に並行実行され、無線時間を巡って競合します。

将来の改善として、BleTransport.senddownloadFile を per-peer の Mutex で囲み、キューに優先フィールドを追加することが考えられます — が、現在の設計はチャット RPC が十分に短く、競合がユーザーに見えることは稀であるという事実に依存しています。

並行制御と静的 GATT キュー

これは Android BLE 実装で最も繊細な側面なので独自のセクションを設けます。

Diagram 10
10

なぜ静的 (プロセス全体) なのか?

Android BLE スタックは 単一の BluetoothGatt インスタンス上での並行 GATT 操作を許可しません — 別の write がインフライト中に writeCharacteristic を呼ぶと false を返し、2 番目の write を暗黙にドロップします。標準的な回避策は BluetoothGatt ごとのキューです。PlainApp は一歩進んで プロセス全体 のキュー (companion object 内) を使い、過度に保守的ですが正確です: アプリ内のどの 2 つの GATT 操作も同時に実行されないことを保証します。

代償は、長い BLE ファイルダウンロードの write/read/notify 操作が他のピアの GATT 操作の後ろに (そしてその逆に) キューイングされることです。各操作は ~ms なので、ユーザーに見えるボトルネックになることは稀ですが — 重い並行 BLE トラフィックを複数ピアに送る場合はボトルネックになり得ます。

トランスポート層に per-peer ロックはない

BleDeviceApi.requestAsync はプレーンな suspend fun で、ミューテックスも、キューも、per-peer の直列化もありません。同じピアに対する 2 つの並行 BleTransport.send 呼び出しはそれぞれ独自の GATT 接続を開き、独立して進行します。直列化は GATT 操作レベルで暗黙に行われます (Android では静的キュー経由、iOS では逐次 await 経由)。

接続ライフサイクルと MTU ネゴシエーション {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

なぜ requestMtu(517) なのか?

デフォルトの ATT MTU は 23 バイト (3 バイトの ATT ヘッダーを引いた後のペイロードは 20 バイト のみ) です。デフォルト MTU では、すべての 380 文字セグメントが 1 回ではなく ~19 回の GATT write を必要とします — 19 倍の低速化です。BLE 仕様が許す最大 MTU (517 バイト) を要求することで、380 文字セグメントが単一の ATT 操作に収まり、スループットが劇的に向上します。

iOS は明示的な MTU 要求 API を公開していません — CoreBluetooth が接続中にペリフェラルと自動的にネゴシエーションします。最近の iOS デバイスは通常 ~185 バイトをネゴシエーションし、380 文字セグメントに (ATT ヘッダー + JSON ラッパーオーバーヘッドを引いても) 余裕で収まります。

通知のフロー制御 {#flow-control-for-notifications}

サーバーはレスポンスフラグメントを notifications として送信しますが、BLE notifications には 組み込みのフロー制御がありません — サーバーがコントローラが送信できる速度より速く notifications を送信すると、それらは暗黙にドロップされます。PlainApp は明示的な ack ベースのフロー制御を実装しています:

Diagram 12
12

このフロー制御がないと、バックツーバックの notifications は BLE コントローラの内部送信キューが一杯になると暗黙にドロップされます — BleGattServer インターフェースのコメントにドキュメント化されたよく知られた Android BLE の問題です。per-device の single-in-flight ルールにより、すべての notification は送信されるか、タイムアウト (その後トランスポート失敗として扱われる) をトリガーするかのいずれかであることが保証されます。

エラー処理: TransportUnavailable と本当の失敗 {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailablePeerTransportRouter次のトランスポートにフォールスルー させる合図です。それ以外は呼び出し側に返される本当の失敗です。

Diagram 13
13

ダウンロード失敗の繊細さ

BleTransport.downloadFileDownloadedResponse(200, channel, onClose)即座に 返します — チャンク化されたダウンロードループはチャネルに書き込むバックグラウンドコルーチンで実行されます。チャンク RPC が途中で失敗すると、ループは channel.close(TransportUnavailable(...)) を呼び出し、コンシューマ (PeerFileDownloader.downloadAsync) は channel.readAvailable(buf) からスローされた例外としてエラーを見ます。

つまり PeerTransportRouter.downloadFile 呼び出し自体は成功 (=DownloadedResponse を返した) しているため、サーキットブレーカーは途中のダウンロードエラーを失敗として記録しません。接続時・スキャン時の失敗のみがルーターに捕捉されます。これは意図的な設計選択です — 途中の失敗がそのピアの BLE を恒久的に無効にすべきではありません (ピアが一時的に範囲外に出ただけかもしれません)。

主要定数リファレンス {#key-constants-reference}

定数場所目的
BleDeviceApi.CHUNK_SIZE380GATT セグメントフラグメンテーションBleSegmentData.data のサイズ (JSON オーバーヘッド後も ATT MTU に収まる)
BleTransport.CHUNK_SIZE16 384 (16 KiB)ファイルダウンロード byte-range/fs チャンクリクエストのサイズ
BleTransport.SCAN_TIMEOUT_MS10 000BLE スキャンscanner.findOne のタイムアウト
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC レスポンスrequestAsync の notification ごとの待機
AndroidBleGattClient MTU517接続セットアップrequestMtu(517) — BLE 仕様の最大値
AndroidBleGattClient 接続タイムアウト10 000接続セットアップSTATE_CONNECTED の待機
AndroidBleGattClient MTU タイムアウト5 000接続セットアップonMtuChanged の待機
AndroidBleGattClient write タイムアウト5 000GATT writeonCharacteristicWrite の待機
AndroidBleGattClient read タイムアウト10 000GATT readonCharacteristicRead の待機 (実データには不使用)
AndroidBleGattClient notify-state タイムアウト5 000CCCD writeCCCD ディスクリプタ write の待機
ensureConnected リトライ3接続セットアップ最大 4 試行 (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Notification フロー制御onNotificationSent の待機
AndroidBleGattServer notifyChunkSize380レスポンスフラグメンテーションBleDeviceApi.CHUNK_SIZE に同じ
IosBleGattServer リトライ上限10Notification フロー制御諦める前の最大 updateValue リトライ
PeerCircuitBreaker.WINDOW_MS30 000トランスポートサーキットブレーカーしきい値後のオープン期間
PeerCircuitBreaker.MAX_FAILURES2トランスポートサーキットブレーカーオープンするまでの窓内失敗数
DownloadQueue.MAX_CONCURRENT3ダウンロードワーカープール同時ダウンロードコルーチン
BleServiceData.SHORT_ID_BYTES8ピア識別切り詰め SHA256 プレフィックスバイト
BleServiceData.PAYLOAD_BYTES9ピア識別1 フラグバイト + 8 shortId バイト
BleSegmentData.STATE_START_BIT1Layer A EOF シグナリング複数セグメントメッセージの最初のセグメント
BleSegmentData.STATE_END_BIT2Layer A EOF シグナリング最後のセグメント (または単一セグメント)

設計トレードオフのまとめ {#design-trade-offs-recap}

Diagram 14
14

関連記事

  • Chat ArchitectureBleTransportLAN → Aware → BLE フォールバックチェーンや広範なチャット送受信パイプラインにどうフィットするか。
  • Pairing Flow — 各 BLE ペイロードが使う共通 ChaCha20 鍵がどう確立されるか、ペアリングハンドシェイクに NEARBY キャラクタリスティックがどう使われるか。