顯示具有 ESP8266 標籤的文章。 顯示所有文章
顯示具有 ESP8266 標籤的文章。 顯示所有文章

2025年4月24日 星期四

MicroPython 學習筆記 : 用於 ESP8266 開發板的 esptools

這幾天完成手上所有 ESP32 開發板的 esptools.py 測試, 結論是所有 ESP32 開發板用 esptools.py 傳送文字與網路圖片到 Telegram, 以及詢問 GPT 都沒問題, 但傳送本機圖片則必須要有 SPI RAM (PSRAM) 才行, 例如 ESP32 WROVER 或 ESP32 S3 開發板. 

剩下 ESP8266 開發板還沒測, 今天找了一塊 D1 Mini 板子測試, 結果如下 : 

MPY: soft reboot
MicroPython v1.25.0 on 2025-04-15; ESP module with ESP8266
Type "help()" for more information.

冷開機後上傳 config.py 與 esptools.py (從 ESP32 那邊複製過來), 先查初始記憶體 :

>>> import gc  
>>> print(gc.mem_free())    
33712

ESP8266 開發板配備 160KB SRAM, 其中一半給 MicroPython 用, 剩下一半約 80KB 給程式使用, 但實際上目前剩下 33KB 而已. 

>>> import config  
>>> print(gc.mem_free())      
32800

匯入 config 後剩下 32.8KB.

>>> import esptools   
>>> print(gc.mem_free())   
28544   

匯入 esptools 後剩下 28.5KB.

>>> ssid=config.SSID   
>>> password=config.PASSWORD   
>>> token=config.TELEGRAM_TOKEN    
>>> chat_id=config.TELEGRAM_CHAT_ID 
>>> ip=esptools.connect_wifi(ssid, password)   
network config: ('192.168.77.84', '255.255.255.0', '192.168.77.150', '192.168.77.150')
>>> print(gc.mem_free())   
26144

連線 WiFi 後只剩 26KB 左右. 試試看傳訊息到 Telegram : 

>>> text='Hello! 你好!'    
>>> esptools.telegram_text(token, chat_id, text)   

Exception occurred : -40
False

結果拋出 -40 例外, 查詢 ChatGPT 原因為記憶體不足所致. ESP8266 因為 SRAM 記憶體有限, 無法進行需要耗費較大記憶體的作業, 所以我將 esptools.py 模組刪減為如下的 ESP8266 版 :

# esptools.py for ESP8266, 2025-04-24 updated (adapted from xtools.py)
from machine import Pin, RTC, unique_id
import urandom, time, network, urequests, ubinascii
import ntptime, ujson
import uos

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, 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, password)
        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(led=2, final_state=0):
    led=Pin(led, Pin.OUT)   # D1 mini built-in D4=LED 2
    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, led=2):
    try:
        r=urequests.post(url, data=value)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error(led)
    finally:
        r.close()  # 釋放資源
    return r

def webhook_get(url, led=2):
    try:
        r=urequests.get(url)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error(led)
    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 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):
    t=time.mktime((
        int(dt_str[0:4]), int(dt_str[5:7]), int(dt_str[8:10]),
        int(dt_str[11:13]), int(dt_str[14:16]), int(dt_str[17:19]),
        0, 0))
    return time.localtime(t)
    
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]))


將 Telegram 與 GPT 等函式刪除後, esptools.py 瘦身為 5KB 左右. 注意, 此處 strptime() 已改為較精簡省記憶體的寫法.  上傳精簡後的 esptools.py 重開機測試 : 

MPY: soft reboot
MicroPython v1.25.0 on 2025-04-15; ESP module with ESP8266
Type "help()" for more information.

>>> import gc   
>>> print(gc.mem_free())    
33712
>>> import config   
>>> print(gc.mem_free())    
32800
>>> import esptools   
>>> print(gc.mem_free())    
28912 
>>> ssid=config.SSID    
password=config.PASSWORD 
>>> ip=esptools.connect_wifi(ssid, password)   
network config: ('192.168.77.84', '255.255.255.0', '192.168.77.150', '192.168.77.150')
>>> print(gc.mem_free())    
27808
>>> esptools.tw_now()   
Querying NTP server and set RTC time ... OK.
'2025-04-24 11:01:44'

可見連線 WiFi 後記憶體多出了 1.7KB 左右. 總之, ESP8266 屬於低階物聯網控制器, 較高階之專案要用 ESP32. 

2024年11月4日 星期一

MicroPython 學習筆記 : SSD1306 指針時鐘 (二)

在前一篇測試中已經完成 SSD1306 指針時鐘的改寫, 後續我想在此基礎上添加顯示秒針與日期資訊, 由於篇幅太長, 所以紀錄在此篇. 

本系列之前文章參考 :


1. 加上秒針 : 

如果要加上秒針, 則原本的分針長度拿來給秒針用, 所以時針與分針的長度要相對縮短一些才行, 修改後的程式碼如下 :

# ssd1306_clock_3.py 
import config   
import xtools
from gfx import GFX   
from machine import I2C, Pin, RTC  
import ssd1306
import time
import math

ip=xtools.connect_wifi(led=5) 
if ip:
    # 建立 SSD1306_I2C 與 GFX 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
    oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
    gfx=GFX(oled.width, oled.height, oled.pixel)
    oled.fill(0)  # 清除螢幕
    oled.show()
    # 繪製圓心與圓形外框
    gfx.circle(31, 31, 1, 1)
    oled.show()
    gfx.circle(31, 31, 29, 1)
    oled.show()
    # 繪製小時刻度
    for i in range(1, 13):
        angle=i * 30
        x=31 + math.trunc(27 * math.sin(math.radians(angle)))
        y=31 - math.trunc(27 * math.cos(math.radians(angle)))
        # 判斷是否為 3, 6, 9 或 12 點的刻度
        if i in [3, 6, 9, 12]:
            gfx.fill_rect(x-1, y-1, 2, 2, 1)  # 繪製 2x2 像素的粗圓點
        else:
            gfx.line(x, y,
                31 + math.trunc(29 * math.sin(math.radians(angle))),
                31 - math.trunc(29 * math.cos(math.radians(angle))), 1)
        oled.show()
    # 繪製時針, 分針與秒針    
    prev_s_angle=None   # 紀錄前一秒針的角度
    while True:
        dt=RTC().datetime() 
        time.sleep_ms(950)  # 延遲 0.95 秒
        gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
        # 計算並繪製時針
        h_angle=(dt[4] % 12 + dt[5] / 60) * 30  # 計算時針角度
        gfx.line(31, 31,
                 31 + math.trunc(15 * math.sin(math.radians(h_angle))),
                 31 - math.trunc(15 * math.cos(math.radians(h_angle))), 1)
        oled.show()  # 繪製時針

        # 計算並繪製分針 (縮短至 20 像素) 
        m_angle=dt[5] * 360 / 60
        gfx.line(31, 31,
                 31 + math.trunc(20 * math.sin(math.radians(m_angle))),  # 修改分針長度為 20
                 31 - math.trunc(20 * math.cos(math.radians(m_angle))), 1)
        oled.show()  # 繪製分針
        # 計算並繪製秒針 (使用原來的分針長度 25)
        s_angle=dt[6] * 360 / 60            
        if prev_s_angle is not None:  # 清除舊的秒針線條
            gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(prev_s_angle))),
                 31 - math.trunc(25 * math.cos(math.radians(prev_s_angle))), 0) 
        gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(s_angle))),  # 秒針長度設為 25
                 31 - math.trunc(25 * math.cos(math.radians(s_angle))), 1)   
        oled.show()  # 繪製新的秒針
        prev_s_angle=s_angle  # 更新前一秒針的角度
        gfx.fill_rect(53, 56, 17, 10, 0)  # 繪製填滿的長方形框
        oled.text(str(dt[6]), 53, 56, 1)  # 繪製秒數 (文字)
        oled.show()      
        if dt[6] == 59: # 59 秒時清空錶面重繪時鐘
            gfx.fill_circle(31, 31, 25, 0) # 填滿 0 清空錶面
            gfx.circle(31, 31, 1, 1)  # 重繪時鐘圓心
            gfx.circle(31, 31, 29, 1) # 重繪時鐘外框
            xtools.tw_now()  # RTC 時鐘同步 NTP 
else:
    print('無法連線 WiFi 網路!')

黃底色部分即添加的顯示秒針功能, 由於 while 迴圈每秒跑一圈, 秒針每秒要重繪, 在畫新的秒針前必須先將舊的秒針線條清除, 否則秒針線條會一直不斷地在錶面累積, 這裡用 prev_s_angle 來記錄前一個秒針的角度 (分針與時針變動較緩慢不須每秒重繪, 迴圈末尾在 59 秒要跳下一分鐘時會重繪錶面, 時針與分針是在跳分時重繪的).

結果如下 : 




可見秒針每秒都會隨秒數跳動而旋轉. 


2. 在右半邊上半部顯示日期時間文字 : 

接下來我想在螢幕右半邊的上半部以文字顯示日期, 星期, 與時間等四列資訊. 右半部寬度 64px. 每個字元 8px, 因此每列只能顯示 8 個字元, 規劃如下 :

2024
Nov.4
Monday
10:16:38

以下是從 RTC 時鐘取得的日期時間 tuple : 

>>> from machine import RTC      
>>> rtc=RTC()    
>>> dt=rtc.datetime()    
>>> dt    
(2024, 11, 4, 0, 11, 33, 24, 909053)     # (年, 月, 日, 星期, 時, 分, 秒, 微秒)
   
前三個元素是日期, 第四個是星期 (0=Monday), 第 5~7 個元素是時間, 我們可以從 datetime() 傳回的元組來製作要顯示的日期時間資訊 . 

首先來處理月份, 先定義一個 months 串列 : 

>>> months=['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']    
製作月份日期字串 : 

>>> md=f'{months[dt[1]-1]}{dt[2]}'     
>>> md   
'Nov.4'   

製作年份字串 : 

>>> year=str(dt[0])     # 轉成字串
>>> year   
'2024'

定義星期串列來製作星期字串 :

>>> days=['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']     
>>> day=days[dt[3]]     
>>> day       
'Monday'

利用格式化字串製作時分秒字串 :  

>>> hms='%02d:%02d:%02d' % (dt[4], dt[5], dt[6])    
>>> hms     
'11:39:27'

依據上面測試修改程式碼如下 :

# ssd1306_clock_4.py
import config   
import xtools
from gfx import GFX   
from machine import I2C, Pin, RTC  
import ssd1306
import time
import math

ip=xtools.connect_wifi(led=5) 
if ip:
    # 建立 SSD1306_I2C 與 GFX 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
    oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
    gfx=GFX(oled.width, oled.height, oled.pixel)
    oled.fill(0)  # 清除螢幕
    oled.show()
    # 繪製圓心與圓形外框
    gfx.circle(31, 31, 1, 1)
    oled.show()
    gfx.circle(31, 31, 29, 1)
    oled.show()
    # 繪製小時刻度
    for i in range(1, 13):
        angle=i * 30
        x=31 + math.trunc(27 * math.sin(math.radians(angle)))
        y=31 - math.trunc(27 * math.cos(math.radians(angle)))
        # 判斷是否為 3, 6, 9 或 12 點的刻度
        if i in [3, 6, 9, 12]:
            gfx.fill_rect(x-1, y-1, 2, 2, 1)  # 繪製 2x2 像素的粗圓點
        else:
            gfx.line(x, y,
                31 + math.trunc(29 * math.sin(math.radians(angle))),
                31 - math.trunc(29 * math.cos(math.radians(angle))), 1)
        oled.show()
    # 繪製時針, 分針與秒針    
    prev_s_angle=None   # 紀錄前一秒針的角度
    while True:
        dt=RTC().datetime() 
        time.sleep_ms(950)  # 延遲 0.95 秒
        gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
        # 計算並繪製時針
        h_angle=(dt[4] % 12 + dt[5] / 60) * 30  # 計算時針角度
        gfx.line(31, 31,
                 31 + math.trunc(15 * math.sin(math.radians(h_angle))),
                 31 - math.trunc(15 * math.cos(math.radians(h_angle))), 1)
        oled.show()  # 繪製時針

        # 計算並繪製分針 (縮短至 20 像素) 
        m_angle=dt[5] * 360 / 60
        gfx.line(31, 31,
                 31 + math.trunc(20 * math.sin(math.radians(m_angle))),  # 修改分針長度為 20
                 31 - math.trunc(20 * math.cos(math.radians(m_angle))), 1)
        oled.show()  # 繪製分針
        # 計算並繪製秒針 (使用原來的分針長度 25)
        s_angle=dt[6] * 360 / 60            
        if prev_s_angle is not None:  # 清除舊的秒針線條
            gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(prev_s_angle))),
                 31 - math.trunc(25 * math.cos(math.radians(prev_s_angle))), 0)
        gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(s_angle))),  # 秒針長度設為 25
                 31 - math.trunc(25 * math.cos(math.radians(s_angle))), 1)
        oled.show()  # 繪製新的秒針
        prev_s_angle=s_angle  # 更新前一秒針的角度
        if dt[6] == 59: # 59 秒時清空錶面重繪時鐘
            gfx.fill_circle(31, 31, 25, 0) # 填滿 0 清空錶面
            gfx.circle(31, 31, 1, 1)  # 重繪時鐘圓心
            gfx.circle(31, 31, 29, 1) # 重繪時鐘外框
            xtools.tw_now()  # RTC 時鐘同步 NTP
        # 在右半部顯示日期時間資訊
        months=['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.',
                'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']
        days=['Monday', 'Tuesday', 'Wednesday', 'Thursday',
              'Friday', 'Saturday', 'Sunday']
        # 在螢幕右半邊上半部四列顯示日期時間資訊
        gfx.fill_rect(64, 0, 64, 64, 0)  # 清除右半邊的顯示區域
        # 更新日期與時間顯示
        oled.text(str(dt[0]), 64, 0, 1)  # 年
        oled.text(f'{months[dt[1] - 1]}{dt[2]}', 64, 8, 1)  # 月.日
        oled.text(days[dt[3]], 64, 16, 1)  # 星期
        oled.text('%02d:%02d:%02d' % (dt[4], dt[5], dt[6]), 64, 24, 1)  # 時分秒
        oled.show()
else:
    print('無法連線 WiFi 網路!')

黃底色部分為此次添加的程式碼. 注意, 由時日期時間資訊中已包含跳動的秒數, 所以原本在時鐘右下角的秒數顯示已被去除. 結果如下 :




3. 在螢幕右下半部顯示氣象資訊 : 

上面的範例程式只使用了螢幕的右上半部, 我們可以在右下半部顯示從 OpenWeatherMap 取得的氣象資料, 關於 OpenWeatherMap 的 API 用法參考 :


在之前的測試中我們已經將查詢 OpenWeatherMap API 的方法寫成如下的爬蟲函式 :

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:
            print(f'code:{data['cod']}')
            return None
    except Exception as e:
        print(e)
        return None 

使用 OpenWeatherMap API 前須先申請 API Key, 然後把它寫入 config.py 裡的一個變數中 (例如 WEATHER_API_KEY) 以備程式取用. 

呼叫 get_weather() 前需要先匯入 ujson 與 urequests 模組 :

>>> import ujson   
>>> import urequests   

傳入 API key, 城市, 與國家代號會傳回一個字典, 我們要將其中的 temperature, humidity, 與 temperature 三個鍵的值顯示在螢幕的右下半部. 

>>> weather_api_key=config.WEATHER_API_KEY      
>>> city='Kaohsiung'       
>>> country='TW'   
>>> data=get_weather(country, city, weather_api_key)      
>>> data   
{'icon': '03d', 'temperature': 28.94, 'geocode': 1673820, 'pressure': 1013, 'humidity': 71}

修改上面的程式碼, 在秒數為 59 秒時呼叫 get_weather() 取得氣象資料:

        if dt[6] == 59: # 59 秒時清空錶面重繪時鐘
            gfx.fill_circle(31, 31, 25, 0) # 填滿 0 清空錶面
            gfx.circle(31, 31, 1, 1)  # 重繪時鐘圓心
            gfx.circle(31, 31, 29, 1) # 重繪時鐘外框
            xtools.tw_now()  # RTC 時鐘同步 NTP
            data=get_weather('TW', 'Kaohsiung', config.WEATHER_API_KEY)  # 查詢氣象資料

 然後顯示在 (64, 32) 開始的右下邊螢幕 :

        if data:
            degree=[0x00, 0x06, 0x09, 0x09, 0x06, 0x00, 0x00, 0x00]  # 溫度符號點陣
            buf=bytearray(degree)
            fb=framebuf.FrameBuffer(buf, 8, 8, framebuf.MONO_VLSB)
            oled.text(str(data['temperature']), 64, 32, 1)  # 溫度
            oled.framebuf.blit(fb, 104, 32) # degree symbol
            oled.text(str(data['humidity']) + '%', 64, 40, 1)  # 濕度
            oled.text(str(data['pressure']) + 'mPh', 64, 48, 1)  # 氣壓
            oled.text('Kaohsiung', 64, 56, 1)  # 濕度

注意, 此處使用 framebuf 來顯示右上角小圈圈的溫度度數符號, 做法參考 : 


完整程式碼如下 :

# ssd1306_clock_5.py
import config   
import xtools
from gfx import GFX   
from machine import I2C, Pin, RTC  
import ssd1306
import time
import math
import urequests  
import ujson  
import framebuf   

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:
            print(f'code:{data['cod']}')
            return None
    except Exception as e:
        print(e)
        return None 

ip=xtools.connect_wifi(led=5) 
if ip:
    # 建立 SSD1306_I2C 與 GFX 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
    oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
    gfx=GFX(oled.width, oled.height, oled.pixel)
    oled.fill(0)  # 清除螢幕
    oled.show()
    # 繪製圓心與圓形外框
    gfx.circle(31, 31, 1, 1)
    oled.show()
    gfx.circle(31, 31, 29, 1)
    oled.show()
    # 繪製小時刻度
    for i in range(1, 13):
        angle=i * 30
        x=31 + math.trunc(27 * math.sin(math.radians(angle)))
        y=31 - math.trunc(27 * math.cos(math.radians(angle)))
        # 判斷是否為 3, 6, 9 或 12 點的刻度
        if i in [3, 6, 9, 12]:
            gfx.fill_rect(x-1, y-1, 2, 2, 1)  # 繪製 2x2 像素的粗圓點
        else:
            gfx.line(x, y,
                31 + math.trunc(29 * math.sin(math.radians(angle))),
                31 - math.trunc(29 * math.cos(math.radians(angle))), 1)
        oled.show()
    # 繪製時針, 分針與秒針    
    prev_s_angle=None   # 紀錄前一秒針的角度
    data=None  # 氣象資料
    while True:
        dt=RTC().datetime() 
        time.sleep_ms(950)  # 延遲 0.95 秒
        gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
        # 計算並繪製時針
        h_angle=(dt[4] % 12 + dt[5] / 60) * 30  # 計算時針角度
        gfx.line(31, 31,
                 31 + math.trunc(15 * math.sin(math.radians(h_angle))),
                 31 - math.trunc(15 * math.cos(math.radians(h_angle))), 1)
        oled.show()  # 繪製時針

        # 計算並繪製分針 (縮短至 20 像素) 
        m_angle=dt[5] * 360 / 60
        gfx.line(31, 31,
                 31 + math.trunc(20 * math.sin(math.radians(m_angle))),  # 修改分針長度為 20
                 31 - math.trunc(20 * math.cos(math.radians(m_angle))), 1)
        oled.show()  # 繪製分針
        # 計算並繪製秒針 (使用原來的分針長度 25)
        s_angle=dt[6] * 360 / 60            
        if prev_s_angle is not None:  # 清除舊的秒針線條
            gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(prev_s_angle))),
                 31 - math.trunc(25 * math.cos(math.radians(prev_s_angle))), 0)
        gfx.line(31, 31,
                 31 + math.trunc(25 * math.sin(math.radians(s_angle))),  # 秒針長度設為 25
                 31 - math.trunc(25 * math.cos(math.radians(s_angle))), 1)
        oled.show()  # 繪製新的秒針
        prev_s_angle=s_angle  # 更新前一秒針的角度
        if dt[6] == 59: # 59 秒時清空錶面重繪時鐘
            gfx.fill_circle(31, 31, 25, 0) # 填滿 0 清空錶面
            gfx.circle(31, 31, 1, 1)  # 重繪時鐘圓心
            gfx.circle(31, 31, 29, 1) # 重繪時鐘外框
            xtools.tw_now()  # RTC 時鐘同步 NTP
            data=get_weather('TW', 'Kaohsiung', config.WEATHER_API_KEY)  # 查詢氣象資料
        # 在右半部顯示日期時間資訊
        months=['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.',
                'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']
        days=['Monday', 'Tuesday', 'Wednesday', 'Thursday',
              'Friday', 'Saturday', 'Sunday']
        # 在螢幕右半邊上半部四列顯示日期時間資訊
        gfx.fill_rect(64, 0, 64, 64, 0)  # 清除右半邊的顯示區域
        # 更新日期與時間顯示
        oled.text(str(dt[0]), 64, 0, 1)  # 年
        oled.text(f'{months[dt[1] - 1]}{dt[2]}', 64, 8, 1)  # 月.日
        oled.text(days[dt[3]], 64, 16, 1)  # 星期
        oled.text('%02d:%02d:%02d' % (dt[4], dt[5], dt[6]), 64, 24, 1)  # 時分秒
        if data:  
            degree=[0x00, 0x06, 0x09, 0x09, 0x06, 0x00, 0x00, 0x00] # 溫度符號點陣
            buf=bytearray(degree)   
            fb=framebuf.FrameBuffer(buf, 8, 8, framebuf.MONO_VLSB)    
            oled.text(str(data['temperature']), 64, 32, 1)  # 溫度
            oled.framebuf.blit(fb, 104, 32) # degree symbol
            oled.text(str(data['humidity']) + '%', 64, 40, 1)  # 濕度
            oled.text(str(data['pressure']) + 'mPh', 64, 48, 1)  # 氣壓
            oled.text('Kaohsiung', 64, 56, 1)  # 濕度
        oled.show()
else:
    print('無法連線 WiFi 網路!')

黃底色部分為新增的部分, 結果如下 : 




大功告成! 

最終範例與所需模組已打包上傳 GitHub :


2024年11月3日 星期日

MicroPython 學習筆記 : SSD1306 指針時鐘 (一)

測試完 GFX 函式庫就可以來改寫下面這篇文章所展示的 SSD1306 指針時鐘了 : 

# Micropython NTP指針時鐘 OLED SSD1306

關於 GFX 函式庫用法參考前一篇測試 :

# MicroPython 學習筆記 : 使用 Adafruit 的 GFX 函式庫繪圖 

本測試需要用到 SSD1306 驅動程式 ssd1306.py 與 GFX 模組 gfx.py, 可在 GitHub 下載 :


另外連線 WiFi 與查詢 NTP 伺服器會用到 xtools.py 函式庫, 下載網址如下 :


連線 WiFi 的 SSID 與密碼放在 config.py 中的 SSID 與 PASSWORD 變數中 :

# config.py
SSID="YOUR SSID"
PASSWORD="YOUR PASSWORD"

將 config.py, xtools.py, ssd1306.py, 與 gfx.py 這四個模組上傳到開發板, 匯入 config 與 xtools 後呼叫 xtools.connect_wifi() 連上網路 :

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)      
Connecting to network...
network config: ('192.168.50.156', '255.255.255.0', '192.168.50.1', '192.168.50.1')

>>> from machine import RTC   
>>> dt=RTC().datetime()     
>>> dt    
(2024, 11, 3, 6, 7, 33, 9, 988798)

元組的索引 4, 5, 6 分別為時分秒, 就是顯示指針時鐘所需的時間資料. 

接下來匯入 machine.PIN 與 machine.I2C 類別來建立 I2C 通訊, 匯入 ssd1306 模組與 gfx.GFX 類別來繪圖, 匯入 time 用來延遲時間 : 

>>> from gfx import GFX    
>>> from machine import I2C, Pin      
>>> import ssd1306 
>>> import time   

然後建立 SSD1306_I2C 與 GFX 物件 : 

>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21))    # ESP32 I2C    
>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c)    # 0.96 吋解析度 128*64
>>> gfx=GFX(oled.width, oled.height, oled.pixel)        

首先指針時鐘的圓心 : 

>>> gfx.circle(31, 31, 1, 1)      
>>> oled.show()     

接著繪製外框, 半徑取 29px : 

>>> gfx.circle(31, 31, 29, 1)      
>>> oled.show()     

結果如下 : 




然後繪製小時刻度 :

>>> for i in range(1,13):
    angle=i * 30
    gfx.line(31+math.trunc(27*math.sin(math.radians(angle))),
        31-math.trunc(27*math.cos(math.radians(angle))),
        31+math.trunc(29*math.sin(math.radians(angle))),
        31-math.trunc(29*math.cos(math.radians(angle))), 1)
oled.show()

結果如下 :




最後是用一個無限迴圈每隔約 1 秒鐘讀取 RTC 時鐘, 取得目前時間的時分秒資料, 然後繪製時針與分針線條 :

>>> while True: 
    dt=RTC().datetime() 
    time.sleep_ms(950)  # 延遲 0.95 秒
    gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
    h_angle=dt[4] % 12 * 30  # 計算時針角度
    gfx.line(31, 31,
        31+math.trunc(15*math.sin(math.radians(h_angle))),
        31-math.trunc(15*math.cos(math.radians(h_angle))), 1)
    oled.show()  # 繪製時針
    m_angle=dt[5] * 360 / 60  # 計算分針角度
    gfx.line(31, 31,
        31+math.trunc(25*math.sin(math.radians(m_angle))),
        31-math.trunc(25*math.cos(math.radians(m_angle))), 1)
    oled.show()  # 繪製分針
    gfx.fill_rect(53, 56, 17, 10, 0)  # 繪製填滿的長方形框
    oled.text(str(dt[6]), 53, 56, 1)  # 繪製秒數 (文字)
    oled.show()
    if dt[6] == 59: # 59 秒時
        gfx.fill_circle(31, 31, 25, 0) # 填充圓
        gfx.circle(31, 31, 1, 1)  # 繪製時鐘圓心
        gfx.circle(31, 31, 29, 1) # 繪製時鐘外框
        xtools.tw_now()  # 同步 NTP

我在原始程式末尾添加了一行 tw_now(), 此函式會查詢 NTP 伺服器, 成功的話會更新 RTC 時鐘, 從而保證 RTC 時間的準確, 如果沒有同步, 時間一久 RTC 時鐘會漂移而不準. 結果如下 : 




完整程式碼如下 : 

# ssd1306_clock_1.py
import config   
import xtools
from gfx import GFX   
from machine import I2C, Pin, RTC  
import ssd1306
import time
import math

ip=xtools.connect_wifi(led=5) 
if ip:
    # 建立 SSD1306_I2C 與 GFX 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
    oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
    gfx=GFX(oled.width, oled.height, oled.pixel)
    oled.fill(0)  # 清除螢幕
    oled.show()
    # 繪製圓心與圓形外框
    gfx.circle(31, 31, 1, 1)
    oled.show()
    gfx.circle(31, 31, 29, 1)
    oled.show()
    # 繪製小時刻度
    for i in range(1,13):
        angle=i * 30
        gfx.line(31+math.trunc(27*math.sin(math.radians(angle))),
            31-math.trunc(27*math.cos(math.radians(angle))),
            31+math.trunc(29*math.sin(math.radians(angle))),
            31-math.trunc(29*math.cos(math.radians(angle))), 1)
    oled.show()
    # 繪製時針與分針
    while True:
        dt=RTC().datetime() 
        time.sleep_ms(950)  # 延遲 0.95 秒
        gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
        h_angle=dt[4] % 12 * 30  # 計算時針角度
        gfx.line(31, 31,
            31+math.trunc(15*math.sin(math.radians(h_angle))),
            31-math.trunc(15*math.cos(math.radians(h_angle))), 1)
        oled.show()  # 繪製時針
        m_angle=dt[5] * 360 / 60  # 計算分針角度
        gfx.line(31, 31,
            31+math.trunc(25*math.sin(math.radians(m_angle))),
            31-math.trunc(25*math.cos(math.radians(m_angle))), 1)
        oled.show()  # 繪製分針
        gfx.fill_rect(53, 56, 17, 10, 0)  # 繪製填滿的長方形框
        oled.text(str(dt[6]), 53, 56, 1)  # 繪製秒數 (文字)
        oled.show()
        if dt[6] == 59: # 59 秒時
            gfx.fill_circle(31, 31, 25, 0) # 填充圓
            gfx.circle(31, 31, 1, 1)  # 繪製時鐘圓心
            gfx.circle(31, 31, 29, 1) # 繪製時鐘外框
            xtools.tw_now()  # 同步 NTP
else:
    print('無法連線 WiFi 網路!')

但我觀察發現, 時針的位置似乎沒有伴隨分針做適當的移動, 例如已經 12:45 了, 時針還是指到 12 點, 正確位置應該是較靠近 1 點才對, 它要到 13:00 才會突然從 12 點突然指向 13 點, 我詢問 ChatGPT 得到的修改建議是將 h_angle 變數改成如下 :

h_angle=(dt[4] % 12 + dt[5] / 60) * 30  # 計算時針角度   

加入目前的分鐘數 dt[5] / 60 作為調整項, 這樣時針的位置就正確了. 另外, 12 個小時刻度都是相同, 我想將 3, 6, 9, 12 這 4 個小時刻度改成 2x2 px 的方點, ChatGPT 建議將刻度迴圈修改為如下 : 

    # 繪製小時刻度
    for i in range(1, 13):
        angle=i * 30
        x=31+math.trunc(27*math.sin(math.radians(angle)))
        y=31-math.trunc(27*math.cos(math.radians(angle)))
        # 判斷是否為 3, 6, 9 或 12 點的刻度
        if i in [3, 6, 9, 12]:
            gfx.fill_rect(x-1, y-1, 2, 2, 1)  # 繪製 2x2 像素的粗圓點
        else:
            gfx.line(x, y,
                31+math.trunc(29 * math.sin(math.radians(angle))),
                31-math.trunc(29 * math.cos(math.radians(angle))), 1)
        oled.show()

黃底色部分就是 3, 6, 9, 12 這 4 個小時刻度改用 fill_rect() 繪製 2x2 矩形, 結果如下 :




這樣子小時刻度辨識度就比較高了. 完整程式碼如下 :

# ssd1306_clock_2.py
import config   
import xtools
from gfx import GFX   
from machine import I2C, Pin, RTC  
import ssd1306
import time
import math

ip=xtools.connect_wifi(led=5) 
if ip:
    # 建立 SSD1306_I2C 與 GFX 物件
    i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
    oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
    gfx=GFX(oled.width, oled.height, oled.pixel)
    oled.fill(0)  # 清除螢幕
    oled.show()
    # 繪製圓心與圓形外框
    gfx.circle(31, 31, 1, 1)
    oled.show()
    gfx.circle(31, 31, 29, 1)
    oled.show()
    # 繪製小時刻度
    for i in range(1, 13):
        angle=i * 30
        x=31+math.trunc(27*math.sin(math.radians(angle)))
        y=31-math.trunc(27*math.cos(math.radians(angle)))
        # 判斷是否為 3, 6, 9 或 12 點的刻度
        if i in [3, 6, 9, 12]:
            gfx.fill_rect(x-1, y-1, 2, 2, 1)  # 繪製 2x2 像素的粗圓點
        else:
            gfx.line(x, y,
                31+math.trunc(29 * math.sin(math.radians(angle))),
                31-math.trunc(29 * math.cos(math.radians(angle))), 1)
        oled.show()
    # 繪製時針與分針
    while True:
        dt=RTC().datetime() 
        time.sleep_ms(950)  # 延遲 0.95 秒
        gfx.circle(31, 31, 29, 1)  # 繪製時鐘外框
        h_angle=(dt[4] % 12 + dt[5] / 60) * 30  # 計算時針角度
        gfx.line(31, 31,
            31+math.trunc(15*math.sin(math.radians(h_angle))),
            31-math.trunc(15*math.cos(math.radians(h_angle))), 1)
        oled.show()  # 繪製時針
        m_angle=dt[5] * 360 / 60  # 計算分針角度
        gfx.line(31, 31,
            31+math.trunc(25*math.sin(math.radians(m_angle))),
            31-math.trunc(25*math.cos(math.radians(m_angle))), 1)
        oled.show()  # 繪製分針
        gfx.fill_rect(53, 56, 17, 10, 0)  # 繪製填滿的長方形框
        oled.text(str(dt[6]), 53, 56, 1)  # 繪製秒數 (文字)
        oled.show()
        if dt[6] == 59: # 59 秒時
            gfx.fill_circle(31, 31, 25, 0) # 填充圓
            gfx.circle(31, 31, 1, 1)  # 繪製時鐘圓心
            gfx.circle(31, 31, 29, 1) # 繪製時鐘外框
            xtools.tw_now()  # 同步 NTP
else:
    print('無法連線 WiFi 網路!')

2024年11月2日 星期六

MicroPython 學習筆記 : 使用 Adafruit 的 GFX 函式庫繪圖

今天在網路上搜尋 MicroPython 指針時鐘資料時找到下面這篇 : 


範例程式使用了 GFX 這個模組來繪圖, 查詢網路發現原來這也是 Adafruit 的作品, GFX 旨在為各種尺寸的 OLED 及 LCD 營幕提供一個通用繪圖函式庫, 其原始碼使用 C 語言寫成, 參考 :


GFX 的 MicroPython 驅動程式 gfx.py 可從 GitHub 下載 :


GFX 運作原理參考下面這兩篇 :


以下測試使用 128*64 解析度的 SSD1306 OLED 顯示器, 搭配燒錄 MicroPython v1.23.0 韌體的 LOLIN D32 開發板 (ESP32). SSD1306 透過 I2C 介面與 ESP32 開發板的 GPIO21 (SDA) 與 GPIO22 (SCL) 連接, 這是 ESP32 預設的 I2C 接腳, 但也可以用 SoftI2C 模組將一般 GPIO 腳模擬成 I2C 的 SCL 與 SDA 接腳, 參考 :


本測試使用的 Adafruit SSD1306 驅動程式可從 GitHub 下載 :


首先將下載的 ssd1306.py 與 gfx.py 上傳到開發板 : 

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

用 dir() 檢視 gfx 模組內容 :

>>> import gfx     
>>> dir(gfx)      
['__class__', '__name__', '__dict__', '__file__', 'GFX']   

其中 GFX 類別就是用來建立繪圖物件的類別, 為了方便通常直接從 gfx 匯入 GFX 即可 :

>>> from gfx import GFX   

先匯入模組 : 

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

建立 SSD1306_I2C 物件 :

>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   
>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64  

這樣就可以呼叫 GFX 類別的建構式 GFX() 來建立 GFX 繪圖物件了, 呼叫時必須傳入螢幕寬度, 高度, 以及繪製像素的函式, 參數結構如下 :

GFX(width, height, pixel_function)   
 
因為 GFX 是一個通用的繪圖函式庫, 不是專用於特定螢幕 (可用於 SSD1306 或 ILI9341 等螢幕), 因此必須傳入螢幕尺寸與繪製像素的函式, 以 SSD1306 而言, 這些參數都可在 SSD1306_I2C 物件中取得, 檢視 oled 物件 : 

>>> dir(oled)     
['__class__', '__init__', '__module__', '__qualname__', '__dict__', 'addr', 'buffer', 'fill', 'framebuf', 'invert', 'pixel', 'scroll', 'text', 'width', 'height', 'external_vcc', 'pages', 'poweron', 'init_display', 'write_cmd', 'show', 'poweroff', 'contrast', 'write_framebuf', 'i2c', 'temp']

其中 width 與 height 屬性就是螢幕尺寸; 而 pixel() 就是像素繪製函式 : 

>>> oled.width   
128
>>> oled.height   
64
>>> oled.pixel      
<bound_method>

只要將此三個參數傳入 GFX() 即可建立 GFX 物件 : 

>>> gfx=GFX(oled.width, oled.height, oled.pixel)     
>>> type(gfx)     
<class 'GFX'>   

檢視 GFX 物件內容 :

>>> dir(gfx)    
['__class__', '__init__', '__module__', '__qualname__', '__dict__', 'fill_rect', 'hline', 'line', 'rect', 'vline', 'width', 'height', '_pixel', '_slow_hline', '_slow_vline', 'circle', 'fill_circle', 'triangle', 'fill_triangle']

其中 width 與 height 為傳入隻螢幕尺寸 :

>>> gfx.width   
128
>>> gfx.height      
64

GFX 物件提供許多繪圖函式, 摘要說明如下表 :


 GFX 物件的方法 說明
 line(x0, y0, x1, y1, color)  在座標 (x0, y0) 與 (x1, y1) 間畫一直線, color=0 (暗)/1 (亮)
 hline(x, y, w, color) 從座標 (x, y) 開始向右畫一段長 w 的水平線, color=0 (暗)/1 (亮)
 vline(x, y, h, color) 從座標 (x, y) 開始向下畫一段高 h 的水平線, color=0 (暗)/1 (亮)
 rect(x, y, w, h, color) 從座標 (x, y) 開始繪製寬 w 高 h 的矩形, color=0 (暗)/1 (亮)
 fill_rect(x, y, w, h, color) 從座標 (x, y) 開始繪製寬 w 高 h 的填滿矩形, color=0 (暗)/1 (亮)
 triangle(x0, y0, x1, y1, x2, y2, color) 連接座標 (x0, y0), (x1, y1), (x2, y2) 三點繪製三角形, color=0 (暗)/1 (亮)
 fill_triangle(x0, y0, x1, y1, x2, y2, color) 連接座標 (x0, y0), (x1, y1), (x2, y2) 三點繪製填滿三角形, color=0 (暗)/1 (亮)
 circle(x0, y0, r, color) 以 (x0, y0) 為圓心, r 為半徑繪製圓形, color=0 (暗)/1 (亮)
 fill_circle(x0, y0, r, color) 以 (x0, y0) 為圓心, r 為半徑繪製填滿圓形, color=0 (暗)/1 (亮)


這些繪圖方法都是利用呼叫 GFX() 時傳入的第三參數, 即顯示器的像素繪製函式 oled.pixel() 將圖描出來的, 不同的螢幕有各自的像素繪製函式, 因此 GFX 是一個可用於各型螢幕的通用函式庫. 


1. 繪製直線 :   

GFX 物件的 line(), hline(), vline() 可用來繪製直線, 先清除螢幕 : 

>>> oled.fill(0)      # 填滿暗點
>>> oled.show()     

呼叫 line() 方法繪製直線, 先畫一條左上角到右下角的對角線 : 

>>> gfx.line(0, 0, gfx.width-1, gfx.height-1, 1)  # 從左上角到右下角的對角線
>>> oled.show()    

再畫一條右上角到左下角的對角線 : 

>>> gfx.line(gfx.width-1, 0, 0, gfx.height-1, 1)   # 從右上角到左下角的對角線
>>> oled.show()    

結果如下 : 




接下來測試 vline() 與 hline(), 先清螢幕 :

>>> oled.fill(0)      # 填滿暗點
>>> oled.show()   

先畫一條起點為 (0, 31) 寬度為 128 的水平線  (即左邊中央往右) : 

>>> gfx.hline(0, 31, 128, 1)     
>>> oled.show()   

然後畫一條起點為 (63, 0) 高度為 63 的垂直線 (即最上面中央往下) : 

>>> gfx.vline(63, 0, 64, 1)    
>>> oled.show()     

結果如下 :




當然這也可以用 line() 方法來畫, 只是要傳入直線兩端座標較麻煩 : 

>>> gfx.line(0, 31, 127, 31, 1)   
>>> gfx.line(63, 0, 63, 63, 1)   
>>> oled.show()    

結果與上面是一樣的. 


2. 繪製矩形 :   

GFX 物件的 rect() 與 fill_rect()  可用來繪製矩形, 先清除螢幕 : 

>>> oled.fill(0)      # 填滿暗點
>>> oled.show()     

繪製一個沿螢幕邊框的方框矩形 : 

>>> gfx.rect(0, 0, 128, 64, 1)    
>>> oled.show()   

結果是一個有 1px 寬度的方框 :




如果要讓框邊加粗 1 個 px, 可以在此矩形內畫一個內縮 1px 的矩形, 這時左上角座標要用 (1, 1), 尺寸因兩邊各縮 1px 變成 128-2=126 與 64-2=62 : 

>>> gfx.rect(1, 1, 126, 62, 1)      
>>> oled.show()     

結果如下 :

 


下面測試 fill_rect() 效果, 先清除螢幕 : 

>>> oled.fill(0)      # 填滿暗點
>>> oled.show()      

然後在螢幕四周畫一個 1px 邊框 : 

>>> gfx.rect(0, 0, 128, 64, 1)     
>>> oled.show()     

接著往內縮一個 px 用 fill_rect() 畫一個填滿內部的矩形, 因邊框佔據 1px, 內縮 1px, 故左上角座標是 (2, 2), 尺寸要減 4 : 

>>> gfx.fill_rect(2, 2, 124, 60, 1)    
>>> oled.show()  

結果如下 : 




由於填滿效果會不斷繪製畫素, 故照相時會因掃描頻率較低產生 aliasing 現象, 實際顯示效果是白邊框內有一黑框, 裡面才是白色填滿. 


3. 繪製三角形 :   

GFX 物件的 triangle() 與 fill_triangle()  繪製三角形, 先清除螢幕 : 

>>> oled.fill(0)      # 填滿暗點
>>> oled.show()     

用 triangle() 在螢幕繪製一個等腰三角形 :

>>> gfx.triangle(0, 0, 127, 0, 63, 64, 1)   
>>> oled.show()      

結果如下 : 




用 fill_triangle() 在螢幕繪製一個填滿的等腰三角形 :

>>> oled.fill(0)      # 填滿暗點清除螢幕
>>> oled.show()     
>>> gfx.fill_triangle(0, 0, 127, 0, 63, 64, 1)    
>>> oled.show()    

結果如下 : 




4. 繪製圓形 :   


GFX 物件的 triangle() 與 fill_triangle()  繪製三角形, 先清除螢幕 : 

>>> oled.fill(0)      # 填滿暗點清除螢幕
>>> oled.show()     

呼叫 circle() 在螢幕左半邊畫一個圓形, 圓心為 (31, 31); 再呼叫 fill_circle() 在右半邊畫一個填滿的圓形, 圓心為 (96, 31), 半徑都是 31 : 

>>> gfx.circle(31, 31, 31, 1)   
>>> gfx.fill_circle(96, 31, 31, 1)   
>>> oled.show()     

結果如下 : 




2024年10月30日 星期三

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 更新 :