Menu na wyświetlaczu 16×2

Bardzo często robiąc jakiś projekt zachodzi konieczność zaimplementowania interfejsu do komunikacji człowiek-urządzenie.  W prostych konstrukcjach z reguły wystarcza kilka pirzycisków i świecących diod, jednak gdy robimy bardziej zaawansowane urządzenie warto byłoby zastanowić się, czy nie wygodnie byłoy dodać wyświetlacz LCD i zrobić na nim menu w którym pomieścimy tyle ustawień na ile nam pamięć kontrolera pozwoli.

Potrzebne części

Tak to już wszystko! Aby zrobić takie menu poza „mózgiem” w postaci mikrokontrolera będziemy potrzebowali tylko wyświetlacza i czterech przycisków :)

Opis podłączenia

Do wszystkich czterech przycisków podłączamy z jednej strony masę, a z drugiej strony wybrany przez nas pin cyfrowy. Nie musimy się martwić o rezystor podciągający, gdyż ustawimy go programowo.

Aby podłączyć wyświetlacz z konwererem należy podpiąć do niego zasilanie oraz magistralę I2C (piny oznaczone jako SDA i SCL). Dokładniejszy opis podłączenia i sterowania takim wyświetlaczem możnap rzeczytać tutaj.

Założenia programu

Interfejs będzie składał się z linii nagłówka oraz linii opcji. Na pierwszym poziomie menu w nagłówku będzie wyświetlany napis „Menu glowne”, natomiat na drugim poziomie będzie tam widniała nazwa aktualnie ustawianej opcji.

4 przyciski będą nam służyły do nawigowania po menu oraz zatwierdzania opcji. Będą to przyciski W LEWO, W PRAWO, OK oraz COFNIJ. Przycisk OK będzie wybierał daną opcję i wchodził w drugi poziom menu oraz zapisywał ustawienia. Przycisk COFNIJ będzie tylko wracał do menu głównego (bez zapisywania).

Nasze menu powinno być uniwersalne, to jest powinniśmy móc swobodnie po nim nawigować, ustawiać wartości graniczne, wyświetlać listę możliwych ustawień dla danej opcji, ustawić daną opcję jako akcję (po wciśnięciu przycisku OK zamiast wchodzenia poziom niżej wykona się jakaś funkcja).

Piszemy kod

Gotowy kod do pobrania tutaj.

Jak widać po zadeklarowanych stałych

#define BTN_BACK  8
#define BTN_NEXT  3
#define BTN_PREV  7
#define BTN_OK    5

przycisk COFNIJ należy podłączyć do pinu 8, W PRAWO do 3, W LEWO do 7 oraz OK do 5. Możemy oczywiście zmienić te piny na takie, jakie są dla nas dogodne :)

W naszym kodzie deklarujemy też jedną strukturę oraz typ wyliczeniowy:

typedef struct {
  String label;
  int minVal;
  int maxVal;
  int currentVal;
  void (*handler)();
} STRUCT_MENUPOS;

typedef enum {
  BACK, NEXT, PREV, OK, NONE
} ENUM_BUTTON;

Każdy element STRUCT_MENUPOS będzie reprezentacją pojedynczej opcji w menu. Zastanawiać może tutaj ostatnie pole, czyli wskaźnik do funkcji nie przyjmującej oraz nie zwracającej żadnych parametrów. Będzie ona nam służyła na dwojaki sposób. Jeżeli minVal będzie większe bądź równe maxVal (czyli sytuacja zdawałoby się bezsensowna) funkcja ta będzie akcją, którą chcemy wykonać (bez wchodzenia na drugi poziom menu). Drugi sposób wykorzystania tej funkcji to nadpisanie domyślnego zachowania programu (czyli wyświetlania użytkownikowi bezpośrednio wartości currentVal – będziemy mogli wstawić tam własne stringi). Trzecia możliwość to wpisanie tam wartości NULL – wtedy program zachowa się domyślnie, czyli wyświetli currentVal bez żadnego formatowania.

LiquidCrystal_PCF8574 lcd(LCD_ADDR);
STRUCT_MENUPOS menu[5];

int currentMenuPos = 0;
int menuSize;
bool isInLowerLevel = false;
int tempVal;

Teraz musimy zadeklarować kilka przydacnych później zmiennych. Tablica menu będzie zawierała pozycje naszego menu. currentMenuPos będzie informowało porgram która opcja jest aktualnie wybrana. menuSize to rozmiar menu (jest obliczany automatycznie na podstawie deklaracji). isInLowerLevel informuje nas, czy jesteśmy w menu główny, czy poziom niżej, a tempVal to tymczasowa wartośc dla currentVal ze struktury STRUCT_MENUPOS.

Przyjrzyjmy się teraz funkcji setup:

void setup() {
  Serial.begin(9600);
  lcd.begin(16, 2);
  lcd.setBacklight(255);

  pinMode(BTN_NEXT, INPUT_PULLUP);
  pinMode(BTN_PREV, INPUT_PULLUP);
  pinMode(BTN_BACK, INPUT_PULLUP);
  pinMode(BTN_OK, INPUT_PULLUP);

  menu[0] = {"Cyfry", 0, 9, 5, NULL};
  menu[1] = {"Liczby", 10, 1000, 15, NULL};
  menu[2] = {"Napisy", 0, 2, 0, formatNapisy};
  menu[3] = {"Ulamki", 0, 30, 15, formatUlamki};
  menu[4] = {"Port szer.", 0, 0, 0, actionPortSzeregowy};

  menuSize = sizeof(menu)/sizeof(STRUCT_MENUPOS);
}

Poza oczywistymi oczywistościami jak ustawianie trybu działania pinów cyfrowych i inicjalizacji wyświetlacza ustawiamy tutaj wszystkie pozycje naszego menu i tak np. menu z indeksem 2 będzie miało etykietkę „Napisy”, wartości w granicach od 0 do 2, a rysowanie tego podmenu będzie realizowała funkcja o sygnaturze void formatNapisy(). Menu z indeksem 4 wykona funkcję zadeklarowaną jako void actionPortSzeregowy() i nie wejdzie do niższego poziomu.

Czas zobaczyć co dzieje się w sercu programu, czyli w funkcji drawMenu:

Pozwolę sobie zacząć omawiać tę funkcję od końca, tak chyba będzie prościej zrozumieć w jaki sposób ona działa.

  lcd.home();
  lcd.clear();
  if(isInLowerLevel) {
    lcd.print(menu[currentMenuPos].label);
    lcd.setCursor(0, 1);
    lcd.print(F("> "));

    if(menu[currentMenuPos].handler != NULL) {
      (*(menu[currentMenuPos].handler))();
    } else {
      lcd.print(tempVal);
    }
  } else {
    lcd.print(F("Menu glowne"));
    lcd.setCursor(0, 1);
    lcd.print(F("> "));

    lcd.print(menu[currentMenuPos].label);
  }

Powyższy fragment odpowiada za wyświetlanie danych na ekranie. Na początku czyścimy wyświetlacz oraz ustawiamy kursor na pozycji początkowej (lcd.home()). Następnie sprawdzamy, czy jesteśmy na drugim poziomie. Jeżeli tak to w nagłówku wyświetlamy etykietkę opcji w któej jesteśmy, wypisujemy znak zachęty i jeżeli nie ma żadnej funkcji, która obsłuży nam wyświetlanie dostępnych możliwości to wypisujemy na ekran zmienną tymczasową. W przeciwnym wypadku wywołujemy funkcję z pola handler, która powinna zająć się wyświetleniem wartości na swój sposób.

Przyjrzyjmy się teraz obsłudze przycisków:

  ENUM_BUTTON pressedButton = getButton();

[…]

  switch(pressedButton) {
    case NEXT: handleNext(); break;
    case PREV: handlePrev(); break;
    case BACK: handleBack(); break;
    case OK: handleOk(); break;
  }

Odczytaniem przycisku zajmuje się funkcja getButton(). Nie będę się o niej rozpisywał, gdyż jedyne co robi to sprawdza stany na pinach za pomocą funkcji digitalRead i zwraca wciśnięty przycisk. Następnie wywołujemy odpowiednią funkcję w zależności od przycisku.

void handleNext() {
  if(isInLowerLevel) {
    tempVal++;
    if(tempVal > menu[currentMenuPos].maxVal) tempVal = menu[currentMenuPos].maxVal;
  } else {
    currentMenuPos = (currentMenuPos + 1) % menuSize;
  }
}

Obsługa przycisku W PRAWO jest prosta. Jeżeli jesteśmy na drugim poziome menu to zwiększamy wartość tymczasową i sprawdzamy, czy nie wyszła poza zakres. Jeżeli jesteśmy w menu głównym to zwiększamy currentMenuPos i w razie przekroczenia zakresu zaczynamy od 0 (operacja modulo). Podobnie działa przycisk W LEWO z tym, że w drugą stornę :)

void handleBack() {
  if(isInLowerLevel) {
    isInLowerLevel = false;
  }
}

Przycisk WSTECZ jedyne co robi to cofa się o poziom wyżej (do menu głównego).

void handleOk() {
  if(menu[currentMenuPos].handler != NULL && menu[currentMenuPos].maxVal <= menu[currentMenuPos].minVal) {
    (*(menu[currentMenuPos].handler))();
    return;
  }
  if(isInLowerLevel) {
    menu[currentMenuPos].currentVal = tempVal;
    isInLowerLevel = false;
  } else {
    tempVal = menu[currentMenuPos].currentVal;
    isInLowerLevel = true;
  }
}

Zobaczmy co kryje pod sobą funkcja obsługująca przycisk OK. Jeżeli pole handler nie jest NULLem oraz maxVal jest mniejszy bądź równy minVal to wykonujemy funkcję zdefiniowaną jako handler i wychodzimy z handleOk(). W przeciwnym wypadku jeżeli jesteśmy na niższym poziomie to zapisujemy tempVal do pola currentVal i przechodzimy poziom wyżej. Jeżeli jesteśmy w menu głównym to robimy dokładnie odwrotnie: zapisujemy currentVal do tempVal oraz wchodizmy na niższy poziom menu.

Teraz ostatnia (a właściwie pierwsza) część funkcji drawMenu:

  static unsigned long lastRead = 0;
  static ENUM_BUTTON lastPressedButton = OK;
  static unsigned int isPressedSince = 0;
  int autoSwitchTime = 500;

  ENUM_BUTTON pressedButton = getButton();

  if(pressedButton == NONE && lastRead != 0) {
    isPressedSince = 0;
    return;
  }
  if(pressedButton != lastPressedButton) {
    isPressedSince = 0;
  }

  if(isPressedSince > 3) autoSwitchTime = 70;
  if(lastRead != 0 && millis() - lastRead < autoSwitchTime && pressedButton == lastPressedButton) return;

  isPressedSince++;
  lastRead = millis();
  lastPressedButton = pressedButton;

W tym fragmencie kodu dbamy o to, aby menu nie odświeżało się za każdym razem a tylko wtedy, keidy wciśniemy jakiś przycisk. Jest też funkcjonalność, która przy przytrzymaniu przycisku przez 3 zmiany pozycji menu zacznie robić to szybciej (widać na filmiku na końcu artykułu). Są to głównie zależności czasowe na podstawie funkcji millis().

Zerknijmy jeszcze tylko szybko na funkcje-uchwyty:

/* Funkcje-uchwyty użytkownika */
void actionPortSzeregowy() {
  Serial.println("Wywolano akcje: Port szeregowy");
}

void formatNapisy() {
  String dictonary[] = {"Napis 1", "Napis 2", "Napis 3 :)"};
  lcd.print(dictonary[tempVal]);
}

void formatUlamki() {
  lcd.print(tempVal / 10.0);
}

Tak przygotowany program możemy wgrać na Arduino i sprawdzic go w praktyce.