2024年10月31日 星期四

康芮颱風假

今天因康芮颱風來襲放了一天颱風假, 但高雄這次還好, 比起上回山陀兒這次算是小巫了. 不過姊姊說台北風雨很大, 看新聞花東首當其衝, 風雨跟山陀兒襲擊高雄的慘狀差不多. 花了一個早上整理二哥大學時做專題完帶回來的零件箱, 清出好多寶貝, 包括一個數位電錶, 最近本想中將電子買一個的說, 這下就不用了. 還有一個迷你口袋型示波器, 一個 XBOX 遙控器, 以及麵包板與2電子零件與模組等. 

昨天下午菁菁的客人臨時請假, 駕訓班又因前一梯要考試不上課, 想要請我們去吃新光三越 11F 的好正點, 我原先以為是家附近自由路的好正點, 後來才搞清楚是前鎮的 (有吃到飽). 我上回評鑑有補休, 請了一個小時補休提前下班去楠梓載菁菁, 想說放在書包超過三年的 SOGO 禮券四千元順便帶去花掉, 但吃完要載菁菁回楠梓, 所以也沒時間到隔壁 SOGO. 

下午仔細看禮券上寫履約保證至 113 年 10 月 31 日, 那不就今天? 看外面也沒啥雨就邀水某做捷運去三多商圈的 SOGO 把禮券用掉. 結果興沖沖到 SOGO 門口卻看到一張告示牌 : 因應颱風, 本公司今日暫停營業, 哈! 白跑一趟, 只好搭捷運回家, 剛好捷運站內的 Mister Donut 有萬聖節買五送五活動, 就帶了十個甜甜圈回家免得空手而回. 仔細看禮券說明, 過了履約保證期應該還是可以使用.   

2024年10月30日 星期三

露天購買 ESP32-WROVER-DEV 開發板 (附 OV2640 攝像頭) x 1

今天發現 ESP32-WROVER 居然也有像 ESP32-CAM 那樣帶 OV2640 攝像頭的開發板, 在露天找到一片 $218 的賣家, 先買一片來玩看看 : 





萊爾富取貨免運 218 元. 

下面這篇文章就是使用這塊開發板做測試的 :


含驅動程式的韌體是 MicroPython v1.21 版 :


MicroPython 學習筆記 : xtools for ESP32

昨天借助 ChatGPT 對 ESP8266 版的 xtools.py 進行了記憶體耗用上的優化, 參考 :


雖然 ESP32 開發板基本上有較大的 SRAM (520KB), 但寫程式不浪費記憶體仍是基本要求, 所以今天把 ESP32 版的 xtools.py 也丟給 ChatGPT 去檢視看看哪裡可以改進, 前一篇 ESP8266 重疊的部分就不列了, ChatGPT 修改建議如下 :

1. 將 set_ap() 中不再使用的變數 (例如 data, request 等) 在使用完後手動設定為 None 以協助垃圾回收進行內存釋放.   

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')
        del data, addr   
        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, request   

更動的部分是將 del data, addr 向上移入 while 迴圈內而已. 

2. 將 set_ap() 函式中的 html、form、ok 和 ng 的 HTML 内容拆出來成為網頁模板, 然後於 set_ap() 中呼叫新增的 load_template() 載入網頁內容來用 :

例如:
    (1). template.html:包含主要的 HTML 框架
    (2). form_template.html:包含 WiFi 設定的表單
    (3). ok_template.html:包含 WiFi 連線成功的內容
    (4). ng_template.html:包含 WiFi 連線失敗的內容

set_ap() 修改如下 :

def load_template(filename):
    """從文件讀取 HTML 模板"""
    with open(filename, 'r', encoding='utf-8') as file:
        return file.read()

def set_ap(led=2):
    # 載入 HTML 模板文件
    html_template = load_template('template.html')
    form_template = load_template('form_template.html')
    ok_template = load_template('ok_template.html')
    ng_template = load_template('ng_template.html')

    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:
        cs, addr = s.accept()
        print('發現來自客戶端的連線 : ', addr)
        data = cs.recv(1024)
        request = str(data, 'utf8')
        print(request, end='\n')
        del data, addr 

        if request.find('update_ap?') == 5:  # 檢查是否為更新之 URL
            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]
                print('取得 IP : ' + ip)
                with open('config.py', 'w', encoding='utf-8') as f:
                    f.write(f'SSID="{ssid}"\nPASSWORD="{pwd}"')  # 更新設定檔
                # 回應連線成功頁面
                cs.send(html_template % ok_template.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:  # WiFi 連線失敗
                print('WiFi 連線失敗 : 請按 Reset 鈕後重設.')
                wifi_led.value(1)  # 熄滅 LED
                cs.send(html_template % ng_template)  # 回應連線失敗頁面
                cs.close()
                s.close()
                return None
        else:  # 顯示設定 WiFi 頁面
            cs.send(html_template % form_template)  # 回應設定 WiFi 頁面
        cs.close()
        del cs, request

    不過這個建議我沒採納, 因為這樣就多出四個網頁檔出來, 還不如內建在 xtools 中方便


3. 在line_msg(), line_sticker(), line_image_url(), 和 ask_gpt() 等函式中, 重複的 HTTP header 可以提取到共用函數中統一處理.  

修改後的程式碼如下 : 

def get_headers(service, token=None, api_key=None):
    headers={
        "Content-Type": "application/x-www-form-urlencoded"
        } # 須有初始值 
    if service == 'line' and token: # 根據服務類型返回標頭
        headers["Authorization"]="Bearer " + token
    elif service == 'openai' and api_key:
        headers["Authorization"]="Bearer " + api_key
        headers["Content-Type"]="application/json"
    return headers

def line_msg(token, message):
    url="https://notify-api.line.me/api/notify"
    headers=get_headers('line', token=token)    
    params={"message": message}  # 參數字典
    # 將參數字典轉成 URL 字串, 再轉成 utf-8 編碼的 bytes 
    payload=urlencode(params).encode('utf-8')
    # 用編碼後的 payload 傳給 data 參數發送 POST 請求
    r=urequests.post(url, headers=headers, data=payload)  
    if r is not None and r.status_code == 200:
        print("Message has been sent.")
    else:
        print("Error! Failed to send notification message.")  
    r.close()  # 關閉連線

def line_sticker(token, message, stickerPackageId, stickerId):
    url="https://notify-api.line.me/api/notify"
    headers=get_headers('line', token=token)
    # 設定正確的 payload
    params={
        "message": message,
        "stickerPackageId": stickerPackageId,
        "stickerId": stickerId
        }
    # 使用自訂的 urlencode 函數將參數編碼,並轉換成 UTF-8 的字節串
    payload=urlencode(params).encode('utf-8')
    # 發送 POST 請求
    r=urequests.post(url, headers=headers, data=payload)
    # 判斷是否成功
    if r is not None and r.status_code == 200:
        return "The sticker has been sent."
    else:
        return "Error! Failed to send the sticker."

def line_image_url(token, message, image_url):
    # 透過 LINE Notify 發送雲端圖片
    url="https://notify-api.line.me/api/notify"
    headers=get_headers('line', token=token)
    # 構造請求的數據,包含圖片的 URL
    params={
        "message": message,
        "imageFullsize": image_url,  # 完整圖片的 URL
        "imageThumbnail": image_url  # 縮略圖圖片 URL,可與完整圖片相同
        }
    # 轉成 URL 字串並用 utf-8 編碼為 bytes 
    payload=urlencode(params).encode('utf-8')
    # 發送 POST 請求
    r=urequests.post(url, headers=headers, data=payload)
    # 判斷是否成功
    if r is not None and r.status_code == 200:
        return "The image URL has been sent."
    else:
        return "Error! Failed to send the image URL."

def ask_gpt(prompt, api_key, model='gpt-4o-mini'):
    url='https://api.openai.com/v1/chat/completions'
    headers=get_headers('openai', api_key=api_key)
    # 建立 data 參數字典
    data={
        'model': model,
        'messages': [{'role': 'user', 'content': prompt}]
        }
    # 將字典轉成字串後再編碼成 UTF-8
    payload=ujson.dumps(data).encode('utf-8')
    # 發送 POST 請求
    response=urequests.post(url, headers=headers, data=payload)
    if response.status_code == 200:
        reply=response.json() # 轉成字典
        return reply['choices'][0]['message']['content']
    else:
        return response.json()  # 返回錯誤信息

測試結果如下 :

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

>>> import config   
>>> import xtools     
>>> ip=xtools.connect_wifi(led=5)   
network config: ('192.168.192.189', '255.255.255.0', '192.168.192.92', '192.168.192.92')
>>> line_token=config.LINE_NOTIFY_TOKEN 
>>> openai_api_key=config.OPENAI_API_KEY    
>>> message='test'   
>>> image_url='https://cdn.pixabay.com/photo/2024/03/15/17/50/dogs-8635461_1280.jpg'    
>>> xtools.line_msg(line_token, message)    
Message has been sent.   
>>> xtools.line_sticker(line_token, message, 1, 4)      
'The sticker has been sent.'    
>>> xtools.line_image_url(line_token, message, image_url)      
'The image URL has been sent.'
>>> prompt='Who are you'         
>>> print(xtools.ask_gpt(prompt, openai_api_key))     
I am an AI language model created by OpenAI, designed to assist with a wide range of questions and topics by providing information and generating text-based responses. How can I help you today?

結果如下 : 




程式碼 :

import config   
import xtools     
ip=xtools.connect_wifi(led=5)   
line_token=config.LINE_NOTIFY_TOKEN 
openai_api_key=config.OPENAI_API_KEY    
message='test'   
image_url='https://cdn.pixabay.com/photo/2024/03/15/17/50/dogs-8635461_1280.jpg'    
xtools.line_msg(line_token, message)    
xtools.line_sticker(line_token, message, 1, 4)      
xtools.line_image_url(line_token, message, image_url)      
prompt='Who are you'         
print(xtools.ask_gpt(prompt, openai_api_key))

優化後的 xtools (for ESP32) 已上傳 GitHub 更新 :


2024年10月29日 星期二

露天購買 2000 mAh 鋰聚合物電池包 x 2

想要測試看看 LOLIN D32 開發板用鋰電池供電在一直工作模式與間歇深度睡眠模式下能撐多久, 在露天找到下面這賣家, 先買兩顆 2000 mAh PH 2.0mm 接頭的:





全家取貨免運 240 元. 

MicroPython 學習筆記 : xtools for ESP8266

自從讀了陳會安老師的 "超簡單 Python/MicroPython 物聯網應用" 這本書後我都改用書中提供的 xtools.py 函式庫, 但我有添加與改寫不分函式, 使得大小從 3KB 增加到 6KB, 上傳到 ESP8266 後發現會在呼叫函式時當機重啟, 已下是用 gc.mem_free() 追蹤記憶體耗用情形的紀錄 :

MicroPython v1.23.0 on 2024-06-02; ESP module with ESP8266
Type "help()" for more information.
>>> import gc  
>>> gc.mem_free()   
33568  

開機後 160KB 的 SRAM 剩下 33.5KB (系統佔掉一大部分). 接

著匯入 6KB 的 xtools 與設定檔 : 

>>> import config  
>>> import xtools  
>>> gc.mem_free()  
24160  

吃掉 9.4KB. 連線 WiFi :

>>> xtools.connect_wifi()   
#4 ets_task(4020f540, 28, 3fffa050, 10)
Connecting to network...
network config: ('192.168.192.208', '255.255.255.0', '192.168.192.92', '192.168.192.92')
'192.168.192.208'
>>> gc.mem_free()      
23920  

低於 24KB 了. 呼叫 tw_now() 查詢 NTP 還可以 :

>>> xtools.tw_now()   
Querying NTP server and set RTC time ... OK.
'2024-10-28 10:00:38'
>>> gc.mem_free()   
23248

但呼叫 line_msg() 就會發生系統重啟動 (Reset) :

>>> xtools.line_msg(line_token, message)     

 ets Jan  8 2013,rst cause:2, boot mode:(3,6)

load 0x40100000, len 31212, room 16 
tail 12
chksum 0xa6
ho 0 tail 12 room 4
load 0x3ffe8000, len 1060, room 12 
tail 8
chksum 0x80
load 0x3ffe8430, len 1124, room 0 
tail 4
chksum 0xcb
csum 0xcb
����n�r��n|� l lll`b� �|r�l�n� �n�

研判是記憶體溢位造成, 可用的記憶體低於 24KB 會有此問題. 

於是我將 xtools.py 中大部分我自行添加的函式刪除, 包含 set_ap(), line_sticker(), line_img_url(), 以及 ask_gpt(), 但留下 line_msg(), 盡量回歸到接近陳會安老師原著中的原始樣貌, 大小從 13KB 縮小到 5KB 左右 (書中原版約 3KB), 瘦身後的 ESP8266 版 xtools 如下 :

# xtools.py for ESP8266
from machine import Pin, RTC, unique_id
import urandom, math
import time, network, urequests
import ubinascii
import config
import ntptime
import ujson

def get_id():
    return ubinascii.hexlify(unique_id()).decode('utf8')

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

def get_num(x):
    return float("".join(ele for ele in x if ele.isdigit() or ele =="."))

def random_in_range(low=0, high=1000):
    r1 = urandom.getrandbits(32)
    r2 = r1 % (high-low) + low
    return math.floor(r2)

def map_range(x, in_min, in_max, out_min, out_max):
   return int((x-in_min) * (out_max-out_min) / (in_max-in_min) + out_min)
   
def connect_wifi(ssid=config.SSID, passwd=config.PASSWORD, led=2, timeout=20):
    wifi_led=Pin(led, Pin.OUT, value=1)
    sta=network.WLAN(network.STA_IF)
    sta.active(True)
    start_time=time.time() # 記錄時間判斷是否超時
    if not sta.isconnected():
        print("Connecting to network...")
        sta.connect(ssid, passwd)
        while not sta.isconnected():
            wifi_led.value(0)
            time.sleep_ms(300)
            wifi_led.value(1)
            time.sleep_ms(300)
            # 判斷是否超過timeout秒數
            if time.time()-start_time > timeout:
                print("Wifi connecting timeout!")
                break
    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] 

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))

def show_error(final_state=0):
    led = Pin(2, Pin.OUT)   # Built-in D4
    for i in range(3):
        led.value(1)
        time.sleep(0.5)
        led.value(0)
        time.sleep(0.5)
    led.value(final_state)    

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

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

def urlencode(params):
    # 將字典的鍵值對轉換為 URL 編碼的字串 (k=v) 並以 & 連接多個鍵值對
    kv=['{}={}'.format(k, v) for k, v in params.items()]
    return '&'.join(kv)

def line_msg(token, message):
    url="https://notify-api.line.me/api/notify"
    headers={
        "Authorization": "Bearer " + token,
        "Content-Type": "application/x-www-form-urlencoded"
        }     
    params={"message": message}  # 參數字典
    # 呼叫自訂的 URL 編碼函式將字典轉成 URL 字串, 再轉成 utf-8 編碼的 bytes 
    payload=urlencode(params).encode('utf-8')
    # 用編碼後的 payload 傳給 data 參數發送 POST 請求
    r=urequests.post(url, headers=headers, data=payload)  
    if r is not None and r.status_code == 200:
        print("Message has been sent.")
    else:
        print("Error! Failed to send notification message.")  
    r.close()  # 關閉連線

def tw_now():
    try: # 從 NTP 取得 UTC 時戳加 8 為台灣時間, 若成功設定 RTC
        print('Querying NTP server and set RTC time ... ', end='')
        utc=ntptime.time() # 取得 UTC 時戳
        print('OK.')
        t=time.localtime(utc + 28800) # 傳回台灣時間的元組
        rtc=RTC() # RTC 物件
        rtc.datetime(t[0:3] + (0,) + t[3:6] + (0,)) # 設定 RTC
    except:  # 查詢 NTP 失敗不設定 RTC 
        print('Failed.')
    return strftime()  # 傳回目前之日期時間字串 YYYY-mm-dd HH:MM:SS 

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")
    
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]))

原始碼放在 GitHub :


將此 xtools.py 上傳 ESP8266 開發板重新測試追蹤記憶體耗用情形 : 

MicroPython v1.23.0 on 2024-06-02; ESP module with ESP8266
Type "help()" for more information.
>>> import gc  
>>> gc.mem_free()     
35152
>>> import config  
>>> import xtools  
>>> gc.mem_free()     
28640

瘦身下來多出 4KB 容量, 連線 WiFi : 

>>> xtools.connect_wifi()   
network config: ('192.168.192.208', '255.255.255.0', '192.168.192.92', '192.168.192.92')
'192.168.192.208'
>>> gc.mem_free()      
28592

呼叫 tw_now() :

>>> xtools.tw_now()   
Querying NTP server and set RTC time ... OK.
'2024-10-28 11:13:12'
>>> gc.mem_free()    
29872

呼叫 line_msg() :

>>> line_token=config.LINE_NOTIFY_TOKEN     
>>> message='test'   
>>> gc.mem_free()     
28400  
>>> xtools.line_msg(line_token, message)      
Message has been sent.
>>> gc.mem_free()      
27424

可見減肥 3KB 是有效果的, 只要 SRAM 可用容量保持在 25KB 以上應該就沒問題. 


2024-10-29 補充 :

雖然經過上面一番操作刪除一些函式將 xtools 瘦身後消除了記憶體溢位造成的當機問題, 但是函式庫寫法可能存在浪費記憶體的情形, 於是我將瘦身後的 xtools.py 貼給 ChatGPT 請它幫我檢查函式寫法是否有改進之處 : 

"你是精通 MicroPython 的物聯網大師, 請問下列這個 xtools.py 函式庫是否有浪費記憶體的寫法? 有的話是哪個函式, 請分別指出改正寫法."

結果它指出下列優化建議 :

1. random_in_range() 可以用 urandom 來處理, 無須匯入 math, 改寫如下 :

def random_in_range(low=0, high=1000):
    return urandom.getrandbits(32) % (high - low) + low

2. connect_wifi() 寫法改為如下 :

def connect_wifi(ssid=config.SSID, passwd=config.PASSWORD, led=2, timeout=20):
    wifi_led=Pin(led, Pin.OUT, value=1)
    sta=network.WLAN(network.STA_IF)
    if not sta.active():
        sta.active(True)   # 確保已啟動 WiFi
    start_time=time.time() # 記錄時間判斷是否超時
    if not sta.isconnected():
        print("Connecting to network...")
        sta.connect(ssid, passwd)
        while not sta.isconnected() and time.time() - start_time <= timeout:
            wifi_led.value(0)
            time.sleep_ms(300)
            wifi_led.value(1)
            time.sleep_ms(300)
        if not sta.isconnected():
            print("Wifi connecting timeout!")
            return None
    if sta.isconnected():
        for _ 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]

主要是迴圈改用 _ 當虛變數, 無法連線時傳回 None. 


3. webhook_post() 與 webhook_get() 加入 finally 釋放資源 :

def webhook_post(url, value):
    try:
        r=urequests.post(url, data=value)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error()
    finally:
        r.close()  # 釋放資源
    return r

def webhook_get(url):
    try:
        r=urequests.get(url)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error()
    finally:
        r.close()  # 釋放資源
    return r


4. tw_now() 移除 rtc 變數直接用 RTC() :

def tw_now():
    try: # 從 NTP 取得 UTC 時戳加 8 為台灣時間, 若成功設定 RTC
        print('Querying NTP server and set RTC time ... ', end='')
        utc=ntptime.time() # 取得 UTC 時戳
        print('OK.')
        t=time.localtime(utc + 28800) # 傳回台灣時間的元組
        RTC().datetime(t[0:3] + (0,) + t[3:6] + (0,))
    except Exception as e:  # 加入例外處理
        print(f'Failed. {e}')
    return strftime()  # 傳回目前之日期時間字串 YYYY-mm-dd HH:MM:SS


5. scan_ssid() 傳回值改用 f 字串嵌入 : 

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(f'{ssid} {mac} {rssi}')


優化後的完整 xtools for ESP8266 如下 : 

# xtools.py for ESP8266 (optimized)
from machine import Pin, RTC, unique_id
import urandom, time, network, urequests, ubinascii, ntptime
import config

def get_id():
    return ubinascii.hexlify(unique_id()).decode('utf8')

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

def get_num(x):
    return float(''.join(filter(lambda c: c.isdigit() or c == ".", x)))

def random_in_range(low=0, high=1000):
    return urandom.getrandbits(32) % (high - low) + low

def map_range(x, in_min, in_max, out_min, out_max):
    return int((x-in_min) * (out_max-out_min) / (in_max-in_min) + out_min)
   
def connect_wifi(ssid=config.SSID, passwd=config.PASSWORD, led=2, timeout=20):
    wifi_led=Pin(led, Pin.OUT, value=1)
    sta=network.WLAN(network.STA_IF)
    if not sta.active():
        sta.active(True)   # 確保已啟動 WiFi
    start_time=time.time() # 記錄時間判斷是否超時
    if not sta.isconnected():
        print("Connecting to network...")
        sta.connect(ssid, passwd)
        while not sta.isconnected() and time.time() - start_time <= timeout:
            wifi_led.value(0)
            time.sleep_ms(300)
            wifi_led.value(1)
            time.sleep_ms(300)
        if not sta.isconnected():
            print("Wifi connecting timeout!")
            return None
    if sta.isconnected():
        for _ 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]

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(f'{ssid} {mac} {rssi}')

def show_error(final_state=0):
    led = Pin(2, Pin.OUT)   # Built-in D4
    for i in range(3):
        led.value(1)
        time.sleep(0.5)
        led.value(0)
        time.sleep(0.5)
    led.value(final_state)    

def webhook_post(url, value):
    try:
        r=urequests.post(url, data=value)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error()
    finally:
        r.close()  # 釋放資源
    return r

def webhook_get(url):
    try:
        r=urequests.get(url)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error()
    finally:
        r.close()  # 釋放資源
    return r

def urlencode(params):
    # 將字典的鍵值對轉換為 URL 編碼的字串 (k=v) 並以 & 連接多個鍵值對
    kv=['{}={}'.format(k, v) for k, v in params.items()]
    return '&'.join(kv)

def line_msg(token, message):
    url="https://notify-api.line.me/api/notify"
    headers={
        "Authorization": "Bearer " + token,
        "Content-Type": "application/x-www-form-urlencoded"
        }     
    params={"message": message}  # 參數字典
    # 呼叫自訂的 URL 編碼函式將字典轉成 URL 字串, 再轉成 utf-8 編碼的 bytes 
    payload=urlencode(params).encode('utf-8')
    # 用編碼後的 payload 傳給 data 參數發送 POST 請求
    r=urequests.post(url, headers=headers, data=payload)  
    if r is not None and r.status_code == 200:
        print("Message has been sent.")
    else:
        print("Error! Failed to send notification message.")  
    r.close()  # 關閉連線

def tw_now():
    try: # 從 NTP 取得 UTC 時戳加 8 為台灣時間, 若成功設定 RTC
        print('Querying NTP server and set RTC time ... ', end='')
        utc=ntptime.time() # 取得 UTC 時戳
        print('OK.')
        t=time.localtime(utc + 28800) # 傳回台灣時間的元組
        RTC().datetime(t[0:3] + (0,) + t[3:6] + (0,))
    except Exception as e:  # 加入例外處理
        print(f'Failed. {e}')
    return strftime()  # 傳回目前之日期時間字串 YYYY-mm-dd HH:MM:SS 

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")
    
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]))

2024年10月28日 星期一

高雄市圖 LINE 官方帳號好友募集中

今天收到高雄市圖來信通知已成立 LINE 官方帳號 :


身為市圖重度使用者立馬加官方帳號成為第 25 位好友 :



可以按官方帳號底下按鈕進行網路辦證, 搜尋館藏, 與登入個人書房 :



2024年10月27日 星期日

2024 年第 43 周記事

我上周日晚上回高雄時太匆忙忘了帶冰箱的兩袋芭樂, 所以這一周都沒芭樂吃, 週五菁菁要回家, 我週四下班去全聯買了一袋四顆居然要價 169 元, 真的買不下去, 但為了讓菁菁帶回工作室也只好買了. 週六早上回到鄉下先去菜園繞一圈, 發現似乎已被小舅採收一遍了, 我只採了一顆. 而且也沒有新結果需要套袋, 剛好這兩天下午都下起豪雨, 就算有也沒辦法套袋. 

鄉下的母貓小灰生的三隻小貓崽兩周前被小灰叼到車庫的置物櫃後, 爸每天外出與睡覺前都把玻璃窗關到剩下通風空隙, 避免野狗跑來咬它們, 但這樣也導致小灰很少跳上去餵奶, 只好放風時才抓它們下來吃奶 :




但這三隻小崽不知是否較少與貓媽媽在一起, 居然看到第一胎的哥哥姐姐就跑過去要吃奶, 嚇得哥姊趕緊閃開. 不過這周第一胎的三隻只有兩隻有在家, 另外一隻不知為何沒回來. 它們都已經比媽媽還大隻了, 回來居然還要吃奶哩. 

週五開了一個跟組織變動有關的會, 周一又有人在打辦公室隔壁那間的主意, 所幸被上司拒絕了. 但離退休還有六年多, 工作上的變化很難預料, 能否維持現狀是個未知數, 咱就做一天和尚敲一天鐘. 總之, 已經不是追求事業心的年紀了, 升職限在對我一點吸引力也沒有, 反而是那些開不完的會一定會讓我抓狂. 射手座閒不下來是沒錯, 但做不喜歡的事跟失去自由沒兩樣啊! 

如何在 Colab 中隱藏與取用密鑰

在測試 OpenAI 或 Line Notify 等 API 功能時都需要註冊申請金鑰 (API key) 或權杖 (token), 通常在測試時為了方便會直接賦值給一個字串變數例如 OPENAI_API_KEY 或 LINE_NITIFY_TOKEN 來使用, 但這種做法常常在將程式分享到 GitHub 或社群時一時忘記刪除真實金鑰或權杖而洩漏, 尤其是 GitHub 有版本控制功能, 即使刪除或更新仍會保留舊資料. 

解決辦法是使用第三方模組例如 dotenv 將金鑰或權杖儲存在系統變數裡, 利用 os 模組的 getenv() 來取得 API Key 或 token (在 Colab 中統稱為密鑰 Secret). 在 Colab 中除了這個方法外還提供了 Secrets 頁面來儲存金鑰或權杖, 在程式中則可利用 google.colab 的 userdata 模組來取得這些機敏資料. 

本系列相關文章參考 :



1. 設定密鑰 (Secret) :   

下面以設定 OpenAI API key 為例說明如何在 Colab 上設定密鑰 :

首先在 Colab 新增一個 ipynb 筆記本後按左方工具列上的鑰匙圖示開啟密鑰設定頁面 : 




按 "+ 新增密鑰" 鈕 : 




依序勾選開啟筆記本存取權, 輸入密鑰名稱 (此處為 OPENAI_API_KEY), 貼上 OpenAI API key, 然後按右上角的 X 關閉設定頁面 (隨時可按鑰匙圖示進來按眼睛圖示右邊的複製鈕將密鑰複製起來) : 




2. 取用密鑰 (Secret) :   

在上面設定密鑰頁面底下就有取用密鑰的語法, 只要呼叫 google.colab.userdata 模組的 get() 函式並傳入密鑰名稱就會傳回密鑰內容 : 

from google.colab import userdata   

由於上面密鑰設定中設定密鑰名稱為 OPENAI_API_KEY, 因此要傳入此字串 : 

openai_api_key=userdata.get("OPENAI_API_KEY")

呼叫 OpenAI API 之前要先安裝 openai 套件 : 

!pip install openai 

參考 :





接著用 userdata.get() 讀取密鑰 :

OPENAI_API_KEY=userdata.get('OPENAI_API_KEY')   

雖然很犯規 (用 Secret 儲存就是要掩藏密鑰啊) 但還是印出來確認看看 :

print(OPENAI_API_KEY)      




密鑰內容正確, 呼叫 OpenAI 類別的建構子 OpenAI() 並傳入密鑰以建立 OpenAI 物件 :

client=OpenAI(api_key=OPENAI_API_KEY)    # 建立 OpenAI 物件

呼叫 OpenAI API (chat.completion.create) : 

chat_completion=client.chat.completions.create(
    messages=[
        {"role": "user",
         "content": "請簡單說明何謂量子糾纏?",
        }],
    model="gpt-4o-mini",
    )     

取得回應內容 : 

print(chat_completion.choices[0].message.content)    

結果如下 : 

量子糾纏是一種量子力學現象,當兩個或多個粒子在某種方式上互相聯繫時,這些粒子的量子狀態將不再獨立,而是形成一種整體的狀態。即使這些粒子相隔很遠,對其中一個粒子的測量會立即影響到另一個粒子的狀態。這意味著它們之間存在著某種超越經典物理的「瞬時」聯繫。

量子糾纏的特性挑戰了我們對物質和信息傳遞的傳統理解,並且在量子通信和量子計算等領域具有重要的應用潛力。




我們可以將呼叫 OpenAI API 的程序寫成一個函式 ask_gpt() :

def ask_gpt(prompt, api_key, model='gpt-4o-mini'):
    client=OpenAI(api_key=api_key)
    chat_completion=client.chat.completions.create(
        messages=[
            {"role": "user",
             "content": prompt,
            }],
        model=model,
        )
    return chat_completion.choices[0].message.content

結果如下 : 

量子糾纏是一種量子力學現象,當兩個或多個量子系統(例如,粒子)相互作用並形成一個整體時,它們的狀態會變得互相關聯,即使這些粒子相隔很遠。這意味著對一個粒子的測量會立即影響到另一個粒子的狀態,不管它們之間的距離有多遠。

舉例來說,如果兩個糾纏的粒子被創造出來,並且其中一個被測量為某種特定的狀態,那麼另一個粒子的狀態也會瞬間變成與之相對應的狀態。這種現象挑戰了我們對距離和獨立性的直觀理解,並引發了許多關於量子力學和宇宙基本性質的討論與研究。量子糾纏在量子計算、量子通訊等技術中具有重要的應用潛力。




除了 OpenAI API key 外, 任何其他不想因為不小心洩漏的密鑰都可以放在 Secret 中, 例如要在 Colab 筆記本上發出 LINE Notify 推播訊息, 可以將其權杖 (token) 存入 Secret 中, 然後透過 userdata.get() 取用. 

MicroPython 學習筆記 : 使用 SoftI2C 與 SSD1306 通訊

在前面所做的 SSD1306 實驗中都使用 ESP32 預設的硬體 I2C 腳 : GPIO21 (SDA) 與 GPIO22 (SCL), 其實也可以用 machine.SoftI2C 類別將任何其他的 GPIO 腳模擬為軟體 I2C 腳. 


本實驗使用 LOLIN D32 開發板, 使用 GPIO4 模擬 SDA 腳, GPIO5 模擬 SCL 腳. 下列程式改自 "MicroPython 學習筆記 : 用 SSD1306 顯示氣象資訊 (一)" 最後面的 weather_ssd1306.py :

# weather_ssd1306_softi2c.py
import config   
import xtools
import urequests   
import ujson
from machine import SoftI2C, Pin   
import ssd1306
import time

def get_weather(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'],
                 'icon': data['weather'][0]['icon'],
                 'temperature': data['main']['temp'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

def show_weather(oled, data):
    oled.fill(0)  # (填滿 0 熄滅畫素)
    oled.text(data['city'], 0, 0, 1)  
    oled.text(data['time'], 0, 8, 1)  
    oled.text(f"Temperature:{data['temperature']}", 0, 16, 1)    
    oled.text(f"Humidity:{data['humidity']}", 0, 24, 1)    
    oled.text(f"Pressure:{data['pressure']}", 0, 32, 1)    
    oled.show()

def main():
    ip=xtools.connect_wifi(config.SSID, config.PASSWORD)
    if ip:
        # 設定氣象爬蟲參數
        weather_api_key=config.WEATHER_API_KEY
        city='Kaohsiung'   
        country='TW'
        # 建立 I2C 與 SSD1306_I2C 物件
        i2c=SoftI2C(scl=Pin(5), sda=Pin(4)) # ESP32 I2C
        oled=ssd1306.SSD1306_I2C(128, 64, i2c)
        while True:  # 每分鐘抓一次更新 OLED 顯示器
            data=get_weather(country, city, weather_api_key)
            data['time']=xtools.tw_now()  # 新增或更新欄位
            data['city']=city # 新增或更新欄位
            show_weather(oled, data)
            time.sleep(60)
    else:
        print('無法連線 WiFi')

if __name__ == '__main__':
  main()

僅修改了黃底部分, 主要是把 I2C 換成 SoftI2C 類別, 且 GPIO 腳更換為 GPIO4 與 GPIO5 而已,  結果如下 : 




可見結果是一樣的. 

2024年10月26日 星期六

好站 : micro:bit全攻略

今天在整理瀏覽器書籤時發現之前搜尋 SSD1306 資料時找到的 micro:bit 教學網站 :


從網址看應該是以前的母校高應大的學生或老師架設的, 裡面整理了許多 MicroPython on micro:bit 開發板的物聯網實驗, 包含許多常見的感測器如 DHT11 (溫溼度), HC-SR04 (超音波), 光敏電阻等等, 值得參考. micro:bit 的 MicroPython 用法與 ESP8266/ESP32 的有些差異, 這也是我很多年前買了 5 片 micro:bit 卻簡單測試後就束之高閣, 遲遲沒繼續玩的原因之一. 它比較適合用在教學上. 

好站 : 醬是創客

今天在找 ESP32 的 Pinout 資料時找到下面這篇 :


網頁中提供了大且簡明的 ESP32 接腳圖非常好用, 仔細瀏覽發現此網站內容豐富, 原來這是一群熱愛手作開發的創客所寫, 除了接受委託開發物聯網等應用外, 也在露天等拍賣網站開設店鋪販賣零組件, 有空可以好好來挖寶 :


好站 : esp32使用MicroPython驱动oled屏显示中文和英文

今天在 GitHub 找到下面這個可顯示中英文的 MicroPython SSD1306 驅動程式, 強國人寫的, 所以是簡體中文 :


此驅動程式強制要求將解壓後的驅動程式 ssd1306py 子目錄放在 /lib 目錄下. 現在並沒有顯示中文的強烈需要, 所以先記著, 用到時再來研究. 

露天購買 LiitoKala 充電器

最近打算要組裝 18650 電池組, 需要一個充電器輔助測試, 在露天找到下面這款 LiitoKala 充電器, 可充 18650 系列之鋰電池與鎳氫電池, 可測量電池容量, 也能作為行動電源向外供電 (有 USB 輸出槽), 最大亮點是可測澱池內阻, 附充電頭才多 40 元 : 






小七至興店 (原新上明) 取貨付款免運 460 元. 

2024年10月25日 星期五

MicroPython 學習筆記 : LOLIN D32 開發板的按鈕與 LED 測試

最近在做 MicroPython 測試時主要使用 LOLIN D32 開發板, 此板的主要規格與介面特性如下 :
  • ESP32-WROOM-32 模組
  • 240 MHz 雙核 Tensilica LX6 MPU (算力 600 DMIPS)
  • 520KB SRAM (無 PSRAM)
  • 4MB Flash 記憶體
  • 22 個數位 GPIO 腳 (每一個均有 PWM 功能與計時器中斷輸出)
  • 12 個 ADC (類比至數位轉換) 輸入 : 12~14, 25~27, 32~36, 39 
  • 2 個 DAC (數位至類比轉換) 輸出 : 25 (DAC1), 26 (DAC2) 腳
  • 具備 PH-2 2.0mm 鋰電池充放電插槽 (Lipo 3.7V, 充電電流 500 mA)
其針腳排列如下 :




注意, 我買的是 LOLIN D32, 板子中間有明確標示, 參考 :


市面上還有一款很像的 LOLIN32, 它同樣具有 LiPo 鋰電池插槽, 但方向與 USB 垂直, 針腳排列也不相同, 參考 :


為了確定使用下列程式掃描幾個 GPIO 腳 : 

from machine import Pin  
import time  
pins=[2, 5, 16, 17, 18, 19, 21, 22, 23]
for pin in pins:
    print(f"正在測試 GPIO{pin}...")
    led=Pin(pin_num, Pin.OUT)    
    # 閃爍三次來測試 LED
    for _ in range(3):
        led.value(1)  # 開啟 LED
        time.sleep(0.5)
        led.value(0)  # 關閉 LED
        time.sleep(0.5)
    # 延遲一秒後繼續測試下一個
    time.sleep(1)

結果如下 :
    
正在測試 GPIO2...
正在測試 GPIO5...  (有閃爍) 
正在測試 GPIO16...
正在測試 GPIO17...
正在測試 GPIO18...
正在測試 GPIO19...
正在測試 GPIO21...
正在測試 GPIO22...
正在測試 GPIO23...

可見板上 LED 確實是在 GPIO5, 此 LED 是陽極經內部限流電阻接 Vcc, 因此要輸出 0 才會點亮, 輸出 1 熄滅 :

>>> from machine import Pin   
>>> led=Pin(5, Pin.OUT)      
>>> led.value(1)      # 燈熄滅
>>> led.value(0)      # 燈點亮

下面是在 GPIO14 接一個按鈕 (要接短邊接點, 長邊接點是連通的), 另一端接地, 執行下面程式後, 按下按鈕時 GPIO14 接地點亮板上 LED, 鬆開按鈕則熄滅 :

>>> from machine import Pin   
>>> import time   
>>> button=Pin(14, Pin.IN, Pin.PULL_UP)   # 預設 High 熄滅 LED
>>> led=Pin(5, Pin.OUT)     
>>> while True:      
    state=button.value()
    print(state)      
    led.value(state)  
    time.sleep(0.1)     

結果如下 : 

1
1
1
1
0    (LED 亮)
0    (LED 亮)
1
1
1
1
1
1
1
0    (LED 亮)
0    (LED 亮)
0    (LED 亮)

下列程式修改自之前的測試 :


from machine import Pin
import time

led=Pin(5, Pin.OUT)
button=Pin(14, Pin.IN)
led.value(1)  # 預設熄滅 LED

while True:
    if not button.value():  # 按鈕按下
        led.value(not led.value())    # 原點亮就熄滅, 原熄滅就點亮
        while not button.value():     # 若還按住就不反應
            pass

但有時不靈光, 按下去雖有改變狀態, 但馬上又恢復原狀態, 這是因為按鈕會有機械彈跳現象所致. 這種輪詢法 (Polling) 若要消除彈跳現象必須設置時間差窗口來濾掉彈跳, 例如 :

from machine import Pin
import time

button=Pin(14, Pin.IN, Pin.PULL_UP)   # 預設 High 熄滅 LED
led=Pin(5, Pin.OUT) 
led_state=False  # 記錄 LED 狀態
last_button_state=button.value()  # 紀錄上次按鈕狀態
debounce_delay=50  # 設定防彈跳延遲 (毫秒)
last_press_time=0  # 上次按下按鈕的時間初始值
while True:
    current_button_state=button.value() # 讀取當前按鈕狀態
    # 檢查按鈕是否被按下 (低電位)
    if last_button_state == 1 and current_button_state == 0:        
        current_time=time.ticks_ms()  # 取得當前時間
        # 如果從上次按下後超過 debounce_delay 時間表示按鈕已穩定按下
        if time.ticks_diff(current_time, last_press_time) > debounce_delay:
            led_state=not led_state
            led.value(led_state)  # 切換 LED 狀態            
            last_press_time=current_time  # 更新上次按壓的時間
    last_button_state=current_button_state  # 更新上次按鈕狀態
    time.sleep(0.01)  # 加入一點延遲避免浪費過多 CPU 資源

此程式使用一個 50ms 的時間差窗口來過濾彈跳, 測試結果確實能交替明滅 LED. 

也可以用偵測按鈕按下時觸發 IRQ 中斷, 例如 :

from machine import Pin
import time

def toggle_led(pin):
    global led_state, last_press_time
    current_time=time.ticks_ms()  # 取得當前時間
    # 如果從上次按下後超過 debounce_delay 時間表示按鈕已穩定按下
    if time.ticks_diff(current_time, last_press_time) > debounce_delay:
        led_state=not led_state
        led.value(led_state)  # 切換 LED 狀態            
        last_press_time=current_time  # 更新上次按壓的時間

button=Pin(14, Pin.IN, Pin.PULL_UP)   # 預設 High 熄滅 LED
led=Pin(5, Pin.OUT) 
led_state=False  # 記錄 LED 狀態
debounce_delay=50  # 設定防彈跳延遲 (毫秒)
last_press_time=0  # 上次按下按鈕觸發中斷的時間初始值
button.irq(trigger=Pin.IRQ_FALLING, handler=toggle_led) # 偵測按鈕下降沿(按下)

while True:  # 等待中斷觸發
    time.sleep(0.1)

但測試發現效果沒有 Polling 那麼好. 

參考 : 


露天購買 ASUS X550C 筆電充電器

水某約 10 年前買的一台 ASUS 紅色 X550C 筆電充電器不見了, 雖然是老機器了但還能用, 今天在露天找到下面這個賣家 :





X550C 的充電孔規格是外徑 5.5mm, 內徑 2.5mm, 所以勾選 A 款. 全家取貨免運 290 元. 

另一個賣家賣 $295 無須免運券 :


2024年10月24日 星期四

露天購買 4 號鎳氫可充電電池 x 12 顆

因小米藍芽+無線滑鼠實在太耗電, 兩顆碳鋅四號電池大概 2~3 周就得換新實在吃不消 (之前網友就有提醒我, 但小米滑鼠操控很順手又便宜沒辦法), 今天上露天找尋到下面這賣家剛好全家取貨付款免運, 一組四顆 79 元平均一顆近 20 元, 就買了 3 組共 12 顆 :





全家取貨付款免運 237 元. 

其他賣家 :



2024年10月23日 星期三

2024年10月22日 星期二

MicroPython 學習筆記 : 用 SSD1306 顯示氣象資訊 (四)

在前一篇測試中我們已經將從 OpenWeatherMap 取得的氣象資料用自訂像素圖顯示在 SSD1306 顯示器上, 本篇則是要來複製趙英傑老師 "超圖解 Python 物聯網實作入門" 這本書第 18-2 節的做法, 利用 OpenWeatherMap 傳回值中的 icon 編號來顯示天氣概況圖, 溫濕度, 與氣壓等氣象資訊 (氣壓是我添加的, 書上範例僅顯示溫溼度). 

氣象爬蟲做法參考 :

 
本實驗所需的 icon 圖檔可從該書範例壓縮檔中取得, 網址如下 : 


本篇測試所需的向量圖示 .bin 檔放在壓縮檔解開後的 ch18/icons 資料夾內 (共 17 個) :




其中最後一個 na.bin 不是 OpenWeatherMap 定義的圖示名稱, 而是此書作者特製, 用在萬一找不到天氣的圖示時顯示用的. 這些 .bin 檔都是用 wb 模式將圖示的 bytes 類型資料寫入二進位檔而成. 可用 Thonny 將整個 icons 資料夾上傳到開發板 (全部約 4.78KB) :






注意, 這些點陣像素圖檔轉成 byte 陣列時都是用 MVLSB 方式排列的, 用 framebuf 讀取時須指定為此格式. 

首先匯入要用的模組與類別 : 

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

>>> import framebuf  
>>> from machine import I2C, Pin   
>>> import ssd1306      

其中 framebuf 用來操控顯示緩衝區以便顯示點陣像素圖檔, 這在上一篇測試中已熟悉其用法. 接著是建立 SSD1306_I2C 物件, 同樣是使用 LOLIN D32 的 ESP32 開發板 :

>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21))    
>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c)      

先來顯示 02d (晴有雲) 這張圖檔, 以 rb 模式讀取後傳給 bytearray() 轉成 bytearray 類型, 然後以MVLSB 格式寫入緩衝顯示區, 然後呼叫 FrameBuffer 類別的靜態方法 blit() 顯示於螢幕 : 

>>> icon_file='/icons/02d.bin'   
>>> with open(icon_file, 'rb') as f:    
    icon=f.read()
    oled.fill(0)
    fb=framebuf.FrameBuffer(bytearray(icon), 48, 48, framebuf.MVLSB)
    oled.framebuf.blit(fb, 0, 0)
    oled.show()

結果如下 : 




接下來要用之前寫好的 OpenWeatherMap 氣象爬蟲來取得高雄的即時天氣資料 : 

def get_weather(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'],
                 'icon': data['weather'][0]['icon'],
                 'temperature': data['main']['temp'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

先匯入 xtools 函式庫, 氣象爬蟲需要的 urequests 與 ujson 模組, 以及前一篇測試中用來顯示 16*16 大字型數字圖檔的自訂 big_simbol 模組 :

>>> import config   
>>> import xtools    
>>> import urequests 
>>> import ujson     
>>> import big_symbol    

連上網路 :

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

定義呼叫爬蟲函式須要的變數 :

>>> weather_api_key=config.WEATHER_API_KEY   
>>> city='Kaohsiung'     
>>> country='TW'   

呼叫 get_weather() : 

>>> data=get_weather(country, city, weather_api_key)   
>>> data    
{'icon': '04d', 'temperature': 28.32, 'geocode': 1673820, 'pressure': 1007, 'humidity': 79}

其中 icon 為 04d (多雲), 我們要利用此屬性來載入 /icons 目錄下的 04d.bin 圖檔於顯示緩衝區後顯示於螢幕上. 先清除螢幕 :

>>> oled.fill(0)  # (填滿 0 熄滅畫素)   
>>> oled.show()    

用 f 字串製作圖檔路徑 : 

>>> icon_file=f'/icons/{data["icon"]}.bin'   
>>> icon_file     
'/icons/04d.bin'   

在座標 (0, 15) 處開始顯示此天氣概況圖示 (保留兩列, 第一列用來顯示城市名稱) : 

>>> with open(icon_file, 'rb') as f:    
    icon=f.read()
    oled.fill(0)
    fb=framebuf.FrameBuffer(bytearray(icon), 48, 48, framebuf.MVLSB)
    oled.framebuf.blit(fb, 0, 15)
    
注意此處要指定 icon 點陣圖的排列方式為 MVLSB. 

接下來要用大字型模組 big_symbol 以圖檔方式來顯示氣象資料, 由於天氣概況圖示寬度為 48px, 因此氣象資料會從 X 座標 50 開始顯示, 溫度 Y 座標從 10 開始, 因每個大字型是 16*16 解析度, 因此濕度從 Y=28 開始顯示 (10+16+2, 留 2px 間隔), 氣壓從 Y=46 開始顯示 (28+16+2, 留 2px 間隔). 

先建立一個 Symbol 物件 : 

>>> sb=big_symbol.Symbol(oled)     # 建立 Symbol 物件   

因傳回之溫度是小數點後兩位之浮點數, 占用寬度太寬, 所以用 round() 取整數 : 

>>> temperature=round(data["temperature"])     
>>> temperature   
28   
>>> sb.text(f'{temperature}c', 50, 10)   
>>> sb.text(f'{data["humidity"]}%', 50, 28)      
>>> sb.text(f'{data["pressure"]}hPa', 50, 46)    

呼叫 show() 將顯示緩衝區資料輸出至螢幕 : 

>>> oled.show()    

最後在預留的前兩列顯示區的第一列開頭之 (0, 0) 座標顯示城市 'Kaohsiung' : 

>>> oled.text(city, 0, 0, 1)    # 在第一列顯示城市名稱
>>> oled.show()   

注意, 此處文字要放在最後才輸出, 如果放在前面不會隨圖檔輸出一起顯示. 結果如下 : 




可見氣壓因字串太長, 後面的單位 hPa 僅顯示 h 而已, 若值為 3 位數就會顯示 hP. 

完整程式碼如下 : 

# weather_ssd1306_4.py 
import config  
import xtools   
import urequests  
import ujson
from machine import I2C, Pin
import ssd1306
import framebuf
import big_symbol
import time

def get_weather(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'],
                 'icon': data['weather'][0]['icon'],
                 'temperature': data['main']['temp'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

ip=xtools.connect_wifi(config.SSID, config.PASSWORD)
if ip:
    # 設定氣象爬蟲參數
    weather_api_key=config.WEATHER_API_KEY
    city='Kaohsiung'   
    country='TW'
    # 建立 I2C 與 SSD1306_I2C 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C
    oled=ssd1306.SSD1306_I2C(128, 64, i2c)
    sb=big_symbol.Symbol(oled)
    while True:  # 每分鐘抓一次更新 OLED 顯示器
        data=get_weather(country, city, weather_api_key)
        if data:
            sb.clear()
            icon_file=f'/icons/{data["icon"]}.bin'
            with open(icon_file, 'rb') as f:    
                icon=f.read()
                oled.fill(0)
                fb=framebuf.FrameBuffer(bytearray(icon), 48, 48, framebuf.MVLSB)
                oled.framebuf.blit(fb, 0, 15)
            temperature=round(data["temperature"])
            sb.text(f'{temperature}c', 50, 10)
            sb.text(f'{data["humidity"]}%', 50, 28)
            sb.text(f'{data["pressure"]}hPa', 50, 46)                
            oled.show()
            oled.text(city, 0, 0, 1)
            oled.show()
        time.sleep(60)
else:
    print('無法連線 WiFi')

以上實驗所有檔案 zip 壓縮檔可在 GitHub 下載 :


注意, 上傳開發板之前須先編輯 config.py 設定 SSID, PASSWORD, 以及 OpenWeatherMap 的 API Key 等資料. 

2024-10-23 補充 :

上面的程式碼在 OpenWeatherMap 傳回的 icon 萬一不存在時會出現檔案讀取錯誤, 原作者為了避免此問題發生製作了一個 na.bin (找不到圖檔之圖示), 可以用 os.stat() 先檢查檔案路徑存不存在, 存在就傳回該天氣概況之圖檔路徑, 否則傳回 na.bin 的路徑, 程式碼修改如下 :

import config  
import xtools   
import urequests  
import ujson
from machine import I2C, Pin
import ssd1306
import framebuf
import big_symbol
import time
import os  

def get_weather(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'],
                 'icon': data['weather'][0]['icon'],
                 'temperature': data['main']['temp'],
                 'pressure': data['main']['pressure'],
                 'humidity': data['main']['humidity']}
            return ret
        else:
            return None
    except Exception as e:
        return None

def check_icon(icon):  
    try:
        os.stat(icon)   # 檢查圖檔是否存在
        return icon
    except:
        return '/icons/na.bin'   # 圖

ip=xtools.connect_wifi(config.SSID, config.PASSWORD)
if ip:
    # 設定氣象爬蟲參數
    weather_api_key=config.WEATHER_API_KEY
    city='Kaohsiung'   
    country='TW'
    # 建立 I2C 與 SSD1306_I2C 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C
    oled=ssd1306.SSD1306_I2C(128, 64, i2c)
    sb=big_symbol.Symbol(oled)
    while True:  # 每分鐘抓一次更新 OLED 顯示器
        data=get_weather(country, city, weather_api_key)
        if data:
            sb.clear()
            icon_file=check_icon(f'/icons/{data["icon"]}.bin')  
            with open(icon_file, 'rb') as f:    
                icon=f.read()
                oled.fill(0)
                fb=framebuf.FrameBuffer(bytearray(icon), 48, 48, framebuf.MVLSB)
                oled.framebuf.blit(fb, 0, 15)
            temperature=round(data["temperature"])
            sb.text(f'{temperature}c', 50, 10)
            sb.text(f'{data["humidity"]}%', 50, 28)
            sb.text(f'{data["pressure"]}hPa', 50, 46)                
            oled.show()
            oled.text(city, 0, 0, 1)
            oled.show()
        time.sleep(60)
else:
    print('無法連線 WiFi')

黃底色為修改或新增的部分 (注意新增 import os). 另外溫度顯示也可以放寬到小數點後一位, 只要將 round(data["temperature"]) 改為 round(data["temperature"], 1) 即可.