2015年11月5日 星期四

使用 ITEAD 的 WeeESP8266 函式庫進行網路對時

這幾天晚飯後的空閒時間都在研究如何使用 Arduino+ESP8266 進行網路對時, 搞了老半天都徒勞無功, 不僅對傳輸層 UDP 的傳送接收霧煞煞, 應用層的 NTP 協定那更不用講了, 我對網路的了解太淺, 但要深入研究又太耗時間, 眼前我只不過是要利用 ESP8266 從網路取得精確的時間來對時而已.

今天找到 WeeESP8266 這個函式庫 (其實前幾天就搜尋過卻不記得了, 參閱 "撰寫 Arduino 的 ESP8266 WiFi 函式" 最底下的補充), 發現此函式庫非常完整, 乃軍工級精品, 我根本不需要花時間去自行撰寫 ESP8266 函式, 因為別人已造好輪子啦! 而且造輪技術遠遠在我之上, 毋須東施效顰也. 

 

WeeESP8266 函式庫可在 Git 下載 :



按右側欄底下的 "Download zip" 下載函式庫, 解壓縮後將整個目錄複製到 Arduino 安裝目錄的 libraries 下, 或文件的  Arduino/libraries 下亦可. 不過要注意的是, 如果像我一樣使用 Arduino Nano, 必須利用 SoftwareSerial 函式庫與 ESP8266 溝通的話, 務必將 ESP8266.h 檔案中第 27 列的最前面註解符號拿掉 (刪除兩個斜線) :

//#define ESP8266_USE_SOFTWARE_SERIAL

這樣才能編譯成功. 我已將函式庫的兩個主檔 ESP8266.h, ESP8266.cpp, 以及 NTP 的範例程式壓縮在一起放在 Dropbox :

# https://www.dropbox.com/s/5qq1424pc7iikl4/WeeESP8266.zip?dl=0 

裡面的 ESP8266.h 我已經啟動軟體序列埠設定, 所以不需要再去改了. Arduino 與 ESP01 模組的接線參考 :

撰寫 Arduino 的 ESP8266 WiFi 函式

我使用 ESP8266 轉換板簡化接線 :
使用轉換板

電路圖

利用 UDP 向 NTP 伺服器要求對時的範例程式如下 (我已加上備註, 並酌以更改程式撰寫風格), 測試 OK, 可以順利取得網路時間 :

#include "ESP8266.h"
#include <SoftwareSerial.h>
SoftwareSerial espSerial(10, 11); // (RX:D10, TX:D11)

#define HOST_NAME   "82.209.243.241"   //NTP 伺服器 IP
#define HOST_PORT   123  //NTP 埠號

ESP8266 wifi(espSerial);  //建立名為 wifi 的 ESP8266 物件

void setup() {
  Serial.begin(9600);  //設定硬體序列埠速率 (debug 用)
  Serial.print("setup begin\r\n");
  Serial.print("Join AP success\r\n");
  Serial.print("IP: ");
  Serial.println(wifi.getLocalIP().c_str()); //傳回本地 IP 的字元串
  if (wifi.disableMUX()) {Serial.print("single ok\r\n");} //關閉多重連線
  else {Serial.print("single err\r\n");}
  Serial.print("setup end\r\n");
  }
void loop() {
  ntpupdate();  //呼叫 NTP 對時函數
  delay(20000);
  }

void ntpupdate() {
  uint8_t buffer[128]={0};  //儲存 NTP 回應字元的緩衝器
  if (wifi.registerUDP(HOST_NAME, HOST_PORT)) { //註冊 UDP (CIPSTART)
    Serial.print("register udp ok\r\n");
    }
  else {Serial.print("register udp err\r\n");}
  //定義兩個程式記憶體變數 (Flash) 儲存要傳送的 UDP 資料 (0~3, 12~15)
  static const char PROGMEM timeReqA[]={227,0,6,236};  //E3,00,06,EC (前 4 Bytes)
  static const char PROGMEM timeReqB[]={49,78,49,52};  //31,4E,31,34 (索引 12~15)
  //製作要傳送給 NTP 伺服器的封包資料
  uint8_t buf[48];  //傳送與接收 UDP 封包的字元串緩衝器
  memset(buf, 0, sizeof(buf));  //全部先填入預設值 0
  //把存於 Flash 的陣列複製到字元串緩衝器 (0~3, 12~15)
  memcpy_P(buf, timeReqA, sizeof(timeReqA));
  memcpy_P(&buf[12], timeReqB, sizeof(timeReqB));
  //傳送資料給 NTP 伺服器
  wifi.send((const uint8_t*)buf, 48);
  //接收 NTP 伺服器回應至 buffer 緩衝器 (索引 43~40, timeout=1 秒)
  uint32_t len=wifi.recv(buffer, sizeof(buffer), 10000);
  if (len > 0) { //有收到 NTP 回應
    Serial.print("NTP time (s):");
    //計算 UNIX 開始到現在之毫秒數 (前面三個 byte 分別移位後做 OR 運算)
    unsigned long t=(((unsigned long)buffer[40] << 24) |
                     ((unsigned long)buffer[41] << 16) |
                     ((unsigned long)buffer[42] <<  8) |
                      (unsigned long)buffer[43]);
    Serial.println(t);
    Serial.print("UNIX time (s):");
    Serial.println(t - 2208988800UL);
    }
  //登出 UDP (CIPCLOSE)
  if (wifi.unregisterUDP()) {
    Serial.print("unregister udp ");
    Serial.println(" ok");
    }
  else {
    Serial.print("unregister udp ");
    Serial.println(" err");
    }
  }

此程式使用了 PROGMEM (程式記憶體, 即 Nano 的 ATMEL328P 處理器內置的 32KB Flash) 來儲存 NTP 伺服器的 IP 與傳回時間. 關於 PROGMEM 可參考官網或葉難關於 Arduino 記憶體的大作 :

https://www.arduino.cc/en/Reference/PROGMEM
# Arduino:關於記憶體之二三事

NTP 伺服器回應的是從 1900/01/01 以來至今的秒數, 放在回應字元的 byte 40 (MSB) ~43 (LSB), 因此 byte 40 要向左移 24 位元, byte 41 左移 16 位元, byte 42 左移 8 位元, byte 43 不用移, 這樣四個 byte 做 OR 後就組成 32 位元的長整數了, 也就是自 1900/1/1 以來的秒數.

此範例程式的輸出如下 :

setup begin
Join AP success
IP: 192.168.43.40
single ok
setup end
register udp err
NTP time (s):3655701527
UNIX time (s):1446712727
unregister udp  ok
register udp ok
NTP time (s):3655701548
UNIX time (s):1446712748
unregister udp  ok
register udp ok
NTP time (s):3655701569
UNIX time (s):1446712769
unregister udp  ok
register udp ok
NTP time (s):3655701590
UNIX time (s):1446712790
unregister udp  ok
register udp ok
NTP time (s):3655701610
UNIX time (s):1446712810
unregister udp  ok
......

如果要將 NTP 傳回的 UTC 時間戳記顯示為 "時:分:秒" 該怎麼做呢? 這就要用到餘數的觀念, 除以一天的秒數 86400 後的餘數, 就是小時的秒數, 再除以 一小時 3600 秒即得時數也. 除以一小時 3600 秒後的餘數, 即為分之秒數, 再除以 60 即得分; 同理除以 60 的餘數即為秒也, 程式如下 :

#include "ESP8266.h"
#include <SoftwareSerial.h>
SoftwareSerial espSerial(10, 11); // (RX:D10, TX:D11)

#define HOST_NAME   "82.209.243.241"   //NTP 伺服器 IP
#define HOST_PORT   123  //NTP 埠號

ESP8266 wifi(espSerial);  //建立名為 wifi 的 ESP8266 物件

void setup() {
  Serial.begin(9600);  //設定硬體序列埠速率 (debug 用)
  Serial.print("setup begin\r\n");
  Serial.print("Join AP success\r\n");
  Serial.print("IP: ");
  Serial.println(wifi.getLocalIP().c_str()); //傳回本地 IP 的字元串
  if (wifi.disableMUX()) {Serial.print("single ok\r\n");} //關閉多重連線
  else {Serial.print("single err\r\n");}
  Serial.print("setup end\r\n");
  }
void loop() {
  ntpupdate();  //呼叫 NTP 對時函數
  delay(20000);
  }

void ntpupdate() {
  uint8_t buffer[128]={0};  //儲存 NTP 回應字元的緩衝器
  if (wifi.registerUDP(HOST_NAME, HOST_PORT)) { //註冊 UDP (CIPSTART)
    Serial.print("register udp ok\r\n");
    }
  else {Serial.print("register udp err\r\n");}
  //定義兩個程式記憶體變數 (Flash) 儲存要傳送的 UDP 資料 (0~3, 12~15)
  static const char PROGMEM timeReqA[]={227,0,6,236};  //E3,00,06,EC (前 4 Bytes)
  static const char PROGMEM timeReqB[]={49,78,49,52};  //31,4E,31,34 (索引 12~15)
  //製作要傳送給 NTP 伺服器的封包資料
  uint8_t buf[48];  //傳送與接收 UDP 封包的字元串緩衝器
  memset(buf, 0, sizeof(buf));  //全部先填入預設值 0
  //把存於 Flash 的陣列複製到字元串緩衝器 (0~3, 12~15)
  memcpy_P(buf, timeReqA, sizeof(timeReqA));
  memcpy_P(&buf[12], timeReqB, sizeof(timeReqB));
  //傳送資料給 NTP 伺服器
  wifi.send((const uint8_t*)buf, 48);
  //接收 NTP 伺服器回應至 buffer 緩衝器 (索引 43~40, timeout=1 秒)
  uint32_t len=wifi.recv(buffer, sizeof(buffer), 10000);
  if (len > 0) { //有收到 NTP 回應
    //計算 UNIX 開始到現在之毫秒數 (後面三個 byte 分別移位後做 OR 運算)
    unsigned long t=(((unsigned long)buffer[40] << 24) |
                     ((unsigned long)buffer[41] << 16) |
                     ((unsigned long)buffer[42] <<  8) |
                      (unsigned long)buffer[43]) - 2208988800UL;
    //將 UTC 時間戳記轉換為 "時:分:秒"
    Serial.print("The UTC time is ");  //輸出 UTC 時間
    Serial.print((t % 86400L) / 3600); //時 (一天 86400 秒, 取餘數除 3600 為時)
    Serial.print(':');
    if (((t % 3600) / 60) < 10 ) {Serial.print('0');} //分 (小於 10 補 0)
    Serial.print((t  % 3600) / 60);  //分
    Serial.print(':');
    if ( (t % 60) < 10 ) {Serial.print('0');}  //秒 (小於 10 補 0)
    Serial.println(t % 60); //秒

    }
  //登出 UDP (CIPCLOSE)
  if (wifi.unregisterUDP()) {
    Serial.print("unregister udp ");
    Serial.println(" ok");
    }
  else {
    Serial.print("unregister udp ");
    Serial.println(" err");
    }
  }

其輸出為 :

setup begin
Join AP success
IP: 192.168.2.106
single ok
setup end
register udp ok
The UTC time is 15:49:56
unregister udp  ok
register udp ok
The UTC time is 15:50:16
unregister udp  ok
register udp ok
The UTC time is 15:50:37
unregister udp  ok
register udp ok
The UTC time is 15:50:58
unregister udp  ok
register udp ok
The UTC time is 15:51:19
unregister udp  ok

如果要顯示台灣的中原標準時間, 則 UTC 要加 8 小時, 上面程式 t 變數後面改成如下即可 :

    //將 CCT 時間戳記轉換為 "時:分:秒"
    Serial.print("The CCT time is ");  //輸出 CCT 時間 (UTC+8)
    if ((((t % 86400L) / 3600 + 8) % 24) < 10) {Serial.print('0');} //時 (小於 10 補 0)
    Serial.print(((t % 86400L) / 3600 + 8) % 24); //時 (一天 86400 秒, 取餘數除 3600 為時)
    Serial.print(':');
    if (((t % 3600) / 60) < 10 ) {Serial.print('0');} //分 (小於 10 補 0)
    Serial.print((t  % 3600) / 60);  //分
    Serial.print(':');
    if ( (t % 60) < 10 ) {Serial.print('0');}  //秒 (小於 10 補 0)
    Serial.println(t % 60); //秒

哈哈, 費了一周的功夫終於搞定網路對時的問題了, 開源的好處就是這樣, 高手貢獻的函式庫可以大大地節省我們學習以及開發的時間, 在此要向這些前輩致敬. 取之於網路, 用之於網路, 這就是我勤於撰寫實測紀錄的原因, 資訊的公開交流是文明進步的重要推手, 以前的科學家們還得透過寫信與同儕交換彼此的發現呢! 有網路真好.

2015-11-06 補充 :

此函式庫似乎只能在 0.9.2 版韌體上運行, 我用 0.9.5 版韌體的 ESP8266 跑, 結果在註冊 UDP 時開始失敗, 輸出如下 :

setup begin
Join AP success
IP: +CIFSR:STAIP,"0.0.0.0"
+CIFSR:STAMAC,"18:fe:34:f3:00:ae"
single ok
setup end
register udp err
unregister udp  err
register udp err
unregister udp  err
register udp err
unregister udp  err
register udp err
unregister udp  err
register udp err
unregister udp  err
.....

參考資料 :

# type: uint8_t, uint16_t, uint32_t, uint64_t
c_str()這個函數是做什麼用的
# Difference between memcpy_p and memcpy?
# memcpy
http://docs.iteadstudio.com/ITEADLIB_Arduino_WeeESP8266/index.html
http://phoenard.com/esp8266-connect-to-a-network/
PM2.5 空氣品質偵測與自動化控制器 使用Arduino UNO開發 (第一話) 數據記錄器篇

其他 ESP8266 WiFi 函式庫 :

ESP8266WiFi/examples/NTPClient/NTPClient.ino
sandeepmistry/esp8266-Arduino
ekstrand/ESP8266wifi

6 則留言 :

匿名 提到...

版主您好,我還是新手,使用esp8266這種WIFI功能的開發版時想到是否能直接利用網路取得時間,本以為是很容易做到的事,看完這篇發現其實還蠻複雜的,請問這樣的功能困難點在哪呢?

小狐狸事務所 提到...

其實耐心摸索並勤做實驗, 克服學習曲線後就會發現沒那麼難. 學習 ESP8266 需要對 TCP/IP 等規約有基本的了解, 以 NTP 時間同步來說還要懂第四層的 NTP 規約 (這也不難). 我部落格有紀錄所有我做過的實驗, 您可以拿來參考.

匿名 提到...

版主您好

我想請問一下,在這個程式碼中,為什麼不用去設定SSID跟PSWD呢?

那他是怎麼連上分享器與網路溝通連線的呢

小狐狸事務所 提到...

這是因為 ESP8266 只要做過一次設定 SSID 與 PWSSWORD 後就會記錄在內部 FLASH 中, 以後每次開機都預設會以這組資訊連線基地台. 我之前已經用 Arduino IDE 下 AT 指令設定好了. 為了簡潔起見我就沒在程式中加入設定 SSID 與 PASSWORD 的程式碼了.

匿名 提到...

版主您好

我想再請教一個問題

AT+CIPMUX 一定得關起來嗎?
因為我還要設計其他東西來連我的Arduino

所以我必須開著它,但是我把它打開以後 就開使err

小狐狸事務所 提到...

AT+CIPMUX=1 表示開啟 ESP8266 多重連線, 如果 Arduino 要當作伺服器, 則 CIPMUX 一定要維持在 1 喔.