前回の記事で紹介したように今回はArduino化したESP-WROOM-02(ESP8266)でWebSocketを実現するための私なりの解説をしていこうと思います。
WebSocketはネットで様々な情報が入手できますが、Arduinoで実現した例は殆どありませんので、ここでは電子工作としてのWebSocketを解説していきます。
まずはハンドシェイク(コネクション確立)ができなければデータの送受信はできませんので、今回はそれに絞って大まかな概要を説明します。
実際の工作は次回以降に記事にします。
WebSocket を実現するためには以下のサイトが大変参考になりました。
ここまで日本語訳をしていただけるのはとても素晴らしいことです。大変感謝いたします。
RFC6455 — The WebSocket Protocol 日本語訳
また、英語のWebSocketコミュニティーサイトはWebSocketのサーバーテストをすることができます。
WebSoket.org
いといろとWeb上で試すことができるので、重宝させていただきました。
ではまず、ハンドシェイク確立させるためには以下の順番の手順を踏みます。
ブラウザからのHTTPリクエストにレスポンスしてHTMLページを表示させる
ブラウザからArduino化したWROOMサーバーへIPアドレスを入力してアクセスします。
例えば、
http://192.168.0.21
と入力すると、WROOMにGETリクエストが届きます。
WROOMからはブラウザに以下のようなHTTPレスポンスヘッダを付加したHTMLコードを送信します。
これはあくまで例です。
HTTP/1.1 200 OK Content-Type:text/html Connection:close <!DOCTYPE html> <html> <head> <meta charset='utf-8'> <meta name='viewport' content='initial-scale=1.1'> <title>WebSocket Test</title> <script language='javascript' type='text/javascript'> var wsUri = 'ws://192.168.0.21'; var output; var websocket = null; function init() { output = document.getElementById('output'); testWebSocket(); } function testWebSocket() { if(websocket == null){ websocket = new WebSocket(wsUri);//WebSocketオブジェクト生成 websocket.onopen = function(evt) { onOpen(evt) }; websocket.onclose = function(evt) { onClose(evt) }; websocket.onmessage = function(evt) { onMessage(evt) }; websocket.onerror = function(evt) { onError(evt) }; } } function onOpen(evt) { writeToScreen('CONNECTED'); doSend('WebSocket rocks'); } function onClose(evt) { writeToScreen('WS.Close.DisConnected'); websocket.close(); websocket = null; } function onMessage(evt) { var ms1 = document.getElementById('WROOM_DATA'); ms1.innerHTML = evt.data; } function onError(evt) { writeToScreen("<span style='color: red;'>ERROR:</span> " + evt.data); } function doSend(data) { websocket.send(data); } function WS_close() { websocket.close(); websocket = null; } function writeToScreen(message) { var msg = document.getElementById('msg'); msg.innerHTML = message; } window.onload = function() { setTimeout('init()', 3000); } </script> </head> <body> <h2 style='color:#5555FF'> <center>ESP-WROOM-02(ESP8266) WebSocket Test</center></h2> from WROOM DATA = <font size=4> <span id='WROOM_DATA' style='font-size:45px; color:#FF0000;'></span> <br>JS-innerHTML= <input type='number' name='v_box' id='v_box' style='width:30px'> <br><br> <center>LED dimming <input type='range' name='slider' ontouchmove="doSend(this.value); document.getElementById('v_box').value=this.value;"> </center> <br><br> <div id='msg' style='font-size:25px; color:#FF0000;'> </div><br> <input type='button' id='WS_close' value='WS.CLOSE' style='width:150px; height:40px; font-size:17px;' onclick='WS_close()'> <br> </body> </html>
1~4行目がHTTPレスポンスヘッダです。4行目の空行は絶対必要です。
12行目の WebSocket接続用URIは
ws://xxx.xxx.xxx.xxx
となります。http://ではないので要注意です。
23行目のnew WebSocket(wsUri)
でWebSocketオブジェクトを生成していますが、これは64~67行目までにあるようにページを表示したらすぐに生成するのではなく、WROOMとブラウザのHTTPリクエスト、レスポンスが終了するまで(例えば3秒後まで)生成しないようにします。
ブラウザから http://192.168.0.21
へリクエストしたら、WROOMからこのHTMLタグをブラウザへレスポンスします。これは通常のHTTPリクエスト、レスポンスと一緒です。
そのコネクションが成立したら必ずWROOM側からコネクション切断します。
この切断がしっかり行われないとハンドシェイク確立できません。
Google Chromeの場合は切断後すぐにGET /favicon
リクエストがWROOMに送信されてきます。
それも受信し終えたらコネクションをWROOM側で切断する必要があります。
このfavicon処理が結構面倒なんです。
iOSのSafariにはfaviconリクエストはありませんので有り難いのですが・・・。
このfaviconリクエストは必要なんですかねぇ・・・?
WebSocketリクエストの受信
通常のHTTPリクエスト、レスポンスが終了したら、ブラウザの上記のJavaScriptによって3秒後に以下のようなWebSocket通信リクエスト文が送信されてきます。
GET / HTTP/1.1 Host: 192.168.0.21 Connection: Upgrade Pragma: no-cache Cache-Control: no-cache Upgrade: websocket Origin: http://192.168.0.21 Sec-WebSocket-Version: 13 DNT: 1 User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; xxx Build/V38R73C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36 Accept-Encoding: gzip, deflate, sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Extensions: permessage-deflate; 空行
GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: 192.168.0.21 Origin: http://192.168.0.21 Pragma: no-cache Cache-Control: no-cache Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Sec-WebSocket-Extensions: x-webkit-deflate-frame User-Agent: Mozilla/5.0 (iPad; CPU OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1 空行
このWebSocket-KeyをArduino化WROOMで文字列として抽出します。
このキーはコネクションする度に毎回異なるキーがブラウザ側から発行されます。
ArduinoやESP-WROOM-02でGoogle Chrome と iPad両方受信対応とすると、どこまで読み込むかが問題となります。
例えば、Chrome用にSec-WebSocket-Extensions:
行まで読み込んだとしたら、iPadにした場合、User-Agent:
行を捨てることになります。その場合、しっかり捨てきらないと、次にサーバー側で受信するデータに被って受信されるので、WebSocket通信がうまく動作しないことが考えられます。これは要注意点ですね。
過去の記事で、Accept-Language:
まで読み込むようなプログラムを組んでいましたが、今回は ¥r¥n が先頭になるまで、つまり空行が来るまで読み込むようにしました。そうしたら上手くいきました。過去記事では\r\nをうまく検知できなかったのですが、今回は検知できるようになりました。
因みに、¥r¥n は \r\n と同じです。要するにバックスラッシュです。
フォントによって、¥が\と表示されます。機能は同じです。キャリッジ・リターン と ライン・フィード という特殊文字です。
WebSocket-KeyとGUID連結
次に、上記のWebSocket-Key文字列にGUIDというものを連結します。
GUIDは以下のようなコードです。
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
WebSocket-Key文字列にGUIDを連結すると
dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
となります。
ハッシュ値生成
GUID連結キーからハッシュ値を得ます。
Arduino IDE ver 1.6.5 にボードマネージャーでESP8266ボードをインストールすると
Hash.h
というライブラリがあります。これを使用してハッシュ値を得ます。
ESP8266ボードのインストールはこちらの3段落目以降を参照してください。
ハッシュ値生成ライブラリの関数sha1を使って20桁のハッシュ値を得ます。
すると
0xb3,0x7a,0x4f,0x2c,0xc0,0x62,0x4f,0x16,0x90,0xf6,0x46,0x06,0xcf,0x38,0x59,0x45,0xb2,0xbe,0xc4,0xea
という値が得られます。
20桁ハッシュ値をBASE64エンコード(符号化)
まず、ハッシュ値を2進数にしてそれぞれ連結して6bitづつに分割します。
例えば、
0xb3=10110011 0x7a=01111010 0x4f=01001111 ・・・・
ならば
というふうに分割します。
ビット数が6bitに満たないところは0を追加して6bitにします。
それを下図の変換表を参照して文字に変換します。
そして、4の倍数の文字数で足りないところは「=」を追加します。
つまり、6bit化したところで、27文字変換できたので、4の倍数に1文字足りません。つまり28文字にしなければいけないので、最後に「=」を追加します。
そして、最終的に
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
というキーが得られればOKです。
これがBASE64エンコード(符号化)です。
ここまではネットで多くの情報がありますので参照してみてください。
このキーさえ生成できれば、WebSocket通信は出来たも同然です。
ハッシュ値さえ得られれば、BASE64エンコードはArduinoやWROOMでプログラムを組めば、それほど難しくなくキーを生成できます。
では、このキー生成を Arduino化した ESP-WROOM-02 で作ってみましたので、以下のスケッチを参考にしてみてください。
シリアルモニタに20桁のキーを入力すると、28桁のキーを生成します。
//ハッシュ関数、BASE64エンコードテスト #include <Arduino.h> #include <Hash.h> String hash_req_key; char hash_resp_key[28]; char c; byte ii, jj; void setup() { Serial.begin(115200); } void loop() { while(Serial.available()){ c = Serial.read(); if(c != '\n' && c != '\r'){//シリアルモニタでCRおよびLF送信になっていた場合の対処 hash_req_key += c; }else{ break; } if(ii>22){ Serial.print("Original Hash key = "); Serial.println(hash_req_key); //ハッシュ値、BASE64エンコード関数 Hash_Key(hash_req_key, hash_resp_key); Serial.print("SHA-1 & BASE64 encord = "); for(jj=0; jj<28; jj++){ Serial.print(hash_resp_key[jj]); } Serial.println(); ii=0; hash_req_key=""; break; } ii++; } } void Hash_Key(String h_req_key, char* h_resp_key) { //BASE64エンコード用文字テーブル。プラスして最後に「=」を追加 char Base64[65] = { 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P', 'Q','R','S','T','U','V','W','X','Y','Z','a','b','c','d','e','f', 'g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v', 'w','x','y','z','0','1','2','3','4','5','6','7','8','9','+','/', '=' }; byte hash_six[27]; byte dummy_h1, dummy_h2; byte bb; byte i, j; i=0; j=0; String GUID_str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; String merge_str; merge_str = h_req_key + GUID_str;//オリジナルキーとGUIDを連結 Serial.println(); Serial.print("merge_str ="); Serial.println(merge_str); Serial.print("SHA1:"); Serial.println(sha1(merge_str)); byte hash[20]; sha1(merge_str, &hash[0]);//ハッシュライブラリからハッシュ値を得る Serial.print("SHA1:"); for(uint16_t i = 0; i < 20; i++) { Serial.printf("%02x", hash[i]);//生成したハッシュ値を16進表示 Serial.print("-"); } Serial.println(); Serial.print("SHA1:"); for(uint16_t i = 0; i < 20; i++) { Serial.print(hash[i],BIN);//生成したハッシュ値を2進表示 Serial.print("-"); } Serial.println(); //ここからBASE64エンコード for( i = 0; i < 20; i++) { hash_six[j] = hash[i]>>2; hash_six[j+1] = hash[i+1] >> 4; bitWrite(hash_six[j+1], 4, bitRead(hash[i],0)); bitWrite(hash_six[j+1], 5, bitRead(hash[i],1)); if(j+2 < 26){ hash_six[j+2] = hash[i+2] >> 6; bitWrite(hash_six[j+2], 2, bitRead(hash[i+1],0)); bitWrite(hash_six[j+2], 3, bitRead(hash[i+1],1)); bitWrite(hash_six[j+2], 4, bitRead(hash[i+1],2)); bitWrite(hash_six[j+2], 5, bitRead(hash[i+1],3)); }else if(j+2 == 26){ dummy_h1 = 0; dummy_h2 = 0; dummy_h2 = hash[i+1] << 4; dummy_h2 = dummy_h2 >>2; hash_six[j+2] = dummy_h1 | dummy_h2; } if( j+3 < 27 ){ hash_six[j+3] = hash[i+2]; bitWrite(hash_six[j+3], 6, 0); bitWrite(hash_six[j+3], 7, 0); }else if(j+3 == 27){ hash_six[j+3] = '='; } h_resp_key[j] = Base64[hash_six[j]]; h_resp_key[j+1] = Base64[hash_six[j+1]]; h_resp_key[j+2] = Base64[hash_six[j+2]]; if(j+3==27){ h_resp_key[j+3] = Base64[64]; break; }else{ h_resp_key[j+3] = Base64[hash_six[j+3]]; } i = i + 2; j = j + 4; } Serial.print("hash_six = "); for(i=0; i<28; i++){ Serial.print(hash_six[i],BIN); Serial.print('_'); } Serial.println(); }
Hash.h ライブラリはArduino IDE 1.6.5 のESP8266 ボードをインストールしたら自動的に入っていると思います。
「スケッチの例」で Hash とあれば、インストールされているはずです。
ESP8266ボードインストールの方法はこちらを参照ください。
エンコード結果のシリアルモニターはこんな感じになります。
シリアルモニターの入力欄に20桁のキーを入力した結果です。
これって、電子工作で暗号化みたいなことをやっているようで、なかなかスゴイなぁと我ながら思ってしまいました・・・。
WebSocketレスポンスをブラウザへ送信
次に、ブラウザへ以下のようなWebSocketレスポンスヘッダを送信します。
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= 空行
4行目が先ほど生成したハッシュキーです。
5行目の空行は必ず必要です。
また、ネットにある情報で
Sec-WebSocket-Protocol: chat
という文字列は不要です。
Arduino化WROOMでこれを送るとハンドシェイク確立できませんでした。
以上がハンドシェイクの概要です。
ここまでのハンドシェイクが確立することができれば、Arduino化WROOMでWebSocket双方向通信の完全制覇はもうすぐです。
これだけで記事が満載になってしまいましたので、実際の電子工作での実現は次回以降に記事をアップする予定です。
Arduino化したESP-WROOM-02では、このリクエスト、レスポンスをdelay関数を所々入れることによって調節します。
それの入れる場所やタイムの長さによっては確立できない場合がありますので、何回もカットアンドトライをする必要がありました。
今回はここまでで、次回はデータ送信方法などを紹介しようと思います。
ではまた・・・。
Amazon.co.jp 当ブログのおすすめ
コメント