2022年12月3日 星期六

MicroPython 學習筆記 : ESP8266/ESP32 網路存取測試 (三)

在前一篇測試中, 我們使用 xtools 模組的 connect_wifit_led() 函式連線 WiFi 基地台取得 ip 位址後, 利用第三方的 ESP8266WebServer 模組建立了網頁伺服器, 透過 HTTP GET 方法可以在網頁上控制 ESP32/ESP8266 的 GPIO 進行區網範圍內的遠端控制. 這種網頁伺服器是利用 STA (Station) 介面建立的, 必須先連上 WiFi 基地台取得 IP 才行; 本篇則是要用 AP 介面來建立網頁伺服器, 可以在上面建立像 STA 介面上那樣的網站透過網頁來控制硬體, 但必須使用低階的 TCP socket 物件, 實作上較為繁瑣 (須自行處理網路 I/O 串流之讀寫), 由於 AP 模式下 ESP32/ESP8266 無法連接 Internet, 主要用途是拿來實作線上設定 STA 模式下要連線之 WiFi 基地台 SSID 與密碼功能, 這樣產品就可以在現場用手機進行連網設定, 不需要改程式. 

ESP32/ESP8266 的 AP 介面啟動後會讓開發板本身成為一個 WiFi 基地台 (即可被連線之熱點), 其網址固定為 192.168.4.1, 熱點之 SSID 為 MicroPython-xxxxxx, 其中 xxxxxx 為 ESP32/ESP8266 的 MAC 地址末六碼, 連線密碼固定為 micropythoN (注意最後字元為大寫的 N). 在 AP 介面上建立網站必須用 TCP socket 物件實作伺服端程式來處理客戶端的 HTTP 請求 (前一篇使用的 ESP8266WebServer 模組已經將這些細節封裝起來, 但那是用在 STA 介面), 關於 socket 物件用法與如何用它來實作網頁伺服器請參考 :


本系列之前的文章參考 :

設定 STA 模式下要連線之 WiFi 基地台 SSID 與密碼的程式原型來自我以前寫的 "MicroPython on ESP8266 (十四) : 網頁伺服器測試" 這篇最後一個範例中的 set_ap() 函式, 此處將其做了局部修改 後加入 xtools 模組中, 主要是美化了輸出網頁與連線控制方式 (不論連線成功或失敗都跳出無限迴圈, 按 Reset 鈕重試), 其次是添加了建立 AP 模式的程式碼, 因為這次測試才發現 ESP32/ESP8266 開啟過 AP 模式後會記住狀態, 下次開機時會自動開啟, 所以舊的 set_ap() 沒有添加開啟 AP 模式的程式碼, 這對新板子來說就會搜尋不到 MicroPython-xxxxxx 的基地台. 

啟用 ESP32/ESP8266 的 AP 模式必須先建立一個 network.AP_IF 模式的 WLAN 物件, 然後呼叫 active(True) 方法來啟用, 例如 : 

>>> import network   
>>> ap=network.WLAN(network.AP_IF)   
>>> type(ap)   
<class 'WLAN'>     
>>> if not ap.active():    # 若尚未啟動 AP 模式就開啟它
      ap.active(True)    

開啟 AP 模式後打開手機 WiFi 設定, 就可以看到 SSID 為 "MicroPython-xxxxxxx" 的基地台, 輸入密碼 "micropythoN" (注意大寫 N) 連線此熱點 : 




接下來就可以用 socket 物件建立伺服端程式綁定 192.168.4.1 的 80 埠, 在此 socket 上建立一個網頁伺服器來處理並回應客戶端請求 :
  • 當客戶端連線 192.168.4.1 時回應一個可輸入 SSID 與連線密碼的網頁, 按連線鈕會將 SSID 與密碼傳送到 192.168.4.1/?update_ap. 
  • 當伺服端收到 192.168.4.1/?update_ap 請求時, 取出所傳遞的 SSID 與密碼, 建立 STA 模式的 WLAN 物件來連線此 WiFi 基地台, 並更新 config.py 檔案. 
我將以上功能寫成如下的 set_ap() 函式放進 xtools.py 模組中, 完整原始碼如下 :

# set_ap() in xtools.py
def set_ap():
    html='''
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
      </head>
      <body>
        %s
      </body>
    </html>
    '''
    form='''
        <form method='get' action='/update_ap' 
        style='width: max-content; margin: 10px auto'>
          <h2 style='text-align: center; font-size: 20px'>設定 WiFi 基地台</h2>
          <div style='display: block; margin-bottom: 20px'>
            <label for='ssid' style='display: block; font-size: 16px'>SSID</label>
            <input type='text' id='ssid' name='ssid' 
            style='padding: 10px 8px; width: 100%; font-size: 16px'>
          </div>
          <div style='display: block; margin-bottom: 20px'>
            <label for='pwd' style='display: block; font-size: 16px'>Password</label>
            <input type='text' id='pwd' name='pwd' 
            style='padding: 10px 8px; width: 100%; font-size: 16px'>
          </div>
          <button type="submit" style='width:100%;font-size: 16px'>連線</button>
        </form>
    '''
    ok='''
       <h2>WiFi 連線成功<br>IP : <a href={0}>{0}</a></h2>
       <a href=192.168.4.1>
         <button style="width:100%;font-size: 16px">重新設定</button>
       </a>              
    '''
    ng='''
       <h2 style="text-align: center;">WiFi 基地台連線失敗<br> 
       按 Reset 鈕後重新設定</h2>
       <a href="192.168.4.1">
         <button style="width:100%;font-size: 16px">重新設定</button>
       </a>   
    '''
    wifi_led=Pin(2, Pin.OUT, value=1)  # 預設熄滅板上 LED
    ap=network.WLAN(network.AP_IF)     # 開啟 AP 模式
    ap.active(True)
    sta=network.WLAN(network.STA_IF)   # 開啟 STA 模式
    sta.active(True)
    import socket
    addr=socket.getaddrinfo('192.168.4.1', 80)[0][-1] # 傳回 (ip, port)
    s=socket.socket()  # 建立伺服端 TCP socket
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # 網址可重複請求
    s.bind(addr)  # 綁定 192.168.4.1 的 80 埠
    s.listen(5)   # 最多同時 5 個連線
    print('網頁伺服器正在監聽 : ', addr)
    while True:   # 監聽 192.168.4.1 的 80 埠
        cs, addr=s.accept()  
        print('發現來自客戶端的連線 : ', addr)
        data=cs.recv(1024)      
        request=str(data, 'utf8')   
        print(request, end='\n')
        if request.find('update_ap?') == 5:  # 檢查是否為更新之 URL 
            # 擷取請求參數中的 SSID 與密碼
            para=request[request.find('ssid='):request.find(' HTTP/')]
            ssid=para.split('&')[0].split('=')[1] 
            pwd=para.split('&')[1].split('=')[1]
            sta.connect(ssid, pwd)       # 連線 WiFi 基地台
            start_time=time.time()       # 紀錄起始時間  
            while not sta.isconnected(): # 連線 WiFi (15 秒)
                wifi_led.value(0)  # 讓板載 LED 閃爍
                time.sleep_ms(300)
                wifi_led.value(1)
                time.sleep_ms(300)                
                if time.time()-start_time > 15: # 是否超過連線秒數
                    print('Wifi 連線逾時!')
                    break  # 逾時跳出無限迴圈
            # 確認是否連線成功
            if sta.isconnected():     # WiFi 連線成功
                wifi_led.value(1)     # 連線成功 : 熄滅 LED
                print('WiFi 連線成功 : ', sta.ifconfig())
                ip=sta.ifconfig()[0]  # 取得 ip
                print('取得 IP : ' + ip)
                with open('config.py', 'w', encoding='utf-8') as f:
                    f.write(f'SSID="{ssid}"\nPASSWORD="{pwd}"') # 更新設定檔
                cs.send(html % ok.format(ip))  # 回應連線成功頁面
                return ip
            else:
                print('WiFi 連線失敗 : 請按 Reset 鈕後重設.')
                cs.send(html % ng)   # 回應連線失敗頁面
                return None
        else:  # 顯示設定 WiFi 頁面
            cs.send(html % form)  # 回應設定 WiFi 頁面
        cs.close()
        del cs, addr, data, request

此函式的開頭先用長字串定義了一個主網頁模板 html, 其中 body 元素內夾了一個 %s 用來插入網頁內容. 其次定義一個 form 變數儲存基地台設定網頁, 用來在未能連上預設的 WiFi 基地台時插入主網頁的 body 中, 讓使用者輸入並設定 SSID 與連線密碼, 其結構與樣式參考了下列網站 : 


程式首先開啟 AP+STA 雙模式, 建立 socket 物件綁定 192.168.4.1 的 80 埠後用 while 無限迴圈監聽是否有客戶端連線, 這樣就在 AP 模式下建立一個網頁伺服器了. 

修改後的 xtools.py 放在 GitHub :


測試結果如下 :

匯入 xtools 後呼叫 set_ap() 函式就會啟動 AP 模式的網頁伺服器 : 

>>> import xtools   
>>> xtools.set_ap()    
網頁伺服器正在監聽 :  ('192.168.4.1', 80)  

這表示 AP 模式的網頁伺服器已啟動了, 用手機連線 MicroPython-xxxxxx 熱點後打開瀏覽器輸入 192.168.4.1 網址, 因為請求網址中沒有 "update_ap?", 程式會將 form 字串插入 html 字串的 %s 處作為回應網頁, 這時瀏覽器會看到連線 WiFi 基地台的表單 : 



終端機顯示如下連線訊息 : 

發現來自客戶端的連線 :  ('192.168.4.3', 65205)
GET /192.168.4.1 HTTP/1.1
Host: 192.168.4.1
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1
Accept-Language: zh-TW,zh-Hant;q=0.9
Accept-Encoding: gzip, deflate
Connection: keep-alive

這時在瀏覽器表單中輸入 SSID 與密碼, 按 "連線" 鈕後程式會用 STA 模式的 WLAN 物件去連線此基地台. 如果連線成功, 就將 ok 回應字串插入 html 網頁的 %s 處作為回應網頁以顯示連線成功訊息, 並傳回所取得的 ip (這樣就自動跳出無限迴圈了) :



這時終端機也顯示如下連線成功訊息 : 

發現來自客戶端的連線 :  ('192.168.4.3', 54037)
GET /update_ap?ssid=EDIMAX-tony&pwd=12345678 HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1
Referer: http://192.168.4.1/192.168.4.1
Accept-Language: zh-TW,zh-Hant;q=0.9
Accept-Encoding: gzip, deflate


WiFi 連線成功 :  ('192.168.2.142', '255.255.255.0', '192.168.2.1', '168.95.1.1')
取得 IP : 192.168.2.142
'192.168.2.142'  

最後一列顯示的就是 set_ap() 傳回的 ip 字串. 

如果故意輸入錯誤的基地台 SSID 或密碼讓連線失敗 :




經過 15 秒後連線逾時, 程式就會將 ng 字串嵌入 html 字串的 %s 處作為回應網頁以顯示連線失敗訊息, 並傳回 None  (這樣也會自動跳出無限迴圈) :




這時終端機也顯示如下連線失敗訊息 : 

發現來自客戶端的連線 :  ('192.168.4.3', 55177)
GET /update_ap?ssid=Aaa&pwd=Bbb HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1
Referer: http://192.168.4.1/192.168.4.1
Accept-Language: zh-TW,zh-Hant;q=0.9
Accept-Encoding: gzip, deflate


Wifi 連線逾時!
WiFi 連線失敗 : 請按 Reset 鈕後重設.

因為 set_ap() 傳回 None, 所以不會顯示出來. 不管是連線成功或失敗都會因為傳回 ip 或 None 而跳出 set_ap() 中的無限迴圈, 所以應用程式可以利用傳回值來判斷是否連線成功, 是的話則繼續執行, 否則就必須按下 Reset 鈕重新呼叫 set_ap().

接下來參考之前這篇文章裡的基本應用程式架構來建立一個可現場設定 WiFi 連線功能的 MicroPython 物聯網 App 專案 : 


此處的 App 很簡單, 就是用 PWM 來讓 D1 mini 或 ESP-WROOM-32 板載的 LED (GPIO2) 閃爍, App 程式 app1.py 的內容如下 :

# app1.py
from machine import Pin,PWM

def main():
    #application codes are placed here  
    pwm2=PWM(Pin(2), freq=1, duty=512)

if __name__ == "__main__":  
    main() 

主程式 main.py 如下 :

# main.py
import xtools    
import config
import app1

ip=xtools.connect_wifi_led(config.SSID, config.PASSWORD)
if not ip:
    ip=xtools.set_ap()   # 這是我增添到 xtools 的自訂函式
    if not ip:  
        print('無法連線 WiFi 基地台')
    else:
        print("WiFi 連線成功! IP : ", ip)
        app1.main()  
else:
    print("WiFi 連線成功! IP : ", ip)
    app1.main()

它先呼叫 xtools.connect_wifi_led() 從 config.py 讀取 WiFi 基地台的 SSID 與密碼進行網路連線, 若連線成功會傳回從 DHCP 取得的 IP 字串 (否則是 None), 那就走 else 這個分支印出連線成功訊息後呼叫 app1.py 的 main() 函式讓板載 LED 閃爍. 

如果連線失敗 (SSID 與密碼不正確) 就呼叫 xtools.set_ap() 開啟 ESP32/ESP8266 的 AP 模式並綁定 192.168.4.1 建立一個網頁伺服器, 然後開啟手機 WiFi 連線 SSID 為 MicroPython-xxxxxx 的熱點 (ESP32/ESP8266 AP 模式建立的, 密碼固定為 micropythoN), 完成後打開手機瀏覽器, 連線 192.168.4.1 將出現 WiFi 基地台設定頁面, 設定成功後按開發板的 Reset 鈕就可以連線成功了, 以上作法與程序參考 :


將上面 app1.py 與 main.py 兩個程式上傳到 ESP8266/ESP32 開發板然後按 Reset 鈕

Connecting to network...
network config: ('192.168.43.98', '255.255.255.0', '192.168.43.1', '192.168.43.1')
WiFi 連線成功! IP :  192.168.43.98

MicroPython v1.19.1 on 2022-06-18; ESP module with ESP8266
Type "help()" for more information.

然後故意將開發板根目錄下的 config.py 內容改成一個不存在的 SSID 或錯誤的密碼存檔後按 Reset 鈕, 這時 main.py 會讀到錯誤的連線資訊導致 WiFi 連線失敗, 這時就會呼叫 xtools.set_ap() 建立 ESP32/ESP8266 的本身 AP 熱點, Thonny 底下互動環境視窗出現監聽 192.168.4.1 的訊息 : 

onnecting to network...
Wifi connecting timeout!
網頁伺服器正在監聽 :  ('192.168.4.1', 80)

這時依據如上程序用手機連線 192.168.4.1, Thonny 互動環境出現連線資訊 : 

發現來自客戶端的連線 :  ('192.168.4.2', 61026)
GET / HTTP/1.1
Host: 192.168.4.1
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1
Accept-Language: zh-TW,zh-Hant;q=0.9
Accept-Encoding: gzip, deflate
Connection: keep-alive


發現來自客戶端的連線 :  ('192.168.4.2', 49352)
GET /favicon.ico HTTP/1.1
Host: 192.168.4.1
Connection: keep-alive
DNT: 1
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6

用手機瀏覽器設定好正確的 WiFi 基地台連線資訊按連線鈕 :




連線成功頁面 : 




Thonny 互動環境也出現連線成功訊息 :

WiFi 連線成功 :  ('192.168.43.98', '255.255.255.0', '192.168.43.1', '192.168.43.1')
取得 IP : 192.168.43.98
WiFi 連線成功! IP :  192.168.43.98

MicroPython v1.19.1 on 2022-06-18; ESP module with ESP8266
Type "help()" for more information.
>>> 

連線成功後 main.py 會呼叫 app1.py 的 main() 執行其功能 (此處為用 PWM 閃爍板載 LED). 

沒有留言 :