2024年10月5日 星期六

MicroPython 學習筆記 : 串接物聯網雲端平台 ThingSpeak (三)

在前一篇測試中我們使用 HTTP GET 方法, 利用 urequests.get() 呼叫 ThingSpeak API 將氣象資料傳送到 ThingSpeak 通道資料表中儲存, 本篇則是要改用 POST 方法, 順便測試原 xtools 函式庫中的 webhook.post() 函式, 它原本是呼叫 xrequests.post(), 但我將其改為 urequests.post(), 如果沒問題就可以拿掉 xrequests.py 了. 


以下實驗是在燒錄 MicroPython v1.23 版韌體的 ESP32-WROOM 開發板上進行. 為了不干擾前一篇實驗的運作, 這次改用 Gmail 申請的 ThingSpeak 帳號來測試, 建立了一個 '高雄的天氣2' 通道, 同樣有 field1 (溫度), field2 (濕度), 與 field3 (氣壓) 三個欄位 : 




點選 API key 頁籤將 WRITE API key 記下來備用 (存入 config.py). 


5. 使用 HTTP POST 方法呼叫 ThingSpeak API : 

先匯入 config 與 xtools 模組呼叫 xtools.coneect_wifi() 連上網路 : 

>>> import xtools   
>>> import config    
>>> ip=xtools.connect_wifi(config.SSID, config.PASSWORD)  
network config: ('192.168.50.9', '255.255.255.0', '192.168.50.1', '192.168.50.1')
'192.168.50.9'

我已經將 ThingSpeak 的通道寫入 API key 儲存於 config.py 的 THINGSPEAK_API_KEY 字串變數中, 只要將其取出即可 :

>>> thingspeak_api_key=config.THINGSPEAK_API_KEY   

接著製作 POST 方法要傳送的資料字典, 將 API key 放入 api_key 鍵, 欄位資料分別放入 filed1~filed8 鍵 :

>>> data={
    'api_key': thingspeak_api_key,
    'field1': 27,
    'field2': 87,
    'field3': 980
    }

在 CPython 中可以在呼叫 requests.post() 時將字典直接傳給 data 參數, 但在 MicroPython 不能這麼做, 因為 MicroPython 的 urequests 模組要求傳給 post() 函式的 data 參數必須是字串或 bytes 類型, 故此處須先呼叫 xtools 的 urlencode() 函式先將 data 字典轉成 URL 字串, 然後呼叫 encode() 以 utf-8 格式把 URL 字串編碼為 bytes 類型資料 : 

>>> payload=xtools.urlencode(data).encode('utf-8')      

這樣就可以匯入 urequests 呼叫其 post() 方法將編碼後的 payload 傳給 data 參數, ThingSpeak 的 POST API 網址如下 :

# https://api.thingspeak.com/update 

>>> import urequests     
>>> url='https://api.thingspeak.com/update'    
>>> r=urequests.post(url, data=payload)    
>>> r.text       
'1'

傳回數值表示請求成功, 1 表示這是向 ThingSpeak 傳送的第一則訊息, 免費帳戶一年內有 300 萬則訊息額度可用. 這時 Dashbord 會在欄位的折線圖上顯示這個數據 :




改用 xtools 的 webhook_post(url, value) 也是可以的, 只要將編碼過的 payload 傳給它的第二參數 value 即可 : 

>>> r=xtools.webhook_post(url, payload)   
invoking webhook
Webhook invoked
>>> r.text    
'2'

傳回 2 表示第二則訊息也傳成功了, 這時 Dashboard 上會顯示兩個相同值的點 : 




注意, xtools 中的 webhook_post() 原本是呼叫原作者自訂 xrequests 模組的 post(), 我已將其改為 MicroPython 內建的 urequests,post() 且傳回回應值 r : 

def webhook_post(url, value):
    print("invoking webhook")
    r = urequests.post(url, data=value)
    if r is not None and r.status_code == 200:
        print("Webhook invoked")
    else:
        print("Webhook failed")
        show_error()
    return r

以上測試確認 webhook_post() 改用 urequests.post() 沒有問題, 應此在新版 xtools 函式庫中我已移除 xrequests.py 這個檔案, 且 xtools.py 中取消匯入 xrequests.py. 

我將上一篇的 weather_app.py 改成如下的 POST 版 : 

# weather_app.py
import xtools    
import config
import time
import urequests
import ujson

def get_weather_now(country, city, api_key):
    url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'
    try:
        res=urequests.get(url)
        data=ujson.loads(res.text)
        if data['cod']==200:    # 注意是數值
            ret={'geocode': data['id'],
                 'description': data['weather'][0]['description'],
                 'temperature': data['main']['temp'],
                 'min_temperature': data['main']['temp_min'], 
                 'max_temperature': data['main']['temp_max'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

def main():
    thingspeak_api_key=config.THINGSPEAK_WRITE_API_KEY   
    weather_api_key=config.WEATHER_API_KEY      
    city='Kaohsiung'     
    country='TW'    
    while True:
        r=get_weather_now(country, city, weather_api_key)
        if r != None:
            temperature=r['temperature']
            humidity=r['humidity']
            pressure=r['pressure']
            url=f'https://api.thingspeak.com/update'
            data={
                'api_key': thingspeak_api_key,
                'field1': temperature,
                'field2': humidity,
                'field3': pressure
                }
            payload=xtools.urlencode(data).encode('utf-8')
            r=urequests.post(url, data=payload)
            print(r.text)
            time.sleep(15)
        else:
            print('Fail to get weather data.')

if __name__ == '__main__':  
    main()  
      
修改之處為藍色粗體部分, 將程式上傳後按 Reset 鈕重啟系統, 就可看到新帳號上的 '高雄的天氣2' 通道每 15 秒更新的氣象資料統計圖 : 




開啟 Sharing 為 "Share chnnel view with everyone" :


不過此通道只是測試 POST 用, 跑一個下午後就會關掉了. 

2024年10月4日 星期五

MicroPython 學習筆記 : 串接物聯網雲端平台 ThingSpeak (二)

今天是山陀兒颱風假第四天, 昨晚暴風圈北移後雨停了, 我外出去餵河堤小咪發現外面滿目瘡痍, 路樹倒了很多, 環保局漏夜清理中, 原以為週五該上班了, 但看到這種情形難怪邁邁要放這史無前例的四天颱風假, 這次的 17 級颱風真的刷新了高雄人自賽洛瑪以來對風災的記憶. 

昨晚註冊了 ThingSpeak 新帳號後, 今天早上繼續測試 ESP32 串接其 API 的實驗, 任務是從  OpenWeatherMap 取得高雄的即時溫溼度與大氣壓資料後傳送至 ThingSpeak 儲存與顯示. 本系列之前的文章參考 :


在前一篇測試中已建立一個 ThingSpeak 通道 (即資料表) 來儲存高雄的氣象資料, 此通道資料表含有下列三個欄位 :
  • field1 : 溫度 (Temperature)
  • field2 : 濕度 (Humidity)
  • field3 : 氣壓 (Pressure)
本篇的測試目標是要將 OpenWeatherMap 取得的溫溼度與氣壓數值透過呼叫 ThingSpeak API 將它們存入通道的欄位中, 關於從 OpenWeatherMap 網站取得氣象資料的做法參考 :



3. 使用 ThingSpeak REST API 上傳資料 : 

ThingSpeak 提供 REST 與 MQTT 兩種 API 讓使用者上傳感測器資料, 官方文件參考 : 


REST API 就是透過網址 URL 向伺服器請求, URL 格式如下 :

https://api.thingspeak.com/update?api_key=<API_KEY>&field1=<V1>&field2=<V2> .....

此 API 會將欄位資料存入通道的欄位裡, 其中 API_KEY 是該通道的 Write API key, field1, field2, filed3 ... 則是該通道的 1~8 欄位, V1, V2, V3, ... 是要寫入各欄位之值.

先匯入 config 與 xtools 模組呼叫 xtools.coneect_wifi() 連上網路 : 

>>> import xtools   
>>> import config    
>>> ip=xtools.connect_wifi(config.SSID, config.PASSWORD)  
network config: ('192.168.50.9', '255.255.255.0', '192.168.50.1', '192.168.50.1')
'192.168.50.9'

設定 api_key 變數製作 API 網址字串, 先用一組固定資料測試看看 : 

>>> api_key='在此填入 ThingSpeak WRITE API key'     
>>> temperature=25.6    
>>> humidity=67     
>>> pressure=998     
>>> url=f'https://api.thingspeak.com/update?api_key={api_key}&field1={temperature}>&field2={humidity}&field3={pressure}'      
>>> url  
'https://api.thingspeak.com/update?api_key=<API_KEY>&field1=25.6>&field2=67&field3=998'

然後匯入 urequests, 呼叫其 get() 函式對此 API 提出 GET 請求 :

>>> r=urequests.get(url)     
>>> r.text   
'2'

傳回值 2 表示是第二次請求, 因為之前我有先用官網教學文件中的 URL 範例提出第一次請求, 直接貼到瀏覽器網址列, 網頁顯示 1 表示是第一次請求 :

https://api.thingspeak.com/update?api_key=<API_KEY>&field1=0 



也可以用 xtools.webhook_get() 呼叫 API : 

>>> r=xtools.webhook_get(url)   
invoking webhook
Webhook invoked
>>> r.text     
'3'

傳回 3 表示這是第三次請求, 這時檢視 Dashboard, 會發現此通道已有此三欄位之資料圖形 :




免費帳戶的 API 請求限制是必須間隔 15 秒以上, 接下來使用 xtools 的 random_in_range() 函式來產生虛擬的氣象資料來跑一跑看看 :

>>> import time    
>>> while True:
    temperature=xtools.random_in_range(10, 40)
    humidity=xtools.random_in_range(50, 100)
    pressure=xtools.random_in_range(600, 1200)
    url=f'https://api.thingspeak.com/update?api_key={api_key}&field1={temperature}&field2={humidity}&field3={pressure}'
    r=urequests.get(url)
    print(r.text)
    time.sleep(15)
    
4
5
6
7
...
390
391
392
393
394
395

跑了 395 筆後結果如下 :




右邊一段空白是我外出時筆電休眠所致. 

接下來要從 OpenWeatherMap 取得氣象資料, 在之前的測試中我們已經將擷取即時氣象資料的程式碼寫成如下的函式 get_weather_now() :

def get_weather_now(country, city, api_key):
    url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'
    try:
        res=urequests.get(url)
        data=ujson.loads(res.text)
        if data['cod']==200:    # 注意是數值
            ret={'geocode': data['id'],
                 'description': data['weather'][0]['description'],
                 'temperature': data['main']['temp'],
                 'min_temperature': data['main']['temp_min'], 
                 'max_temperature': data['main']['temp_max'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

此函式若成功取得氣象資料會傳回一個字典, 失敗傳回 None, 成功的話只要用傳回值字典的 temperature, humidity, 與 pressure 鍵即可取得傳送給 ThingSpeak 所需要的溫溼度與大氣壓等欄位參數. 因此處會用到 ThingSpeak 與 OpenWeatherMap 兩個服務的 API key, 所以我將 OpenWeatherMap 的 API key 變數名稱設為 weather_api_key 以資分別 :

>>> api_key='在此填入 ThingSpeak WRITE API key'   
>>> weather_api_key='在此輸入 OpenWeatherMap 的 API keye'      
>>> city='Kaohsiung'     
>>> country='TW'    
>>> r=get_weather_now(country, city, weather_api_key)        
>>> r['temperature']   
28.91
>>> r['humidity']     
83
>>> r['pressure']    
1010  

然後改寫上面的無窮迴圈, 改為填入從 OpenWeatherMap 取回的即時氣象資料 : 

>>> import time   
>>> while True:  
    r=get_weather_now(country, city, weather_api_key)
    if r != None:
        temperature=r['temperature']
        humidity=r['humidity']
        pressure=r['pressure']
        url=f'https://api.thingspeak.com/update?api_key={api_key}&field1={temperature}&field2={humidity}&field3={pressure}'
        r=urequests.get(url)
        print(r.text)
        time.sleep(15)
    else:
        print('Fail to get weather data.')
        
0
515
516
517
518
...
530
531
532
533
534

結果如下 :




可見與之前的隨機氣象資料比起來, 實際的氣象數值變化不大, 幾乎是一條水平線, 時間要拉長後才看得出起伏變化, 或者把取得氣象資料與上傳 ThingSpeak 的週期加長 (例如 60 秒或 5 分鐘抓一次) 亦可, 畢竟氣象數據變化並沒有那麼快速. 

因為我在前一篇測試中有設定此 ThingSpeak 通道有公開分享, 所以在 Dashboard 上按 Public View 頁籤就可以看到公開檢視的網頁, 上方網址列 URL 如下 :


URL 後面的數值就是此通道的 Channel ID (即資料表編號). 




以上在互動環境完成測試功能正常, 就可以將程式碼寫成如下的 weather_app.py 檔案 :

# weather_app.py 
import xtools    
import config
import time
import urequests
import ujson

def get_weather_now(country, city, api_key):
    url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'
    try:
        res=urequests.get(url)
        data=ujson.loads(res.text)
        if data['cod']==200:    # 注意是數值
            ret={'geocode': data['id'],
                 'description': data['weather'][0]['description'],
                 'temperature': data['main']['temp'],
                 'min_temperature': data['main']['temp_min'], 
                 'max_temperature': data['main']['temp_max'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

def main():
    thingspeak_api_key=config.THINGSPEAK_WRITE_API_KEY   
    weather_api_key=config.WEATHER_API_KEY      
    city='Kaohsiung'     
    country='TW'    
    while True:
        r=get_weather_now(country, city, weather_api_key)
        if r != None:
            temperature=r['temperature']
            humidity=r['humidity']
            pressure=r['pressure']
            url=f'https://api.thingspeak.com/update?api_key={thingspeak_api_key}&field1={temperature}&field2={humidity}&field3={pressure}'
            r=urequests.get(url)
            print(r.text)
            time.sleep(15)
        else:
            print('Fail to get weather data.')

if __name__ == '__main__':  
    main()  
      
主程式是放在 main() 中, 它會先從 config.py 中讀取 THINGSPEAK_WRITE_API_KEY 與 WEATHER_API_KEY 這兩個金鑰字串, 然後進入一個無窮迴圈每隔 15 秒呼叫 get_weather_now() 函式向 OpenWeatherMap 擷取高雄氣象資料, 然後呼叫 ThingSpeak API 將溫溼度與氣壓寫入通道欄位中. 

然後寫一個主程式 main.py 負責連網 : 

import xtools    
import config
import weather_app    

ip=xtools.connect_wifi(config.SSID, config.PASSWORD)
mac=xtools.get_id()
print('ip: ', ip)
print('mac: ', mac)
if not ip: # 連線失敗傳回 None
    print('無法連線 WiFi 基地台')
else:      # 連線成功傳回 ip 字串: 執行 app
    weather_app.main() # 連線成功才執行 App 程式

此主程式利用 xtools.connect_wifi() 連線 WiFi 基地台, 連網成功會傳回 IP 字串, 這時就呼叫執行上面 weather_app.py 中的 main() 函式; 連網失敗傳回 None 就印出連線失敗訊息而結束程式. 

不論是 WiFi 連網的基地台帳密或 OpenWeatherMap 與 ThingSpeak 的 API key 都寫入 config.py 裡的變數中, 再於程式中讀取出來 : 




以上程式架構原理參考 :


簡言之, MicroPython 韌體其實就是一個微作業系統, 它擁有一個檔案系統與精簡版的 Python 3 函式庫, 以及一個 Python 互動執行環境. 當開發板硬啟動時 (即按下 Reset 鈕或插上電源), 此微作業系統會先去執行根目錄下的 boot.py, 接著執行 main.py (如果有的話). 

應用程式可以直接寫在 main.py, 也可以寫在各個 app.py 程式檔中, 可於 main.py 裡切換鑰呼叫哪個 app.py 中的 main(). boot,py 的作用類似 Arduino 程式中的 setup(); 而 main() 或 app.py 裡的無窮迴圈則相當於 Arduino 裡的 loop(). 


4. 使用 ThingSpeak 的小工具 (Widget) : 

ThingSpeak 除了預設用折線圖來顯示通道中各欄位值的變化外, 還提供了三個小工具來呈現資料內容 : 
  • Gauge 量表 : 以指針表顯示欄位的即時數值
  • Numeric Display 數位顯示器 : 直接顯示即時欄位值
  • Lamp Indicator : 可設定條件, 當欄位值符合時點亮指示燈號
在 Dashboard 按 "Add widgets" 鈕新增小工具 :




在彈出視窗中點選鑰新增的小工具類型, 例如 Gauge, 按 Next : 




於彈出視窗中勾選鑰顯示的欄位 (此處選擇溫度), 填寫名稱與單位等, 其中 range 可設定量表的數值範圍以指定之顏色顯示 (此處設 35~100 表示高溫區), 按 Create 新增 : 




這時 Dashboard 就會出現一個即時的溫度欄位量表 : 



重複上面操作添加 Numeric Display 小工具來顯示欄位 3 大氣壓 (單位 hPa) : 




結果如下 : 




最後新增 Lamp Indicator, 設定當 filed2 的溼度大於 90% 時讓顯示燈亮 :




結果如下 :




因目前濕度尚未達到 90 以上門檻, 故指示燈未亮. 至此全部 Dahboard 外觀如下 :




以上測試之程式碼壓縮檔可從 GitHub 下載 (使用前要先編籍 config.py) :


MicroPython 學習筆記 : 串接物聯網雲端平台 ThingSpeak (一)

ThingSpeak 是一個老牌的雲端 IoT 物聯網網站, 可用來儲存與顯示感測器輸出的資料, 此網站在十年前我開始玩 Arduino 時就非常有名, 但它後來被圖控軟體大廠 MathWorks 併購 (其知名產品即 MATLAB), 使用者帳號系統也被整合, 我之前申請的帳號太久沒使用也必須重設才行, 所以今天乾脆用備用手機 iPhone 的 Gmail 信箱來申請一個全新的帳號. 


1. 註冊 ThingSpeak 帳號 : 

ThingSpeak 官網網址如下 :


點 Email 框下方的 Create one! 超連結 : 


填寫 Email Address, 勾選 Location, 填寫姓名後按 Continue :



偵測到所填寫的 Email Address 是個人信箱, 請勾選 Email 欄位下的 Use this email for my Mathworks Account 後按 Continue : 



通知去收信, 按下信中的確認按鈕後再回來此頁按 Continue :




按下確認信中的 Verify email 鈕 : 




選擇美國/歐洲網站伺服器, 預設選美國 :




看到 profile verified 表示信箱認證完成 : 



回到上面註冊頁面按 Continue 後設定密碼 (至少 8 個字元, 須含一個大寫與小寫字母), 勾選 I accept the Online Service Aggrement 後按 Continue :




這樣便完成帳號註冊了, 按 OK 會詢問申請帳號之用途 :




按 OK 會即進入通道設定頁面. 


2. 新增通道 Channel 與欄位 Field : 

在通道設定頁面按 New Channel 鈕新增通道 (Channel), 所謂通道其實就是 ThingSpeak 資料庫中的一張資料表, 裡面有欄位名稱為 field1, field2, field3, ... 等順序編號的固定欄位名稱 (免費帳戶每個通道只有 8 個欄位) : 




填寫通道名稱, 描述, 與欄位 (Field) 名稱後按底下的 Save Channel 鈕 :





這時會進入 Dashboard 頁面, 上面的 Channel ID 要記下來, 這在呼叫 ThingSpeak API 存取通道時會用到. 底下則是此通道各欄位資料的圖形框, 因為現在還沒有傳送資料儲存在 ThingSpeak, 所以目前沒有資料圖形出現 : 




呼叫 ThingSpeak API 需要取得其金鑰, 按上方導覽頁籤 API key 會看到有寫入與讀取兩個 API key (注意, 每個通道的 key 都不同以資識別), 將資料寫入 ThingSpeak 資料庫用的是寫入的 API key, 這要與 Channel ID 一起記下來備用 : 




然後切到 Sharing 頁籤, 因我這個氣象資料是要讓任何人都能看到, 所以在 Channel sharing settings 中要改為勾選中間的 Share channel view with everyone



至此就完成全部設定了, 可以按 New Channel 鈕繼續新增其他通道, 點選 Dashboard 上方 Channels 選單的 My Channels 會顯示全部通道清單 :




按右上角帳戶頭像點選 My Account 會顯示帳戶基本資訊, 可見免費帳戶最多可以建立 4 個通道,  使用期限為一年 (到期可付費繼續使用), 免費總訊息量為 300 萬則, 亦即這些免費額度須在一年內用完, 到期就須付費了 :




按右上角帳戶頭像點選 My Profile 會顯示帳戶詳細資訊, 例如註冊之 Email, 使用者名稱, 時區, 帳戶之 API key, 以及 REST API 呼叫的網址格式等 : 




可以按 Edit 修改時區欄位為台灣時區, 注意, ThingSpeak 伺服器是依據此選項紀錄欄位資料寫入資料表之時戳, 若沒改為自己的時區的話, 則 Dashboard 顯示的統計圖上的數據點時間軸會是預設時區的時間 :




右下角是 REST API 格式的說明 : 




按 Learn More 前往 REST API 教學文件 :


我們主要會用到的是用下列格式的 REST API 網址向 ThingSpeak 提出 GET 請求 : 

https://api.thingspeak.com/update.?api_key=<API_KEY>&field1=<V1>&field2=<V2> .....

此 API 會將欄位資料存入通道的欄位裡, 其中 API_KEY 是該通道的 Write API key, field1, field2, filed3 ... 則是該通道的 1~8 欄位, V1, V2, V3, ... 是要寫入各欄位之值.

下一步即可開始撰寫程式呼叫 ThingSpeak API 了.

2024年10月3日 星期四

MicroPython 學習筆記 : 手刻 strftime() 與 strptime() 函式

在 CPython 中處理日期時間最常用的內建模組是 datetime, 特別是要將 datetime 物件格式化成日期時間字串時的 strftime() 與將日期時間字串轉回 datetime 物件的 strptime() 這兩個方法/函式最常用 (兩者是反運算). 

在 CPython 中, 要將日期時間字串轉成 datetime 物件可以直接呼叫 datetime 類別的靜態方法 strptime() 例如 : 

>>> from datetime import datetime  
>>> datetime.strptime('2024-10-03 09:04:35.123456', '%Y-%m-%d %H:%M:%S.%f')    
datetime.datetime(2024, 10, 3, 9, 4, 35, 123456)    

也可以呼叫 datetime 物件的 strptime() 方法, 例如 : 

>>> from datetime import datetime  
>>> today=datetime.today()   # 用 now() 也可以
>>> today    
datetime.datetime(2024, 10, 3, 9, 11, 55, 129274)   
>>> today.strptime('2024-10-03 09:04:35.123456', '%Y-%m-%d %H:%M:%S.%f')    
datetime.datetime(2024, 10, 3, 9, 4, 35, 123456)

此處我們只是借用 today 這個 datetime 物件的 strptime() 方法而已, strptime() 實際要轉換的日期時間字串內容與 today 物件自己的日期時間內容完全無關, 而是由傳入的第一參數 (日期時間字串). 

但是將 datetime 物件轉成日期時間字串的 strftime() 則必須使用物件方法, 因為它的傳入參數只有格式化串一個而已 (datetime 類別的 strftime() 靜態方法也是), 所以轉換的對象就是 datetime 物件本身 : 

>>> today.strftime('%Y-%m-%d %H:%M:%S.%f')    
'2024-10-03 09:11:55.129274'   

總之, 在 CPython 環境中, strptime() 既可以當類別的靜態方法用, 也可以當物件方法用; 但 strftime() 則只能當物件方法用, 參考 : 

但是很可惜, MicroPython 並未實作 datetime 模組, 不論是匯入 datetime 或 udatetime 都會出錯 :

>>> import datetime   
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: no module named 'datetime'
>>> import udatetime    
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: no module named 'udatetime'

所以在 MicroPython 中如果要用到 strftime() 與 strptime() 的功能必須手刻, 但不需要完全實作 CPython 的 strftime() 與 strptime() 之完整功能, 只要針對物聯網最常用的 "%Y-%m-%d %H:%M:%S" 格式進行轉換即可. 

strftime() 須用到 time 模組來產生日期時間物件, 其 localtime() 函式會傳回年月日時分秒的日期時間 tuple 物件, 透過字串的 replace() 方法將格式化字串中的相對應子字串用 tuple 中的元素取代. 傳入參數為格式化字串 format_str 與日期時間元組 dt (預設為 None 表示目前的日期時間), 傳回值為替換後的格式化字串 :  

import time

def strftime(dt=None, format_str="%Y-%m-%d %H:%M:%S"):
    if dt is None:
        dt=time.localtime()
    return format_str.replace("%Y", str(dt[0])) \
                        .replace("%m", "{:02d}".format(dt[1])) \
                        .replace("%d", "{:02d}".format(dt[2])) \
                        .replace("%H", "{:02d}".format(dt[3])) \
                        .replace("%M", "{:02d}".format(dt[4])) \
                        .replace("%S", "{:02d}".format(dt[5]))

此函式兩個參數皆有預設值, 未傳入參數時就傳回目前時間的格式化字串, 傳入年月日時分秒 tuple 時小於 10 也會補 0, 測試如下 :

>>> now=time.localtime()    
>>> now   
(2024, 10, 3, 9, 28, 40, 3, 277)
>>> strftime(now)      
'2024-10-03 09:28:40'
>>> strftime()                # 未傳參數 : 現在時間
'2024-10-03 11:59:48'
>>> strftime((2024, 1 , 1, 11, 2, 3))     # 有傳參數 : 指定時間
'2024-01-01 11:02:03'

而 strptime() 函式則是傳入日期時間字串 dt_str 與格式化字串 format_str, 經過字串剖析後取出年月日時分秒各元素後組成 tuple 物件傳回 : 

def strptime(dt_str, format_str="%Y-%m-%d %H:%M:%S"):
    if format_str=="%Y-%m-%d %H:%M:%S":
        year=int(dt_str[0:4])
        month=int(dt_str[5:7])
        day=int(dt_str[8:10])
        hour=int(dt_str[11:13])
        minute=int(dt_str[14:16])
        second=int(dt_str[17:19])
        return (year, month, day, hour, minute, second, 0, 0, 0)
    else:
        raise ValueError("Unsupported format string")

測試如下 :

>>> dt=strptime('2024-10-03 09:28:40')   
>>> dt   
(2024, 10, 3, 9, 28, 40, 0, 0, 0)

檢視 xtools 函式庫已有一個類似 strftime() 功能的函式 format_datetime() : 

def format_datetime(local_time):
    Y,M,D,H,m,S,W,ds = local_time
    t = str(Y) + '-'
    t += pad_zero(M)
    t += '-'
    t += pad_zero(D)
    t += ' '
    t += pad_zero(H)
    t += ':'
    t += pad_zero(m)
    t += ':'
    t += pad_zero(S)
    return t

它也能將傳入的日期時間元組轉成日期時間字串傳回 : 

>>> import xtools   
>>> import time 
>>> now=time.localtime()   
>>> now     
(2024, 10, 3, 12, 23, 58, 3, 277) 
>>> xtools.format_datetime(now)         
'2024-10-03 12:23:58'

不過要注意的是如果要自行傳入日期時間元組給 xtools.format_datetime() 時必須傳入 8 個元素 (後面兩個設為 0 即可), 不能只傳年月日時分秒 6 個:

>>> xtools.format_datetime((2024, 10, 3, 12, 34, 56))    
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "xtools.py", line 191, in format_datetime
ValueError: need more than 6 values to unpack   
>>> xtools.format_datetime((2024, 10, 3, 12, 34, 56, 0, 0))     
'2024-10-03 12:34:56'

我已將此兩函式加入 xtools 函式庫中, 因為我覺得函式名稱較習慣 :

>>> import time  
>>> now=time.localtime()     
>>> xtools.strftime(now)     
'2024-10-03 10:24:18'
>>> xtools.strftime((2024, 10, 3, 12, 34, 56))    
'2024-10-03 12:34:56'
>>> dt=xtools.strptime('2024-10-03 10:24:18')   
>>> dt   
(2024, 10, 3, 10, 24, 18, 0, 0, 0)

2024年10月2日 星期三

MicroPython 學習筆記 : 從 OpenWeather 網站取得氣象資料

今天是山陀兒颱風假的第二天, 昨天在家完成兩套公司維運軟體改版, 今天要來做自己的 ESP32 實驗了. 為了在 xtools 函式庫改版中徹底踢掉 xrequests 模組, 過去這一周來回翻看陳會安老師的大作 "超簡單 Python/MicroPython 物聯網應用", 覺得陳老師的書編得真好, 但此書已出版三年需要改版, 例如 IFTTT Webhook 改收費應該用其它資源取代 (例如 Make). 

今天要測試的項目是書中第 9-6 與 10-5 節, 透過 OpenWeatherMap API 取得指定城市氣象資訊, 然後用 Line Notify 發送訊息, 順便測試 xtools 函式庫中的 webhook_get() 與 webhook_post() 函式. 使用 OpenWeatherMap API 須先註冊帳號取得 API key, 參考 :


以下測試是在 ESP32-WROOM-32E 開發板上進行 (MicroPython v1.23 韌體), 使用上週改版後的 xtools 函式庫, 參考 :


開發板接上筆電 USB 槽, 點選 Thonny 的 "執行/設定直譯器" :




點選 "MicroPython(ESP32)" 與勾選正確連接埠 : 




然後點選 "停止/重新啟動後端程式", 這樣開發板就會軟啟動並出現互動環境提示號, 就可以輸入 Python 程式碼了. 首先匯入 xtools 函式庫與設定檔 config.py 並呼叫 xtools.connect_wifi() 連上網路後即可進行測試 : 


 

MicroPython v1.23.0 on 2024-06-02; Generic ESP32 module with ESP32
Type "help()" for more information.

>>> import xtools   
>>> import config    
>>> ip=xtools.connect_wifi(config.SSID, config.PASSWORD)      
Connecting to network...
network config: ('192.168.50.9', '255.255.255.0', '192.168.50.1', '192.168.50.1')


1. 取得即時氣象資訊 : 

連上網路後匯入內建模組 urequests 與 ujson, 指定城市, 國家, 與 API key 製作 OpenWeatherMap API 的網址, 傳給 urequests.get() 向 API 請求提供即時的氣象資訊, 傳回值是一個 JSON 字串, 使用 ujson.loads() 將其轉成 Python 字典即可透過屬性與索引取得溫溼度氣壓等資料 : 

>>> import urequests   
>>> import ujson   
>>> api_key='在此輸入 OpenWeatherMap 的 API key'    
>>> city='Kaohsiung'   
>>> country='TW'   
>>> url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'    
>>> r=urequests.get(url)   
>>> data=ujson.loads(r.text)    # 轉成字典
>>> data   
{'timezone': 28800, 'rain': {'1h': 5.84}, 'cod': 200, 'dt': 1727806162, 'base': 'stations', 'weather': [{'id': 502, 'icon': '10n', 'main': 'Rain', 'description': '\u5927\u96e8'}], 'sys': {'country': 'TW', 'sunrise': 1727819425, 'sunset': 1727862335, 'id': 2002588, 'type': 2}, 'clouds': {'all': 100}, 'coord': {'lon': 120.3133, 'lat': 22.6163}, 'name': 'Kaohsiung City', 'visibility': 6655, 'wind': {'gust': 8.94, 'speed': 3.13, 'deg': 70}, 'main': {'feels_like': 24.86, 'pressure': 1003, 'temp_max': 23.91, 'temp': 23.91, 'temp_min': 23.91, 'humidity': 96, 'sea_level': 1003, 'grnd_level': 1002}, 'id': 1673820}

最後面的 id 屬性值 1673820 為城市 city 之地理編碼 (geocode), 這在後面取得未來一周氣象預報時會用到 (注意, 傳回值中有好幾個 id 屬性, geocode 是最上層的那個 id 屬性); 字典的 weather 屬性值存放溫溼度大氣壓等氣象資訊, 其中 description 欄位含有中文天氣描述之 Unicode, 用 print() 即可轉碼為中文字元 : 

>>> data['weather'][0]['description']    # 氣象描述會用地區語言之 Unicode 表示
'\u5927\u96e8'
>>> print(data['weather'][0]['description'])      
大雨
>>> print(data['main']['temp'])     
23.91
>>> print(data['main']['temp_min'])    
23.91
>>> print(data['main']['temp_max'])     
23.91
>>> print(data['main']['pressure'])     
1003
>>> print(data['main']['humidity'])      
96
>>> geocode=data['id']    # 城市 city='Kaohsiung' 的地理編碼
>>> geocode   
1673820 

可以將溫濕度與氣壓等訊息透過 xtools.line_msg() 傳到手機 LINE 上 :

>>> token='在此輸入 LINE Notify token'  
>>> weather=f"\n{city} 天氣: {data['weather'][0]['description']}\n" +\
    f"氣溫: {data['main']['temp']}\n" +\
    f"濕度: {data['main']['humidity']}\n" +\
    f"氣壓: {data['main']['pressure']}"    
>>> xtools.line_msg(token, weather)       
Message has been sent.

結果如下 : 




可以將上面的程式碼寫成函式 get_weather_now() :

def get_weather_now(country, city, api_key):
    url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'
    try:
        res=urequests.get(url)
        data=ujson.loads(res.text)
        if data['cod']==200:    # 注意是數值
            ret={'geocode': data['id'],
                 'description': data['weather'][0]['description'],
                 'temperature': data['main']['temp'],
                 'min_temperature': data['main']['temp_min'], 
                 'max_temperature': data['main']['temp_max'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

注意這裡 MicroPython 只能捕捉 Exception, 因為它沒有實作 Exceptions 類別. 使用時先匯入 urequests 與 ujson, 定義 country, city, 與 api_key 變數, 呼叫 get_weather_now() 時將這些變數傳進去就可以取得氣象資料字典 :

>>> import urequests  
>>> import ujson   
>>> api_key='在此輸入 OpenWeatherMap 的 API key'    
>>> city='Kaohsiung'   
>>> country='TW' 
>>> r=get_weather_now(country, city, api_key)    
>>> r    
{'presure': 1002, 'temperature': 25, 'min_temperature': 23.05, 'humidity': 91, 'geocode': 1673820, 'description': '\u9663\u96e8', 'max_temperature': 25.02}
>>> print(r['description'])   
陣雨
>>> r['geocode']   
1673820


2. 取得未來五日氣象預報資訊 : 

也可以利用上面即時氣象查得的 geocode 與 API key 去查詢 API 取得未來一周天氣預報資料, 氣象預報 API 網址格式如下 :

url=f'http://api.openweathermap.org/data/2.5/forecast?id={geocode}&appid={api_key}'

其中 geocode 為城市之地理編碼, 也就是上面查詢即時氣象時傳回值中的 id 屬性. 

>>> geocode=1673820    # 高雄市之地理編碼
>>> api_key='在此輸入 OpenWeatherMap 的 API key'   
>>> url=f'http://api.openweathermap.org/data/2.5/forecast?id={geocode}&appid={api_key}'   
>>> r=urequests.get(url)   
>>> predict=ujson.loads(r.text)   
>>> len(predict)    
5
>>> predict.keys()    
dict_keys(['cnt', 'list', 'city', 'message', 'cod'])   

可見傳回值字典含有 5 個屬性, 其中 city 是關於城市的經緯度, 國家, 時區等地理資訊 :

>>> predict['city']    
{'coord': {'lat': 22.6163, 'lon': 120.3133}, 'id': 1673820, 'name': 'Kaohsiung City', 'sunrise': 1727819425, 'sunset': 1727862335, 'country': 'TW', 'timezone': 28800, 'population': 0}    

氣象預報資料放在 list 屬性裡, 其值為長度 40 的串列, 氣象預報資料為間隔 3 小時預報一次, 一天會有 8 筆資料, 五天就是 40 筆 : 

>>> len(predict['list'])    
40

檢視第一筆與最後一筆預報資料 : 

>>> predict['list'][0]   
{'sys': {'pod': 'n'}, 'visibility': 2758, 'pop': 1, 'rain': {'3h': 21.77}, 'dt': 1727870400, 'main': {'pressure': 1001, 'grnd_level': 1001, 'temp_kf': 0.21, 'feels_like': 298.65, 'sea_level': 1001, 'temp_max': 297.73, 'temp_min': 297.52, 'temp': 297.73, 'humidity': 92}, 'clouds': {'all': 100}, 'weather': [{'id': 502, 'icon': '10n', 'main': 'Rain', 'description': 'heavy intensity rain'}], 'wind': {'gust': 18.36, 'speed': 11.21, 'deg': 93}, 'dt_txt': '2024-10-02 12:00:00'}
>>> predict['list'][39]    
{'sys': {'pod': 'd'}, 'visibility': 10000, 'pop': 0.37, 'dt': 1728291600, 'main': {'pressure': 1012, 'grnd_level': 1011, 'temp_kf': 0, 'feels_like': 305.52, 'sea_level': 1012, 'temp_max': 302.07, 'temp_min': 302.07, 'temp': 302.07, 'humidity': 69}, 'clouds': {'all': 9}, 'weather': [{'id': 800, 'icon': '01d', 'main': 'Clear', 'description': 'clear sky'}], 'wind': {'gust': 2.72, 'speed': 3.07, 'deg': 275}, 'dt_txt': '2024-10-07 09:00:00'}

主要欄位如下 : 
  • 'dt_txt' : 預報時間
  • 'main' : 主要之氣象資訊, 放在子欄位 'temp' (華氏溫度), 'pressure' (大氣壓), 'humidity' (濕度)
  • 'weather' : 子欄位 'main' 為天氣主分類, 'description' 為較詳細描述
例如 :

>>> predict['list'][0]['dt_txt']   
'2024-10-02 12:00:00'
>>> predict['list'][0]['main']['temp']   
297.73
>>> predict['list'][0]['main']['humidity']    
92
>>> predict['list'][0]['main']['pressure']   
1001
>>> predict['list'][0]['weather'][0]['main']   
'Rain'
>>> predict['list'][0]['weather'][0]['description']   
'heavy intensity rain'

以上程式碼可以寫成函式 get_weather_forecast() :

def get_weather_forecast(geocode, api_key):
    url=f'http://api.openweathermap.org/data/2.5/forecast?id={geocode}&appid={api_key}'
    try:
        res=urequests.get(url)
        data=ujson.loads(res.text)
        ret=[]
        if data['cod']=='200':     # 注意是字串
            for i in data['list']:
                d={'time': i['dt_txt'],
                   'temperature': round(i['main']['temp']-273.15),    # 卡式溫度轉攝氏
                   'humidity': i['main']['humidity'],
                   'pressure': i['main']['pressure'],
                   'description': i['weather'][0]['description']}
                ret.append(d)
            return ret
        else:
            return None
    except Exception as e:
        return None

注意, OpenWeatherMap 的未來五天氣象預報 API 與即時氣象 API 的傳回值有三個不同 :
  • 即時氣象的溫度為攝氏溫度, 未來五天的卻是卡氏絕對溫度.
  • 即時氣象的回應狀態碼 cod 欄位值為數值 (例如 200), 未來五天的卻是字串 (例如 '200'). 
  • 即時氣象的天氣描述 (description) 為中文; 未來五天的是英文, 
此函式已將卡氏溫度轉成攝氏溫度. 

只要匯入 urequests 與 ujson, 設定 geocode 與 api_key 變數, 將其傳入 get_weather_forecast() 即可取得未來五天的氣象預報資料字典串列 :

>>> import urequests  
>>> import ujson   
>>> api_key='在此輸入 OpenWeatherMap 的 API key'    
>>> r=get_weather_forecast(geocode, api_key)    
>>> r     
[{'temperature': 25, 'pressure': 1001, 'description': 'heavy intensity rain', 'time': '2024-10-02 15:00:00', 'humidity': 87}, {'temperature': 25, 'pressure': 1000, 'description': 'moderate rain', 'time': '2024-10-02 18:00:00', 'humidity': 88}, {'temperature': 25, 'pressure': 998, 'description': 'heavy intensity rain', 'time': '2024-10-02 21:00:00', 'humidity': 90}, {'temperature': 25, 'pressure': 995, 'description': 'heavy intensity rain', 'time': '2024-10-03 00:00:00', 'humidity': 91}, {'temperature': 24, 'pressure': 995, 'description': 'heavy intensity rain', 'time': '2024-10-03 03:00:00', 'humidity': 92}, {'temperature': 24, 'pressure': 999, 'description': 'very heavy rain', 'time': '2024-10-03 06:00:00', 'humidity': 93}, {'temperature': 24, 'pressure': 1005, 'description': 'heavy intensity rain', 'time': '2024-10-03 09:00:00', 'humidity': 93}, {'temperature': 24, 'pressure': 1008, 'description': 'moderate rain', 'time': '2024-10-03 12:00:00', 'humidity': 91}, {'temperature': 26, 'pressure': 1009, 'description': 'light rain', 'time': '2024-10-03 15:00:00', 'humidity': 87}, {'temperature': 25, 'pressure': 1009, 'description': 'light rain', 'time': '2024-10-03 18:00:00', 'humidity': 86}, {'temperature': 25, 'pressure': 1009, 'description': 'light rain', 'time': '2024-10-03 21:00:00', 'humidity': 85}, {'temperature': 25, 'pressure': 1011, 'description': 'overcast clouds', 'time': '2024-10-04 00:00:00', 'humidity': 83}, {'temperature': 27, 'pressure': 1012, 'description': 'overcast clouds', 'time': '2024-10-04 03:00:00', 'humidity': 75}, {'temperature': 27, 'pressure': 1010, 'description': 'overcast clouds', 'time': '2024-10-04 06:00:00', 'humidity': 73}, {'temperature': 27, 'pressure': 1010, 'description': 'overcast clouds', 'time': '2024-10-04 09:00:00', 'humidity': 76}, {'temperature': 26, 'pressure': 1012, 'description': 'light rain', 'time': '2024-10-04 12:00:00', 'humidity': 80}, {'temperature': 26, 'pressure': 1012, 'description': 'overcast clouds', 'time': '2024-10-04 15:00:00', 'humidity': 80}, {'temperature': 26, 'pressure': 1010, 'description': 'overcast clouds', 'time': '2024-10-04 18:00:00', 'humidity': 81}, {'temperature': 26, 'pressure': 1011, 'description': 'overcast clouds', 'time': '2024-10-04 21:00:00', 'humidity': 81}, {'temperature': 27, 'pressure': 1012, 'description': 'overcast clouds', 'time': '2024-10-05 00:00:00', 'humidity': 76}, {'temperature': 29, 'pressure': 1012, 'description': 'overcast clouds', 'time': '2024-10-05 03:00:00', 'humidity': 66}, {'temperature': 30, 'pressure': 1010, 'description': 'light rain', 'time': '2024-10-05 06:00:00', 'humidity': 67}, {'temperature': 29, 'pressure': 1010, 'description': 'light rain', 'time': '2024-10-05 09:00:00', 'humidity': 70}, {'temperature': 29, 'pressure': 1012, 'description': 'light rain', 'time': '2024-10-05 12:00:00', 'humidity': 73}, {'temperature': 29, 'pressure': 1012, 'description': 'light rain', 'time': '2024-10-05 15:00:00', 'humidity': 74}, {'temperature': 28, 'pressure': 1011, 'description': 'light rain', 'time': '2024-10-05 18:00:00', 'humidity': 74}, {'temperature': 28, 'pressure': 1012, 'description': 'light rain', 'time': '2024-10-05 21:00:00', 'humidity': 76}, {'temperature': 28, 'pressure': 1013, 'description': 'light rain', 'time': '2024-10-06 00:00:00', 'humidity': 73}, {'temperature': 30, 'pressure': 1013, 'description': 'scattered clouds', 'time': '2024-10-06 03:00:00', 'humidity': 67}, {'temperature': 30, 'pressure': 1011, 'description': 'scattered clouds', 'time': '2024-10-06 06:00:00', 'humidity': 65}, {'temperature': 29, 'pressure': 1011, 'description': 'light rain', 'time': '2024-10-06 09:00:00', 'humidity': 68}, {'temperature': 29, 'pressure': 1013, 'description': 'light rain', 'time': '2024-10-06 12:00:00', 'humidity': 73}, {'temperature': 28, 'pressure': 1013, 'description': 'light rain', 'time': '2024-10-06 15:00:00', 'humidity': 74}, {'temperature': 28, 'pressure': 1012, 'description': 'overcast clouds', 'time': '2024-10-06 18:00:00', 'humidity': 74}, {'temperature': 27, 'pressure': 1012, 'description': 'light rain', 'time': '2024-10-06 21:00:00', 'humidity': 74}, {'temperature': 28, 'pressure': 1014, 'description': 'overcast clouds', 'time': '2024-10-07 00:00:00', 'humidity': 71}, {'temperature': 29, 'pressure': 1013, 'description': 'overcast clouds', 'time': '2024-10-07 03:00:00', 'humidity': 68}, {'temperature': 29, 'pressure': 1011, 'description': 'overcast clouds', 'time': '2024-10-07 06:00:00', 'humidity': 69}, {'temperature': 29, 'pressure': 1011, 'description': 'overcast clouds', 'time': '2024-10-07 09:00:00', 'humidity': 70}, {'temperature': 28, 'pressure': 1013, 'description': 'broken clouds', 'time': '2024-10-07 12:00:00', 'humidity': 73}]

第一筆與最後一筆氣象預測資料如下 :

>>> r[0]    
{'temperature': 25, 'pressure': 1001, 'description': 'heavy intensity rain', 'time': '2024-10-02 15:00:00', 'humidity': 87}
>>> r[39]   
{'temperature': 28, 'pressure': 1013, 'description': 'broken clouds', 'time': '2024-10-07 12:00:00', 'humidity': 73}

可見溫度都已轉成攝氏了. 

如果要從這 40 個預報資料中擷取固定時段的資料, 例如未來五天每天早上六點的預報資料, 由於 MicroPython 未實作 datetime 模組, 所以可以用字串拆解比對方式針對 time 欄位進行過濾, 例如 :

>>> result=[]   
>>> for r in ret:    
    # 取出 'time' 屬性值的時間部分
    time_str=r['time'].split(' ')[1]  # '2024-10-02 06:00:00' -> '06:00:00'    
    # 比對是否為 06:00:00
    if time_str == '06:00:00':   
        result.append(r)    
>>> result     
[{'temperature': 24, 'pressure': 999, 'description': 'very heavy rain', 'time': '2024-10-03 06:00:00', 'humidity': 93}, {'temperature': 27, 'pressure': 1010, 'description': 'overcast clouds', 'time': '2024-10-04 06:00:00', 'humidity': 73}, {'temperature': 30, 'pressure': 1010, 'description': 'light rain', 'time': '2024-10-05 06:00:00', 'humidity': 67}, {'temperature': 30, 'pressure': 1011, 'description': 'scattered clouds', 'time': '2024-10-06 06:00:00', 'humidity': 65}, {'temperature': 29, 'pressure': 1011, 'description': 'overcast clouds', 'time': '2024-10-07 06:00:00', 'humidity': 69}]

這樣就抓出每天 06:00 的預報資料了. 我們可以走訪此串列並製作每日 06:00 之氣象預報資訊字串, 先放在串列中再用跳行字元串接後傳給 line_msg() 傳送 : 

>>> forcasts=[]   
>>> for r in result: 
    s=f"❖{r['time']} 預報\n" +\
      f"描述: {r['description']}\n" +\
      f"氣溫: {r['temperature']} " +\
      f"濕度: {r['humidity']} " +\
      f"氣壓: {r['pressure']}"
    forcasts.append(s)

>>> forcast_str='\n'.join(forcasts)   
>>> token='在此輸入 LINE Notify token'    
>>> xtools.line_msg(token, forcast_str)     
Message has been sent.

結果如下 :




3. 測試 xtools.webhook.get() 函式 : 

最後來測試 xtools 函式庫的 webhook_get() 函式, 書中原始的函式如下 :

def webhook_get(url):
    print("invoking webhook")
    r = urequests.get(url)
    if r is not None and r.status_code == 200:
        print("Webhook invoked")
    else:
        print("Webhook failed")
        show_error()

所以其實它就是呼叫 urequests.get(), 但卻沒有傳回 r, 所以我將其修改為 : 

def webhook_get(url):
    print("invoking webhook")
    r = urequests.get(url)
    if r is not None and r.status_code == 200:
        print("Webhook invoked")
    else:
        print("Webhook failed")
        show_error()
    return r

測試如下 : 

>>> import xtools
>>> import config   
>>> import ujson   
>>> xtools.connect_wifi()    
network config: ('192.168.50.9', '255.255.255.0', '192.168.50.1', '192.168.50.1')
'192.168.50.9'
>>> api_key='在此輸入 OpenWeatherMap 的 API key'    
>>> city='Kaohsiung'   
>>> country='TW'   
>>> url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'   
>>> r=xtools.webhook_get(url)       
invoking webhook
Webhook invoked
>>> data=ujson.loads(r.text)    
>>> data    
{'timezone': 28800, 'rain': {'1h': 1.78}, 'cod': 200, 'dt': 1727931614, 'base': 'stations', 'weather': [{'id': 522, 'icon': '09d', 'main': 'Rain', 'description': '\u5927\u96e8'}], 'sys': {'country': 'TW', 'sunrise': 1727905845, 'sunset': 1727948677, 'id': 2002588, 'type': 2}, 'clouds': {'all': 100}, 'coord': {'lon': 120.3133, 'lat': 22.6163}, 'name': 'Kaohsiung City', 'visibility': 1400, 'wind': {'gust': 33.95, 'speed': 22.12, 'deg': 90}, 'main': {'feels_like': 24.62, 'pressure': 998, 'temp_max': 23.97, 'temp': 23.62, 'temp_min': 23.05, 'humidity': 99, 'sea_level': 998, 'grnd_level': 997}, 'id': 1673820}

可見與上面直接用 urequests() 是一樣的.