2022年11月17日 星期四

MicroPython 學習筆記 : 網路存取函式庫

今天來整理陳會安老師的大作 "超簡單Python/MicroPython物聯網應用" 這本書中的網路存取相關函式庫, 以便後續進行測試. 本系列之前的文章參考 :


比較舊的 ESP8266/ESP32 測試文章參考 :


在 CPython 中常用的第三方網路請求模組 requests 在 MicroPython 中已被改寫內建為 urequests 模組, 用法與 requests 相同, 即匯入後呼叫 urequests.get() 與 urequests.post() 進行 GET 與 POST 方法提交網頁請求. 如果請求參數值是英數字, 則使用 urequests 即可; 否則要使用此書作者修改過的 xrequests.py.

除了 MicroPython 內建的 urequests 模組外, 這本書還提供了如下四個自訂模組 :


 自訂模組 說明
 config.py 紀錄 WiFi 基地台 SSID 與連線密碼的檔案
 xtools.py 網路連線工具函式庫
 urlencode.py URL 字串編碼模組
 xrequests.py 改編自內建的 urequests, 添加 params 參數並替 data 參數加上 URL 編碼


其中 urlencode.py 與 xrequests.py 都是從 MicroPython 原始碼中抽出修改而來, 其實這兩個模組都是為了 xtools.py 服務的. 這些好工具可以讓我們在開發物聯網應用時更方便, 程式碼更簡短. 程式員最重要的守則是~不要重複造輪子, 站在巨人的肩膀上看得更遠. 


1. 基地台模組 config.py :

此檔案用來儲存 ESP8266/ESP32 要連線之基地台 SSID 與密碼, 格式如下 :

# config.py
SSID = "<WiFi名稱>"                 # WiFi名稱, 例如 "EDIMAX-tony"
PASSWORD = "<WiFi密碼>"    # WiFi密碼, 例如 "blabla123"

在 xtools.py 模組的連線函式 conect_wifi_led() 會用到其中的 SSID 與 PASSWD 來連線, 這樣要改連其他基地台時只要修改此檔案即可, 而不必去改 connect_wifi_led(). 用法是先匯入 config :

import config

然後用 config.SSID 與 config.PASSWORD 取得設定值, 詳見下面的 xtools.py 模組. 


2. URL 字串編碼模組 urlencode.py : 

此模組的 urlencode(query) 函式用來將字典型式的查詢參數 query 轉成 URL 查詢字串後傳回, 尤其是這些參數值是中文時必須這樣處理轉成 '%' 開頭編碼之字串才行, 例如用 urequests.get() 去觸發 IFTTT 服務時就會用到, 使用方式如下 :

from urlencode import urlencode
import urequests

param={"account": "admin", "pwd": "123456"}
url = "https://abc.com/?" + urlencode(param)
urequest.get(url)

字典 param 經 urlencode() 轉換後會變成 GET 查詢字串 "account=admin&pwd=123456". 

此模組原始碼參考 : 



3. 網頁請求模組 xrequests.py : 

由於 MicroPython 內建的 urequests 模組並無 params 參數, 且 data 參數沒有經過 URL 編碼 (可能是沒考慮非英文參數值), 在進行 GET 與 POST 請求時可能會出問題, 此書作者將 urequests 原始碼取出修改, 添加 params 參數並把 data 參數套用 URL 編碼 (即呼叫上面改良過的 urlencode) 後改名為 xrequests. 下面的 xtools.py 中的 webhook_get() 與 webhook_post() 即使用了 xrequests 模組, 而不是 urequests. 

此模組原始碼參考 :



4. 工具模組 xtools.py : 

此模組的兩個主角為 connect_wifi_led() 與 line_msg() 函式, 前者用來簡化 ESP8266/ESP32 WiFi 連線操作, 後者用來發送 Line 訊息, 此外也收納了一些好用的輔助函式. 為了方便取用, 我將其複製一份在 GitHub :


xtools.py 中的主要函式功能說明如下 : 


 函式 說明
 get_id() 傳回開發板的 MAC 位址字串
 get_num(x) 傳回字串 x 中的數字 (含浮點數)
 random_in_range(low, high) 傳回 low~high 之間的整數亂數 (預設 0~1000)
 map_range(x, in_min, in_max,  out_min, out_max) 將數值 x 從 [in_min, in_max] 映射至 [out_min, out_max] 區間
 conect_wifi_led(ssid, passwd, timeout=15) 以密碼 passwd 連線 ssid 基地台 (15 秒內閃 LED), 成功 LED 恆亮並傳回所獲取之 IP 
 show_error(final_state=0) 閃爍板載 LED 3 秒以表示錯誤 (預設結束後恆亮)
 webhook_post(url, value) 利用 xrequests.py 模組送出 POST 請求, 失敗呼叫 show_error()
 webhook_get(value) 利用 urequests.py 模組送出 GET 請求, 失敗呼叫 show_error()
 line_msg(token, message) 使用 xrequests.py 模組送出 LINE 訊息 (POST)
 format_datetime(localtime) 將元組 localtime 轉成日期時間字串 'YYYY-MM-DD HH:mm:SS'


使用方法是匯入 xtools 後直接呼叫以上這些函式. 例如連線 WiFi 基地台 : 

import xtools
import config

ip=xtools.connect_wifi_led(config.SSID, config.PASSWORD)
print(ip)

可見程式碼非常簡潔. 

注意, 書中範例所用的原始 get_ip() 函式傳回的是 bytes 類型資料, 但實務上較常用的是字串型態, 所以我已經修改 get_id() 函式, 在後面附加呼叫 decode('utf8'), 故傳回值為字串 :

def get_id():
    return ubinascii.hexlify(machine.unique_id()).decode('utf8')    
 
另外, get_id() 其實只是傳回 MAC 位址的後 6 碼而已 (且 high/low 順序顛倒), 完整的 MAC 位址要用 WLAN 物件 (AP/STA 模式均可) 的 config('mac') 方法查詢, 例如 AP 模式下 :

>>> import network   
>>> ap=network.WLAN(network.AP_IF)   
>>> type(ap)   
<class 'WLAN'>     
>>> ap.active(True)     # 啟用 AP 模式   

呼叫 WLAN 物件的 config('mac') 可以取得 bytes 類型的 MAC 位址, 但它是 16 進制表示法, 每一個 byte 前面都有 \x, 這可以用可用 ubinascii 模組的 hexlify() 方法去除, 不過結果仍然是 bytes 類型, 可呼叫 decode('utf8') 方法轉成 UTF-8 字串 : 

>>> mac=ap.config('mac')    
>>> mac  
b'\xea\xdb\x84\xa8\xf5\x14'    
>>> import ubinascii    
>>> ubinascii.hexlify(mac)    
b'eadb84a8f514'  
>>> ubinascii.hexlify(mac).decode('utf8')   
'eadb84a8f514'

hexlify() 函式可傳入每個 byte 16 進制值的分隔字元當第二參數, 例如 ":" :

>>> ubinascii.hexlify(mac, ':')   
b'ea:db:84:a8:f5:14'
>>> ubinascii.hexlify(mac, ':').decode('utf8')    
'ea:db:84:a8:f5:14'   

xtools 透過 machine 模組的 unique_id() 函式取得的其實是 MAC 位址的後六碼加上 '00' :

>>> import machine   
>>> machine.unique_id()    
b'\x14\xf5\xa8\x00'   
>>> ubinascii.hexlify(machine.unique_id()).decode('utf8')    
'14f5a800'
>>> import xtools   
>>> xtools.get_id()   
'14f5a800'

其餘函式會在後續測試中 用到. AP 模式下 ESP32/ESP8266 建立的熱點, 其 SSID 為 "MicroPython-xxxxxx", 後面的 6 個 x 就是用此末六碼, 例如上面這塊板子 AP 模式的 SSID 即為 "MicroPython-a8f514", 密碼固定為 "micropythoN" (注意 N 大寫).


5. 網頁伺服器模組 ESP8266WebServer.py :   

在該書第 14 章介紹的 ESP8266WebServer.py 可用來在 ESP8266/ESP32 開發板上建立網頁伺服器, 比起以前直接用 socket 實作方便得多, 此模組可在 GitHub 下載 :


不過我下載最新版測試書中範例卻出現錯誤, 原因是新版模組中的函式介面有修改, 所以我還是去博碩下載書本範例中所附的 ESP8266WebServer.py, 另存於 GitHub :


使用 Socket 建立網頁伺服器的原始做法參考 : 


將以上五個模組檔案用 AmpyGUI 或 Thonny 上傳到 ESP8266/ESP32 開發板後即可開始用它們來進行網路存取了.





AmpyGUI 可以一次上傳多個檔案, 如果用 Thonny 則一次只能傳一個檔案, 點選左上方本機資料夾底下的檔案, 按滑鼠右鍵選 "上傳到 /" 即可, 參考 :






這樣就準備妥當, 可以開始做實驗了. 


2022-11-18 補充 :

我今天在檢視之前自己寫的 WiFi 連線函式庫時發現掃描附近基地台的函式 scan(), 我將其改名為 scan_ssid() 添加到 xtools.py 裡面 :

def scan_ssid():
    sta=network.WLAN(network.STA_IF)
    sta.active(True)
    aps=sta.scan()
    for ap in aps:
        ssid=ap[0].decode()
        mac=ubinascii.hexlify(ap[1], ':').decode()
        rssi=str(ap[3]) + 'dBm'
        print('{:>20} {:>20} {:>10}'.format(ssid, mac, rssi))

參考 : 


2022-11-22 補充 :

因為 format_datetime() 函式傳回的是 GMT/UTC 時間, 今天改寫了 xtools.py, 新增 import ntptime 與 tw_now() 函式, 可取得台灣時區 (GMT/UTC+8) 目前的時間. 

import ntptime

def tw_now():
    try:
        ntptime.settime()
    except:
        pass
    utc_epoch=time.mktime(time.localtime())
    Y,M,D,H,m,S,ms,W=time.localtime(utc_epoch + 28800)
    t='%s-%s-%s %s:%s:%s' % \
    (Y, pad_zero(M), pad_zero(D), pad_zero(H), pad_zero(m), pad_zero(S))
    return t

參考 :



2022-12-03 補充 :

如上所述, xtools.get_id() 經修改後會傳回 STA 模式 下 WLAN 介面的 MAC 位址字串 (而非原版 xtools 傳回之 bytes 型態), 因為 MAC 位址是唯一的, 因此可用來當作 id. 其實 MAC 位址還可以用 WLAN 物件的 config('mac') 方法求得, 

>>> import network      
>>> sta=network.WLAN(network.STA_IF)     
>>> mac=sta.config('mac')     
>>> mac   
b'\x80}:\xb7\xc0\x8c'   
>>> import ubinascii    
>>> ubinascii.hexlify(mac).decode('utf8')   
'807d3ab7c08c'      
>>> ubinascii.hexlify(mac, ':').decode('utf8')   
'80:7d:3a:b7:c0:8c'

這與 get_id() 用 machine.unique_id() 取得的結果是一樣的 : 

>>> import machine    
>>> ubinascii.hexlify(machine.unique_id()).decode('utf8')    
'807d3ab7c08c'    
>>> ubinascii.hexlify(machine.unique_id(), ':').decode('utf8')    
'80:7d:3a:b7:c0:8c'

因為有時會用到用冒號區隔的 MAC 位址, 所以我用 STA 模式的 WLAN 物件寫了一個 get_mac() 函式添加到 xtools.py 模組中 : 

def get_mac():
    sta=network.WLAN(network.STA_IF)
    mac=sta.config('mac')
    return ubinascii.hexlify(mac, ':').decode('utf8')

注意, 此處必須使用 STA 模式才會取得與 machine.unique_id() 一樣的結果, 如果使用 AP 模式, 則得到的 MAC 位址將與 machine.unique_id() 取得者不同 :  

>>> ap=network.WLAN(network.AP_IF)    
>>> mac=ap.config('mac')   
>>> mac   
b'\x80}:\xb7\xc0\x8d'
>>> ubinascii.hexlify(mac, ':').decode('utf8')      
'80:7d:3a:b7:c0:8d'

可見 AP 模式的 MAC 位址比 STA 模式的多了 1. 


2022-12-03 補充 :

今天完成自訂的 set_ap() 函式添加到 xtools.py 裡面, 利用 AP 模式用手機連線 SSID=MicroPython-xxxxxx, 密碼為 micropythoN 的 ESP32/ESP8266 本身基地台, 用瀏覽器連線 192.168.4.1 會顯示 WiFi 基地台設定網頁, 連線成功會更新 config.py, 可用來現場設定要連線的 WiFi 基地台, 不需要寫死在 config.py 裡 : 

def set_ap(led=2):
    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(led, 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 連線成功
                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))  # 回應連線成功頁面
                for i in range(25):   # 連線成功 : 快閃 5 秒
                    wifi_led.value(0)
                    time.sleep_ms(100)
                    wifi_led.value(1)
                    time.sleep_ms(100)
                cs.close()
                s.close()
                return ip
            else:
                print('WiFi 連線失敗 : 請按 Reset 鈕後重設.')
                wifi_led.value(1)     # 連線失敗 : 熄滅 LED
                cs.send(html % ng)    # 回應連線失敗頁面
                cs.close()
                s.close()
                return None
        else:  # 顯示設定 WiFi 頁面
            cs.send(html % form)  # 回應設定 WiFi 頁面
        cs.close()
        del cs, addr, data, requestxtools

同時也修改了 connect_wifi_led() 內連線成功後的通知方式, 原始的 xtools.py 是將板載 LED 設為 0 點亮 LED, 但這是在 Witty Cloud 與 D1 mini 等板子才會這樣, 在 ESP32-WROOM 卻剛好相反, value(0) 是熄滅 LED, 因此我將其與 set_ap() 一樣改為快閃 : 

    if sta.isconnected():
        for i in range(25):   # 連線成功 : 快閃 5 秒
            wifi_led.value(0)
            time.sleep_ms(100)
            wifi_led.value(1)
            time.sleep_ms(100)
        print("network config:", sta.ifconfig())
        return sta.ifconfig()[0] 

即連線中是慢閃 (300 ms), 連線成功變快閃 (100 ms). 


2022-12-07 補充 : 

經測試發現上面我自訂添加地的 tw_now() 函式有 bug, 在網路未連線或 NTP 伺服器查詢失敗時從 RTC 讀取的本地時間會加上 28800 秒變成超前正確時間 8 小時, 改正如下 :

def tw_now():
    try:
        print('Querying NTP server and set RTC time ... ', end='')   
        ntptime.settime()
        print('OK.')
        delta=28800     # 從 NTP 取得的是 UTC 時間, 要加 28800 秒
    except:
        print('Failed.')
        delta=0             # 從 localtime() 取得的是本地時間, 不必調整
    utc_epoch=time.mktime(time.localtime())
    Y,M,D,H,m,S,ms,W=time.localtime(utc_epoch + delta)
    t='%s-%s-%s %s:%s:%s' % \
    (Y, pad_zero(M), pad_zero(D), pad_zero(H), pad_zero(m), pad_zero(S))
    return t

在連網情況下每次呼叫 tw_now() 會從 NTP 取得 UTC 時間更新 RTC (同步); 在斷網或 NTP 無回應情況下由 localtime() 取得的就是本地時間, 不需加 8 小時, 測試如下 : 

MicroPython v1.19.1 on 2022-06-18; ESP module with ESP8266
Type "help()" for more information.
>>> import xtools    
>>> xtools.tw_now()        # 未連網情況下呼叫 tw_now() 
Querying NTP server and set RTC time ... Failed.   
'2022-12-07 22:02:21'   
>>> xtools.connect_wifi_led()      # 連網
Connecting to network...
network config: ('192.168.2.110', '255.255.255.0', '192.168.2.1', '168.95.1.1')
'192.168.2.110'   
>>> xtools.tw_now()      # 連網後呼叫 tw_now() 但 NTP 無回應
Querying NTP server and set RTC time ... Failed.
'2022-12-07 22:02:58'
>>> xtools.tw_now()      # 連網後呼叫 tw_now() NTP 有回應
Querying NTP server and set RTC time ... OK.
'2022-12-07 22:03:39'

可見不論有無連網都能取得正確的台灣時間 (有連網且 NTP 有回應時就會同步 RTC).

沒有留言 :