Obsługa SPI
Podobnie jak w przypadku interfejsu I²C, w MicroPythonie mamy do dyspozycji sprzętową oraz programową implementację SPI. Implementacja programowa opiera się na zwyczajnej obsłudze pinów GPIO, dzięki czemu można wykorzystać dowolne piny, a mikrokontroler wcale nie musi mieć sprzętowego bloku SPI (co w obecnych czasach jest jednak rzadko spotykane). Interfejs SPI zrealizowany programowo działa bardzo wolno.
Implementacja sprzętowa korzysta z interfejsu, który razem z DMA pracuje niezależnie od rdzenia. Dzięki temu przesyłanie danych odbywa się w tle, a procesor może w tym czasie wykonywać jakieś inne zadania. W wielu mikrokontrolerach musimy użyć ściśle określonych pinów jako sygnałów MISO, MOSI i SCK, ale w przypadku ESP32 mamy pełną dowolność w tym zakresie. W układach z omawianej rodziny znajduje się programowalna matryca połączeniowa, dzięki której wejścia i wyjścia bloku SPI możemy połączyć z dowolnymi pinami GPIO. W takiej sytuacji programowa implementacja SPI traci sens i nie będziemy jej omawiać, a więc w tym i kolejnych odcinkach kursu będziemy korzystać wyłącznie ze sprzętowego SPI.
Aby mieć dostęp do SPI, najpierw musimy zaimportować klasy SPI oraz Pin z modułu machine.
Następnie musimy utworzyć instancję klasy SPI.
Pierwszym argumentem konstruktora jest numer interfejsu SPI, z którego chcemy korzystać. W ESP32-S3 dostępne są porty 1 i 2 (port 0 jest używany przez pamięci Flash i PSRAM). Argument baudrate to częstotliwość taktowania zegara SCK, podana w hertzach, polarity określa stan linii SCK w stanie spoczynku, a phase decyduje, które zbocze sygnału SCK ma wywoływać próbkowanie sygnałów danych. Dalej podajemy numery pinów mikrokontrolera, które mają funkcjonować jako linie SCK, MOSI i MISO. Istnieje możliwość, by interfejs SPI pracował w trybie jednokierunkowym – wtedy jako pin MISO lub MOSI podajemy None.
Interfejs SPI można zastosować do komunikacji z wieloma układami slave. Każdy z nich ma wejście CS, które sygnalizuje urządzeniu podrzędnemu, że ma odebrać lub zignorować bieżącą transmisję. W stanie spoczynkowym na tym wejściu musi panować stan wysoki. Wówczas slave będzie ignorował wszelkie zmiany na liniach SCK, MISO i MOSI. Zmiana stanu pinu CS z 1 na 0 powoduje aktywację układu slave. W takiej sytuacji będzie on odczytywał dane z wejścia MOSI i będzie wysyłał odpowiedź wyjściem MISO.
W MicroPythonie musimy ręcznie obsługiwać pin CS dla każdego układu slave. Najpierw dla każdego z nich musimy utworzyć instancję klasy, która ma sterować tym pinem. Robimy to tak, jak w przypadku zwykłych pinów wyjściowych.
Możemy teraz przesłać kilka bajtów do układu slave. Poniżej prezentujemy kilka sposobów:
spi.write(b”\x11\x22\x33\x44”) #1
spi.write(bytes([0x11, 0x22, 0x33, 0x44])) #2
spi.write(bytearray([0x11, 0x22, 0x33, 0x44])) #3
cs(1)
Sposób pokazany w linii 1 jest najbardziej optymalny, najszybszy i zajmuje najmniej pamięci, ponieważ bufor z danymi do wysłania jest już gotowy. W linii 2 tworzymy obiekt typu bytes na podstawie listy, w której znajdują się poszczególne wartości – stałe lub zmienne. Musimy tu pamiętać, że każda z nich musi znajdować się w zakresie od 0 do 255. Sposób zaprezentowany w linii 3 jest podobny do 2, lecz tworzymy bytearray. Różnica między tymi obiektami jest taka, że bytes stanowi typ tylko do odczytu, a bytearray można modyfikować. W tym przypadku nie ma takiej potrzeby, zatem użycie bytes jest bardziej optymalne niż bytearray.
Możemy odczytywać dane przy pomocy metody read. W poniższym przykładzie odczytujemy 4 słowa. Należy jednak pamiętać, że SPI jest interfejsem full-duplex, który przesyła informacje jednocześnie w obu kierunkach. Zatem aby coś odczytać, trzeba jednocześnie coś wysyłać. W drugim argumencie definiujemy, jaki bajt ma być wysyłany podczas odczytywania. Zwykle jest to 0x00 lub 0xFF.
rx_buffer = spi.read(4, 0x00)
cs(1)
Metoda read tworzy w pamięci nowy bufor. W przypadku wielokrotnych odczytów takiej samej liczby bajtów optymalne będzie wykorzystanie bufora, który już istnieje, a nie tworzenie nowego dla każdej transmisji. W tym celu wykorzystujemy metodę readinto, do której podajemy bufor utworzony wcześniej jako bytearray o odpowiednim rozmiarze. Trzeba także zapewnić informację o tym, jaki bajt ma być wysyłany podczas odczytywania.
cs(0)
spi.readinto(rx_buffer, 0x00)
cs(1)
Możemy oczywiście operować także na dwóch buforach jednocześnie – nadawczym i odbiorczym. Ważne tylko, by długość obu buforów była jednakowa. Przykład takiej operacji prezentujemy poniżej.
rx_buffer = bytearray(len(tx_buffer))
cs(0)
spi.write_readinto(tx_buffer, rx_buffer)
cs(1)
Wyświetlacz TFT ze sterownikiem ST7796
Wyświetlacze o rozdzielczości 320×480 (lub 480×320) i przekątnej 4” są bardzo popularne na znanym, chińskim portalu z darmowymi wysyłkami. Dostępne są jako samodzielne elementy, a także w zestawach z różnymi płytkami, na których są już przylutowane wszystkie elementy potrzebne do pracy takiego wyświetlacza. Istnieją dwa modele płytek do tych wyświetlaczy, niestety nie mają one żadnych oznaczeń ani nawet nazwy producenta. Płytka czerwona ma wyświetlacz z rezystancyjnym panelem dotykowym ze sterownikiem XPT2046 lub bez niego. Płytka czarna ma natomiast wyświetlacz z panelem pojemnościowym na bazie sterownika FT6336 (albo bez takiego panelu). Oba moduły mają 14-pinowe złącza goldpin, na które wyprowadzone są linie sterujące wyświetlaczem i panelem dotykowym. Zasilanie i sygnały wyświetlacza na obu płytkach wyprowadzone są w taki sam sposób, więc można stosować je zamiennie. W przypadku paneli dotykowych są jednak różnice z uwagi na to, że sterownik panelu rezystancyjnego XPT2046 korzysta z interfejsu SPI (nie połączonego z liniami SPI sterownika wyświetlacza), a kontroler panelu dotykowego FT6336 ma interfejs I²C.
W niniejszym odcinku kursu będziemy korzystać z płytki czarnej, pokazanej na fotografiach 1 i 2.
Proponuję wykonać drobną modyfikację, ponieważ płytka jest przystosowana do zasilania napięciem 5 V (gdyż takiego wymaga podświetlanie), ale na płytce znajduje się translator napięć na liniach I²C oraz rezystory podciągające te linie do 5 V. Może to powodować ryzyko dla mikrokontrolera, ponieważ ESP32 nie toleruje takiego napięcia na swoich pinach. Aby rozwiązać problem, wystarczy odlutować rezystory R3, R4, R5, R6 oraz tranzystory Q2, Q3 – w miejsce tych ostatnich wstawiamy mały drucik tak, jak to pokazano na fotografii 3.
Nasz wyświetlacz ma rozdzielczość 320×480 pikseli, a nie 480×320 – oznacza to, że jego standardowym trybem pracy jest orientacja pionowa, a nie pozioma. W konfiguracji sterownika ST7796 możemy zmienić tryb pracy na poziomy, co w tym odcinku kursu uczynimy. Pamiętać należy, że zastosowany wyświetlacz jest kiepskiej jakości matrycą TFT. Jego wada to dość wąski kąt widzenia. Jeżeli będziemy patrzeć na wyświetlacz pod niewłaściwym kątem, wówczas zobaczymy obraz w negatywie. Problem rozwiązuje technologia IPS, jednak w tanich, chińskich płytkach testowych trudno takowe znaleźć.
Wyświetlacz może korzystać z różnych palet kolorów. Tutaj jesteśmy ograniczeni możliwościami MicroPythona, ponieważ obsługuje on tylko format RGB565. W takiej palecie kolor każdego piksela zapisywany jest przy pomocy trzech składowych – są to: 5-bitowa składowa czerwona, 6-bitowa zielona i 5-bitowa niebieska. Razem mamy 16 bitów, co daje nam możliwość wyświetlenia 65536 różnych kolorów.
Zastanówmy się teraz, ile pamięci RAM potrzebujemy, aby obsłużyć taki wyświetlacz. Rozdzielczość 480×320 daje 153 600 pikseli. Na każdy piksel przypada 16 bitów koloru, czyli 2 bajty. Zatem bufor obrazu powinien mieć pojemność 307 200 bajtów. W praktycznych rozwiązaniach stosuje się dwa bufory. W jednym znajduje się obraz aktualnie wyświetlany, a w drugim generowany jest obraz, który ma być wyświetlony w najbliższej przyszłości. Generowanie obrazu może zająć trochę czasu – na tyle długo, że widok budowanych na bieżąco elementów obrazu mógłby być irytujący dla użytkownika. Dlatego w tym czasie musimy pokazywać obraz wygenerowany uprzednio. Na szczęście w sterowniku ST7796 znajduje się wystarczająco dużo pamięci RAM, aby przechowywać tam całą klatkę obrazu aktualnie wyświetlanego. W ESP32 musimy mieć jedynie bufor obrazu generowanego, czyli wystarczą nam 307 200 bajty pamięci. Jest to zarazem mało i dużo. Zbyt dużo, by zastosować mikrokontrolery ESP32 w podstawowej wersji. Musimy skorzystać z tych, które mają dodatkowy PSRAM. W przypadku rodziny ESP32-S3 istnieją moduły z wbudowaną pamięcią PSRAM o pojemności 2 MB i 8 MB. Polecam ten o pojemności 8 MB, nawet jeżeli nie ma potrzeby stosowania tak dużej pamięci, ponieważ jest on jednocześnie dwukrotnie szybszy.
Zastanówmy się też, ile czasu jest potrzebne na przesłanie takiego obrazu. Sterownik ST7796 może komunikować się przez SPI stosując zegar o częstotliwości nawet 80 MHz! To znaczy, że może przesłać 80 milionów bitów na sekundę. 307 200 bajtów to 2 457 600 bitów. Możemy to wszystko przesłać w czasie 30,72 ms, a obliczając odwrotność tej liczby dowiemy się, że w ciągu sekundy możemy przesłać 32 pełne klatki obrazu.
Częstotliwość zegara SPI rzędu 80 MHz to bardzo dużo, ale jest to realne do osiągnięcia nawet, kiedy wyświetlacz pozostaje podłączony do procesora kabelkami na płytce stykowej. Jeżeli masz problemy z uzyskaniem obrazu na wyświetlaczu, spróbuj obniżyć częstotliwość do 40 lub 20 MHz i sprawdź, czy wszystko jest dobrze podłączone. Jeżeli wyświetlacz działa na niższej częstotliwości, a na wyższej nie, problem mogą rozwiązać dodatkowe kondensatory na liniach zasilających.
Płytkę wyświetlacza możemy połączyć z modułem ESP32-S3 przy użyciu kilku kabelków i płytki stykowej. Przykładowy sposób połączeń pokazano w tabeli 1. Możemy oczywiście wykorzystać inne piny GPIO, ale wtedy trzeba zmodyfikować kody programów.
Moduł ST7796
Przygotujemy moduł obsługujący sterownik ST7796 w dwóch wersjach – jeden będzie obsługiwał obraz w poziomie, a drugi w pionie. Kod napiszemy w taki sposób, aby można było stosować zamiennie oba moduły w zależności od potrzeb. Różnić się będą jedynie sekwencją inicjalizacyjną i stałymi, definiującymi wysokość i szerokość w pikselach. Będą to pliki st7796_horizontal.py oraz st7796_vertical.py. Weźmy pod lupę kod pokazany na listingu 1, zawierającym kod dla wyświetlacza w orientacji poziomej, a wersję pionową Czytelnik może pobrać z GitHuba autora, dostępnego pod adresem [1].
import machine
import framebuf
import time
WIDTH = const(480) # 1
HEIGHT = const(320)
RED = const(0b000_00000_11111_000) # 2
YELLOW = const(0b111_00000_11111_111)
GREEN = const(0b111_00000_00000_111)
CYAN = const(0b111_11111_00000_111)
BLUE = const(0b000_11111_00000_000)
MAGENTA = const(0b000_11111_11111_000)
WHITE = const(0b111_11111_11111_111)
BLACK = const(0b000_00000_00000_000)
class ST7796(framebuf.FrameBuffer): # 3
def __init__(self, spi, cs, dc, rst): # 4
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.cs.init(mode=machine.Pin.OUT, value=1) # 5
self.dc.init(mode=machine.Pin.OUT, value=1)
self.rst.init(mode=machine.Pin.OUT, value=1)
self.array = bytearray(WIDTH * HEIGHT * 2) # 6
super().__init__(self.array, WIDTH, HEIGHT, framebuf.RGB565) # 7
self.rst(0) # 8
time.sleep_ms(15)
self.rst(1)
time.sleep_ms(15)
self.write_cmd(0x3A) # COLMOD: Pixel Format Set
self.write_data(0x05) # 16-bit pixel format
self.write_cmd(0x36) # Memory Access Control
self.write_data(0b11101100); # MY=1 MX=1 MV=1 ML=0 BGR=1 MH=1
self.write_cmd(0x2B) # Row range 0..319
self.write_data(0x00)
self.write_data(0x00)
self.write_data(0x01)
self.write_data(0x3F)
self.write_cmd(0x2A) # Col range 0..479
self.write_data(0x00)
self.write_data(0x00)
self.write_data(0x01)
self.write_data(0xDF)
self.write_cmd(0x11) # Sleep Out
self.write_cmd(0x29) # Display ON
def write_data(self, data): # 8
self.dc(1)
self.cs(0)
self.spi.write(bytes([data]))
self.cs(1)
def write_cmd(self, cmd): # 9
self.dc(0)
self.cs(0)
self.spi.write(bytes([cmd]))
self.cs(1)
def refresh(self): # 10
self.cs(0)
self.dc(0)
self.spi.write(bytes([0x2C]))
self.dc(1)
self.spi.write(self.array)
self.cs(1)
def color(self, red, green, blue): # 11
red = int(red)
green = int(green)
blue = int(blue)
if red > 255: # 12
red = 255
if green > 255:
green = 255
if blue > 255:
blue = 255
red = red & 0xF8 # 13
green1 = (green & 0xE0) >> 5
green2 = (green & 0x1C) << 11
blue = (blue & 0xF8) << 5
color = red | green1 | green2 | blue
return color
def print_char(self, font, char, x, y, color): # 14
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]
offset_x = 0
offset_y = 0
for byte in bitmap[3:]:
for bit in range(8):
if byte & (1<<bit):
self.pixel(offset_x+x, offset_y+y+bit, color)
offset_x += 1
if offset_x == width:
offset_x = 0
offset_y += 8
return width + space
def print_text(self, font, text, x, y, align="L", color=1): # 15
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)
def get_text_width(self, font, text): # 16
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
Listing 1. Kod pliku st7796_horizontal.py
Program zaczynamy standardowo, czyli od zaimportowania modułów, które będziemy wykorzystywać, a następnie w liniach 1 i 2 definiujemy stałe odpowiadające za wysokość i szerokość. Stałe const() to coś, czego nie znajdziemy w normalnym Pythonie na zwykłe komputery. Dzięki ich implementacji w MicroPythonie możemy utworzyć stałe i zaoszczędzić trochę pamięci RAM.
Dalej, w linii 2 i kolejnych, tworzymy definicje podstawowych barw. Są to najbardziej intensywne odcienie kolorów: czerwonego, żółtego, zielonego, błękitnego (cyan), niebieskiego, fioletowego (magenta), białego i czarnego, jakie tylko można uzyskać przy pomocy takiego wyświetlacza. Aby zrozumieć, co oznaczają poszczególne bity w tych stałych, zobaczmy rysunek 1. W kodowaniu RGB565 mamy możliwość ustawienia jasności koloru czerwonego przy pomocy 5 bitów, zielonego – 6 bitów, a na niebieski przypada 5 bitów. Bity te zapisujemy od najstarszego do najmłodszego, a całość zajmuje dwa bajty – nazywane starszym i młodszym. Niektóre sterowniki (do nich należy też nasz ST7796) korzystają z odwróconej notacji. Wtedy starszy i młodszy bajt zamienione są kolejnością. Przypatrz się bitom w linii 2 i tabelce na rysunku 1, a na pewno dostrzeżesz prawidłowość. Dla poprawy czytelności kodu zastosowałem znaki „_”, aby rozdzielić bity poszczególnych składowych kolorów.
W linii 3 rozpoczynamy klasę ST7796. W nawiasach za jej identyfikatorem podajemy nazwę nadrzędnej klasy, z której nasza klasa ma dziedziczyć różne metody. Będziemy tutaj korzystać z klasy FrameBuffer z modułu framebuf. Znajdują się tam wszystkie przydatne moduły do obsługi bufora obrazu oraz rysowania w nim różnych kształtów. My musimy tę klasę rozbudować jedynie o metodę inicjalizującą wyświetlacz, transferującą bufor obrazu przez SPI. Ewentualnie możemy dodać nasze własne metody do generowania tekstu przy pomocy różnych czcionek, co także uczynimy.
Klasę rozpoczynamy od napisania jej konstruktora __init__ w linii 4. Jako argumenty przyjmuje on interfejs SPI oraz piny CS, DC, RST, które mają być wykorzystywane do obsługi wyświetlacza. Oczekujemy, że zostaną tutaj dostarczone instancje klasy SPI oraz Pin z modułu machine. Zapisujemy te wszystkie obiekty do zmiennych wewnętrznych klasy. Następnie w linii 5 inicjalizujemy wszystkie piny CS, DC i RST jako wyjścia w stanie wysokim.
W linii 6 tworzymy bufor obrazu array. Jest to obiekt typu bytearray, którego rozmiar ustalamy jako iloczyn wysokości i szerokości, a następnie mnożymy go przez 2, bo na każdy piksel obrazu trzeba zarezerwować dwa bajty.
W kolejnej linii wywołujemy konstruktor klasy FrameBuffer. Musimy się do niej odwołać korzystając z funkcji specjalnej super(). Do konstruktora przekazujemy następujące argumenty: bufor obrazu utworzony linijkę wyżej, szerokość, wysokość oraz format obrazu.
Przechodzimy do fizycznej inicjalizacji wyświetlacza. W linii 8 ustawiamy pin reset w stan niski na 15 ms, a następnie w stan wysoki na kolejne 15 ms. Dopiero po tym przesyłamy sekwencję konfiguracyjną przy pomocy metod write_cmd i write_data. Metody te definiujemy w linijkach 8 i 9 – przesyłają one bajt podany w argumencie jako dane lub polecenie. Są bardzo podobne – ustawiają pin CS w stan niski, transmitują bajt przez SPI i ponownie ustawiają pin CS w stan spoczynkowy (wysoki). Różnią się jedynie ustawieniem pinu DC przed transmisją. Zadanie tej linii polega właśnie na wskazaniu, czy bajty przesyłane przez interfejs SPI mają być interpretowane jako polecenia, czy jako dane.
W linii 10 tworzymy metodę refresh. Jej zadaniem jest przesłanie bufora obrazu do sterownika ST7796. Aby to uczynić, rozpoczynamy transmisję od ustawienia pinów CS i DC w stan niski, aby przesłać polecenie 0x2C informujące sterownik, że ma ustawić kursor na współrzędnej zerowej. Dalej w tej samej transakcji musimy przesłać dane obrazu. Ustawiamy pin DC w stan wysoki, po czym przesyłamy cały bufor array. Kończymy transmisję, ustawiając linię CS w stan wysoki.
Można zapytać, dlaczego tutaj nie użyto metod write_data i write_cmd, jakie opracowaliśmy wcześniej? Po pierwsze chodzi o szybkość działania. Funkcja write_data rozpoczyna transmisję, ustawia pin DC, przesyła tylko jeden bajt i kończy transmisję. Wydajniej jest natomiast przesłać cały bufor obrazu w jednej transmisji. Po drugie polecenie 0x2C powoduje, że wszystkie kolejne dane, aż do zakończenia transmisji, mają być interpretowane jako obraz. To znaczy, że nie możemy przełączyć pinu CS w stan wysoki, dopóki nie prześlemy całego bufora obrazu. W przeciwnym wypadku musielibyśmy ponownie wysłać polecenie 0x2C i przesyłać bufor obrazu od początku.
Metoda color w linii 11 służy do konwersji kolorów na format RGB565 z odwróconymi bajtami. W argumentach należy podać jasność składowych koloru czerwonego, zielonego i niebieskiego w zakresie od 0 do 255. Metoda zwraca 16-bitową wartość, zrozumiałą dla sterownika naszego wyświetlacza. Mogłoby się zdarzyć, że przekazywane argumenty nie są liczbami całkowitymi – z tego powodu wykonujemy konwersję na zmienne całkowite przy pomocy funkcji int. Następnie upewniamy się, że argumenty nie przekraczają dopuszczalnych granic, ale jeżeli tak jest, to przypisujemy im wartości maksymalne. Dalej pozostają już tylko operacje bitowe i zwracanie gotowego wyniku.
Metody print_char (linia 14), print_text (linia 15) i get_text_width (linia 16) służą do wyświetlania napisów niestandardowymi czcionkami i działają tak samo, jak w przypadku wyświetlacza OLED z poprzedniego odcinka kursu. Odsyłam więc do wrześniowego numeru „Elektroniki Praktycznej”.
Pobierz z GitHuba gotowe pliki [1] i skopiuj st7796_horizontal.py oraz st7796_vertical.py do katalogu głównego dysku w ESP32-S3.
Testujemy wyświetlacz
Czas zobaczyć, jak nasz wyświetlacz działa w praktyce. Zobacz kod pliku demo_simple.py pokazany na listingu 2. Jest on bardzo prosty. W liniach 1 i 2 importujemy moduł st7796_horizontal lub st7796_vertical, w zależności od tego, która linia jest zakomentowana, a która nie. Korzystając ze słowa kluczowego as możemy zmienić nazwę importowanego modułu i w obu przypadkach nazywamy go st7796, aby w dalszej części programu nie trzeba było nic modyfikować po zmianie używanego modułu.
from machine import Pin, SPI
import st7796_horizontal as st7796 # 1
# import st7796_vertical as st7796 # 2
spi = SPI(2, baudrate=80_000_000, polarity=0, phase=0, # 3
sck=Pin(15), mosi=Pin(7), miso=None)
display = st7796.ST7796(spi, cs=Pin(4), dc=Pin(6), rst=Pin(5)) # 4
display.rect(0, 0, 128, 64, st7796.WHITE) # 5
display.text('abcdefghijklm', 1, 2, st7796.RED)
display.text('nopqrstuvwxyz', 1, 10, st7796.YELLOW)
display.text('ABCDEFGHIJKLM', 1, 18, st7796.GREEN)
display.text('NOPQRSTUVWXYZ', 1, 26, st7796.CYAN)
display.text('0123456789+-*/', 1, 34, st7796.BLUE)
display.text('!@#$%^&*(),.<>?', 1, 42, st7796.MAGENTA)
display.refresh() # 6
Listing 2. Kod pliku demo_simple.py
W liniach 3 i 4 tworzymy instancje klas SPI, a także ST7796, którą zapisujemy do zmiennej display. Przy pomocy tej zmiennej możemy uzyskać dostęp do wszystkich metod klasy. To wszystko co musimy zrobić, aby mieć możliwość pokazania czegoś na wyświetlaczu.
W linii 5 rysujemy biały prostokąt bez wypełnienia. W kolejnych liniach tworzymy różne napisy przy pomocy standardowej metody text, odziedziczonej z klasy FrameBuffer. Na koniec wywołujemy metodę refresh (linia 6), która przesyła nasz obraz do wyświetlacza. Efekt pracy tego kodu można zobaczyć na fotografii 4.
Na GitHubie umieściłem kilka różnych demonstracji. Nie będziemy szczegółowo analizować każdej z nich, a tylko skrótowo je omówimy.
Program w pliku demo_rainbow.py generuje tęczę. Dzieli obszar wyświetlacza na 320 poziomych linii i każdej z nich przypisuje inny kolor, korzystając z metody color klasy ST7796. Uzyskany efekt widać na fotografii 5.
Plik demo_fonts.py pokazuje, w jaki sposób można wyświetlać napisy przy pomocy niestandardowych czcionek. Działa to tak samo, jak w przypadku wyświetlacza OLED, omawianego w poprzednim odcinku kursu (nawet pliki czcionek są te same), a jedyna różnica polega na tym, że tekst może mieć dowolny kolor z palety RGB565, a nie tylko czarny lub biały. Demonstrację czcionek pokazuje fotografia 6.
Na naszym wyświetlaczu oczywiście możemy pokazywać także pliki graficzne, analogicznie jak w poprzednim odcinku kursu. Otwórz i uruchom pliki demo_images.py oraz demo_logo_ep.py. Efekt działania tego drugiego widzimy na fotografii 7 – program wyświetla logo „Elektroniki Praktycznej”.
Moduł FT6336 z pollingiem
Na naszym wyświetlaczu zamontowany jest pojemnościowy panel dotykowy ze sterownikiem FT 6336 firmy Focal Tech. Dokumentację tego układu znajdziemy pod adresem [5]. Sterownik jest już skonfigurowany przez producenta, a wszystkie dane kalibracyjne są zapisane w pamięci. Ponadto sterownik zna rozdzielczość wyświetlacza, a jego układ współrzędnych jest zgodny z układem współrzędnych wyświetlacza przy domyślnej konfiguracji (tzn. kiedy wyświetlacz pokazuje obraz pionowo, a nie poziomo). Po włączeniu zasilania sterownik od razu działa. Wystarczy tylko odczytywać z niego dane.
FT6336 obsługuje śledzenie dwóch palców jednocześnie, rozpoznawanie prostych gestów, pamięć buforującą dotknięcia, a także wiele innych funkcjonalności. Jednak, aby ten odcinek kursu nie był nadmiernie długi, ograniczymy się do rozpoznawania tylko jednego punktu dotykowego.
Komunikacja przez interfejs I²C może odbywać się na dwa sposoby. Układ slave na I²C, wykorzystujący tylko sygnały SDA i SCL, nie ma może rozpocząć komunikacji z masterem. Jedną z możliwości rozwiązania tego problemu jest cykliczne odpytywanie sterownika by sprawdzić, czy panel został dotknięty. Jest to tzw. polling. Drugą opcją jest zastosowanie trzeciego sygnału INT, którym slave informuje mastera, że powinien rozpocząć komunikację i odczytać dane.
Sterownik FT6336 przechowuje dane tak, jakby był pamięcią o 8-bitowym adresowaniu (podobnie jak omawiany wcześniej zegar DS1307). Zobacz rysunek 2, który pokazuje fragment mapy rejestrów przechowujących informacje związane z pierwszym wykrywanym punktem dotykowym.
Z tego wszystkiego nas interesować będą współrzędne x oraz y tego punktu oraz 2-bitowy kod zdarzenia z nim związanego. Możliwe zdarzenia to:
- 00 – dotknięcie,
- 01 – puszczenie,
- 10 – trzymanie,
- 11 – brak zdarzeń.
Zatem interesować nas będą rejestry o adresach od 0x03 do 0x06, czyli odczytywać będziemy 4 bajty danych.
Czas na analizę kodu widocznego na listingu 3. Zawiera on klasę obsługującą FT6336 metodą pollingu. Cykliczne odczytywanie danych zrealizujemy korzystając z wielowątkowości z modułu _thread, który importujemy w linii 1.
import _thread # 1
import time
ADDRESS = const(0x38) # 2
EVENT_PRESS = const(0b00_000000) # 3
EVENT_LIFT = const(0b01_000000)
EVENT_CONTACT = const(0b10_000000)
EVENT_NONE = const(0b11_000000)
class FT6336():
def __init__(self, i2c, period_ms, callback): # 4
self.i2c = i2c
_thread.start_new_thread(self.task, (period_ms, callback)) # 5
def task(self, period_ms, callback): # 6
while True: # 7
time.sleep_ms(period_ms) # 8
buffer = self.i2c.readfrom_mem(ADDRESS, 0x03, 4) # 9
x = ((buffer[0] & 0x0F) << 8) | buffer[1]
y = ((buffer[2] & 0x0F) << 8) | buffer[3]
event = buffer[0] & 0b11000000
callback(x, y, event) # 10
Listing 3. Kod pliku ft6336_polling.py
Adres sterownika FT6336 na magistrali I²C to 0x38 i nie można go zmienić. Dlatego definiujemy go jako stałą (linia 2). Poniżej, w linii 3, mamy kilka kolejnych stałych, odpowiedzialnych za kody zdarzeń.
W linii 4 znajduje się konstruktor klasy FT6336. Przyjmuje on następujące argumenty:
- i2c – instancja klasy odpowiedzialnej za komunikację przez I²C,
- period_ms – okres w milisekundach pomiędzy odczytami,
- callback – wskaźnik do funkcji, która ma być wywołana po każdym odczycie.
Argument i2c zapisujemy jako zmienną lokalną klasy, a dwa pozostałe służą uruchomienia tasku odczytującego przy pomocy funkcji start_new_thread z modułu _thread. Pierwszym argumentem tej funkcji jest wskaźnik do tasku, który ma być cyklicznie wywoływany. Drugi argument to krotka, w której możemy spakować dowolne informacje, jakie mają być przekazywane do tasku. W naszym przypadku są to: okres pomiędzy odczytami oraz wskaźnik do funkcji – czyli argumenty przekazywane do konstruktora podajemy dalej do tasku (linia 5).
Task tworzymy w linii 6. W gruncie rzeczy to jest zwykła funkcja. Podczas wywołania przekazywane są do niej argumenty period ms oraz callback. Charakterystyczną cechą wszystkich tasków jest to, że mają w sobie pętlę nieskończoną while True (linia 7), która wykonuje się tak długo, aż proces zostanie uśpiony przy pomocy funkcji sleep_ms z modułu time (linia 8).
W linii 9 odczytujemy interesujące nas rejestry, po czym wykonujemy kilka operacji, aby wyłuskać z nich współrzędne x, y oraz kod zdarzenia. Na zakończenie, w linii 10, wywołujemy funkcję przekazaną do konstruktora klasy, która poprzez argumenty otrzymuje informacje odczytane chwilę wcześniej ze sterownika.
Przetestujmy, jak to działa. Skopiuj plik ft6336_polling.py do pamięci ESP32, a następnie uruchom demo_touch_polling.py, którego kod znajduje się na listingu 4. Importujemy tam uprzednio tworzony moduł ft6336_polling i dla uproszczenia zmieniamy mu nazwę na ft6336. Podobnie robimy z modułem sterownika wyświetlacza (linia 1).
from machine import Pin, SPI, I2C
import ft6336_polling as ft6336 # 1
import st7796_vertical as st7796
event_str = { # 2
ft6336.EVENT_PRESS: "Press",
ft6336.EVENT_LIFT: "Lift",
ft6336.EVENT_CONTACT: "Contact",
ft6336.EVENT_NONE: "None",
}
def draw_point(x, y, event): # 3
display.fill_rect(0, 0, 320, 8, st7796.BLUE) # 4
display.text(f"x={x:3d}, y={y:3d}, event={event_str[event]}", # 5
0, 0, st7796.YELLOW)
if event == ft6336.EVENT_PRESS: # 6
display.pixel(x, y, st7796.GREEN)
elif event == ft6336.EVENT_CONTACT:
display.pixel(x, y, st7796.YELLOW)
elif event == ft6336.EVENT_LIFT:
display.pixel(x, y, st7796.RED)
display.refresh() # 7
spi = SPI(2, baudrate=80_000_000, polarity=0, phase=0, # 8
sck=Pin(15), mosi=Pin(7), miso=None)
display = st7796.ST7796(spi, cs=Pin(4), dc=Pin(6), rst=Pin(5)) # 9
i2c = I2C(0) # 10
touch = ft6336.FT6336(i2c, period_ms=100, callback=draw_point) # 11
display.fill(st7796.BLACK) # 12
display.refresh() # 13
Listing 4. Kod pliku demo_touch_polling.py
W linii 2 tworzymy prosty słownik event_str, ułatwiający nam przekształcenie kodu zdarzenia na zrozumiały napis, który będzie pokazany na wyświetlaczu.
Przeskoczmy teraz do linii 8. Tworzymy tam interfejs SPI. Dalej, w linii 9 tworzymy instancję klasy obsługującej wyświetlacz. W następnej linii tworzymy instancję I²C oraz klasę obsługującą panel dotykowy. Cały bufor wyświetlacza zapełniamy czarnym kolorem (linia 12) i przesyłamy go do wyświetlacza (linia 13). Klasę panelu dotykowego konfigurujemy tak, aby odczytywać dane z panelu co 100 ms, a po każdym odczycie ma być wywołana funkcja draw_point.
Definiujemy tę funkcję w linii 3. Odczytuje ona współrzędne i kod zdarzenia. Rysuje niebieski prostokąt (linia 4), a na nim napis ze współrzędnymi i opisem zdarzenia w kolorze żółtym (linia 5). W zależności od tego, jakie zdarzenie nastąpiło, rysujemy piksele o różnych kolorach na współrzędnych, jakie zostały odczytane ze sterownika panelu (linia 6). Pozostaje już tylko przesłać obraz do wyświetlacza (linia 7). Efekt pracy tego kodu pokazuje fotografia 8.
Moduł FT6336 z przerwaniami
Teraz zrobimy podobną klasę do obsługi panelu dotykowego, ale zamiast pollingu będzie ona korzystać z przerwań. Zobaczmy kod pliku ft6336_int.py, który zaprezentowano na listingu 5. Jest on podobny do poprzednio opisanego, więc omówimy tylko istotne różnice.
W konstruktorze klasy nie mamy już okresu w milisekundach, a zamiast niego pobieramy instancję klasy odpowiedzialnej za pin INT obsługujący przerwania, którym sterownik panelu dotykowego zgłasza wystąpienie zdarzenia. Wskaźnik do funkcji callback zapisujemy jako zmienną lokalną klasy, a pin konfigurujemy tak, aby pracował jako wejście z rezystorem pull-up (linia 3) oraz aby wywoływał przerwania (linia 4). W momencie wystąpienia zbocza opadającego na pinie INT, ma zostać wywołana funkcja irq_handler. Tworzymy także bytearray o rozmiarze 4 bajtów (linia 2), ponieważ autorzy MicroPythona zalecają, aby w przerwaniach nie tworzyć nowych zmiennych, a zamiast tego wykorzystywać te, które już istnieją.
W linii 5 tworzymy funkcję irq_handler. Musi ona przyjmować argument source, który jest obiektem wywołującym przerwanie, ale nam ta funkcjonalność nie jest potrzebna. Wewnątrz tej funkcji jedynie odczytujemy dane przez I²C, przekształcamy je i następnie wywołujemy funkcję callback, do której przekazujemy odczytane wartości poprzez argumenty.
Program demonstrujący działanie tej klasy wymaga drobnych zmian – popatrz na listing 5. Jedyne dwie różnice to importowanie modułu o innej nazwie (linia 1) oraz wskazanie pinu INT w konstruktorze klasy FT6336 (linia 2). Poza tym wszystko pozostało bez zmian.
import machine
ADDRESS = const(0x38)
EVENT_PRESS = const(0b00_000000)
EVENT_LIFT = const(0b01_000000)
EVENT_CONTACT = const(0b10_000000)
EVENT_NONE = const(0b11_000000)
class FT6336():
def __init__(self, i2c, int_gpio, callback): # 1
self.i2c = i2c
self.callback = callback
self.buffer = bytearray(4) # 2
int_gpio.init(mode=machine.Pin.IN, pull=machine.Pin.PULL_UP) # 3
int_gpio.irq(self.irq_handler, machine.Pin.IRQ_FALLING) # 4
def irq_handler(self, source): # 5
self.i2c.readfrom_mem_into(ADDRESS, 0x03, self.buffer)
x = ((self.buffer[0] & 0x0F) << 8) | self.buffer[1]
y = ((self.buffer[2] & 0x0F) << 8) | self.buffer[3]
event = self.buffer[0] & 0b11000000
self.callback(x, y, event) # 6
Listing 5. Kod pliku ft6336_int.py
Czas zastanowić się, które podejście jest lepsze. Sposób z pollingiem pozwala wykorzystać tylko dwa standardowe piny SDA i SCL interfejsu I²C i nic więcej nie jest potrzebne. Jednak tutaj kończą się zalety tej metody. Ciągłe odpytywanie marnuje czas i energię, a przez większość czasu pracy urządzenia wyświetlacz nie jest dotykany, zatem odczytywanie danych z niego jest bez sensu. Ponadto czas reakcji na dotyk uzależniony jest od częstotliwości odczytywania.
from machine import Pin, SPI, I2C
import ft6336_int as ft6336 # 1
import st7796_vertical as st7796
event_str = {
ft6336.EVENT_PRESS: "Press",
ft6336.EVENT_LIFT: "Lift",
ft6336.EVENT_CONTACT: "Contact",
ft6336.EVENT_NONE: "None",
}
def draw_point(x, y, event):
display.fill_rect(0, 0, 320, 8, st7796.BLUE)
display.text(f"x={x:3d}, y={y:3d}, event={event_str[event]}",
0, 0, st7796.YELLOW)
if event == ft6336.EVENT_PRESS:
display.pixel(x, y, st7796.GREEN)
elif event == ft6336.EVENT_CONTACT:
display.pixel(x, y, st7796.YELLOW)
elif event == ft6336.EVENT_LIFT:
display.pixel(x, y, st7796.RED)
display.refresh()
spi = SPI(2, baudrate=80_000_000, polarity=0, phase=0,
sck=Pin(15), mosi=Pin(7), miso=None)
display = st7796.ST7796(spi, cs=Pin(4), dc=Pin(6), rst=Pin(5))
i2c = I2C(0)
touch = ft6336.FT6336(i2c, int_gpio=Pin(16), callback=draw_point) # 2
display.fill(st7796.BLACK)
display.refresh()
Listing 6. Kod pliku demo_touch_int.py
W przypadku implementacji z wykorzystaniem przerwań musimy poświęcić jeden pin procesora na sygnalizację zdarzeń. Procesor odczytuje dane z panelu dotykowego tylko, wtedy, kiedy faktycznie jest co odczytywać. Ponadto procesor reaguje szybciej, bo czas wejścia w przerwanie jest bardzo krótki. Szybszy czas reakcji widać wyraźnie w naszym programie demonstracyjnym.
To wszystko na dziś! W następnym odcinku kursu, a także kilku kolejnych, będziemy poznawać możliwości komunikacji bezprzewodowej ESP32 z wykorzystaniem MicroPythona.
Dominik Bieczyński
leonow32@gmail.com
• Repozytorium kursu na GitHubie https://github.com/leonow32/micropython
• Dokumentacja klasy SPI https://docs.micropython.org/en/latest/library/machine.SPI.html#machine-spi
• Dokumentacja klasy FrameBuffer https://docs.micropython.org/en/latest/library/framebuf.html
• Dokumentacja sterownika wyświetlacza ST7796 https://www.displayfuture.com/Display/datasheet/controller/ST7796s.pdf
• Dokumentacja sterownika panelu dotykowego FT6336 https://www.buydisplay.com/download/ic/FT6236-FT6336-FT6436L-FT6436_Datasheet.pdf