Prosty czujnik cofania – Arduino i HC-SR04

Dziś przedstawiam prosty projekt Arduino, który uratuje Twój samochód. Wykonanie go zajmie Ci dosłownie chwilę, a przy okazji zbudujesz coś pożytecznego, funkcjonalnego i mam nadzieję sporo się też nauczysz. Zatem do dzieła!

Historia jakich wiele. Ostatnio parkując samochód w swoim dość małym garażu uważnie sprawdzając w lusterkach, czy zaraz nie wyjadę przez tylną ścianę wpadłem na pomysł jak sobie taki proces parkowania znacznie ułatwić. Można zbudować układ oparty o Arduino, który graficznie jak i dźwiękowo zasygnalizuje mi jak blisko ściany jest samochód oraz będę mógł go sobie dowolnie skalibrować. Z pomocą przyszedł mi nowy Starter Kit Nettigo, w którym znajdziemy części niezbędne do wykonania tego prostego projektu.

Opis projektu

Zbudujemy urządzenie mierzące odległość, korzystając z ultradźwiękowego czujnika HC-SR04. Wyobraźmy sobie, że zamontujemy układ na ścianie, linijka LED będzie sygnalizować odległość a buzzer dodatkowo zapewni sygnał dźwiękowy. Im samochód bliżej końcowej ściany, tym więcej diod będzie się świecić i będzie ulegał zmianie ich kolor.

Lista potrzebnych części

Oczywiście przydatne będą też płytka stykowa i przewody montażowe. Jak już wspomniałem, wszystkie elementy (oprócz samego Arduino) wchodzą w skład nowego Nettigo Starter Kit dla Arduino.

Schemat podłączenia

Całość możemy w prosty sposób zmontować na płytce stykowej w sposób przedstawiony na poniższym schemacie:

Schemat wskaźnika odległości
Schemat naszego układu
Realizacja układu miernika odległości na Arduino, HC-SR04
Szczegółowy wygląd układu zmontowanego wg poprzedniego schematu

Opis programu

Aby udało nam się wszystko skompilować przed przystąpieniem do pracy będziemy musieli zaopatrzyć się w dwie biblioteki do Arduino.

Pierwszą z nich możemy w bardzo prosty sposób zainstalować poprzez wbudowany w Arduino IDE menedżer bibliotek. Wystarczy, że wybierzemy opcję Szkic > Dołącz bibliotekę > Zarządzaj bibliotekami…, a następnie wyszukamy frazę NeoPixel i po wybraniu biblioteki wciśniemy przycisk Instaluj.

NewPing z racji, że hostowana jest w serwisie na BitBucket, którego menedżer nie obsługuje, musi zostać zainstalowana ręcznie. Wystarczy pobrać ją w formacie ZIP ze strony biblioteki, w Arduino IDE wybrać opcję Szkic > Dołącz bibliotekę > Dodaj bibliotekę .ZIP …, a następnie wskazać pobrany plik. Instalacja przebiegnie automatycznie.

Alternatywną metodą jest ręczne ściągnięcie bibliotek i wgranie ich do folderu Arduino/libraries/, który powinniśmy znaleźć w folderze Dokumenty.

Kiedy już mamy zainstalowane wszystkie potrzebne pomoce, możemy utworzyć nowy szkic i zacząć od załączenia pachnących jeszcze świeżością bibliotek:

#include <Adafruit_NeoPixel.h>
#include <NewPing.h

 

Dobrą praktyką jest, aby zaraz pod klauzulami #include znajdowały się stałe używane w programie:

#define LED_PIN             6   // Pin, do którego podłączony jest "DIN" z NeoPixeli
#define LEDS_COUNT          8   // Ilość diod na pasku NeoPixel
#define BUZZER_PIN          9   // Pin buzzera
#define TRIGGER_PIN         12  // Pin "TRIG" czujnika odległości
#define ECHO_PIN            11  // Pin "ECHO" czujnika odległości
#define MAX_DISTANCE        200 // Maksymalny możliwy do zmierzenia dystans (200 dla HC-SR04)
#define TRIG_DISTANCE       100 // Dystans od którego zaczniemy informować o przeszkodzie
#define MEASUREMENT_PERIOD  200 // Co ile ms ma być wykonywany pomiar odległości?
#define BEEP_TIME           50  // Ile ms ma trwać piszczenie buzzera?

 

Zapisałem tu najważniejsze wartości używane w wielu miejscach w programie. Jeżeli w przyszłości postanowilibyśmy dla przykładu linijkę LED zastąpić wycinkiem taśmy (np. takiej) składającym się z 15 diod, możemy podmienić tylko stałą LEDS_COUNT z 8 na 15 i program będzie działał bez problemu :) Pamiętaj tylko, żeby zapewnić właściwe zasilanie. Im więcej diod, tym więcej prądu potrzebują. Upewnij się czy Twój zasilacz będzie miał należytą wydajność prądową.

Teraz przyszedł czas, aby zadeklarować globalne zmienne. Najpierw utwórzmy obiekty odpowiedzialne za sterowanie LEDami oraz czujnikiem odległości:

Adafruit_NeoPixel leds(LEDS_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
NewPing sonar(TRIGGER_PIN, ECHO_PIN, MAX_DISTANCE);

Teraz zainicjujmy kilka pomocniczych zmiennych:

unsigned int distance = 0; // Aktualna odległość
unsigned long lastDistanceMeasurementTime = 0; // Czas wykonania ostatniego pomiaru odległości

Myślę, że nazwy zmiennych jak i komentarze do nich dołączone dobrze opisują do czego będziemy ich używali w programie.

Kiedy już mamy z głowy wszystkie biblioteki, stałe i zmienne warto jako pierwsze zaimplementować funkcje setup() oraz loop(). Dlaczego? Myślę, że dobrze kiedy zaraz na początku kodu źródłowego widać co jest wykonywane zaraz po uruchomieniu procesora jak i w głównej pętli programu.

void setup() {
  pinMode(BUZZER_PIN, OUTPUT);
  leds.begin();
}

Funkcja setup() jest w przypadku tego programu banalnie prosta. Jedyne co musimy w niej zrobić to zakomunikować Arduino, że pin do któego podłączony będzie buzzer jest wyjściem, a także wykonać pewne procedury przygotowujące liniję led do działania za pomocą metody begin() wykonanej na naszym globalnym obiekcie leds. Dlaczego nie musimy ustawić pinu TRIG jako wyjście, a ECHO jako wejście w przypadku czujnika HC-SR04? Odpowiedź na to pytanie jest prosta – robi to za nas biblioteka NewPing :)

void loop() {
  if(millis() - lastDistanceMeasurementTime >= MEASUREMENT_PERIOD) {
    distance = sonar.ping();
    distance = sonar.convert_cm(distance);
    lastDistanceMeasurementTime = millis();
  }
  
  led_displayDistance(distance);
  buzzer_beepDistance(distance);
}

Zajrzyjmy teraz pod maskę funkcji loop(), czyli tej która będzie wykonywała się przez zdecydowaną większość czasu działania Arduino.

Na samym początku mamy tutaj fragment kodu, który wykona się po spełnieniu takiego oto warunku:

if(millis() - lastDistanceMeasurementTime >= MEASUREMENT_PERIOD) {...}

Funkcja millis() zwraca nam liczbę milisekund, które upłynęły od początku działania programu. Załóżmy, że program działa już od 200 ms. Zmienna lastDistanceMeasurementTime ma akualnie wartość 0, tak jak to zadeklarowaliśmy parę linijek wyżej. Stała MEASUREMENT_PERIOD wynosi 200. W tym przypadku 200 – 0 jest większe/równe od 200 i warunek będzie spełniony. Wykona się pomiar, jego wynik w cm zapisze się do zmiennej distance, a zmienna lastDistanceMeasurementTime dostanie nową wartość równą aktualnemu millis(), czyli z dużym prawdopodobieństwem będzie to 200. Kolejny raz ten kod wykona się za następne 200 ms, kiedy w warunku będzie równanie 400 – 200 >= 200.

Pytanie: Dlaczego nie możemy użyć operatora == zamiast >=? Odpowiedź: W przypadku, gdy coś (np. odczyt z jakiegoś czujnika) zablokowałoby nasz procesor w momencie, kiedy warunek millis() - lastDistanceMeasurementTime == MEASUREMENT_PERIOD byłby spełniony nasz kod w warunku mógłby się nie wykonać przez kolejne 50 dni (okres przepełnienia pojemności zmiennej i początek liczenia millis() od nowa.

Poza warunkiem widzimy wywołanie 2 funkcji – led_displayDistance(distance) oraz buzzer_beepDistance(distance). Są to funkcje odpowiedzialne za graficzne reprezentowanie odległości na linijce LED oraz pikanie buzzerem w zależności od dystansu dzielącego samochód od przeszkody.

Aby funkcja led_displayDistance(distance) mogła działać będziemy potrzebowali 2 funkcji pomocniczych. Jedną z nich jest funkcja przeliczająca kolor HSV na RGB, a drugą funkcja wyłączająca dany pixel.

void led_setHue(int pixel, int hue) {
  hue = hue % 360;
  int r,g,b;
  if(hue < 60) {
    r = 255;
    g = map(hue, 0, 59, 0, 254);
    b = 0;
  } else if(hue < 120) {
    r = map(hue, 60, 119, 254, 0);
    g = 255;
    b = 0;
  } else if(hue <  180) {
    r = 0;
    g = 255;
    b = map(hue, 120, 179, 0, 254);
  } else if(hue < 240) {
    r = 0;
    g = map(hue, 180, 239, 254, 0);
    b = 255;
  } else if(hue < 300) {
    r = map(hue, 240, 299, 0, 254);
    g = 0;
    b = 255;
  } else {
    r = 255;
    g = 0;
    b = map(hue, 300, 359, 254, 0);
  }

  leds.setPixelColor(pixel, r, g, b);
}

void led_turnOff(int pixel) {
  leds.setPixelColor(pixel, 0, 0, 0);
}

Dzięki led_setHue(int pixel, int hue) możemy na danym pixelu ustawić wartość hue z przedzału 0-359 odpowiadającą kolorom od czerwonego (dla wartości 0) poprzez pomarańczowy, żółty, zielony (wartość 120), niebieski (240), różowy i znów na czerwonym kończąc – jednym słowem wszystkie kolory tęczy.

Ok, skoro mamy już wszystko co potrzebne to możemy zabrać się za implementację 2 kluczowych funkcji, które w sposób wizualny i dźwiękowy zasygnalizują użytkownikowi że pora już wciskać pedał hamulca.

Najpierw weźmy na warsztat sygnalizację graficzną:

void led_displayDistance(int distance) {
  int numLeds = map(distance, 0, TRIG_DISTANCE, LEDS_COUNT, 0);

  if(distance == 0) numLeds = 0;

  for(int x = 0; x < LEDS_COUNT; x++) {
    if(x < numLeds) {
      led_setHue(x, 120 - (120/LEDS_COUNT) * x);
    } else {
      led_turnOff(x);
    }
  }
  leds.show();
}

Pierwsze co widzimy to zmienna numLeds i jej wartość wylicozna przez funkcję map(). Jest to funkcja, która przelicza zadaną wartość (w naszym przypadku distance), z jednego przedziału na drugi.

Przykład: Wartość 50 zmierzoną w przedziale od 0 do 100 (np. wypełnienie w %) chcielibyśmy przeliczyć na wartość z przedziału od 0 do 255. Funkcja zwróci nam wartość 127, jako że jest on w połowie drugiego przedziału, tak jak 50 jest w połowie pierwszego przedziału.

W naszym przypadku chcemy przeliczyć wartość od 0 do TRIG_DISTANCE (czyli zakresu od którego nasz czujnik zacznie wskazywać, że jakiś obiekt jest w pobliżu) na wartości od LEDS_COUNT (czyli ilości diod) do 0. Należy zwrócić tutaj uwagę, że w tym przypadku dla wartości zmiennej distance równej 100 zapali się 0 diod i im dystans będzie się zmniejszał, tym więcej diod się zaświeci.

Biblioteka NewPing zwraca 0 w przypadku nieudanego pomiaru (np. pomiar poza zakresem). Dlatego też przyjmiemy, że jeżeli distance będzie równy 0 to nie zapalimy żadnej diody. Z resztą jeżeli odległość faktycznie wynosiłaby 0 to sygnalizacja świetlna myślę byłaby już zbędna :(

Z tego powodu dodajemy pod spodem następującą linijkę:

if(distance == 0) numLeds = 0;

Teraz czas na pętlę, która zaświeci odpowiednią ilość diod:

for(int x = 0; x < LEDS_COUNT; x++) {
    if(x < numLeds) {
      led_setHue(x, 120 - (120/LEDS_COUNT) * x);
    } else {
      led_turnOff(x);
    }
  }

Pętla wykona się tyle razy ile mamy diod w pasku (w tym przypadku 8). Jeżeli aktualny iterator pętli (zmienna x) będzie mniejsza niż liczba ledów do zapalenia, to zapalamy daną diodę na odpowiedni kolor. W przeciwnym wypadku gasimy diodę.

Pytanie: Co to za tajemniczy wzór: 120 - (120/LEDS_COUNT) * x? Odpowiedź: 120 jest to wartość hue odpowiadająca kolorowi zielonemu. Jeżeli podzielimy ją przez ilość diod, to uzyskamy wartość kroku, czyli o ile zmieni się kolor na kolejnej diodzie. Teraz mnożymy to przez pozycję aktualnej diody i każda świeci się dzięki temu na jakiś kolor pomiędzy czerwonym a zielonym. Obliczoną wartość odejmujemy od 120 dlatego, że dioda 0, która jest na początku otrzymałaby w ten sposób kolor czerwony, a chcemy kolor zielony.

Na końcu już tylko zatwierdzamy ustawienia i wysyłamy je do paska wykonując polecenie

leds.show();

Zajmijmy się teraz sygnalizacją dźwiękową:

void buzzer_beepDistance(int distance) {
  int beepsPerSecond = map(distance, 0, TRIG_DISTANCE, LEDS_COUNT, 0);
  
  if(beepsPerSecond == 0 || distance == 0) {
    digitalWrite(BUZZER_PIN, LOW);
    return;
  }

  int period = 1000 / beepsPerSecond;
  if(beepsPerSecond == LEDS_COUNT) {
      period = BEEP_TIME;
  }
  
  analogWrite(BUZZER_PIN, 128 * (millis() % period < BEEP_TIME));
}

Początek tej funkcji jest podobny do poprzedniej. Przeliczamy odległość na ilość beepnięć na sekundę.

Teraz, jeżeli głośniczek ma być cicho to ustawiamy na nim stan niski i kończymy działanie funkcji słówkiem kluczowym return.

if(beepsPerSecond == 0 || distance == 0) {
  digitalWrite(BUZZER_PIN, LOW);
  return;
}

Teraz możemy obliczyć okres działania buzzera (czas dźwięku + czas ciszy). Zakładając że czas ciszy będzie stały, to im krótszy będzie okres tym szybciej buzzer będzie pikał.

int period = 1000 / beepsPerSecond;

Jeżeli chcielibyśmy uzyskać stałe piszczenie (czyli np. jak wszystkie diody będą zapalone) wystarczy ustawić okres równy czasowi piknięcia.

if(beepsPerSecond == LEDS_COUNT) {
    period = BEEP_TIME;
}

Aby zapiszczeć buzzerem bez generatora musimy bardzo szybko zmieniać stan z wysokiego na niski. Im szybciej będziemy to robili tym wyższą częstotliwość dźwięku uzyskamy. Abyśmy nie musieli robić tego ręcznie z pomocą przychodzi nam PWM i funkcja analogWrite().

analogWrite(BUZZER_PIN, 128 * (millis() % period < BEEP_TIME));

k, ale co tutaj się dzieje? Otóż równanie (millis() % period < BEEP_TIME) będzie fałszem lub prawdą (0 lub 1). Wynik funkcji millis() dzielimy na równe odcinki równe okresowi brzęczenia naszego buzzera. Wynikiem tej operacji zawsze będzie liczba z przedziału 0 – period. Teraz sprawdzamy, czy liczba ta jest mniejsza od czasu brzęczenia buzzera.

Przykład: Okres wynosi 500 ms. Czas brzęczenia to 50 ms. Weźmy pod lupę 4 wartości zwracane przez millis():

  • 1500
  • 1510
  • 1560
  • 1900

Po wykonaniu na nich operacji modulo 500 otrzymamy kolejno takie wyniki:

  • 0
  • 10
  • 60
  • 400

Sprawdzając, czy każdy z nich jest mniejszy od 50 okaże się, że tylko dwa pierwsze spełniają warunek. Wynikami takiej operacji logicznej będą:

  • 1
  • 1
  • 0
  • 0

Teraz wyniki te mnożymy przez 128, które jest na początku wzoru i dostaniemy 128, 128, 0, 0. Takie wyniki oznaczają, że dla 2 pierwszych czasów buzzer będzie piszczał, a dla dwóch kolejnych nie. Stąd też na każde 500 ms działania programu przypada 50 ms brzęczenia i 450 ms ciszy.

Dlaczego 128? Jest to wartość odpowiadająca połowie wypełnienia, czyli przez cały okres stan niski będzie trwał tyle samo co stan wysoki.

Teraz możemy skompilować nasz kod, wgrać go na płytkę i przystąpić do testów :)

Cały projekt możemy pobrać jako archiwum ZIP lub z tego gista.