Zegarek z budzikiem – Arduino

Witaj w drugiej części naszych poradników dla początkujących użytkowników Arduino. Masz już zbudowany pierwszy projekt zegara opartego z #SK01? Chcesz rozszerzyć go o dodatkowe funkcje? W tym artykule dowiesz się, jak dodać funkcjonalność budzika rozbudowując nieco poprzedni projekt. Zaczynajmy więc!

Lista potrzebnych części

Poza częściami, które były już użyte w projekcie #SK01 musisz dołożyć dodatkowo:

Wszystkie te części znajdziesz oczywiście w Starter-Kicie Nettigo :) więc jeżeli masz ten zestaw, masz wszystko czego potrzeba.

Schemat podłączenia

Aby móc rozpocząć programowanie i testowanie układu należy najpierw podłączyć wszystkie części według poniższego schematu:

Schemat układu

Po podłączeniu wszystkich elementów powinniście uzyskać mniej więcej taki układ:

Po co nam tranzystor przy podłaczaniu buzzera? Buzzer bez generatora to zwykły głośnik o rezystancji kilku ohmów. Jeżeli podłączymy go bezpośrednio do pinu cyfrowego Arduino może się okazać, że trwale się on uszkodzi (poprzez przepuszczenie zbyt dużego prądu). Z tego też powodu stosujemy tranzystor, który poradzi sobie z dużo większymi prądami niż taki zwykły pin cyfrowy. Taki trik można zastosować do sterowania całą gamą komponentów, które do wysterowania potrzebują więcej niż 40 mA.

Prawda jest taka, że Arduino wytrzymuje często więcej niż podane w specyfikacji. Np sami (również na tym blogu) wielokrotnie podłączaliśmy buzzer bezpośrednio do wyjścia cyfrowego Arduino. Jakoś działało i nie uległo uszkodzeniu, ale jak mierzyliśmy prąd na wyjściu potrafi przekroczyć 50 mA. A to nie jest dobrze przy Arduino, więc – podłączaj buzzer przez tranzystor.

Obsługa enkodera

Do poprawnej i łatwej obsługi enkodera polecam bibliotekę o nazwie Encoder stworzoną przez Paula Stoffregen’a. Znajdziesz ją w Mendeżerze bibliotek Arduino po wpisaniu w wyszukiwarkę słowa Encoder. Aby zapoznać się z jej działaniem wybierz Plik > Przykłady > Encoder > Basic. Jak łatwo zauważyć szkic ten jest wręcz banalnie prosty. Autor tworzy na początku zmienną o nazwie myEnc, z której później odczytuje aktualną wartość za pomocą metody read().

Enkoder, który dołączyliśmy do Starter-Kitu posiada w sobie wbudowany przycisk. Możesz obsługiwać go stanardowymi funkcjami takimi jak digitalRead(), żeby sprawdzić czy jest wciśnięty, jednak nie zawsze jest to takie proste. Całą sprawę komplikuje nieco zjawisko zwane drganiem styków – przy wciśnięciu przycisku mikrokontroler widzi w bardzo krótkim czasie szybkie zmiany stanów z wysokiego na niski i vice versa. Na szczęście również na tę przypadłość znajdzie się biblioteka :) Nazywa się ona Bounce2 i również z łatwością znajdziesz ją w menedrzeże bibliotek. W przypadku tej biblioteki obsługa też jest bajecznie prosta. Zgodnie z tym co możemy zobaczyć w przykładowym szkicu Bounce2 > bounce pierwszą wymaganą czynnością jest stworzenie zmiennej typo Bounce, następnie ustawienie przycisku w tryb INPUT_PULLUP oraz dołączenie go do utworzonej zmiennej za pomocą metody attach(). Dodatkowo za pomocą metody interval() ustawiamy także czas, kiedy może występować drganie (niech będzie 5 ms). W przypadku użycia tej biblioteki ważne jest, aby w funkcji loop() nie używać takich metod jak delay() lub delayMicroseconds() (czyli ogólnie blokujących procesor na jakiś czas), gdyż wymaganie jest nieustanne monitorowanie stanu przcisku poprzez wywoływanie metody update(). Sprawdzić stan przycisku możemy np. metodą read().

Obsługa buzzera

Tutaj powiem krótko, aby zadzwonić buzzerem najłatwiej będzie podłączyć go do pinu PWM Arduino (oznaczone na płytce falką ~). Piny te potrafią generować sygnał prostokątny (dokładnie taki, jakiego potrzebujemy aby wysterować buzzer :)). Wystarczy, że ustawimy taki pin jako OUTPUT, a następnie wywołamy funkcję analogWrite(NUMER_PINU, WARTOŚĆ), gdzie WARTOŚĆ to liczba z przedziału 0 – 255. My chcielibyśmy uzyskać wypełnienie 50% dlatego wpiszmy tam 128.

Modyfikacja poprzedniego programu

Zaczynając od samej góry musisz dołączyć dwie nwoe biblioteki, których użyjesz później do obsługi enkodera i jego przycisku:

#include <Encoder.h>
#include <Bounce2.h>

Zdefinijuj także kika stałych, aby później łatwiej było odnosić się do odpowiednich numerów pinów:

#define PIN_ENCODER_CLK 3
#define PIN_ENCODER_DAT 2
#define PIN_MODE_SELECT 8
#define PIN_BUZZER      9

oraz kika nowych zmiennych gobalnych, które przydadzą się w trakcie działania programu:

Encoder encoder(PIN_ENCODER_CLK, PIN_ENCODER_DAT);
Bounce modeSelect = Bounce(); 

bool displayCurrentTime = true;
unsigned long alarmFiredTime = 0;
long lastEncoderPos = -1;

Zmienna encoder to oczywiście zmienna do obsługi enkodera, natomiast modeSelect to przycisk do wyboru trybu działania zegarka (wyświetlanie godziny / ustawianie budzika).

Sprawdźmy teraz jak powinna zmienić się funkcja setup():

void setup() {
  pinMode(PIN_MODE_SELECT, INPUT_PULLUP);
  pinMode(PIN_BUZZER, OUTPUT);
  modeSelect.attach(PIN_MODE_SELECT);
  modeSelect.interval(5);
  
  Wire.begin();
  lcd.begin(16, 2);
  lcd.setBacklight(true);
  
  lcd.print("Zagar");
  lcd.setCursor(0, 1);
  lcd.print("Arduino");
  delay(1000);
  lcd.clear();
}

Jak już pewnie widzisz dodane zostały tutaj zaledwie 4 linijki. Służą one do ustawienia przycisku w tryb INPUT_PULLUP oraz buzzera w tryb OUTPUT, a także powiadomienia zmiennej modeSelect, że ma obserwować pin PIN_MODE_SELECT.

Rzućmy okiem na nowego loop’a:

void loop() {
  handleAlarms();
  handleButtonClick();
  
  if(displayCurrentTime) {
    displayDateTime();
  } else {
    displayAlarmSettings();
  }
}

Przyglądając się temu kodowi można już wywnioskować jak będzie działała logika programu, któy piszemy.

  1. Program sprawdza, czy są jakieś alarmy i jeżeli tak to wywołuje ich obsługę (handleAlarms)
  2. Program monitoruje stan przycisku i w razie przyciśnięcia wywołuje odpowiednie akcje (handleButtonClick)
  3. Jeżeli powinniśmy wyświetlić na ekranie aktualną datę i godzinę
    • Wyświetlamy aktualną datę i godzinę
  4. W przeciwnym wypadku
    • Wyświetlamy ustawienia budzika

Tak przezentuje się algorytm działania programu. Jedyne co pozostało Ci teraz zrobić to odpowiednio zaimplementować podane powyżej funkcje. displayDateTime() jest już zaimplementowana (w poprzednim wpisie), ale należy ją odrobinę zmodyfikować.

Modyfikacja displayDateTime()

Przypomnijmy sobie jak wyglądała owa funkcja w swojej pierwotnej wersji w #SK01:

void displayDateTime() {
  lcd.clear();
  lcd.print(getDateString());
  lcd.setCursor(0, 1);
  lcd.print(getTimeString());
}

Wywoływaliśmy ją co sekundę poprzez zastosowanie funkcji delay(1000) w funkcji loop(). Tak jak pisaliśmy wcześniej poprawne działanie biblioteki Bounce wymaga, aby nie stosować funkcji opóźniających. Dlatego też nową wersję displayDateTime() będziemy wywoływali zawsze, ale wewnątrz niej będziemy sprawdzali, czy od ostatniego wywołania minęła przynajmniej sekunda. Możemy to zrobić w ten sposób:

void displayDateTime() {
  static unsigned long lastExecute = millis();

  if(millis() - lastExecute >= 1000) {
    lcd.clear();
    lcd.print(getDateString());
    lcd.setCursor(0, 1);
    lcd.print(getTimeString());
    lastExecute = millis();
  }
}

Zmiana jest niewielka. Deklarujemy sobie zmienną lastExecute do której przypisujemy aktualny czas od uruchomienia procesora. Następnie w instrukcji warunkowej (if) sprawdzamy, czy aktualny czas od uruchomienia procesora pomniejszony od ostatni taki czas, kiedy została wywołana funkcja jest większy, bądź równy 1000. Jeżeli tak, to przechodzimy do właściwego wywołania funkcji, a na końcu aktualizujemy zmienną lastExecute na aktualną wartość millis(). Cała ta sztuczka możliwa jest dzięki temu, że deklarując lastExecute użyliśmy słowa static. Powoduje ono, że pomiędzy wywołaniami funkcji wartość zmiennej określonej takim słowem nie ginie.

Implementacja displayAlarmSettings()

Skoro jesteśmy już przy wyświetlaniu wartości na ekranie to w następnej kolejności zaimplementujmy wyświetlanie ustawień budzika.

void displayAlarmSettings() {
  if(lastEncoderPos != encoder.read()) {
    lcd.clear();
    lcd.print(F("Alarm o godz.:"));
    lcd.setCursor(0, 1);
    lcd.print(encoderPositionToTimeString());
    lastEncoderPos = getEncoderPositionOverflow();
  }
}

Jak można zauważyć tutaj aktualizacja ekranu nie odbywa się co sekundę, a przy obrocie enkodera (bo przecież tylko wtedy zmienia się ustawienie alarmu). Analogicznie po wykonaniu wszystkich niezbędnych czynności aktualizujemy zmienną lastEncoderPos do aktualnego stanu rzeczy. Wykorzystane są tu 2 funkcje, które musimy zaimplementować w dalszych krokach (getEncoderPositionOverflow() oraz encoderPositionToTimeString()).

Implementacja getEncoderPositionOverflow()

Z pewnością pierwsze pytanie jakie przychodzi Ci do głowy to po co mi ta funkcja? Kręcąc enkoderem dodajemy lub odejmujemy minuty. Nasz budzik będziemy ustawiali na daną godzinę. W dobie mamy 24 godziny po 60 minut każda, stąd też chcielibyśmy żeby wartość enkodera nie wyszła poza jedną dobę (a jeżeli wyjdzie to żeby zaczeła liczyć od nowa). Zobaczmy jak to wykonać:

long getEncoderPositionOverflow() {
  long currentEncoderValue = encoder.read();
  while(currentEncoderValue < 0) {
    currentEncoderValue += 24 * 60;
  }
  currentEncoderValue %= 24 * 60;
  encoder.write(currentEncoderValue);
  return currentEncoderValue;
}

Dopóki wartość enkodera będzie mniejsza od 0 to będziemy dodawali tyle minut, ile jest w dobie, aby skompensować ten wynik. Następnie musimy zagwarantować, aby wartość nie była większa niż liczba minut w dobie stąd dzielenie modulo przez 24 * 60. Kiedy już upewnimy się, że nasza wartość jest w dobrym przedziale możemy nadpisać wartość w enkoderze i zwrócić ją.

Implementacja encoderPositionToTimeString()

Ta metoda ma za zadanie zwrócić aktualnie ustawioną przez enkoder godzinę w postaci ciągu znaków, który możemy wyświetlić na ekranie. W tym celu pobieramy aktualną wartość na enkoderze, a następnie obliczamy godzinę (dzieląc całkowicie wartość enkodera przez 60) oraz minutę (obliczając resztę z dzielenia wartości enkodera przez 60).

String encoderPositionToTimeString() {
  long currentEncoderValue = getEncoderPositionOverflow();
  
  byte hour = (currentEncoderValue / 60);
  byte minute = currentEncoderValue % 60;

  return toStringWithLeadingZeros(hour) + ":" + toStringWithLeadingZeros(minute);
}

Pozostały nam do zaimplementowania jeszcze dwie główne funkcje. Mianowicie handleAlarms() oraz handleButtonClick(). Zajmijmy się teraz pierwszą z nich.

Implementacja handleAlarms()

void handleAlarms() {
  if(clock.checkIfAlarm(2)) {
    alarmFiredTime = millis();
    displayCurrentTime = true;
  }

  if(alarmFiredTime > 0) {
    bool state = (millis() % 400) < 200;
    lcd.setBacklight(state);
    analogWrite(PIN_BUZZER, 128 * state);
  } else {
    lcd.setBacklight(true);
    analogWrite(PIN_BUZZER, 0);
  }
}

Wiadomo, że funkcja ta odpowiada za obsługę dzwoniącego budzika. Dlatego pierwszym krokiem jaki należy zrobić jest odpytanie zegara DS3231, czy aktualnie powinien dzwonić budzik. Możemy to sprawdzić za pomocą metody checkIfAlarm(), która przyjmuje typ alarmu (DS3231 posiada 2 typy: dopasowanie do sekund oraz do minut). Nas interesuje dopasowanie do minut, dlatego jako parametr podamy liczbę 2. Jeżeli zegar poinformuje nas, że powinniśmy uruchomić alarm to ustawiamy zmienną alarmFiredTime (czyli czas odkąd budzik dzwoni) na aktualną wartość millis(). Wymuszamy także przełączenie widoku na widok daty/czasu.

W kolejnym bloku sprawdzamy, czy alarmFiredTime > 0 (zakładamy, że jeżeli jest zerem to budzik aktualnie nie dzwoni). Jeżeli tak jest, to wykonujemy miganie ekranem oraz piszczenie buzzerem. Najbardziej zagadkowym zapisem jest tutaj:

bool state = (millis() % 400) < 200;

Jest to wartość prawda / fałsz (0/1) zależna od aktualnego czasu. Wynik funkcji millis() dzielimy modulo przez 400 ms. Dzięki temu zawsze otrzymamy liczbę z zakresu 0-399, która będzie zwiększała się co 1 milisekundę oraz po przekroczeniu wartości 399 zacznie liczyć od nowa. Dodatkowo zakładamy sobie warunek, że liczba ta powinna być mniejsza od 200. Dzięki takiemu zabiegowi przez pierwsze 199 milisekund będziemy mieli w wyniku prawdę (1), a przez pozostały czas fałsz (0). Niezależnie od wartości funkcji millis() co ok. 200 ms zmienna ta zmieni stan na przeciwny.

Pomoże nam to w sprawnym miganiu ekranem i piszczeni buzzerem, gdyż teraz wystarczy wywołać:

lcd.setBacklight(state);
analogWrite(PIN_BUZZER, 128 * state);

Po takim zabiegu ekran co 200 ms zmieni stan podświetlenia, a buzzer raz otrzyma wypełnienie 0, a raz 128 * 1, czyli 128 :)

Implementacja handleButtonClick()

Tym sposobem pozostała nam już do implementacji ostatnia główna funkcja. Odpowiada ona za obsługę wciśnięcia przycisku. Przycisk ten będzie powodował różne akcje w zależności od momentu, kiedy został wciśnięty. I tak:

  • Jeżeli wciśniemy go podczas wyświetlania daty/godziny to przełączy nas na ekran ustawiania budzika,
  • Jeżeli wciśniemy go na ekranie ustawiania budzika, to zatwierdzi aktualnie ustawianą godzinę, a następnie wróci do wyświetlania daty/godziny,
  • Jeżeli wciśniemy go w trakcie dzwonienia budzika, to budzik zostanie wyłączony.
void handleButtonClick() {
  modeSelect.update();
  if(modeSelect.fell()) {
    if(alarmFiredTime > 0) {
      alarmFiredTime = 0;
      return;
    }
    
    if(displayCurrentTime) {
      encoder.write(determineEncoderPosition());
      lastEncoderPos = -1;
    } else {
      long currentEncoderValue = getEncoderPositionOverflow();
      byte hour = (currentEncoderValue / 60) % 24;
      byte minute = currentEncoderValue % 60;
      setAlarm(hour, minute);
    }
    displayCurrentTime = !displayCurrentTime;
  }
}

Na samym początku aktualizujemy stan przycisku. Kiedy już to zrobimy sprawdzamy za pomocą metody fell(), czy przycisk zmienił stan z wysokiego na niski (opadł). Jeżeli tak, to wykonujemy całą logikę związaną z wciśnięciem przycisku. Na początku sprawdzamy, czy aktualnie dzwoni budzik. Jeżeli tak, to ustawiamy alarmFiredTime na wartość 0 (czyli taką jaką przyjęliśmy dla nie dzwoniącego budzika) i kończymy obsługę wciśnięcia przycisku za pomocą return. Jeżeli byśmy tego nie zrobili, to program przeszedłby do dalej, czyli zmieniłby ekran na ustawianie budzika.

W dalszej części funkcji sprawdzamy, czy aktualnie wyświetlamy datę/godzinę. Jeżeli tak to musimy przygotować się na przełączenie stanu na wyświetlanie ustawień budzika. W tym celu piszemy:

encoder.write(determineEncoderPosition());
lastEncoderPos = -1;

W momencie, kiedy nie wyświetlamy aktualnie ustawień budzika możemy dowolnie kręcić enkoderem (biblioteka odnotuje zmianę jego wartości). Kiedy chcemy przełączyć się na ustawienia budzika musimy zakomunikować bibliotece enkodera jaką wartośc powinna zwracać dla aktualnie ustawionego alarmu. Ustawienie zmiennej lastEncoderPos na -1 zapewni nam przy pierwszym wywołaniu funkcji displayAlarmSettings(), że zmienna lastEncoderPos będzie miała wartość inną od encoder.read(), dzięki czemu nastąpi wypisanie aktualnych ustawień na ekran (gdybyśmy tego nie zrobili to ekran nie zaktualizowałby swoich informacji aż do pierwszego przekręcenia enkodera).

Instrukcje w bloku else wykonają się w przypadku, kiedy przechodzimy z ekranu ustawień do wyświetlania daty/godziny. W tym momencie zatwierdzić ustawiony przez użytkownika alarm. Dlatego też pobieramy aktualne ustawienie enkodera, obliczamy godzinę oraz minutę i wywołujemy funkcję setAlarm(). Na koniec niezależnie od tego, czy przełączamy się z ekranu daty na ekran budzika, czy odwrotnie musimy poprostu zmienić stan tej zmiennej na przeciwny. Dlatego też wywołujemy instukcję:

displayCurrentTime = !displayCurrentTime;

Implementacja setAlarm()

Ta funkcja ma za zadanie powiadomienie zegara o tym, że ma ustawić budzik na daną godzinę.

void setAlarm(byte hour, byte minute) {
  clock.setA2Time(1, hour, minute, 0b01000000, false, false, false);
  clock.turnOnAlarm(2);
}

Jednym z parametrów jest tutaj enigmatyczne 0b01000000. Jest to maska móiąca DS3231, że chcemy aby alarm odpalił się w momencie, kiedy znajdzie dopasowanie godziny oraz minuty. O tym jak kodowana jest taka maska można poczytać w kodzie źródłowym biblioteki od zegara.

Implementacja determineEncoderPosition()

Funkcja ta ma ustalić odpowiednią wartość, jaką powinien przyjąć enkoder dla aktualnie ustawionego budzika.

long determineEncoderPosition() {
  if(!clock.checkAlarmEnabled(2)) {
     return 0;
  }
  byte day, hour, minute, alarmBits;
  bool dy, h12, pm;
  
  clock.getA2Time(day, hour, minute, alarmBits, dy, h12, pm);

  return ((long) 60) * hour + minute;
}

Jeżeli żaden alarm nie jest ustawiony, to zwracamy 0. Jeżeli budzik jest ustawiony, to pobieramy z niego godzinę oraz minutę i zwracamy w postaci liczby minut (liczba godzin * 60) + liczba minut.

Koniec

To już wszystko :) Tak poskładany program możemy wgrać na Arduino i cieszyć się, że udało nam się zupgrade’ować zwykły zegarek do zegarka z budzikiem.

A jeśli ktoś potrzebuje to całość kodu jest do ściągnięcia tutaj.