THETA プラグインで 市販の BLE ボタンをリモコンにしてみた

リコーの @shrhdk_ です。

市販の BLE ボタンをリモコンとして使える RICOH THETA のプラグインを作ってみたのでご紹介します。

THETA プラグインの BLE API で BLE ボタンの電波を受信して、THETA のシャッターを切るようにしました。

BLE は Bluetooth Low Energy の略称で、つまりすごく省電力な無線通信の規格です。

BLE よくわかっていないマンがなんとなく作ったインチキプラグインなので、BLE つよつよの人からアドバイスを貰えると嬉しいです :flushed:

THETA プラグインって何??という方は以下の記事をご覧ください。

* [THETAプラグインパートナープログラムはじめました](https://qiita.com/mrtakada/items/8f3bbf8d283c26899473)

<img width="480px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299819/739daed5-49ba-65f8-c432-b3946adb1317.jpeg"/>

## BLE ボタン

BLE ボタンとして、株式会社 Braveridge 様の [Pochiru(eco)](http://www.products.braveridge.com/pochiru_eco/) 使いました。Braveridge 公式ストアで税込み1,091円で買えました。Pochiru(eco) は Android のスマホと連携して色々できるらしいですが、今回はシンプルに BLE の電波が出るボタンとして使いました。

Pochiru(eco) は本体のボタンを押すと、BLE アドバタイジングという電波を繰り返し出しつづけます。この BLE アドバタイジングを THETA プラグインで受信して THETA のシャッターを切るようにしました。

## THETA プラグイン

プラグインのブロック図と処理の流れを下の図にまとめました。

まず、Pochiru(eco) のボタンを押すと、800ミリ秒間隔で30秒間、BLEアドバタイジングが送出されつづけます。次に BLE アドバタイジングをプラグイン側でスキャンし、スキャンに成功したら、THETA の Web API を叩いてシャッターを切ります。

[THETA Web API](https://developers.theta360.com/en/docs/v2.1/api_reference/) は、実はプラグインの中からも利用できます。Bluetooth 関係の設定をする API は Android 標準の API と同じです。

![プラグイン概要](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299819/259d6c38-7699-61da-1c26-da1d5c368d8e.png)

## プラグインでやってること

ソースコードは `MainActivity.kt` だけに収まりました。展開すると全コードを見られます。

プロジェクトは https://github.com/theta-skunkworks/theta-plugin-ble-remote-release にアップしています。

<details><summary>MainActivity.kt</summary><div>

```kotlin
package skunkworks.bleremoterelease

import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothManager
import android.bluetooth.le.*
import android.os.ParcelUuid
import android.util.Log
import com.theta360.pluginlibrary.activity.PluginActivity
import com.theta360.pluginlibrary.values.LedTarget
import org.theta4j.osc.OptionSet
import org.theta4j.webapi.BluetoothPower
import org.theta4j.webapi.CaptureMode
import org.theta4j.webapi.Options.BLUETOOTH_POWER
import org.theta4j.webapi.Options.CAPTURE_MODE
import org.theta4j.webapi.Theta
import java.util.*
import java.util.concurrent.Executors

class MainActivity : PluginActivity() {
    companion object {
        private val TAG = "BLE_REMOTE_RELEASE"
        private val PDLD_LINK = UUID.fromString("b3b36901-50d3-4044-808d-50835b13a6cd")
    }

    private val executor = Executors.newSingleThreadExecutor()

    private val theta = Theta.createForPlugin()

    private var mBluetoothLeScanner: BluetoothLeScanner? = null

    private var mOptionsBackup: OptionSet? = null

    override fun onResume() {
        super.onResume()

        // Init and backup settings
        executor.submit {
            mOptionsBackup = theta.getOptions(CAPTURE_MODE, BLUETOOTH_POWER)
            theta.setOptions(
                OptionSet.Builder()
                    .put(CAPTURE_MODE, CaptureMode.IMAGE)
                    .put(BLUETOOTH_POWER, BluetoothPower.ON)
                    .build()
            )
        }

        // Init BLE
        val bluetoothManager = getSystemService(BluetoothManager::class.java)
        mBluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner

        startScan()
    }

    override fun onPause() {
        super.onPause()

        // Terminate BLE
        stopScan()
        mBluetoothLeScanner = null

        // Restore settings
        executor.submit { theta.setOptions(mOptionsBackup!!) }
    }

    private fun startScan() {
        val filter = ScanFilter.Builder()
            .setServiceUuid(ParcelUuid(PDLD_LINK))
            .build()
        val settings = ScanSettings.Builder()
            .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
            .build()
        mBluetoothLeScanner?.startScan(Arrays.asList(filter), settings, scanCallback)

        notificationLedShow(LedTarget.LED4)
    }

    private fun stopScan() {
        notificationLedHide(LedTarget.LED4)

        mBluetoothLeScanner?.stopScan(scanCallback)
    }

    private val scanCallback: ScanCallback = object : ScanCallback() {
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            Log.d(TAG, "Result $result")

            stopScan()
            executor.submit { theta.takePicture() }
            result.device.connectGatt(applicationContext, false, gattCallback)
        }
    }
    private val gattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
            when (newState) {
                BluetoothGatt.STATE_CONNECTED -> {
                    Log.d(TAG, "Bluetooth GATT State: CONNECTED")

                    gatt.disconnect()
                }
                BluetoothGatt.STATE_DISCONNECTED -> {
                    Log.d(TAG, "Bluetooth GATT State: DISCONNECTED")

                    gatt.close()
                    Thread.sleep(8000)
                    startScan()
                }
                else -> Log.d(TAG, "Bluetooth GATT State: $newState")
            }
        }
    }
}
```

</div></details>

処理のポイントを解説していきます。

### パーミッションの宣言

まず、Bluetooth の API を使うのに以下のパーミッション宣言が必要です。なぜ位置情報が必要なのかはわからないですが、そういうものらしいです。

```xml
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" />
```

あと、THETA Web API を呼ぶのに以下のパーミッション宣言が必要です。THETA Web API はローカルで動いている API サーバーですが
INTERNET のパーミッションを宣言せずに呼ぶとエラーになります。

```xml
<uses-permission android:name="android.permission.INTERNET"/>
```

一部のパーミッションは実行時にダイアログを出してユーザーに許可を得ないといけないのですが、THETA は画面がないデバイスなので、プラグインストアからのインストール時に許可設定にします。

開発時は自動的に許可されないので、アプリインストール後に次のような ADB コマンドでパーミッションを許可してください。

```
$ adb shell pm grant <アプリのパッケージ名> android.permission.ACCESS_COARSE_LOCATION
```

### 設定のバックアップと初期化

まず、`onResume` メソッドの中で THETA 本体の設定をバックアップしてから、初期化しています。

```kotlin
// Init and backup settings
executor.submit {
    mOptionsBackup = theta.getOptions(CAPTURE_MODE, BLUETOOTH_POWER)
    theta.setOptions(
        OptionSet.Builder()
            .put(CAPTURE_MODE, CaptureMode.IMAGE)
            .put(BLUETOOTH_POWER, BluetoothPower.ON)
            .build()
    )
}
```

このプラグインは THETA が静止画モードかつ BLE ON になっている必要があるので、その設定をしています。

プラグインを起動するたびに本体設定が変更されるのは気持ちが悪いので、プラグイン終了時に設定を戻すために、バックアップをとっています。

設定値の操作には拙作のライブラリ ([theta4j/theta-web-api](https://qiita.com/shrhdk/items/5b9bca30db34822ca7af)) を利用しています。

設定値の操作は HTTP を使うので、I/O 処理が伴います。つまり、UI スレッドで動かすとエラーになるので、[Single Thread Executor](https://docs.oracle.com/javase/jp/8/docs/api/java/util/concurrent/Executors.html#newSingleThreadExecutor--) を使って別スレッドで動かしています。

### BLE の初期化

つづいて、`onResume` メソッド内で、Android の BLE API の初期化をしています。

```kotlin
// Init BLE
val bluetoothManager = getSystemService(BluetoothManager::class.java)
mBluetoothLeScanner = bluetoothManager.adapter.bluetoothLeScanner
```

[BluetoothManager](https://developer.android.com/reference/android/bluetooth/BluetoothManager) のインスタンスを取得して、さらにそこから [BluetoothLeScanner](https://developer.android.com/reference/android/bluetooth/le/BluetoothLeScanner) のインスタンスを取得しているだけです。

### BLE スキャンの開始

初期化が終わったら、すぐにスキャンを開始しています。

```kotlin
private fun startScan() {
    val filter = ScanFilter.Builder()
        .setServiceUuid(ParcelUuid(PDLD_LINK))
        .build()
    val settings = ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
        .build()
    mBluetoothLeScanner?.startScan(Arrays.asList(filter), settings, scanCallback)

    notificationLedShow(LedTarget.LED4)
}
```

まず、Pochiru (eco) の BLE アドバタイジングだけを受信するために、フィルタを設定しています。Pochiru (eco) の UUID は予め調べて定数として宣言しています。(実際に BLE アドバタイジングを受信して調べました。)

```kotlin
private val PDLD_LINK = UUID.fromString("b3b36901-50d3-4044-808d-50835b13a6cd")
```

次にスキャンの詳細を設定しています。スキャンモードを `ScanSettings.SCAN_MODE_LOW_LATENCY` に設定して、その他はデフォルトにしています。`SCAN_MODE_LOW_LATENCY` モードにしないと、ボタンを押してからスキャンするまでに時間がかかるようです。

最後に [BluetoothLeScanner#startScan](https://developer.android.com/reference/android/bluetooth/le/BluetoothLeScanner.html#startScan(java.util.List%3Candroid.bluetooth.le.ScanFilter%3E,%20android.bluetooth.le.ScanSettings,%20android.bluetooth.le.ScanCallback)) を呼び出して、スキャンを開始しています。スキャン結果のコールバックとして、[ScanCallback](https://developer.android.com/reference/android/bluetooth/le/ScanCallback.html) オブジェクトを指定しています。

スキャンを開始したら、THETA の本体 LED を点灯して、準備が完了したことを知らせます。

### スキャン結果の処理

Pochiru (eco) の BLE アドバタイジングを受信すると、`ScanCallback#onScanResult` が呼ばれます。

```kotlin
private val scanCallback: ScanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        stopScan()
        executor.submit { theta.takePicture() }
        result.device.connectGatt(applicationContext, false, gattCallback)
    }
}
```

BLE アドバタイジングを受信したら、まず BLE のスキャンを停止します。BLE アドバタイジングは繰り返し送り出されているので、ここで停止しないと、何度も受信してコールバックが呼ばれてしまいます。

`stopScan` 関数では、単純に `BluetoothLeScanner#stopScan` を呼び出しています。また、THETA がスキャンしていないことを表すために、LED を消灯しています。

```kotlin
private fun stopScan() {
    notificationLedHide(LedTarget.LED4)

    mBluetoothLeScanner?.stopScan(scanCallback)
}
```

スキャンを停止したら、takePicture コマンドでシャッターを切っています。これは I/O 処理が伴うので、別スレッドで実行します。

最後に `result.device.connectGatt` でPochiru (eco) に接続をします。接続結果のコールバックとして [BluetoothGattCallback](https://developer.android.com/reference/android/bluetooth/BluetoothGattCallback) オブジェクトを指定しています。Pochiru (eco) はボタンを1度押すと30秒間 BLE アドバタイジングを送信しつづけるのですが、一旦接続処理をすると送出を停止してくれます。(※Pochiru (eco) 固有の挙動と思われます。一般的な BLE ビーコンは切断後も BLE アドバタイジングを送信しつづけるらしいです。)

送出を停止しないと、再スキャンを始めたときに、前回ボタンを押したときの BLE アドバタイジングを受信してしまい、繰り返しシャッターを切ってしまいます。

### 切断処理と再スキャン開始

BLE アドバタイジングの結果は `BluetoothGattCallback#onConnectionStateChange` が呼ばれます。接続状態が引数としてもらえるので、その結果で分岐して処理しています。

```kotlin
private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        when (newState) {
            BluetoothGatt.STATE_CONNECTED -> {
                gatt.disconnect()
            }
            BluetoothGatt.STATE_DISCONNECTED -> {
                gatt.close()
                Thread.sleep(8000)
                startScan()
            }
        }
    }
}
```

まず、接続成功イベント (`STATE_CONNECTED`) のときはすぐに接続を切断します。BLE アドバタイジングの送出を停止してもらうためのダミー接続なので、すぐに切断して問題ありません。

切断処理の次は切断完了イベント (`STATE_DISCONNECTED`) が発生します。このときは 接続の後処理 `gatt.close()` とスリープ8秒の後に 再スキャンを始めます。あとは繰り返しです。

根本がよくわかっていないのですが、スリープを入れないとイマイチ安定しません…。

## 課題

まず、動作が安定しません。また、安定させるために長いスリープを入れているので、シャッターを切れる間隔が長いです！あとは、ペアリングやデバイス識別をきちんとしていないので、第三者の Pochiru (eco) が周辺にあると、勝手に反応してしまうと思われます。

このあたりの課題は、きちんと BLE ボタンの仕様に従って、接続処理を行えば解決できそうです。

## まとめ

THETA プラグインから BLE を扱えることがわかりました。様々な BLE 機器をつなげて応用ができそうです。

## RICOH THETAプラグインパートナープログラムについて

THETAプラグインに興味を持たれた方がいれば、以下の記事もぜひご覧ください。

* [THETAプラグインパートナープログラムはじめました](https://qiita.com/mrtakada/items/8f3bbf8d283c26899473)
* [【THETAプラグイン開発】プラグインパートナープログラムに登録してみよう](https://qiita.com/mShiiina/items/55d98f366e650ca42251)

[RICOH THETAプラグイン開発者コミュニティ](https://qiita.com/organizations/theta-plugin) では、他にも記事を書いています。
RICOH THETAプラグインについては[こちら](https://api.ricoh/products/theta-plugin/)に情報がまとまっています。興味を持たれた方は [Twitter](https://twitter.com/theta_plugin) のフォローと [THETAプラグイン開発コミュニティ (Slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU) への参加もぜひどうぞ｡