Menu na LCD #3: implementacja

Implementacja menu na LCD HD44780

W końcu przechodzimy do najlepszej części, czyli programowania. Wskaźniki, struktury, wskaźniki na funkcje – powinno być ciekawie. Zapraszam do czytania 🙂

Struktura menu

Przed przystąpieniem do pisania kodu, warto sobie to nasze menu przemyśleć i rozrysować. Po pierwsze, powinno ono być wielopoziomowe, tzn. aby możliwe było przejście z jednego poziomu menu (np. menu główne) do któregoś podmenu (np. ustawienia), w którym będziemy mieli kolejne opcje do wyboru. Każdy element menu będzie po prostu węzłem listy dwukierunkowej. Z poprzedniej części wiemy, że w przypadku listy dwukierunkowej przydadzą się dwa wskaźniki *next oraz *prev. Będą one wskazywały odpowiednio na następny i poprzedni element na danym poziomie menu. Przechodzenie pomiędzy poziomami będzie analogiczne, potrzebne więc będą dwa wskaźniki, wskazujące na menu nadrzędne i podrzędne (podmenu). Nazwijmy je *child i *parent.

Na schemacie zobrazowałem bardzo proste, przykładowe menu, które składa się z poziomu (menu) podstawowego oraz wyłącznie jednego podmenu dla elementu 1.  Jak widać wskaźnik *prev pierwszego węzła ustawiamy na ostatni element listy na danym poziomie, a wskaźnik *next ostatniego węzła na NULL. Dlaczego tak? Taki sposób okazał się dla mnie najwygodniejszy w implementacji – rozwinę to jak przejdziemy do kodu.
Jeżeli któryś z elementów ma mieć podmenu to jego wskaźnik *child ustawiamy na pierwszy element odpowiadającego mu menu podrzędnego. W przeciwnym wypadku ustawiamy go na NULL. Natomiast wskaźnik *parent każdego elementu podmenu ustawiamy na element nadrzędny. Wyjątkiem są elementy na poziomie podstawowym, które nie mają rodzica i im przypisujemy wartość NULL. Strukturę menu będzie można oczywiście szeroko rozbudowywać i dostosowywać do własnych potrzeb.

Oprócz wyżej opisanych wskaźników, każdemu elementowi menu musi odpowiadać jakiś tekst (nazwa), który będzie widoczny na wyświetlaczu. Dodatkowo, do każdego elementu warto dodać wskaźnik na funkcję, która będzie wywoływana po kliknięciu „enter”. Np. jeżeli przejdziemy w menu do elementu o nazwie „Włącz silniki” to po jego kliknięciu chcemy, aby została wywołana funkcja, które te silniki nam włączy. Jeśli nie słyszałeś wcześniej o wskaźnikach na funkcje to zapraszam do artykułu: „Wskaźniki funkcyjne”. Tam dowiesz się wszystkiego, a tutaj, na konkretnym przykładzie zobaczysz jakie dają możliwości.

Implementacja

Na podstawię powyższych informacji, możemy stworzyć węzeł listy. W tym celu najlepiej będzie wykorzystać strukturę i zdefiniować dla niej oddzielny typ. Słowo kluczowe const przy elementach struktury nie jest niezbędne, ale warto o nim pamiętać, ponieważ spowoduje umieszczenie zmiennych w pamięci flash mikrokontrolera. Jest to szczególnie istotne w przypadku stringów, ponieważ pomoże to zaoszczędzić sporą ilość pamięci RAM. Menu nie będzie się zmieniać w czasie działania programu, więc nie zaszkodzi też dodać go przy pozostałych zmiennych wskaźnikowych.

Od razu stwórzmy definicję wszystkich elementów (węzłów) menu. Na potrzeby przykładu niech menu ma następującą strukturę (wcięcia oddzielają kolejne poziomy menu) :

Nawigacja

Zajmiemy się teraz implementacją funkcji do obsługi listy (menu), które umożliwią przechodzenie po jej kolejnych elementach. Będziemy do tego potrzebowali kilku zmiennych pomocniczych:

  • *currentPointer przechowuje adres elementu menu, który jest aktualnie wybrany. Przy inicjalizacji wskaźnik ustawiamy na pierwszy element menu.
  • menu_index przechowuje numer aktualnie wybranego elementu na danym poziomie menu (indeksowany od zera)
  • lcd_row_pos przechowuje numer wiersza LCD (indeksowany od zera), na którym wyświetlany będzie aktualnie wybrany element menu.

W ogólnym wypadku pozycji w menu może być więcej niż wierszy wyświetlacza. Aby wszystko było jasne postanowiłem przygotować specjalne grafiki. Poniżej przedstawione są wartości zmiennych menu_index i lcd_row_pos oraz odpowiadający im stan na LCD.

Tutaj sprawa była prosta, jednak kiedy chcemy przejść na element, który wykracza poza wyświetlacz, to kursor pozostawiamy na ostatnim wierszu wyświetlacza, a wszystkie elementy przesuwamy w górę. Graficznie można to przedstawić następująco:Dlatego właśnie potrzebna jest zmienna lcd_row_pos. Oczywiście będzie ona także potrzebna kiedy będziemy przesuwać się w górę menu:

Przechodzenie na kolejny element

Mając te zmienne możemy przejść do pisania funkcji obsługi. Zacznijmy od funkcji umożliwiającej przejście na kolejny element menu.

Na wstępie przypomnę, że jeśli odwołujemy się do struktur poprzez wskaźnik to dostęp do jej poszczególnych elementów uzyskujemy poprzez użycie operatora „->”, a nie „.”. Ewentualnie można zastosować następujący zapis: (*currentPointer).next.

Na początku sprawdzamy czy istnieje następny węzeł (element menu). Jeśli tak to przypisujemy jego adres do wskaźnika currentPointer, inkrementujemy menu_index oraz lcd_row_pos, z tym, że jeśli istnieje więcej elementów menu niż wierszy wyświetlaczy to ograniczamy wartość lcd_row_pos do LCD_ROWS – 1 (kursor ma pozostać na ostatnim wierszu wyświetlacza). LCD_ROWS -1, ponieważ makro jest równe liczbie wierszy LCD (np. 4), a lcd_row_pos jest indeksowane od zera. Jeśli warunek nie był spełniony, czyli currentPointer->next == NULL (nie ma już kolejnego elementu), to przeskakujemy na pierwszy element menu na danym poziomie. Jeśli jesteśmy na którymś z bardziej zagnieżdżonych poziomów to *currentPointer będzie miał jakiegoś rodzica. Ustawiamy więc wskaźnik na dziecko tego rodzica, czyli pierwszy element podmenu, w którym aktualnie jesteśmy. Wiem, że na pierwszy rzut oka może się to wydawać nieco zagmatwane, więc jeśli nie do końca to widzisz to spójrz raz jeszcze na definicję poszczególnych węzłów i schemat menu, który przedstawiłem na początku – wszystko powinno stać się jasne. Po wykonaniu operacji odświeżamy wyświetlacz za pomocą funkcji menu_refresh(), jednak na razie pominę jej opis. Nią zajmiemy się na samym końcu.

Przechodzenie na poprzedni element

Przejście na poprzedni element menu działa podobnie. Jak może pamiętasz, przy definicji elementów listy, wskaźnik *prev w pierwszym węźle ustawialiśmy na ostatni element, a nie na NULL jak to było w przypadku wskaźnika *next w ostatnim elemencie. Takie rozwiązanie okazało się przydatne w implementacji:

Na początku, przestawiamy wskaźnik currentPointer na poprzedni węzeł. Może okazać się, że jest nim ostatni element na danym poziomie menu, dlatego konieczne jest sprawdzenie, na której pozycji aktualnie jesteśmy. Jeśli nie jest to pierwszy element menu to wystarczy, że pomniejszymy indeksy menu_indexlcd_row_pos. Podobnie jak w menu_next() ograniczamy wartość lcd_row_pos, tylko, że tutaj robimy to od drugiej strony – nie może ona być mniejsza od zera. W przypadku gdy menu_index==0 to przeskakujemy na ostatni element menu na danym poziomie. Wskaźnik został już ustawiony na samym początku funkcji, teraz musimy ustawić tylko wartości menu_index i lcd_row_pos. Do wyznaczenia menu_index wykorzystamy funkcję menu_get_index(), która jak sama nazwa wskazuje zwraca numer indeksu elementu menu, który podamy jej jako argument. Pozostało już tylko ustawić lcd_row_pos. Wydawać by się mogło, że wystarczy jej przypisać wartość odpowiadającą ostatniemu wierszowi wyświetlacza, czyli LCD_ROWS – 1. Niestety nie zawsze by się to zgadzało. Domyślasz się już dlaczego? Elementów menu może być przecież mniej niż wierszy wyświetlacza… Już dosyć dawno temu pisałem ten kod, ale pamiętam, że wtedy dałem się na tym złapać 😀

Teraz przedstawię implementację menu_get_index(), którą wykorzystywaliśmy wyżej:

Przydadzą nam się tutaj dwie zmienne pomocnicze: wskaźnik *temp do tymczasowego przechowywania adresów kolejnych węzłów oraz zmienna i do przechowywania tymczasowej wartości indeksu. Wskaźnik ustawiamy na pierwszy element na poziomie, na którym jest element *q przekazywany jako argument, a następnie w pętli while przechodzimy przez kolejne węzły do momentu, aż dojdziemy do elementu, który przekazaliśmy jako argument (wskaźniki temp i q będą równe). W każdym obiegu inkrementujemy zmienną i, która po wyjściu z pętli przechowuje interesujący nas menu_index.

Najgorsze za nami 🙂 Zajmijmy się teraz funkcjami umożliwiającymi przejście do kolejnego poziomu (wejście do podmenu) i przejście do poprzedniego poziomu (powrót do menu nadrzędnego).

Przejście do podmenu

Przechodzenie do podmenu będzie realizowane za pomocą funkcji menu_enter():

Na początku sprawdzamy czy dla danego elementu menu przypisana jest jakaś funkcja. Jeśli tak, to ją wywołujemy. Tutaj widać potęgę wskaźników funkcyjnych. Funkcję odpowiadającą danemu węzłowi wywołujemy za pomocą jednej linii kodu! Co by było gdybyśmy nie zastosowali takiego mechanizmu? Konieczne byłoby stworzenie bardzo rozbudowanej instrukcji switch…case. Liczba case byłaby równa liczbie elementów całego menu. Ponadto, gdybyśmy zdecydowali się na zmodyfikowanie menu, trzeba by było pamiętać o zaktualizowaniu case’ów. Masakra. Mam nadzieję, że dzięki temu przykładowi dostrzegasz już korzyści płynące z takiego podejścia i łatwiej będzie Ci wykorzystać ten mechanizm w innych projektach.
Po wywołaniu funkcji (jeśli była jakaś przypisana), sprawdzamy czy istnieje podmenu dla aktualnego elementu. Jeśli tak, to przestawiamy wskaźnik na jego adres i ustawiamy menu_index oraz lcd_row na początek. Przy powrocie do menu nadrzędnego chciałem, aby kursor ustawiał się w tym miejscu, z którego wchodziłem, więc konieczne jest zapamiętanie poprzednich lcd_row_pos. Zmiennej menu_index nie musimy zapamiętywać, potem ją sobie odczytamy za pomocą funkcji menu_get_index(). Zmiennych lcd_row_pos_level_xxx musimy utworzyć o jeden mniej niż jest poziomów. W tym przykładzie są trzy poziomy, więc wystarczą dwie zmienne lub tablica dwuelementowa (gdy wchodzimy na drugi poziom zapamiętujemy wartość z pierwszego poziomu, a gdy wchodzimy na trzeci to zapamiętujemy wartość z drugiego).
Graficznie można to przedstawić następująco:

Powrót do menu nadrzędnego

Kolejną funkcją jest menu_back(), jak można się domyślić, umożliwia ona powrót do wyższego poziomu menu:

Na początku sprawdzamy czy w ogóle jest jakiś wyższy poziom, bo próbując wywołać tę funkcję równie dobrze możemy być aktualnie w menu głównym, gdzie wskaźniki parent wszystkich elementów są równe NULL. Jeśli jesteśmy na poziomie jakiegoś podmenu, to przechodzimy do dalszych czynności: zaczynamy od sprawdzenia, na którym poziomie jesteśmy, aby poprawnie odtworzyć wartość zmiennej lcd_row_pos. W tym celu wykorzystujemy funkcję menu_get_level(), która zwraca poziom menu elementu przekazanego jej jako argument. Następnie przypisujemy odpowiedni adres do currentPointer oraz odczytujemy wartość menu_index odpowiadającą temu elementowi.

Implementacja menu_get_level() wygląda następująco:

Jak widać jest analogiczna do menu_get_index(), dlatego pominę już jej opis.

Odświeżanie

Powoli zbliżamy się do końca. Została do napisania funkcja menu_refresh(), która była wywoływana w poprzednich funkcjach. Służy ona do odświeżenia wyświetlacza po operacjach wykonanych na menu.

Wskaźnik *temp służy do tymczasowego przechowywania adresów. Na początku ustawiamy go na pierwszy element na danym poziome menu, a potem przestawiamy go na element, który ma być wyświetlony na pierwszym wierszu LCD. Zrealizujemy to poprzez przechodzenie element po elemencie w pętli for tyle razy ile wynosi różnica, między menu_index i lcd_row_pos. Najłatwiej będzie to pokazać na grafice, którą już wcześniej zamieszczałem. 

W tym wypadku menu_index – lcd_row_pos = 4 – 3 = 1, więc wykona się jeden obieg pętli i wskaźnik temp zostanie ustawiony na „Element 2”. W sytuacji, kiedy nie ma potrzeby przesunięcia menu, pętla for nie wykona się ani razu i wskaźnik pozostanie na pierwszym elemencie, ponieważ różnica menu_index – lcd_row_pos będzie zawsze równa zero.

Gdy mamy już ustawiony wskaźnik na odpowiedni element możemy przejść do wyświetlenia aktualnego stanu menu. Można to zrobić poprzez bezpośrednie wysyłanie znaków do LCD, jednak ja zdecydowałem się na zastosowanie buforowania wyświetlacza. Więcej na ten temat możesz przeczytać w artykule „Buforowanie LCD”.
Na początku czyścimy wyświetlacz, lub tak jak to jest w tym wypadku – bufor, a następnie w kolejnych obiegach pętli for wyświetlamy kolejne elementy menu na kolejnych wierszach LCD. Jeżeli temp == currentPointer to znaczy, że na tym wierszu znajduje się element, który jest aktualnie wybrany, więc można przy nim narysować kursor. W każdym obiegu przestawiamy wskaźnik na kolejny element. Należy pamiętać, że elementów menu może być mniej niż wierszy wyświetlacza. W takim wypadku wychodzimy z pętli. Na końcu można odświeżyć LCD, za pomocą funkcji lcd_refresh() znanej z wyżej wspomnianego wpisu lub robić to okresowo w pętli głównej. Jeżeli nie będziesz korzystać z bufora, to oczywiście ta funkcja jest zbędna.

Sterowanie

Przechodzenie po poszczególnych elementach i poziomach menu można zrealizować na wiele sposobów. Najprostsze będzie wykorzystanie czterech przycisków. Każdy z nich będzie odpowiadał za inną funkcję:

  • pierwszy – przechodzenie w dół menu ( menu_next() ),
  • drugi – przechodzenie w górę menu ( menu_prev() ),
  • trzeci – enter, czyli wybór aktualnego elementu – przejście na kolejny poziom / wywołanie przypisanej funkcji ( menu_enter() )
  • czwarty – powrót do nadrzędnego menu ( menu_back() )

Równie dobrze można to zrobić korzystając z enkodera z przyciskiem: przy obrocie w prawą może być wywoływana funkcja menu_next(), przy obrocie w lewą funkcja menu_prev(), a przy wciśnięciu enkodera menu_enter(). Powrót w takim wypadku można zrealizować poprzez dodanie na końcu menu elementu o nazwie np. „Powrót” i dla jego wskaźnika funkcyjnego przypisać funkcję menu_back().

W tym przypadku, zdecydowałem się na obsługę przy pomocy przycisków. Masz pewnie jakieś swoje sposoby na obsługę switchy, ja do wyeliminowania efektu związanego z drganiami styków stosuję sposób opisany na blogu pana Mirosława Kardasia: „Obsługa klawiszy – drgania styków CD…2”. Przykładowa funkcja wygląda następująco i jest ona wywoływana w pętli głównej (pozostałe wyglądają analogicznie):

Podsumowanie

Mam świadomość, że niektóre opisane tutaj rzeczy mogą na pierwszy rzut oka być ciężkie do zrozumienia (szczególnie jeśli wcześniej nie miałeś do czynienia z listami), ale w takim wypadku polecam sobie wszystko rozrysowywać i analizować jakie wartości będą przechowywały zmienne w danym momencie –  tak jak to przedstawiałem na grafikach pomocniczych. Mam nadzieję, że artykuł się podobał i dowiedziałeś się czegoś nowego, a opisane menu będzie bardzo pomocne przy tworzeniu różnego rodzaju urządzeń. Jak może zauważyłeś, implementacja jest niezależna od wielkości wyświetlacza (należy jedynie pamiętać o poprawnym ustawieniu makra LCD_ROWS) oraz wykorzystywanej platformy, więc powinieneś ją bez problemu przenieść na dowolny mikrokontroler. Całkowity kod do tego przykładu znajdziesz na moim githubie – stanowi on dobrą bazę do rozbudowywania i dostosowywania menu do własnych potrzeb. W następnej, ostatniej już części z tej serii, wprowadzimy delikatne modyfikacje, aby wszystko wyglądało tak jak na filmie demonstracyjnym. Pokażę także, jak za pomocą jednego przycisku realizować różne funkcje w zależności od sytuacji, np. przechodzenie w górę i rozjaśnianie LCD. Zapraszam 😉

 Menu na LCD #4: implementacja (rozszerzenie)