Android
Kotlin
RaspberryPi
AndroidThings
OthloTechDay 25

Android Thingsと脳波センサで眠くなったら部屋の照明を勝手に消す

これはOthloTech Advent Calendar 2017の25日目の記事です。

脳波を測定して、いい感じに眠くなったら部屋の照明を勝手に消してくれる的なの作ろうと思います。

はじめに

脳波を測定するにあたって利用したのは「Museヘッドバンド」という脳波センサです。
本来の使い方としてはヨガや瞑想するときにつけて、脳がなんか良い感じの状態になると接続しておいたスマホから小鳥がピヨピヨ鳴いてくれるそうです。 Amazonのレビューがなんか怖い

P1140863.JPG

このヘッドバンド、開発用にSDKが公開されていて、ドキュメントもしっかりと有るので開発者に親切です。
原状ではAndroid、iOS、Windowsの3つのプラットフォームに対応しています。Macもそのうち対応するようです。今回はAndroidを使います。

ヘッドバンドからはBluetooth Low Energyで「 α波、β波、θ波、δ波 」などの値を受け取ることができます。
受け取った値から、眠いかどうかを判断して照明のON/OFFをすることにします。

部屋の照明を操作するとは言っても、電気工事ができるわけではないので、サーボでスイッチをパチパチします....アナログです。

今回はRaspberry Pi3にAndroid Thingsを載せて、脳波センサからデータを受け取って、そこからサーボの制御を行おうと思います。
最初は『ヘッドバンド→スマホ→Arduinoでサーボ』て感じでやろうかと思ったのですが、
「Android Things使えば間にスマホ挟まなくても良くね?」ってことに気が付きました。

環境

Kotlin
Android Things 0.6.1 ( Raspberry Pi3 )
Mac OS High Sierra
Museヘッドバンド

Android Thingsのインストール

microSDをPCに挿して、Googleのここの手順通りにやれば良いです。と言うかコンソールが勝手にやってくれました。
起動したらBluetoothをONにします。デフォルトでONになっていると思いこんでいて無駄にハマりました。

Bluetoothをオンにする
adb shell service call bluetooth_manager 6

以前のバージョンはデフォでONだったような記憶もあるのですが...

プロジェクトのセットアップ

AndroidThings用のプロジェクトを立ち上げる。
そのあとMuseヘッドバンドのSDKをダウンロードして、ここの手順通りにライブラリをインポートする。むちゃくちゃ親切。

センサ(ヘッドバンド)から脳波データを受信する

FABを押したら周囲にある電源がONのヘッドバンドとの接続を開始します。
その時に受信する脳波の種類やデータを決めます 。

MainActivity.kt
class MainActivity : Activity() {

    private val museManager = MuseManagerAndroid.getInstance()
    private val dataListener = DateListener()
    private val handler = Handler()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setActionBar(toolbar)

        museManager.setContext(this)

        // fabを押したら、周囲からヘッドセットを探して、接続を開始します。
        // その時受信する脳波の種類やデータを決めます(反応するリスナを決める) 。
        fab.setOnClickListener {
            museManager.stopListening()

            val museList = museManager.muses//周囲にあるヘッドバンドのリストを取得する
            if (museList.size >= 1) {
                val muse = museList[0]
                muse.unregisterAllListeners() //以前登録されたリスナをすべて解除
                muse.registerDataListener(dataListener, ALPHA_ABSOLUTE) // α波
                muse.registerDataListener(dataListener, BETA_ABSOLUTE) // β波
                muse.registerDataListener(dataListener, DELTA_ABSOLUTE) // δ波
                muse.registerDataListener(dataListener, THETA_ABSOLUTE) //θ波

                muse.runAsynchronously()// ヘッドバンドとの接続を開始し、データを非同期でストリーミングする。
                Toast.makeText(this, "successfully", Toast.LENGTH_LONG).show()
            } else {
                //周りに一つもヘッドセットが存在しない時
                Toast.makeText(this, "Headset is not found around", Toast.LENGTH_LONG).show()
            }
        }


//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜


}

データを受信したときのリスナを用意

リスナを保持する『MuseDataListener()』を実装したクラスを用意して、データを受信したらバッファーに流し込みます。
バッファーはマップで管理していて(キー:脳波の種類、値:4つの要素の配列)になっています。
ヘッドバンドにはセンサが「左耳、左額、右額、右頬」の4つに付いていて、それらの値を配列に格納していきます。

MainActivity.kt
// 脳波センサから送られてきた値を格納するMapを用意する。
// MapのValueは 受け取ったデータを格納するバッファー。
// MapのKeyは 脳波に対応するライブラリ内のEnumを利用する。
private val eegBufferMap = mutableMapOf(
        ALPHA_ABSOLUTE to arrayOfNulls<Double>(4),
        BETA_ABSOLUTE to arrayOfNulls(4),
        THETA_ABSOLUTE to arrayOfNulls(4),
        DELTA_ABSOLUTE to arrayOfNulls(4))

//バッファーにデータが格納してあるかを確認するBoolean を保持するマップを用意
private val hasEegData: HashMap<MuseDataPacketType, Boolean> = hashMapOf(
        ALPHA_ABSOLUTE to false, BETA_ABSOLUTE to false, THETA_ABSOLUTE to false, DELTA_ABSOLUTE to false)




class DateListener : MuseDataListener() {

    //脳波データを受信したときのリスナ
    override fun receiveMuseDataPacket(p0: MuseDataPacket, p1: Muse) {
        val eegType = p0.packetType()
        val eegBuffer = eegBufferMap.getValue(eegType)
        for (i in 0..3) {
            // EEG1(左耳), EEG2(左額), EEG3(右額), EEG4(右耳)の値をバッファーに格納する
            eegBuffer[i] = p0.getEegChannelValue(Eeg.values()[i])
        }
        // データが受信されたかどうかを確認するマップの値をtrueにする。
        hasEegData.put(eegType, true)
    }

    // ヘッドセットが外された、目の瞬き...などが検出されたときに呼び出される。今回は実装なし。
    override fun receiveMuseArtifactPacket(p0: MuseArtifactPacket, p1: Muse) {}

}

脳波をグラフにして眠いか判断する

グラフのセットアップ

せっかくAndroidでやるならGUIがなんとなく欲しいので、脳波の値が解りやすいように折れ線グラフにして出力するようにしてみます。グラフの描写はMPAndroidChartを利用します。

MainActivity.kt
class MainActivity : Activity() {

//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

     // グラフをセットアップする
        chart.setDescription("") // グラフのタイトル。今回は空。
        chart.data = LineData() // LineDataインスタンスを追加

        // グラフのラベルと色のマップ
        val chartMap = linkedMapOf(
                "alpha" to Color.parseColor("#F44336"),
                "beta" to Color.parseColor("#FF9800"),
                "delta" to Color.parseColor("#00BCD4"),
                "theta" to Color.parseColor("#2196F3"))

                // 脳波4つに対応した折れ線グラフを用意する。
        for (label in chartMap.keys) {
            val line = LineDataSet(null, label) // 新しく折れ線グラフを生成
            line.color = chartMap.getValue(label) //線の色を指定
            line.setDrawCircleHole(false) // エントリー部分に円を表示しない
            line.setDrawCircles(false)
            line.setDrawValues(false) // エントリー部分に値を表示しない

            chart.lineData.addDataSet(line) // 折れ線グラフを追加する
        }
}

グラフを描写するスレッド

グラフの描写はリアルタイムで行う必要があるのでメインのUIスレッドと別で行います。

配列に格納してあるセンサの値を統合する

現在、それぞれ脳波の各バッファーには「左耳、左額、右額、右頬」の4つのセンサの値が格納されている。
これらの値を統合して、一つの値にしてグラフに描写する必要がある。
どうやって統合するのが正解かいまいちわからないので...とりあえず全部足して平均を出すことにしました。

MainActivity.kt
private val tickUI: Runnable = object: Runnable {

        override fun run() {

            //取得した脳波の値を保持する
            val eegValueMap = hashMapOf<MuseDataPacketType, Double>()
            val data = chart.lineData

            // 各脳波(α波、β波、θ波、δ波 )のバッファーには(左耳、左額、右額、右頬)の4つのセンサの値が格納されている。
            // その4つの値を統合して一つの値にし、グラフに渡して描写していく。
            for ((index, eegType) in arrayOf(ALPHA_ABSOLUTE, BETA_ABSOLUTE, DELTA_ABSOLUTE, THETA_ABSOLUTE).withIndex()) {
                // 脳波の値はバッファーに格納してあるか?
                if (hasEegData.getValue(eegType)) {
                    // 統合した値を格納する変数を用意。
                    // それぞれ値を100倍して、すべて足す。
                    // それを4で割った値を統合した値とする。
                    var eegValue = 0.0
                    eegBufferMap.getValue(eegType).filterNotNull()
                            .forEach { eegValue = it * 100 }
                    eegValue /= 4
                    eegValueMap.put(eegType, eegValue)

                    //グラフに値をセットする
                    val line = data.getDataSetByIndex(index)
                    data.addEntry(Entry(line.entryCount.toFloat(), eegValue.toFloat()), index)
                    data.notifyDataChanged()
                }
            }

//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

        }
    }

算出した値から眠い状態かどうかを判断する

これまた、脳波とか全く知らないのでどのような脳波の状態が眠いのか判断できない...
どの波形が何を司っているか調べてみると、

α波がリラックス
β波が注意
θ波が寝る
δ波が寝る(弱い)  

らしい。

ということで、むちゃくちゃ安直だがθ波とδ波が勝っている状態、
つまり ( α波の値 + β波の値 ) < ( δ波の値 + θ波の値 ) が眠い状態と定義する!!!
この状態が5秒間くらい続いたとき消灯する、ということにしよう。脳波とか知らんすぎる。

MainActivity.kt
// 眠い状態なったとき現在時刻を保持する変数を用意
private var startTime: Long = 0
MainActivity.kt
   private val tickUI: Runnable = object: Runnable {

        override fun run() {


//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
//〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜


                        // すべての脳波のデータがちゃんと受信された状態か?
            if (hasEegData.getValue(ALPHA_ABSOLUTE) && hasEegData.getValue(BETA_ABSOLUTE)
                    && hasEegData.getValue(THETA_ABSOLUTE) && hasEegData.getValue(DELTA_ABSOLUTE)) {

                // グラフの表示を更新する
                chart.notifyDataSetChanged()
                chart.setVisibleXRangeMaximum(50.toFloat())
                chart.moveViewToX(data.entryCount.toFloat())

                // 眠い状態か?
                if ((eegValueMap.getValue(ALPHA_ABSOLUTE) + eegValueMap.getValue(BETA_ABSOLUTE))
                        < (eegValueMap.getValue(DELTA_ABSOLUTE) + eegValueMap.getValue(THETA_ABSOLUTE))) {

                    // 眠い状態なら
                    // 状態を示すTextViewの値を更新する
                    awakeningStatus.text = "Sleepy"
                    awakeningStatus.setTextColor(Color.parseColor("#F44336"))

                    // 初めて(もしくは再び)眠い状態に陥ったとき、現在時刻を取得する
                    if (startTime == 0.toLong()) {
                        startTime = System.currentTimeMillis()
                    }


                    // 5秒間眠い状態が状態が続いたら、部屋の照明を消す。
                    if (System.currentTimeMillis() - startTime > 5000) {

                        // 部屋の照明を消す処理。
                        // サーボモータで直接スイッチをパチパチする。
                    }

                } else {
                    //眠くなくて覚醒状態のとき

                    // 状態を示すTextViewの値を更新する
                    awakeningStatus.text = "Awakening"
                    awakeningStatus.setTextColor(Color.parseColor("#76c13a"))

                    // 眠くなった時刻を保持する変数を0にする
                    startTime = 0
                }
            }

            //再帰的にLooperのキューに1000/60msの間隔で、このRunnableを追加する。
            handler.postDelayed(this, 1000 / 60)
        }

    }

サーボモータを動かす

使うのはおなじみのSG92RをPWM制御します。
秋月のリンクにあるように、このサーボはパルス幅が0.5ms〜2.4msで角度を指定しますから、
周期が20ms(50Hz)としたときデューティ比は2.5%〜12%になります。
つまり、目的角へのデューティ比は( ( 12 - 2.5 ) ( 目的の角度 / 180 ) + 2.5 )で求める事ができます。

今回、部屋のスイッチを押すにはだいたい70°ぐらいがちょうど良さそうなので
(サーボホーンの可動域の中心を90°と見たとき)、だいたい6.1%(1.2ms)くらいのデューティ比を渡せば良さそうです。

MainActivity.kt
                   // 5秒間眠い状態が状態が続いたら、部屋の照明を消す。
                    if (System.currentTimeMillis() - startTime > 5000) {

                        // 部屋の照明を消す処理
                        val service = PeripheralManagerService()
                        val pwm = service.openPwm("PWM0")
                        pwm.setPwmFrequencyHz(50.0) // 周波数を50Hzに
                        pwm.setPwmDutyCycle(6.1) // 70°動かす
                        pwm.setEnabled(true)

                    }

ラズパイはハードウェアPWMで出力できるのは2ピンしかありません。今回はPWM0に出力します。
サーボの電源は適当に5Vくらい与えときます。ラズパイから直接電源取ることもできますが、ラズパイの動作が不安定になりかねないので、電源は別で用意するほうがベターです。

IMG_20171227_024931.jpg
貼っただけです。
赤外線リモコンで操作できるタイプの照明なら赤外線LED使ってやるのも良いかもしれません。

GUI

GUIは取り急ぎで作ったので簡素です。XMLは必要ないでしょう...見たままです。
プライマリーカラーにQiitaのサイトカラーと同じカラーコードを指定したのですが、ADBから録画したデータはなぜか抹茶みたいな色になっちゃいました。嫌。
スクリーンショット 2017-12-26 5.50.28.jpg

問題点

BluetoothLEの通信が凄まじく不安定です。
手元にあるスマホ(Pixel2、Nexus5X)でテストした限りは普通に通信できるので、ヘッドセット自体は問題ないです
BLEの通信部分はヘッドセットのライブラリ内あります。そこがAndroidThings(&RspberryPi3)と相性が悪いのでしょうか。
もしくは、AndroidThingsがまだプレビュー版なので通信部分がまだまだ安定していないのかもしれません。(あまり、考えられませんが...)

総括

邪魔すぎてこんなのつけて寝れん。