由於 NodeMCU (ESP8266) 或 ESP32 的MCU 裡面帶有實時時鐘 (Real-time clock,RTC),在RTC 被校正之後,能用於時間戳章(Time Stamp)的相關應用。比方說,可以把溫度感測器的測量數值,跟當前的時間,一起存進去 SD 卡的 log 紀錄檔,這樣讀取歷史資料時,就知道某個溫度,是在何時被量測紀錄的。
但由於MCU模塊,不像電腦主機板有安裝 CR2032 鋰電池,去維持RTC不間斷運作,一旦 MCU電源中斷,之前網路校準好的時間就沒了,重新上電後,RTC會回到預設的 2000年1月1日0點。好在 NodeMCU 跟 ESP32 都內建 WiFi,因此透過網路對時伺服器 NTP Server,便能隨時校正RTC。對於價格便宜的 MCU 來說,更頻繁地執行網路校時,可以彌補 RTC 不準的缺陷。
詢問 NTP Server 目前時間
NTP Server是使用 UDP 通訊協定,並使用 123 這個連接埠來通訊,台灣有以下幾個常用的 NTP Server,可就近對時,縮短回應時間:
tick.stdtime.gov.tw (118.163.81.62)
tock.stdtime.gov.tw (211.22.103.157)
watch.stdtime.gov.tw (118.163.81.63)
clock.stdtime.gov.tw (211.22.103.158)
詢問 NTP Server 當前時間,需要設定 UDP 通訊協定 socket.socket(socket.AF_INET, socket.SOCK_DGRAM),並送出以下這一長串位元組串 (bytes) 給 NTP Server,就會得到 NTP Server 回覆時間:
b'\x23\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
這個詢問用的封包,第一個位元組可用\0x23,然後接上 47 個 0x00 ,總共48個Bytes,下面會解釋這個詢問封包的意義。要製造這個封包,你可以先產生字串: '\x23' + 47 * '\0',然後丟給字串編碼函式 encode('ascii') ,就能得到 ByteArray格式的資料封包:ntpquery.encode('ascii')。
送出以上封包給 NTP Server 後,它會回覆一樣格式、長度 48 bytes 的封包,裡面包含你要對實用的時間。要解開這串封包,我們可以用 Python內建的 struct 的 unpack 方法,把封包的 ByteArray,直接轉成實際的數字。
NTP 48-Byte 封包格式解析
我們先來看一下NTP Server 這 48個 bytes 的意義,傳送、接收都是一樣的格式:
我們詢問 NTP Server ,或它回覆我們的 48個bytes的封包,就只有上面藍色的部份,每一排 4個 byte (32-bit)。灰色的部份,目前NTP協定還沒有用到、也不會傳送。為了讓你更容易了解48byte封包的架構,我把上面網路上找到的封包格式,重新畫一個用 Python 角度思考的的表格:
比對我們前面傳送過去 NTP Server 的封包,只有第一個Byte、即上表最前面那 4bytes 檔頭的第1個byte,也就是最前面那八個bit有意義,剩下3個byte都用0x00填滿即可。之前告訴你那個詢問封包的第一個位元組 0x23 ,轉成二進位表示,就是 00100011 ,說明一下這個二進位串的意義:
(註:二進位跟十進位之間的轉換:00 = 0; 01 = 1; 10 = 2; 11 = 3; 100 = 4; 101 = 5; 110 = 6; 111 = 7)
LI (2bit) 閏秒:00 無閏秒調整
0: No leap second adjustment
1: Last minute of the day has 61 seconds
2: Last minute of the day has 59 seconds
3: Clock is unsynchronized>
VN (3bit) NTP 版本:100 NTP通訊協定版本 4
版本4主要針對 IPv6的支援並更正版本3的一些Bug,對資料結構無影響,所以填入100。網路上有一些範例,填入011,也是可以。
Mode (3bit) 通訊模式:011宣告我這邊是 Client
0: Reserved
1: Symmetric active
2: Symmetric passive
3: Client
4: Server
5: Broadcast
6: NTP control message
7: Reserved for private use
因為你的角色是用戶Client端,要去詢問 NTP Server 當前時間,所以這三個 bit,一定要用 011 (3)。也因為你是Client,所以後面那三個byte,Stratum, Poll, Precision,也就不用管了,那是給 Server 用的。然後接下來的三組4個Byte,Root Delay, Root Dispersion, Reference Identifier 也都是Server間的對時才用的到,全部都填入0x00。所以,48-byte 的檔頭才會長成這樣:
b'\x23\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
NTP Server 封包裡的時間戳記 Timestamp
解開 NTP 回覆封包 (48 bytes)
使用 struct.unpack() 解開位元組封包
由於網路Socket傳回的是一長串8位元(8bits = 1byte) 的ByteArray封包packet。你必須把封包解開,才能獲得這兩個整數時間值。如同一般解開網路封包的方式,你可以用 Python內建的 struct 的unpack 方法,來把網路封包解開。首先我們先把這48-byte 的封包,擷取出 40到47這段。Python 要擷取某段資料很簡單,用List的語法即可:msg, address = ntpclient.recvfrom(48)#讀取NTP Server 回覆的 48-byte 封包 ttransmit = struct.unpack("!2I", msg[40:])#從位址第40byte開始到結尾第47byte,一共8個Byte
然後,解開這個封包所使用的格式是"!2I"。第一個 ! 指定解開的是網路封包的位元組排列方式,等同於 > 符號。網路封包都是使用big-endian 大端序的位元組排列方式解碼,也就是高位先到的原則,解網路封包,用驚嘆號就對了。
什麼是 big-endian 大端序?
舉例來說,收到一組 4個byte的封包放在變數 byteobj = b'\x01\x02\x03\x04'。其中,0x01從socket先收到,0x04最後收到,在Python的ByteArray物件byteobj中,byteobj[0]=0x01代表的是高位,byteobj[3]=0x04代表是低位,轉成整數就是0x01020304 = 1690906的十進位,跟我們在10進位系統,右邊是低位、左邊是高位的概念一樣。不過,以byteobj 這個 ByteArray所代表的 List來說,你會容易搞混,因為正好相反 [0] 是高位,而不是低位。
所以說,big-endian 大端序,以ByteArray印出來看的格式 b'\x01\x02\x03\x04',跟我們對10進位的理解一樣。然而,用ByteArray的List來看,則是倒過來,反而是小的在前面:byteobj[0] byteobj[1] byteobj[2] byteobj[3],別搞混了。你可以自己進去Python的互動視窗,拿一台工程計算機試驗一下。
struct.unpack() 解碼格式定義
抱歉,又離題了。主要我之後想些一篇關於 MCU 跟 Server 之間通訊的教學,《替 Arduino Nano 開無線外掛 (二) 使用 WiFiEsp 函式庫》有提到一些概念,但不是很完整,之後有時間再寫一篇Client Server 兩端,都是用 MicroPython 通訊的範例。用Python寫網路通訊程式,有很多優勢,你寫了之後,就不想回去C++了。
格式定義 "!12I"裡面的12I,則是代表把兩組4-byte封包,解開成2個整數,然後存進去 ttransmit 這個List,ttransmit[0] 是秒數,ttransmit[1]是亞秒。要知道更多 struct.unpack 格式的意義,你可以參考:unpact 官方說明書。
如何將NTP傳回的秒數值,轉換成正確的年月日時間?
秒數的部份,NTP Server是從 1900年1月1號起算,而MicroPython的ESP32改系統時鐘的方法RTC.datetime(),是從2000年1月1號起算,之間差了3155673600秒。自己打開電腦 Python 互動視窗,寫個程式就能得到,不用Google。實在太喜歡Python了,真方便:
from datetime import date
(date(2000,1,1) - date(1900,1,1)).days*24*3600
另由於台灣處於 +8 時區,比格林威治天文台所處的本初子午線那裡多8小時,也就是8*3600秒,所以別忘了要加上(範例中的寫法,負負得正)。而亞秒,前面有提到,他的單位是 1/2^32,算出來實際亞秒數(單位也是秒,只是亞秒是小數點)以後,必須乘上1000000,得到的才是 us 微秒。
下面這幾行,讓你可以得到校正用的秒數與微秒數:
epoch = ttransmit[0]
epoch -= TIME2000 - timezone*3600
micros = int(ttransmit[1]//4294.967296)
執行時間校準
ttransmit = struct.unpack("!6I", msg[24:])
後記
MicroPython 程式範例 ntpsync.py
import socket
import machine
import time
import struct
from micropython import const
ntpsync(host = "211.22.103.158", port = 123, timezone=8):
ntpaddr = (host, port)
ntpquery = '\x23' + 47 * '\0'#詢問用的字串
# connect to NTP server
ntpclient = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ntpclient.sendto(ntpquery.encode('ascii'), ntpaddr)#先用encode把字串包成ByteArray封包,才能送出
msg, address = ntpclient.recvfrom(48)
TIME2000 = const(3155673600)# 1900-01-01 00:00:00 to 2000-01-01 00:00:00 seconds從1900年算到2000年的秒數,要扣除這個數字
ttransmit = struct.unpack("!2I", msg[40:])
epoch = ttransmit[0]
epoch -= TIME2000 - timezone*3600
micros = int(ttransmit[1]//4294.967296)
tm = time.gmtime(epoch)
#gmtime format (year, month, mday, hour, minute, second, weekday, yearday)
#RTC datetime format: (year, month, mday, weekday, hour, minute, second, micros)
tm = tm[0:3] + (tm[6],) + tm[3:6] + (micros,)
return(tm)
if __name__ == "__main__":
#以下這段,是把 NodeMCU、ESP32連線上去 WiFi AP
wlan_sta = network.WLAN(network.STA_IF)
wlan_sta.active(True)
wlan_sta.connect("ssid", "password")#SSID就是無線路由器名稱,跟密碼要自己更改
tmstart = time.ticks_ms()
while not wlan_sta.isconnected():
if time.ticks_diff(time.ticks_ms(), tmstart) > 10000:#10 seconds timeout wlan_sta.disconnect()
print("WiFi AP Connection Failed!")
break
else:
print("WiFi AP Connected!")
#連線無線AP之後,開始對時,校正板子裡面的RTC時鐘
rtc = machine.RTC()
timenow = ntpsync(timezone=8)
rtc.datetime(timenow)
沒有留言:
張貼留言