これまでのあらすじ:
2016年3月、フェルト生地を手で裁断している際にレーザーカッターがあれば複雑なカットが容易にできるなあと思って、安価になってきたレーザーカッターを購入しようと思ったのがきっかけ。調べていくうちに、合板も切れたほうがいいと思うようになって、CNCルーター(CNCミリング)についても考えるようになった。
Arduinoは以前から使っており、CNCシールドがあると気付いて自作も可能と思うようになった。当初はShapeOkoやX-CARVEを参考にMakerSlide、OpenRail、V-Wheel、2GTタイミングベルトなどで5万円くらいで自作しようと思っていた。AliExpressでも部品が安く買えることが分かって、しばらくは部品探し。探せば探すほど安くて本格的な部品も見つかってくるので、そんなにケチらなくてもいいのではないかと徐々にスペックアップ。最終的には剛性や精度のことも考えてボールスクリューやリニアスライドを使うことになり、予想以上に重厚な3軸CNCマシンをつくることに(約7万円)。
構想から約5週間(制作約3週間)でルーターとレーザーともに使えるようになり、現在はgrbl1.1+Arduino CNCシールドV3.5+bCNCを使用中(Macで)。余っていたBluetoothモジュールをつけてワイヤレス化。bCNCのPendant機能でスマホやタブレット上のブラウザからもワイヤレス操作可能。


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

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


2017年4月16日日曜日

IoTその6:音声認識+ESP8266(改良中)

以前から続いている音声認識によるESP8266制御についてです。 いろいろと音声認識のAPIやデバイス、そしてサーバの立ち上げ方や外部クラウドの利用の仕方など試してみましたが、結局のところWeb Speech APIを使い、スマホ、タブレット、ノートパソコンあるいはRaspberry Pi上のブラウザ(Chrome)で、複数あるESP8266をWebサーバとして通信させることにしました。

SnowboyVoice Recognition Module VR3.1も認識力は良かったのですが、事前に録音させた自分の声を元に認識させる仕組みであり、しかもあまり長い言葉をしゃべることができないし、正規表現も使うことができないため、やはりWeb Speech APIになってしまったという感じです。

改良点:
やりたいことは、リビング、寝室、トイレなどに設置したESP8266で電源のスイッチをオンオフすることです。 問題となっていたのは、複数の異なるIPアドレスを与えたWebサーバ同士をどうやってつなげるかということです。今回は、ブラウザからそれぞれのIPアドレスへリクエストを送る際に、今の状況(どれがオンでどれがオフになっているかなど)をアドレスの最後にクエリをつけて変数を渡してあげるという方法でなんとか解決できました。
音声認識のほうについては、前回のようにWeb Speech APIと正規表現によって、かなり柔軟に認識してくれるのでいいのですが、それよりもHTMLとJavaScriptによる変数の受け渡し方に時間がかかりました。
追記:
その後の改良案では、今回の方法(以下の方法)は使わず、直接AjaxのXMLHttpRequestを使う方法で解決できました。

3つのWeb Server間の変数渡し:
蛍光灯のON/OFFはIPアドレス192.168.3.12のESP8266
寝室とエアコンは192.168.3.13のESP8266
トイレは192.168.3.14のESP8266
という感じで、同じ画面でも3つ異なる送信先があります。
以下の状態だと、蛍光灯をONしたので、192.168.3.12にアクセスしているところです。つぎにエアコンをONにする場合は、192.168.3.13/air_on.htmlへ移動するということになります。エアコンONした瞬間、通常なら移動先では今の情報(蛍光灯がONになっているなど)を知らないので初期状態の画面(蛍光灯が画面表示上OFFになってしまう)になってしまいますが、移動の際にクエリを渡すので、移動先でもこの画面を再現できるというわけです。

操作画面:
以下が今回の操作画面です。というか、音声認識なので、あまり画面は重要ではないのですが、一応下半分には、それぞれのオンオフが可能なボタンがついています。
アドレスの.html?以下にクエリとして、各ボタンのオンオフ状態、そして音声のテキスト内容が後続して並んでいます。
 クエリとして日本語文字列はエンコードしないといけないみたいですが、なぜか上のアドレスバーにはそのままエンコードされずに日本語が出ています。その後、HTMLで表示する際にもデコードしなおす必要があるようです。

追加するクエリの仕組み:
クエリ追加の仕組みは簡単で、それぞれのアドレス「〜.html?」以下に

1,0,0,1,0,1,0,1,COMの言葉,YOUの言葉

という感じで「,」で区切った10個の変数値が並んでいます。通常のクエリは、key=valueという組み合わせになり、JavaScript内でも連想配列で[key:value...]となりますが、面倒なのでkey(文字列)だけにしてあります。
本来なら、
〜.html?fluo=1&bed=0&aircon=0&wc=0&com=1234&you=5678
などとなるのかもしれませんが、今回は独自の勝手なやり方で。

ESP8266のほうでは、/fluo_onや/fluo_offのようなアドレスのリクエストがあった場合、対応するPINのオンオフ制御をしつつ、表示用HTML(CSS+JavaScript)が返されます。異なるアドレス(異なるスイッチ)に移動する際に、移動先アドレスのあとに?と上記変数を追加して、JavaScriptのwindow.location.hrefでURL移動しているだけです。
以前は、ESP8266に書き込んだHTMLの一部をArduino言語の変数に置き換えていたのですが、それだとHTMLやJavaScript内にある変数があるたびに、いちいち区切って書き込まなければならないので、今回のようなSPIFFSを使って一気にファイルをアップロードする際には不都合となってしまいます。

画面遷移時にやや問題:
リビング、寝室、トイレと場所が分かれているので、それぞれ異なるIPアドレスへアクセスすることになります。そのためブラウザの画面上では、見た目同じでもIPアドレス192.168.3.12から192.168.3.13へリクエストしたときに画面遷移しなければいけないので、画面の再読み込みが発生して一瞬チラついてしまいます。この辺はAjaxやAngularJSを使うと解決できるのかもしれませんが、Web技術にそれほど詳しいわけでもないので、今回はそこまではできていません。それでも結果的にはESP8266の技術というよりも、利用状況を表示させるHTMLとJavaScriptのほうが難しかったです。

その他のやり方:
当初は、PythonのFlask、あるいはNode.jsなどでサーバを立ち上げておいて、そのサーバが中枢的な役割を果たし、末端にいる複数のESP8266を管理するというイメージもありましたが、そのためにはリアルタイムの送受信であったり、WebSocketを使う方法などいろいろあるようで、そこまで大げさなことをすることもないかなと。

本題である音声認識の仕方や電源のオンオフのさせ方よりも、現在しゃべった言葉やスイッチのオンオフ状況の情報が画面遷移したあとでも保持されて表示されるという部分をどう処理するかで悩みました。 どうやら、同じドメイン内ならAjaxで変化させることができますが、異なるドメイン間になると難しいようで、このへんは割り切るしかありません。pjaxやAngularJsなら可能なのかもしれませんが、そこまで詳しくないので、これもそのうち。 それにしても、今回はかなり迂回しましたが、そのおかげでいろいろ勉強になったというか、たかだかWifiによるワイヤレススイッチなのに、Flask、Node.js、Node-RED、MQTTのようなサーバ関係やAjaxなどのJavaScriptの様々なスキルなどWeb技術ばかりでした。

SPIFFSでアップロード(やり方はページ下の方にあります):
ほぼシステムは出来上がりましたが、ESP8266の設置などはまだです。
今回のソースは以下です。CSSもあるので、かなり長くなりましたが、ESP8266にはSPIFFSを使って別にアップロードしたので大丈夫でした。
今回Speech to Textは使っていますが、Text to Speechは使っていないので音はでません。画面の文字だけです。重くなってしまうかもという懸念とたまに自分でしゃべった音に反応することがあったり、エラーにもなりかねないので外してしまいました。つまらなそうなら、また音声つきに戻そうかとも思っています。


<!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('fluo_on')">蛍光灯: ON</div>
<div class="btn" id="fluo_off" onclick="buttonClick('fluo_off')">蛍光灯: OFF</div>
<div class="btn" id="bed_on" onclick="buttonClick('bed_on')">寝室: ON</div>
<div class="btn" id="bed_off" onclick="buttonClick('bed_off')">寝室: OFF</div>
<div class="btn" id="air_on" onclick="buttonClick('air_on')">エアコン: ON</div>
<div class="btn" id="air_off" onclick="buttonClick('air_off')">エアコン: OFF</div>
<div class="btn" id="wc_on" onclick="buttonClick('wc_on')">トイレ: ON</div>
<div class="btn" id="wc_off" onclick="buttonClick('wc_off')">トイレ: 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 url_to=["fluo_on",
                "fluo_off",
                "bed_on",
                "bed_off",
                "air_on",
                "air_off",
                "wc_on",
                "wc_off"];

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

    var url_val=[];

    function setQuery(){
        if(window.location.search.length<1){
            for(var i=0;i<url_to.length;i++){
                if(i%2==0){
                    url_val[i]="0";
                    document.getElementById(url_to[i]).style.backgroundColor = '#aaa';
                }else{
                    url_val[i]="1";
                    document.getElementById(url_to[i]).style.backgroundColor = '#c2b'; 
                };     
            };
            url_val[url_to.length]="COM: 音声入力して下さい";
            url_val[url_to.length+1]="YOU: 認識された音声";
            document.getElementById("api").innerHTML=url_val[url_to.length];
            document.getElementById("you").innerHTML=url_val[url_to.length+1];
        }else{
            var s=window.location.search.slice(1);
            url_val=s.split(",");
            for(var i=0;i<url_to.length;i++){
                if(url_val[i]=="0"){
                    document.getElementById(url_to[i]).style.backgroundColor = '#aaa';
                }else if(url_val[i]=="1"){
                    document.getElementById(url_to[i]).style.backgroundColor = '#c2b';
                };
            };
            document.getElementById("api").innerHTML=decodeURI(url_val[url_to.length]);
            document.getElementById("you").innerHTML=decodeURI(url_val[url_to.length+1]);
        };
    };

    function buttonClick(id){
        var idx=url_to.indexOf(id);
        url_val[idx]="1";
        url_val[url_to.length]="COM: "+apiAns[idx];
        url_val[url_to.length+1]="YOU: ボタンで"+apiAns[idx].slice(3);
        if(idx%2==0){
            url_val[idx+1]="0";
        }else{                 
            url_val[idx-1]="0";
        }
        var ip="";
        if(idx==0||idx==1){
            ip=ipAddress[0];
        }else if(idx<=2 && idx<=5){
            ip=ipAddress[1];
        }else{
            ip=ipAddress[2];
        }
        window.location.href = ip+id+".html?"+url_val;
    };

    function delay_ms( ms ){
        var start = new Date();
        while( new Date() - start < ms );
    };
    
    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;
            var youSaid = "";
            var apiSaid = "";
            var apiHtml = document.getElementById("api");
            var youHtml = document.getElementById("you");
            var bgcolor = document.getElementById("bg");
            for (var i = event.resultIndex; i < results.length; i++) {
                if (results[i].isFinal){
                    youSaid = results[i][0].transcript;
                    for(var j=0;j<youAns.length;j++){
                        if(youSaid.match(youAns[j])){
                            apiHtml.innerHTML ="COM:"+apiAns[j];
                            youHtml.innerHTML ="YOU:"+youSaid;
                            if(j%2==0){
                                url_val[j]="1";
                                url_val[j+1]="0";
                            }else{   
                                url_val[j-1]="0";
                                url_val[j]="1";
                            };
                            url_val[youAns.length]=encodeURI("COM: "+apiAns[j]);
                            url_val[youAns.length+1]=encodeURI("YOU: "+youSaid);
                            var ip="";
                            if(j==0||j==1){
                                ip=ipAddress[0];
                            }else if(j>=2 && j<=5){
                                ip=ipAddress[1];
                            }else{
                                ip=ipAddress[2];
                            }
                            window.location.href = ip+url_to[j]+".html?"+url_val;
                        }else{
                            apiHtml.innerHTML ="COM: 音声入力をどうぞ";
                            youHtml.innerHTML ="YOU: "+youSaid;
                            bgcolor.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() {
        setQuery();
        vr_function();
    };
</script> 
</body>
</html>


なお、Web Speech APIの途切れない利用法については、以下を参考にいたしました。
http://jellyware.jp/kurage/iot/webspeechapi.html


そして以下がESP8266のソース:

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <WiFiClient.h>
#include "FS.h"

#define ledPin 13
#define relayPin 2

MDNSResponder mdns;

const char* ssid = "*****";
const char* password = "*****";
String webPage = "";
ESP8266WebServer server(80);

void setup(void){
  SPIFFS.begin();
  File f = SPIFFS.open("/index.html", "r");
  if (!f) {
    Serial.println("file open failed");
  }else{
    webPage = f.readString();
    f.close();
  }

  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());
  
  if (mdns.begin("studio", WiFi.localIP())) {
    Serial.println("MDNS responder started");
  }
    
  server.on("/", [](){
    server.send(200, "text/html", webPage);
  });
  server.on("/index.html", [](){
    server.send(200, "text/html", webPage);
  });
  server.on("/fluo_on.html", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, HIGH);
    digitalWrite(relayPin, HIGH);
    delay(1000);
  });
  server.on("/fluo_off.html", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, LOW);
    digitalWrite(relayPin, LOW);
    delay(1000); 
  });

  server.begin();
  Serial.println("HTTP server started"); 
}
 
void loop(void){
  server.handleClient();
}

これは、一台のESP8266のソースで、このほかIPアドレスの異なる(対応するスイッチも異なる)ESP8266へは、多少内容を変更して書き直します。
基本的には、

server.on("/air_on.html,[](){...}

などと対応するスイッチに応じて変更するだけです。ほぼサンプルのままです。


SPIFFSによる外部ファイル(HTMLファイル)の読み込み:
まず、こちらのサイトなどを参考にToolをダウンロードしてインストールしておきます。
Macであれば、アプリケーション>Arduinoを右クリックで「パッケージの内容を表示」。
Contents>Java>tools>ESP8266>tool>esp8266fs.jar
の位置にダウンロード&解凍したesp8266fs.jarを入れておき、Arduino IDEを再起動でOK。

用意したindex.htmlを「ファイルを追加...」でスケッチ内に取り入れます。
ファイル選択画面でデスクトップなどにあるindex.htmlファイルを選択。
取り込んだら、以下で確認します。

そうすると、
このようにメインのソースとなるinoファイルと同じディレクトリにdataフォルダが出来上がり、その中にindex.htmlが取り込まれているのがわかります。
つぎに、


「ESP8266 Sketch Data Upload」を使ってindex.htmlをESP8266のメモリ内にアップロードします。2~3%ずつゆっくりとアップロードしていくので、かなり時間がかかります(3~5分)。
アップロード後、従来通りinoファイルをESP8266にアップロードして終了。

プログラム内でのindex.htmlの読み込み:
index.htmlの内容を読み込むために、setup()内で、

 SPIFFS.begin();
  File f = SPIFFS.open("/index.html", "r");
  if (!f) {
    Serial.println("file open failed");
  }else{
    webPage = f.readString();
    f.close();
  }

を使って読み込んでいます。f.readString()でString変数に代入して終了。
同様のやり方で複数のファイルも読み込むことができるらしいです。
しかし疑問に思ったのは、こうやって別々にアップロードされた外部ファイルは、アップロードするたびに上書きされてしまうのか?それとも残ったままになっているの?この辺はよくわからない。たぶん、上書き保存だと思うのだけれども。

追記:
外部ファイルのアップロード確認については、こちらに書いておきました。
続きの改良案はこちら

0 件のコメント:

コメントを投稿