1.今回の所要機能
Wi-Fiによる次のようなコミュニケーション機能を持たせることにします。
①DNS機能
マルチキャストDNS機能を利用してホスト名でアクセスできるようにする。
②指示機能
ブラウザーからコマンドで計測データの取得方法を指示できる機能。
③応答機能
ブラウザーからの指示を切り分けて応答する機能。
④抽出機能
計測記録から指定された条件のデータを抽出する機能。
⑤編集送信機能
SDカード上のファイルから計測データを読み込んで編集し、送信する機能。
2.ソフト開発のポイント
(1)DNS機能
ESP-WROOM-02はマルチキャストDNSを利用することができます。マルチキャストDNSとは、ローカルネットワーク内の機器を、DNSサーバを介さずに名前解決をする仕組みです。この機能を利用することで、クライアントはサーバーを、IPアドレスの代わりにホスト名でアクセスできるようになります。
ところで、このプロジェクトで完成するシステムは何となく大気観測っぽい出来上がりになりそうなので、AirMonitorと命名することにします。そしてその略称「airmon」をホスト名にしてみましょう。
指定はとても簡単で、ヘッダーファイルESP8266mDNS.hをインクルードして、初期化処理で次のようにホスト名を宣言するだけです。
#include <ESP8266mDNS.h>
void setup() {
// Set local DNS
MDNS.begin("airmon");
}
(2)コマンド入力画面と指示
ブラウザーからサーバーへの接続は、次のようにアドレス入力欄にサーバーアドレスまたはホスト名を入力して[Enter]キーをたたきます。
http://192.168.x.xxx[Enter]
http://airmon.local[Enter]
サーバーとの接続が完了するのを待って、ブラウザーに次のようなコマンド入力画面を表示します。入力欄(→の右のテキストボックス)にコマンドを入力してサーバーに指示を出し、サーバーはクライアントからのコマンド入力を待ちます。
コマンドの書式は画面のとおりですが、例えば、nowと入力して[Enter]キーをたたくか[実行]ボタンをクリックすることで、「その時点の計測値を表示する」よう指示できます。レコードのファイル上の位置を指定したり、日付を指定する場合は次のように入力します。
・ファイル先頭からすべてのレコードを表示させる
rec=1
・ファイルの21レコード目からすべてのレコードを表示させる
rec=21
・ファイルの21レコード目から10レコードだけ表示させる
rec=21,10
・日付が2017年2月25日のレコードを表示させる
date=17/02/25
・日付が2017年2月25日~26日のレコードを表示させる
date=17/02/25,17/02/26
ところで、Webサーバーでは、クライアントとサーバーの間はHTTP(HyperText Transfer Protocol)という通信規約に従ってやり取りが行われるので、HTTPについての知識が必要になります。
下図は別のコーナーからの引用ですが、サーバーとDB(データベース)の部分がESP-WROOM-02と考えてください。図のように、HTTPではクライアントからサーバーに要求(HTTPリクエスト)を送り、サーバーがクライアントに応答(HTTPレスポンス)を返す方法で通信します。これを利用して、Webページを構成するHTMLやスタイルシート、スクリプトといったファイルのやりとりや、画像・音声・動画などのデータファイルを送受信することができるわけです。重要なのはリクエストに対してレスポンスを返す仕組みになっているということです。もう少し詳しいことは『Webアプリケーションの基礎知識』を参考にしてください。
ここではESP8266の標準ライブラリーである「ESP8266WebServer」を利用してスケッチを作成します。
今回のメインとなる処理は、ブラウザーからのリクエストに応じてコマンド入力画面を表示し、コマンドを受けて解析し、一連の処理を行って応答する部分です。以下に示すように、ブラウザーとの通信部分はとても簡単に記述できます。
1~2行: Wi-Fi・Webサーバーを作成するために、ヘッダーファイルWiFiClient.hとESP8266WebServer.hを
インクルードします。
4行目: 3種類の処理モード(即時計測処理、レコード番号によるデータ抽出、日付によるデータ抽出)の識別
を定義しています。
14行目: WiFiServerのインスタンスを作成します。HTTPの標準ポート番号「80」を指定しています。
16行目: 続く行でレスポンスヘッダーやHTMLヘッダー、タイトルなどを定義しています。本来はレスポンス
ヘッダーの定義は不要なのですが、大量データの送信ロジックをコーディングするためにこのように
しています。リクエストヘッダー(strHeader)は、計測結果や検索結果の送信(表示)で使用します。
ここで記述している「method='get'」に注目してください。
27行目: これまでと同様に、ssidとpasswordを指定してWiFiを開始します。
40~41行: 標準ライブラリー「ESP8266WebServer」を利用する上で、もっとも特徴的なコードです。
クライアント(ブラウザー)から要求があった場合に動作させたいメソッド、つまりコールバック関数
を指定します。HTTP_GETを受信するとコマンド入力画面送信メソッドのsendCommandScreenを、
HTTP_POSTを受信するとコマンド解析メソッドprocAnalyzeCommandを呼び出すように指定して
います。クライアント側の計測結果や検索結果画面から呼び出されると、先のリクエストヘッダーの
「method='get'」によってHTTP_GETを受信することでコマンド入力画面を表示しています。
45行目: loop()内では、クライアントからの接続要求待ちを指定するだけです。この指定により、接続要求が
あれば該当するコールバック関数が呼び出されます。
52行目: コールバック関数のコマンド入力画面送信メソッドsendCommandScreenです。
54行目: コマンド入力画面のHTML文を記述しています。画面デザインのためのスタイルシート、画面制御
のためのJavaScriptがHTMLの構成要素と共に詰め込まれています。[実行]ボタンがクリックさ
れたら現在のウィンドウ上でコマンド解析メソッドprocAnalyzeCommandを実行させるために、
form文で「target='_self' method='post'」と指定しています。
72行目: ESP8266WebServerのsendメソッドで、クライアント宛にコマンド入力画面のHTMLを送信して
います。第1引数の200は「OK」を示すステータスコード、第2引数はコンテンツ種別です。
78行目: コールバック関数のコマンド解析メソッドprocAnalyzeCommandです。
82行目: testProcessModeメソッドで入力されたコマンドを解析します。Serial.~はすべてデバッグ用で
す。86行目以下の部分で、コマンド種別に対応した抽出・送信メソッドを実行させています。
115行目: コマンド解析メソッドです。解析の詳細についての説明は割愛しますが、String.indexOf()を
使ってキーワードを判定し、それぞれの処理モードに対応した変数に抽出条件の値を設定しています。
184行目: メソッドの呼び出し元に解析結果の処理モードを通知します。
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
// Process mode
#define MODE_CURRENT 1 // Response current values
#define MODE_RECORDS 2 // Send records from SD limited the range of record position
#define MODE_DATE 3 // Send records from SD limited the range of date or date-time
// WiFi connection
const char *ssid = "your_ssid";
const char *password = "your_password";
// WiFi response control
ESP8266WebServer server(80);
// WiFi Response constants
const String strResponseHeader = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
const String strHeader = "<!DOCTYPE html>\r\n"
"<html><head><meta charset=\"utf-8\"><title>AirMonitor</title>"
"<style>body{line-height:120%;font-family:monospace;}</style>"
"</head><body><form name='resultform' target='_self' method='get'>";
const String strFooter = " <INPUT type='submit' value=' 戻 る ' autofocus /></form></body></html>";
const String strTitle = "No., Temp(C), Press(hPa), Hum(%), Illum(lx), Date Time";
void setup() {
// Prepare WiFi system.
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
server.begin();
Serial.print("\nServer started! IP: ");
Serial.println(WiFi.localIP());
// Set local DNS
MDNS.begin("airmon");
// Set server callback functions
server.on("/", HTTP_GET, sendCommandScreen);
server.on("/", HTTP_POST, procAnalyzeCommand);
}
void loop() {
server.handleClient();
}
/************************< Server callback functions >************************/
/*
* Display initial screen.
*/
void sendCommandScreen()
{
String strBuf = "<!DOCTYPE html><html><head><meta charset=\'utf-8\'><title>AirMonitor Parameter setting</title>"
"<style>body{line-height:150%;font-family:monospace;}"
"table.tbl{font-size:13px;color:#0;margin-left:20px;border-collapse: collapse;}table.tbl tr {border: 1px solid #888888;text-align: left;}table.tbl td {border: 1px solid #888888;text-align: left;}table.tbl tr.ttl {background-color:#e6e6fa;}div{color:red;}</style>"
"<script>function setCursor() {var obj = document.getElementsByName('COMMAND')[0];obj.focus();obj.value+= '';}"
"function checkForm(){var data=document.getElementsByName('COMMAND')[0];if(data.value.trim()=='')return false; else return true;}</script></head>"
"<body onload='setCursor();'><form name='mainform' target='_self' method='post' onsubmit='return checkForm();'><br>"
"<input type='hidden' name='proc_kind' value='command' />"
"<table class='tbl'><caption><font color='blue' size='+1'><b> 【コマンド入力】</b></font><br></caption>"
"<tr class='ttl'><td width='220'><b> コマンド</b></td><td width='420'><b> 機能・動作</b></td></tr>"
"<tr><td> now</td><td> 現在の計測値を表示する</td></tr>"
"<tr><td> date={date}</td><td> 指定日付の計測データをすべて表示する</td></tr>"
"<tr><td> date={from_date},{to_date}</td><td> 日付範囲のデータを表示する</td></tr>"
"<tr><td> rec={position}</td><td> 指定レコード位置からファイル終端までのデータを表示する</td></tr>"
"<tr><td> rec={position},{records}</td><td> 指定レコード位置から指定したレコード数だけ表示する</td></tr></table>"
"<table border='0'><tr><td width='20'> </td><td width='220'>→ <input type='text' name='COMMAND' maxlength='25' value='' autofocus' /></td>"
"<td><input type='submit' name='SUBMIT' value=' 実 行 ' /></td></tr></table><br>"
"<font color='darkcyan'> (注記)<br> =記号の右辺には{ }の内容を指定します。<br> dateは年月日をyy/mm/ddのように、positionとrecordsは数字を、それぞれ半角で指定してください。</font>"
"</form></body></html>";
server.send(200, "text/html", strBuf);
}
/*
* Analyze command and control execution.
*/
void procAnalyzeCommand()
{
String cmd = server.arg("COMMAND");
Serial.print("cmd: "); Serial.println(cmd);
int iMode = testProcessMode(cmd);
Serial.print("iMode: "); Serial.println(iMode);
switch (iMode) {
case MODE_CURRENT:
Serial.println("<<Mode: MODE_CURRENT>>");
doMeasurement();
sendMeasuredResult(rstMeasured);
break;
case MODE_DATE:
Serial.println("<<Mode: MODE_DATE>>");
sendDataRecord(LOG_FILE, strFromDate, strToDate);
break;
case MODE_RECORDS:
Serial.println("<<Mode: MODE_RECORD>>");
sendDataRecord(LOG_FILE, iRecPos, iRecNo);
break;
case 0:
Serial.println("<<Mode: == non == >>");
break;
case -1:
Serial.println("<<Command error!>>");
sendFormatError();
break;
}
}
/*****************************************************************************/
/*
* Analyze HTTP request parameter and decide process mode.
* Argument: (String)Command string.
* Return: Process mode(MODE_CURRENT / MODE_DATE / MODE_RECORDS / -1(Error))
*/
int testProcessMode(String strParam)
{
//strParam.trim();
int iPos, iPos2;
// Analyze the process
int iMode = 0;
if (strParam.indexOf("now") != -1) {
iMode = MODE_CURRENT;
Serial.println("Mode: Current data.");
}
else if ((iPos = strParam.indexOf("date=")) != -1) {
iMode = MODE_DATE;
strParam = strParam.substring(iPos);
Serial.print("Param: ");
Serial.println(strParam);
if ((iPos2 = strParam.indexOf(",")) == -1) {
strFromDate = strParam.substring(5);
strToDate = strFromDate;
} else {
strFromDate = strParam.substring(5, iPos2);
strToDate = strParam.substring(iPos2 + 1);
}
Serial.print(" From:");
Serial.print(strFromDate);
Serial.print(", To:");
Serial.println(strToDate);
if (!(strFromDate.length() == 8 && strToDate.length() == 8))
return -1;
if (!(strFromDate.charAt(2) == '/' && strFromDate.charAt(5) == '/'
&& strToDate.charAt(2) == '/' && strToDate.charAt(5) == '/'))
return -1;
if (!(isNumeric(strFromDate.substring(0, 2))
&& isNumeric(strFromDate.substring(3, 5))
&& isNumeric(strFromDate.substring(6, 8))
&& isNumeric(strToDate.substring(0, 2))
&& isNumeric(strToDate.substring(3, 5))
&& isNumeric(strToDate.substring(6, 8))))
return -1;
strFromDate = "20" + strFromDate;
strToDate = "20" + strToDate;
}
else if ((iPos = strParam.indexOf("rec=")) != -1) {
iMode = MODE_RECORDS;
strParam = strParam.substring(iPos);
Serial.print("Param: "); Serial.println(strParam);
if ((iPos2 = strParam.indexOf(",")) == -1) {
if (!(isNumeric(strParam.substring(4)) && strParam.substring(4).length() > 0)) {
return -1;
}
iRecPos = (strParam.substring(4)).toInt();
iRecNo = 10000;
} else {
if (!(isNumeric(strParam.substring(iPos + 4, iPos2))
&& isNumeric(strParam.substring(iPos2 + 1))
&& strParam.substring(iPos + 4, iPos2).length() > 0
&& strParam.substring(iPos2 + 1).length() > 0)) {
return -1;
}
iRecPos = (strParam.substring(iPos + 4, iPos2)).toInt();
iRecNo = (strParam.substring(iPos2 + 1)).toInt();
}
if (iRecPos < 1)
iRecPos = 1;
if (iRecNo > 10000)
iRecNo = 10000;
}
else
iMode = -1;
return iMode;
}
(3)測定・応答処理
現時点の計測値を要求された場合は、次の2つのメソッドで処理しています。
doMeasurement
sendMeasuredResult
doMeasurementは計測処理を行うべきロジックですが、今回はMeasuredResult型の構造体に適当なダミーの測定値を設定しています。sendMeasuredResultは以下のように、測定結果をeditMeasuredResultメソッドでCSV形式に編集して、タイトル(項目見出し)と共にクライアント宛に送信しています。送信にはESP8266WebServerライブラリーのsendメソッドを使用しています。第1引数は「OK」を示すステータスコード、第2引数はコンテンツ種別です。
/*
* Send HTTP response <MODE_CURRENT: Real time measured result>
* Argument: (struct)Measured result.
*/
void sendMeasuredResult(MeasuredResult data)
{
char num[20];
String strBuf = strHeader + strTitle + "<br>";
String number = printNum(num, 0, 4);
strBuf += number + ", " + (editMeasuredResult(data)) + "<br></body></html>";
strBuf.replace(" ", " ");
server.send(200, "text/html", strBuf);
}
/*
* Measure and calibrate BME280, also illuminance
* Result stored into rstMeasured structure.
*/
void doMeasurement()
{
/* DUMMY PROCESS */
//Store into measured resut structure
rstMeasured.temperature = 23.4;
rstMeasured.pressure = 1013.0;
rstMeasured.humidity = 45.8;
rstMeasured.illuminance = 1250;
rstMeasured.soil_moisture = 256;
rstMeasured.datetime = "2017/01/30 13:00:00";
}
(4)測定値データ抽出・応答処理
日付とレコード位置を指定しての抽出・送信処理はそれぞれ独立したメソッドを記述しますが、どちらもsendDataRecordという同じ名前にしています。C++言語の多態性(ポリモーフィズム)の利用です。同じメソッド名でも、引数の違いを判定して適切な(つまり対応する)メソッドが実行されます。
void sendDataRecord(String strName, int iRecPos, int iRecords)
void sendDataRecord(String strName, String strFrom, String strTo)
以下では、レコード位置を指定して抽出・送信を行うsendDataRecord(String strName, int iRecPos, int iRecords)だけを簡単に説明しておきます。
11~12行 レスポンスヘッダー、HTMLヘッダー、項目見出しを連結してESP8266WebServerライブラリーの
sendContentメソッドで送信しています。部分的な内容を送信するので、このように、先頭に自前の
レスポンスヘッダーを付けています。
18~19行 『Ⅳ.データ記録とアナログ入力』で作成したreadlnメソッドを使って、計測データファイルから抽
出先頭レコードまで読み飛ばしています。
38,39,41行 行番号と共に読み込んだレコードを結合して、50件分をまとめてserver.sendContentメソッドで
送信しています。細切れ送信による遅延を抑えるのがねらいです。
52~53行 未送信の内容にHTMLページの最終部分を結合して、sendContentメソッドで送信しています。
/*
* Send HTTP response <MODE_RECORDS: Data record>
* Argument: (String)File name,
* (int)Target record position, (int)How many records.
*/
void sendDataRecord(String strName, int iRecPos, int iRecords)
{
char buf[256], num[20];
// Send title with a standard http response header
String strBuf = strResponseHeader + strHeader + strTitle + "<br>";
server.sendContent(strBuf);
// Open data file
File fc = SD.open(strName, FILE_READ);
// Skip records
bool bNodata = false;
for (int i=1; i<iRecPos; i++) {
int len = readln(&fc, buf, 250);
if (len < 1) {
bNodata = true;
break;
}
}
if (bNodata) {
server.sendContent("Nothing!<br>" + strFooter);
return;
}
// Read and transfer each record until not EOF or condition is true
int iCnt = iRecPos;
int n = 0;
strBuf = "";
while (fc.available())
{
int len = readln(&fc, buf, 250);
String number = printNum(num, iCnt++, 4);
strBuf += number + ", " + buf + "<br>";
if (n % 50 == 0) {
strBuf.replace(" ", " ");
server.sendContent(strBuf);
strBuf = "";
}
if (++n >= iRecords)
break;
}
// Close file and sned trailer
fc.close();
if (n < 1)
strBuf = "Nothing!";
strBuf.replace(" ", " ");
strBuf += "\r\n" + strFooter;
server.sendContent(strBuf);
}
3.スケッチの実行
(1)実行結果と評価
シリアルモニターを開いた状態で実行を開始。今回はもっぱらブラウザーを使ってテストします。ブラウザーから「http://airmon.local」でサーバと接続し、コマンド入力画面が表示されたら「now」と入力して[Enter]をたたくか[実行]ボタンをクリックします。すると現在の計測値(先に述べたようにダミーの測定値です)を表示します。
検索結果からコマンド画面への切り替えはブラウザの「←戻る」ボタンを使いたいところですが、ブラウザの種類によっては機能しないものがあるため[戻る]ボタンを設置しています。下記のOSとブラウザの組み合わせでテストしましたが、iOS(iPad)のGoogleChromeではブラウザの「←戻る」ボタンが正常に動作しません。また自動フォーカスの設定などで微妙に動作にばらつきがあります。
○Windows 10のGoogleChrome Version 57.0.2987.98 (64-bit)
○Windows 10IE11 Version 11.0.9600.18537
○Windows 7のGoogleChrome Version 57.0.2987.98 (64-bit)
・Windows 7のIE11 Version 11.0.9600.18537
×iOS(iPad)のGoogleChrome
○iOS(iPad)のSafari
○のものが動作としては良好です。なお、ブラウザの「←戻る」ボタンをクリックした場合は元のキー入力の内容が表示されますが、[戻る]ボタンをクリックするとコマンド入力画面を再送信するため入力内容は空になります。
ブラウザの違いへの対応はコードが煩雑になるので、今回は支障のない範囲にとどめています。
次に計測記録から日付でデータを抽出してみます。日付範囲を「date=17/02/25,17/02/26」と指定すると、次のような抽出結果が表示されます。
続いて「rec=72,20」と指定して、No.72のレコード位置から20件を抽出してみます。
これらの操作の経緯は、シリアルモニター上に下図のように表示されています。
以上のように、ブラウザーからAirMonitorとコミュニケーションするためのインターフェイスが出来上がりました。他にもあれこれ追加してみたいコマンドがありますが、これでひと区切りとします。
テスト中に気づいたことは、マルチキャストDNSの名前解決にかなり時間がかかることです。IPアドレスで接続すると結果がすぐに表示されるのですが、サーバー名を指定すると少々待たされます。
(2)スケッチ全景