W tym odcinku kursu nauczymy się obsługiwać wyświetlacz OLED o przekątnej 2,42 cala i rozdzielczości 128×64 pikseli z wbudowanym sterownikiem SSD1309 firmy Solomon Systech. Takie wyświetlacze produkuje chińska firma Wisevision Optronics. Dostępne są w kolorach: białym, żółtym, zielonym i niebieskim. Istnieją także tańsze wyświetlacze o mniejszych rozmiarach i tej samej rozdzielczości. Na rynku można znaleźć podobne rozwiązania z nieco innymi sterownikami, takimi jak SSD1306 lub SH1106. Wszystkie te sterowniki są bardzo podobne i kod, który napiszemy dla SSD1309, powinien działać bez żadnych modyfikacji także z innymi sterownikami.
SSD1309 jest układem scalonym montowanym w technologii chip-on-glass co oznacza, że jest zintegrowany z szybą wyświetlacza, zza której wyprowadzony jest 24-żyłowy kabel. Należy go włożyć do gniazda FFC/FPC o rastrze 0,5 mm. Do uruchomienia wyświetlacza potrzebujemy dwóch napięć zasilających (3,3 V i 12 V) oraz kilku drobnych elementów pasywnych.
Istnieje kilka niedrogich płytek, które wszystkie niezbędne połączenia mają już gotowe do wykorzystania. Są to chińskie produkty typu „no name” - bez marki wytwórcy ani innych oznaczeń - dostępne w wielu sklepach. Mają one kilka charakterystycznych cech, dzięki którym można je rozróżnić. Są to: kolor soldermaski, liczba pinów konektora i jego położenie oraz typ interfejsu komunikacyjnego.
Na początek zdecydowanie odradzam zakup płytek z czarną soldermaską i 4-pinowym konektorem z interfejsem I²C. Płytki te mają jakiś problem z przetwornicą podwyższającą napięcie - w rezultacie konwerter bardzo nieprzyjemnie piszczy, co jest mocno irytujące, zwłaszcza w nocy.
Polecam natomiast płytki z niebieską soldermaską i 7-pinowym konektorem umieszczonym na górze lub po lewej stronie PCB. Przykład takiego modułu pokazuje fotografia 1. Mogą one pracować z interfejsem SPI lub I²C. Fabrycznie płytki są skonfigurowane tak, by pracowały z interfejsem SPI, ale przeróbka na I²C jest bardzo łatwa. Wystarczy do tego lutownica, cyna i kawałek drutu.
Na dolnej stronie płytki znajdują się wszystkie drobne elementy elektroniczne. Aby przerobić płytkę z interfejsu SPI na I²C, musimy najpierw wyjąć kabel wyświetlacza. W tym celu delikatnie przesuwamy szarą klamrę gniazda w kierunku środka płytki, po czym przewód możemy delikatnie wysunąć ze złącza. W pierwszej kolejności musimy odlutować rezystor R8 (0 Ω) i wlutować go w miejsce R9. Pola lutownicze są na tyle duże, że zamiast rezystora możemy umieścić tam kropelkę cyny. Podobnie robimy w miejscu R11.
Kolejna możliwa modyfikacja jest niepotrzebna, jeżeli chcemy zasilać wyświetlacz ze źródła o napięciu 5 V. Na płytce jest umieszczony stabilizator LDO konwertujący 5 V na 3,3 V. Jeżeli chcemy zasilać płytkę bezpośrednio ze źródła 3,3 V, warto obejść stabilizator, lutując mały drucik zwierający wejście i wyjście regulatora (najlepiej uprzednio wylutowując stabilizator - przyp. red.). Wszystkie modyfikacje zaznaczono czerwonymi strzałkami na fotografii 2.
Możemy podłączyć płytkę z wyświetlaczem do ESP32-S3 w sposób pokazany na rysunku 1. Domyślny pin SDA to GPIO8, a w przypadku SCL - GPIO9, ale w konstruktorze klasy I²C można wskazać dowolne inne piny, jeżeli z jakiegoś powodu linie 8 i 9 chcemy wykorzystać w innym celu. Sterownik SSD1309 może używać dwóch adresów na magistrali I²C. Jeżeli pin DC połączymy z masą, wówczas sterownik będzie występował pod adresem 0x3C, a jeżeli do zasilania - adres zmieni się na 0x3D. Pin RES służy do resetowania kontrolera SSD1309. Na płytce znajduje się układ RC, który zainicjalizuje sterownik zaraz po włączeniu zasilania, więc wspomnianą linię możemy zostawić niepodłączoną.
Po podłączeniu wyświetlacza do ESP32 warto (w celach diagnostycznych) przeskanować wszystkie adresy na magistrali I²C przy pomocy skryptu i2c_scan.py, który opracowaliśmy w 3. odcinku kursu. Jeżeli na konsoli pojawi się komunikat:
Znalezione urządzenia: 3C
…to możemy przejść do kolejnego rozdziału.
Sterownik SSD1309
W niniejszym artykule omówimy wyłącznie komunikację z kontrolerem SSD1309 za pośrednictwem interfejsu I²C. Wymiana danych jest jednokierunkowa, za wyjątkiem bitów potwierdzeń ACK, którymi SSD1309 sygnalizuje odebranie danych z szyny I²C. Nie ma możliwości, by odczytać obraz z wyświetlacza ani jakiekolwiek inne dane.
Pierwszym bajtem wysyłanym przez I²C jest adres urządzenia slave, czyli 0x3C lub 0x3D - w zależności od sposobu podłączenia pinu DC. Kolejny bajt to tzw. control byte. Może on przyjąć jedną z dwóch wartości:
- 0x80 - następny bajt będzie poleceniem do wykonania. Jeżeli chcemy przesłać więcej komend w jednej transmisji, to każdy bajt polecenia musimy poprzedzić bajtem 0x80.
- 0x40 - wszystkie kolejne bajty, aż do zakończenia transmisji, są danymi obrazu do wyświetlenia.
Sterownik SSD1309 obsługuje całkiem sporo różnorodnych poleceń. Omówimy tylko kilka z nich, a Czytelników zainteresowanych pozostałymi instrukcjami odsyłam do dokumentacji dostępnej pod adresem [2].
Wyświetlacz ma rozdzielczość 128 pikseli w poziomie i 64 w pionie. Piksele są monochromatyczne, czyli albo się świecą, albo pozostają wygaszone.
Ekran podzielono na osiem pasów o wysokości 8 pikseli. W dokumentacji SSD1309 pasy te nazywane są stronami (page). Wysokość 8 pikseli nie jest przypadkowa - dokładnie tyle bitów zawiera jeden bajt, czyli najmniejsza jednostka danych, jaką możemy przesłać przez interfejs I²C. A zatem przesyłając jeden bajt ustawiamy stan kolumny, złożonej z 8 pikseli. Kursor inkrementuje się automatycznie i przesuwa w prawo o jedną pozycję po odebraniu każdego bajtu danych.
Kursor możemy ustawić na dowolną stronę i dowolną współrzędną x. Możemy także zmieniać zakres automatycznej inkrementacji kursora. W praktycznych rozwiązaniach kursor umieszczamy na współrzędnej (0,0), a zakres jego przemieszczania obejmuje cały wyświetlacz. W taki sposób możemy odświeżyć cały obszar wyświetlacza w pojedynczej transakcji I²C. Skoro mamy 128 kolumn w 8 stronach, to przesłać musimy 1024 bajty.
Sterownik SSD1309 musimy zainicjalizować po każdym włączeniu zasilania lub zresetowaniu, przesyłając do niego szereg różnych poleceń. Często producenci wyświetlaczy podają w dokumentacji sposób, w jaki sterownik musi być skonfigurowany, aby działał poprawnie. Ustawiać można różne tryby multipleksacji, działanie przetwornicy podwyższającej napięcie, częstotliwość odświeżania i wiele innych parametrów. Te rzeczy to „wiedza tajemna”. Konieczność ustawiania takich parametrów wynika z faktu, iż wielu producentów stosuje sterownik SSD1309 z wyświetlaczami o różnych rozmiarach i parametrach, a kontroler nie ma wbudowanej pamięci Flash, przez co nie może być skonfigurowany przez producenta wyświetlacza. Przykład sekwencji inicjalizacyjnej znajdziemy w dokumentacji firmy Wisevision Optronics pod adresem [3].
Dla nas najbardziej istotne będą instrukcje odpowiedzialne za regulację kontrastu i możliwość obrócenia obrazu o 180°. Omówimy to dokładniej analizując kod programu.
Biblioteka FrameBuffer
Do generowania grafiki wykorzystamy moduł FrameBuffer, który jest standardowo wbudowany w implementację MicroPythona na ESP32. Dokładny opis wszystkich możliwości tego modułu znajduje się pod adresem [4].
Biblioteka dostarcza różnych funkcji pozwalających narysować pojedyncze piksele, linie, prostokąty, okręgi i inne figury geometryczne. Możemy tworzyć napisy (choć w standardzie dostępny jest tylko jeden font) albo skorzystać z przygotowanych wcześniej plików graficznych.
Na początku programu musimy utworzyć bufor, w którym biblioteka będzie rysować wszystkie kształty. Po zakończeniu rysowania klatki obrazu przesyłamy całą zawartość bufora do wyświetlacza. W tym celu musimy sami opracować jakąś funkcję, która odczyta gotowy bufor w pamięci RAM, prześle go do wyświetlacza przez interfejs komunikacyjny i, jeżeli to potrzebne, wyśle również określone polecenia.
Omówimy teraz najbardziej przydatne funkcje z klasy FrameBuffer. Zacznijmy od jej konstruktora, który przyjmuje cztery argumenty - są to:
- obiekt typu bytearray, w którym znajdzie się bufor roboczy obrazu. Ważne, by jego rozmiar był dopasowany do rozdzielczości wyświetlacza i sposobu kodowania pikseli. W naszym wyświetlaczu OLED na jeden bajt przypada 8 pikseli. W przypadku wyświetlacza kolorowego w formacie RGB565 każdy piksel korzysta z 2 bajtów,
- szerokość w pikselach,
- wysokość w pikselach,
- format obrazu. W przypadku wyświetlaczy monochromatycznych istnieje kilka możliwości, ale dla SSD1309 właściwą opcją jest MONO_VLSB. W przypadku wyświetlaczy kolorowych najczęściej stosuje się format RGB565.
Instancję klasy FrameBuffer możemy utworzyć w następujący sposób:
HEIGHT = const(64)
array = bytearray(WIDTH * HEIGHT // 8)
buffer = FrameBuffer(array, WIDTH, HEIGHT, MONO_VLSB)
Uzyskujemy w ten sposób dostęp do różnych metod z klasy FrameBuffer. Pierwszą z nich jest fill, która wypełnia cały wyświetlacz jednolitym kolorem. W przypadku wyświetlaczy monochromatycznych mamy tylko dwa kolory - 0 oznacza piksel wygaszony, a 1 to piksel zaświecony.
buffer.fill(1) # Wszystkie piksele świecą
Oczywiście możemy kontrolować także każdy piksel osobno. W tym celu korzystamy z metody piksel, do której przekazujemy współrzędne x, y oraz żądany kolor.
Do rysowania linii mamy aż trzy metody. Pierwsza z nich to line, rysująca linię prostą od punktu (x1, y1) do punktu (x2, y2) przy użyciu algorytmu Bresenhama, który został dokładnie omówiony pod adresem [5]. Jest on uniwersalny, ale w przypadku linii pionowych okazuje się nieoptymalny - dużo szybciej linię narysujemy stosując zwyczajną pętlę, która koloruje piksele po kolei. W tym celu autorzy MicroPythona dodali metody hline (do rysowania linii poziomych) oraz vline (do pionowych). W obu wspomnianych metodach podajemy współrzędne początku linii, jej długość oraz kolor.
buffer.hline(x, y, w, color)
buffer.vline(x, y, h, color)
W bibliotece mamy dostępny tylko jeden font o wysokości 8 pikseli i stałej szerokości znaków, równej także 8 pikseli. Font jest mało estetyczny, a litery - niesymetryczne. Nie ma możliwości wyrównania tekstu do prawej ani do środka. Dlatego w dalszej części artykułu poznamy sposób pozwalający na rozwiązanie tego problemu i zastosowanie własnych fontów.Aby wyświetlić napis wbudowanym fontem we FrameBuffer, należy skorzystać z metody text.
Wyświetlenie bitmapy jest dość trudne. Trzeba ją najpierw przekonwertować na obiekt typu bytearray, w którym wszystkie bity są ustawione w takim samym formacie jak bytearray bufora obrazu. Następnie tworzymy drugi obiekt klasy FrameBuffer, a potem z bufora obrazu wywołujemy metodę blit. Pierwszym argumentem jest utworzony wcześniej FrameBuffer bitmapy, a następne parametry to współrzędne x i y, w których ma znaleźć się lewy górny narożnik bitmapy. W jaki sposób przekształcić plik graficzny na bytearray - o tym będzie mowa w dalszej części artykułu.
bytearray(b'...dane bitmapy...'),
szerokość, wysokość, framebuf.MONO_VLSB)
buffer.blit(moja_bitmapa, x, y)
Klasa SSD1309
Moduł FrameBuffer nie ma żadnego powiązania ze sprzętem - generuje on tylko grafikę w pamięci RAM, którą sami musimy przesłać do wyświetlacza. Możemy do tego tematu podejść na dwa sposoby.
Pierwszym jest utworzenie instancji klasy FrameBuffer, tak jak to zrobiliśmy w poprzednim rozdziale. „Obok” niej tworzymy osobną klasę odpowiadającą za komunikację z wyświetlaczem. Chcąc wyświetlić klatkę obrazu na wyświetlaczu, przekazujemy bytearray z FrameBuffera do funkcji transmitującej ów bufor przez I²C do wyświetlacza. Takie rozwiązanie jest oczywiście poprawne, ale mało wygodne, bo mamy dwie osobne klasy. Lepiej byłoby wszystko, co związane z wyświetlaczem, wrzucić do jednego worka.
Z pomocą przychodzi dziedziczenie. Możemy zrobić klasę SSD1309 w taki sposób, że wszystkie metody klasy FrameBuffer będą działać tak, jakby one były metodami klasy SSD1309. Jedyne, co w klasie SSD1309 musimy zrobić, to obsługa sprzętu. Oprócz tego dodamy kilka metod umożliwiających bardziej zaawansowanie wyświetlanie napisów różnymi fontami. Nasza biblioteka będzie miała możliwość obrócenia obrazu o 180°, a także zmiany adresu sterownika na magistrali I²C. Ciekawą funkcjonalnością będzie metoda wyświetlająca bufor obrazu jako ASCII-art w konsoli, co pozwoli przetestować kod wszystkim tym Czytelnikom, którzy nie posiadają akurat pod ręką wyświetlacza ze sterownikiem SSD1309. Weźmy pod lupę kod modułu SSD1309 pokazany na listingu 1.
import framebuf # 1
WIDTH = const(128) # 2
HEIGHT = const(64)
class SSD1309(framebuf.FrameBuffer): # 3
@micropython.native
def __init__(self, i2c, rotate=False, address=0x3C): # 4
self.i2c = i2c
self.address = address
self.array = bytearray(WIDTH * HEIGHT // 8) # 5
super().__init__(self.array, WIDTH, HEIGHT, framebuf.MONO_VLSB) # 6
config = ( # 7
0xAE, # Display disable
0x20, 0x00, # Set memory addressing mode
# to horizontal addressing mode
0x40, # Set display start line to 0
0xA0 if rotate else 0xA1, # Set segment remap
0xA8, 0x3F, # Set multiplex ratio to 63
0xC0 if rotate else 0xC8, # Set COM scan direction
0xD3, 0x00, # Set display offset to 0
0xDA, 0x12, # Set COM pins hardware config
# to enable COM left/right remap
0xD5, 0x80, # Set clock and oscillator frequency
0xD9, 0xF1, # Set pre-charge period
0xDB, 0x3C, # Set VCOMH to max
0x81, 0xFF, # Set contrast to 255 (max)
0xA4, # Use image in GDDRAM memory
0xA6, # Display not inverted
0xAF, # Display enable
)
for cmd in config: # 8
self.write_cmd(cmd)
@micropython.viper
def write_cmd(self, cmd: int): # 9
self.i2c.writeto(self.address, bytes([0x80, cmd]))
@micropython.viper
def display_on(self): # 10
self.write_cmd(0xAF)
@micropython.viper
def display_off(self): # 11
self.write_cmd(0xAE)
@micropython.viper
def contrast(self, value): # 12
self.write_cmd(0x81)
self.write_cmd(value)
@micropython.viper
def refresh(self): # 13
for cmd in (0x21, 0x00, 0x7F, 0x22, 0x00, 0x07): # 14
self.write_cmd(cmd)
self.i2c.writevto(self.address, (b"\x40", self.array)) # 15
@micropython.viper
def simulate(self): # 16
for y in range(HEIGHT):
print(f"{y}\t", end="")
for x in range(WIDTH):
bit = 1 << (y % 8)
byte = int(self.array[(y // 8) * WIDTH + x])
pixel = "#" if byte & bit else "."
print(pixel, end="")
print("")
@micropython.native
def print_char(self, font, char, x, y, color=1): # 17
try:
bitmap = font[ord(char)]
except:
bitmap = font[0]
print(f"Char {char} doesn't exist in font")
width = bitmap[0]
height = bitmap[1]
space = bitmap[2]
if color:
buffer = framebuf.FrameBuffer(bitmap[3:], width, height, 0)
else:
negative_bitmap = bitmap[:]
for i in range(3, len(bitmap)):
negative_bitmap[i] = ~negative_bitmap[i]
buffer = framebuf.FrameBuffer(negative_bitmap[3:], width, height, 0)
self.rect(x-space, y, space, height, 1, True)
self.rect(x+width, y, space, height, 1, True)
self.blit(buffer, x, y)
return width + space
@micropython.native
def print_text(self, font, text, x, y, align="L", color=1): # 18
width = self.get_text_width(font, text)
if align == "R":
x = WIDTH - width
elif align == "C":
x = WIDTH//2 - width//2
elif align == "r":
x = x - width + 1
elif align == "c":
x = x - width//2
for char in text:
x += self.print_char(font, char, x, y, color)
@micropython.native
def get_text_width(self, font, text): # 19
total = 0
last_char_space = 0
for char in text:
bitmap = font.get(ord(char), font[0])
total += bitmap[0]
total += bitmap[2]
last_char_space = bitmap[2]
return total - last_char_space
if __name__ == "__main__": # 20
i2c = I2C(0)
print(i2c)
display = SSD1309(i2c)
display.rect(0, 0, 128, 64, 1)
display.text("abcdefghijklm", 1, 2, 1)
display.text("nopqrstuvwxyz", 1, 10, 1)
display.refresh()
display.simulate()
Listing 1. Kod pliku ssd1309.py
Zaczynamy od zaimportowania modułu framebuf w linii 1, po czym tworzymy dwie stałe, odpowiadające za szerokość i wysokość wyświetlacza. W naszym przypadku jest to 128 pikseli (szerokość) i 64 (wysokość). Aby nasza biblioteka była bardziej uniwersalna i obsługiwała mniejsze wyświetlacze, można pomyśleć o ustawianiu tych wartości poprzez konstruktor klasy. Przypomnijmy, że stałe const() to twór dostępny tylko w MicroPythonie, który pozwala zaoszczędzić pamięć RAM. Nie znajdziemy nic podobnego w klasycznym Pythonie.
W linii 3 rozpoczynamy klasę o nazwie SSD1309. W nawiasach po jej nazwie podajemy nazwę klasy, z której mają zostać odziedziczone wszystkie metody - w naszym przypadku jest to FrameBuffer z modułu framebuf.
Klasę rozpoczynamy metodą specjalną __init__, czyli konstruktorem. Przekazujemy do niego kilka argumentów, które zostaną następnie zapisane w postaci zmiennych wewnętrznych klasy, między innymi: i2c oraz address, czyli instancja interfejsu I²C oraz adresem sterownika SSD1309. Dzięki takiemu rozwiązaniu możemy wykorzystać tę samą klasę, aby sterować więcej niż jednym wyświetlaczem - obojętnie, czy ekrany będą podłączone do wielu różnych interfejsów, czy do jednego, ale na różnych adresach.
Argument rotate może przyjmować wartości True lub False, a jego celem jest odwrócenie (lub nie) obrazu na wyświetlaczu o 180°. Wykorzystujemy tutaj sprzętowe możliwości sterownika. Stan tego argumentu modyfikuje niektóre instrukcje w sekwencji inicjalizacyjnej, która jest przesyłana do sterownika kilka linijek niżej.
W linii 5 tworzymy zmienną array. Przechowujemy w niej bufor obrazu, będący obiektem typu bytearray. W nawiasach musimy podać rozmiar bufora - musi on pomieścić wszystkie piksele. Mnożymy więc szerokość i wysokość, a następnie dzielimy wynik przez 8, ponieważ w jednym bajcie mieści się 8 pikseli. Małe przypomnienie - w języku Python operator / zwraca wynik dzielenia jako liczbę zmiennoprzecinkową, nawet jeżeli po przecinku jest zero. Operator // zwraca natomiast zawsze liczbę całkowitą - a taki musi być przecież rozmiar bufora.
Klasa FrameBuffer też ma swój konstruktor, który musimy wywołać, aby ją odpowiednio skonfigurować. By uzyskać dostęp do niego musimy posłużyć się funkcją specjalną super (linia 6). W ten sposób możemy wywołać konstruktor __init__ klasy nadrzędnej i skonfigurować ją, a dokładniej - przekazać do niej bufor obrazu utworzony linijkę wcześniej, a także szerokość, wysokość i format obrazu.
Następnym krokiem jest stworzenie sekwencji inicjalizacyjnej. W linii 7 tworzymy zmienną config i zapisujemy do niej krotkę, w której znajdują się wszystkie polecenia do przesłania do wyświetlacza w celu jego inicjalizacji. Wykonujemy to w pętli for (linia 8), gdzie każdy element krotki przekazujemy do metody write_cmd, zdefiniowanej w linii 9. Linia ta jest bardzo prosta i ogranicza się do wywołania metody writeto, należącej do klasy i2c, a przekazanej przez konstruktor. Do metody przekazujemy adres sterownika na magistrali oraz polecenie, jakie ma zostać wysłane. Zgodne z notą katalogową sterownika, polecenie musimy poprzedzić bajtem kontrolnym o wartości 0x80. W tym celu ów bajt kontrolny - oraz bajt polecenia - obejmujemy nawiasami kwadratowymi, a następnie taki twór zapisujemy jako bytes. Moglibyśmy także wykorzystać bytearray. Przypomnijmy, że różnica między nimi jest następująca: bytes to format tylko do odczytu, a bytearray - do odczytu i zapisu poszczególnych składowych. Nasza metoda nic nie modyfikuje, zatem użycie bytes będzie optymalne.
Trzy kolejne metody demonstrują praktyczne zastosowanie write_cmd. W liniach 10 i 11 mamy metody, które włączają i wyłączają wyświetlacz. Całość sprowadza się do wysłania 1-bajtowego polecenia. W linii 12 mamy metodę ustawiającą kontrast wyświetlacza. Przyjmuje ona wartość w zakresie od 0 do 255. Aby ustawić kontrast, najpierw wysyłamy bajt 0x81, a zaraz po nim kolejne polecenie ustawiające wartość kontrastu. Każdy z tych dwóch bajtów poprzedzony jest bajtem kontrolnym 0x80, który automatycznie dodaje metoda write_cmd.
Dochodzimy do metody refresh w linii 13. Jej zadaniem jest przesłanie bufora array, zawierającego obraz, do sterownika SSD1309. Transmisja składa się z dwóch części. Najpierw musimy ustalić obszar obrazu, jaki chcemy zaktualizować. Wykonujemy to w sposób podobny do sekwencji inicjalizacyjnej, ale ponieważ musimy przesłać mniej poleceń, trochę to uprościmy. W linii 14 rozpoczynamy pętlę for iterującą po krotce, którą tworzymy w tej samej linii, co pętlę. Wewnątrz krotki znajduje się sześć bajtów - ich celem jest wskazanie prostokątnego obszaru na wyświetlaczu, który ma zostać zaktualizowany. Znaczenie kolejnych bajtów jest następujące:
- 0x21 - dwa kolejne bajty to zakres, w którym może się zmieniać współrzędna x,
- 0x00 - początek zakresu od współrzędnej x0=0,
- 0x7F - koniec zakresu na współrzędnej x1=127,
- 0x22 - dwa kolejne bajty to zakres, w którym może się zmieniać numer strony,
- 0x00 - początek zakresu od strony 0,
- 0x07 - koniec zakresu na stronie 7.
W ten sposób informujemy kontroler SSD1309, że chcemy zaktualizować cały obszar wyświetlacza o rozdzielczości 128×64, składający się z 8 stron o wysokości 8 pikseli każda.
Następnie, w linii 15, korzystamy z metody writevto z klasy i2c, która w jednej transakcji przesyła podane „wektory”. Poprzez słowo wektor autorzy MicroPythona rozumieją zmienne zawierające ciągi bajtów. W naszym programie pierwszym takim ciągiem jest pojedynczy bajt 0x40, który informuje sterownik, że wszystkie kolejne bajty są grafiką do wyświetlenia. Drugim ciągiem jest oczywiście bufor array.
Metoda simulate (linia 16) ma za zadanie wyświetlić bufor obrazu na konsoli. Piksel widoczny oznaczany jest znakiem #, a niewidoczny będzie reprezentowany jako kropka. W ten sposób na konsoli powstaje coś à la ASCII art
Pozostało jeszcze do omówienia kilka metod, ale na razie je pominiemy. Służą one do generowania napisów z wykorzystaniem różnych fontów, co nie jest standardową funkcjonalnością MicroPythona. Wrócimy do nich w dalszej części artykułu.
Na początku każdej metody znajdują się dziwne napisy, takie jak @micropython.viper albo @micropython.native. Są to tzw. dekoratory poprawiające wydajność kodu. Funkcje oznaczone takimi dekoratorami muszą być napisane w dość specyficzny sposób, ale za to wykonują się szybciej i zajmują mniej pamięci.
@micropython.native powoduje, że kompilator Pythona tworzy kod zawierający instrukcje wykonywane bezpośrednio przez procesor, a nie interpreter. W takim przypadku nie można używać bloków with, generatorów, a ponadto są też pewne ograniczenia w obsłudze wyjątków.
@micropython.viper generuje kod jeszcze bardziej kompaktowy, ale ma to jeszcze wyższą cenę. Wszystkie argumenty funkcji muszą mieć zdefiniowany typ, a wartość zwracana przez funkcję również musi mieć typ znany jeszcze zanim funkcja zacznie się wykonywać. Możliwe typy to int (32-bitowa liczba całkowita ze znakiem), uint (liczba całkowita bez znaku), ptr8, ptr16, ptr32 (wskaźniki na liczby odpowiednio, 8, 16 oraz 32-bitowe), a także ptr (wskaźnik na dowolny obiekt). Poniżej można zobaczyć przykład funkcji, która pobiera argument arg typu int i zwraca wartość typu uint. Warto dodać, że funkcje oznaczone dekoratorem @micropython.viper nie mogą mieć argumentów z wartościami domyślnymi.
def funkcja(self, arg: int) -> uint:
Więcej szczegółów na temat native i viper znajdziesz na stronie pod adresem [6].
Testujemy!
Czas najwyższy wygenerować jakąś grafikę i pokazać ją na wyświetlaczu. Płytkę z wyświetlaczem podłącz tak, jak pokazuje to fotografia 2. Sygnał SCL należy doprowadzić do pinu GPIO 9 mikrokontrolera ESP32-S3, a SDA - do GPIO 8. Ewentualnie możesz użyć też innych pinów. Zapisz plik ssd1309.py w pamięci ESP32-S3, a następnie otwórz nowy skrypt i umieść w nim taki kod pokazany na listingu 2.
from machine import Pin, I2C # 1
import ssd1309 # 2
i2c = I2C(0) # 3
print(i2c) # 4
display = ssd1309.SSD1309(i2c) # 5
# display = ssd1309.SSD1309(i2c, rotate=True)
# display = ssd1309.SSD1309(i2c, address=0x3D)
display.rect(0, 0, 128, 64, 1) # 6
display.text('abcdefghijklm', 1, 2, 1) # 7
display.text('nopqrstuvwxyz', 1, 10, 1) # 8
display.refresh() # 9
display.simulate() # 10
Listing 2. Kod pliku demo_simple.py
Program jest bardzo prosty - ma on wygenerować litery alfabetu korzystając z wbudowanego fontu w MicroPytona, a także narysować prostokąt. Tak przygotowaną grafikę prześlemy do wyświetlacza oraz zasymulujemy na konsoli. Prześledźmy ten kod linia po linii.
Zaczynamy od zaimportowania klas Pin oraz I2C z modułu machine (linia 1), a także modułu ssd1309 (linia 2), który omawialiśmy wcześniej. W linii 3 tworzymy instancję klasy I2C, korzystającą z interfejsu o numerze 0 i domyślnych pinów GPIO. Jeżeli chcesz zastosować inne piny niż domyślne, podaj ich numery poprzez argumenty nazwane sda i scl. Linia 4 wyświetla na konsoli konfigurację I²C - między innymi są to numery pinów używanych jako SDA i SCL, a także częstotliwość zegara interfejsu.
W linii 5 tworzymy instancję klasy SSD1309 z modułu ssd1309 i zapisujemy ją do zmiennej display. Poprzez argument przekazujemy do niej klasę i2c, która ma być wykorzystywana do komunikacji. W kolejnych liniach widzimy zakomentowane przykłady pokazujące, jak możemy zainicjalizować klasę, by wyświetlała obraz obrócony o 180° oraz jak zmienić adres na magistrali I²C.
Linia 6 to wywołanie metody rect z klasy SSD1309 zapisanej w zmiennej display. Dwa pierwsze argumenty wskazują współrzędne lewego górnego narożnika prostokąta, czyli (0, 0). Dwa kolejne to jego wymiary, tzn. szerokość 128 pikseli i wysokość 64 piksele. Ostatni argument to kolor. W tym przypadku możliwe są tylko dwie opcje, czyli 1, co oznacza jasne piksele lub 0 - piksele wygaszone.
W liniach 7 i 8 generujemy napisy przy pomocy metody text. Pierwszym argumentem jest napis do wyświetlenia. Dwa kolejne to współrzędne x i y lewego górnego rogu napisu. Ostatni to kolor, podobnie jak w przypadku prostokąta.
Aby przesłać zawartość bufora obrazu do wyświetlacza, musimy wywołać metodę refresh (linia 9), natomiast by ów bufor wyświetlić na konsoli, wywołujemy metodę simulate (linia 10). Warto wspomnieć, że metody refresh i simulate pochodzą z klasy SSD1309, którą napisaliśmy sami, a metody rect i text są dziedziczone do SSD1309 z klasy FrameBuffer.
Efekt działania kodu z listingu 2 pokazano na rysunku 3 i fotografii 3.
Grafiki
Zajmijmy się teraz wyświetlaniem obrazów. Obrazki zapisujemy w osobnych plikach, a każdy z nich powinien być umieszczony w folderze image tak, jak pokazuje to rysunek 4. Oczywiście nie jest to jakaś żelazna zasada i można je umieszczać w dowolnych miejscach, ale proponowane podejście ułatwia pisanie kodu oraz automatyczne konwertowanie obrazów, o czym będzie w dalszej części artykułu.
Jak już pewnie zauważyłeś, każdy plik obrazu zapisany jest jako kod w Pythonie. Otwórzmy jeden z tych plików, na przykład ep_logo_128x40.py, którego kod pokazano na listingu 3. Składa się on tylko z dwóch linijek. Pierwszą jest import modułu framebuf. Druga to utworzenie zmiennej o nazwie takiej samej, jak nazwa pliku - tutaj wpisujemy instancję klasy FrameBuffer. Inicjalizujemy ją buforem pamięci, w którym znajduje się właściwa bitmapa, a w kolejnych argumentach podajemy szerokość, wysokość oraz format obrazu. To wszystko!
import framebuf
ep_logo_128x40 = framebuf.FrameBuffer(
bytearray(b'\x00\xf8\...cięcie...\x00'),
128, 40, framebuf.MONO_VLSB)
Listing 3. Kod pliku ep_logo_128x40.py
Zobaczmy teraz, w jaki sposób możemy zastosować tak przygotowane pliki obrazów. Przykład takich operacji pokazano na listingu 4. Rozpoczynamy od zaimportowania klas I2C oraz Pin z modułu machine oraz modułu wyświetlacza ssd1309. Następnie importujemy obrazki (linia 1). Proponuję w tym przypadku użyć nieco innej składni niż zwykle, czyli from katalog.plik import *. Ów sposób wydaje mi się wygodniejszy, bo zaimportowane zmienne będą widoczne dokładnie tak, jakby były zmiennymi utworzonymi w pliku je importującym. Następnie tworzymy instancję klasy I2C oraz SSD1309 - dokładnie tak samo, jak w poprzednich przykładach.
from machine import Pin, I2C
import ssd1309
from image.back_32x32 import * # 1
from image.book_32x32 import *
from image.cancel_32x32 import *
from image.clock_32x32 import *
from image.down_32x32 import *
from image.ep_logo_128x40 import *
from image.hand_32x32 import *
from image.light_32x32 import *
from image.ok_32x32 import *
from image.settings_32x32 import *
from image.square_8x16 import *
from image.up_32x32 import *
from image.world_128x64 import *
i2c = I2C(0)
display = ssd1309.SSD1309(i2c)
display.blit(ok_32x32, 0, 0) # 2
display.blit(back_32x32, 0, 32)
display.blit(clock_32x32, 32, 0)
display.blit(settings_32x32, 32, 32)
display.blit(book_32x32, 64, 0)
display.blit(light_32x32, 64, 32)
display.blit(up_32x32, 96, 0)
display.blit(down_32x32, 96, 32)
# display.blit(world_128x64, 0, 0) # 3
# display.blit(world_128x64, 0, 0, 0) # 4
# display.blit(ep_logo_128x40, 0, 12)
display.refresh() # 5
Listing 4. Kod pliku demo_image.py
W linii 2 i kolejnych dodajemy obrazki do bufora wyświetlacza. Wykorzystujemy tutaj metodę blit, która pochodzi z klasy FrameBuffer i została odziedziczona do naszej klasy SSD1309. Przy pomocy trzech argumentów podajemy obiekt typu FrameBuffer, który przechowuje bitmapę do wyświetlenia oraz współrzędne x i y lewego górnego narożnika bitmapy.
W naszym przykładzie wyświetlamy osiem różnych ikonek o rozmiarach 32×32 piksele, co powinno dać efekt widoczny na fotografii 4. W liniach 3 i 4 mamy polecenia wyświetlające kolejną bitmapę, reprezentującą mapę świata. Linia 4 różni się od 3 tylko tym, że ma dodatkowy argument o wartości 0. Powoduje to, że kolor czarny obrazu traktowany jest jako przezroczystość. W ten sposób można tworzyć warstwy z różnymi nakładającymi się na siebie grafikami.
Pozostaje już tylko linia 5, w której wywołujemy metodę refresh, aby przesłać bufor obrazu do wyświetlacza. Efekty uzyskane przy pomocy tego kodu pokazują fotografie 5…7.
Jak wygenerować pliki z obrazkami? Niestety autorzy MicroPythona nie dostarczają żadnych narzędzi do tego celu i trzeba je zrobić samemu. Zaprezentuję tutaj moje rozwiązanie, które opracowałem na własne potrzeby jakiś czas temu.
W katalogu naszego projektu musimy utworzyć dwa podkatalogi. W pierwszym z nich, o nazwie image_source, umieszczać będziemy wszystkie potrzebne grafiki w formacie BMP, zapisane jako bitmapy 16-kolorowe. W tym celu możemy posłużyć się programem Paint, w którym format obrazu i paletę kolorów wybiera się podczas zapisywania pliku. W drugim katalogu, o nazwie image, znajdą się pliki po konwersji.
Właściwą konwersję wykonuje skrypt _convert_images.py (w moich projektach często stosuję znak „_” na początku nazwy pliku, aby wyróżnić pliki Pythona przeznaczone do wykonania na komputerze, a nie ESP32), który umieścić musimy w katalogu głównym projektu. Jego działanie jest bardzo proste - otwiera po kolei wszystkie pliki *.bmp z katalogu image_source, przekształca je i wynik zapisuje w katalogu images, w plikach o analogicznych nazwach, ale z rozszerzeniem *.py.
Aby skrypt działał, musimy zainstalować dwa dodatkowe pakiety. W tym celu na komputerze otwieramy konsolę systemową, po czym wydajemy dwa polecenia:
pip install pillow
Możemy teraz uruchomić skrypt konwertujący grafiki. Przy pomocy polecenia cd przechodzimy do folderu, w którym ten skrypt się znajduje, a następnie wydajemy polecenie python _convert_images.py. Na konsoli powinniśmy zobaczyć nazwy wszystkich obrazków, które zostały przekonwertowane. W przypadku plików z tego odcinka kursu, na konsoli zobaczymy następujące komunikaty:
Processing: back_32x32.bmp
Processing: book_32x32.bmp
Processing: cancel_32x32.bmp
Processing: clock_32x32.bmp
Processing: down_32x32.bmp
Processing: ep_logo_128x40.bmp
Processing: hand_32x32.bmp
Processing: light_32x32.bmp
Processing: ok_32x32.bmp
Processing: settings_32x32.bmp
Processing: square_8x16.bmp
Processing: up_32x32.bmp
Processing: world_128x64.bmp
Folder image należy w całości skopiować do pamięci ESP32.
Fonty
Ostatnim tematem do omówienia w niniejszym odcinku kursu jest wykorzystanie fontów innych niż te wbudowane w MicroPythona. W tym przypadku autorzy MicroPythona również nie dają żadnego wsparcia i wszystko musimy zrobić samodzielnie. Na potrzeby kursu udostępniam kilka moich autorskich fontów. Katalog font należy skopiować w całości do systemu plików MicroPythona, tak samo jak to robiliśmy w przypadku obrazów. Powinieneś widzieć go tak, jak pokazano na rysunku 5.
Otwórzmy jeden z plików fontów, aby dowiedzieć się, jak one działają. Fragment pliku fontu mini8 znajduje się na listingu 5. Wewnątrz pliku znajduje się jedynie słownik i nic więcej. Kluczami w słowniku są numery znaków, przy czym należy tu zaznaczyć, że nie ograniczamy się jedynie do 256 znaków ASCII, lecz obsługujemy standard Unicode UTF-8. Wartościami dla każdego klucza są obiekty bytearray. Pierwsze trzy bajty takiego obiektu kodują odpowiednio: szerokość znaku, wysokość znaku oraz odstęp w pikselach pomiędzy sąsiadującymi znakami. Kolejne bajty to właściwy obraz znaku. Można by rozdzielić te dane i zapisywać je np. jako krotkę, jednak umieszczenie wszystkiego w jednym obiekcie bytearray znacząco przyspiesza działanie programu.
mini8 = {
0: bytearray(b'\x03\x08\x01\xff\x81\xff'),
32: bytearray(b'\x02\x08\x01\x00\x00'),
33: bytearray(b'\x01\x08\x01_'),
34: bytearray(b'\x03\x08\x01\x03\x00\x03'),
35: bytearray(b'\x05\x08\x01\x14>\x14>\x14'),
...cięcie...
127: bytearray(b'\x03\x08\x01\xff\x81\xff'),
}
Listing 5. Kod pliku mini8.py
Aby umieścić napis w buforze obrazu, musimy posłużyć się metodą print_text. Przyjmuje ona następujące argumenty:
- nazwa fontu,
- tekst do wyświetlenia,
- współrzędna x,
- współrzędna y,
- opcjonalny argument ustawiający wyrównanie tekstu. Możliwe są następujące opcje:
- „l” - wyrównanie do lewej względem współrzędnej x,
- „c” - wyrównanie do środka względem współrzędnej x,
- „p” - wyrównanie do prawej względem współrzędnej x,
- „L” - wyrównanie do lewej względem krawędzi wyświetlacza,
- „C” - wyrównanie do środka względem krawędzi wyświetlacza,
- „P” - wyrównanie do prawej względem krawędzi wyświetlacza,
- kolor - „1” oznacza jasny tekst na czarnym tle, „0” to czarny tekst na jasnym tle.
Zobaczmy teraz, w jaki sposób możemy wyświetlać napisy różnymi fontami. Przykłady takich operacji pokazuje listing 6. Kod jest bardzo prosty i nie wymaga komentarza. Rezultat jego działania widać na fotografii 7.
from machine import Pin, I2C
import framebuf
import ssd1309
from font.dos8 import *
from font.galaxy16_digits import *
from font.mini8 import *
from font.mini8B import *
from font.squared16_unicode import *
from font.squared16B_unicode import *
i2c = I2C(0)
display = ssd1309.SSD1309(i2c)
display.print_text(mini8B, "Font demo SSD1309", 0, 0, "C")
display.print_text(mini8, "abcdefghijklmnopqrstuvwxyz01234", 0, 8, "C")
display.print_text(squared16_unicode, "ąęłćśńóźż", 0, 16, "L")
display.print_text(squared16B_unicode, "ąęłćśńóźż", 0, 16, "R")
display.print_text(dos8, "abcdefghijklmnop", 0, 32, "C")
display.print_text(dos8, "qrstuvwxyz123345", 0, 40, "C", 0)
display.print_text(galaxy16_digits, "0123456789", 0, 49, "C")
display.refresh()
Listing 6. Kod pliku demo_fonts.py
Wszystkie pliki wykorzystywane w kursie MicroPythona znajdziesz w repozytorium autora na GitHubie, dostępnym pod adresem [1].
W następnym odcinku będziemy kontynuować temat wyświetlania grafiki, ale podniesiemy poprzeczkę. Weźmiemy na warsztat kolorowy wyświetlacz TFT ze sterownikiem ST7796, o rozdzielczości 480×320 pikseli. Ponadto zapoznamy się z obsługą panelu dotykowego ze sterownikiem FT6336.
Dominik Bieczyński
leonow32@gmail.com
• Repozytorium kursu na GitHubie: https://github.com/leonow32/micropython
• Dokumentacja sterownika SSD1309: https://support.newhavendisplay.com/hc/en-us/article_attachments/4414433936535
• Dokumentacja wyświetlacza Wisevision Optronics OLED 128×64 2.42” yellow: https://www.lcsc.com/datasheet/lcsc_datasheet_2410121457_Newvisio-X242-2864KSYUG01-C24_C5123572.pdf
• Dokumentacja klasy FrameBuffer: https://docs.micropython.org/en/latest/library/framebuf.html
• Algorytm Bresenhama do rysowania linii prostych: https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
• Dekoratory Viper i Native: https://docs.micropython.org/en/latest/reference/speed_python.html#the-native-code-emitter