Android
gas
IoT
FCM
GoogleHome

Google Home+IFTTT+GAS+FCMによる音声リモコンAndroidアプリ開発でハマったこと


はじめに

カーテン自動開閉機「mornin' plus」をGoogle HomeやAmazon Echo(Alexa)から音声制御したい(音声リモコンのように使いたい)という要望は多いようです。

ネットでは下記の方法が公開されています。

上記では、IFTTTとPushbulletとTaskerアプリで「めざましカーテンmornin' plus」アプリを自動操作しています。ただ、Taskerアプリで操作を指示するのもそれなりに手間がかかります。

mornin' plusをBluetooth経由で直接操作するアプリを作ってしまえば、もう少し簡単になるのではと思って作ってみたのが「mornin' plus 連携」アプリです。

mornin' plus 連携」アプリはmornin' plusの開発・販売元である株式会社ロビット様に特別に許可を頂いて作りました。mornin' plusをBluetooth経由でどうやって操作しているのかに興味がある方もおられるかと思いますが、株式会社ロビット様とのお約束もあり、mornin' plusの制御方法の詳細については残念ながらご説明できません。

ただ、アプリを開発する過程で、スマホがスリープ状態でも反応できるようにする部分でいろいろハマりました。

この記事ではそのハマりポイントと対応方法についてご説明したいと思います。


ハマりポイント・対応方法の要約と最終のシステム構成

時間の無い方は以下の要約をご覧ください。


  • ハマりポイント1:スリープ中だとIFTTTからの通知に素早く反応できない

  • 対応方法1:High Priority指定のFirebase Cloud Messagingを使う



  • ハマりポイント2:スリープ中はBluetoothが使えないことがある

  • 対応方法2:wakelockで画面を点灯させる



  • ハマりポイント3:Android9以降だとBluetoothを自動的にON/OFFできない

  • 対応方法3:Android 9以降は諦めて、Android 8以下で自動ON/OFFさせる

最終システム図2_small.png


前提

本記事は以下の知識・経験があることを前提としています。


  • IFTTT

  • Firebase Cloud Messaging

  • Google Apps Script

  • Androidアプリ開発


[失敗] IFTTTアプリの通知をフックする方法はスリープに素早く反応できない

うまくいかなかった方法の説明なので、興味ない方は次の「Firebase Cloud Messagingを使ったシステム」の項目に進んでください。


最初のシステム構成

最初のシステム図_small.png

AndroidのIFTTTアプリから他のアプリをIntentなどで直接起動する方法は無いため、IFTTTアプリからAndroidの通知を行い、連携アプリで通知をフックして起動することにしました。

Androidアプリで通知を取得/削除する処理はこちらを参考にさせてもらいました。

上記の構成で一通り動作はするのですが、実際に利用してみると下記の問題があることが分かりました。


[問題1] 浅いスリープでIFTTTアプリの通知が遅くなる

ここでいう「浅いスリープ」とは、端末の画面をOFFにした直後ぐらいのことを指しています。OSバージョンや端末によっては、時間が経つとさらに深いスリープに入るのですが、深いスリープの問題は[[問題3]でご説明します。

Google HomeからIFTTTのGoogle Assitantへの連携時間は問題ありません。

その後の、IFTTTサーバからAndroid端末のIFTTTアプリ経由でAndroid端末に通知を出すまでに、時間がかかることがあります。

うまくいく時は1-2秒で通知が来るのですが、うまくいかない時は1分ぐらい遅れることもあります。

音声リモコンとして、この遅れは致命的です。


[対応1] 「電池の最適化」の対象から外す

端末の設定で「電池の最適化」の対象から外すことで反応はだいぶマシになることが分かりました。

外し方はOSバージョンや端末によって異なりますので、例えばこちらを参考にしてみてください。


[問題2] 通知をフックできなくなることがある

通知をフックする機能が、なぜか突然機能しなくなってしまうことが時々あります。一度その状態になると自動的に復帰することはないようです。

ネットで調べてみるとこの問題は「通知フック開発あるある」のようで、他の方も同様の問題に悩まされているようです。

通知に反応するアプリを例外で強制終了させてしまうとこの状態に陥りやすいです。強制終了を通知の度に発生させないように、OS側で動作を監視して止めているのかもしれません。


[対応2] 通知許可のON/OFF切り替え、もしくは端末再起動で復旧する

対処療法ですが、下記のように端末の設定の通知許可を一旦OFFにしてからONに設定しなおすと、復旧することがあります。

  [設定]→[アプリと通知]→[詳細設定]→[特別なアプリアクセス]→[通知へのアクセス]

一番確実なのは、端末再起動です。


[問題3] 深いスリープ状態(deep doze)で動作しない

一番の問題がこれでした。

dozeモードはAndroid 6から導入されています。省電力のため、一番深いスリープ状態だと十数分に1回ぐらいしか通知が取得されません。

これは音声リモコンとしては致命的です。


[対策3] Firebase Cloud Messagingならdozeモードでも反応できる

dozeモードについて調べてみると、High Priorityを指定したFirebase Cloud Messaging(FCM)はdeep doze状態でも瞬時に反応してくれることが分かりました。


GCM の優先度の高いメッセージは、ユーザーの端末が Doze モードになっている、またはアプリがスタンバイ モードになっている場合でも、アプリを確実にアクティブにして、ネットワークにアクセスできるようにします。


https://developer.android.com/training/monitoring-device-state/doze-standby?hl=JA より)


Firebase Cloud Messagingを使ったシステム


最終のシステム構成

最終システム図2_small.png


Firebaseプロジェクトの作成とサーバーキー取得

Firebaseプロジェクトを作ってAndroidアプリにFCM機能を追加する場合、Android Studioの「Firebase Assistant」を使うのが一番簡単です。詳しい手順は下記が参考になるかと思います。

サーバーキーの取得方法は下記が参考になるかと思います。


Androidアプリ側の実装


CurtainMessageService.kt

class CurtainMessageService : FirebaseMessagingService() {

override fun onMessageReceived(remoteMessage: RemoteMessage?) {
if ( 0 < remoteMessage.data.size ) {
val action = remoteMessage.data["action"]

// AsyncTaskを使って、mornin' plusでactionを実行する処理を行う
// ...
}
}

override fun onNewToken(token: String?) {
// tokenは端末を識別するdevice文字列として保存しておく
}

}


Firebase Assistantを使えば雛形は作成してくれると思いますが、主な処理は上記のようになります。

deep doze状態でonMessageReceived()からあまり時間のかかる処理を呼ぶとOSから止められるかもとは思いましたが、1分ぐらい動作させてもOSから止められることは無いようです。

なお、onMessageReceived()がUIスレッドではないため、onMessageReceived()から生成したAsyncTaskで処理を行う場合、


Handler handler = new Handler();


で取得したHandlerを使うと


Can't create handler inside thread that has not called Looper.prepare()


というエラーメッセージの例外が発生します。


Handler handler = new Handler(Looper.getMainLooper());


とすると使えるようになります。

(参考:https://stackoverflow.com/questions/41729152/display-messagebody-of-firebase-notification-as-toast

追記(2019年6月16日):

そもそも、AsyncTaskはUIスレッドで使わないといけないとAPI referenceにも記載されています。上記はちょっと強引なやり方です。

onNewToken()の引数のtokenはAndroid端末を識別する文字列です。音声リモコン用途としてFCMを使う場合、特定の端末にだけメッセージを送る必要があるので、このtokenを宛先指定として覚えておく必要があります。


Google Apps Script (GAS)

FCMを呼び出すには、サーバーキーが必要です。「Firebaseプロジェクトの作成とサーバーキー取得」の項目で取得したサーバーキーを使います。

サーバーキーはPOST呼び出しのheaderに指定しますが、IFTTTのThatとして指定できるWebhookでは任意のheader指定ができません。そのため、Webhookから外部のシステムを経由してサーバーキーのheaderを付加してFCMを呼び出す必要があります。それに加えて、あまりサーバーキーを公開したくないという側面もあります。

外部のシステムとしてwebscript.ioを使った例Integromatを使った例もありますが、今回は使い慣れたGASを使うことにしました。


GASのコード

function doPost(e) {

var params = JSON.parse(e.postData.getDataAsString()); // WebhookからPOSTされたJSONデータ
var action = params.action; // FCMから送信するデータ(mornin' plusの開閉指示文字列)
var device = params.device; // FCMからデータを送信する端末

// POSTデータ
var payload = {
"to":device,
"data": {
"action": action
},
"priority":"high"
}

var headers = {
"Content-Type" : "application/json",
"Authorization" : "key=xxxxxxxxxxx" // サーバーキー
}

var options = {
"method" : "POST",
"headers" : headers,
"payload" : JSON.stringify(payload)
}

var url = "https://fcm.googleapis.com/fcm/send"
var response = UrlFetchApp.fetch(url, options);

return response.getResponseCode(); // Webhookに返されるレスポンスコード
}


FCMからのデータ送信でHigh Priorityを指定する方法は下記を参考にさせていただきました。

GASで「公開」/「ウェブアプリケーションとして導入」を選択して、「アプリケーションにアクセスできるユーザー」を「全員(匿名)」にして公開します。その際、「ウェブ アプリケーションの URL」が取得できますので、URLを控えておきます。


IFTTTの設定

IFTTTの「This」は「Google Home」もしくは「Alexa」、「That」は「Webhook」を設定します。

IFTTTのWebhookの設定では、URLは控えておいたGASの「ウェブアプリケーションのURL」、MethodPOSTContent TypeApplication/jsonBodyは下記のようなJSON文字列を指定します。


{ "action" : "open C7:11:25:6A:01:9E", "device" : "dBz1sLNLZ8g:APA91bEevGN2racBu75BlEiwpIjUOrDrkfLJzFqhkoxk43_e4jyHRDBBimmphsGVcIpXxyjUQXOl42giSploqUJks2N82atvZmjgdBiCmJSh5I-Saot4-YCJ0jLvxh6kW9rm3CxUpKw3" }


actionの値は、mornin' plus連携アプリに与えるmornin' plusの開閉指示文字列です。"open"もしくは"close"とmornin' plus本体のMacアドレスを組み合わせた単純なものです。複数のmornin' plusを制御したい場合は、" ; "で区切って複数並べることもできます。

deviceの値は、onNewToken()の引数tokenで取得した文字列です。

URLBodyは、mornin' plus 連携アプリでは、コピーボタンを押すことでクリップボードにコピーできるようにしています。


[問題4] Android9の一部機種でスリープ中はBluetoothの機能が制限される

Android 9共通の問題かどうか分かりませんが、ファーウェイのある機種で動作確認をすると、スリープ状態ではBluetoothのスキャン機能が呼べませんでした。


[対策4] wakelockで画面を点灯させる

wakelockを使って画面を点灯させることで、この問題を回避できました。


wakelock例

val mWakelock = (getSystemService(android.content.Context.POWER_SERVICE) as PowerManager)

.newWakeLock( PowerManager.SCREEN_DIM_WAKE_LOCK + PowerManager.ACQUIRE_CAUSES_WAKEUP
+ PowerManager.ON_AFTER_RELEASE, "startmornin:disableLock")

if ( mWakelock != null && mWakelock!!.isHeld == false ) {
mWakelock?.acquire(60L * 1000);
}



[問題5] Android9だとBluetoothを自動的にON/OFFできない

普段はバッテリーを節約するためにBluetoothをOFFにしておき、音声リモコンとして使う時だけBluetoothをONにできるようにしたい方はいらっしゃると思います。

mornin' plus連携アプリでは、起動時にBluetoothがOFFであればアプリで自動的にONにし、mornin' plusの制御が終わったアプリ終了時に自動的にOFFにするようにしようとしました。起動時のONの場合はアプリ終了後もONのままにします。

Android8まではアプリからBluetoothを自動的にON/OFFすることができました。

しかし、Android 9以降では、アプリからBluetoothをON/OFFさせようとすると、毎回、ユーザーに許可を求めるダイアログが表示されてしまいます。

一度許可をしたらそのアプリでは二度とダイアログが表示されないのでしたら問題ないのですが、毎回表示されてしまいます。これでは音声リモコンとしては使えません。


[対策5] OSバージョンによる処理の振り分け

いろいろ試行錯誤してみましたが、やはりこれはOSの仕様であり、どうしようもないようです。

Android 9以降はBluetoothを予めONにしておいてもらう必要があります。最近のスマホではdozeモードのお陰か、Bluetoothを常時ONにしてもバッテリー消費は小さいようなのであまり問題は無さそうです。

最終的にmornin' plus連携アプリでは、Android 8まではアプリでBluetoothを自動的にON/OFFしますが、Android 9以降はBluetoothがOFFの場合はエラー終了させるようにしました。


連携動作

以上のようなシステムを構築して設定を行うことで、Google Home(やAlexa)に「OK、グーグル、カーテン開けて」などと音声で指示すると、Google Home → IFTTT → GAS → FCM → mornin' plus連携アプリと伝わり、カーテンが動作します。

動作の動画

https://youtu.be/vtxMwGFVf34