Arduino porady i przykłady

Większość osób zainteresowanych mikrokontrolerami na pewno słyszała o Arduino – platformie edukacyjnej zawierającej szereg płytek rozwojowych które w łatwy sposób można użyć we własnych projektach.

W sieci powstało szereg artykułów, przykładów, rozwiązań na ten temat.

W niniejszym dziale przedstawię kilka prostych aczkolwiek nieoczywistych porad które pomogą w rozpoczęciu programowania w Arduino:

   1. Funkcja delay - czyli jak i na co czekamy

   2. Obsługa przetwornika ADC - pomiar napięcia teoria i praktyka

   3. Obsługa termometru

 

1. Funkcja delay - czyli jak i na co czekamy

Często wykorzystywaną funkcją jest delay() - czyli poczekaj wybraną ilość milisekund.

Przykładowy szkic arduino mrugający wbudowaną diodą :

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);                    
  digitalWrite(LED_BUILTIN, LOW); 
  delay(1000);                    
}

Program ustawia wyjście kontrolera na 1 digitalWrite(LED_BUILTIN, HIGH); (dioda świeci) czeka sekundę  - delay(1000) potem ustawia na 0 dioda gaśnie i czeka sekundę. Kod zawarty w funkcji loop wykonuje się w kółko więc po zakończeniu kodu wykona się on od nowa.

Program jest bardzo prosty problem powstaje wtedy gdy chcemy zrobić coś dodatkowo. W tym programie przez większość czasu mikrokontroler czeka. Funkcja delay blokuje także wykonywanie kodu niektórych bibliotek np AsyncWeb Serwer.

Należałoby zamienić taką funkcje na funkcje która nie blokuje wykonywania innych czynności. do tego posłużymy się funkcją millis() Zwraca ona ilość milisekund od uruchomienia mikrokontrolera.

Przykładowy nieblokujący kod:

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);
}

unsigned long miliSekundy = 0;
bool stanLedy = HIGH;
void loop() {
  if (millis() - miliSekundy > 1000) {
    miliSekundy = millis();
    stanLedy = !stanLedy;
    digitalWrite(LED_BUILTIN, stanLedy);
  }
//.... pozostały kod programu
}

W tym kodzie sprawdzamy ile milisekund upłynęło od ostatniej zmiany i jak więcej niż 1000 to zmieniamy stan na odwrotny. Nie zatrzymuje to wykonywania kodu programu. Można w tym czasie wykonywać inne czynności.

Powstaje tutaj jednak dodatkowy problem często omijany w przykładach o którym chciałem wspomnieć. Funkcja millis() zwraca liczbę milisekund od czasu uruchomienia mikrokontrolera. Niby wszystko ok lecz w ardunino liczba unsigned long jest 32 bitowa czyli może zawierać liczby od 0 do 4 294 967 295.  Po jakim czasie licznik się wyzeruje ? 4294967295   ÷   1000(milisekund)   ÷   3600(sekund)   ÷   24(godziny)  =49,7 dnia. Co potem? W niektórych przypadkach nastąpi błędne działanie funkcji. Przykładowo zmienna miliSekundy ma wartość bliską końca licznika  np 4 294 967 290 i mamy poczekać 1000 milisekund więc wartość millis() powinna być większa od 4 294 968 290 niestety nigdy taka nie będzie bo się wyzeruje na liczbie  4294967295  i zacznie liczyć od nowa.

Jeżeli zastosujemy poprawną konstrukcję w zapytaniu tj  if (millis() - miliSekundy > 1000) zamiast if (millis() > miliSekundy + 1000)  to pomimo przepełnienia warunek zostanie poprawnie przeliczony.

Np.  czasPoczątku=4294967290;  czasBiezacy=1000;  to  czasBiezacy-czasPoczatku da wynik 1006 - czyli poprawny.

Zaś  czasPoczątku=2000;  czasBiezacy=1000;  to  czasBiezacy-czasPoczatku da wynik 4294966296 - czyli także poprawny ale jak widać teraz trzeba czekać 49 dni.

Gdy zastosujemy zły warunek dane będą niepoprawne i może on nigdy nie zostać spełniony.

Aby sprawdzić jaka jest różnica w działaniu możemy napisać prosty program testujący:

void setup() {
  Serial.begin(115200);
  delay(1000);
  unsigned long currentTime = 2000;
  unsigned long DELAY = 1000;
  unsigned long startTime = 4294957295;
  Serial.println();
  Serial.print("startTime:"); Serial.println(startTime);
  Serial.print("currentTime"); Serial.println(currentTime);
  Serial.print("DELAY:"); Serial.println(DELAY);
  Serial.println("1. PRAWIDŁOWA WERSJA:");
  Serial.print("currentTime-startTime>DELAY: "); Serial.print(currentTime - startTime);
  Serial.print(">"); Serial.print(DELAY); Serial.print(" ?"); Serial.println((currentTime - startTime > DELAY) ? "PRAWDA" : "FAŁSZ");
  Serial.println("2. ZŁA WERSJA:");
  Serial.print("currentTime>startTime+delay:"); Serial.print(currentTime);
  Serial.print(">"); Serial.print(startTime + DELAY);
  Serial.print(" ?"); Serial.println((currentTime > startTime + DELAY) ? "PRAWDA" : "FAŁSZ");
}

void loop() {delay(1);}

 Wynik możemy wyświetlić w okienku Monitora Portu szeregowego :

startTime:4294957295
currentTime:2000
DELAY:1000

1. PRAWIDŁOWA WERSJA:
currentTime-startTime>DELAY: 12001>1000 ?PRAWDA

2. ZŁA WERSJA:
currentTime>startTime+delay:2000>4294958295 ?FAŁSZ

 

 

2. Obsługa przetwornika ADC - pomiar napięcia teoria i praktyka 

 Często zachodzi potrzeba pomiaru wartości analogowych. np wartości prądu napięcia czy rezystancji np położenie potencjometru). Mikrokontrolery mają często wbudowany przetwornik ADC. Zamienia on wartość analogową napięcia na wartość cyfrową.

Mierząc napięcie  i stosując odpowiednie podłączenie możemy tez zmierzyć prąd rezystancję a także wyliczyć wartości pośrednie temperaturę moc wilgotność itp. itd.

Zanim jednak zaczniemy mierzyć należy przyjrzeć się co i jak się da zmierzyć.

  • Pierwsza ważna rzecz to rozdzielczość przetwornika przykładowo 8,10,12, 16 bitów.  mówi nam na ile "porcji" dzielony jest cały zakres pomiarowy. np . 8 bitowy przetwornik może mieć 256 stanów od 0 do 255.  10 bitowy ma 1024 stany,  12-4096 a 16 bit 65535  Im więcej bitów tym większa dokładność pomiaru.
  • Druga to zakres pomiarowy bezpośrednio związany z napięciem odniesienia czyli jakie maksymalne napięcie można podłączyć bezpośrednio do wejścia. NP 1.1V, 2V, 3.3V, 5V itp. W wielu mikrokontrolerach można je zmieniać w zależności od tego co jest potrzebne. Czasem mikrokontroler posiada kilka wewnętrznych źródeł odniesienia czasem można tez podłączyć zewnętrzne dokładniejsze źródło odniesienia o innym napięciu. Pamiętając, że nie może ono przekroczyć maksymalnego zakresu. Przykładowo wykorzystamy  wewnętrzne źródło odniesienia w procesorze Atmega 328 (np Arduno Nano)  o wartości 1,1V . Czasem napięciem odniesienia jest napięcie zasilania - należy unikać takiego przypadku ze względu na słabą stabilność takiego źródła. analogReference(INTERNAL);  W tym procesorze przetwornik jest 10 bitowy więc mamy 1024 kroki dla napięcia od 0 do 1,1V co daje 0,00107421875V na jedną działkę przetwornika np różnica odczytana pomiędzy wartością 0 a 1 będzie mniej więcej taka.
  • Trzecim ważnym parametrem są szumy przetwornika. Czyli jak zmienia się wartość mierzona np dla 12,34V (z dzielnikiem rezystancyjnym) wartość bezpośrednio odczytana na przetworniku waha się od 643 do 651
  • I wiele innych czynników wpływających na pomiar jak np zmiany temperatur, stabilność w czasie itd.

Aby mierzyć większe napięcie np 12V należy zastosować dzielnik złożony z rezystorów np:

 

 W przykładzie mierzone napięcie będzie dzieliło się na spadek na diodzie zabezpieczającej oraz na  dwóch rezystancjach R1 i R2.  Teoretycznie spadek na diodzie w pewnym zakresie napięć jest bardzo podobny więc można go tutaj pominąć. Zawsze można dodać spadek na diodzie do obliczeń.

Jeżeli na taki dzielnik podłączymy źródło zasilania o napięciu 17V podzieli się ono proporcjonalnie do rezystancji R1 i R2 i wyniesie 16V i 1V.

Znając wartości R1 i R2 oraz odczytując wartość z przetwornika możemy obliczyć mierzone napięcie :

#define V_REF 1.1
#define R1 16000
#define R2 1000
...  
  unsigned int adcValue = analogRead(analogInput);
  float napiecie = ((adcValue * V_REF) / 1024.0) / (R2 / (R1 + R2));
...

Znając dokładną wartość V_REF oraz rezystorów w łatwy sposób obliczymy wartość napięcia.

Takie rozwiązanie ma szereg wad. Przede wszystkim musimy znać dokładną wartość rezystancji. Rezystory mają rozbieżność w produkcji czyli powielając układ każdy kolejny będzie mierzył napięcie trochę inaczej. Aby skalibrować taki układ trzeba dokładnie zmierzyć rzeczywista rezystancję oraz ją wpisać do programu.  Aby zapewnić powtarzalność w produkcji konieczna jest kalibracja.  Można to zrobić za pomocą potencjometru i skalibrować układ ręcznie. Lecz po pewnym czasie może się zmienić jego wartość od drgań, brudu czy innych czynników. Też nie po to mamy układ cyfrowy aby stosować elementy analogowe.

Pierwszym rozwiązaniem jest zamiana w  kodzie rezystancje na napięcia.  R1 i R2 będą proporcjonalne tak samo jak spadki napięć na nich wobec czego na zmontowanym układzie możemy zmierzyć napięcia na rezystorach i podstawić do tego wzoru. Wynik będzie identyczny.

Jednak mierzenie napięć na jakichś dyskretnych elementach jest trochę trudne. Najlepszym rozwiązaniem jest wyrzucenie tego szkolnego wzoru i podejście do tematu w inny sposób.

Wartość odczytana z przetwornika jest wprost proporcjonalna do napięcia. np wartości 714 odpowiada rzeczywiste napięcie 12,26V można z prostej proporcji wyliczyć wartość napięcia dla innych wartości bez rozpatrywania napięć w dzielniku.

Należy napisać odpowiednią procedurę która powie sterownikowi teraz masz 12,12V on sam odczyta wartość z przetwornika i ma już dwie wartości niezbędne do obliczeń napięcia tj  wartości przetwornika i jakiemu napięciu ona odpowiada. Dane można zapisać w eepromie i taki układ jest już skalibrowany.

unsigned int V_RAW_ADC = 714;
unsigned long V_VOLTS = 1226;

unsigned int getVoltage()
{
  return (unsigned int)((analogRead(A0)* V_VOLTS) / V_RAW_ADC) ; 
}

Jak widać kod jest dużo prostszy. Zastosowałem tutaj prostą sztuczkę. Napięcia są liczbami zmiennoprzecinkowymi np 12.24. Dla łatwiejszego liczenia i obsługi (zapis odczyt zamian na string i z powrotem) zamieniamy je na int. 12,24 jest po prostu liczbą 1224  czyli 100* większą ale za to bez przecinków. Zawsze można z niej zrobić liczbę typu float dzieląc ją przez 100.0. Przykładowa kalibracja w aplikacji jest bardzo prosta i polega na wprowadzeniu jednej zmiennej bieżącego napięcia. (wartość przetwornika sterownik pobierze sam). Np:

Podczas pomiarów  mierzonego napięcia występują wahania mierzonej wartości np:

12.65

12.64

12.67

itd.. jest to normalne wynika z szumów i niedokładności przetwornika. Można temu zapobiec zacznijmy jednak od początku.

Stosowane przetworniki ADC w tanich układach nie są liniowe i pomiar obarczony jest pewnym błędem. Dodatkowo przetwornik taki ma swój własny szum czyli dla takiego samego napięcia odczytana wartość się zmienia.

Wbudowane napięcie odniesienia tez nie jest super stabilne w funkcji czasu in temperatury. I tu tez uwaga przykładowo  Arduino Nano może mieć napięcie odniesienia 1,1V i 5V - gdzie 1,1 jest z wewnętrznego źródła a 5 po prostu z zasilania. Przy napięciu odniesienia 1,1V będą bardziej widoczne szumy przetwornika zaś stabilność zasilania 5V jest też niekoniecznie duża.  Dokładność zwiększymy np dodając lepsze napięcie odniesienia o dość dużej wartości jednak mniejszej równej napięciu zasilania (5V). Jednak przy 10 bitowym przetworniku nie możemy się spodziewać zbyt dużego skoku jakościowego.

Poniżej tabelka z dokładnością odczytu z zakresu 20V dla różnych przetworników przy przykładowym napięciu 12,31V i wahaniu +/-1 i +/-2 działki przetwornika.

Przetwornik Zakres

Minimalna różnica

Rzeczywista

wartość napięcia

Wartość szumu +/-1

Wartość szumu+/-2

8 bit. 0-255 20/255=~0,078V 12,31V 12,232-12,388 12.154-12,466
10 bit. 0-1023 20/1023=~0,02V 12,31V 12,290-12,330 12,270-12,350
12 bit 0-4095 20/4095=~0,005 12,31V 12,305-12,315 12,300-12,320
16 bit 0-65535 20/65535=~0,0003 12,31V 12,3097-12,3103 12,3094-12,3106

Są to wartości teoretyczne nie uwzględniające wielu czynników np. większy zakres bitowy może powodować większe szumy przetwornika itp. Jest to tylko tabela obrazująca jak niedokładny jest to pomiar.

Warto się zastanowić czy na prawdę niezbędny jest nam pomiar 12,31V czy nie wystarczy informacja 12,3 ?  I tak mamy pewną niedokładność wynikającą z wielu czynników.

Przy standardowym przetworniku 10 bit dla zakresu 20V mamy +/-0,02V(0,04V) niedokładności tylko przy zmianie wartości odczytanej z przetwornika o +/-1 (np 629, 630, 631).  Wahania w takim przypadku będą od ok 12,29 do 12,33. uśredniając ta wartość do jednego miejsca po przecinku uzyskamy wartość 12,3 - co usunie nam w większości przypadków wahania pomiaru. Po prostu standardowym przetwornikiem 10 bit. nie da się dokładniej. Patrząc z tabelki idealny byłby 16 bitowy przetwornik ale tu też uwaga - dobrej jakości z małymi szumami.

Można próbować trochę odszumić cyfrowo odczytywane dane z przetwornika np. można pomiar uśrednić - odczytać np 100 wartości i wyciągnąć średnią czy medianę.

Rozwiązaniem na szumy przetwornika jest też zastosowanie np. biblioteki https://github.com/dxinteractive/ResponsiveAnalogRead.  W uproszczeniu likwiduje ona cyfrowo szumy z przetwornika jednak wskazany pomiar może mieć trochę większy błąd.

Można też coś podobnego zrobić samodzielnie - ustawić wartość różnicy powyżej, której zmieni się odczytana wartość np. odczytujemy napięcie 12,31V i dopóki nie zmieni się o więcej niż 0,04V to nie przepisujemy nowych wartości. Dodatkowo można dodać warunek czasu - odśwież po 10 sekundach nawet jak różnica wartości jest mniejsza niż wspomniane 0,04V

Podczas pomiaru napięć z przetwornika należy tez zwrócić uwagę aby nie wykonywać ich w każdej wolnej chwili w funkcji loop gdy mamy uruchomione biblioteki wymagające  nieblokowania jak np wspomniany ESP Async Web Server.

Poniżej kod przykładowego projektu uwzględniający powyższe uwagi:

 

// ANALLOG READ - (c)' 2021 ELFRO.pl Tomasz Fronczek

#define MINIMAL_VALUE_CHANGE 4  // 4- oznacza zmianę minimum o 0.04V
#define MINIMUM_VOLTAGE 20      // wartość poniżej której odczytywane napięcie traktowane jest jako 0V  np. 20=0.20V
#define FORCE_REFRESH_TIME 10000 // co ile milisekund odświeżać odczytywane napięcie pomimo braku zmian 0 - brak timera

unsigned int rawADC = 0;      // wartość odczytana z przetwornika
int currentRawVoltage = 0;    // wartość obliczona z rawADC

// kalibracja
unsigned int V_RAW_ADC = 630; // wartość z przetwornika
unsigned long V_VOLTS = 1235; // dla której jest napięcie w voltach *100
unsigned int DIODE=70;        // spadek napięcia na ewentualnej diodzie - tu 0,7V 

int currentVoltage = 0;       // napięcie w voltach do dalszej obsługi przez użytkownika *100 np 1234= 12,34V;
unsigned long forceVoltsUpdate = 0; // czas do aktualizacji
unsigned long sec = 0;        // czas co ile będzie sprawdzane napięcie

// czy jest rożnica w napięciu ? - można było użyć funkcji abs lecz ta funkcja ma możliwość zmiany wartości np na float
bool delta(int oldVal, int val, int result)
{
  int v = val - oldVal;
  if (v < 0) v = -v;
  return v > result;
}

// sprawdź napięcie - opcjonalny parametr FORCE - przepisze wartość napięcia na wyliczoną niezależnie od algorytmu
void checkVoltage(bool FORCE = false)
{
  bool force =  (millis() - forceVoltsUpdate >= FORCE_REFRESH_TIME  ) || FORCE;
  if (force) forceVoltsUpdate = millis();
  rawADC = analogRead(A0);
  if (V_VOLTS > DIODE) {
    currentRawVoltage = (int)((rawADC * (V_VOLTS - DIODE)) / V_RAW_ADC) + DIODE ;
    if (currentRawVoltage < MINIMUM_VOLTAGE + DIODE) currentRawVoltage = 0;
  }
  else {
    currentRawVoltage = (int)((rawADC * (V_VOLTS)) / V_RAW_ADC) ;
    if (currentRawVoltage < MINIMUM_VOLTAGE) currentRawVoltage = 0;
  }
  if (delta(currentVoltage, currentRawVoltage, MINIMAL_VALUE_CHANGE) || force) currentVoltage = currentRawVoltage;
}

void setup() {
  Serial.begin(115200);
  // jeżeli jest możliwość to trzeba skonfigurować napięcie odniesienia inne niż napięcie zasilania np :
  // analogReference(INTERNAL);
}

void loop() {
  if (millis() - sec > 1000)  // pętla wykonywana co sekundę
  {
    sec = millis();
    checkVoltage();
    Serial.println("----------");
    Serial.print("Dane z przetwornika: "); Serial.print(rawADC );
    Serial.print("V obliczone: "); Serial.print(currentRawVoltage);
    Serial.print("(int) "); Serial.print(currentRawVoltage / 100.0); Serial.println("V");
    Serial.print("V dla użytkownika  : "); Serial.print(currentVoltage);
    Serial.print("(int) "); Serial.print(currentVoltage / 100.0); Serial.println("V  !!!!");
    Serial.println();
  }
  // ..... pozostały kod aplikacji.
}

 

 3. Obsługa termometru 

 

Do odczytu temperatury często stosowany jest popularny układ DS18B20. Podłącza się go za pomocą magistrali OneWire.

Dokumentacja podaje że wg specyfikacji magistrali One Wire linia danych powinna być podciągnięta rezystorem 4,7 k. Próby wykazały że nie zawsze to działa dobrze i warto go trochę zmniejszyć. Dla 5V proponuję 3,6k a dla 3,3V 2,2k.

Magistrala one wire umożliwia podłączenie kilku termometrów.  Aby odczytać dane z termometru należy ustawić jego rejestr do odczytu poczekać 750 ms i odczytać zgromadzone dane. Warto jest rozwiązać to w sposób nieblokujący. (patrz pkt1.)

Jeżeli mamy kilka termometrów warto jest zapamiętywać ich adresy. Po ponownym włączeniu mogą one zostać wykryte w innej kolejności dlatego warto sprawdzić z którego urządzenia o jakim adresie jest temperatura.  Dodatkowo przy wymianie urządzenia dobrze jest aby program wykrył nowy termometr i zastąpił stary nieobecny.

Kolejną sprawą jest  częsty pomiar temperatury.  Jeżeli zrobimy to zbyt szybko (pomiar za pomiarem) pomiary mogą być zafałszowane. Termometr może się odrobinę rozgrzać od ciągłego mierzenia dlatego warto zastosować chwilę przerwy pomiędzy pomiarami.

Kompletny kod dla dwóch termometrów uwzględniający powyższe uwagi można pobrać stąd. Łatwo go przerobić do swoich potrzeb, zwiększyć ilość termometrów czy dodać obsługę po USB czy innej magistrali, dodać wyświetlacz itp.