Arduino化 WROOM で WebSocket ハンドシェイク 確立方法

記事公開日:2015年11月3日
最終修正日:2018年3月7日

前回の記事で紹介したように今回は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
・・・・

ならば
WebSocket-handshake01
というふうに分割します。
ビット数が6bitに満たないところは0を追加して6bitにします。

それを下図の変換表を参照して文字に変換します。

10 進数2 進数文字
0000000A
1000001B
2000010C
3000011D
4000100E
5000101F
6000110G
7000111H
8001000I
9001001J
10001010K
11001011L
12001100M
13001101N
14001110O
15001111P
16010000Q
17010001R
18010010S
19010011T
20010100U
21010101V
22010110W
23010111X
24011000Y
25011001Z
26011010a
27011011b
28011100c
29011101d
30011110e
31011111f
32100000g
33100001h
34100010i
35100011j
36100100k
37100101l
38100110m
39100111n
40101000o
41101001p
42101010q
43101011r
44101100s
45101101t
46101110u
47101111v
48110000w
49110001x
50110010y
51110011z
521101000
531101011
541101102
551101113
561110004
571110015
581110106
591110117
601111008
611111019
62111110+
63111111/

そして、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桁のキーを入力した結果です。
Hash_Base64
これって、電子工作で暗号化みたいなことをやっているようで、なかなかスゴイなぁと我ながら思ってしまいました・・・。

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関数を所々入れることによって調節します。
それの入れる場所やタイムの長さによっては確立できない場合がありますので、何回もカットアンドトライをする必要がありました。

今回はここまでで、次回はデータ送信方法などを紹介しようと思います。
ではまた・・・。


スポンサーリンク

mgo-tec電子工作 関連コンテンツ ( 広告含む )
Amazon.co.jp 当ブログのおすすめ

投稿者:

mgo-tec

Arduino , ESP32 ( ESP-WROOM-32 ) , ESP8266 ( ESP-WROOM-02 )等を使って、主にスマホと連携した電子工作やプログラミング記事を書いてます。ライブラリも作ったりしてます。趣味、独学でやってますので、動作保証はしません。 電子回路やプログラミングの専門家ではありません。 畑違いの仕事をしていて、介護にドップリ浸かりそうな年配者です。 少しだけ電気の知識が必要な仕事なので、電気工事士や工事担任者等の資格は持っています。

コメントを残す

メールアドレスが公開されることはありません。

*画像の文字を入力してください。(スパム防止の為)

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください