WiFiサーバを進化させよう
オウム返しサーバ
今回は、サーバとクライアントの間でちゃんと通信をしてみます。通信以外の処理を最低限にするため、Arduinoでも製作した『オウム返しサーバ』を題材とします。
つまり、
『クライアントから送信された1行の文字列を、そのままクライアントに返す』
というものです。言われた言葉をそのまま返すことから『オウム返しサーバ』と名付けました。
ただ、これだけだと通信を切断する条件が判らないので、
『クライアントから “exit” という文字列を送信されたら、通信を切断する』
という仕様も追加しておきます。
製作・実験
回路
今回も ESP32-Devkit 本体だけで動作します。ESP32-Devkit をPCとUSBケーブルで接続するだけで準備完了です。
プログラム
全ソースコード
メインプログラム:parrotingServer
#include "WiFi.h"
#include "wifisecret.h"
// IPアドレス、ポートなどの設定
IPAddress serverIP(192,168,0,101);
IPAddress gatewayIP(192,168,0,1);
IPAddress subnetMask(255,255,255,0);
IPAddress dnsIP(8,8,8,8);
uint16_t listenPort=23;
// サーバのインスタンスを作成
WiFiServer server(listenPort);
void setup(){
// シリアルインターフェイスの初期化
Serial.begin(115200);
delay(1000);
// 無線LANへの接続
Serial.print("接続中...");
if(!WiFi.config(serverIP, gatewayIP, subnetMask, dnsIP)){
Serial.println("サーバアドレスの設定に失敗しました");
}
WiFi.begin(_SSID, _PASS);
while(true){
if(WiFi.status() == WL_CONNECTED)break;
Serial.print(".");
delay(1000);
}
Serial.println("完了");
// ESP32に割り当てられたIPアドレスの確認
Serial.print("IPアドレス:");
Serial.println(WiFi.localIP());
// サーバの起動
server.begin();
}
void loop(){
WiFiClient client = server.available();
if(client){
// クライアントと接続した時の処理
while(true){
// 1行入力
String currentLine = "";
while(true){
if(!client.connected()){
currentLine = "exit";
break;
}
if(client.available()){
// 受信した文字がある場合
char c = client.read();
if(c == '\n'){
break;
}
if(c != '\r'){
currentLine += c;
}
}
}
// もし入力文字列が"exit"だったら終了
if(currentLine=="exit"){
client.stop();
break;
}else{
// クライアントに『オウム返し』
client.println(currentLine);
Serial.println(currentLine);
}
}
}
}
wifisecret.h
#define _SSID "xxxxxxxx"
#define _PASS "yyyyyyyy"
※ご利用の無線LAN環境にあわせて、”xxxxxxxx” の部分には SSID、”yyyyyyyy” の部分にはパスワードを記述してください。
プログラムの解説
HelloWorldサーバと違う部分のみ解説します。
IPアドレスの設定
// IPアドレス、ポートなどの設定
IPAddress serverIP(192,168,0,101);
IPAddress gatewayIP(192,168,0,1);
IPAddress subnetMask(255,255,255,0);
IPAddress dnsIP(8,8,8,8);
uint16_t listenPort=23;
ここでちょっと新しいことをやってみます。いままではマイコンのIPアドレスは接続先のLANのDHCPから動的に取得する前提になっていましたが、今回からは(後でやってみたいことに備えて)プログラム中で固定のIPアドレスを設定するように変更します。
実際に設定する関数は後で出てきますが、まずはここでネットワーク接続に必要な3つの値を設定しています。
変数名 | 意味 |
---|---|
serverIP | ESP32自身のIPアドレス。DHCPで割り当てられる値と重複しないように注意してください。 |
gatewayIP | デフォルトゲートウェイのIPアドレス。 |
subnetMask | 接続先のネットワークのサブネットマスク。接続先のLAN環境に合わせます。 |
dnsIP | DNSサーバのIPアドレス。 |
このうち、デフォルトゲートウェイのIPアドレス、サブネットマスク、DNSについては、PC上でipconfigコマンドを使用するなどして調べ、同じ値にするとよいでしょう。
IPアドレスは、IPAddressクラスのインスタンスとして設定してあります。コンストラクタは4つのオクテットをそれぞれ別の引数として与えますので、
serverIP(192,168,0,101)
のような書式になります(カンマ区切り)。
IPアドレスなので、つい『192.168.0.101』(ピリオド区切り)と書いてしまうとエラーになりますので、注意してください。
変数 listenPort は、名前の通りサーバの Listen Port番号を表します。
サーバのIPアドレスの指定
// 無線LANへの接続
Serial.print("接続中...");
if(!WiFi.config(serverIP, gatewayIP, subnetMask, dnsIP)){
Serial.println("サーバアドレスの設定に失敗しました");
}
サーバのIPアドレスの設定には、WiFi.config()関数を使用します。
書式
bool config(IPAddress ip, IPAddress gateway, IPAddress subnet, IPAddress dns1, IPAddress dns2);
引数
名前 | 型 | 意味 |
---|---|---|
ip | IPAddress | サーバ自身のIPアドレス |
gateway | IPAddress | デフォルトゲートウェイのIPアドレス |
subnet | IPAddress | サブネットマスク |
dns1 | IPAddress | プライマリDNSのIPアドレス(省略可) |
dns2 | IPAddress | セカンダリDNSのIPアドレス(省略可) |
返却値
返却値はBOOL型で、以下の意味を持ちます。
値 | 意味 |
---|---|
TRUE | 設定に成功 |
FALSE | 設定に失敗 |
WiFiServerクラスのコンストラクタにも引数にIPアドレスを渡すことが出来るのですが、どのみちサブネットアドレスやデフォルトゲートウェイも設定しないと通信は出来ませんので config()関数も使用することになります。
今回の例ではプログラム中からドメイン名を使用したインターネットアクセスをしないのでDNSは設定していません。
クライアントと接続した時の処理
void loop(){
WiFiClient client = server.available();
if(client){
// クライアントと接続した時の処理
while(true){
// 1行入力
String currentLine = "";
while(true){
if(!client.connected()){
currentLine = "exit";
break;
}
if(client.available()){
// 受信した文字がある場合
char c = client.read();
if(c == '\n'){
break;
}
if(c != '\r'){
currentLine += c;
}
}
}
// もし入力文字列が"exit"だったら終了
if(currentLine=="exit"){
client.stop();
break;
}else{
// クライアントに『オウム返し』
client.println(currentLine);
Serial.println(currentLine);
}
}
}
}
まず、新たに接続したクライアントを server.available()関数で確認・取得して、if(client) でクライアントがあった場合の処理を行う点は前回の HelloWorldサーバと同じです。
また、HelloWorldサーバとは異なり、今回は一方的にメッセージを送信してすぐ終了ではなく、クライアントとサーバの間で繰り返しやりとりをする想定なので、while(true) でループを作っています。
一行入力
// 1行入力
String currentLine = "";
while(true){
if(!client.connected()){
currentLine = "exit";
break;
}
if(client.available()){
// 受信した文字がある場合
char c = client.read();
if(c == '\n'){
break;
}
if(c != '\r'){
currentLine += c;
}
}
}
『オウム返し』するためにはクライアントから送信されてきた文字列を1行単位でまとめて取得する必要があります。しかしクライアントから送られてくるデータが読もうとした時点で1行分以上到着しているとは限りません。そこで『1文字ずつ取得して文字列として連結する』という作業を繰り返し『改行記号が来たら終了』という処理を行っています。
まず、
// 1行入力
String currentLine = "";
あたらしい行の入力を開始するために、受信文字列を表すString型の変数 currentLine を宣言・クリアします。
while(true){
if(!client.connected()){
currentLine = "exit";
break;
}
if(client.available()){
// 受信した文字がある場合
char c = client.read();
if(c == '\n'){
break;
}
if(c != '\r'){
currentLine += c;
}
}
}
『1文字ずつ受信して文字列として連結する』の基本的な部分はこの通りです。
基本部分だけ抜き出して書くと以下のようになります。
while(true){
if(client.available()){
char c = client.read();
currentLine += c;
}
}
まず、クライアントから送信されてきたデータがあるかどうかをチェックするのが WiFiClient.available()関数です。
書式
int WiFiClient.available()
返却値
ストリームで利用可能なバイト数(クライアントから送信されてきたデータのうち、未読のもののバイト数)を整数値で返します。
C/C++の場合、条件式の代わりに結果が数値となる式を使用すると『0ならfalse、0以外ならtrue』と読み替えますので、今回のプログラム例にある『 if(client.available()) 』という部分は『まだ読んでいない受信データがあれば』という判断をしていることになります。
受信データが残っている場合、残っているうちの最初の1バイトを読み込みます。
読み込みには WiFiClient.read()関数を使用します。
書式
int WiFiClient.read()
返却値
クライアントから送信されてきたデータを1バイト取得して返します(0~255)。取得に失敗した場合は負の値を返します。
今回のプログラム例では、コードをできるだけ簡単にするためclient.read()関数が負値を返した場合の処理は記述していません。
そして、『currentLine += c』で、文字列変数 currentLine の末尾に文字 c を追加しています。
これを無限に繰り返します。
改行コードの扱い
ここまでだと一行入力どころか永久に入力待ちを続けてしまいますので、『改行コード』を受信したらそこまでの入力内容を『一行』として処理を終了しなければなりません。そのための処理を考えてみましょう。
さて、一口に『改行コード』といっても、実はコンピュータデータで『改行』を表すコードは主なものだけで3種類あります。深く突っ込み始めるとそれだけで長文の記事になりそうな話なので要点だけまとめると下の表のとおりです。
名称 | 16進表記 | エスケープ文字 | 使われる環境 |
---|---|---|---|
CR(改行) | 0x0D | \r | ver.9以前のMac OSなど |
LF(復帰) | 0x0A | \n | UNIX、Linux、X以降のmacOSなど |
CR(改行) LF(復帰) | 0x0D 0x0A | \r\n | MS-DOS、Windowsなど |
コードをできるだけ簡単にするため、この3種のうち今後使用頻度が減りそうな『CR(改行)だけ』というコードは対応を諦めることにしました。そうすると、
- 『LF(復帰)』=『\n』が来たら1行終了
- 『CR(改行)』=『\r』は読み飛ばす
という単純な処理で下2種の改行コードに対応可能です。該当するコードは以下の部分です。
while(true){
if(!client.connected()){
currentLine = "exit";
break;
}
if(client.available()){
// 受信した文字がある場合
char c = client.read();
if(c == '\n'){
break;
}
if(c != '\r'){
currentLine += c;
}
}
}
読み込んだ1文字 c が ‘\n’(LF)だった場合、breakで一行入力のループを脱出します。その時点での文字列変数currentLineの内容が、入力された『一行』になります(つまり『一行』の末尾には改行コードは付加されません)。
読み込んだ1文字 c が ‘\r’(CR)だった場合、単に読み飛ばします。’\r’以外だった場合のみ、文字列変数currentLine に文字 c を連結します。
意図しない切断時の処理
if(!client.connected()){
currentLine = "exit";
break;
}
最後にこの部分ですが、これはクライアントとの接続が維持されているかをチェックしています。チェックには WiFiClient.connected()関数を使用しています。
書式
uint8_t WiFiClient.connected()
返却値
値 | 意味 |
---|---|
0 | クライアントとの接続が切断された |
0以外 | クライアントとの接続が保たれている |
1行入力は無限ループになっているので、もしその間にクライアントとの接続が切断してしまった場合にはループを脱出します。このとき、”exit”が入力されたものとすることによりサーバ側でもクライアントとの切断処理を行うようにしてあります。
C/C++だとJavaのような例外のスローができないので、エラー処理をスマートに書くのが難しいです…。
ネット上でみかけるコード例には、WiFiClient.connected()の判定をするタイミングが不適切で、通信中にtelnetクライアントを閉じてしまうとサーバが無限ループから脱出できなくなるバグのあるものが多く見られます。
1行入力が終わった後の処理
// もし入力文字列が"exit"だったら終了
if(currentLine=="exit"){
client.stop();
break;
}else{
// クライアントに『オウム返し』
client.println(currentLine);
Serial.println(currentLine);
}
一行入力のwhile(true)ループを抜けた後の処理です。この時点で文字列変数currentLineには読み込んだ一行(またはクライアントと切断してしまった場合に”exit”)が入っています。なお『改行コードまでが一行』と定義しましたがcurrentLine変数の末尾には改行コードを含んでいません。
もしcurrentLineが “exit” の場合は、クライアントとの切断処理をします。クライアントとの切断処理には WiFiClient.stop()関数を使用します。
書式
void WiFiClient.stop()
この関数には引数も返却値もありません。
クライアントとの切断後はbreakでループを脱出します。
currentLineが “exit” 以外の場合は、クライアントにその文字列を WiFiClient.println()関数で送信します(ここが『オウム返し』)。また動作確認用に同じ文字列をSerial.println()関数でシリアル出力します。出力後はループを継続し、1行入力待ちに戻ります。
実行
コマンドプロンプトのtelnetコマンドや、TeraTermなどのターミナルソフトウェアを使用して動作確認が出来ます。
telnetコマンドを使う場合
Windowsではじめてtelnetコマンドを使う場合、コマンドの有効化を行う必要があります。以下の記事を参考にしてtelnetコマンドを有効化しておいてください。
1.サーバを起動する
ESP32 をPCに接続し、Arduino IDEでシリアルモニタを表示した状態でプログラムを実行すると、自動的に無線LANに接続してサーバとしての動作を開始します。
このプログラムではIPアドレスは固定(掲載のソースコードのままなら 192.168.0.101)ですが、シリアルモニタにIPアドレスが表示されるのを確認することがサーバが動作開始したことの目印になります。
2.telnetでESP32に接続する
telnetクライアントを用いて、ESP32のIPアドレスに接続します。
この画面写真では、Windowsのコマンドプロンプトから telnet コマンドを使用しています。telnetコマンドは、
telnet 接続先のIPアドレス [ポート番号]
の書式で使用します。ポート番号の指定はオプションで、今回は使用していません(ESP32のサーバプログラム側でtelnetコマンドのデフォルト使用ポートで待ち受けするようになっているため)。
『telnet 192.168.0.101』と入力し、Enterキーを押します(プログラムでサーバのIPアドレスを変更した場合はコマンドもそれに合わせて変更してください)。
3.telnetのコマンドモードに入る
telnetコマンドでArduinoに接続すると一度画面がクリアされるので、キーボードのctrlキーと]キーを同時に押します。すると上の画面写真のようなメッセージが表示され、コマンドを入力できるようになります。
4.ローカルエコーを設定する
『set localecho』と入力し、Enterキーを押します。上の写真のように『ローカルエコー:オン』と表示されれば設定完了です。
この設定は保存されないので、面倒ですがtelnetコマンドを使用するたびにやりなおさなければなりません。
5.オウム返し動作開始
もう一度Enterキーを押すと画面がクリアされます。そのあとは好きな言葉を打ち込んでみてください。 Enterキーを押すたびに、それまで入力した言葉が表示されます。
“exit”と入力すると通信が切断され、コマンドプロンプトに戻ります。
TeraTermの場合
ターミナルエミュレータの定番、TeraTermでも実験することができます。ただTeraTermは初期設定ではサーバとの通信が切断されると自動的にウィンドウを閉じてしまうため『Hello,World!』というメッセージを確認できません。そこで、あらかじめウィンドウを閉じないように設定を変更します。
※TeraTermをお持ちでない場合は、窓の杜などでダウンロード&インストールしてください。
1.『新しい接続』のウィンドウをいったん閉じる
TeraTermを起動すると自動的に『新しい接続』ダイアログが開くので、『キャンセル』をクリックしていったんダイアログを閉じます。
2.『端末の設定』ダイアログ
メニューの『設定』→『端末』を選択します。
『端末の設定』ダイアログが開くので、
①改行コードを『受信』『送信』ともに『LF』にします。
②『ローカルエコー』にチェックを入れます。
③『OK』ボタンをクリックします。
3.『TCP/IP設定』ダイアログ
メニューの『設定』→『TCP/IP』を選択します。
『TCP/IP設定』ダイアログが開くので、
①『自動的にウィンドウを閉じる』のチェックを外します。
②『OK』ボタンをクリックします。
4.ESP32に接続
メニューの『ファイル』→『新しい接続』を選択します。
『新しい接続』ダイアログが開くので、
①TCP/IPを選択します。
②ESP32に割り当てられたIPアドレス(ソースコードそのままなら192.168.0.101)を入力します。
③『サービス』で『Telnet』を選択します。
④『OK』ボタンをクリックします。
5.オウム返し動作開始
そのあとは好きな言葉を打ち込んでみてください。 Enterキーを押すたびに、それまで入力した言葉が表示されます。
“exit”と入力すると通信が切断されます。
まとめ
- クライアントとの通信には、WiFiClientクラスを使用すると簡単
- WiFi.config()関数を使用すると、サーバのIPアドレスを指定できる
コメント