Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Core BluetoothにおけるL2CAP実装 - 基礎編

研究開発部の堤と申します。先日Eightのモバイルアプリで「タッチ名刺交換」という機能をリリースしました。

この機能では BLE (Bluetooth Low Energy) を利用するのですが、コアライブラリも含めて開発メンバーの誰でもメンテできるよう、6月頃にEight iOSチームを対象にBLE勉強会を開催しました。

勉強会は以下の3本立てで行ったのですが、

  • 第1回: 「BLE / Core Bluetooth基礎」編
  • 第2回: 「RSSIを用いた距離推定」編
  • 第3回: 「Core BluetoothにおけるL2CAP実装」編

第1回は 拙著GitHubで公開しているサンプル をベースに解説したので割愛し、第2回の内容は下記記事にまとめました。

buildersbox.corp-sansan.com

本記事では第3回の内容について書きたいと思います。

L2CAPとは

L2CAP (Logical Link Control and Adaptation Protocol) はBluetoothプロトコルスタックの中でGATTよりも下層に位置するプロトコルです。

WWDC17の"What's New in Core Bluetooth"より引用

といってもiOSエンジニアがCore Bluetoothを通してL2CAPを扱う上でプロトコルの詳細を意識する機会はあまりないので本記事では割愛します。

iOSエンジニアが知っておくべきポイントは以下:

GATTは利用せず、2つのデバイス間でのデータストリームを取り扱うのがL2CAP

→ GATTにあった1パケットのデータ長は20バイトまで 1 といった制限を受けない

ストリーミングや大量のデータ転送などを行う場合にL2CAPは最適

L2CAPを用いた通信は、Core BluetoothではiOS 11より使用できるようになりました 2

もちろん、Core Bluetooth / Apple独自規格ではなくBluetooth標準の規格なので、Androidでも利用可能です。

実装のポイント

第1回で基本的なBLEによる通信の実装方法については解説したので、そことの差分をメインにL2CAPの実装を整理しました。

L2CAPチャンネルオープンまでの処理シーケンス

処理シーケンスで見ると一見複雑ですが、従来のBLE通信との差分だけに着目すると実は非常にシンプルです。

チャンネルをオープンするまでは従来のBLEの通信とほぼ同様

  • CBCentralManagerCBPeripheralManagerを使う
  • スキャン/アドバタイズし、接続し、サービスとキャラクタリスティックを探索する

違いは「PSMの受け渡し → L2CAPチャンネルオープン」

  • L2CAPチャンネルをオープンするためには、PSM(Protocol and Service Multiplexer)と呼ばれる CBL2CAPPSM 型の値をピア間で受け渡す必要がある
  • ペリフェラル側でPSMを生成し、セントラル側でそれを受け取ってチャンネルオープンする

ペリフェラル側の違い

ペリフェラル側は、CBUUIDL2CAPPSMCharacteristicString をUUIDとするキャラクタリスティック(以下PSMキャラクタリスティックと呼ぶ)を持つサービスを提供する。

let characteristic = CBMutableCharacteristic(type: CBUUID(string:CBUUIDL2CAPPSMCharacteristicString), properties: [.read], value: nil, permissions: [.readable])
service.characteristics = [characteristic]
peripheralManager.add(service)

publishL2CAPChannel メソッドで、L2CAPチャンネルをパブリッシュする。

peripheralManager.publishL2CAPChannel(withEncryption: false)

パブリッシュ完了すると呼ばれる、CBPeripheralManagerDelegatedidPublishL2CAPChannel メソッドを実装しておく。

public func peripheralManager(_ peripheral: CBPeripheralManager, didPublishL2CAPChannel PSM: CBL2CAPPSM, error: Error?) {
    ...

    channelPSM = PSM
}

ここで得られる CBL2CAPPSM 型(UInt16のtypealias)の値は、PSMキャラクタリスティックの値にセットしてセントラル側に共有する。

セントラル側の違い

セントラル側では、CBPeripheralDelegatedidDiscoverCharacteristicsFor メソッドの中でPSMキャラクタリスティックを発見したら、値をReadしてPSMを取り出す。

// (コード自体はPSM固有のものはないので割愛)

PSMの値(型はCBL2CAPPSM)を用いて、 CBPeripheralopenL2CAPChannel メソッドでL2CAPチャンネルをオープンする。

peripheral.openL2CAPChannel(psm)

チャンネルオープン後

L2CAPチャンネルがオープンすると、CBPeripheralDelegate(セントラル側)、CBPeripheralManagerDelegate(ペリフェラル側)でそれぞれ以下のメソッドが呼ばれる。

func peripheral(_ peripheral: CBPeripheral, didOpen channel: CBL2CAPChannel?, error:
func peripheralManager(_ peripheral: CBPeripheralManager, didOpen channel: CBL2CAPChannel?, error: Error?)

それぞれ CBL2CAPChannelinputStream , outputStream を用いてストリーム処理を開始する。

channel.inputStream.delegate = self
channel.outputStream.delegate = self
channel.inputStream.schedule(in: RunLoop.main, forMode: .default)
channel.outputStream.schedule(in: RunLoop.main, forMode: .default)
channel.inputStream.open()
channel.outputStream.open()

これ以降はiOS 2.0の頃からあるStreamクラス(Objectvie-CではNSStream)を用いたストリーム処理であり、L2CAPやCore Bluetoothには依存しない話なので、本記事では割愛。

Core BluetoothのL2CAP関連APIのポイント

上述の実装以外に、勉強会ではL2CAP関連APIのポイントとなる点について解説しました。

zenn.dev

Core BluetoothのL2CAP関連APIは数も多くなく非常にシンプルですが、「何がないか」に着目すると理解が深まります。上記記事の【メモ】にそのあたりをまとめてあるのでご参照ください。

L2CAPにおける親子問題

タッチ名刺交換という機能においては多対多での接続に対応する必要があるので、ひとつの端末で CBCentralManagerCBPeripheralManager も両方持つことになります。

その場合に、自身がセントラルロールとしてのL2CAPチャンネルと、自身がペリフェラルロールとしてのL2CAPチャンネルの2つのL2CAPチャンネルがオープンされる可能性があります。

これをどちらか一方のチャンネルのみ使用して通信したい場合に、以下のような問題があります。


先にdidOpen が呼ばれた方を優先する(ストリームを open() する)ことにする。

つまり後に didOpen したチャンネルのストリームは open() しない。

期待する挙動

先にペリフェラルとの接続が確立(L2CAPチャンネルのPSMを取得)した方の端末をAとする

⇒ 端末Aで openL2CAPChannel を先に呼ぶことになる

  • 端末Aではセントラル側の peripheral(_:didOpen:error:) が先に呼ばれ、
  • 端末Bではペリフェラル側の peripheralManager(_:didOpen:error:) が先に呼ばれる

実際に発生しうる挙動

2台の端末どちらでも

  • ペリフェラル側のperipheralManager(_:didOpen:error:) が先に呼ばれ、
  • セントラル側の(つまりCBPeripheralDelgateの) peripheral(_:didOpen:error:) が次に呼ばれる

相手側端末でストリームを open() しないため通信できない

ということが容易に発生する。


これに対してどう対応するかというのは複数方法があるわけですが、それがL2CAPを用いたタッチ名刺交換の通信フロー 3 の設計に関わってくるため、「なぜこのようなフローになっているか」を設計に関わっていないメンバーも理解できるよう、勉強会でこの問題について共有しました。

まとめ

Core Bluetoothを用いたL2CAP実装について、基礎的・一般的な内容について解説しました。

より実践的な「ドキュメント等には記載されていないような挙動・ハマりどころ」について、また別記事にて解説したいと思います。


  1. MTU (Maximum Transfer Unit) を変更することでこのパケットあたりの最大サイズは拡張できますが、いずれにしてもL2CAPではこの制約の影響を受けません。
  2. 2017年のWWDCが初出。拙著iOS×BLE本は2015年刊行なので載っていない。
  3. この通信フローについてはおそらく詳細は公開できないですが、一般論として書ける部分についてはまた別記事にて書きたいと思います。

© Sansan, Inc.