2017年7月3日 星期一

MicroPython on ESP8266 (十四) : 網頁伺服器測試

上週做完 ThingSpeak 物聯網測試後, 我想回過頭來用 socket 模組在 ESP8266 模組上建立一個網頁伺服器, 可以提供 Web 網頁服務. 之前在測試 Arduino + ESP8266 的伺服器功能時得到一個結論, 由於 Arduino 運算能力較弱, 當伺服器雖然可用, 但效能不是很好, 無法處理較複雜的網頁. 參考 :

# 利用網頁控制 Arduino (三) : 使用超連結與按鈕
# AllAboutEE 的 ESP8266 伺服器測試 (四) : 完結篇

其實 ESP8266 本身是 32 位元處理器, 比 8 位元的 Arduino ATMEGA328P 強多了, 把 ESP8266 拿來當 Arduino 的上網模組著實是大材小用, ESP8266 本身就能當微控器. 本篇就以之前的 Arduino 測試為範本, 來測試看看 MicroPython on ESP8266 的伺服器功能.

本系列之前的測試紀錄參考 :

MicroPython on ESP8266 (二) : 數值型別測試
MicroPython on ESP8266 (三) : 序列型別測試
MicroPython on ESP8266 (四) : 字典與集合型別測試
MicroPython on ESP8266 (五) : WiFi 連線與 WebREPL 測試
MicroPython on ESP8266 (六) : 檔案系統測試
MicroPython on ESP8266 (七) : 時間日期測試
MicroPython on ESP8266 (八) : GPIO 測試
MicroPython v1.9.1 版韌體測試
MicroPython on ESP8266 (十) : socket 模組測試
MicroPython on ESP8266 (十一) : urllib.urequest 模組測試
MicroPython on ESP8266 (十二) : urequests 模組測試
MicroPython on ESP8266 (十三) : DHT11 溫溼度感測器測試

MicroPython 文件參考 :

http://docs.micropython.org/en/latest/micropython-esp8266.pdf
http://docs.micropython.org/en/latest/pyboard/library/usocket.html#class-socket
http://docs.micropython.org/en/v1.8.7/esp8266/library/usocket.html#module-usocket

首先來建立一個只會回應 "Hello World" 的簡單網頁伺服器. 參考上面 測試 (十) 關於 socket 的文章, HTTP 應用層是透過 TCP 傳輸層來互相交換訊息的, 要建立伺服器必須先建立一個 TCP socket 物件, 這是要求作業系統分配一個通訊槽 (socket ID) 給應用程式以便進行網路通訊 :

import socket          #匯入 usocket 模組
s=socket.socket()    #未傳入參數預設是建立 TCP Socket, 傳回 socket ID

呼叫 socket 類別的建構式 socket() 預設會建立一個 TCP socket 物件. Socket 有兩種 : 主機當伺服器被動接受連線時所用的 Socket 稱為 Server socket; 主機當用戶端主動與遠端伺服主機建立連線時所用的 Socket 稱為 Client socket. 建立 Socket 時不需要綁定網路位址, 原因是有些應用程式需要一直使用同一位址 (例如伺服器), 但有些程式卻不在乎使用甚麼特定位址 (例如瀏覽器), 需要綁定位址的應用如伺服器程式可以在建立 Socket 物件後再去呼叫 bind() 方法綁定位址.

TCP 傳輸層提供下列 socket 物件方法來進行傳輸服務之基本操作 (primitives), 其中 connect() 專用於 Client socket; bind(), listen(), accept() 這三個專用於 Server socket;  而 send(), recv(), 與 close() 則不論 Client 或 Server socket 都會用到 :

方法 說明
 connect(addr) 與在位址 addr 之遠端 Socket 連線
 send(bytes) 向遠端端傳送資料 (須為 bytes 類型)
 recv(bufsize) 從 Socket 接收資料並儲存於傳回之 bytes 物件
 bind(addr) 將 Socket 綁定到主機的網路位址 addr (IP, Port 之 tuple)
 listen([backlog]) 監聽所綁定通訊埠之連線請求, backlog 為最大等候佇列數
 accept() 接受遠端連線請求, 傳回值為 (socket,addr) 組成之 tuple
 close() 關閉連線

參考 :

http://docs.micropython.org/en/latest/pyboard/library/usocket.html#methods

這些方法都是源自 BSD UNIX 的 TCP socket 基本操作 (primitives), 在 UNIX 上是執行系統呼叫來完成, MicroPython OS 也實作了這些方法. 注意, 其中的 connect() 與 accept() 是阻斷式 (blocking) 的系統呼叫, 亦即程式執行到這兩個指令時會停住, 控制權轉移到系統呼叫, 等待收到對方資料後, 控制權才會回到程式中繼續往下執行. 例如呼叫 Client socket 的 connect() 後要等待遠端伺服器回應 ACK (連線完成) 後程式才會繼續執行; 而呼叫 Server socket 的 accept() 後則要等到有遠端連線要求進來控制權才會回到程式中.

在伺服器程式中會用到 bind(), listen(), accept(), send(), close() 這五個 socket 物件方法, 其中 bind() 方法是將從作業系統取得的通訊槽 (socket ID) 與網路位址 (IP+Port) 綁定起來, 因為一個主機可能有多個 IP (即多個網路卡), 且多個應用程式會使用同一個 IP, 因此必須靠通訊埠來識別應用程式. 伺服器的特性是被動向客戶端提供服務, 其所使用的 IP 與 Port 必須固定, 因此必須將 socket 與特定網路位址綁定起來; 客戶端程式如瀏覽器則不必綁定位址, 因為與其通訊的伺服器不會在乎客戶端使用甚麼 IP 與 Port.

listen() 方法用來做網路通訊前之準備, 主要是設定連線等候佇列 (queue) 的大小, 並監聽所綁定之通訊埠. 由於伺服器一次只能處理一個連線, 若有多個連線進來, 比較晚進來的會被放在佇列中等候處理. 傳入參數 backlog 就是設定此等候連線之最大數目, 超過的會直接被拒絕連線, 參考 :

What is “backlog” in TCP connections?

accept() 方法用來檢查所監聽的通訊埠是否有連線進來, 由於伺服器必須不斷地提供服務, 因此 accept() 必須放在無窮迴圈中持續偵測連線. 如果有連線進來, 就向客戶端送出回應訊息 (例如 HTML 網頁). 當進來的連線被服務時, accept() 方法會傳回一個 (socket, address) 元組, 其中的 socket 是 accept() 方法產生的一個新的 (Client) socket 物件, 用來與該連線之遠端客戶通訊 (因為 Server socket 專門用來處理進來的連線), 而 address 則是該連線遠端客戶之網路位址 (IP, Port). 這個新的 socket 是用來與連線進來的遠端客戶溝通的管道, 可呼叫 send() 方法將回應訊息傳遞給客戶端, 例如下面的程式碼會在偵測到連線進來時回應客戶端一個顯示 "Hello! World" 的網頁 :

html='<!DOCTYPE html><html><body>Hello!World</body></html>'
while True:
    cs, addr=s.accept()    #程式停頓 (blocking) 在此等待連線進來, 傳回新 socket : cs
    print('client connected from', addr)    #顯示遠端客戶之網路位址
    cs.send(html)             #利用新的 socket 與遠端客戶通訊
    cs.close()                    #關閉新的 socket

注意, 呼叫 accept() 後程式會停頓在此函數, 直到收到客戶端連線要求為止, 這是因為 accept() 是一個阻塞式系統呼叫. 另外傳送回應訊息是利用 accept() 傳回的 Client socket 而非利用 Server socket, 後者是專門用來監聽客戶端連線的. 下列測試 1 使用 ESP8266 AP 模式的固定 IP=192.168.4.1 當伺服端位址並綁定 80 埠監聽是否有客戶端連線進來, 有的話就回應 "Hello World" 網頁給遠端客戶.

測試 1 : 簡單的 HTTP 網頁伺服器 

#main.py
import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]

s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  #設定 socket 位址可重複使用
s.bind(addr)       #Server socket 綁定本身 AP 的網址與 80 埠
s.listen(5)           #設定連線等候佇列最大連線數目為 5
print('listening on', addr)

html='<!DOCTYPE html><html><body>Hello!World</body></html>'

while True:
    cs, addr=s.accept()    #等候連線進來, 接受後傳回與該連線通訊之新 socket 與遠端位址
    print('client connected from', addr)   #輸出遠端網址
    cs.send(html)    #以網頁回應遠端客戶
    cs.close()           #關閉新 socket
s.close()    #關閉 Server socket

將上面程式存成 main.py, 使用 ampy 上傳到 ESP8266 模組 :

D:\test>ampy --port COM4 put main.py

然後開啟 Putty 連線 ESP8266, 按 Ctrl+D 軟開機或插拔 ESP8266 電源重新執行 main.py, 由於進入無窮迴圈之故 REPL 介面最初會無反應.

分別使用筆電或手機以 WiFi 連線 ESP8266 的 AP 基地台 (SSID 是 MicroPython-xxxxxx, 其中 xxxxxx 是 MAC 位址後六碼, 而密碼預設是 micropythoN, 注意最後字元是大寫的 N), 會收到伺服器回應的 HTML 碼顯示如下網頁 :

瀏覽器連線前預先使用 Putty 連線 ESP8266, , 但瀏覽器連線 192.168.4.1 後顯示如下輸出訊息 :

client connected from ('192.168.4.2', 12736)
client connected from ('192.168.4.2', 12737)
client connected from ('192.168.4.3', 60557)
client connected from ('192.168.4.3', 60558)

這裡 192.168.4.3 是來自手機 Chrome 瀏覽器的連線, 而 192.168.4.3 則是來自筆電 Chrome 的連線. 注意, 每一次瀏覽 192.168.4.1 都會有兩筆連線要求進來 (port 不同), 其中第一筆才是原始的要求, 第二筆是瀏覽器自動發出的 favicon.ico (網站圖案) 資源要求.

其次, 上面程式中呼叫了 socket 物件的 setsockopt() 方法去設定 SO_REUSEADDR 選項 :

s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

這是必要的設定, 否則所綁定的 IP 位址 192.168.4.1 將無法被重複使用, 再次瀏覽時會出現 "OSError: [Errno 98] EADDRINUSE" 錯誤訊息 :

MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.
>>>
PYB: soft reboot
#8 ets_task(40100164, 3, 3fff829c, 4)
WebREPL is not configured, run 'import webrepl_setup'
Traceback (most recent call last):
  File "main.py", line 7, in <module>
OSError: [Errno 98] EADDRINUSE  
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.

參考 :

# Python [Errno 98] Address already in use

另外, 如果程式更新上傳後按 Ctrl+D 軟啟動卻出現 "OSError: [Errno 12] ENOMEM" 的錯誤訊息, 這表示記憶體並未刷新, 這時只要關閉 ESP8266 電源後重開即可 :

#7 ets_task(40100164, 3, 3fff829c, 4)
WebREPL is not configured, run 'import webrepl_setup'
Traceback (most recent call last):
  File "main.py", line 8, in <module>
OSError: [Errno 12] ENOMEM
MicroPython v1.9.1-8-g7213e78d on 2017-06-12; ESP module with ESP8266
Type "help()" for more information.

參考 :

# esp8266 RAM does not seem to be cleared by soft reset

上面測試 1 中, REPL 介面僅顯示連線進來的客戶端之網路位址, 若要顯示遠端所傳送的 HTTP 訊息, 則須呼叫 socket 物件的 recv() 方法, 如下列測試 2-1 所示 :


測試 2-1 : 簡單的 HTTP 網頁伺服器-顯示遠端訊息-使用 recv()

#main.py
import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]

s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)

print('listening on', addr)
html='<!DOCTYPE html><html><body>Hello!
while True:
    cs, addr=s.accept()
    print('client connected from', addr)
    data=cs.recv(1024)                    #從 socket 接收緩衝區讀取資料, 最大 1024 bytes
    print(str(data,'utf8'), end='\n')    #以 utf-8 編碼將 bytes 資料轉成字串
    cs.send(html)
    cs.close()
s.close()

此程式只是在測試 1 中加入如上藍色部分的兩行指令而已, recv() 方法會從 socket 的接收緩衝區讀取資料, 再用 print() 印出來, REPL 介面輸出結果如下 (前兩筆從筆電, 後兩筆從手機) :

client connected from ('192.168.4.2', 13501)
GET / HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.2', 13502)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.3', 51625)
GET / HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.3', 51627)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: image/webp,image/*,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

注意, recv() 的傳回值 data 是 bytes 型的串流, 如果沒有用 str() 函數將 data 以 utf-8 編碼轉成字串, 直接輸出 data 的話, 亦即將上面程式中的 print() 改為如下 :

print(data, end='\n')

則輸出結果為 :

client connected from ('192.168.4.3', 51620)
b'GET / HTTP/1.1\r\nHost: 192.168.4.1\r\nConnection: keep-alive\r\nCache-Control: max-age=0\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\nAccept-Encoding: gzip, deflate, sdch\r\nAccept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4\r\n\r\n'

client connected from ('192.168.4.3', 51621)
b'GET /favicon.ico HTTP/1.1\r\nHost: 192.168.4.1\r\nConnection: keep-alive\r\nUser-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36\r\nAccept: image/webp,image/*,*/*;q=0.8\r\nReferer: http://192.168.4.1/\r\nAccept-Encoding: gzip, deflate, sdch\r\nAccept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4\r\n\r\n'

雖然 MicroPython 可以直接使用 recv() 讀取資料, 不需要再使用 makefile() 將串流轉成檔案類型, MicroPython 仍然可用 makefile(), 如下列測試 2-2 所示 :


測試 2-2 : 簡單的 HTTP 網頁伺服器-顯示遠端訊息-使用 makefile()

#main.py
import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]

s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)

print('listening on', addr)
html='<!DOCTYPE html><html><body>Hello!World</body></html>'
while True:
    cs, addr=s.accept()
    print('client connected from', addr)
    cs_file=cs.makefile('rwb', 0)  
    while True:    
        line=cs_file.readline()  
        print(str(line,'utf8'),end='')  
        if not line or line == b'\r\n':  
            break  
    cs.send(html)
    cs.close()
s.close()

上面藍色部分是與測試 2-1 不同處, 首先是呼叫 socket 物件的 makefile() 方法, 傳回值為檔案類型, 因此可以在迴圈中用檔案物件的 readline() 方法逐列讀取, 結果是一樣的 :

client connected from ('192.168.4.3', 13623)
GET / HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.3', 13630)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

同樣地, 如果每一列不用 utf-8 編碼轉成字串, print() 改成如下 :

print(line, end='')

則 REPL 輸出變成 :

client connected from ('192.168.4.2', 43461)
b'GET / HTTP/1.1\r\n'
b'Host: 192.168.4.1\r\n'
b'Connection: keep-alive\r\n'
b'Cache-Control: max-age=0\r\n'
b'Upgrade-Insecure-Requests: 1\r\n'
b'User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36\r\n'
b'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n'
b'Accept-Encoding: gzip, deflate, sdch\r\n'
b'Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4\r\n'
b'\r\n'
client connected from ('192.168.4.2', 43462)
b'GET /favicon.ico HTTP/1.1\r\n'
b'Host: 192.168.4.1\r\n'

在上面三個範例的基礎上, 只要增加一個全域變數來記錄連線次數, 就可以製作一個簡易的訪客計數器了, 如下列測試 3 所示 :

測試 3 : 訪客計數器

#main.py
import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]

s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)

counts=0    #全域變數紀錄連線次數
print('listening on', addr)
while True:
    cs, addr=s.accept()
    counts += 1    #計數器增量 1
    print('client connected from', addr)
    data=cs.recv(1024)
    print(str(data,'utf8'), end='\n')
    html='<!DOCTYPE html><html><body>' +\   
         'Visitor counts=' + str(counts) +\  
         '</body></html>'  
    cs.send(html)
    cs.close()
s.close()

此程式利用全域變數累計連線次數, 但是由於每次連線都會伴隨請求 favicon.ico 之故, 每次重新整理網頁會讓訪客次數遞增 2 次 :

以上的三個測試都與硬體無關, 所以我都是在前陣子新購的 ESP8266 Adaptor board (轉接板) 上進行的, 我覺得非常好用, 直接將 ESP-01 模組插在母座上, 再插入電腦 USB 插槽即可 (唯一缺點是接腳沒有針腳可接出, 例如將 GPIO 0 接地以便燒錄韌體) :


接下來是參考官網教學文件範例在 ESP8266 上建立一個 HTTP 網頁伺服器, 當客戶端連線至伺服器時, 伺服器會回應一個 HTML 網頁, 顯示目前 ESP8266 GPIO 各腳位的輸入位準為 0 (Low) 或 1 (High) :

5.3. Simple HTTP server

由於我使用 ESP-01 模組測試, 此板只接出 GPIO0 與 GPIO2 兩支輸出入腳, 因此我刪減了 pins 串列所包含的 Pin 數, 原始程式修改如下 :

測試 4 : 建立網頁伺服器顯示 GPIO 輸入腳的狀態

#main.py
import machine
pins=[machine.Pin(i, machine.Pin.IN) for i in (0, 2)]    #GPIO 的 Pin 物件串列

html="""<!DOCTYPE html>
<html>
    <head> <title>ESP8266 Pins</title> </head>
    <body> <h1>ESP8266 Pins</h1>
        <table border="1"> <tr><th>Pin</th><th>Value</th></tr> %s </table>
    </body>
</html>
"""

import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]
s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)
print('listening on', addr)

while True:
    cs, addr=s.accept()
    print('client connected from', addr)
    data=cs.recv(1024)
    print(str(data,'utf8'), end='\n')
    rows=['<tr><td>%s</td><td>%d</td></tr>' % (str(p), p.value()) for p in pins]  
    response=html % '\n'.join(rows)
    cs.send(response)
    cs.close()

s.close()

此程式利用串列內的 for 迴圈建立一個 Pin 物件串列, 這裡因我使用 ESP-01 模組, 所以只有 GPIO 0 與 GPIO 2 兩支腳而已. 關於 GPIO 用法參考 :

# MicroPython on ESP8266 (八) : GPIO 測試

然後使用 Python 長字串建立一個回應網頁字串 html, 這裡使用字串格式化方法將表格內容插入字串中以組成完整之 HTML 碼. 變數 html 中含有格式化字串 %s, 當接收到遠端連線請求時需讀取 GPIO 腳位狀態組成儲存格內容後嵌入 %s 的位置. rows 串列儲存各 GPIO 腳的列資料, 最後使用 str 的 join() 方法以跳行字元將各列黏合起來嵌入 html 字串裡面.

使用手機瀏覽器連線 ESP8266 的 AP (192.168.4.1), 顯示收到的回應網頁內容如下 :

REPL 介面輸出如下 :

client connected from ('192.168.4.2', 36549)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: image/webp,image/*,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.2', 36552)
GET / HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

用杜邦線將 GPIO0 或 GPIO2 接地再重新整理網頁就可以看到 Value 變成 0 了. 這個實驗的 Arduino 版本參考 :

# 利用網頁控制 Arduino (二)

上面範例是將 ESP-01 的 GPIO0 與 GPIO2 設定為輸入腳來偵測其狀態, 事實上輸出腳也是可以呼叫 value() 方法來讀取其輸出狀態的, 這跟 Arduino 的 Digital Pin 作法是一樣的, 參考 :

利用網頁控制 Arduino (三) : 使用超連結與按鈕
webserver to turn an LED on or off   (Good)

接下來的測試要將 GPIO0, GPIO2 設定為輸出腳, 並在網頁上控制這兩支腳輸出 HIGH 或 LOW 以點亮或熄滅 LED. 因此我在 GPIO 0 與 GPIO 2 腳各串接一個 LED 與 220 歐姆電阻後接地.

我在上面測試 3 的回應網頁中增加一個 Action 欄, 裡面有 HIGH 與 LOW 兩個超連結, 按超連結會讓該 GPIO 輸出相應的準位. 考量 pins 串列中的 Pin 物件字串化後是 'Pin(0)' 與 'Pin(2)', 為了方便我將超連結的格式定義如下 :

<a href='/?Pin(0)=1'>HIGH</a><a href='/?Pin(0)=0'>LOW</a>
<a href='/?Pin(2)=1'>HIGH</a><a href='/?Pin(2)=0'>LOW</a>

超連結位址 /?Pin(0)=1 表示傳遞參數 Pin(0)=1 給伺服器, 點擊這四個超連結會分別向伺服器送出如下的 GET 請求, 我們可以擷取參數 Pin(0) 與 Pin(2) 之值是 1 或 0 來決定將 GPIO 腳設為 HIGH 或 LOW, 但實際上不需要這麼做, 只要搜尋客戶端請求行中是否含有下面黃色部分的 Pattern 即可 :

GET /?Pin(0)=1 HTTP/1.1
GET /?Pin(0)=0 HTTP/1.1
GET /?Pin(2)=1 HTTP/1.1
GET /?Pin(2)=0 HTTP/1.1

HTTP 訊息的請求行中 ? 開頭的字串位於第六個字元 (其索引為 5), 因此只要讀取客戶端請求訊息, 在訊息字串中用 find() 方法搜尋上面黃色部分子字串, 例如搜尋 '?Pin(0)=1' 若傳回索引位置=5, 表示 Pin(0) 的 HIGH 超連結被按下, 可呼叫  Pin(0) 的 value(1) 或 high() 方法將 Pin(0) 設為 HIGH 輸出. 若沒找到 find() 會傳回 -1, 完整程式如下列測試 4 所示 :

測試 4 : 利用網頁超連結控制 GPIO 0/2 輸出以點亮或熄滅 LED

#main.py
import machine
pins=[machine.Pin(i, machine.Pin.OUT) for i in (0, 2)]

html="""<!DOCTYPE html>
<html>
  <head><title>ESP8266 Pins</title></head>
    <body>
      <h1>ESP8266 Pins</h1>
      <table border="1">
        <tr>
          <th>Pin</th>
          <th>Value</th>
          <th>Action</th>
        </tr>
        %s
      </table>
  </body>
</html>
"""

import socket
addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]
s=socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)
print('listening on', addr)

while True:
    cs, addr=s.accept()
    print('client connected from', addr)
    data=cs.recv(1024)               #讀取 socket 接收緩衝區
    request=str(data,'utf8')          #將 bytes 串流轉成 utf-8 字串
    print(request, end='\n')
    print('?Pin(0)=0:',request.find('?Pin(0)=0'), end='\n')
    print('?Pin(0)=1:',request.find('?Pin(0)=1'), end='\n')
    print('?Pin(2)=0:',request.find('?Pin(2)=0'), end='\n')
    print('?Pin(2)=1:',request.find('?Pin(2)=1'), end='\n')
    rows=[]
    for p in pins:
        set='?%s=1' % str(p)          #設定 GPIO=1 之字串
        reset='?%s=0' % str(p)       #設定 GPIO=0 之字串
        if request.find(set)==5:      #找到此 GPIO 的 set 字串
            p.value(1)                       #將此 GPIO 設為 HIGH
        if request.find(reset)==5:   #找到此 GPIO 的 reset 字串
            p.value(0)                       #將此 GPIO 設為 LOW
        row="""
        <tr>
          <td>
            %s
          </td>
          <td>
            %d
          </td>
          <td>
            <a href='/?%s=1'>HIGH</a>
            <a href='/?%s=0'>LOW</a>
          </td>
        </tr>
        """
        rows.append(row % (str(p), p.value(), str(p), str(p)))    #將列資料存入 rows 串列
    response=html % '\n'.join(rows)     #將表格列資料黏合後嵌入 html 中
    cs.send(response)
    cs.close()

s.close()

這裡因為表格增加的第三欄 Action 內容比較長, 所以我把 for 迴圈從 list 中解放出來, 先建立一個空串列 rows, 然後在迴圈中先用字串的 find() 方法自客戶端請求訊息中搜尋上面提到的特定四個字串, 有找到的話才去進行輸出位準設定. Python 的字串格式化功能實在很好用. 使用手機連線 ESP8266 AP 的固定 IP=192.168.4.1 後以瀏覽器連線 192.168.4.1 結果如下, 直接按 HIGH/LOW 超連結就可以改變 GPIO 0/2 的狀態, 而 LED 也會相應地明滅 :

REPL 介面輸出如下, 第一筆是按下 Pin(0) 的 HIGH 連結之輸出訊息, 而第二筆則是按下 Pin(2) 的 HIGH 連結之輸出訊息, 可見它們的 find() 傳回索引 5 :

client connected from ('192.168.4.2', 37284)
GET /?Pin(0)=1 HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Referer: http://192.168.4.1/?Pin(2)=0
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

?Pin(0)=0: -1
?Pin(0)=1: 5  
?Pin(2)=0: 368
?Pin(2)=1: -1

client connected from ('192.168.4.2', 37283)
GET /?Pin(2)=1 HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; Android 4.4.2; Che2-L12 Build/HonorChe2-L12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Referer: http://192.168.4.1/?Pin(0)=1
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

?Pin(0)=0: -1
?Pin(0)=1: 368
?Pin(2)=0: -1
?Pin(2)=1: 5



上面的圖像中, 紅色的 LED 接 GPIO 2, 而綠色 LED 則接 GPIO 0. 由這個範例可知, MicroPython on ESP8266 比 Arduino 更能勝任微型伺服器工作.

最後, 也是最重要的測試是利用 ESP8266 的 AP 模式所綁定的 IP=192.168.4.1 建立一個網頁伺服器, 讓使用者能透過手機來設定其 STA 模式下要連線之無線基地台的 SSID 與 PWD, 這樣就不需要 USB 硬體接線就可以修改連線設定了. 此功能在商品化時有簡化使用者介面之價值, 因為不需要將連線資訊寫死在應用程式中, 也不需要在現場用 USB 連線去設定, 用戶只要用手機就可以將設備與現場無線基地台連線.

測試 5 : 以手機瀏覽器設定欲連線之無線基地台

#main.py
import time
WAIT_FOR_CONNECT=8

def set_ap():
    html="""
    <!DOCTYPE html>
    <html>
      <head><title>AP Setup</title></head>
      <body>
        %s
      </body>
    </html>
    """
    form="""
        <form method=get action='/update_ap'>
          <table border="0">
            <tr>
              <td>SSID</td>
              <td><input name=ssid type=text></td>
            </tr>
            <tr>
              <td>PWD </td>
              <td><input name=pwd type=text></td>
            </tr>
            <tr>
              <td></td>
              <td align=right><input type=submit value=Connect></td>
            </tr>
          </table>
        </form>
    """
    import socket
    addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1]
    s=socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(addr)
    s.listen(5)
    print('listening on', addr)
    while True:
        cs, addr=s.accept()
        print('client connected from', addr)
        data=cs.recv(1024)            
        request=str(data,'utf8')
        print(request, end='\n')
        if request.find('update_ap?') == 5:
            para=request[request.find('ssid='):request.find(' HTTP/')]
            ssid=para.split('&')[0].split('=')[1]
            pwd=para.split('&')[1].split('=')[1]
            sta.connect(ssid,pwd)
            while not sta.isconnected():
                pass
            print('Connected:IP=',sta.ifconfig()[0])
            cs.send(html % 'Connected:IP=' + sta.ifconfig()[0])
        else:
            cs.send(html % form)
        cs.close()
    s.close()

import network
sta=network.WLAN(network.STA_IF)
sta.active(True)
print('Connecting to AP ...')
time.sleep(WAIT_FOR_CONNECT)  
if not sta.isconnected():
    set_ap()
else:
    print('Connected:IP=', sta.ifconfig()[0])
    #Application code is written here or import from a separate file
    #import myapp
    #myapp.main()

此程式先建立 WLAN 物件 sta, 開啟 Station 模式後等待 WAIT_FOR_CONNECT 秒 (預設 8 秒) 後用 isconnected() 方法判斷 ESP8266 是否能連線上一次連線過的無線基地台 (資料存在 ESP8266 晶片本身內建的 Flash 內不會消失), 如果能連上就在 REPL 介面顯示所獲得之 IP, 並開始執行應用程式. 注意, 連線 WiFi 基地台一般在 8 秒內都可以完成, 因此 WAIT_FOR_CONNECT 預設為 8 秒, 若連線速度較慢可放寬.

在 set_ap() 函數中, web 伺服器是透過讀取客戶端 (瀏覽器) 傳送的 HTTP 請求訊息中是否含有 'update_ap?' 字串來判別要回應甚麼頁面給客戶端, 因為預期的 WiFi 設定請求行 (HTTP 訊息的第一行) 如下 :

GET /update_ap?ssid=H30-L02-webbot&pwd=1234567890 HTTP/1.1

如果有找到 'update_ap?' 字串的話, find() 方法會傳回 5, 因為此字串的開頭字元 'u' 是出現在整個回應字串的索引 5. 這時就用 split() 與切片等字串處理方法將所設定的 SSID 與 PWD 擷取出來, 再用 WLAN 物件的 connect() 方法去連線基地台, 並在連線成功後以所獲得的 IP 回應給客戶端. 如果沒找到 'update_ap?' 字串就顯示 WiFi 設定網頁.

將上面的程式存成 main.py 用 ampy 上傳 ESP8266 後須關閉電源重開, 假定 ESP8266 原先 STA 所設定欲連線的 AP 不在收訊範圍, 則程式會呼叫 set_ap() 函數在 192.168.4.1 建立一個網頁伺服器等候客戶端連線, REPL 介面顯示如下 :

bl▒#5 ets_task(40100164, 3, 3fff829c, 4)
listening on ('192.168.4.1', 80)

此時開啟手機瀏覽器連線 ESP8266 的內建 AP (SSID=MicroPython_xxxxxx, PWD=micropythoN), 於網址列輸入 192.168.4.1 即顯示 WiFi AP 設定網頁 :

輸入欲連線 AP 之 SSID 與 PWD, 按 Connect 鈕即更新 ESP8266 本身 Flash 內的 WiFi AP 設定, 等連線成功後即顯示該 AP 所指派之 IP :

REPL 介面輸出訊息如下 :

bl▒#5 ets_task(40100164, 3, 3fff829c, 4)
listening on ('192.168.4.1', 80)  
client connected from ('192.168.4.2', 37937)
GET / HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

client connected from ('192.168.4.2', 37938)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Safari/537.36
Accept: image/webp,image/*,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4


client connected from ('192.168.4.2', 37940)
GET /update_ap?ssid=H30-L02-webbot&pwd=1234567890 HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Referer: http://192.168.4.1/
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

Connected:IP= 192.168.43.72  
client connected from ('192.168.4.2', 37941)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Safari/537.36
Accept: image/webp,image/*,*/*;q=0.8
Referer: http://192.168.4.1/update_ap?ssid=H30-L02-webbot&pwd=1234567890
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.6,en;q=0.4

因為使用 GET 方法傳送參數, 可見 SSID 與 PWD 都會放在請求行中傳遞.

設定好後 ESP8266 重開機, REPL 介面就不會輸出監聽 80 port 訊息, 而是輸出所獲得之 IP, 因為有連上新設定的無線基地台就不會呼叫 set_ap() 去建立 web 伺服器了 :


由於 ESP8266 會記住最近一次的 WiFi 連線設定, 因此只要此無線基地台在收訊範圍, ESP8266 一開機就會自動連線基地台, 這樣就不會在 192.168.4.1 建立網頁伺服器, 也就無法更改連線設定. 所以測試 5 這個程式沒辦法用在切換目前可連線的兩個基地台, 要切換的話一定要將連線中的那台關掉. 如果 ESP8266 在一個新環境開機, 抓不到之前所設定之基地台, 這樣就可以用手機設定現在可連線之基地台了.

比起之前在 Arduino + ESP8266 時還要耗掉一個 GPIO 腳來控制程式進入 WiFi 設定模式或工作模式, 上面這個解決方案就好太多了, 完全不必為了 WiFi 設定浪費任何 GPIO 腳. 這是測試 MicroPython on ESP8266 以來最讓我開心的收穫.

參考 :

# Network - TCP sockets
Micropython + ESP8266 + DHT11 + Thingspeak
# update data to thingspeak
An Inexpensive IoT Enabler Using ESP8266
# Official MicroPython MQTT client
# Messaging with MQTT
# MQTT & MicroPython
# micropython-umqtt.simple 1.3
# ESP8266 and MicroPython - Part 1
# ESP8266 and MicroPython - Part 2 : MQTT
# DHT driver
# Python 的 Socket 程式設計教程
ESP8266 - WiFiManager
# Python socket.accept nonblocking?
webserver to turn an LED on or off
micropython /examples/unix/sock-server.py
examples/network: Split recv- and read-based HTTP servers (Good)
# MICROPYTHON ON THE ESP8266: KICKING THE TIRES (send \x??)
examples/network: Split recv- and read-based HTTP servers (recv 範例)
Controlling relays using Micropython and an ESP8266 (使用 ure 模組)

2017-07-04 補充 :

今天在下面這篇看到從數字與文字雜沓的字串中抽取數字部分的方法 :

extract digits in a simple way from a python string [duplicate]

這個函數簡單又好用, 紀錄存查 :

def get_num(x):  #傳回字串中的數字部分
    return float(''.join(ele for ele in x if ele.isdigit() or ele == '.'))

client connected from ('192.168.4.2', 37635)
GET /update_ap?ssid=Test&pwd=1234 HTTP/1.1

https://docs.micropython.org/en/latest/pyboard/library/ure.html#module-ure
# MicroPython on ESP8266 (三) : 序列型別測試

沒有留言 :