ESP8266と音声認識をつかったWifiスイッチの続きというか「まとめ」です。ほぼ一段落ついたので。
もともとは、ESP8266+赤外線LEDでエアコン制御するところから始まりましたが、その後さらに音声認識でもON/OFF可能にしようということで、ここ最近までやっていました。
以下がこれまでの経緯です。
・IoTその2:ESP8266(Wifi赤外線リモコン:仮)
・IoTその4:再度ESP8266+Blynk
・IoTその5:音声認識でESP8266をWifi制御
・音声認識:Voice Recognition Module VR3.1
・IoTその6:音声認識+ESP8266(改良中)
・ESP8266ファイルシステム:SPIFFSについて
・ESP8266:WebサーバからHttpリクエスト
・ESP8266:XMLHttpRequestで改良
・ESP8266:音声認識Wifiスイッチ:フィードバックで現状表示
これまで、かなり試行錯誤しましたが、おかげでJavaScriptの勉強もでき、今回はそれほど利用しなかったのですが、Node.js、EJS、Express、Python Flask、そしてNode-RED、Blynk HTTP RESTful APIなどの使い方も多少理解できるようになったという感じです。
追記:2017年8月以降
その後、Chromeのセキュリティ仕様が改変されたためか、http://〜(ノンセキュア)のサーバにWeb Speech APIのJavascriptを含んだHTMLファイルをアップロードすると、マイクへのアクセスができなくなってしまいエラーがでてしまいました(Chromeの設定などを変えたりしてみましたがダメでした)。http://だと保護された通信ではないために自動的にマイク使用を遮断してしまうようです。そこで保護された通信とするためにhttps://のサーバへアップロードすれば大丈夫かと思いHerokuをつかってみました(以前参考にさせて頂いた「クラゲのIoTテクノロジー」さんのHerokuへのアップロードを再度参考にしました)。
さらに追記(2018年):
上記の方法でセキュアなhttpsへアップロードしても、以下のように最初はブロックされてしまいます。
これを利用すれば、セキュアなローカルIPアドレスにアクセスすることになるためchromeのブロックや警告がでなくなるはずなのですが、このWiFiHTTPSServerで証明書と暗号鍵を生成してアップロードすると、またchromeではブロックされてしまいました。FireFoxだとセキュリティにおいて多少融通がきくのか、一度許可を与えれば大丈夫でした。しかしながら、FireFoxだとWeb Speech APIが使えないので、結局のところ最初に戻って、ESP8266のローカルIPアドレスは「http」のまま、chromeで「安全ではないスクリプトを読み込む」という方法をとっています。
以下はこれまでの内容。
手順としては:
・Herokuへ登録
・Heroku CLIをダウンロード&インストール
・Heroku Static Providerのインストール&デプロイ
要は元々動的サーバとして使うHerokuを静的サーバとして使い、そこにWeb Speech APIのJavascriptを含んだHTMLファイルをアップロード/デプロイするという感じです。そうするとhttps://のセキュアなサーバにアクセスするためにマイク使用が遮断されなくなるようです。とりあえず動くようにはなりましたが、これ以外の方法でも可能なのか現在検討中です。
以下からは、これまでの内容。
プログラムの流れ:
・Web Speech APIのText to Speechで、音声をテキストに変換
・テキストに含まれるキーワードを正規表現を使って一致判定
・キーワードに応じたURLへリクエスト(デバイスのON/OFF制御)を出す
・リクエストを受け取ったESP8266が各デバイスのスイッチをON/OFFする
・温度/湿度の値や各スイッチのON/OFF状況はチェック用URLへリクエストを出しレスポンスとして変数を受け取る
・レスポンス(変数)の内容をブラウザ画面上に反映させ、ON/OFF状況などを表示する
今回の仕組み:
前回で、だいたいのシステムができあがったのですが、もう少し内容を整理しなおしてみました。前回までは音声出力は省いていましたが、せっかく音声を使ったスイッチなので、音声出力も復活させました。「エアコンをオンにしてください」と話しかければ、「エアコンをオンにしました」と答えてくれます。また、デバイスが非接続の状況だと「現在、エアコンは制御できません」と言い返してきます。
現在は3個のESP8266に以下のようにスイッチやセンサをつけています。今後も増える予定です。
・リビング(IP:192.168.3.12):蛍光灯(ON/OFF)
・寝室(IP:192.168.3.13):照明(ON/OFF)、エアコン(ON/OFF)、温度/湿度(センサ値)
・トイレ(IP:192.168.3.14):照明(ON/OFF)
寝室だけ、照明のON/OFF、赤外線リモコンによるエアコンのON/OFF、そして温度/湿度センサからの読み取りという感じでパラメータが多く、しかも温度/湿度の値を画面内のエアコンONのボタン上に表示させるので、ひとつだけ例外的なプログラムになってしまいました。
追記:
その後、音楽を再生させる機能もつけようと試してみました。
soundcloud.comでよく音楽を聞くので、「音楽かけて」などと言えば、window.open()で、
https://soundcloud.com/stream#play
にアクセスして自動で再生されるようにしてみましたが、音楽が流れているとうるさくて音声認識できなくなってしまうこともあり、採用するかどうか検討中です。
エディタ:
最近はAtomエディタを使ってJavaScriptを書いています。
これまではSublime Textで書いていましたが、ESP8266やESP32でPlatformIOも使い始めたのでAtomを使うことにしました。Visual Studio Codeも便利そうですが、Atomエディタは無料だしGitHub製なのでいいかと。
Atomでは、Scriptというパッケージをインストールするとcommand+IでJavaScriptをすぐに実行できるので便利です。しかもHTMLを表示させるHTML Previewのパッケージもインストールしておけば、HTML上でどのように見えるかリアルタイムでチェックできます(ローカルなので音声認識は不可/ESP8266とは通信可)。色など変えるときはソースに書き込むと同時に反映されるので、わざわざブラウザで確かめるまでもないと言う感じです。
通信の大まかな流れ:
通信上の仕組みは前回からはあまり変わっていませんが、最終的にはこんな感じにしました。
3台あるESP8266には、簡易的なHTML(音声認識なし)をアップロードしておき、それぞれは異なるIPアドレスを持ったWebサーバとして機能しています。
それとは別に外部のWebサーバ(無料ホームページのサーバなど 追記:実はHerokuなどのhttps://〜が使えるセキュアなサーバがいい)に、今回の音声認識のJavaScriptを書いたHTMLファイルをアップロードしています。この外部WebサーバとESP8266は通信しあうわけではなく、すべてはブラウザからXMLHttpRequestでESP8266と通信しています。なので、音声認識のプログラム自体はどこにあってもいいという感じです。仮にESP8266に音声認識のプログラムをアップロードしてもいいとは思いますが、いちいち書き換えが面倒なので、外部Webサーバにドラッグ&ドロップでアップロードしたほうが早いということです。もちろんローカルネットワーク内のRaspberry Piなどにアップロードしても構いません。
前回同様に、各スイッチのON/OFF状況やセンサの値は、ブラウザを立ち上げると同時に3台のESP8266のIPアドレス/checkというURLへXMLHttpRequestしてESP8266内にある変数を送ってもらい、その値を表示画面に反映させています。どれがON/OFFになっているか、あるいは温度/湿度の値、または非接続中のESP8266などが分かります。
基本的に、Speech to TextとText to Speechは、このようなJavaScriptによって可能になりますが(最終的にはHTMLへ埋め込む必要がある)、音声認識機能を途切れなくするためには、コード中ほどにある
のように、rec.onend()で音声認識が切れたら、rec.start()で再度開始させるようにしています。いろいろ試してみましたが、このようにsetTimeout()をつかって、発動タイミングをずらすとエラーなどでないようです。ちなみに、setTimeout()は0秒でも大丈夫です。setTimeout()なしで即rec.start()させると、もともとrec.onend()が非同期通信のためか、rec.onend()の処理が完了するまえにrec.start()が発動してしまうようでエラーがでてしまいます。このときMacBook上のブラウザ(Chrome)では特に問題ないのですが、Androidタブレットやスマホ上のChromeだと、再スタートを繰り返しているうちにブラウザが落ちてしまい(あるいはフリーズ)、長時間待機そのものが不可能になっていました。今回の場合は、setTimeout()を使うことでエラーがでなくなったために、その問題も解決できたようです。
使い方として、部屋の電源をON/OFFしたいときに、わざわざブラウザを立ち上げて、それに向かって話しかけるというのではなく(この場合、話しかけるよりも画面上でボタン操作したほうが確実で早い)、サーバのURLにアクセスした状態のタブレットをテーブルの上などに置いておき(長時間待機させておき)、気が向いたときに話しかけてON/OFF制御するという感じです(上画像)。1時間ほど長時間待機させてみましたが、途中エラーでブラウザが落ちてしまうこともなく大丈夫でした(以前は数分で落ちていた)。
音声認識だからと言って、毎回話しかけてON/OFFする必要もないかもしれません。どちらかと言えば、パソコン、スマホ、タブレットなどでボタン操作するほうが主な使い方かもしれません。音声認識については、いちおうそれも可能という程度で。
ESP8266からON/OFF状況を取得することができるようになったので、ON/OFFボタンをひとつにまとめてPUSH/ON-PUSH/OFFにしようかと思いましたが、万が一にそなえてあえてONとOFFのボタンを別々にしておきました。ONを押したのに電気がつかないというときに、再度ONボタンを押せるようにするためです。交互にON/OFFが切り替わるボタンだと、ONのつぎはOFFとなり、連続してONが押せなくなってしまいます。
正規表現:
今回の音声認識では、エアコンをオンにする場合であるならば、
「エアコンをオンにしてください」
「エアコンをオン」
「エアコンをつけて」
などと言っても認識できるように以下のような正規表現で認識の幅を持たせてあります。Speech to Textでは、たまに「オン」が「on」や「音(おん)」に自動変換されてしまうときもあるので、それも受け入れるようにしてあります。
また、「〜をオンしない」あるいは、「〜をつけない」と言えば、「〜ない」が含まれているために反応しないようにしてあります。同様に、「オン」させたい場合は「オフ」という言葉が含まれていれば反応しないようにしてあります。
さらに、蛍光灯やエアコンなどのデバイスをdev_jpという変数として正規表現内に挿入しています。「〜をオンにする」という感じです。JavaScriptにおいては、「/abc/」のようなスラッシュをつかった正規表現内では変数を使うことができないようですが、上記のようなnew RegExp()でオブジェクト生成したときは、カッコ内には正規表現にしたい文字列を入れることになるので、この文字列内で変数が挿入可能となります。変数が使えることで、ON用の正規表現、OFF用の正規表現をつくっておき、あとは変数内のデバイス名を入れ替えて使いまわすということになります。ちなみに、Rubyでは、「/#{abc}/」とすることで、abcを変数として扱えるようです。
また、Scriptularというオンラインのサイトで正規表現のチェックができます。これはJavaScript用です。
今回のような単純なキーワードによる文字列の一致判定をするには、正規表現をつかわないでincludes()でも十分可能かもしれません。プログラム的には、一致させたいキーワードが含まれているかどうか、NGワードが含まれているかどうかを判定しているだけです。
赤外線リモコン制御(エアコン用):
今回の音声認識Wifiスイッチでは、照明器具のON/OFF以外にエアコンの赤外線リモコンによるON/OFF制御も含まれています。ESP8266につけた赤外線LEDで制御します。赤外線制御については、IRremoteという便利なArduino用ライブラリもありますが、今回は使わずパルスやキャリアを生成するところからプログラムしました。
これについては、以前のこの投稿に書いてあります。
以下は、HTML+CSS+JavaScriptのコードです。このソースはESP8266にはアップロードせずに、外部サーバにアップロードします。そのURLにアクセスして、ブラウザ上で画面遷移せずに、ローカルネットワーク上に設置されたESP8266へリクエストを出して制御するということになります。
そして、以下がESP8266にアップロードするプログラム。3台ESP8266があるので、微妙に違うのですが、代表として一番複雑なエアコン制御や温度/湿度センサーが含まれているESP8266のコードです。照明器具のON/OFFに関してはリレーを使っています。
ESP8266のプログラムのほうでは、server.on()に対応したURLによって、それぞれの端子のON/OFFをしています。他のESP8266へもリクエストをおくることが可能にしてあるので、ESP8266同士でもON/OFFできるようになっています。現状では、主にローカルネットワーク上で動作する仕様にしてあります。ポートマッピングを使えば、おそらくWANからのアクセスも可能になると思います。
まだJavaScriptやESP8266のコードの改善の余地はありますが、とりあえず動くので大丈夫かと思います。今後さらにデバイス追加していくことで、コード自体も(特に非同期通信の部分)改良していこうと思います。
もともとは、ESP8266+赤外線LEDでエアコン制御するところから始まりましたが、その後さらに音声認識でもON/OFF可能にしようということで、ここ最近までやっていました。
以下がこれまでの経緯です。
・IoTその2:ESP8266(Wifi赤外線リモコン:仮)
・IoTその4:再度ESP8266+Blynk
・IoTその5:音声認識でESP8266をWifi制御
・音声認識:Voice Recognition Module VR3.1
・IoTその6:音声認識+ESP8266(改良中)
・ESP8266ファイルシステム:SPIFFSについて
・ESP8266:WebサーバからHttpリクエスト
・ESP8266:XMLHttpRequestで改良
・ESP8266:音声認識Wifiスイッチ:フィードバックで現状表示
これまで、かなり試行錯誤しましたが、おかげでJavaScriptの勉強もでき、今回はそれほど利用しなかったのですが、Node.js、EJS、Express、Python Flask、そしてNode-RED、Blynk HTTP RESTful APIなどの使い方も多少理解できるようになったという感じです。
追記:2017年8月以降
その後、Chromeのセキュリティ仕様が改変されたためか、http://〜(ノンセキュア)のサーバにWeb Speech APIのJavascriptを含んだHTMLファイルをアップロードすると、マイクへのアクセスができなくなってしまいエラーがでてしまいました(Chromeの設定などを変えたりしてみましたがダメでした)。http://だと保護された通信ではないために自動的にマイク使用を遮断してしまうようです。そこで保護された通信とするためにhttps://のサーバへアップロードすれば大丈夫かと思いHerokuをつかってみました(以前参考にさせて頂いた「クラゲのIoTテクノロジー」さんのHerokuへのアップロードを再度参考にしました)。
さらに追記(2018年):
上記の方法でセキュアなhttpsへアップロードしても、以下のように最初はブロックされてしまいます。
おそらくこれは、Javascriptに書き込まれているESP8266のローカルIPアドレスが「http://192.168.3.12」となっているため、リンク先がノンセキュアな「http」だからだと思います。このまま「安全ではないスクリプトを読み込む」をクリックすればマイクやスピーカーが使用できるので一応機能はします(自己責任的な使い方)。
ただ、ローカルIPアドレスも「https」を使えばこういったブロックもでないはずです。そのためにはさらにもう一つ工夫が必要です。
Arduino IDEのライブラリをアップデートしておけば、以下のようなESP8266においてもhttpsサーバーを立てられるようです。
以下はこれまでの内容。
手順としては:
・Herokuへ登録
・Heroku CLIをダウンロード&インストール
・Heroku Static Providerのインストール&デプロイ
要は元々動的サーバとして使うHerokuを静的サーバとして使い、そこにWeb Speech APIのJavascriptを含んだHTMLファイルをアップロード/デプロイするという感じです。そうするとhttps://のセキュアなサーバにアクセスするためにマイク使用が遮断されなくなるようです。とりあえず動くようにはなりましたが、これ以外の方法でも可能なのか現在検討中です。
以下からは、これまでの内容。
プログラムの流れ:
・Web Speech APIのText to Speechで、音声をテキストに変換
・テキストに含まれるキーワードを正規表現を使って一致判定
・キーワードに応じたURLへリクエスト(デバイスのON/OFF制御)を出す
・リクエストを受け取ったESP8266が各デバイスのスイッチをON/OFFする
・温度/湿度の値や各スイッチのON/OFF状況はチェック用URLへリクエストを出しレスポンスとして変数を受け取る
・レスポンス(変数)の内容をブラウザ画面上に反映させ、ON/OFF状況などを表示する
今回の仕組み:
前回で、だいたいのシステムができあがったのですが、もう少し内容を整理しなおしてみました。前回までは音声出力は省いていましたが、せっかく音声を使ったスイッチなので、音声出力も復活させました。「エアコンをオンにしてください」と話しかければ、「エアコンをオンにしました」と答えてくれます。また、デバイスが非接続の状況だと「現在、エアコンは制御できません」と言い返してきます。
現在は3個のESP8266に以下のようにスイッチやセンサをつけています。今後も増える予定です。
・リビング(IP:192.168.3.12):蛍光灯(ON/OFF)
・寝室(IP:192.168.3.13):照明(ON/OFF)、エアコン(ON/OFF)、温度/湿度(センサ値)
・トイレ(IP:192.168.3.14):照明(ON/OFF)
寝室だけ、照明のON/OFF、赤外線リモコンによるエアコンのON/OFF、そして温度/湿度センサからの読み取りという感じでパラメータが多く、しかも温度/湿度の値を画面内のエアコンONのボタン上に表示させるので、ひとつだけ例外的なプログラムになってしまいました。
追記:
その後、音楽を再生させる機能もつけようと試してみました。
soundcloud.comでよく音楽を聞くので、「音楽かけて」などと言えば、window.open()で、
https://soundcloud.com/stream#play
にアクセスして自動で再生されるようにしてみましたが、音楽が流れているとうるさくて音声認識できなくなってしまうこともあり、採用するかどうか検討中です。
端末(パソコン、タブレット、スマホ)上のChromeブラウザではこんな画面になります。
1段目:音声認識の状態 [認識中/エラー/停止中]
2段目:操作結果の表示
3段目:認識された音声/ボタン操作内容の表示
4段目以降:各デバイスのON/OFF(黄/灰)スイッチ
6段目:エアコンONボタンには温度と湿度を表示
*接続確認が取れなかったESP8266のデバイスには[未接続](薄灰)を表示
ブラウザでこの操作画面のURLにアクセスし、音声認識もしくは画面上のボタンクリックで制御します。
ブラウザでアクセスした時点で、それぞれのデバイスのON/OFF状態、温度/湿度の数値、接続/非接続の状態をチェックし、画面上の文字やボタンの色分け表示に反映させています。
今回のような仕組みを可能とするために:
・音声認識Web Speech APIの使用(Speech to Text/Text to Speech)
・Web Speech APIを長時間待機させるための工夫(通常は7秒程度で自動的に停止)
・正規表現を使って認識の幅をもたせる/正規表現における変数の使い方
・画面遷移なしでのXMLHttpRequestによる非同期通信
というようなことが必要でした。それぞれについては、以下に説明があります。
最近はAtomエディタを使ってJavaScriptを書いています。
これまではSublime Textで書いていましたが、ESP8266やESP32でPlatformIOも使い始めたのでAtomを使うことにしました。Visual Studio Codeも便利そうですが、Atomエディタは無料だしGitHub製なのでいいかと。
Atomでは、Scriptというパッケージをインストールするとcommand+IでJavaScriptをすぐに実行できるので便利です。しかもHTMLを表示させるHTML Previewのパッケージもインストールしておけば、HTML上でどのように見えるかリアルタイムでチェックできます(ローカルなので音声認識は不可/ESP8266とは通信可)。色など変えるときはソースに書き込むと同時に反映されるので、わざわざブラウザで確かめるまでもないと言う感じです。
通信の大まかな流れ:
通信上の仕組みは前回からはあまり変わっていませんが、最終的にはこんな感じにしました。
3台あるESP8266には、簡易的なHTML(音声認識なし)をアップロードしておき、それぞれは異なるIPアドレスを持ったWebサーバとして機能しています。
それとは別に外部のWebサーバ(
前回同様に、各スイッチのON/OFF状況やセンサの値は、ブラウザを立ち上げると同時に3台のESP8266のIPアドレス/checkというURLへXMLHttpRequestしてESP8266内にある変数を送ってもらい、その値を表示画面に反映させています。どれがON/OFFになっているか、あるいは温度/湿度の値、または非接続中のESP8266などが分かります。
ESP8266上には、メインのソースのなかにこのような単純なHTMLを組み込んであるので、外部のWebサーバと通信できなくても、ESP8266のIPアドレスにつなげば、最低限のスイッチのON/OFFだけはできるようにしてあります。異なるIPアドレスのESP8266へはリクエストを出して制御しているので、3個あるESP8266のどれからでもすべてをボタン制御することができます。このへんは前回から、あまり変わっていません。
パラメータ用の配列:
今回、温度/湿度センサも加えたので、再度パラメータを格納する配列を組み直してみました。
以下のように、通常の配列と連想配列が入れ子になっています。
ESP8266が3個あるので、それぞれ
IPアドレス・通信接続・デバイス[デバイス名・画面表示名・値]
という感じで並んでいます。
var esp = [{ip:"http://192.168.3.12/", connection: 0, dev:[{ name: "fluo", name_jp: "蛍光灯", param: null }]},{ ip:"http://192.168.3.13/", connection: 0, dev:[{ name: "bed", name_jp: "寝室", param: null },{ name: "air", name_jp: "エアコン", param: null },{ name: "temp", name_jp: "温度", param: null },{ name: "humid", name_jp: "湿度", param: null }]},{ ip:"http://192.168.3.14/", connection: 0, dev:[{ name: "wc", name_jp: "トイレ", param: null }]}];
すべて連想配列にしようかとも思ったのですが(あるいはJSONフォーマット)、数値を使った繰り返し処理のほうがコードが書きやすかったので、[ ]を使った配列とオブジェクト型のドット表記が混在しています。エアコンの電源なら、esp[1].dev[1].paramで書き換え/読み取りできると言う感じです。今後もスイッチやESP8266を増やしていくかもしれないので、自分なりに扱いやすい並べ方にしたつもりです。
ESP8266からのパラメータの読み取りは、IPアドレス順にESP8266にリクエストを出し、レスポンスとして変数を受け取るのですが、特に寝室のESP8266からは4つの値が「1,0,28,23」などと返されるので、split(",")を使ってデリミタ分割して、JavaScript側の変数に入れ直しています。以前、クエリをつかった実験のときはエンコード/デコードを使う必要がありましたが、今回はそれほど面倒でもありません。
前回の機能に音声出力も加え、さらにすっきりしたコードにしようと思って書き直してみましたが、非同期通信であることと、エアコンの部分だけ温度/湿度表示があったりと、やや不規則にな仕組みになってしまい、結局ダラダラといまいちすっきりしないコードになってしまいました。一応現段階では期待した動きにはなっているので、とりあえずOKということにしておきました。
追加した機能は以下ですが、
・音声出力
・温度/湿度センサ
・アップデート(パラメータ)
例えば「温度は?(あるいは:何度?)」と話しかければ、音声で「現在、温度は24度、湿度22%です」と答えてくれます。同様に、画面上にも文字として表示されます。やはり音声出力があったほうが、いちいちブラウザの画面を覗き込まなくてもすむので便利です。
また、「アップデート(あるいは、リセット)」と話しかければ、再度各ESP8266と通信して現状のパラメータのアップデートができるようにしておきました。温度/湿度の値の再取得やON/OFF状況の再確認用です。
非同期通信:
今回は、ESP8266へはXMLHttpRequestを使って非同期通信しています。この非同期通信に慣れなくて、
(1)XMLHttpRequestで3つのESP8266へ連続でリクエストを送る
(2)3つのESP8266から各端子のON/OFF状況の変数をレスポンスで受け取る
(3)レスポンスで受け取った値を画面表示へ反映させる
という手順において、(3)のレスポンス結果をHTML表示へ反映させるときに(どのボタンが現在ON/OFFあるいは非接続状態となっているかを表示させる)、非同期なのでタイミングがずれてしまいエラーがでたりしていましたが、なんとかつじつまを合わせて動くようにはなりました。
非同期用にPromiseオブジェクトもあるようですが、今回の場合はそこまで複雑ではないので、普通にそのまま処理してしまいました。その分、多少ややこしい入れ子状のコードになってしまったのは仕方ありません。
以下は、XMLHttpRequestでパラメータを取得する部分です。この部分がかなりハマったところでした。
function checkEach(i){ var xhr = new XMLHttpRequest(); xhr.timeout = 1000; xhr.onload = function() { esp[i].connection = 1; var res = xhr.response.trim().split(","); for(var j = 0; j < res.length; j++){ esp[i].dev[j].param = parseInt(res[j]); }; for(var j = 0; j < res.length; j++){ updateEach(i,j); }; }; xhr.onerror = function() { esp[i].connection = 0; for(var j = 0; j < esp[i].dev.length; j++){ esp[i].dev[j].param = null; updateEach(i,j); }; }; xhr.ontimeout = function () { esp[i].connection = 0; for(var j = 0; j < esp[i].dev.length; j++){ esp[i].dev[j].param = null; updateEach(i,j); }; }; xhr.open("GET", esp[i].ip + "check", true); xhr.send(); };
最初に受信完了までのタイムアウトを1000msに設定しておき、onloadで通信成功時はレスポンスからパラメータを取得します。タイムアウトしてしまったり、エラーの場合はonerrorやontimeoutを使ってパラメータにnullを代入することにしてあります。ここで得られたパラメータによって、つぎのupdateEach()という関数で、画面表示に反映させます。
前回まではonreadystatechangeを使っていたのですが、今回の場合はonload、onerror、ontimeoutをつかって処理したほうがやりやすかったという感じです。
音声認識(Web Speech API):
音声認識については、Web Speech APIのSpeech to Textを使用しています。Web Speech APIの問題点として、7秒くらい無言でいると自動的にタイムアウトしてしまい、それ以後音声入力を受け付けなくなってしまいます。途切れなく、あるいは長時間待機させるように使用するには、少しばかり工夫が必要です。
以下は、話しかけた声をオウム返ししてくるサンプルです(特にHTML上での画面表示はありません)。
*尚、以下のJavaScriptを含んだHTMLファイルは、Webサーバにアップロードするか、PythonやNode.jsなどでサーバを立ち上げてネットワークに接続された状態で実行しないと機能しません。
window.SpeechRecognition = window.SpeechRecognition || webkitSpeechRecognition; var rec = new webkitSpeechRecognition(); rec.lang = 'ja-JP'; rec.interimResults = false; rec.continuous = true; var syn=new SpeechSynthesisUtterance(); syn.lang = "ja-JP"; syn.rate=1.2; syn.volume=0.8; rec.onend = function(){ setTimeout(function(){ rec.start(); },0) }; rec.onresult = function(event) { var your_voice = event.results[0][0].transcript; syn.text = your_voice; speechSynthesis.speak(syn); rec.stop(); }; rec.start();
基本的に、Speech to TextとText to Speechは、このようなJavaScriptによって可能になりますが(最終的にはHTMLへ埋め込む必要がある)、音声認識機能を途切れなくするためには、コード中ほどにある
rec.onend = function(){ setTimeout(function(){ rec.start(); },0) };
のように、rec.onend()で音声認識が切れたら、rec.start()で再度開始させるようにしています。いろいろ試してみましたが、このようにsetTimeout()をつかって、発動タイミングをずらすとエラーなどでないようです。ちなみに、setTimeout()は0秒でも大丈夫です。setTimeout()なしで即rec.start()させると、もともとrec.onend()が非同期通信のためか、rec.onend()の処理が完了するまえにrec.start()が発動してしまうようでエラーがでてしまいます。このときMacBook上のブラウザ(Chrome)では特に問題ないのですが、Androidタブレットやスマホ上のChromeだと、再スタートを繰り返しているうちにブラウザが落ちてしまい(あるいはフリーズ)、長時間待機そのものが不可能になっていました。今回の場合は、setTimeout()を使うことでエラーがでなくなったために、その問題も解決できたようです。
使い方として、部屋の電源をON/OFFしたいときに、わざわざブラウザを立ち上げて、それに向かって話しかけるというのではなく(この場合、話しかけるよりも画面上でボタン操作したほうが確実で早い)、サーバのURLにアクセスした状態のタブレットをテーブルの上などに置いておき(長時間待機させておき)、気が向いたときに話しかけてON/OFF制御するという感じです(上画像)。1時間ほど長時間待機させてみましたが、途中エラーでブラウザが落ちてしまうこともなく大丈夫でした(以前は数分で落ちていた)。
音声認識だからと言って、毎回話しかけてON/OFFする必要もないかもしれません。どちらかと言えば、パソコン、スマホ、タブレットなどでボタン操作するほうが主な使い方かもしれません。音声認識については、いちおうそれも可能という程度で。
ESP8266からON/OFF状況を取得することができるようになったので、ON/OFFボタンをひとつにまとめてPUSH/ON-PUSH/OFFにしようかと思いましたが、万が一にそなえてあえてONとOFFのボタンを別々にしておきました。ONを押したのに電気がつかないというときに、再度ONボタンを押せるようにするためです。交互にON/OFFが切り替わるボタンだと、ONのつぎはOFFとなり、連続してONが押せなくなってしまいます。
正規表現:
今回の音声認識では、エアコンをオンにする場合であるならば、
「エアコンをオンにしてください」
「エアコンをオン」
「エアコンをつけて」
などと言っても認識できるように以下のような正規表現で認識の幅を持たせてあります。Speech to Textでは、たまに「オン」が「on」や「音(おん)」に自動変換されてしまうときもあるので、それも受け入れるようにしてあります。
また、「〜をオンしない」あるいは、「〜をつけない」と言えば、「〜ない」が含まれているために反応しないようにしてあります。同様に、「オン」させたい場合は「オフ」という言葉が含まれていれば反応しないようにしてあります。
var reg_exp_on = new RegExp('^(?!.*(オフ|ない)).*(?=' + dev_jp + ').*(?=(オン|on|音|つけ|付け)).*$');
さらに、蛍光灯やエアコンなどのデバイスをdev_jpという変数として正規表現内に挿入しています。「〜をオンにする」という感じです。JavaScriptにおいては、「/abc/」のようなスラッシュをつかった正規表現内では変数を使うことができないようですが、上記のようなnew RegExp()でオブジェクト生成したときは、カッコ内には正規表現にしたい文字列を入れることになるので、この文字列内で変数が挿入可能となります。変数が使えることで、ON用の正規表現、OFF用の正規表現をつくっておき、あとは変数内のデバイス名を入れ替えて使いまわすということになります。ちなみに、Rubyでは、「/#{abc}/」とすることで、abcを変数として扱えるようです。
また、Scriptularというオンラインのサイトで正規表現のチェックができます。これはJavaScript用です。
今回のような単純なキーワードによる文字列の一致判定をするには、正規表現をつかわないでincludes()でも十分可能かもしれません。プログラム的には、一致させたいキーワードが含まれているかどうか、NGワードが含まれているかどうかを判定しているだけです。
赤外線リモコン制御(エアコン用):
今回の音声認識Wifiスイッチでは、照明器具のON/OFF以外にエアコンの赤外線リモコンによるON/OFF制御も含まれています。ESP8266につけた赤外線LEDで制御します。赤外線制御については、IRremoteという便利なArduino用ライブラリもありますが、今回は使わずパルスやキャリアを生成するところからプログラムしました。
これについては、以前のこの投稿に書いてあります。
以下は、HTML+CSS+JavaScriptのコードです。このソースはESP8266にはアップロードせずに、外部サーバにアップロードします。そのURLにアクセスして、ブラウザ上で画面遷移せずに、ローカルネットワーク上に設置されたESP8266へリクエストを出して制御するということになります。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Speech API</title> </head> <body id="bg"> <div class="title" id="status">[音声認識開始]</div> <div class="api"><p id="api">COM: 音声入力して下さい</p></div> <div class="you"><p id="you">YOU: (認識された音声)</p></div> <div class="btn" id="fluo_on" onclick="buttonClick(0,0,1)">蛍光灯: ON</div> <div class="btn" id="fluo_off" onclick="buttonClick(0,0,0)">蛍光灯: OFF</div> <div class="btn" id="bed_on" onclick="buttonClick(1,0,1)">寝室: ON</div> <div class="btn" id="bed_off" onclick="buttonClick(1,0,0)">寝室: OFF</div> <div class="btn" id="air_on" onclick="buttonClick(1,1,1)">エアコン: ON [T:00°C H:00%]</div> <div class="btn" id="air_off" onclick="buttonClick(1,1,0)">エアコン: OFF</div> <div class="btn" id="wc_on" onclick="buttonClick(2,0,1)">トイレ: ON</div> <div class="btn" id="wc_off" onclick="buttonClick(2,0,0)">トイレ: OFF</div> <style type="text/css"> body{ text-align:center; font-family: 'Helvetica',sans-serif; color:#fff; margin:0px; padding:0px; background-color:#fff; } .title{ width:100%; background-color:#fa8; margin:0px; padding:2% 0% 2% 0; } .api{ text-align: left; width:98%; background-color:#e75; padding:1%; margin:1% 0 0 0; } .you{ text-align: left; width:98%; background-color:#d66; padding:1%; margin:1% 0 0 0; } .btn{ float:left; background-color:#aaa; padding:3% 0px; margin:1% 1% 0 1%; width:48%; } .btn:hover{ opacity: 0.8; cursor: pointer; } </style> <script type = "text/javascript"> window.SpeechRecognition = window.SpeechRecognition || webkitSpeechRecognition; var rec = new webkitSpeechRecognition(); rec.lang = 'ja-JP'; rec.interimResults = false; rec.continuous = true; var syn=new SpeechSynthesisUtterance(); syn.lang = "ja-JP"; syn.rate=1.2; syn.volume=0.8; var esp = [{ip:"http://192.168.3.12/", connection: 0, dev:[{ name: "fluo", name_jp: "蛍光灯", param: null }]},{ ip:"http://192.168.3.13/", connection: 0, dev:[{ name: "bed", name_jp: "寝室", param: null },{ name: "air", name_jp: "エアコン", param: null },{ name: "temp", name_jp: "温度", param: null },{ name: "humid", name_jp: "湿度", param: null }]},{ ip:"http://192.168.3.14/", connection: 0, dev:[{ name: "wc", name_jp: "トイレ", param: null }]}]; var api_tx = "COM: 音声入力して下さい(default)"; var you_tx = "YOU: 認識された音声(default)"; function checkEach(i){ var xhr = new XMLHttpRequest(); xhr.timeout = 1000; xhr.onload = function() { esp[i].connection = 1; var res = xhr.response.trim().split(","); for(var j = 0; j < res.length; j++){ esp[i].dev[j].param = parseInt(res[j]); }; for(var j = 0; j < res.length; j++){ updateEach(i,j); }; }; xhr.onerror = function() { esp[i].connection = 0; for(var j = 0; j < esp[i].dev.length; j++){ esp[i].dev[j].param = null; updateEach(i,j); }; }; xhr.ontimeout = function () { esp[i].connection = 0; for(var j = 0; j < esp[i].dev.length; j++){ esp[i].dev[j].param = null; updateEach(i,j); }; }; xhr.open("GET", esp[i].ip + "check", true); xhr.send(); }; function checkAll(){ for(var n = 0; n < esp.length; n++){ checkEach(n); }; }; function updateEach(i,j){ var con = esp[i].connection; var _name = esp[i].dev[j].name; var _name_jp = esp[i].dev[j].name_jp; var _param = esp[i].dev[j].param; if(_name != "temp" || _name != "humid"){ if(esp[i].connection == 0){ document.getElementById(_name + "_on").innerHTML = _name_jp + ": [非接続]"; document.getElementById(_name + "_on").style.backgroundColor = '#ccc'; document.getElementById(_name + "_off").innerHTML = _name_jp + ": [非接続]"; document.getElementById(_name + "_off").style.backgroundColor = '#ccc'; if(_name == "air"){ document.getElementById("air_on").innerHTML = "エアコン: [非接続][温度/湿度:不明]"; }; }else{ if(_param == 0){ document.getElementById(_name + "_on").style.backgroundColor = '#999'; document.getElementById(_name + "_off").style.backgroundColor = '#f94'; }else if(_param == 1){ document.getElementById(_name + "_on").style.backgroundColor = '#f94'; document.getElementById(_name + "_off").style.backgroundColor = '#aaa'; }; if(_name == "air"){ var t_h = "[T:" + esp[1].dev[2].param + "°C H:" + esp[1].dev[3].param + "%]"; document.getElementById("air_on").innerHTML = "エアコン: ON " + t_h; }; }; }; document.getElementById("api").innerHTML=api_tx; document.getElementById("you").innerHTML=you_tx; document.getElementById("bg").style.backgroundColor = '#fff'; }; function buttonClick(i,j,k){ if(esp[i].connection == 1){ var xhr = new XMLHttpRequest(); xhr.onload = function(){ if(k == 1){ api_tx = "COM: " + esp[i].dev[j].name_jp + "をONにしました"; you_tx = "YOU: ボタンで" + esp[i].dev[j].name_jp + "をONにしました"; }else{ api_tx = "COM: " + esp[i].dev[j].name_jp + "をOFFにしました"; you_tx = "YOU: ボタンで" + esp[i].dev[j].name_jp + "をOFFにしました"; }; esp[i].dev[j].param = k; updateEach(i,j); }; var footer = "_off"; if(k == 1){ footer = "_on"; }; xhr.open("GET", esp[i].ip + esp[i].dev[j].name + footer, true); xhr.send(); }else{ api_tx = "COM: 非接続中のため操作できません"; you_tx = "YOU: "+ esp[i].dev[j].name_jp + "のボタンは利用不可です" document.getElementById("bg").style.backgroundColor = '#888'; }; document.getElementById("api").innerHTML=api_tx; document.getElementById("you").innerHTML=you_tx; }; rec.onstart = function() { document.getElementById('status').innerHTML = "[ 認識中 ]"; }; rec.onerror = function() { document.getElementById('status').innerHTML = "[ エラー ]"; setTimeout(function(){ rec.stop(); console.log("rec.error & stop"); },0) }; rec.onend = function(){ document.getElementById('status').innerHTML = "[ 停止中 ]"; setTimeout(function(){ rec.start(); console.log("rec.start again"); },0) }; rec.onresult = function(event) { var you_said = event.results[0][0].transcript; you_tx = "YOU: " + you_said; api_tx = "COM: 音声入力して下さい"; for(var i = 0; i < esp.length; i++){ var out_loop1 = false; var out_loop2 = false; var out_loop3 = false; for(var j = 0; j < esp[i].dev.length; j++){ var dev_jp = esp[i].dev[j].name_jp; var dev_param = esp[i].dev[j].param; var reg_exp_on = new RegExp('^(?!.*(オフ|ない)).*(?=' + dev_jp + ').*(?=(オン|on|音|つけ|付け)).*$'); var reg_exp_off = new RegExp('^(?!.*(オン|ない)).*(?=' + dev_jp + ').*(?=(オフ|off|切|消)).*$'); var reg_exp_temp = new RegExp('(?=(温度|湿度|何度|何パーセント)).*$'); var reg_exp_reset = new RegExp('(?=(リセット|reset|アップデート|update)).*$'); if(you_said.match(reg_exp_on)){ if(esp[i].connection == 1){ var xhr = new XMLHttpRequest(); xhr.onload = function(){ api_tx = "COM: " + dev_jp + "をONにしました"; esp[i].dev[j].param = 1; syn.text = api_tx.slice(5); speechSynthesis.speak(syn); updateEach(i,j); }; xhr.open("GET", esp[i].ip + esp[i].dev[j].name + "_on", true); xhr.send(); out_loop1 = true; }else{ api_tx = "COM: 現在、" + dev_jp + "の制御はできません"; you_tx = "YOU: " + you_said + " [非接続のため無効]"; out_loop2 = true; }; break; }else if(you_said.match(reg_exp_off)){ if(esp[i].connection == 1){ var xhr = new XMLHttpRequest(); xhr.onload = function(){ api_tx = "COM: " + dev_jp + "をOFFにしました"; esp[i].dev[j].param = 0; syn.text = api_tx.slice(5); speechSynthesis.speak(syn); updateEach(i,j); }; xhr.open("GET", esp[i].ip + esp[i].dev[j].name + "_off", true); xhr.send(); out_loop1 = true; }else{ api_tx = "COM: 現在、" + dev_jp + "の制御はできません"; you_tx = "YOU: " + you_said + " [非接続のため無効]"; out_loop2 = true; }; break; }else if(you_said.match(reg_exp_temp) && i == 1){ if(esp[i].connection == 1){ api_tx = "COM: 温度は" + esp[1].dev[2].param + "度、湿度は" + esp[1].dev[3].param + "%です"; }else{ api_tx = "COM: 現在、温度と湿度は測定できません"; you_tx = "YOU: " + you_said + " [非接続のため無効]"; }; out_loop2 = true; break; }else if(you_said.match(reg_exp_reset)){ api_tx = "COM: 表示内容をリセットします"; you_tx = "YOU: " + you_said + " [リセット]"; checkAll(); out_loop2 = true; break; }; }; if(out_loop1){ out_loop3 = false; break; }; if(out_loop2){ out_loop3 = false; syn.text = api_tx.slice(5); speechSynthesis.speak(syn); updateEach(i,j); break; }; out_loop3 = true; }; if(out_loop3){ api_tx = "COM: " + you_said + "? もう一度、音声入力して下さい"; you_tx = "YOU: " + you_said; document.getElementById("api").innerHTML = api_tx; document.getElementById("you").innerHTML = you_tx + " [対応項目なし]"; syn.text = api_tx.slice(5); speechSynthesis.speak(syn); }; rec.stop(); }; window.onload = function() { rec.start(); checkAll(); }; </script> </body> </html>
そして、以下がESP8266にアップロードするプログラム。3台ESP8266があるので、微妙に違うのですが、代表として一番複雑なエアコン制御や温度/湿度センサーが含まれているESP8266のコードです。照明器具のON/OFFに関してはリレーを使っています。
#include <ESP8266WiFi.h> #include <ESP8266WebServer.h> #include <WiFiClient.h> #include "DHT.h" #define bed_pin 5 #define air_pin 0 #define duty_high 8 #define duty_low 16 #define DHTPIN 2 #define DHTTYPE DHT11 DHT dht(DHTPIN, DHTTYPE); const char* ssid = "*****"; const char* password = "*****"; const char* host_0 = "192.168.3.12"; //const char* host_1 = "192.168.3.13";//This IP Address const char* host_2 = "192.168.3.14"; String web_page = ""; String bed_param = "0"; String air_param = "0"; String temp_param = "0"; String humid_param = "0"; String param = "0,0,0,0"; unsigned int air_on[243] = {3400, 1400, 600, 250, 550, 250, 550, 1050, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550, 1050, 550, 250, 550, 300, 500, 250, 550, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1200, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1200, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 400, 400, 1200, 400, 400, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 1150, 450, 1150, 450, 350, 450, 1200, 400, 400, 400, 400, 400, 1200, 400, 400, 400}; unsigned int air_off[99] = {3400, 1450, 550, 250, 550, 250, 500, 1100, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550, 1050, 550, 300, 500, 250, 550, 300, 500, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 350, 450, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450}; ESP8266WebServer server(80); void setup(void){ web_page = "<html><head><meta charset='utf-8'>"; web_page += "<title>WIFI SWITCH</title></head><body>"; web_page += "<div style='text-align:center; font-size:30px;'>"; web_page += "<a href='/fluo_on'><div>蛍光灯: ON</div></a>"; web_page += "<a href='/fluo_off'><div>蛍光灯: OFF</div></a>"; web_page += "<a href='/bed_on'><div>寝室: ON</div></a>"; web_page += "<a href='/bed_off'><div>寝室: OFF</div></a>"; web_page += "<a href='/air_on'><div>エアコン: ON</div></a>"; web_page += "<a href='/air_off'><div>エアコン: OFF</div></a>"; web_page += "<a href='/wc_on'><div>トイレ: ON</div></a>"; web_page += "<a href='/wc_off'><div>トイレ: OFF</div></a>"; web_page += "</div></body></html>"; pinMode(bed_pin, OUTPUT); pinMode(air_pin, OUTPUT); dht.begin(); Serial.begin(115200); delay(500); WiFi.begin(ssid, password); Serial.println(""); while (WiFi.status() != WL_CONNECTED) { delay(5000); Serial.print("."); } WiFi.config(IPAddress(192,168,3,13),IPAddress(),IPAddress()); Serial.println(""); Serial.print("Connected to "); Serial.println(ssid); Serial.print("IP address: "); Serial.println(WiFi.localIP()); server.on("/", [](){ server.send(200, "text/html", web_page); }); server.on("/check", [](){ read_DHT(); param = bed_param + "," + air_param + "," + temp_param + "," + humid_param; server.send(200, "text/plain", param); }); server.on("/fluo_on", [](){ server.send(200, "text/html", web_page); http_request(host_0,"/fluo_on"); delay(1000); }); server.on("/fluo_off", [](){ server.send(200, "text/html", web_page); http_request(host_0,"/fluo_off"); delay(1000); }); server.on("/bed_on", [](){ server.send(200, "text/html", web_page); digitalWrite(bed_pin, HIGH); bed_param="1"; delay(1000); }); server.on("/bed_off", [](){ server.send(200, "text/html", web_page); digitalWrite(bed_pin, LOW); bed_param="0"; delay(1000); }); server.on("/air_on", [](){ server.send(200, "text/html", web_page); air_send(air_on,243); air_param="1"; delay(1000); }); server.on("/air_off", [](){ server.send(200, "text/html", web_page); air_send(air_off,99); air_param="0"; delay(1000); }); server.on("/wc_on", [](){ server.send(200, "text/html", web_page); http_request(host_2,"/wc_on"); delay(1000); }); server.on("/wc_off", [](){ server.send(200, "text/html", web_page); http_request(host_2,"/wc_off"); delay(1000); }); server.begin(); Serial.println("HTTP server started"); read_DHT(); } void http_request(const char* host, String url){ WiFiClient client; const int http_port = 80; if (!client.connect(host, http_port)) { Serial.println("connection failed"); return; } client.print(String("GET ") + url + " HTTP/1.1\r\n" + "Host: " + host + "\r\n" + "Connection: close\r\n\r\n"); unsigned long timeout = millis(); while (client.available() == 0) { if (millis() - timeout > 5000) { Serial.println("Client Timeout!"); client.stop(); return; } } } void read_DHT(){ temp_param = String(int(dht.readTemperature())); humid_param = String(int(dht.readHumidity())); delay(250); } void air_send(unsigned int data[], int d){ for (int i = 0; i < d; i++) { unsigned long duration = data[i]; unsigned long start_time = micros(); while (start_time + duration > micros()){ digitalWrite(air_pin, 1-(i&1)); delayMicroseconds(duty_high); digitalWrite(air_pin, 0); delayMicroseconds(duty_low); } } } void loop(void){ server.handleClient(); }
ESP8266のプログラムのほうでは、server.on()に対応したURLによって、それぞれの端子のON/OFFをしています。他のESP8266へもリクエストをおくることが可能にしてあるので、ESP8266同士でもON/OFFできるようになっています。現状では、主にローカルネットワーク上で動作する仕様にしてあります。ポートマッピングを使えば、おそらくWANからのアクセスも可能になると思います。
まだJavaScriptやESP8266のコードの改善の余地はありますが、とりあえず動くので大丈夫かと思います。今後さらにデバイス追加していくことで、コード自体も(特に非同期通信の部分)改良していこうと思います。