2017/6/6

以ESP8266存取IoT數據資料庫

依照前兩個案例: ESP8266AT命令建立HTTP服務」與「ESP8266為基礎的IoT控制中心--溫濕度感測數據傳輸, 我們可以很容易地衍生出許多其它運用, 例如家電開關、感測數據收集與服務等, 甚至我們之前討論過的「藍芽版」的家電控制中心遙控車遙控機器怪蟲等範例也都能簡單地改成「WiFi版本」.

不過以上都還只停留在LAN的範圍, 若我們要將控制範圍擴展到WAN, 那麼我們可能需要申請一個固定IP、架設伺服器以及能隨時供我們存取的IoT資料庫. 幸運的是, 這一切基礎建設都已經有人幫我們打理好了, 若我們存取的數據量不大還可以免費使用呢! ThingSpeak所提供的服務為例, 整個概念摘要如下圖所示.




IoT數據資料庫服務
若我們沒有申請固定IP也沒有自己架設的資料庫, 也仍然可以利用許多免費的網路資源以供我們使用(如本例中的監控器資料或任何其它控制數據的存取). 以筆者為例, 也申請一個許多Maker愛用的ThingSpeak所提供的服務(即以ThingSpeak為我們的MQTT server). 



使用者必須先對其所使用的每一種資料類別建立一個channel, 例如我們先為將來想存取的「溫濕度感測數據」建立一個DHT11channel名稱(使用者可以任意取不重複的名字). 接下來僅需定義資料(表單)欄位並按儲存即可. 本例中我們只需勾選兩個欄位, 以分別記錄濕度與溫度並為各自欄位取一個方便自己記憶/區別資料種類的名稱. (我們總不可能永遠記得field1是甚麼吧?) 



以上是ThingSpeak所提供的IoT資料庫存取方式法, 它會再分配各個channel一個mapping過的channel ID(一組6的數字)read/write API Key(一組16ASCII字元), 如此即可區分分散在全球許多不同數據存取的需求並提供對應的服務. 當然它並不安全就是了! 因為資料update只需提供一組16 ByteAPI Key, 萬一地球上的哪個傢伙不小心可能就寫到別人家裡去了?




ThingSpeak也提供視覺化資料工具, 其它一些資料統計分析的服務則不在此討論(須要付費). 在實作以ESP-01模組存取IoT資料庫之前, 筆者建議先用下面兩個簡單方法測試一下如何存取所申請的資料庫, 以熟悉背後運做的方式.



測試: URL API 存取 ThingSpeak
下面是直接透過URL API存取ThingSpeak資料庫的測試. 使用者可以在電腦或手機開啟網頁瀏覽器並於其URL欄中(依照ThingSpeak提供的範例)填入所申請登錄的channelAPI Key(請注意, readwrite有各自的API Key), 即可直接新增資料或查詢. 在資料查詢的部份, 其中包含channel/feeds的路徑每次只回傳單筆資料(依照資料建立的時間順序, 1.json表示第一筆建立的資料, 2.json表第二筆, 依此類推, last.json則為最後/最新建立的一筆資料), channel/fields的路徑則一次回傳多筆資料.





測試: JavaScript 存取 ThingSpeak
下面是包含兩個按鈕物件的HTML範例, 跟直接使用URL API不同的是, 我們在使用者按下按鈕時才透過JavaScript呼叫GET方法來存取ThingSpeak. 其中GET指令可以接受以JSON物件回傳的方式, ThingSpeak也接受使用者自訂的回呼函式(call back)來處理JSON物件.

透過下面的HTML範例(請填入所申請的channelAPI Key), 我們可以了解ThingSpeak如何將回傳的資料以JSON物件丟給我們自訂的匿名函式處理(以匿名函式的參數data傳入). 因為資料已經被打包成JSON物件, 所以使用者可以直接讀取需要的資料欄位也無需再進行任何的parsing動做

<html>
<head><title>Button Event</title></head>
<body>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
<script>
$(function(){
    // get the latest data from ThinkSpeak
    $("#getBtn1").click(function(){
        $.getJSON("https://api.thingspeak.com/channels/Channel/feeds/last.json?key=KEY", function(data) {
            $.each(data, function(key, value){
                $("div").append( "<p>" + key + ": " + value + "</p>");
            });
        });
    });

    // get the latest data (field1 and field2) from ThinkSpeak
    $("#getBtn2").click(function(){
       $.getJSON("https://api.thingspeak.com/channels/Channel/feeds/last.json?key=KEY", function(data) {
                $("div").append( "<p>" + data.created_at + "</p>");
                $("div").append( "<p>" + data.entry_id + "</p>");
                $("div").append( "<p>" + data.field1 + "</p>");
                $("div").append( "<p>" + data.field2 + "</p>");
        });
    });
});
</script>

<p><input type="button" value="GET URL: [key: value]" name="getBtn1" id="getBtn1"></p>
<p><input type="button" value="GET URL: [content]  " name="getBtn2" id="getBtn2"></p>
<div>===Response===</div>

</body>
</html>



根據我們在ThingSpeak中所建立channel的定義, data.created_at表示該筆資料被建立的時間, data.entry_id表示該筆資料依建立時間的排序, data.field1即為我們所定義的濕度資料, data.field2即為我們所定義的溫度資料. 回傳的資料我們以JavaScript值接轉成HTML貼回標示為<div>的標籤.





為系統增加一個 Switch 開關(硬體中斷)
為了避免製造太多IoT數據垃圾, 或是避免像筆者不小心把數據上傳間距設定太短又忘了Arduino一直接著USB電源, 結果一覺醒來收到每日免費數據使用限制的溫馨提醒(雖然不太容易超過). 我們可以把程式關於ESP-01IoT資料庫上傳數據的時間間距加大、限制上傳次數或是跟筆者一樣為系統增加一個「硬體中斷」(多了一個黃色按鈕的switch開關), 用來開始或暫停上傳溫濕度資料到IoT資料庫.





細部接線
整個系統接法基於前一個案例「ESP8266為基礎的IoT控制中心--溫濕度感測數據傳輸」再追加一個switch開關(當硬體中斷/致能), 而整個系統看起來如下圖. 其中我們將Pin7規劃成「掛上拉電阻型的輸入」並接到switch一段, 再將switch另一端接地即可. 如此, 當讀到該數位腳位值為「零」時則表按鍵被壓下(平常為pull-up). 筆者用到的switch有兩組輸出/, 可先用電表的「導通蜂鳴功能」量測一下哪兩點間可導通, 再選擇接其中一邊即可(另一排兩根pin空著即可).





Arduino程式
筆者的Arduino IDE開發環境是1.8.2 ZIP免安裝版. 程式部份我們基於前一個案例「ESP8266為基礎的IoT控制中心--溫濕度感測數據傳輸, 並針對網路連線與通訊部份將程式碼稍微優化與模組化. 本例將完全透過AT命令達成, 因此沒有引用其它WiFi函式庫.

為了實做「硬體中斷/致能」的功能, 我們須自己改寫delay的函式呼叫(稱之為setDelay), 其中我們除了使用millis呼叫來判斷時間是否到期之外, 我們還須持續監控switch按鍵是否被壓下. 因為筆者使用的switch是「彈簧式」的, 每次按壓會自動彈回來(沒有記憶), 所以程式必須宣告一個Boolean狀態變數來記錄目前按鍵狀態, 而判斷的函式每次觸發只要將值反態即可. 此外為了避免按鍵下壓過程發生彈跳的判讀, 建議增加約300msdebounce延遲(可根據自己的壓按鍵的習慣調整). 當然, 讀者可以改用閘刀式的開關就可以省去狀態變數的記憶.


bool swState = 0;   // switch to enable/disable humidity and temperature update
int  dataCnt = 0;   // times to access ThingSpeak IoT database

void setDelay(unsigned int ms) {
    unsigned long timeout = ms+millis();
    while( millis()<timeout ) {
        checkSwitchButton();
    }
}

bool checkSwitchButton() {
    if ( digitalRead(7)==LOW ) {
        swState = !swState;
        Serial.println("\nInterrupt: Switch Button = " + String(swState) );
        delay(300); // debounce delay (depending on your behavior)
    }
    return swState;
}



我們從新改寫sendATcmd函式, 將模擬給ESP-01的串列埠所接收到的信息抄到espMsg記憶區塊並可選擇性地決定是否將其輸出到串列埠監控窗. 如此, 我們可以在處理ESP-01緩衝區資料時能不干擾原本所接收到的訊息並將其輸出到串列埠監控窗. 請注意, 由於本例我們希望在上傳溫濕度偵測值至IoT資料庫之後也能順便對IoT資料庫提出讀取要求以檢驗數據內容是否一致. 然而, Arduino IDE開發環境預設的軟體模擬串列埠緩衝區大小只有64 Bytes , 我們可能須將它增加至128 Bytes (#define _SS_MAX_RX_BUFF 128) 否則傳回的內容將不完整. 請找到Arduino IDE開發下標頭檔SoftwareSerial.h, 例如以筆者的1.8.2版本為例, 其路徑在arduino-1.8.2\hardware\arduino\avr\libraries\SoftwareSerial\src.

// #define _SS_MAX_RX_BUFF 128 // software serial RX buffer size (default is 64 bytes)
SoftwareSerial ESP(3, 2); // ESP8266 ESP-01: Tx, Rx
String espMsg = "";

void sendATcmd(char* cmd, unsigned int msDelay,bool dbg) {
    String espMsg="";
    unsigned long timeout = millis()+msDelay;
    Serial.print(">>ESP-01: "+String(cmd)); // debug
    ESP.print(cmd); // send AT command to ESP-01 
    while ( ESP.available() || millis()<timeout ) {
        while ( ESP.available() ) {
            espMsg += (char)ESP.read();
        }
    }
    if ( dbg ) {
        Serial.print(String("<<ESP-01: ")+espMsg);
    }
}



關於WAN的網路連線功能, 我們將前面案例中提到連續呼叫ESP-01模組的幾個AT命令打包成一個函式呼叫. 本例是將ESP-01模組以client方式(設置成STA mode)先連線至家中已經連結WANAP, 而連線過程之中會有一些信息持續回應給ESP-01, 直到我們抓到AP傳來OK的回應時判定為連線成功. 其中SSIDPASSWORD須填上自己家中AP的連線設定.

bool wifiState = 0; // check wifi connection

void setup() {
    Serial.begin(115200);
    ESP.begin(115200);
    delay(1000);
       
    Serial.println("try to connect ESP-01 to WAN (through home AP) ...");
    if ( connectWAN(SSID,PASSWORD) ) { // join ESP-01 to WAN through your home AP
        wifiState = 1;
        Serial.println("press switch button to start ...");
    } else {
        Serial.println("fail to join WAN ...");
    }
}

bool connectWAN(char* ssidName, char* passWord) {
    int status = false;
    String joinCmd = "AT+CWJAP=\"" + String(ssidName) + "\",\"" + String(passWord) + "\"\r\n";
    sendATcmd("AT+RST\r\n",2000,DEBUG);   // reset ESP-01
    sendATcmd("AT+CWMODE=1\r\n",2000,DEBUG); // config ESP-01 as STA mode
    sendATcmd(joinCmd.c_str(),2000,false);   // wait the server to ack "OK"
    for (int loop=0; loop<10; loop++ ) {
        if ( ESP.find("OK") ) {
            status = true;
            Serial.println("join ESP-01 to WAN success ...");
            break;
        }
        setDelay(1000);
    }
    return status;
}



資料查詢(queryThingSpeak)與更新(updateThingSpeak)的部份也以AT命令打包成獨立的函式呼叫, 其中所需的channelread/wrote API Key等跟前面範例以URL API的存取方式相同. 請參考ThingSpeak並依照實際申請的channel查詢對應的URL資料存取範例.

// query data from ThingSpeak
void queryThingSpeak(char* ipName,char* channelName, char* apiKey) {
    String tcpCmd = "AT+CIPSTART=\"TCP\",\"" + String(ipName) + "\",80\r\n";
    String getCmd = "GET /channels/" + String(channelName) +
                 "/feeds/last.json?key=" + String(apiKey) + "\r\n";
    String cipCmd = "AT+CIPSEND=" + String(getCmd.length()) + "\r\n";
   
    sendATcmd(tcpCmd.c_str(),2000,DEBUG); // ask HTTP connection, and wait the server to ack "OK"
    sendATcmd(cipCmd.c_str(),2000,DEBUG); // start CIPSEND request, and wait the server to ack "OK"
    sendATcmd(getCmd.c_str(),2000,true);  // send GET command
}

// update data to ThingSpeak
void updateThingSpeak(char* ipName, char* apiKey,float humidity, float temperature) {
    String tcpCmd = "AT+CIPSTART=\"TCP\",\"" + String(ipName) + "\",80\r\n";
    String getCmd = "GET /update?key=" + String(apiKey) +
                 "&field1=" + String(humidity) +
                 "&field2=" + String(temperature)+ "\r\n";
    String cipCmd = "AT+CIPSEND=" + String(getCmd.length()) + "\r\n";
   
    sendATcmd(tcpCmd.c_str(),2000,DEBUG); // ask HTTP connection, and wait the server to ack "OK"
    sendATcmd(cipCmd.c_str(),2000,DEBUG); // start CIPSEND request, and wait the server to ack "OK"
    sendATcmd(getCmd.c_str(),2000,true);   // send GET command
}



所有完整程式碼整理如下, 於迴圈loop中我們等待wifiConnect成功設置並且在switch開關被致能之後才開始以每隔60秒將溫濕度監測資料上傳. 並且在每一筆資料上傳兩秒後再對IoT資料庫查詢最新一筆的資料回來, 以供我們直接於串列埠監控窗中比對一致性. 而在每次資料上傳之間, 我們都可以使用switch開關將整個運做中斷或重啟.

#include <SoftwareSerial.h>
#include "DHT.h" // package required from https://github.com/adafruit/DHT-sensor-library
// !NOTE! You may need to enlarge the serial RX buffer size to 128 for baud rate 115200bps, described in the
// include file arduino-1.8.2\hardware\arduino\avr\libraries\SoftwareSerial\src\SoftwareSerial.h, e.g.,
// #define _SS_MAX_RX_BUFF 128 // software serial RX buffer size (default is 64 bytes)
#define DEBUG 0

DHT dht(5, DHT11); // digital pin5 connected to the DHT11
SoftwareSerial ESP(3, 2); // ESP8266 ESP-01: Tx, Rx

String espMsg = "";
bool swState = 0; // switch to enable/disable hunidity and temperature update
bool wifiState = 0; // check wifi connection
int  dataCnt = 0; // times to access ThingSpeak IoT database

void setup() {
    pinMode(7, INPUT_PULLUP); // set pin7 as the switch button
   
    Serial.begin(115200);
    dht.begin();
    delay(1000); // Sensor readings may be up to 2 seconds
    Serial.println("DHT11 start ...");
   
    ESP.begin(115200);
    delay(1000);
       
    Serial.println("try to connect ESP-01 to WAN (through home AP) ...");
    if ( connectWAN( SSID, PASSWORD ) ) { // join ESP-01 to WAN through your home AP
        wifiState = 1;
        Serial.println("press switch button to start ...");
    } else {
        Serial.println("fail to join WAN ...");
    }
}

void sendATcmd(char* cmd, unsigned int msDelay,bool dbg) {
    String espMsg="";
    unsigned long timeout = millis()+msDelay;
    Serial.print(">>ESP-01: "+String(cmd)); // debug
    ESP.print(cmd); // send AT command to ESP-01 
    while ( ESP.available() || millis()<timeout ) {
        while ( ESP.available() ) {
            espMsg += (char)ESP.read();
        }
    }
    if ( dbg ) {
        Serial.print(String("<<ESP-01: ")+espMsg);
    }
}

bool connectWAN(char* ssidName, char* passWord) {
    int status = false;
    String joinCmd = "AT+CWJAP=\"" + String(ssidName) + "\",\"" + String(passWord) + "\"\r\n";
    sendATcmd("AT+RST\r\n",2000,DEBUG);      // reset ESP-01
    sendATcmd("AT+CWMODE=1\r\n",2000,DEBUG); // config ESP-01 as STA mode
    sendATcmd(joinCmd.c_str(),2000,false);   // wait the server to ack "OK"
    for (int loop=0; loop<10; loop++ ) {
        if ( ESP.find("OK") ) {
            status = true;
            Serial.println("join ESP-01 to WAN success ...");
            break;
        }
        setDelay(1000);
    }
    return status;
}

// query data from ThingSpeak
void queryThingSpeak(char* ipName,char* channelName, char* apiKey) {
    String tcpCmd = "AT+CIPSTART=\"TCP\",\"" + String(ipName) + "\",80\r\n";
    String getCmd = "GET /channels/" + String(channelName) +
                 "/feeds/last.json?key=" + String(apiKey) + "\r\n";
    String cipCmd = "AT+CIPSEND=" + String(getCmd.length()) + "\r\n";
   
    sendATcmd(tcpCmd.c_str(),2000,DEBUG); // ask HTTP connection, and wait the server to ack "OK"
    sendATcmd(cipCmd.c_str(),2000,DEBUG); // start CIPSEND request, and wait the server to ack "OK"
    sendATcmd(getCmd.c_str(),2000,true);  // send GET command
}

// update data to ThingSpeak
void updateThingSpeak(char* ipName, char* apiKey,float humidity, float temperature) {
    String tcpCmd = "AT+CIPSTART=\"TCP\",\"" + String(ipName) + "\",80\r\n";
    String getCmd = "GET /update?key=" + String(apiKey) +
                 "&field1=" + String(humidity) +
                 "&field2=" + String(temperature)+ "\r\n";
    String cipCmd = "AT+CIPSEND=" + String(getCmd.length()) + "\r\n";
   
    sendATcmd(tcpCmd.c_str(),2000,DEBUG); // ask HTTP connection, and wait the server to ack "OK"
    sendATcmd(cipCmd.c_str(),2000,DEBUG); // start CIPSEND request, and wait the server to ack "OK"
    sendATcmd(getCmd.c_str(),2000,true);  // send GET command
}

void setDelay(unsigned int ms) {
    unsigned long timeout = ms+millis();
    while( millis()<timeout ) {
        checkSwitchButton();
    }
}

bool checkSwitchButton() {
    if ( digitalRead(7)==LOW ) {
        swState = !swState;
        Serial.println("\nInterrupt: Switch Button = " + String(swState) );
        delay(300); // debounce delay (depending on your behavior)
    }
    return swState;
}

void loop() {
    if ( swState && wifiState && dataCnt<100 ) {
        Serial.println("update data to ThingSpeak: " + String(++dataCnt) );
        updateThingSpeak(
            "184.106.153.149",     // ThingSpeak IP
            Write_APIKEY,         // write API-KEY
            dht.readHumidity(),     // reading temperature or humidity takes about 250 milliseconds
            dht.readTemperature()  // read temperature as Celsius (the default)
        );
        setDelay(2000);
        Serial.println("query data from ThingSpeak ...");
        queryThingSpeak(
            "184.106.153.149",   // ThingSpeak IP
            ChannelID,          // Channel
            Read_APIKEY        // read API-KEY
        );
        Serial.println("press switch to stop (will start after one minute) ...");
        if ( swState )
          setDelay(60000); // update humidity and temperature per minute
    }
    setDelay(500); // monitor hardware interrupt
}



如圖所示, 整個ESP-01透過家中AP連結WAN並對ThingSpeak做資料存取的完整過程都可以直接顯示於串列埠監控窗中(像是兩個機器人在聊天一樣, 直到你打斷它們為止).




Maker一旦上了WAN之後, 他的思路就更天馬行空了. 筆者最近正不停地趕進度追隨全球Maker的腳步, 也才剛學會寫Alexa Skill, 相信我們在過不久之後也能把觸角帶到最近鬧得火紅的聲控語音助理之類的運用了!