Android
bluetooth
BLE

Android BLE 実装についての概要

スタートトゥデイ工務店 Advent Calendar 2017 19日の担当です。BLEについて浅くだらだら書きます。

BLEの概要

Bluetooth Low Energey(以下BLE)はBluetooth4.0以降の規格の一部です。
それ以下はクラシックBluetoothと呼び、主な違いとしては電力消費が大幅に改善されているところです。スマホや家電、車載システムといった多くの機器に実装され、IoTに代表される近距離無線通信のプロトコルです。

登場人物

Central(セントラル) = スマホ、PC

スキャンし周辺のペリフェラルの発見と接続やread/writeなどといった操作を行います。

Peripheral(ペリフェラル) = BLE機器

アドバタイジングパケットを送信し、セントラルに自身の存在を知らせます。
セントラルと接続後にデータのやり取りを行います。

GATT

Generic Attribute Profile の略です。セントラルとペリフェラルがやり取りをする際に、このGATTという共通仕様に基づいて行います。また、データの読み込みや書き込みを実際に行うには characteristics(キャラクタリスティクス)という単位を指定し、操作を行います。例えば、あるペリフェラルの温度情報を取得したい場合のキャラクタリスティクスが00000000-0000-0000-0000-00000000000AといったUUIDの形式になっていた場合、この値を指定してRead を実行するとコールバックで、そのvalue(この場合は温度)が返ってくる感じとなります。これらのキャラクタリスティクスをまとめたものをサービスと呼ばれます。また、プロファイルの基準はBluetooth SIGという標準化団体が策定してますが、ManufacturerSpecificというものがあり、その領域は企業独自のカスタマイズも可能になってます。

gatt.png
図: GATT Profile

Android BLEの実装について

BLEに対応しているのは4.3からです。ただ、4.3は動作がだいぶ不安定なようなので、対応は4.4以上を推奨します。また、4.4と5.0ではAndroidフレームワークのAPIが異なるので実装を分岐させる必要があります。なのでBLE対応をする場合は5.0からが無難かと思います。ただ、5.0以上からは安定していると随所でかかれていますが、機種による動作安定さのばらつきがあります。また、6.0以上の場合、ACCESS_FINE_LOCATIONのランタイムパーミッションの対応が必須です。一部の端末では端末自体の位置情報がONでなければ、スキャンができないという問題もありました。また、OSバージョンによって、同時に接続できるペリフェラルが限られているようです。

処理の流れ

基本的な処理の流れは以下のようになります。
scan -> connect -> callbackでServiceDiscoveredが返ってきた時点で実際のセントラルとペリフェラルの接続が完了します。
その後、notification, write, read といった操作を行います。

ble_sequence00.png

図:基本的な処理の流れ

scan

周辺にあるペリフェラルを検出します。4.4と5.0以降で実装方法が異なります。
周辺にあるペリフェラルのアドバタイズパケットを受け取るとアドバタイズデータを含むコールバックが返ってきます。

4.3, 4.4

    /* ~ 略 ~ */
    bluetoothAdapter?.startLeScan(leScanCallback)
    /* ~ 略 ~ */

    private LeScanCallback leScanCallback = new BluetoothAdapter.LeScanCallback() {
        @Override
        public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) {
            ScanResult result = parsUuids(device, scanRecord);
            scanResultSubject.onNext(result);
        }
    };

4.3, 4.4のstartLeScan のUUIDフィルタリングは問題があるので、スキャン結果のアドバタイズデータをパースしてサービスのUUIDを抽出する必要があります。

5.0以降

    /* ~ 略 ~ */
    bluetoothLeScanner.startScan(null, scanSettings, scanCallback);
    /* ~ 略 ~ */

    final ScanCallback scanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            scanResultSubject.onNext(result.device, result);
        }

        @Override
        public void onScanFailed(int errorCode) {
            scanResultSubject.onError(new Throwable("scan error!!"));
        }
    };

基本的にはサービスUUID指定のスキャンのフィルタリングは問題があるそうなので極力しないほうがいいようです。また、対象のペリフェラルのscanが完了し、発見できた場合、stopScan()を実行するべきです。
検出をずっと実行していると、以降の接続の試行処理が大幅に遅くなり、失敗する可能性があるみたいです。

connect

発見されたペリフェラルと接続を開始します。

    public void connect(BluetoothDevice device) {
        device.connectGatt(context, false, gattCallback);
        // 6.0以上の場合、transportを指定したconnectGattメソッドもあります。
    }

以下のコールバックに値が返ってきます。

    private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
            if (newState == BluetoothGatt.STATE_CONNECTED) {
                // ペリフェラルとの接続に成功した時点でサービスを検索する
                gatt.discoverServices();
            } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
                // ペリフェラルとの接続が切れた時点でオブジェクトを空にする
                if (bluetoothGatt != null) {
                    bluetoothGatt.close();
                    bluetoothGatt = null;
                }
                state = DISCONNECTED;
            }
        }
        /* ~ 略 ~ */
    };

gatt.discoverServices();が成功すると以下のコールバックに値が返ってきます。この時点でやっとペリフェラルへのオペレーションが可能となります。

        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                bluetoothGatt = gatt;
                state = CONNECTED;
            }
        }

notification

read, writeの操作があると思いますが、ひとまずnotificationの操作について書きたいと思います。エラー処理などは端折ってますが基本的には以下のような構成になります。

    private void registerNotification() {
        // notificationが有効なBluetoothGattCharacteristicを取得する
        BluetoothGattCharacteristic characteristic = findCharacteristic(serviceUUID, characteristicUUID, BluetoothGattCharacteristic.PROPERTY_NOTIFY);

        // ペリフェラルのnotificationを有効化する。下のUUIDはCharacteristic Configuration Descriptor UUIDというもの
        BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));

        // Androidフレームワークに対してnotification通知登録を行う, falseだと解除する
        bluetoothGatt.setCharacteristicNotification(characteristic, true)

        // characteristic のnotification 有効化する
        descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
        bluetoothGatt.writeDescriptor(descriptor);
    }

以下のコールバックに指定したキャラクタリスティクスに対してのデータが返ってきます。

        @Override
        public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
            if (NOTIFICATION_CHARACTERISTIC_UUID.equals(characteristic.getUuid().toString())) {
                byte[] notification_data = characteristic.getValue();
                /* ~ 略 ~ */
            }
        }

disconnect

ペリフェラルとの接続を解除する場合は、明示的にdisconnectを呼び出します。

    public void disconnect(BluetoothGattCharacteristic characteristic) {
        // notoficationを無効にする
        bluetoothGatts.setCharacteristicNotification(characteristic, false);
        bluetoothGatts.disconnect();
    }

成功した場合、onConnectionStateChangeのコールバックにBluetoothGatt.STATE_DISCONNECTEDが返ってきます。

はまったところ

ところどころ、ハマりポイントはあって細かくは覚えていないのですが大きく3つぐらいハマりました。
以下の内容はおそらく、BLEを実装したことがないとわからない内容だと思います。すみません。

133エラー

AndroidのBLE実装をした人であれば多分目にしたことがあるエラー133。当然のごとく発生し、事象としてはconnectGatt()を開始した時点でonConnectionStateChange()のstateに133が返る感じでした。調べるとGATT_ERROR=0x85との記述がありました。がっとえらあ、意味が広すぎです。StackOverflowなどでは一旦、disconnect()して最初からやり直すといったことが書かれていたので、それを試しましたが頻発しました。ペリフェラルとの2回目以降の接続で頻発している気がしたので、disconnect()後にsleep()してみたり、connectGatt()の引数を色々変えてみたり、リフレクションでreflesh()という怪しいメソッドを呼び出したり、遠くを見つめたり、ポケモンGOをしたりしましたが発生します。有識者の方から聞いた話では、ペリフェラル側で処理負荷などでパニックを起こしていると発生しやすいということを聞きました。
最終的にとった回避策(?)としては、onConnectionStateChange()133が返ってきても、タイムアウトするまでconnectGatt()をリトライし続ける、という方式をとりました。

うんともすんとも言わなくなる端末

某中華ベンダーの端末で何回か発生したのですが、2,3台のペリフェラルとconnectした状態で別ペリフェラルと接続しようとすると全く接続できなくなる問題が発生しました。調べてみるとonCharacteristicChanged()のコールバックがまったく返ってきません。なるほどです。
こちらも有識者の方に教えてもらったのですが、原因の切り分けに便利なのがNordic社が提供しているnRFConnectというBLE専用アプリです。Nordic社はBLE機器などのリーディングカンパニーであり一定の信頼があるということで、もしこのアプリで再現しない場合は高確率でこちらの実装が悪く、逆に再現する場合は端末、もしくはペリフェラル側に問題があると言っていいかと思います。
結果、このnRFConnectでも2,3台のペリフェラルとしかconnectできなかったので、問題は端末側にあると結論づけて諦めました。

disconnectしない端末

これも某中華ベンダーの端末です。こちらはdisconnect()し、onConnectionStateChange()newStateBluetoothGatt.STATE_DISCONNECTEDが返ってきます。正しい動作です。ただし、次のスキャンで先ほど接続していたペリフェラルが全く見つからない事象が発生しました。原因としては先に接続していたペリフェラルが実はまだ内部的にはconnectし続けていることで、ペリフェラルと接続が解除ができていませんでした。コールバックでBluetoothGatt.STATE_DISCONNECTEDが呼ばれているのにも関わらず、この時点でのBluetoothManager#getConnectionStateの値はBluetoothGatt.STATE_CONNECTEDでした。コールバックから返ってくる状態としてはSTATE_DISCONNECTEDなので再度、disconnect()を呼び出しても、そのコールバックが返ってこないので手詰まり状態になりました。これは現在調査中です。

総括

結論から言うと、AndroidのBLEはiOSと比べ不安定です。 端末によって挙動が変わることが多いです。(各ベンダーのチップセットに依存?)
問題が起きた時、その問題が割と手の届かない箇所(コールバックを返しくる側に問題)にあることが比較的あります。端末をゴミ箱に捨ててしまおうかとか思いました。また、端末のBluetoothを長時間使っているとOSのキャッシュ機能?か何かで調子がおかしくなっていくこともありました。

BLE開発はOSバージョンによっての差もあり、デバイスによっても差があり、もしかするとペリフェラル側にも問題があるかもしれず、結構辛いことが多いですが、まずは、

  • 設定のBluetooth ON-OFF切り替え or 再起動
  • 問題の切り分けはnRFConnectで確認

などをして、落ち着いてください。また、端末の個体差がすごいので、より多くのデバイスで挙動を確認したほうがいいです。

以上です!

参考URL:
startLeScan with 128 bit UUIDs doesn't work on native Android BLE implementation
Generic Attributes
BLE視点でまとめるAndroid OSの違い
Android BLEのつらみを予防するTips
nRFConnect for mobile