2024年10月14日 星期一

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

在前一篇測試中我們已經掌握如何利用 MicroPython 內建的 framebuf 模組將圖片的 bytearray 資料存入顯示緩衝器後顯示於 SSD1306 OLED 螢幕上, 本篇則是要從 OpenWeatherMap 每 60 秒抓取一次氣象資料, 然後以圖片方式顯示於 SSD1306 上. 氣象爬蟲做法參考 :



4. 以圖片方式顯示 OpenWeatherMap 氣象資訊 : 

在 "MicroPython 學習筆記 : 用 SSD1306 顯示氣象資訊 (一)" 這篇測試中我們已將 OpenWeatherMap 氣象資料爬蟲寫成如下之 get_weather() 函式 : 

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

首先將 SSID 與密碼以及 OpenWeatherMap API key 寫在 config.py 中, 匯入要用到的模組 : 

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

連上網路 : 

>>> ip=xtools.connect_wifi(config.SSID, config.PASSWORD)     
Connecting to network...
network config: ('192.168.192.189', '255.255.255.0', '192.168.192.92', '192.168.192.92')

設定 get_weather() 需要的參數後呼叫它就會傳回現在的溫濕度與氣壓等資料 :

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

接下來就可以用 big_symbol 模組的 Symbol 物件將這些氣象資料以較大尺寸的圖片顯示至 SSD1306 上. 先建立 SSD1306_I2C 物件 oled :

>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21))     # 建立 I2C 物件 (ESP32)
>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c)     # 建立 SSD1306_I2C 物件 
>>> sb=big_symbol.Symbol(oled)     # 建立 Symbol 物件

先清除螢幕 :

>>> sb.clear()    

呼叫 Symbol 物件的 temp() 方法顯示溫度像素圖 :

>>> sb.temp(0, 0)   
>>> oled.show()      

顯示 get_weather() 傳回的溫度於溫度像素圖後面 :

>>> sb.text(f"{data['temperature']}c", 32, 0)   
>>> oled.show()   

接下來呼叫 Symbol 物件的 temp() 方法顯示濕度像素圖 :

>>> sb.humid(0, 16)    
>>> oled.show()   

顯示 get_weather() 傳回的溼度於濕度像素圖後面 :

>>> sb.text(f"{data['humidity']}%", 32, 16)    
>>> oled.show()   

然後是顯示氣壓 : 

>>> sb.press(0, 32)   
>>> sb.text(f"{data['pressure']}hPa", 32, 32)   
>>> oled.show()    

最後可用 ASCII 文字在座標 (0, 48) 位置顯示目前時間以及在座標 (0, 56) 顯示地點 : 

>>> now=xtools.tw_now()    
Querying NTP server and set RTC time ... OK.
>>> now    
'2024-10-14 22:15:09'
>>> oled.text(now, 0, 48, 1)    
>>> oled.text(city, 0, 56, 1)    
>>> oled.show()     

這樣就會在 OLED 上顯示氣象資料了 :




完整程式碼如下 :

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

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)
weather_api_key=config.WEATHER_API_KEY
city='Kaohsiung'   
country='TW'
data=get_weather(country, city, weather_api_key) 
i2c=I2C(0, scl=Pin(22), sda=Pin(21)) 
oled=ssd1306.SSD1306_I2C(128, 64, i2c)
sb=big_symbol.Symbol(oled) 
sb.clear()
sb.temp(0, 0) 
sb.text(f"{data['temperature']}c", 32, 0)  
sb.humid(0, 16)  
sb.text(f"{data['humidity']}%", 32, 16)   
sb.press(0, 32)   
sb.text(f"{data['pressure']}hPa", 32, 32) 
oled.text(city, 0, 56, 1)   
now=xtools.tw_now()    
oled.text(now, 0, 48, 1)    
oled.show() 

但上面的測試只是取得並顯示一筆資料而已, 我們的目標是要每分鐘更新最新氣象資料, 所以要用無窮迴圈來做, 程式碼如下 :

import config  
import xtools   
import urequests  
import ujson
from machine import I2C, Pin
import ssd1306   
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)
        sb.clear()
        sb.temp(0, 0)       
        sb.text(f"{data['temperature']}c", 32, 0)
        sb.humid(0, 16) 
        sb.text(f"{data['humidity']}%", 32, 16)
        sb.press(0, 32)
        sb.text(f"{data['pressure']}hPa", 32, 32)
        oled.text(xtools.tw_now(), 0, 48, 1)
        oled.text(city, 0, 56, 1)
        oled.show()
        time.sleep(60)
else:
    print('無法連線 WiFi')

這樣每分鐘就會刷新螢幕顯示最新的氣象資料了. 

2024年10月13日 星期日

2024 年第 41 周記事

週五請了一天休假變成連休四天, 連同上週颱風假, 這兩周只上了四天班. 我週三國慶日下午才決定要回鄉下, 早上整理零件庫, 清出許多塊被遺忘的零組件, 例如 GPS 模組, 做完 SSD1306 與 MQTT 實驗後就輪到 GPS 模組啦! 這四天無人造訪也無外務, 所以能專心玩 SSD1306 (連阿泉伯打電話叫我中午去愛心會會員大會吃飯我都婉拒啦, 去一趟至少要花掉 2.5 小時, 時間較寶貴). 

這幾天鄉下天氣都是早上大太陽, 下午就狂風驟雨, 所以休假這幾天沒去巡墓園, 颱風過後可能很多雜草樹枝沖刷下來, 等下周電動打草機到貨, 打算周五晚上帶回鄉下, 電池充一個晚上後週六早上拿來去墓園試打看看. 

這幾天時間很多, 程式寫一段落就去給芭樂套袋, 兩天就全套完了, 合計大約 110 顆, 4 棵樹平均本棵套了 20 多個, 本來想疏一下果, 但颱風吹掉一些剛開的花, 所以我只拔掉三生的果, 雙生的就姑且留下. 

前天發現部落格有人留言, 打開一看居然是服役時的長官阮連長, 昨晚我打電話過去聊了好久, 真是太高興了. 會寫那篇是五年前二哥高中畢業時東華大學書審正取, 我陪他去東華面試, 行前找了谷歌地圖了解一下, 畢竟退伍之後就一直沒再去過, 突然想起阮連長似乎是花蓮人, 就試試看能否在臉書找到他, 結果居然找到了, 但因為時間趕就沒在臉書留言, 一晃五年就過去了. 剛退休過著含飴弄孫的生活令我羨慕 (我還要六年哈哈), 下次有到花蓮再來去拜訪.  

下周要去台北出差當評鑑委員, 本想順道去拜訪小阿姨, 但還是搭高鐵當日來回算了, 主要是報差旅也簡單, 因為很少出差, 每次出差回來都要到處問很麻煩. 

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

在前一篇測試中我們已將從 OpenWeatherMap 取得的氣象資料成功地顯示在 SSD1306 顯示器上, 但由於 SSD1306 只能顯示 ASCII 字元, 所以無法顯示傳回值中以 Unicode 表示的天氣描述. 替代方案是可以利用傳回值中的 icon 編號來顯示天氣概況圖, 然後用 MicroPython 內建的 framebuf 模組顯示該像素圖於 OLED 螢幕. 

本篇先來研究 SSD1306 顯示原理以及如何利用 framebuf 模組來操作顯示緩衝區. 

本系列之前的文章參考 :

MicroPython 相關文章索引 :


此實驗主要使用 MicroPython 內建的 framebuf 模組來操作顯示緩衝區的 Bytes 類型資料, 以便能在 SSD1306 OLED 螢幕上顯示圖形與圖形化的文字. 


1. 顯示自訂字元 : 

SSD1306 是尺寸為 0.96 吋, 解析度為 128*64 畫素的 OLED 螢幕, 驅動程式 ssd1306.py 透過內建模組 framebuf 可以顯示 ASCII 字元, 每個 ASCII 字元佔用 8*8 像素區域, 但實際使用 6*8 像素 (左右各保留 1px 作為間距), 例如下面為 '0' 的像素圖 (bitmap) :




因此, 128*64 的解析度每列可顯示 128/8=16 個字, 共可顯示 64/8=8 列字, 整個螢幕可顯示 16*8=128 個字. 

如果要顯示 ASCII 字元以外的符號, 例如表示溫度的右上角小圈圈, 就要自行製作像素圖 (bitmap) 利用內建模組 framebuf 寫入顯示緩衝區, 除了自訂字元外, 顯示圖片也是使用此方法, 原理都是一樣利用 bitmap : 



顯示一個像素可以用 1 表示, 不顯示則為 0, 因此一張像素圖可用元素為 0 或 1 的二維陣列表示, 以上面溫度符號的 8*8 的像素圖而言, 最上面一列可以用二進位的 0b00110000 表示, 或者用 16 進位的 0x30 表示, 因此整張像素圖可以用下列串列表示 :

degree=[0x30, 0x48, 0x48, 0x30, 0x00, 0x00, 0x00, 0x00]  

其中元素是由上而下順序排列, 最後四列都是不顯示的 0, 所以都是 0x00. 這種表示法稱為 HLSB (水平最低有效位元, Horrizontal Least Significant Bit), 即不論行列都是先從 LSB (即座標索引 0) 開始排列.

另外一種像素圖表示方式稱為 HMSB (水平最大有效位元, Horrizontal Maximum Significant Bit), 列仍是從最小列索引 0 開始, 但行卻由最大索引 7 開始排, 也就是列仍由上而下, 但行改為由右而左, 這樣上面的溫度符號第一列就要表示成 0b00001100, 或 16 進位的 0x0c; 第二列是 0b00010010 或 0x12; 依此類推, 整個像素圖用 HMSB 排列表示如下 :

degree=[0x0c, 0x10, 0x10, 0x0c, 0x00, 0x00, 0x00, 0x00]  

還有一種表示法稱為 VLSB (垂直最低有效位元, Vertical Least Significant Bit), 它是列由下而上, 行由左而右排列, 即由左下角像素往上跑到頂後再往右跑, 故第一個 byte 是 0b00000000 或 0x00, 第二個 byte 是 0b00000110 或 0x06, 依此類推, 整個像素圖用 VLSB 排列表示如下 :

degree=[0x00, 0x06, 0x09, 0x09, 0x06, 0x00, 0x00, 0x00] 

不論哪一種像素圖排列方式, framebuf 模組都支援, 只要指定好排列方式它就能正確解讀. 但上面這些像素圖串列其實都是整數串列, 必須用內建函式 bytearray() 轉成 bytes 類型的串列才能放進顯示緩衝器為 framebuf 所用, 例如 :

>>> degree=[0x00, 0x06, 0x09, 0x09, 0x06, 0x00, 0x00, 0x00]    # VLSB 排列模式
>>> degree   
[0, 6, 9, 9, 6, 0, 0, 0]
>>> type(degree)       # 整數串列
<class 'list'>
>>> type(degree[0])      
<class 'int'>
>>> buf=bytearray(degree)    # 轉成 bytearray 物件
>>> buf    
bytearray(b'\x00\x06\t\t\x06\x00\x00\x00')
>>> type(buf)   
<class 'bytearray'>

接下來匯入 framebuf 模組並檢視其成員 :

>>> import framebuf      
>>> dir(framebuf)    
['__class__', '__name__', 'FrameBuffer', 'FrameBuffer1', 'GS2_HMSB', 'GS4_HMSB', 'GS8', 'MONO_HLSB', 'MONO_HMSB', 'MONO_VLSB', 'MVLSB', 'RGB565', '__dict__']   

其中 FrameBuffer 類別就是操作顯示緩衝區記憶體的主角, 呼叫其建構式 FrameBuffer() 並傳入要存入顯示緩衝器的 bytearray 資料, 尺寸, 與像素圖表示方式即可建立一個 FrameBuffer 物件, 

fb=framebuf.FrameBuffer(buf, width, height, mode)    

其中 buf 為像素圖之 bytearray 物件, width 與 height 是像素圖之尺寸 (px), 而 mode 為像素圖的排列模式, 也就是 FrameBuffer 類別成員中的三個常數 MONO_HLSB, MONO_HMSB, 與 MONO_VLSB, 其值均為常數 : 

>>> framebuf.MONO_HLSB    
3
>>> framebuf.MONO_HMSB   
4
>>> framebuf.MONO_VLSB  
0

以上面的溫度符號為例, 如果以 VLSB 排列模式建立該字元的 FrameBuffer 物件 :

>>> fb=framebuf.FrameBuffer(buf, 8, 8, framebuf.MONO_VLSB)    
>>> type(fb)   
<class 'FrameBuffer'>

檢視 FrameBuffer 物件內容 :

>>> dir(fb)   
['__class__', 'blit', 'ellipse', 'fill', 'fill_rect', 'hline', 'line', 'pixel', 'poly', 'rect', 'scroll', 'text', 'vline'] 

可見 FrameBuffer 物件提供了 13 個方法來操控顯示緩衝區, 通常都是透過 SSD1306_I2C 物件的 framebuf 類別以靜態方法呼叫使用, 例如 :

>>> from machine import I2C, Pin
>>> import ssd1306   
>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32   
>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c)     
>>> type(oled)    
<class 'SSD1306_I2C'>  

變數 oled 是一個 SSD1306_I2C 物件, 其成員包含一個 framebuf 類別 :

>>> 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']
>>> dir(oled.framebuf)      
['__class__', 'blit', 'ellipse', 'fill', 'fill_rect', 'hline', 'line', 'pixel', 'poly', 'rect', 'scroll', 'text', 'vline']

可見與上面 FrameBuffer 物件的方法完全一樣, 所以這些方法可以做為 framebuf 的靜態方法呼叫, 例如要在 OLED 上顯示 27 度, 可以呼叫 oled 的 text() 先輸出兩個字元 27, 然後在第三個字元 (座標 x=16, y=0) 輸出儲存在 buf 緩衝區的度字元 :

>>> oled.text('27', 0, 0)   
>>> oled.framebuf.blit(fb, 16, 0)   
>>> oled.show()    

結果如下 :




不管像素圖排列模式為何, 只要在建立 FrameBuffer 物件時指定正確的 mode 即可, 例如下面是改用 HLSB 排列的像素圖 :

>>> degree=[0x30, 0x48, 0x48, 0x30, 0x00, 0x00, 0x00, 0x00]   
>>> buf=bytearray(degree)   
>>> buf    
bytearray(b'0HH0\x00\x00\x00\x00')
>>> fb=framebuf.FrameBuffer(buf, 8, 8, framebuf.MONO_HLSB)        
>>> oled.fill(0)       # 將 1 填滿顯示緩衝器 (點亮畫素)
>>> oled.show()   
>>> oled.text('27', 0, 0)    
>>> oled.framebuf.blit(fb, 16, 0)    
>>> oled.show()       

顯示的結果是一樣的. 


2. 顯示圖片 : 

在 SSD1306 上顯示圖片同樣是使用 bitmap 原理, 只是若要像上面製作溫度符號那樣一個一個像素去編輯串列就太累了, 有現成的線上軟體可以自動產生 bytesarray 資料, 例如 :


先用小畫家或其他圖片編輯軟體 (我使用抓圖軟體 picpick) 製作 bmp 圖檔後上傳, 指定排列模式即可產生 bytesarray 資料. 例如要顯示 32*16 解析度的中文 "溫度" 像素圖, 因解析度小不好編輯, 所以先用 Picpick 開啟一個 320*160 的新檔案 :




然後用大小為 60 的粗體微軟正黑體字型輸入 "溫度" :

 


點選 "常用/調整大小/調整圖片大小縮放" :




設定縮放比為 10% 將目前的 320*160 縮成 32*16 大小 : 




最後存檔為 temperature.bmp (注意, 必須為 .bmp). 

接下來到 image2cpp 網站將此像素檔上傳 :



第二步基本上不用設定, 預設即可 : 




第三步要選擇 Plain bytes, 挑選一個排列模式, 用 Horizontal 即可 (即 HLSB 模式), 然後按 Generate code 鈕就會在下方顯示 bytesarray 資料 : 




將這些像素資料複製起來 :




將其存入一個串列中後用 bytearray() 轉成 bytearray 類型 :

>>> temperature=[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe6, 0x03, 0xfc, 0xff, 0xf2, 0xdb, 0xc0, 0x07, 
0xfe, 0x9b, 0xdf, 0xff, 0xee, 0xab, 0xd0, 0x07, 0xe6, 0xf3, 0xdb, 0xdf, 0xff, 0xff, 0xd8, 0x1f, 
0xff, 0xff, 0xdf, 0xff, 0xfe, 0x03, 0xd0, 0x1f, 0xf7, 0xab, 0xdb, 0x9f, 0xf7, 0xab, 0xdd, 0x3f, 
0xe4, 0xab, 0xbc, 0x3f, 0xec, 0x01, 0xb3, 0xc7, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]   
>>> buf=bytearray(temperature)   
>>> buf    
bytearray(b'\xff\xff\xff\xff\xff\xff\xff\xff\xe6\x03\xfc\xff\xf2\xdb\xc0\x07\xfe\x9b\xdf\xff\xee\xab\xd0\x07\xe6\xf3\xdb\xdf\xff\xff\xd8\x1f\xff\xff\xdf\xff\xfe\x03\xd0\x1f\xf7\xab\xdb\x9f\xf7\xab\xdd?\xe4\xab\xbc?\xec\x01\xb3\xc7\xff\xff\xff\xff\xff\xff\xff\xff')   

然後呼叫 framebuf.FrameBuffer() 並傳入此 bytearray 建立 FrameBuffer 物件, 注意, 上面用 image2cpp 產生像素圖時指定水平排列, 故此處要用 MONO_HLSB), 就可以呼叫 blit() 方法顯示在 OLED 上了 :

>>> fb=framebuf.FrameBuffer(buf, 32, 16, framebuf.MONO_HLSB)    
>>> oled.fill(0)     # 熄滅全部畫素    
>>> oled.show()    
>>> oled.framebuf.blit(fb, 0, 0)    
>>> oled.show()   

結果如下 : 




由於是使用 Windows 字型, 周圍有美化字型的像素調整, 經過縮小後會有些失真, 但基本上還算清楚可讀. 用同樣方法我硬製作了 "濕度" : 

>>> humidity=[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe4, 0x03, 0xfc, 0xff, 0xf4, 0xfb, 0xc0, 0x07, 
0xfc, 0xfb, 0xdf, 0xff, 0xfc, 0x03, 0xd0, 0x0f, 0xe6, 0xff, 0xdb, 0x9f, 0xf4, 0xab, 0xd8, 0x3f, 
0xfc, 0x67, 0xdf, 0xff, 0xf6, 0xe3, 0xd0, 0x1f, 0xf4, 0x21, 0xdb, 0x9f, 0xef, 0xff, 0xdd, 0xbf, 
0xed, 0x6b, 0xbc, 0x3f, 0xeb, 0x6d, 0xa3, 0x87, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]     
>>> buf=bytearray(humidity)    
>>> fb=framebuf.FrameBuffer(buf, 32, 16, framebuf.MONO_HLSB)    
>>> oled.framebuf.blit(fb, 0, 16)     # Y 軸往下挪 16 個畫素
>>> oled.show()    

以及 "氣壓" : 

>>> pressure=[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfb, 0xff, 0x80, 0x0f, 0xf0, 0x03, 0xbf, 0xff, 
0xe7, 0xff, 0xa4, 0xdf, 0xef, 0xff, 0xa4, 0x8f, 0xf0, 0x07, 0xbf, 0xdf, 0xff, 0xf7, 0xa4, 0x9f, 
0xf5, 0xb7, 0xa5, 0xaf, 0xfc, 0xf7, 0xad, 0x6f, 0xec, 0xf7, 0xbf, 0xff, 0xfd, 0xf5, 0xa0, 0x0f, 
0xe5, 0x93, 0xbe, 0xff, 0xed, 0xf3, 0xe0, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]    
>>> buf=bytearray(pressure)     
>>> buf   
bytearray(b'\xff\xff\xff\xff\xff\xff\xff\xff\xfb\xff\x80\x0f\xf0\x03\xbf\xff\xe7\xff\xa4\xdf\xef\xff\xa4\x8f\xf0\x07\xbf\xdf\xff\xf7\xa4\x9f\xf5\xb7\xa5\xaf\xfc\xf7\xado\xec\xf7\xbf\xff\xfd\xf5\xa0\x0f\xe5\x93\xbe\xff\xed\xf3\xe0\x0f\xff\xff\xff\xff\xff\xff\xff\xff')   
>>> fb=framebuf.FrameBuffer(buf, 32, 16, framebuf.MONO_HLSB)     
>>> oled.framebuf.blit(fb, 0, 32)       # Y 軸再往下挪 16 個畫素
>>> oled.show()      

結果如下 : 




如果在這些中文像素圖後面用 oled.text() 顯示文字, 因為其大小為 8*8, 會顯得不搭配 :

>>> oled.text('27.5', 32, 0)   
>>> oled.text('89%', 32, 16)   
>>> oled.text('1012hPa', 32, 32)   
>>> oled.show()  

結果如下 : 




如果要讓標題與數值搭配, 則需要替 0~9 與 % 等符號製作 32*16 的像素圖, 利用 framebuf 建立顯示緩衝區輸出到 OLED 螢幕. 


3. 使用 bigSymbol.py 模組顯示氣象資訊 : 

下面利用趙英傑老師的 "超圖解 Python 物聯網實作入門" 這本書第 12-5 節提供的 bigSymbol.py 模組來顯示 32*16 解析度的氣象資料, 該模組提供  0~9, c (溫度符號), %, 小數點, 以及中文 "溫度" 與 "濕度" 等字元的 32*16 像素圖. 


不過此 bigSymbol.py 在新版韌體上無法執行, 因為 SSD1306_I2C 物件已不能直接呼叫 blit() 方法, 必須呼叫 framebuf 類別的靜態方法 blit() 才行, 所以我將此模組略加修改後改名為 big_symbol.py :

sefl.oled.blit(fb, x, y)  => sefl.oled.framebuf.blit(fb, x, y) 

另外我想顯示氣壓資訊, 所以將上面氣壓像素圖 pressure 的 bytearray 資料加進來新增了一個 _PRESS 變數 :

_PRESS = bytearray(b'\xff\xff\xff\xff\xff\xff\xff\xff\xfb\xff\x80\x0f\xf0\x03\xbf\xff\xe7\xff\xa4\xdf\xef\xff\xa4\x8f\xf0\x07\xbf\xdf\xff\xf7\xa4\x9f\xf5\xb7\xa5\xaf\xfc\xf7\xado\xec\xf7\xbf\xff\xfd\xf5\xa0\x0f\xe5\x93\xbe\xff\xed\xf3\xe0\x0f\xff\xff\xff\xff\xff\xff\xff\xff') 

以及一個顯示 "氣壓" 像素圖的 press() 方法

def press(self, x, y):
        fb = framebuf.FrameBuffer(self._PRESS, 32, 16, framebuf.MONO_HLSB)
        self.oled.framebuf.blit(fb, x, y)

注意, 因為我上面製作 "氣壓" 像素圖時使用水平排列模式, 所以這裡要指定用 MONO_HLSB 去讀取才會正確解碼, 而原 bigSymbol.py 模組裡面的像素圖則都使用 MONO_VLSB. 模式. 

氣壓單位為 hPa, 所以我也新增了 'h', 'P', 'a' 這三個字元像素圖的 bytearray : 

>>> h=[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xfc, 0x40, 0x40, 0x40, 0xc0, 0x80, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0x1f, 0x00, 0x00, 0x00, 0x1f, 0x1f, 0x00, 0x00, 0x00]   
>>> buf=bytearray(h)   
>>> buf      
bytearray(b'\x00\x00\x00\x00\x00\x00\xfc\xfc@@@\xc0\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x1f\x00\x00\x00\x1f\x1f\x00\x00\x00')
>>> P=[0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x08, 0x08, 0x08, 0x18, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]   
>>> buf=bytearray(h)   
>>> buf   
bytearray(b'\x00\x00\x00\x00\x00\xf8\x08\x08\x08\x18\xf0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x03\x01\x01\x01\x00\x00\x00\x00\x00\x00')
>>> a=[0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x90, 0x90, 0xe0, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x00, 0x07, 0x09, 0x08, 0x00, 0x07, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
>>> buf=bytearray(h)   
>>> buf   
bytearray(b'\x00\x00\x00\x00\x000\x90\x90\xe0\xc0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\t\x08\x00\x07\x0f\x00\x00\x00\x00\x00\x00')

注意, 因為數字符號字元都是暗底亮字顯示, 所以在製作 hPa 這三個字元的 bytearray 資料時, 要在第二步勾選 Invert image colors 這一項, 否則會變成亮底暗字, 跟 0~9 相反 :




其次, 為了跟原來 0~9 等字元一致, hPa 都使用垂直排列, 第四步要選 Vertical :




我將新版的 big_symbol.py 模組放在 GitHub :


將此 big_symbol.py 上傳開發板後重啟系統, 執行下列程式 :

# ssd1306_big_symbol.py 
from machine import I2C, Pin
import ssd1306
import big_symbol

i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32
oled=ssd1306.SSD1306_I2C(128, 64, i2c)
sb=big_symbol.Symbol(oled)
sb.clear()
sb.temp(0, 0)
sb.text('27.85c', 32, 0)
sb.humid(0, 16)
sb.text('89.66%', 32, 16)
sb.press(0, 32)
sb.text('1021hPa', 32, 32)
oled.show()

結果如下 : 




可見當氣壓是 4 位數時, hPa 最後一個字元 a 會因為超出螢幕而消失. 如果是 3 位數就會顯現 :

>>> sb.text('782hPa', 32, 32)   
>>> oled.show()    




完整程式碼與上面所用檔案壓縮檔可在 GitHub 下載 :


終於在自請休假連休 4 天的結尾搞定顯示緩衝區 framebuf 的用法了, 好累累. 

2024年10月11日 星期五

露天購買 GY-NEO6MV2 飛控 GPS 模組

今天在趙英傑老師的 "超圖解 Python 物聯網實作入門" 這本書上看到用 GPS 模組的介紹 (使用 UART 通訊), 書中使用的 GPS 模組型號是 GY-NEO6MV2, 我在露天找到之前常光顧的賣家 DIY_LAB 有賣, 一片 120 元, 先買兩片來玩看看 : 





全家取貨免運 240 元. 

另一個賣家是一片 110 元, 但外型與書中不同, 可能是舊型的 :


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

昨天搞定 SSD1306 OLED 顯示器的驅動程式問題後, 想要用它來顯示從 OpenWeatherMap 抓取的高雄氣象資料, 氣象爬蟲做法參考 :


本系列之前的文章參考 :


MicroPython 相關文章索引 :


基於此項實驗使用較多記憶體, 因此使用 ESP32 開發板, 此次使用 LOLIN D32, 同時將連網 SSID 帳密與 OpenWeatherMap 的 API key 寫在 config.py 檔案內. 首先匯入 config 與 xtools 模組連網 : 

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

接著匯入 urequests 與 ujson 模組, 從 config.py 取出 OpenWeaherMap API key, 定義 city 與 country 變數將其嵌入 API 網址中, 然後呼叫 urequests.get() 傳入 API 網址即可取得氣象資料 :

>>> import urequests   
>>> import ujson   
>>> weather_api_key=config.WEATHER_API_KEY
>>> city='Kaohsiung'   
>>> country='TW'   
>>> url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={weather_api_key}'   
>>> r=urequests.get(url)   
>>> data=ujson.loads(r.text)    # 轉成字典
>>> data   
{'timezone': 28800, 'cod': 200, 'dt': 1728572708, 'base': 'stations', 'weather': [{'id': 803, 'icon': '04n', 'main': 'Clouds', 'description': '\u591a\u96f2'}], 'sys': {'country': 'TW', 'sunrise': 1728510789, 'sunset': 1728553082, 'id': 2002588, 'type': 2}, 'name': 'Kaohsiung City', 'clouds': {'all': 75}, 'coord': {'lon': 120.3133, 'lat': 22.6163}, 'visibility': 10000, 'wind': {'speed': 2.57, 'deg': 40}, 'id': 1673820, 'main': {'feels_like': 26.33, 'pressure': 1012, 'temp_max': 25.97, 'temp': 25.58, 'temp_min': 25.58, 'humidity': 82, 'sea_level': 1012, 'grnd_level': 1011}}

我們感興趣的氣象資料是溫溼度與氣壓 : 

>>> temperature=data['main']['temp']   
>>> temperature   
25.58
>>> humidity=data['main']['humidity']    
>>> humidity   
82 
>>> pressure=data['main']['pressure']   
>>> pressure   
1012

氣候狀況描述會傳回該地區語言的 Unicode, 用 print() 即可解碼 :

>>> data['weather'][0]['description']  
'\u591a\u96f2'
>>> print(data['weather'][0]['description'])  
多雲

但中文無法在 SSD1306 顯示, 但可以用天氣圖示表示, OpenWeatherMap 定義了 17 種天氣概況圖示並與已命名, 放在  data['weather'][0]['icon'] : 

>>> data['weather'][0]['icon']    
'04n'

這個編號後續測試會用到. 

接下來就可以將這些資料輸出至 SSD1306 顯示, 先匯入 machine 模組下的 Pin 與 I2C 類別, 以及 ssd1306 模組 (鑰先上傳開發板) : 

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

呼叫 I2C 類別之建構式 I2C() 並傳入 I2C 的兩個接腳 SDA 與 SCL 之 Pin 物件以建立 i2c 物件 : 

>>> i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C   

建立代表 OLED 顯示器的 SSD1306_I2C 物件 : 

>>> oled=ssd1306.SSD1306_I2C(128, 64, i2c)    # 0.96 吋解析度 128*64  

此物件有如下常用方法 : 


 SSD1306_I2C 物件的方法 說明
 fill(col) 將顯示記憶體全部畫素填入 col=1 (亮) 或 0 (暗)
 pixel(x, y, col) 在顯示記憶體指定畫素位置 (x, y) 填入 col=1 (亮) 或 0 (暗)
 text(string, x, y, col=1) 在顯示記憶體指定畫素位置 (x, y) 起填入預設 col=1 之字串 string
 show() 將顯示記憶體內容輸出於面板顯示內容
 scroll(dx, dy) 將顯示記憶體畫素內容向上下 (dy) 或向左右 (dx) 捲動
 invert(state) state=True (反白), False (取消反白)


先點亮 OLED 畫面 :

>>> oled.fill(1)      # 將 1 填滿顯示緩衝器 (點亮畫素)
>>> oled.show()  

然後呼叫 text(x, y, on) 在第一列顯示城市, 其中 x, y 為座標, on 表示畫素點亮與否, 1=點亮, 0= 熄滅, 因上面我們將 OLED 每個畫素都點亮, 故顯示文字時需將其熄滅 : 

>>> oled.text(city, 0, 0, 0)   

在第二列顯示縣在時間, 先呼叫 xtools.tw_now() 取得時間再傳給 oled.text() : 

>>> now=xtools.tw_now()   
Querying NTP server and set RTC time ... OK.
>>> now     
'2024-10-10 23:39:13'
>>> oled.text(now, 0, 8, 0)  

注意, SSD1306 解析度 128*64, text() 方法以 8*8 畫素繪製一個字元, 因此每列可顯示 128/8=16 個字元, 共可顯示 64/8=8 列文字, 往下移一列時 Y 座標加 8. 

第 3~5 列分別顯示溫溼度與氣壓 :

>>> oled.text(f'Temperature:{temperature}', 0, 16, 0)    
>>> oled.text(f'Humidity:{humidity}', 0, 24, 0)    
>>> oled.text(f'Pressure:{pressure}', 0, 32, 0)     

最後需呼叫 show() 方法才會顯示 : 

>>> oled.show()  

結果如下 : 




不過亮背景方式較耗電, 應改採用暗背景亮文字方式較省電, 這時要先熄滅所有畫素, 呼叫 text() 方法時第三參數需傳入 1 點亮畫素 :

>>> oled.fill(0)      # 將 0 填滿顯示緩衝器 (熄滅畫素)
>>> oled.text(city, 0, 0, 1)  
>>> oled.text(now, 0, 8, 1)  
>>> oled.text(f'Temperature:{temperature}', 0, 16, 1)    
>>> oled.text(f'Humidity:{humidity}', 0, 24, 1)    
>>> oled.text(f'Pressure:{pressure}', 0, 32, 1)    
>>> oled.show()   

結果如下 : 




SSD1306 只能顯示 ASCII 字元, 因此天氣狀況描述 (中文 Unicode) 在缺乏中文字元集解碼情況下無法正常顯示 :

>>> oled.text(f'Pressure:{pressure}', 0, 40, 1)        
>>> oled.show()     

結果顯示一個長直條 :




以上測試之完整程式碼如下 : 

# weather_ssd1306_1.py
import config   
import xtools
import urequests   
import ujson
from machine import I2C, Pin   
import ssd1306   

ip=xtools.connect_wifi(config.SSID, config.PASSWORD)
weather_api_key=config.WEATHER_API_KEY
city='Kaohsiung'   
country='TW'   
url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={weather_api_key}'   
r=urequests.get(url)   
data=ujson.loads(r.text) # 轉成字典
temperature=data['main']['temp']   
humidity=data['main']['humidity']    
pressure=data['main']['pressure']
description=data['weather'][0]['description']
now=xtools.tw_now() 
i2c=I2C(0, scl=Pin(22), sda=Pin(21)) # ESP32 I2C
oled=ssd1306.SSD1306_I2C(128, 64, i2c) # 0.96 吋解析度 128*64
oled.fill(0)      # 將 0 填滿顯示緩衝器 (熄滅畫素)
oled.text(city, 0, 0, 1)  
oled.text(now, 0, 8, 1)  
oled.text(f'Temperature:{temperature}', 0, 16, 1)    
oled.text(f'Humidity:{humidity}', 0, 24, 1)    
oled.text(f'Pressure:{pressure}', 0, 32, 1)    
oled.show()  

但此程式只能顯示抓取一次氣象資料, 應該週期性地 (例如每分鐘) 去 OpenWeatherMap 抓資料後更新 SSD1306 才實用. 首先將之前寫的天氣爬蟲函式改寫如下 :

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

參考 :


先在互動環境測試此函式 : 

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

然後將輸出至顯示器的功能寫成一個 show_weather() 函式 :

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

此函式要傳入一個 SSD1306_I2C 物件 oled 與要顯示之資料字典 data, 這主要是在上面呼叫 get_weather() 時取得的傳回值上添加城市 city 與時間 time 這兩個鍵 : 

>>> data['time']=xtools.tw_now()   
Querying NTP server and set RTC time ... OK.
>>> data['city']=city    
>>> data    
{'pressure': 1012, 'time': '2024-10-11 08:25:10', 'temperature': 26.68, 'city': 'Kaohsiung', 'humidity': 84, 'geocode': 1673820, 'icon': '04d'}

呼叫 show_weather() 測試 OK : 

>>> show_weather(oled, data)   

接下來只要匯入 time 模組, 然後用一個無窮迴圈每 60 秒去 OpenWeatherMap 抓一次資料來更新 OLED 顯示器即可 : 

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

完整程式碼如下 : 

# weather_ssd1306_2.py
import config   
import xtools
import urequests   
import ujson
from machine import I2C, 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=I2C(0, scl=Pin(22), sda=Pin(21)) # 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()

比較系統化的結構是把連線 WiFi 的功能寫在 main.py :

# main.py
import xtools    
import config
import weather_ssd1306      

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

然後把上面的 weather_ssd1306_test_2.py 改寫為下面的 weather_ssd1306.py :

# weather_ssd1306.py 
import config   
import xtools
import urequests   
import ujson
from machine import I2C, 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():
    # 設定氣象爬蟲參數
    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)
    while True:  # 每分鐘抓一次更新 OLED 顯示器
        data=get_weather(country, city, weather_api_key)
        data['time']=xtools.tw_now()  # 新增或更新欄位
        data['city']=city # 新增或更新欄位
        show_weather(oled, data)   # 更新 OLED 顯示
        time.sleep(60)

if __name__ == '__main__':
  main()

注意, 此結構須按開發板的 Reset 鈕硬啟動才會執行, Thonny 的 "執行/重新啟動後端程式" 只是軟啟動 (soft restart) 不會執行 boot.py, 所以也不會去執行 main.py. 這些程式壓縮檔可從 GitHub 下載 :


注意, 上傳到 ESP32 開發板之前須先編輯 config.py, 填入 WiFi 基地台的 SSID 與密碼, 以及向 OpenWeatherMap 取得的 API key. 

2024年10月10日 星期四

MicroPython 學習筆記 : ESP8266 載入 xtools 出現 MemoryError 問題

今天台灣國慶日放假一天, 我從昨晚開始到今天早上都在整理零件庫, 盤點這些年來不斷購買的零組件, 發現買了很多 ESP8266 開發板 (主要是 Wemos D1 mini 與 Witty Cloud), 這實在是失算, 因為 ESP8266 的 RAM 只有 160KB, 扣掉 MicroPython 系統與 TCP/IP 協議堆疊, 大約只剩下 50KB 可以給應用程式使用, 難怪載入 xtools 函式庫時會出現 MemoryError 錯誤, 因為 xtools 站了 13KB 左右, 下面是 D1 mini 上開機後載入 xtools 就報錯 : 

MicroPython v1.21.0 on 2023-10-05; ESP module with ESP8266
Type "help()" for more information.

>>> import xtools   
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
MemoryError: memory allocation failed, allocating 468 bytes

如果是在 ESP32 開發板就不會有這種情形, 因為 ESP32 擁有 520KB 的 RAM, 扣掉系統與堆疊至少還有 320KB 給應用程式, 是 ESP8266 的 6 倍以上, 有較充裕的記憶體空間用來執行較複雜的運算. 

所以在 ESP8266 開發板上用 xtools 寫應用程式不要用 import xtools 匯入整個模組, 因為那些用不到的函式會佔用 RAM 空間; 而是要用 from xtools import 語法只匯入接下來會用到的函式, 用完就以 del 指令刪除, 而且不使用 config.py 儲存帳密金鑰, 而是直接傳入參數. 

MicroPython v1.21.0 on 2023-10-05; ESP module with ESP8266
Type "help()" for more information.

>>> from xtools import connect_wifi  
>>> connect_wifi('ASUS-RT-AX3000', '123456')    
network config: ('192.168.50.89', '255.255.255.0', '192.168.50.1', '192.168.50.1')
'192.168.50.89'
>>> del connect_wifi      # 用完就刪除
>>> from xtools import tw_now     
>>> tw_now()  
Querying NTP server and set RTC time ... OK.
'2024-10-10 19:12:37'
>>> del tw_now      

但即使這麼做, 對於消耗記憶體較多的 line_msg() 就沒轍了, 會發生記憶體溢位導致系統重開機 :

>>> from xtools import line_msg    
>>> line_msg('<YOUR LINE TOKEN>', 'test')     

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

load 0x40100000, len 31168, room 16 
tail 0
chksum 0x9f
load 0x3ffe8000, len 1060, room 8 
tail 12
chksum 0xd2
ho 0 tail 12 room 4
load 0x3ffe8430, len 1124, room 12 
tail 8
chksum 0x95
csum 0x95
����o�{��o|� d ldl`c� �|;�l�'� �'�

MicroPython v1.21.0 on 2023-10-05; ESP module with ESP8266
Type "help()" for more information.

>>> 

這就是為什麼應該買 ESP32 而非 ESP8266 的原因. 

市圖還書 3 本 : 特洛伊木馬病毒程式設計等

本周市圖還了下面三本書 : 
前兩本被預約, 第三本到期續借. 

2024年10月9日 星期三

露天購買電動割草機

前陣子花了三個周末時間整理墓園, 覺得用柴刀砍草太費力, 萬一閃到腰要痛好幾天, 所以今天上露天找到下面這款雙鋰電的電動割草機, 隨附配件包含鋰電池*2, 合金刀片*2, , 充電器*1, 塑料刀頭*5, 合金刀頭*2 :





由於尺寸較大無法超取, 必須宅配, 貨到付款免運 1316 元, 應該五天到貨. 

露天購買 SSD1306 OLED 顯示器模組 x 5

這兩天在測試 SSD1306 時只找到兩片模組, 所以上露天買了 5 片藍色的 : 

原裝 串列白色好品質 0.96 IIC 128X64 英寸 SSD1306 OLED 顯示模塊 I2C OLED 12 $52*5





全家取貨付款免運 260 元. 

查詢之前的購買紀錄於 2022 年曾向 Aliexpress 買了 10 片白色的, 呵呵, 晚上有空要來翻找零件庫了, 呵呵. 

MicroPython 學習筆記 : 關於 SSD1306 OLED 驅動程式

前年 (2022) 年底曾經用 Wemos D1 Mini (ESP8266) 做過 SSD1306 顯示器實驗, 參考 :


那時因手上沒有多餘的 ESP32 開發板, 就沒有在 ESP32 上測試, 以為所用的驅動程式 ssd1306.py 在 ESP32 板上應該沒問題. 我依據之前的筆記去 MicroPython 官網下載驅動程式, 發現它居然已經被移除了 : 





我在趙英傑老師的 "超圖解 Python 物聯網實作入門" 這本書的第 12 章讀到 "MicroPython 內建控制 OLED 螢幕的 ssd1306 程式庫", 且書中範例都是直接 import ssd1306, 沒有談到如何上傳 ssd1306.py, 所以我認為可能有一段時間 MicroPython 韌體有把 ssd1306 納入內建函式庫, 但最新的 v1.23 版已經拿掉了 : 

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

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

所以我只好從以前放在 GitHub 的測試資料中下載當時用在 ESP8266 的 ssd1306.py 上傳到 LOLIN D32 的 ESP32 開發板 (韌體是 v1.23 版) 重做實驗, 結果出現 "I2C operation not supported"  錯誤 : 

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

>>> from machine import I2C, Pin  
import ssd1306

# LOLIN D32 的硬體 I2C 為 GPIO22 (SCL) 與 21 (SDA)
i2c=I2C(0, scl=Pin(22), sda=Pin(21))     
oled=ssd1306.SSD1306_I2C(128, 64, i2c)    # 0.96 吋解析度 128*64 
oled.fill(1)      # 將 1 填滿顯示緩衝器 (點亮畫素)
oled.show()    
Traceback (most recent call last):
  File "<stdin>", line 6, in <module>
  File "ssd1306.py", line 109, in __init__
  File "ssd1306.py", line 37, in __init__
  File "ssd1306.py", line 64, in init_display
  File "ssd1306.py", line 89, in show
  File "ssd1306.py", line 119, in write_data
OSError: I2C operation not supported    

但回到 ESP8266 開發板 (MicroPython v1.19.1) 測試又正常, 我原先以為是韌體版本的關係, 於是把一顆 LOLIN D32 燒回 v1.19.1 版重試還是出現相同錯誤, 可見與韌體版本無關, 兩年前若有在 ESP32 開發板上做實驗結果應該一樣. 這樣看來是驅動程式 ssd1306.py 的問題了, 它可能一開始就是為 ESP8266 寫的. 

於是我將上面錯誤訊息與兩年前用的 ssd1306.py 原始碼貼給 ChatGPT 分析錯誤原因, 詢問如何改成 ESP32 開發板可用之驅動程式, 經過來回修正得到下面的 ssd1306_esp32.py : 

# ssd1306_esp32.py
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import time
import framebuf


# register definitions
SET_CONTRAST        = const(0x81)
SET_ENTIRE_ON       = const(0xa4)
SET_NORM_INV        = const(0xa6)
SET_DISP            = const(0xae)
SET_MEM_ADDR        = const(0x20)
SET_COL_ADDR        = const(0x21)
SET_PAGE_ADDR       = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP       = const(0xa0)
SET_MUX_RATIO       = const(0xa8)
SET_COM_OUT_DIR     = const(0xc0)
SET_DISP_OFFSET     = const(0xd3)
SET_COM_PIN_CFG     = const(0xda)
SET_DISP_CLK_DIV    = const(0xd5)
SET_PRECHARGE       = const(0xd9)
SET_VCOM_DESEL      = const(0xdb)
SET_CHARGE_PUMP     = const(0x8d)


class SSD1306:
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        self.framebuf = framebuf.FrameBuffer(self.buffer, self.width, self.height, framebuf.MVLSB)
        self.poweron()
        self.init_display()

    def poweron(self):
        # 如果不需要其他行為,可以保持為 pass,或者你可以加入實際的硬體啟動程式碼
        pass

    def init_display(self):
        for cmd in (
            SET_DISP | 0x00, # off
            # address setting
            SET_MEM_ADDR, 0x00, # horizontal
            # resolution and layout
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
            SET_MUX_RATIO, self.height - 1,
            SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
            SET_DISP_OFFSET, 0x00,
            SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV, 0x80,
            SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1,
            SET_VCOM_DESEL, 0x30, # 0.83*Vcc
            # display
            SET_CONTRAST, 0xff, # maximum
            SET_ENTIRE_ON, # output follows RAM contents
            SET_NORM_INV, # not inverted
            # charge pump
            SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01): # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP | 0x00)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width == 64:
            # displays with width of 64 pixels are shifted by 32
            x0 += 32
            x1 += 32
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)

    def fill(self, col):
        self.framebuf.fill(col)

    def pixel(self, x, y, col):
        self.framebuf.pixel(x, y, col)

    def scroll(self, dx, dy):
        self.framebuf.scroll(dx, dy)

    def text(self, string, x, y, col=1):
        self.framebuf.text(string, x, y, col)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80 # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)
        
    def write_data(self, buf):
        # 使用 writeto 將資料一次性寫入
        self.i2c.writeto(self.addr, b'\x40' + buf)        

class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(0)
        self.cs(0)
        self.spi.write(bytearray([cmd]))
        self.cs(1)

    def write_data(self, buf):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(1)
        self.cs(0)
        self.spi.write(buf)
        self.cs(1)

    def poweron(self):
        self.res(1)
        time.sleep_ms(1)
        self.res(0)
        time.sleep_ms(10)
        self.res(1)

藍色粗體部分就是增加或改寫的程式碼, 其一是在 SSD1306 類別中新增一個 dummy 的 power_on() 方法, 其二是在 SSD1306_I2C 類別中的 write_data() 方法改寫為只用 writeto() 一次寫入. 此驅動程式經測試在 ESP8266 與 ESP32 上均可順利執行. 我將此驅動程式放在 GitHub : 


既然此驅動程式用在 ESP8266/ESP32 都可以, 其實可以去掉後面的 "_esp32". 

完成驅動程式改寫後卻發現 Random Nerd 的網站也有提供 ssd1306.py :


我將它的 Adafruit 版本驅動程式分別在 ESP8266 與 ESP32 上測試均可正常執行, 複製如下 :

# ssd1306.py 
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces created by Adafruit

import time
import framebuf

# register definitions
SET_CONTRAST        = const(0x81)
SET_ENTIRE_ON       = const(0xa4)
SET_NORM_INV        = const(0xa6)
SET_DISP            = const(0xae)
SET_MEM_ADDR        = const(0x20)
SET_COL_ADDR        = const(0x21)
SET_PAGE_ADDR       = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP       = const(0xa0)
SET_MUX_RATIO       = const(0xa8)
SET_COM_OUT_DIR     = const(0xc0)
SET_DISP_OFFSET     = const(0xd3)
SET_COM_PIN_CFG     = const(0xda)
SET_DISP_CLK_DIV    = const(0xd5)
SET_PRECHARGE       = const(0xd9)
SET_VCOM_DESEL      = const(0xdb)
SET_CHARGE_PUMP     = const(0x8d)


class SSD1306:
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        # Note the subclass must initialize self.framebuf to a framebuffer.
        # This is necessary because the underlying data buffer is different
        # between I2C and SPI implementations (I2C needs an extra byte).
        self.poweron()
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP | 0x00, # off
            # address setting
            SET_MEM_ADDR, 0x00, # horizontal
            # resolution and layout
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
            SET_MUX_RATIO, self.height - 1,
            SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
            SET_DISP_OFFSET, 0x00,
            SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV, 0x80,
            SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1,
            SET_VCOM_DESEL, 0x30, # 0.83*Vcc
            # display
            SET_CONTRAST, 0xff, # maximum
            SET_ENTIRE_ON, # output follows RAM contents
            SET_NORM_INV, # not inverted
            # charge pump
            SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01): # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP | 0x00)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width == 64:
            # displays with width of 64 pixels are shifted by 32
            x0 += 32
            x1 += 32
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_framebuf()

    def fill(self, col):
        self.framebuf.fill(col)

    def pixel(self, x, y, col):
        self.framebuf.pixel(x, y, col)

    def scroll(self, dx, dy):
        self.framebuf.scroll(dx, dy)

    def text(self, string, x, y, col=1):
        self.framebuf.text(string, x, y, col)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        # Add an extra byte to the data buffer to hold an I2C data/command byte
        # to use hardware-compatible I2C transactions.  A memoryview of the
        # buffer is used to mask this byte from the framebuffer operations
        # (without a major memory hit as memoryview doesn't copy to a separate
        # buffer).
        self.buffer = bytearray(((height // 8) * width) + 1)
        self.buffer[0] = 0x40  # Set first byte of data buffer to Co=0, D/C=1
        self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80 # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_framebuf(self):
        # Blast out the frame buffer using a single I2C transaction to support
        # hardware I2C interfaces.
        self.i2c.writeto(self.addr, self.buffer)

    def poweron(self):
        pass


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        self.buffer = bytearray((height // 8) * width)
        self.framebuf = framebuf.FrameBuffer1(self.buffer, width, height)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.high()
        self.dc.low()
        self.cs.low()
        self.spi.write(bytearray([cmd]))
        self.cs.high()

    def write_framebuf(self):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.high()
        self.dc.high()
        self.cs.low()
        self.spi.write(self.buffer)
        self.cs.high()

    def poweron(self):
        self.res.high()
        time.sleep_ms(1)
        self.res.low()
        time.sleep_ms(10)
        self.res.high()

我將這兩個程式碼貼給 ChatGPT 比較後得到下面評語 :
  • Adafruit 版本 : 對 I2C 實現做了更專門的優化, 使用硬體兼容的緩衝區和 FrameBuffer1, 可以更高效地管理顯示更新.
  • 建議版本 : 是比較通用的 MicroPython 驅動實現, 使用標準 framebuf, 適合不依賴特定硬體兼容性的場景. 
兩者在功能上都是完整的 SSD1306 驅動, 只是在硬體兼容和效率優化上略有不同. 

總之, Adafruit 的版本優點是效能優化; 而 ChatGPT 建議的版本則是硬體相容性較好, 可擇一使用取代原本只能用在 ESP8266 那個舊版本.