grbl1.1+Arduino CNCシールドV3.5+bCNCを使用中。
BluetoothモジュールおよびbCNCのPendant機能でスマホからもワイヤレス操作可能。
その他、電子工作・プログラミング、機械学習などもやっています。
MacとUbuntuを使用。

CNCマシン全般について:
国内レーザー加工機と中国製レーザー加工機の比較
中国製レーザーダイオードについて
CNCミリングマシンとCNCルーターマシンいろいろ
その他:
利用例や付加機能など:
CNCルーター関係:



*CNCマシンの制作記録は2016/04/10〜の投稿に書いてあります。


2017年6月9日金曜日

AliExpressのトラブル(その2)

以前、AliExpressで注文した商品が結局届かなくて払い戻ししてもらいましたが、また違ったトラブルがありました。あいかわらず、数百円程度の電子パーツなどをよく注文しており、もう100回以上なにかしら注文しています。実はここ最近で3回ほどトラブルがありました。

(1)商品が期日内に届かなかった(再配送で解決済)
(2)商品のスペックが違う(全額返金済み/商品は返品せず)
(3)商品が期日内に届かない(現在連絡中、追記:結局Open Disputeで返金)

という感じです。
以下順に書いていきます。


(1)商品が期日内に届かなかった場合:
まずは以下のようなUSB接続可能な小型マイクを注文しました。
AliExpress.com Product - Portable Studio Speech Super Mini USB 2.0 Microphone MIC Audio Adapter Driver Free for MSN PC Notebook Lectures Teaching 202円(送料込み)このショップで購入したわけではないのですが、当時の購入金額は159円(送料込み)でした。3/17に注文して約2ヶ月の期日が過ぎても届かないため、まずはショップにメールしました。今回は特別急いでいなかったので(というよりも注文してから2ヶ月も経ってしまい、代替のUSBマイクを使用することにしたため)、いきなりOpen Disputeをせずに連絡してみることにしました。ちなみに、トラッキング状況は以下のような感じで、途中で止まっているように見えます。配送中に紛失してしまったのかどうかはわかりません。

これもまた一番安い通常郵便で配送されているために、きちんとしたトラッキングサービスはカバーされていないはずなので、そもそもあまり当てになりません。もしきちんとしたトラッキングサービスにするには、以下のような輸送オプションがあり、141円上乗せすればAir Mailなのでトラッキングならびに受取り確認(要ハンコ)になります。しかし、今回は安い買い物なのでわざわざそこまではしません。

そのときのやりとりが以下です(やりとりは下から始まって上へ)。

やりとりの内容を書くと、Me:「まだ届いてないけど、返金するためOpen Disputeしていいですか?」Shop:「Open Disputeはしないで下さい。新しいの送るかPaypal経由で返金もできます。」Me:「じゃ、新しいの送って下さい」Shop:「OK、すぐに手配します」Me:「送る時教えてね。今回トラッキング番号とかある?」Shop:「新しいトラッキング番号にしておくので安心してください」という感じでやりとりは進んで、
Shop:「新しいのを送りました。トラッキング番号は〜です。遅れてすみません。なにかあれば連絡下さい。」という感じで、商品を梱包した状態の写真を送ってきてくれたので、大丈夫だろうと信じて、これでやりとり終了。あとはちゃんと届くかどうか。そして、約2週間後無事届きました(いわゆる通常輸送にかかる時間で)。結局、注文して届くまでから2ヶ月半くらいかかったというわけです。
追記:実は、最初に注文したときのUSBマイクが、約4ヶ月後に届きました。つまり、最終的には合計2個届いたということになります。封筒にスタンプされた日時を確認すると、注文後約3ヶ月半くらい中国国内の集配センターのようなところにあったようです。封筒(小包)が10cm角ほどしかなく小さいので、一時的にどこかに紛れ込んで行方不明になっていたのかもしれません。

(2)の商品のスペックが違った場合:リチウム充電池18650です。もともと怪しいのはわかっていたのですが、試しに注文してみたという感じです。
AliExpress.com Product - 2PCS 3.7 V 4200mAh 18650 Battery batteries batteria lithium Li Ion Rechargeable Large Capacity T6 Flashlight free shipping 404円(2本:送料無料)これは4200mAhもあると書かれているリチウム充電池です。この容量に対して一本約200円という値段(安すぎ)。この手の容量をごまかしている18650リチウム充電池は沢山あるようです。以下は、Youtubeで容量を検証している動画です。こんな動画が沢山あります。

今回注文したものはMick Tick 18650で、注文時の価格は2本で399円でした。2週間程度で届いたので配送においては問題ありませんでした。容量が怪しいので、受け取ってもすぐに受取ボタンを押さずに、以下の充電器で計測してみました。
AliExpress.com Product - Original XTAR VC2 2 Slots 1A Current Universal Intelligent USB Battery Charger with LCD Display for 3.6V / 3.7V Li-ion 18650 1566円(送料無料)この充電器では、充電した分のmAhを数値で表示してくれます。なので、いったんバッテリーを放電してから充電してみました。
結果はこの通りです↑。左側が539mAh、右が443mAh。Youtubeの動画などでも、偽物はだいたい500mAhくらいしかないようです。案の定、今回のバッテリーも同様でした。ついでに重さも計測してみると、

左が24.9g、右が24.5gです。きちんとしたものならば、40g以上あるそうです。
これも動画にあったような偽物の特徴と一致しています。

ということで、今回は問答無用でOpen Disputeボタンを押して、これらの画像も証拠写真として添付し、早速クレームをつけました。
まずは、先程の計測結果内容を書き、全然4200mAhではないので、399円中の350円を返金しろと(画像も添えて)。返品すると送料などかかってしまうため、一部返金ということにしました。
すると数日後に以下のような返信。
Disputeする理由を「商品のキャパシティーが違うため」を選んでいたのですが、ショップ側は「買い手の個人的な理由で」に変更して欲しいとのこと。「注文したけど、思っていたものと違うため」というような理由でキャンセルするということにしてくれれば、返金しますと書いてありました。
このことから察すると、ショップとしても理由によってはAliExpressから何らかのペナルティを受けてしまうのを恐れているのでしょう。
返金してくれるということなので、ためしに言われたとおりにDispute理由を変更し、ついでに399円全額返金希望にしました。

「言われたとおり変更したよ」とメールでも送っておき、そうするとショップ側はこの条件を受け入れたようで無事解決。
ショップは「返金手続きしておきました。悪い評価しないでね。」という控えめな姿勢のメール。

結果としては399円支払った分、全額返金。そして18650のリチウム充電池2本(実際の容量は500mAh前後)は返品不要となりました。
ちなみに、単3アルカリ電池ですら約1000mAhあるらしいので、500mAというのはマンガン電池程度という感じです。ちょっとしたものに使うのならいいけれども、あまりにも容量が少なすぎです。
ということから、18650などのリチウム充電池を購入する際は注意が必要です。やはり日本製が高価ではあるけれどもいいのかもしれません。
トラブルはないほうがいいですが、もしこういうトラブルがあってもAliExpressのシステムのせいか、クレームつければ一応きちんと対応してくれます。


(3)の場合:これもまた期限すぎても届かない(もう慣れたかも)
Bluetoothではないワイヤレスマウスもありますが、USBドングルをつけるのは面倒なのでBluetoothのマウスを探していたところ、Amazonだと1000円くらいするのがAliExpressで345円だったので注文してみました。

AliExpress.com Product - Ergonomic Wireless Bluetooth 3.0 mini Optical Computer Mouse 6 Buttons Mice 1600DPI for laptopTablet PC 10M Working distance 656円(送料無料)
注文したのは、このショップのものではないのですが、今はなぜか全般的にBluetoothマウスはAliExpressで650円以上します。当時注文したのは345円でしたが、セールとかだったのでしょうか?このマウスもまた期限(2ヶ月)が過ぎても届かないので、とりあえずメール問い合わせしている最中。いきなりOpen Disputeしてもいいのですが、返金扱いにして新たにBluetoothマウスを購入することになると650円になってしまうので、なんとか345円でそのままもう一度同じマウスを送ってもらったほうがいいのではないかということです。

あいかわらず通常郵便なのでトラッキングサービスはカバーされていません。一応配送状況はこんな感じで出ていますが、4/8以降は途切れたままです。いままでの経験からすると、この状態でも届くものは届くのですが、やはり2ヶ月以上届かないというのは、もう届かないと思ったほうがいいと思います。いままでで、遅くても6週間くらいでした。通常なら2〜3週間。というわけで、これは現在進行中です。
追記:その後、数回のメールのやりとりをして、最終的にはOpen Disputeで返金手続きしました。ということで、また新たにBluetoothマウス(別の製品:378円)を注文し直しました。
AliExpress.com Product - 1600 DPI Bluetooth BT 3.0 Wireless Mouse for Android Phone Computers Notebooks Tablet Office Gaming PC Mouse Mini Mice Newest


まとめ:スペックが怪しい商品、動作確認が必要そうな商品は、受け取ってもすぐに受取ボタンを押さずに、実際に電源につないで動かしてみたほうがいいと思います。そして問題があれば、受取ボタンを押す前にショップへメール連絡したほうがいいでしょう(できれば強気で)。ショップとしても客からクレームつくのは嫌だと思うので、それなりの対応をしてくれると思います。Open Disputeは、まずお互いの条件を出し合い、それでOKならそのまま解決へと向かいますが、どちらかが納得行かない場合は、つぎの段階としてAliExpressが介入してくれるようです。AliExpressメンバーレベルというのがあって、A3レベルだと25ドルまではすぐに返金してくれるようです。A4レベルなら100ドルまで。買い物すればするほどレベルはあがって、様々な特典が得られるようになります。ショップとしてもペナルティにつながるようなことにはなりたくないはずなので(出店禁止とか)、きちんと証拠写真や要望を述べれば大丈夫かと思います。あまりぐだぐだと「このような場合どうしたらいいでしょうか?」というようなお伺いをたてないほうがいいと思います。単純に、「商品が〜だったので(証拠写真付きで)、全額返金希望(あるいは一部返金希望)」と書いた方がいいと思います。
いまのところ110回のショッピングで4回のトラブルがあったということになります。返金や再配送というかたちで解決しているので、特に問題ないという感じです。これにこりてAliExpressではショッピングしなくなるかというと、そういうわけでもありません。AliExpressの場合、2ヶ月経たないとわからないというのが面倒なので、すぐに欲しいものであれば多少高くてもAmazonのほうがいいかもしれません。ただしこのような部品類などはAmazonでも中国からの配送となるので、内容的には同じことだと思います。同じ中国からでも、Amazonのほうが若干早く届くような気がしますが、たまにものすごく遅いときもありました。なので、中国からの配送品に関しては、時間についてはあまり期待しないほうがよさそうです。2週間でつけば早いほうで、1ヶ月くらいは待つつもりでいたほうがいいかもしれません。

2017年6月2日金曜日

線形回帰のプログラミング(その2):勾配降下法

前回、グラフ平面上の複数の点(データ群)に対して直線フィッティングをするために最小二乗法を試してみましたが、今回は勾配降下法によるプログラムです。
最小二乗法は、公式に数値を入れるとすぐに答えがでてくるような感じでしたが、勾配降下法は直線の式に動的に近づいていく求め方なので、コンピューターを利用した求め方に、より適しているような気がします。公式によって一発で答えを導き出すというよりも、経験的に答えを探しにいくという感じでしょうか。

黄直線:勾配降下法、赤直線:最小二乗法

とはいっても、やはり公式があるようで、前回同様求めたい直線の式を

y = m * x + b

とすれば、

Error(m, b) = 1/n * Σ((m * xi + b) - yi)^2

が、求めたい式と暫定的な式とのmとbのトータルの誤差を表しているようです。Σは、i=1からi=nまでの合計値(for文上では、i=0からi=n-1まで)。
式中の (m * xi + b) - yi は、m * xi + bで求めたyの値から、ある点のyiを差し引いているので、その差が誤差(Error)になるというのがわかります。二乗しているのは、前回の最小二乗法のように符号を+にするということですが、そもそも最終的な目的が、誤差の最小化なので二乗してあっても問題ないということらしいです。
1/nのnは点の数なので、Σで合計したエラー値をnで割ることで平均値が得られるという感じでしょうか。
最終的には、このError(誤差)が0に近づくようにmとbを探していけばいいので、そのためにはこのErrorのグラフ上の傾き(接線)を調べると、式中のΣ以降の部分である

((m * xi + b) - yi)^2

を微分して接線(x,y地点の傾き)を調べると

2*((m * xi + b) - yi)

となり、求めたいmとbについての偏微分(変化率)は

Δm = -2/n * Σ xi * ((m * xi + b) - yi)
Δb = -2/n * Σ (m * xi + b) - yi

となって、このΔmとΔbをプログラム上で加算(更新)していくことで、徐々に求めたい式に近づいていくという方法のようです。
暫定的な直線の式にxとyの値を代入して誤差を求めて、その誤差が0になるようにmとbを更新していくというイメージはなんとなく分かるのですが、実際計算上でどのようにそうなっていくのか?このあたりを今回もプログラミングを通して実感できないかという試みです。
数式だけだと分かりにくいのですが、プログラミングでは今回の値に前回の値を再代入して、それを繰り返して徐々にある目標の値に近づいていくという方法はよくあるので、今回もそんなイメージで考えています。

勾配降下法においては、一発で答えを導くわけではないので、学習率という変数が登場してくるようです。少しづつ求めたい値に近づくために、オーバーシュートしないように0.1や0.01を掛けて小刻みにステップさせる係数のようなものだと思います。
さきほどのΔmとΔbの偏微分の方程式を書くと(今回もProcessingを使用)、

float m = 0;
float b = 0;
float learningRate = 0.1;
float db = 0;
float dm = 0;
for(int i = 0; i < x.length; i++){
    dm += x[i] * ((m * x[i] + b) - y[i]);
    db += (m * x[i] + b) - y[i];
}

m -= 2.0/x.length * dm * learningRate;
b -= 2.0/x.length * db * learningRate;

こんな感じになります。ΔmとΔbはdmとdb、学習率はlearningRate=0.1、for文で点の個数分繰り返しΔmとΔbの加算処理をしています。最終的なmとbは、学習率0.1を掛けたΔmとΔbと今回のmとbとの差分を差し引いて、次回また計算し直して徐々に求めたいmとbに近づいていくというプロセスです。偏微分の方程式があるため、それに従って計算してしまえばいいので、今回は結構短いコードでmとbを求めていくことができます。
しかし、最後に求めたmとbの式をよく見ると、適当に決めた学習率(今回の場合0.1)を掛けているので、

m -= dm * learningRate;
b -= db * learningRate;

のように式中の2.0/x.lengthを消してしまっても、あまり大差なさそうです。今回は方程式のまま書いてみましたが、計算をシンプルにするには、learningRateに係数を含ませてもいいと思います。
あるいは、もっと式をシンプルにすれば、

float m = 0;
float b = 0;
float learningRate = 0.1;
for(int i = 0; i < x.length; i++){
    float error = y[i] - (m * x[i] + b);
    m += error * x[i] * learningRate;
    b += error * learningRate;
}

とするだけでもいいのかもしれません。
式中のerrorは、実際のy[i]の値からm*x[i]+bで求めたyの値(もともとy=m*x+bという関係なので)を差し引いた誤差です。
結局は、learingRateで挙動を調整することができるので、ここまで変えてしまっても問題なさそうです。


以下が今回のプログラム(Processing/Processing.js)。前回同様に、以下の空のグラフ上の任意位置をクリックすると点が追加されます。複数の点(2点以上)に対する直線が現れます。 赤の直線は前回の最小二乗法、黄色の直線が今回の勾配降下法によるものです。 点の数や位置によっては、微妙に最小二乗法と勾配降下法の結果がずれることがあります。求め方の違いからくるためなのでしょうか?ブラウザリロードで初期化します。

表示が変な場合(線が点滅するなど)は、前回(一つ前の投稿)のページ上のプログラムと干渉しあっているために起こると思われます。 そうならないようにするためには、この投稿だけのページに切り替えるといいと思います。ここをクリックでこの投稿だけのページへ移動



以下が、全体のコード。
前回と少し違うのは、画面サイズ400x400を一旦0〜1.0に正規化してあります。つまり座標(200,200)の位置をクリックすれば、プログラム内部では(0.5,0.5)という値に相当します。0~1.0で計算しておいて(そうしないと計算の際に数値が溢れてしまったので)、その後画面表示するときは、再度400x400のフォーマットに変換し直しています。

float[] x = new float[0];
float[] y = new float[0];
float m_gd = 0; //勾配降下法のmの変数
float b_gd = 1; //勾配降下法のbの変数
float m_ls = 0; //最小二乗法にmの変数
float b_ls = 0; //最小二乗法にbの変数

void setup(){
  size(400,400);
  rectMode(CORNERS);
}

void draw(){
  background(51); 
  stroke(255);
  fill(255);
  for(int i = 0; i < x.length; i++){//点の描画
    ellipse(x[i]*width,y[i]*height,4,4);
  }
  
  if(x.length > 1){//点が2点以上の場合
    leastSquare();
    gradientDescend();
  }
}

void gradientDescend(){//勾配降下法
  float learningRate = 0.1;
  float db = 0;
  float dm = 0;
  for(int i = 0; i < x.length; i++){  
    dm += x[i] * ((m_gd * x[i] + b_gd) - y[i]);
    db += (m_gd * x[i] + b_gd) - y[i];
  }
  
  m_gd -= 2.0/x.length * dm * learningRate;
  b_gd -= 2.0/x.length * db * learningRate;

  stroke(255,255,0);
  drawLine(m_gd, b_gd);  //勾配降下法による直線描画
}

void leastSquare(){//最小二乗法
  float xsum = 0;
  float ysum = 0;
  for(int i = 0; i < x.length; i++){
    xsum += x[i];
    ysum += y[i];
  }  
  float xmean = xsum / x.length;
  float ymean = ysum / y.length;  
  float xy = 0;
  float xx = 0;
  for(int i = 0; i < x.length; i++){
    xy += (x[i] - xmean) * (y[i] - ymean);
    xx += (x[i] - xmean) * (x[i] - xmean);    
  }  
  m_ls = xy / xx;
  b_ls = ymean - m_ls * xmean;
  
  stroke(255,0,0);
  drawLine(m_ls, b_ls); //最小二乗法による直線描画
}

void drawLine(float M, float B){//mとbによる直線描画
  float x1 = 0;
  float y1 = M * x1 + B;
  float x2 = 1;
  float y2 = M * x2 + B;  
  line(x1 * width, y1 * height, x2 * width, y2 * height);
}

void mousePressed(){//クリックするごとに新たな座標値を配列に追加
  x = append(x, 1.0 * mouseX / width); //0〜1.0に正規化して配列に格納
  y = append(y, 1.0 * mouseY / height);
}


学習率:learningRateは0.1に設定してありますが、0.01に落としてもいいかもしれません。ただその場合、動きはゆっくりになります。
点の数が少なすぎたり、互いに点が近すぎると、最小二乗法による直線(赤)と勾配降下法による直線(黄)とに微妙な違いがでてくるようです。学習率(単位ステップ数)を調整すればいいのかもしれませんが、そのへんについては検討中。

今回の最小二乗法と勾配降下法については、数学的に証明可能なレベルまで理解したり、ここで登場してくる方程式を自ら導き出せるほど完全に理解しているというわけではないのですが、プログラミングにおけるライブラリのように、ライブラリをつくるのは大変だけれども、ある程度の仕組みを理解して使いこなすということはできると思います。画像認識のOpenCVあるいはPID制御やFFT(高速フーリエ変換)などのライブラリもそんな感じです。まったく理解していないとライブラリを使うこともできませんが、そこそこ理解していれば、これまで計算できなかったことが可能になるので、その程度は勉強しておいたほうがいいのかもしれません。もしくは、完全な理解を得るには時間がかかってしまうので(そもそもその分野の専門家でもないので)、深い理解はとりあえずはスキップし、まずは使い方を身につけて、使っているうちに徐々にその仕組がわかってくるという順番のほうがいいのかもしれません(知識欲のために理解しようとしているわけではないので)。

続き:
CourseraのMachine LearningコースとDeep Learningコース

2017年5月31日水曜日

線形回帰のプログラミング

線形回帰(Linear Regression)を理解するためにプログラム(Processingを使用)を組んでみました。相変わらずこのような数学的な内容はWikipediaの説明ではわかりにくいので(もうすでに理解している人のために書いている内容であって、分からない人が理解できるような説明にはなっていない)、プログラミングすれば理解が深まるかもしれないと思って試してみました。

センサーを使ってデータを集めた際に、以下のようなグラフになることがあります。これらのドット分布を簡単な数式(今回の場合は単純な直線の式:赤い直線)に置き換えることができないかということです。


直線の一次式は、

y = m*x + b

で表せるので、要はmの傾きとbの値(x=0の時のyの値)を求めることができればいいというわけです。
どうやら公式があるようで、

m = Σ(Xi - Xmean) * (Yi - Ymean) / Σ(Xi - Xmean) *(Xi - Xmean)
b = Ymean - m*Xmean

となるようです(Σはn=0からn-1までの合計値)。
Xiはある点のXの値、XmeanはすべてのXの点の平均値、Yについても同様。
ただし、この式だとわかりにくいので、Σを使わないで表せば、

それぞれのX座標値:X0、X1、X2・・・Xn-1
それぞれのY座標値:Y0、Y1、Y2・・・Yn-1

とすれば(n個の点があるという前提)、
プログラム上のfor文では、for(int i=0; i<n; i++)となるのでn-1まで、

(X0-X平均値)*(Y0-Y平均値)+(X1-X平均値)*(Y1-Y平均値)+(X1-X平均値)*(Y1-Y平均値)・・・
+(Xn-1-X平均値)*(Yn-1-Y平均値)

の合計と、

(X0-X平均値)*(X0-X平均値)+(X1-X平均値)*(X1-X平均値)+(X2-X平均値)*(X2-X平均値)・・・
+(Xn-1-X平均値)*(Xn-1-X平均値)

の合計を求めて、
上の合計を下の合計で割ればmが求まり、
bについては、

b = Y平均値 - m*X平均値

で直線の式がでてくるようです。
ある式をn=0からi=n-1まで計算していくには、プログラミングではfor文を使えばいいので簡単です。
ただし、方法としてはこれもまたよくでてくる最小二乗法というものです。この最小二乗法が解法のテクニックだと思うのですが、求めたい直線と各点のyの値とのズレをすべて合計して(さらにプラスマイナスの符号をなくすために二乗する)、その差を少なくすればいいというのは直感的に分かるので、そのあたりをプログラムを通して理解しようとしてみました。
複数ある点の平均値を求めるので、そこが点分布の重心となり、かならずこの重心を通るような線となるようです。




空白のグラフ(上)がありますが(上に画面が現れない場合や表示が変な場合はこちらをクリック)、任意の位置をクリックすると点が追加されていきます(Processing/Processing.jsで書いています)。
複数点を打っていくと、その分布する点に対する一次式の直線が計算により求められるというプログラムです。当然二点打てば、その二点を通る直線ができるわけですが、三点目を少しずらしたところに打てば、その三点以上の点分布に対する直線式が出てきます。異なる位置に点を追加していくことで、微妙に線の傾きが変化していきます。
直線は点分布の重心を必ず通ることから、二点打ったあとにその中点を打ち、その上下(y軸と平行に)に等間隔でさらに二点打てば、傾きは変化しないということになります。こうやって、点を追加していくことで、傾きが変化したりしなかったりという性質が分かるので理解が深まります。
*リセット機能はプログラムに含まれていないので、ブラウザでリロードすれば初期化されます。

以下が、Processingによるコード。


//クリックで追加されていく点の座標の配列を用意
int[] x = new int[0]; 
int[] y = new int[0];

void setup(){
  size(400,400);
}

void draw(){
  background(51);

  //xとyの平均値を求める
  int xsum = 0; //xの合計値の変数
  int ysum = 0; //yの合計値の変数
  fill(200);
  stroke(200);
  for(int i = 0; i < x.length; i++){//点の数だけ合計値を求める
    xsum += x[i];
    ysum += y[i];
    ellipse(x[i],y[i],4,4);//各点の描画
  }
  float xmean = 1.0 * xsum / x.length; //xの平均値
  float ymean = 1.0 * ysum / y.length; //yの平均値

  //公式を使って計算する
  float xy = 0; //公式の分子の変数
  float xx = 0; //公式の分母の変数
  for(int i = 0; i < x.length; i++){
    xy += (x[i]-xmean)*(y[i]-ymean); //分子の加算
    xx += (x[i]-xmean)*(x[i]-xmean); //分母の加算
  }
  
  float m = xy / xx; //上記公式からmを求める
  float b = ymean - m * xmean; //公式で求めたmと各平均値からbを求める
  float x1 = 0; //グラフ左端x=0のとき
  float y1 = m * x1 + b; //x=0のときのyの値、結果y1= bとなる
  float x2 = width;  //グラフ右端x=画面幅のとき
  float y2 = m * x2 + b; //x=画面幅のときのyの値、mとbは求まっているのでy2も求まる
  stroke(255,0,0);
  line(x1,y1,x2,y2); //直線を描く
}

//マウスクリックするごとに配列に新たな座標を加える
void mousePressed(){ 
  x = append(x, mouseX); 
  y = append(y, mouseY);
}

プログラミングにおけるY座標は下向きに+ですが、mにマイナスをかけて、height-bとすれば、Y座標上向きを+にすることができます(今回は数値表現していないため省略)。

ネットなどに書いてある線形回帰の説明だと数式だけで数学的に記述されているので、わかりにくいのですが、こうやってプログラミングすることで、どのように計算しているのか理解が深まるという感じです。
もう一つ、勾配法による求め方もあるので、次回やってみようと思います。
以前、A*経路探索アルゴリズムも試してみましたが、このあたりのテクニック/アルゴリズムは、いろいろな場面で使えるので、知っていれば便利かと思って試している最中です。

続き:
線形回帰のプログラミング(その2):勾配降下法

2017年5月12日金曜日

ESP8266:音声認識Wifiスイッチ(まとめ)

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へアップロードしても、以下のように最初はブロックされてしまいます。
おそらくこれは、Javascriptに書き込まれているESP8266のローカルIPアドレスが「http://192.168.3.12」となっているため、リンク先がノンセキュアな「http」だからだと思います。このまま「安全ではないスクリプトを読み込む」をクリックすればマイクやスピーカーが使用できるので一応機能はします(自己責任的な使い方)。
ただ、ローカルIPアドレスも「https」を使えばこういったブロックもでないはずです。そのためにはさらにもう一つ工夫が必要です。
Arduino IDEのライブラリをアップデートしておけば、以下のようなESP8266においても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
にアクセスして自動で再生されるようにしてみましたが、音楽が流れているとうるさくて音声認識できなくなってしまうこともあり、採用するかどうか検討中です。

端末(パソコン、タブレット、スマホ)上の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サーバ(無料ホームページのサーバなど 追記:実はHerokuなどのhttps://〜が使えるセキュアなサーバがいい)に、今回の音声認識のJavaScriptを書いたHTMLファイルをアップロードしています。この外部WebサーバとESP8266は通信しあうわけではなく、すべてはブラウザからXMLHttpRequestでESP8266と通信しています。なので、音声認識のプログラム自体はどこにあってもいいという感じです。仮にESP8266に音声認識のプログラムをアップロードしてもいいとは思いますが、いちいち書き換えが面倒なので、外部Webサーバにドラッグ&ドロップでアップロードしたほうが早いということです。もちろんローカルネットワーク内のRaspberry Piなどにアップロードしても構いません。

前回同様に、各スイッチの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のコードの改善の余地はありますが、とりあえず動くので大丈夫かと思います。今後さらにデバイス追加していくことで、コード自体も(特に非同期通信の部分)改良していこうと思います。

2017年4月30日日曜日

ESP32:Webサーバから外部ファイルを読み込み

ESP32も少しずつ試してはいるのですが、SPIFFSファイルアップロードの仕方がわからない。ESP8266の場合、HTMLファイルなどをアップロードするためには以前やったように「esp8266fs.jar」を使えばよかったのですが、ESP32には「esp8266fs.jar」のようなものはまだないのでしょうか?
Arduino IDEには、「ライブラリのインポート」では一応「FS」があり、インポートはできるようになっているけれども、ファイルのアップロード自体どうすればいいのか?
追記:2017年10月
どうやらESP32のファイルアップローダーできたみたいです。ファイルアップローダーの使い方についてはこちらへ

インクルードされたライブラリもESP8266のとは違うので、ESP8266と同じ要領ではできなさそう。
ネットで調べてみても、これといったものがないし、フォーラムにもいくつか載っていたけれども、具体的にどうすればいいのかサンプルなどがないのでわかりにくい。
Virtual filesystem compornent(vfs)
partitions
この辺を見ればいいのかもしれないけれども、かなりわかりにくい。SPIFFS用のパーティションのオフセットは0x210000らしい。
おそらく、SPIFFSやFATなどのファイルシステムAPIをパーティション0x210000へflashするとつかえるようになるのかもしれない。Lua-RTOS-ESP32が唯一使えるというような投稿もあるけど、具体的なサンプルなどがないのでわからない。だれか成功例のサンプルをどこかに挙げてもらいたいですね。その他、JavaScriptをベースにするEspruinoやDuktape、あるいはMicroPythonを載せてしまうという手もあるけど。あとは、サンプルのあるSDカードから読み込む方法となるのかも。

ESP8266に関しては、かなりのサンプルが見つかるので扱いやすいけど、ESP32のほうはまだそんなにないというか、仕組みもESP8266よりも複雑みたいで、そのぶん扱いにくいのかも。しかたないのでしばらく待つことにして、かわりに以下の方法で。

他のWebサーバからESP32のRAMへ読み込ませる:
ソース(C言語/Arduino言語)の中にHTML(CSS、JavaScript)を埋め込んで毎回アップロードするのは面倒なので、どうせ読み込んだHTMLデータはString変数へ入れられるので、わざわざROMにアップロードしておかなくてもいいはず。電源を落とせば、RAMの場合消えてしまうけど、毎回電源を入れるたびに、setup()内で外部から読み込ませれば同じことかもしれない。
ということで、ESP32からHTTPリクエストを出して他のサーバに置いてあるHTMLファイルを読み込ませようという実験をしてみました。

まずHTMLファイルをアップロードできる無料Webサーバに、今回の場合data.htmlというファイルをあげておきます。data.htmlには、ESP32(Webサーバ)にリクエストがあった場合のレスポンス用HTMLが書いてあります。ブラウザ上にスイッチのON/OFFなどの画面が出てきて制御できるということになります。

#include <WiFi.h>
#include <ESPmDNS.h>
#include <WiFiClient.h>
#define ledPin 23

const char* ssid = "*****";
const char* password = "*****";

const char* host = "www.abc.com";//無料サーバのホスト
String url ="/esp32/data.html";//読み込ませるデータのurl
String header = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
String footer = "\r\n\r\n";
String webPage = "";
const uint16_t port = 80;

WiFiServer server(port);

void setup(void){  
    Serial.begin(115200);
    pinMode(ledPin, OUTPUT);
    //webPage += "<!DOCTYPE HTML>\r\n<html>No Contents, yet.</html>\r\n\r\n";

    WiFi.begin(ssid, password);

    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    //固定IPアドレス
    WiFi.config(IPAddress(192,168,3,20),IPAddress(),IPAddress());
    Serial.print("Connected to ");
    Serial.println(ssid);
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());
    
    //DNSサーバ名(esp32.local)
    if (!MDNS.begin("esp32")) {
        Serial.println("Error setting up MDNS responder!");
        while(1) {
            delay(1000);
        }
    }
    Serial.println("mDNS responder started");

    server.begin();
    Serial.println("TCP server started");
    MDNS.addService("http", "tcp", port);
    httpRequest();//ここで外部ファイルをロードしておく
}

void loop(void){
    WiFiClient client = server.available();
    if (!client) {
        return;
    }
    Serial.println("");
    Serial.println("New client");

    while(client.connected() && !client.available()){
        delay(1);
    }

    String req = client.readStringUntil('\r');

    int addr_start = req.indexOf(' ');
    int addr_end = req.indexOf(' ', addr_start + 1);
    if (addr_start == -1 || addr_end == -1) {
        Serial.print("Invalid request: ");
        Serial.println(req);
        return;
    }
    req = req.substring(addr_start + 1, addr_end);
    Serial.print("Request: ");
    Serial.println(req);
    client.flush();

    String s;
    if (req == "/"){//ルートへアクセスした場合はロードしていないHTMLを表示
        IPAddress ip = WiFi.localIP();
        String ipStr = String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]);
        s = header;
        s += "<!DOCTYPE HTML>\r\n<html>Hello from ESP32 at ";
        s += ipStr;
        s += "<div><a href=\"/on\">TEST: ON</a></div>";
        s += "<div><a href=\"/off\">TEST:OFF</a></div>";
        s += "<div><a href=\"/load\">LOAD DATA</a></div></html>";
        s += footer;
    }else if(req == "/load"){//ロードする画面
        httpRequest();//外部サーバへリクエスト
        s = header;
        s += "<!DOCTYPE HTML>\r\n<html>";
        s += "<div><a href=\"/on\">TEST: ON</a></div>";
        s += "<div><a href=\"/off\">TEST:OFF</a></div>";
        s += "<div>HTML Data Loaded</div></html>";
        s += footer;
    }else if(req == "/on"){//ロードされた画面
        s = header;
        s += webPage;
        s += footer;
        digitalWrite(ledPin, HIGH);
    }else if(req == "/off"){//ロードされた画面
        s = header;
        s += webPage;
        s += footer;
        digitalWrite(ledPin, LOW);
    }else{
        s = "HTTP/1.1 404 Not Found\r\n\r\n";
    }
    client.print(s);
}

void httpRequest(){//ロード用のリクエスト
    Serial.print("connecting to ");
    Serial.println(host);
    WiFiClient myclient;

    if (!myclient.connect(host, port)) {
        Serial.println("connection failed");
        delay(5000);
        return;
    }

    myclient.print(String("GET ") + url + " HTTP/1.1\r\n" +
                 "Host: " + host + "\r\n" +
                 "Connection: close\r\n\r\n");
    
    delay(100);
    
    boolean start=false;
    while(myclient.available()){
        String line = myclient.readStringUntil('\r');
        if(line.indexOf("<!DOCTYPE html>")>0){
          start=true;
        }
        if(start){
            webPage+=line;
        }
    }
    Serial.println("closing connection");
    myclient.stop();
}

サンプルのつぎはぎなので、もしかすると矛盾があるかもしれないけれども、一応機能しました。
ESP32の電源を入れると、setup()内の最後に書いてあるhttpRequest()によって外部サーバにリクエストを出して、そのURLのHTMLを読み込みます。読み込んだ内容はwebPageという変数に入れてしまいます。これが表示用のHTMLデータとなります。
今回の場合はESP32がWebサーバとして機能しており、静的IPアドレス192.168.3.20に固定され、それ以下のディレクトリにアクセスすると:

/(ルート):もともとソースに書き込まれた単純なHTMLを表示
/load :再度HTMLデータを読み込むとき(表示はもともとのHTML)
/on :ロードしたHTMLによる画面表示、23番ピンに接続したLEDをON
/off :ロードしたHTMLによる画面表示、23番ピンに接続したLEDをOFF

となります。
外部サーバからのレスポンスには「HTTP/1.1 200 OK」などのheaderの情報が含まれるので、
line.indexOf("<!DOCTYPE html>")
をつかってHTMLデータの始まりを確認してから、変数webPageに入れていきます。今回の場合は、CSSやJavaScriptもHTMLファイル内に書き込んであるので、このHTMLファイルだけを読み込めばOKということにしています。

ちょっとだけHTMLを書き換えて確かめたいときは、このやり方のほうが早いのかもしれません。毎回ESP32にアップロードするのは確かに面倒。こうすれば、ESP32にはHTML(CSSやJavaScript)データをアップロードしなくてもすむので楽かなと。
ESP8266の場合も、ファイルアップローダを使うとけっこう時間(5分とか)かかるので、この方法のほうが早そうです。
AliExpress.com Product - Official DOIT ESP32 Development Board WiFi+Bluetooth Ultra-Low Power Consumption Dual Core ESP-32 ESP-32S ESP 32 Similar ESP8266976円(送料無料)最近だとAliExpressなら1000円以下になったみたいです。技適マークつきで、秋月のと同じタイプだと思います。

2017年4月26日水曜日

ESP8266:音声認識Wifiスイッチ:フィードバックで現状表示

前回までは、ブラウザを通してサーバに接続し直すたびに、プログラム自体が初期化されてすべてのスイッチがオフになってしまいましたが、今回の改良で、各ESP8266内部にもスイッチのON/OFF状況を記憶させておく変数を用意したので、現在どのスイッチがON/OFFになっているかが分かるようになりました。これで当初考えていた仕組みがほぼ完成という感じです。

これは、サーバに接続し直したときの制御画面です。一度ブラウザを閉じてしまっても、再接続と同時にフィードバック用のURLへリクエストをだし、そのレスポンスとして現在のON/OFF状態のデータを受け取る仕組みになっています。レスポンスがない場合は、一番下の「トイレ:[非接続]」というような表示になり(トイレ用のESP8266はまだ設置していないため)、それぞれのESP8266が稼働中かどうかもわかります。

それにしても今回のこの単純な仕組みを実現したいがために、Node.js、EJS、Express、Node-RED、PythonのFlaskなども試してみましたが、結局使う必要はなかったという。
しかし、ひとつ気になったのは、以前試したBlynkの応用的な使い方であるBlynk HTTP RESTful APIです。

こちらは、いわゆるスマホで操作するBlynkとは違って、パソコンのブラウザからも制御できるという仕組み。Blynkで発行したTokenを使ってBlynkサーバにリクエストすれば、接続してあるESP8266などをリアルタイムで制御できます。当然、今回のように各端子のON/OFF状況も調べることができます。おそらく登録したプロジェクト(ESP8266などのデバイス一個分)ごとにTokenが発行されるので、ESP8266を3つ使うならば3プロジェクト登録して3つのTokenを使い回せば、それぞれの制御が可能になりそうです。この機能を使えば、従来通りにスマホのBlynkアプリから操作してもいいし、パソコンのブラウザからも操作できるというわけです。
しかしながら、これはBlynkのサービスというよりも、Apiaryというモックサーバを使ってWeb開発などをするサービスでした。最近この手のクラウドコンピューティングのようなサービスが増えてきて、どう使えばいいかまだわからないのですが、ネットで調べる限りでは便利そうでした。そのうち必要があれば使ってみたいと思います。

ということで、話はもどって、

当然ながら、すべてのESP8266が接続されていない場合はこの↑ように[非接続]ばかりになります。ハードのほうがまだ出来上がっていないため、現状ではこんな感じ。

例えば、蛍光灯をONにする場合は、http://192.168.3.12/fluo_onへリクエストを出し、読み込み完了とステータス200が確認できたあとに画面表示内容(ボタンの色など)をアップデートします。この場合はレスポンスとして受け取るデータはなしです。他のボタンや音声認識による操作も同様です。
これとは別に各スイッチのON/OFF状況を知るためには、http://192.168.3.12/fluo_on_checkという別のURLへリクエストを送り、レスポンスとして変数を受け取り画面表示内容に反映させています。

接続時のプログラムの流れ:
・ブラウザでメインサーバへアクセスする
・ブラウザから各ESP8266のフィードバック用URLへリクエストをおくる
・各ESP8266との接続確認をとる
・接続確認とれた場合、レスポンスとしてON/OFF状況の変数を受け取る
・接続確認とれない場合、非接続の変数値に置き換える
・ON/OFF/非接続の状況の変数をブラウザ側のJavaScriptの変数へ入れ直す
・変数に応じて画面表示内容をアップデートする

音声認識による制御プログラムの流れ:
・音声認識を開始する
・音声入力する
・音声入力をテキスト変換する
・変換された入力テキストを正規表現でマッチングさせる
・マッチングの内容に応じて各スイッチのON/OFF制御
・対応するESP8266のON/OFF制御用URLへリクエストを送る
・読み込み完了とステータス200を確認できれば画面表示内容をアップデート
・ESP8266側で端子をON/OFFする
・読み込み完了とステータス200を確認できない場合は、「接続できません」と表示する

ボタン操作による制御プログラムの流れ:
・ON/OFFのボタンを押す
・対応するESP8266のON/OFF制御用URLへリクエストを送る
・読み込み完了とステータス200を確認できれば画面表示内容をアップデート
・ESP8266側で端子をON/OFFする
・非接続中のボタンを押しても反応せず「操作できません」と表示

結局のところ、スイッチのON/OFFの仕組みよりも、ON/OFF状態を画面に反映させるためのプログラムにかなり時間がかかってしまいました。
今回の場合はJavaScriptで非同期通信を行っているため、コマンドを発したタイミングとその処理が終了するタイミングがずれるので、値がnullになってしまったりと、エラーのでる原因を見つけるのに結構手間がかかりました。ようやく非同期通信の仕組みがわかってきたという感じです。

以下がHTML、CSS、JavaScriptのプログラム:

<!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)">蛍光灯: ON</div>
    <div class="btn" id="fluo_off" onclick="buttonClick(1)">蛍光灯: OFF</div>
    <div class="btn" id="bed_on" onclick="buttonClick(2)">寝室: ON</div>
    <div class="btn" id="bed_off" onclick="buttonClick(3)">寝室: OFF</div>
    <div class="btn" id="air_on" onclick="buttonClick(4)">エアコン: ON</div>
    <div class="btn" id="air_off" onclick="buttonClick(5)">エアコン: OFF</div>
    <div class="btn" id="wc_on" onclick="buttonClick(6)">トイレ: ON</div>
    <div class="btn" id="wc_off" onclick="buttonClick(7)">トイレ: 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:96%;
        background-color:#e75;
        padding:2%;
        margin:1% 0 0 0;
    }
    .you{
        text-align: left;
        width:96%;
        background-color:#d66;
        padding:2%;
        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">
    var flag_speech = 0;
    var youAns=[/^(?!.*(オフ|ない)).*(?=蛍光灯).*(?=(オン|on|音|つけ|付け)).*$/,
                /^(?!.*(オン|ない)).*(?=蛍光灯).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=寝室).*(?=(オン|on|音|つけ|付け)).*$/,
                /^(?!.*(オン|ない)).*(?=寝室).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=エアコン).*(?=(オン|on|音|つけ|付け)).*$/,
                /^(?!.*(オン|ない)).*(?=エアコン).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=トイレ).*(?=(オン|on|音|つけ|付け)).*$/,
                /^(?!.*(オン|ない)).*(?=トイレ).*(?=(オフ|off|切|消)).*$/];

    var apiAns=["蛍光灯をオンにしました",
                "蛍光灯をオフにしました",
                "寝室をオンにしました",
                "寝室をオフにしました",
                "エアコンをオンにしました",
                "エアコンをオフにしました",
                "トイレをオンにしました",
                "トイレをオフにしました"];

    var button_tx=["蛍光灯: ",
                   "蛍光灯: ",
                   "寝室: ",
                   "寝室: ",
                   "エアコン: ",
                   "エアコン: ",
                   "トイレ: ",
                   "トイレ: "];

    var html_id=["fluo_on",
                 "fluo_off",
                 "bed_on",
                 "bed_off",
                 "air_on",
                 "air_off",
                 "wc_on",
                 "wc_off"];

    var ip_address=["http://192.168.3.12/",
                    "http://192.168.3.12/",
                    "http://192.168.3.13/",
                    "http://192.168.3.13/",
                    "http://192.168.3.13/",
                    "http://192.168.3.13/",
                    "http://192.168.3.14/",
                    "http://192.168.3.14/"];

    var url_param = [0,0,0,0];

    var api_tx="COM: 音声入力して下さい";
    var you_tx="YOU: 認識された音声";

    //画面ボタンクリック処理
    function buttonClick(button_id){
        var param_id = parseInt(button_id / 2);
        if(url_param[param_id] >= 0){
            url_param[param_id]=1-(button_id % 2);
            you_tx = "YOU: ボタンで" + apiAns[button_id];
            request(html_id[button_id],button_id);
        }else{
            api_tx = "COM: 非接続中のため操作できません";
            you_tx = "YOU: ボタンで" + apiAns[button_id] + "が、操作不可能です"
            document.getElementById("bg").style.backgroundColor = '#ebf';
            updateHtml();
        };
    };

    //チェックした各種パラメータをセット
    function setParam(){
        for(var i = 0; i < url_param.length; i++){
            checkParam(ip_address[i*2]+html_id[i*2]+"_check",i);
        };
    };

    //非同期リクエストでパラメータを取得
    function checkParam(url,index){
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function(){
            if(xhr.readyState === 4 && xhr.status === 200){
                url_param[index] = parseInt(xhr.response);
                updateHtml();
            }else{
                url_param[index] = -1;
                updateHtml();
            };
        };
        xhr.open("GET", url, true);
        xhr.send();
    };

    //ボタン操作や音声認識による制御をリクエスト
    function request(url, index){
        var req_url = ip_address[index] + url;
        if(url_param[parseInt(index/2)] >= 0){
            var xhr = new XMLHttpRequest();
            xhr.onreadystatechange = function(){
                if(xhr.readyState === 4 && xhr.status === 200){
                    api_tx = "COM: " + apiAns[index];
                    updateHtml();
                    document.getElementById("bg").style.backgroundColor = '#fff';
                }else{
                    api_tx = "COM: 接続できません";
                    updateHtml();
                    document.getElementById("bg").style.backgroundColor = '#eef';
                };
            };
            xhr.open("GET", req_url, true);
            xhr.send();
        }else{
            api_tx = "COM: 現在、接続していませんよ";
            updateHtml();
            document.getElementById("bg").style.backgroundColor = '#ebf';
        };
    };

    //HTML画面表示アップデート
    function updateHtml(){
        for(var i=0;i<url_param.length;i++){
            if(url_param[i] == 1 ){
                document.getElementById(html_id[i*2]).innerHTML=button_tx[i*2]+"ON";
                document.getElementById(html_id[i*2+1]).innerHTML=button_tx[i*2+1]+"OFF";
                document.getElementById(html_id[i*2]).style.backgroundColor = '#c2b';
                document.getElementById(html_id[i*2+1]).style.backgroundColor = '#999';
            }else if(url_param[i] == 0){
                document.getElementById(html_id[i*2]).innerHTML=button_tx[i*2]+"ON";
                document.getElementById(html_id[i*2+1]).innerHTML=button_tx[i*2+1]+"OFF";
                document.getElementById(html_id[i*2]).style.backgroundColor = '#999';
                document.getElementById(html_id[i*2+1]).style.backgroundColor = '#c2b';
            }else{
                document.getElementById(html_id[i*2]).innerHTML=button_tx[i*2]+"[非接続]";
                document.getElementById(html_id[i*2+1]).innerHTML=button_tx[i*2+1]+"[非接続]";
                document.getElementById(html_id[i*2]).style.backgroundColor = '#ccc';
                document.getElementById(html_id[i*2+1]).style.backgroundColor = '#ccc';
            };
        };
        document.getElementById("api").innerHTML=api_tx;
        document.getElementById("you").innerHTML=you_tx;
    };

    //ここから音声認識処理
    function vr_function() {
        window.SpeechRecognition = window.SpeechRecognition || webkitSpeechRecognition;
        var rec = new webkitSpeechRecognition();
        rec.lang = 'ja-JP';
        rec.interimResults = false;
        rec.continuous = true;

        rec.onstart = function() {
            document.getElementById('status').innerHTML = "[ 認識中 ]";
        };

        rec.onerror = function() {
            document.getElementById('status').innerHTML = "[ エラー ]";
            if(flag_speech == 0){
                vr_function();
            };
        };

        rec.onsoundend = function() {
            document.getElementById('status').innerHTML = "[ 停止中 ]";
            vr_function();
        };

        rec.onresult = function(event) {
            var results = event.results;
            for (var i = event.resultIndex; i < results.length; i++) {
                if (results[i].isFinal){
                    var youSaid = results[i][0].transcript;
                    for(var j=0;j<youAns.length;j++){
                        if(youSaid.match(youAns[j])){
                            var param_id=parseInt(j/2);
                            if(url_param[param_id] >= 0){
                                url_param[param_id]=1-(j%2);
                            };
                            you_tx = "YOU: " + youSaid;
                            request(html_id[j], j);
                            break;
                        }else{
                            document.getElementById("api").innerHTML ="COM: 音声入力をどうぞ!";
                            document.getElementById("you").innerHTML ="YOU: " + youSaid;
                            document.getElementById("bg").style.backgroundColor = '#fc9';
                        };
                    };
                    vr_function();
                }else{
                    document.getElementById('you').innerHTML = "[解析中]:" + results[i][0].transcript;
                    flag_speech = 1;
                };
            };
        };
        flag_speech = 0;
        document.getElementById('status').innerHTML = "[ 起動中 ]";
        rec.start();
    };

    window.onload = function() {
        setParam();
        vr_function();
    };
</script>

</body>
</html>


尚、Web Speech API(途切れなくする方法)は、こちらを参考にしました。
追記:
実は上記の途切れなくする方法だと、スマホChromeでは繰り返しているうちにエラーが出てフリーズしてしまうので、その後改良してエラーなしで動作するようになりました(まとめのページ中ほどに書いてあります)。

以下が、ESP8266のプログラム(複数あるうちの一つ):

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <WiFiClient.h>

#define ledPin 2
#define relayPin 16

const char* ssid = "*****";
const char* password = "*****";
const char* reqHost = "192.168.3.13";

String webPage = "";

String fluo_param="0";

ESP8266WebServer server(80);

void setup(void){
  webPage="<html><head><meta charset='utf-8'><title>WIFI SWITCH</title></head><body>";
  webPage+="<div style='text-align:center; font-size:30px;'>";
  webPage+="<a href='/fluo_on'><button>蛍光灯: ON</button></a><br/>";
  webPage+="<a href='/fluo_off'><button>蛍光灯: OFF</button></a><br/>";
  webPage+="<a href='/bed_on'><button>寝室: ON</button></a><br/>";
  webPage+="<a href='/bed_off'><button>寝室: OFF</button></a><br/>";
  webPage+="<a href='/air_on'><button>エアコン: ON</button></a><br/>";
  webPage+="<a href='/air_off'><button>エアコン: OFF</button></a><br/>";
  webPage+="<a href='/wc_on'><button>トイレ: ON</button></a><br/>";
  webPage+="<a href='/wc_off'><button>トイレ: OFF</button></a><br/>";
  webPage+="</div></body></html>";
  
  pinMode(ledPin, OUTPUT);
  pinMode(relayPin, OUTPUT);
  
  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,12),IPAddress(192,168,3,1),IPAddress(255,255,255,0));

  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
    
  server.on("/", [](){
    server.send(200, "text/html", webPage);
  });
  
  server.on("/fluo_on", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, HIGH);
    fluo_param="1";
    delay(1000);
  });
  
  server.on("/fluo_off", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, LOW);
    fluo_param="0";
    delay(1000); 
  });

  server.on("/fluo_on_check", [](){
    server.send(200, "text/html", fluo_param);
    delay(1000);
  });
  
  server.on("/bed_on", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/bed_on");
    delay(1000);
  });
  
  server.on("/bed_off", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/bed_off");
    delay(1000); 
  });

  server.on("/air_on", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/air_on");
    delay(1000);
  });
  
  server.on("/air_off", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/air_off");
    delay(1000); 
  });

  server.on("/wc_on", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/wc_on");
    delay(1000);
  });
  
  server.on("/wc_off", [](){
    server.send(200, "text/html", webPage);
    http_request(reqHost,"/wc_off");
    delay(1000); 
  });

  server.begin();
  Serial.println("HTTP server started"); 
}

void http_request(const char* host, String url){
  WiFiClient client;
  const int httpPort = 80;
  if (!client.connect(host, httpPort)) {
    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 loop(void){
  server.handleClient();
}

fluo_paramというのが、ESP8266内における蛍光灯のON/OFF状況の変数です。ONのとき"1"、OFFのとき"0"で、フィードバック用URLである/fluo_on_checkにリクエストがくると、そのレスポンスとしてfluo_paramの値("0"か"1")をブラウザへ返します。
その他は前回とほぼ同じで、仮にこのESP8266のIPアドレスにアクセスしてもボタン操作できる画面(音声認識なし)が表示されます。このESP8266から他のESP8266(他のIPアドレス)へもリクエストを送ることができるので、他のESP8266を制御することも可能です。おそらくメインサーバからそれぞれのESP8266を制御することになると思うのですが、基本的にどのESP8266からであっても接続されている全てのESP8266の制御が可能になっています。

追記:
その後、非接続のESP8266に対するボタン操作や音声入力した場合の処理や表示についても追加しておきました。こういったものは、操作するたびに矛盾する処理が見つかるのできりがありません。

関連:
音声認識Wifiスイッチ/ESP8266使用(まとめ)についてはこちら

人気の投稿