Powrót

Kurs Arduino – OneWire i czujnik temperatury DS18B20

W dziesiątym odcinku kursu Arduino omówimy bardzo popularny czujnik temperatury DS18B20, który wykorzystuje cyfrowy interfejs OneWire (1Wire). I znów będziemy z powodzeniem walczyć z różnymi problemami, by się nimi nie zniechęcać.

W poprzednim odcinku kursu opanowaliśmy obsługę zegara czasu rzeczywistego w postaci modułu z kostką DS3231 i baterią podtrzymującą. Zanim przejdziemy do realizacji bardzo ważnego zagadnienia, mianowicie rejestracji danych, w tym odcinku poznamy i praktycznie wykorzystamy czujniki, których pominięcie dziwiło niektórych Czytelników kursu Arduino. Chodzi o DS18B20, które obok analogowych LM35 uznawane są za najpopularniejsze czujniki temperatury.

My zaczęliśmy od innych, moim zdaniem znacznie ciekawszych, zespolonych czujników temperatury i wilgotności, ale nie wypada w kursie Arduino pominąć łącza OneWire, a zapoznać się z nim najłatwiej właśnie na przykładzie popularnych czujników temperatury DS18B20. Ja do zestawu z poprzedniego ćwiczenia dodałem trzy takie czujniki, połączone „równolegle”. Końcówki DQ podłączone są do pinu A0 Arduino. Układ połączeń pokazany jest na rysunku 1, a fotografia 2 prezentuje mój model z trzema czujnikami owiniętymi drutem (lut cynowo-ołowiowy), by miały jednakową temperaturę.

Rysunek 1

Fotografia 2

Jeśli chodzi o stronę programową, to potrzebne będą dwie biblioteki. Jedna do obsługi łącza OneWire (1Wire), a druga, korzystająca z tej pierwszej, do obsługi czujników temperatury rodziny DS18xx (bo warto wiedzieć, że oprócz najpopularniejszej kostki DS18B20, dostępne są też inne kostki z tej rodziny, choćby starsza i gorsza DS18S20).

Jeżeli chodzi o biblioteki, znów mamy do wyboru szereg możliwości. Ja najpierw sprawdziłem na stronie www.github.com, co jest dostępne. Zwracałem uwagę na liczbę gwiazdek. Jeżeli chodzi o OneWire, bezapelacyjnie najpopularniejsza jest biblioteka, której autorem jest Paul Stoffregen, aktualnie dostępna w wersji 2.3.4. Z kolei zdecydowanie najpopularniejszą bibliotekę czujników temperatury DS18B20 stworzył Miles Burton (aktualna wersja na githubie to 3.8.0). W oparciu o te właśnie biblioteki realizowane są prawie wszystkie przykłady i kursy Arduino.

Ja zainstalowałem te biblioteki z poziomu ArduinoIDE, gdzie po wpisaniu w okienko wyszukiwania OneWire oraz DS18B20 proponowane są także inne biblioteki. Dla uniknięcia wątpliwości należy szukać w opisie nazwiska Stoffregen i Burton. Ja upewniłem się, że zostaną zainstalowane wersje najnowsze (rysunki 3, 4), dostępne na githubie.

Rysunek 3

Rysunek 4

Do prób wykorzystałem dołączony do biblioteki Dallas­Temperature szkic „Tester” (rysunek 5).

Rysunek 5

Otwórz oryginalny plik z biblioteki DallasTemperature (nie jest to plik z rozszerzeniem tester.ino, tylko ze starszym .pde, ale to nie ma znaczenia). Koniecznie zacznij od analizy tego oryginalnego szkicu bibliotecznego Tester.pde. Nie zniechęcaj się, jeśli napotkasz wiele zagadek. Zapoznanie się z treścią wpisów na forach pokazuje, że mniej zaawansowani mają poważne kłopoty nie tylko z plikiem Tester, ale i pozostałymi przykładowymi szkicami. Jedną z wątpliwości jest to, że funkcja printTemperature() jest zdefiniowana zaraz za „obowiązkową” funkcją setup(), co przy odrobinie nieuwagi sugeruje, iż jest częścią funkcji setup(). A tak nie jest, bo ta funkcja drukowania wartości temperatury nie jest wykorzystywana w funkcji setup, tylko w pętli loop(). I odwrotnie: na końcu umieszczona jest definicja funkcji printAddress(), wypisującej na ekranie konsoli odczytany adres – numer seryjny, a takie wypisywanie następuje na początku, w jednorazowej funkcji setup().

Szkic Tester.pde dzieli się na pięć części, pokazanych w szkicu 1, gdzie zielonym kolorem wyróżniłem dwie „obowiązkowe” funkcje Arduino.

Szkic 1:

#include <OneWire.h>
#include <DallasTemperature.h>
#define ONE_WIRE_BUS 2
#define TEMPERATURE_PRECISION 9
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
int numberOfDevices;
DeviceAddress tempDeviceAddress;
void setup(void)
{
Serial.begin(9600);
(…) tu dalszy kod funkcji setup()
Serial.print(„but could not detect address.
Check power and cabling”); } }
}

// function to print the temperature for a device
void printTemperature(DeviceAddress deviceAddress)
{ // tu najpierw  zakomentowana wersja wolniejsza
(…) potem „robocza”, szybsza wersja 2
// method 2 – faster
float tempC = sensors.getTempC(deviceAddress);
 Serial.print(„Temp C: „);
 Serial.print(tempC);
 Serial.print(” Temp F: „);
 Serial.println(DallasTemperature::toFahrenheit(tempC));
} // Converts tempC to Fahrenheit
void loop(void)
{
// call sensors.requestTemperatures() (…)
(…) tu ciało funkcji loop()
(…)
//else ghost device!
//Check your power requirements and cabling
}

// function to print a device address
void printAddress(DeviceAddress deviceAddress) {
 for (uint8_t i = 0; i < 8; i++)  {
  if (deviceAddress[i] < 16) Serial.print(„0”);
  Serial.print(deviceAddress[i], HEX);  } }

Na początku szkicu standardowo dołączamy biblioteki i tworzymy obiekty i zmienne globalne. Najpierw tworzymy obiekt na podstawie biblioteki OneWire, ale natychmiast niejako przekazujemy go dalej do tworzonego na podstawie biblioteki DallasTemperature obiektu czujnika, a właściwie obiektu dla wszystkich dołączonych czujników temperatury, który ma nazwę sensors.

Nie dziwi zmienna typu int o oczywistej nazwie numberOfDevices – w niej będziemy mieć liczbę znalezionych czujników. Ale niepokój może budzić deklaracja zmiennej o nazwie tempDeviceAddress. Pierwszy składnik nazwy temp pochodzi zapewne od temporary – tymczasowy, a nie od temperature, ale nie to jest dziwne. Problem w tym, że ma to być zmienna typu… DeviceAddress. W C++ nie ma takiego typu zmiennych. Jest to typ danych zdefiniowany przez programistę. Musimy sięgnąć do pliku nagłówkowego biblioteki DallasTemperature.h, by z linii:

typedef uint8_t DeviceAddress[8];

dowiedzieć się, iż zmienna tempDeviceAddress będzie po prostu ośmiobajtową tablicą.

Wątpliwości początkujących budzą też zakomentowane fragmenty funkcji printTemperature(). Rzeczywiście przyjęte rozwiązanie jest dziwne, ale nie trzeba tu niczego zmieniać.

Jeśli chcesz uruchomić oryginalny szkic Tester, to jedynie w linijce:

#define ONE_WIRE_BUS 2

musisz tylko podać aktualny numer pinu, do którego podłączone są czujniki DS18B20 (w moim przypadku A0).

Spróbuj to zrobić, a gdy skompilujesz, wgrasz program i otworzysz konsolę monitora portu szeregowego, na ekranie komputera zobaczysz jakieś napisy, ale przesuwające się zbyt szybko, żeby cokolwiek zrozumieć. Możesz dodać w pętli loop opóźnienie delay(). Wtedy na początku konsoli przeczytasz informacje pokazane na rysunku 6.

Rysunek 6

Zmodyfikuj i uruchom „biblioteczny” szkic Tester choćby dlatego, że jest on szeroko wykorzystywany w przykładach nie tylko w Internecie. Program ten budzi wiele pytań i wątpliwości, a jednocześnie jest popularny, więc warto poświęcić mu więcej uwagi.

Aby ułatwić taką dokładniejszą analizę, szkic ten został  spolonizowany i nieco uproszczony – jest dostępny tutaj = ZIP A10.zip.

Porównaj oryginalną wersję z plikiem A1001_Tester.ino. „Polonizacja” polegała nie tylko na tym, że angielskie komentarze zastąpiłem polskimi. Zauważ, że nazwy zmiennych, obiektów i funkcji też celowo zostały zmienione na polskie. Dzięki temu polskojęzyczny Czytelnik może łatwiej zrozumieć, co jest nazwą, która jest nadawana przez twórcę szkicu, a które określenia (np. typy zmiennych, nazwy klas) są narzucone przez zawartość bibliotek.

W szkicu 2 znajdziesz początkowe fragmenty „spolonizowanej” wersji. Definiujemy tu dwie etykiety: SZYNA_1WIRE, która zostanie użyta tylko raz i przypisujemy jej wartość A0 – numer pinu oraz ROZDZIELCZOŚĆ. Potem tworzymy obiekt o nazwie obiektOneWire klasy OneWire (zazwyczaj biblioteki są klasami). W programie będziemy go wykorzystywać niejako pośrednio, ponieważ zaraz w następnej linii na jego podstawie i z jego udziałem tworzymy obiekt o nazwie czujniki klasy (biblioteki) DallasTemperature.

Szkic 2:

#include <OneWire.h>
#include <DallasTemperature.h>
// wykorzystujemy pin A0 Arduino, więc od razu piszemy:
#define SZYNA_1WIRE A0
#define ROZDZIELCZOSC 12 // ustaw 9…12
// najpierw korzystamy z „ogólnej” biblioteki OneWire:
OneWire obiektOneWire(SZYNA_1WIRE);
// i zaraz wiążemy ją z obiektem biblioteki DallasTemperature
DallasTemperature czujniki(&obiektOneWire);
int liczbaCzujnikow; //to zmienna dla liczby czujników
//i zmienna typu DeviceAddress na znalezione adresy
DeviceAddress tmpAdresyCzujnikow;
 
void setup(void)   { //inicjalizacja
 Serial.begin(9600);
 Serial.println(„Program testowy termometrow Dallas”);
 czujniki.begin(); //sprawdź dołączone czujniki 1Wire
 Serial.println(„Wyszukujemy czujniki…”);
 liczbaCzujnikow = czujniki.getDeviceCount();
 Serial.print(„Znalezione czujniki: „);
 Serial.println(liczbaCzujnikow, DEC);
// sprawdzanie, czy jest zasilanie pasożytnicze
 Serial.print(„Zasilanie pasozytnicze jest „);
 if (czujniki.isParasitePowerMode()) Serial.println(„wlaczone”);
 else Serial.println(„wylaczone”);
 Serial.println();
// sprawdzam adresy/numery znalezionych czujników
 for(int i=0;i<liczbaCzujnikow; i++) {
  if(czujniki.getAddress(tmpAdresyCzujnikow, i))          {
   Serial.print(„Znalazlem czujnik „); Serial.print(i, DEC);
   Serial.print(” o adresie „);
   drukujAdres(tmpAdresyCzujnikow); Serial.println();
   Serial.print(„i ustawiam jego rozdzielczosc na „);
   Serial.print(ROZDZIELCZOSC, DEC); Serial.println(” bitow.”);
   czujniki.setResolution(tmpAdresyCzujnikow, ROZDZIELCZOSC);
  Serial.print(„Sprawdzilem, ze rozdzielczosc wynosi „);
   Serial.print(czujniki.getResolution(tmpAdresyCzujnikow),DEC);
   Serial.println(” bitow.”); Serial.println();
   }else{ Serial.print(„Znalazlem dziwne urządzenie numer „);
   Serial.print(i, DEC);
   Serial.print(” ale nie moge odczytac adresu/numeru.”);} } }

Na początku „jednorazowej” funkcji setup() dokonujemy inicjalizacji łącza szeregowego Serial oraz obiektu czujniki. Funkcja setup() będzie wypisywać na ekranie konsoli komputera różne informacje. Instrukcja:

liczbaCzujnikow = czujniki.getDeviceCount()

wpisze do zmiennej liczbaCzujnikow liczbę czujników dołączonych do pinu A0. Instrukcja

if (czujniki.isParasitePowerMode())
 Serial.println(„wlaczone”);
 else Serial.println(„wylaczone”);

sprawdzi, czy przynajmniej jeden czujnik dołączony jest za pomocą tylko dwóch, a nie trzech przewodów. Tu trzeba sięgnąć do karty katalogowej kostki DS18B20. Oprócz klasycznego, trzyprzewodowego dołączenia według rysunku 7a, producent sprytnie przewidział możliwość dołączenia dwuprzewodowego według rysunku 7b, gdzie zasilanie i transmisja danych realizowane są w jednej linii. Problem w tym, że w systemie OneWire wykorzystuje się wyjścia z otwartym kolektorem/drenem, które wymuszają stan niski, natomiast stan wysoki wymuszany jest przez rezystor włączony między dodatnią szynę zasilania a linię danych DQ.

Rysunek 7

Dlatego zasadniczo przy zasilaniu pasożytniczym (parazytowym) wszystkie dołączone układy są zasilane przez ten jeden rezystor o znacznej wartości (zwykle 4,7kΩ). Układy z interfejsem OneWire przez większość czasu pobierają znikomy prąd i spadek napięcia na rezystorze „zasilającym” jest mały. Jednak czujniki DS18B20 podczas pomiaru temperatury oraz podczas zapisywania danych do pamięci EEPROM pobierają znaczny prąd, rzędu 1mA, maksymalnie 1,5mA. Jeden miliamper to niezbyt duży prąd, ale gdy rezystor ma 4,7 kilooma, wywołałby spadek napięcia prawie 5V, a napięcie zasilające kostki spadłoby poniżej 1 wolta, co uniemożliwiłoby pracę. Dlatego przy interfejsie dwużyłowym i pasożytniczym zasilaniu producent zaleca zastosowanie na czas przetwarzania i zapisu pamięci EEPROM „silnego podciągania” linii danych (strong pull-up). Zilustrowane jest to na rysunku 7c. Tu do realizacji podciągania proponuje się dodatkowy MOSFET P, ale wystarczająco silne podciąganie można zrealizować, gdy pin pracujący jako klasyczne wyjście ustawiony jest w stan wysoki.

Rysunek 8

Jak pokazuje rysunek 8, przy zasilaniu 5V, typowo przy prądzie wyjściowym 15mA, zmniejszenie napięcia nie przekroczy 0,5V. Oznacza to, że bez zewnętrznego tranzystora pin pracujący jako wyjście może obsłużyć co najmniej 10 kostek DS18B20 pracujących w trybie pasożytniczym. Twórcy bibliotek OneWire i DallasTemperature zatroszczyli się o to, żeby taka możliwość była dostępna, i to bez żadnej ingerencji ze strony piszącego szkic.

Sprawdźmy to!

Najpierw w układzie z rysunku 1 skompiluj, wgraj szkic A1001_Tester.ino, a potem otwórz konsolę monitora. U mnie ekran komputera wyglądał jak na rysunku 9.

Rysunek 9

Program wykrył (czerwona strzałka), że do szyny nie dołączono czujników w sposób dwuprzewodowy i zasilanie pasożytnicze jest wyłączone. A teraz odłącz kabel USB i wyprowadzenia VDD kostek DS18B20 dołącz do masy według rysunku 10 i fotografii 11.

Rysunek 10

Fotografia 11

W Arduino masz wgrany szkic A1001_Tester.ino. Zamknij monitor portu szeregowego, podłącz kabel USB do Arduino i ponownie uruchom monitor. Wtedy bez żadnej ingerencji w program zobaczysz (rysunek 12), że wykrył on podłączone pasożytniczo czujniki i prawidłowo je obsłużył. Zawdzięczamy to dobrze napisanym bibliotekom OneWire i Dalas­Temperature.

Rysunek 12

Wracamy do analizy szkicu 2. Wcześniej w programie setup(), do zmiennej liczbaCzujnikow, za pomocą niewidocznych tu procedur bibliotecznych, została wpisana liczba czujników znalezionych podczas „odpytywania” szyny OneWire. W dalszej części programu te czujniki zostaną dodatkowo „odpytane” w pętli for z wykorzystaniem bibliotecznej metody/funkcji .getAddress(). Idea jest jasna: mamy liczbę czujników, które są poniekąd ponumerowane 0, 1, 2… (w rzeczywistości nie są ponumerowane, tylko są „znajdowane przy odpytywaniu w określonej kolejności”). Mamy więc numer kolejny czujnika 0, 1, 2…, który nazywany jest indeksem. I za pomocą metody/funkcji .getAddress() mamy na podstawie tego indeksu odczytać z czujnika jego ośmiobajtowy adres, numer fabryczny i ten adres wyświetlić w postaci szesnastkowej (HEX). Osiem bajtów da szesnaście cyfr szesnastkowych. Na rysunkach 6, 9 i 12 widzimy, że adresy – numery fabryczne czujników temperatury Dallas zaczynają się od 28. Dalej mamy dwanaście cyfr właściwego numeru (liczba dwójkowa 48-bitowa), a dwie ostatnie cyfry (ostatni bajt) to suma kontrolna (CRC)

Tu znów u mniej zaawansowanych mogą powstać wątpliwości. Otóż z tego, co wiemy o funkcjach języka C (i C++), funkcja zwraca wartość. Można byłoby się więc spodziewać, że do funkcji .getAddress() jako argument przekażemy indeks, czyli znaleziony numer kolejny, a funkcja zwróci adres – numer fabryczny i wpisze go do zmiennej tmpAdresyCzujnikow. W sumie dokładnie tak się właśnie dzieje, ale po pierwsze zmienna tmpAdresyCzujnikow w rzeczywistości jest tablicą (ośmio)bajtową, po drugie .getAddress() to metoda klasy DallasTemperature, a więc specyficzna funkcja. Jeśli zajrzymy do tej biblioteki, a konkretnie do pliku DallasTemperature.cpp, to znajdziemy definicję tej metody – jest pokazana w szkicu 3.

Szkic 3:

// finds an address at a given index on the bus
// returns true if the device was found
bool DallasTemperature::getAddress(uint8_t* deviceAddress, uint8_t index) {
… tu definicja metody/funkcji…   }

Jak widać, ta funkcja-metoda zwraca wartość, ale nie adres, tylko wartość logiczną (bool), a konkretnie true – prawda, gdy czujnik o danym indeksie zostanie znaleziony. Natomiast adres – numer fabryczny jest umieszczany w tablicy tmpAdresyCzujnikow, ale jako wspomniany kiedyś „efekt uboczny”.

W każdym razie w kolejnych przejściach-obiegach pętli for, do tej samej zmiennej tablicowej tmpAdresyCzujnikow są wpisywane adresy – numery seryjne kolejnych czujników. W każdym obiegu pętli z kolejnej kostki pobierany jest numer seryjny, wyświetlany przy użyciu funkcji drukujAdres(), zdefiowanej nie w bibliotece, tylko dalej w naszym szkicu A1001_Tester.ino, a w Tester.pde i w szkicu 1 ma ona nazwę printAddress().

Program nie tylko wypisuje szesnastocyfrowy adres – numer fabryczny, ale też za pomocą metody .setResolution() ustawia zapisaną na początku szkicu rozdzielczość pomiaru temperatury (9…12 bitów), a potem za pomocą metody .getResolution() sprawdza, odczytuje z kostki, czy rozdzielczość  została ustawiona. Przy tych operacjach wykorzystuje adres, zawarty w zmiennej tmpAdresyCzujnikow. Wypisuje też na ekranie stosowne komunikaty.

Wszystko to robione jest jednorazowo w funkcji setup(). Natomiast operacje cykliczne wykonywane są w pętli loop(), co pokazuje szkic 4. Na początku każdego obiegu pętli wykonywana jest metoda biblioteki DallasTemperature o nazwie .requestTemperatures()

Szkic 4:

void loop(void) { //rozkaz do wszystkich czujników:
 czujniki.requestTemperatures(); Serial.println();
//potem dla każdego czujnika wypisz jego temperaturę
 for(int i=0;i<liczbaCzujnikow; i++)
 { // najpierw odczytaj adres kolejnego czujnika
  if(czujniki.getAddress(tmpAdresyCzujnikow, i))
  { // i podaj wynik pomiaru
  Serial.print(„Czujnik „);
  Serial.print(i,DEC); Serial.print(„: „);
//wykorzystujemy funkcję drukujTemperature
  drukujTemperature(tmpAdresyCzujnikow);  }  }
  delay(10000); } // czekaj 10 sekund

Kto lepiej zna kostki DS18B20, wie, że oznacza to wysłanie na szynę rozkazu Convert T (o kodzie 44h), który powoduje, że wszystkie dołączone do szyny czujniki przeprowadzają procedurę pomiaru temperatury i zapisują wynik pomiaru w swoich pamięciach. W kostce DS18B20 zawarta jest 9-bajtowa ulotna pamięć RAM (Scratchpad), z której trzy bajty, zawierające dane konfiguracyjne i alarmy temperatury, mogą być trwale zapisane w (odczytane z) nieulotnej pamieci EEPROM.

Warto wspomnieć, że ustawiona wcześniej rozdzielczość pomiaru temperatury (9…12 bitów, co odpowiada: 0,5°C, 0,25°C, 0,125°C i 0,0625°C) wpływa na czas wykonywania programu. Mianowicie czym większa rozdzielczość, tym dłużej czujnik mierzy temperaturę. Przy rozdzielczości 9 bitów maksymalny czas pomiaru (konwersji) to 94ms, a przy rozdzielczości 12 bitów 750ms, czyli niemalże sekundę.

Rozkaz Convert T powoduje tylko, że po dość długim czasie konwersji, w pamięci poszczególnych czujników pojawią się wyniki aktualnego pomiaru. Potem odczytujemy te wyniki z kolejnych czujników, wykorzystując indeksy. W naszym szkicu 4 wygląda to bardzo prosto. Wykorzystujemy pętlę for, która odczyta wyniki na podstawie indeksów. W kolejnych obiegach pętli podajemy kolejne indeksy wcześniej znalezionych czujników (0, 1, 2, …)

i za pomocą omówionej już metody .getAddress() w tymczasowej zmiennej tmpAdresyCzujnikow uzyskujemy adres – numer fabryczny kolejnego czujnika. Adres ten przekazujemy jako argument do zdefiniowanej w szkicu funkcji:

drukujTemperature(tmpAdresyCzujnikow)

która w szkicu 1 nazywa się printTemperature(). I drukujemy z efektem pokazanym na rysunkach 9, 12.

Cieszymy się, że w sumie program jest tak prosty, ale ta prostota kosztuje i może okazać się poważną wadą. Szkic 4 sugeruje, iż wszystko realizowane jest prosto, łatwo i szybko. W rzeczywistości nie jest tak dobrze. Dzięki gotowym bibliotekom nie widzimy problemu, jednak warto znać niektóre szczegóły. Otóż w prezentowanych szkicach nie zapamiętujemy na stałe adresów-numerów fabrycznych, tylko korzystamy z indeksów, czyli numerów kolejnych przypisanych czujnikom (nie tylko) podczas inicjalizacji. Jednak w rzeczywistości nie można wprost odczytać temperatury czy zrealizować innych funkcji tylko na podstawie indeksu. Tak naprawdę, indeksy… nie istnieją, nie są nigdzie zapisywane.

Żeby było jeszcze ciekawiej, w tym rozwiązaniu nie są też nigdzie zapisywane adresy – numery fabryczne, a bez adresu nie można skomunikować się z czujnikiem.

Wykorzystana tu koncepcja opiera się na specyficznej zasadzie, wynikającej z podstawowych właściwości łącza OneWire i koncepcja ta realizowana jest w dość żmudny sposób. Mianowicie do linii OneWire może być dołączonych wiele urządzeń z tym interfejsem. Urządzenie nadzorujące, mikroprocesor może kontaktować się z dowolnym urządzeniem, mówiąc najprościej, przez podanie sygnału startu, adresu i rozkazu. Może do urządzenia o znanym mu adresie albo wysłać rozkaz lub dane, a może też z urządzenia dane odczytać. Niezbędny jest do tego adres – numer fabryczny.  Problem w tym, że kupując czujnik DS18B20 albo inny układ scalony z interfejsem OneWire, zwykle nie znamy jego numeru seryjnego.

Nie szkodzi! Twórcy interfejsu OneWire dodali bardzo pożyteczne funkcjonalności. Wysyłając w linię odpowiednie sygnały można sprawdzić, czy w ogóle dołączone są jakieś urządzenia z interfejsem OneWire i czy któreś z nich są zasilane pasożytniczo. Ale dla nas teraz najważniejsze jest to, że urządzenie nadzorujące może też sprawdzić, ile i jakich urządzeń dołączono do linii. Polega to na wspomnianym wcześniej „odpytywaniu”. W rzeczywistości polega to na dość żmudnym sprawdzaniu poszczególnych 64 bitów adresu, a raczej adresów dołączonych do linii urządzeń. Szczegóły są bardzo interesujące, ale my tu możemy skrótowo powiedzieć, że urządzenie nadzorujące niejako wysyła do wszystkich zapytanie, czy są urządzenia, mające w swym 64-bitowym  adresie dany bit. Potem pyta o następny bit, i tak w sumie 64 razy.

Na początku takiej procedury odpytywania wszystkie urządzenia nasłuchują i odpowiadają, jaką wartość mają kolejne bity ich adresu. Dzięki obecności rezystora podciągającego na linii realizowana jest funkcja logiczna OR, czyli suma odpowiedzi, a to pozwala automatycznie odczytać adres, powiedzmy „najwyższy” (gdy spojrzymy na rysunki 6, 9, 12, znalezione adresy ułożone są od największych do najmniejszych, ale ściślej biorąc, w grę wchodzi kolejność bitów i bajtów). W kolejnym przebiegu znów odpytywane są urządzenia, ale to ze znalezionym już wcześniej numerem najwyższym jest już nieaktywne i nie odpowiada. W drugim sprawdzaniu 64 bitów odnajdowany jest drugi w kolejności „najwyższy” adres. Takie 64-bitowe „odpytywania” realizowane są dotąd, aż wszystkie dołączone urządzenia podadzą swoje adresy i wszystkie staną się nieaktywne. Wtedy po wysłaniu zapytania nikt nie odpowiada i wiadomo, ile znaleziono urządzeń OneWire.

Jeśliby kolejno odnajdowane 64-bitowe adresy były gdzieś zapisywane, można byłoby ich użyć do późniejszej komunikacji. Ale w analizowanych szkicach jest inaczej. W funkcji setup() owszem, odczytujemy kolejne ośmiobajtowe adresy, ale nie są one zapamiętywane, bo trafiają jedynie do tymczasowej ośmiobajtowej zmiennej tmpAdresyCzujnikow, a po wykorzystaniu są tracone. Potem trzeba je odczytywać od nowa za pomocą metody .getAddress(), która za każdym razem od nowa realizuje żmudną procedurę „odpytywania”.

Sprawa ma kilka aspektów. Po pierwsze mikroprocesor jest szybki, „odpytywanie” też jest stosunkowo szybkie (wielokrotnie szybsze niż pomiar temperatury, który jak wiemy może trwać do 0,75 sekundy, co dla mikroprocesora taktowanego zegarem 16MHz jest wręcz wiecznością). Czyli problem marnotrawienia zasobów przez niepotrzebne wielokrotne „odpytywanie” w ogromnej większości przypadków nie jest istotny.

Owszem, ale czasem chcemy minimalizować i objętość programu, i czas jego realizacji, a wtedy zamiast nieustannego odpytywania za pomocą .getAddress(), należałoby zastosować rozwiązanie, gdzie raz znalezione adresy byłyby zapamiętane i wykorzystane do kontaktów w czujnikami. Ale szczerze mówiąc, Arduino to system o uproszczonej obsłudze, co okupione jest dużą rozrzutnością pod wieloma względami. Minimalizacja i optymalizacja to pojęcia niezbyt pasujące do Arduino. Dlatego kwestię „nieustannego odpytywania” można byłoby całkowicie pominąć.

Jednak jest drugi, bardzo ważny aspekt zagadnienia. Otóż „odpytywanie” realizowane jest według ściśle określonego algorytmu i adresy – numery fabryczne znajdowane są zawsze w tej samej kolejności, powiedzmy od adresów „najwyższych” do „najniższych”.

Zwróć uwagę, że w funkcji setup() najpierw za pomocą funkcji-metody .getDeviceCount() określana jest jedynie liczba czujników (urządzeń OneWire) w naszym systemie. Jeżeli zajrzymy do plików bibliotecznych DallasTemperature.h i DallasTemperature.cpp, metoda .getDeviceCount() okaże się zaskakująco prosta – zwraca tylko zawartość pobieraną z „wewnętrznej zmiennej” (prywatnego pola obiektu) o nazwie devices. A tam zawarta jest liczba dołączonych urządzeń z interfejsem OneWire, która jest określana podczas inicjalizacji za pomocą funkcji-metody .begin().

W naszych programach nie znamy indywidualnych 8-bajtowych adresów dołączonych czujników. Ale możemy się odwoływać do kolejnych czujników niejako za pomocą indeksów nadanych podczas inicjalizacji. A te indeksy to po prostu kolejność, w jakiej przy każdym „odpytywaniu” znajdowane są czujniki o adresach od powiedzmy „najwyższego” do „najniższego”. Czy już widzisz problem?

Cieszymy się, że do obsługi czujników nie są potrzebne ich fabryczne 8-bajtowe (64-bitowe numery), bo można do czujników odwoływać się za pomocą indeksu, czyli kolejności znajdowania przy „odpytywaniu”. Niestety, pojawi się problem, gdy czujnik zostanie wymieniony, dodany lub usunięty. Wtedy na początku pracy programu, podczas inicjalizacji i przy każdym „odpytywaniu”, określone zostaną „nowe” indeksy, bo najprawdopodobniej inna będzie kolejność znajdowanych adresów i wtedy powstanie straszny bałagan, bo w najgorszym przypadku mogą zmienić się indeksy wszystkich czujników (wszystko zależy, jaka będzie nowa kolejność adresów).

Przyjęte rozwiązanie z wykorzystaniem indeksów, czyli kolejności znajdowania adresów, jest więc dobre do testów, ale do praktycznych zastosowań może się okazać zupełnie nieakceptowalne. Aby uniknąć ryzyka bałaganu, można wstępnie odczytać adresy – numery fabryczne posiadanych czujników, co my robimy w funkcji setup(), a następnie te adresy wprowadzić na stałe do programu i obsługiwać czujniki za pomocą tych zapamiętanych adresów, a nie na podstawie indeksu. Bałaganu wtedy nie będzie, ale z kolei przy wymianie czy dodaniu czujnika trzeba będzie określić i wprowadzić do programu jego fabryczny adres. Może to wymagać ponownego programowania mikroprocesora przy każdej zmianie czujnika. Można też tak napisać program, żeby uniknąć konieczności ponownego programowania.

W elementarnym kursie Arduino w dalsze szczegóły nie będziemy się zagłębiać. Pozostawiam to zainteresowanym do samodzielnego zbadania. Wspomnę tylko, że w przypadku dołączenia do linii jednego tylko czujnika można wykorzystać przewidziane do tego uproszczone rozkazy, gdzie nie potrzeba adresu – numeru fabrycznego. W pewnych sytuacjach może to być atrakcyjna alternatywa: można kilka czujników dołączyć do kilku pinów Arduino, co pozwoli uniknąć problemu adresów. Ma to zalety, ale i wady, bo zajmuje kolejne piny i wymaga w programie oddzielnego obiektu dla każdego pinu i czujnika. W każdym razie warto pamiętać i o takiej możliwości.

Jak pokazują rysunek 1 i fotografia 2, w naszym systemie mamy czytnik kart pamięci i zegar RTC, bo dążymy do budowy rejestratora danych. Zmierzając pomału do tego celu, wróćmy jeszcze raz do naszych programów. Szkic 5 pokazuje wersję znacznie uproszczoną, zawartą w pliku A1002.ino, której efekty działania pokazane są na rysunku 13.

Rysunek 13

Przeanalizuj, na czym polegają uproszczenia względem wcześniejszych wersji.

Szkic 5:

#include <OneWire.h>
#include <DallasTemperature.h>
OneWire obiektOneWire(A0); //wykorzystujemy pin A0 Arduino
DallasTemperature czujniki(&obiektOneWire);
int liczbaCzujnikow; DeviceAddress tmpAdresyCzujnikow;
 
void setup(void)   { //inicjalizacja obiektów
 Serial.begin(9600);  czujniki.begin();
 liczbaCzujnikow = czujniki.getDeviceCount();
 Serial.print(„Znalezione czujniki: „);
 Serial.println(liczbaCzujnikow, DEC); Serial.println();
//kolejno sprawdzamy adresy/numery znalezionych czujników:
 for(int i=0;i<liczbaCzujnikow; i++) { //w pętli
  if(czujniki.getAddress(tmpAdresyCzujnikow, i))        {
   Serial.print(„Znalazlem czujnik „); Serial.print(i, DEC);
   Serial.print(” o adresie „);
   drukujAdres(tmpAdresyCzujnikow); Serial.println(); } } }
 
void loop(void)  {  //rozkaz do wszystkich czujników:
 czujniki.requestTemperatures(); Serial.println();
//potem dla każdego czujnika wypisz jego temperaturę
 for(int i=0;i<liczbaCzujnikow; i++)   {
  czujniki.getAddress(tmpAdresyCzujnikow, i);
          Serial.print(„Czujnik „);
          Serial.print(i,DEC); Serial.print(„: „);
          drukujTemperature(tmpAdresyCzujnikow);  }
  delay(10000); } // czekaj 10 sekund
 
// oddzielna funkcja do wypisania temperatury na ekranie
void drukujTemperature(DeviceAddress adres) {
 float tempC=czujniki.getTempC(adres); Serial.print(tempC);
 Serial.print(„\xC2\xB0”); Serial.print(„C „); }
 
// i funkcja wypisująca na ekranie adres
void drukujAdres(DeviceAddress adresik) {
  for (uint8_t i = 0; i < 8; i++)   {
    if (adresik[i] < 16) Serial.print(„0”);
    Serial.print(adresik[i], HEX); } }

A potem przejdź do wersji jeszcze bardziej uproszczonej, którą znajdziesz w szkicu 6 i w pliku A1003.ino, której efekty działania pokazane są na rysunku 14.

Rysunek 14

Znów poświęć czas na analizę, co usunęliśmy  bez szkody dla działania podstawowej funkcjonalności programu.

Szkic 6:

#include <OneWire.h>
#include <DallasTemperature.h>
OneWire obiektOneWire(A0); //wykorzystujemy pin A0
DallasTemperature czujniki(&obiektOneWire);
int liczbaCzujnikow; DeviceAddress tmpAdresyCzujnikow;
 
void setup(void) { Serial.begin(9600); czujniki.begin();
 liczbaCzujnikow = czujniki.getDeviceCount(); }
 
void loop(void)  { czujniki.requestTemperatures();
 for(int i=0;i<liczbaCzujnikow; i++)   {
  czujniki.getAddress(tmpAdresyCzujnikow, i);
  float tempC = czujniki.getTempC(tmpAdresyCzujnikow);
  Serial.print(„Czujnik „);   Serial.print(i,DEC);
  Serial.print(„: „); Serial.print(tempC);
  Serial.print(„\xC2\xB0”); Serial.print(„C; „); }
  Serial.println(); delay(10000); }

Tę krótką wersję wzbogacimy, by uzyskać na ekranie także czas pomiaru odczytywany z zegara RTC. Możemy wykorzystać szkice z poprzedniego odcinka, a konkretnie szkic A0904.  Po połączeniu szkicu A1003 z A0904 otrzymujemy wersję A1004.ino, pokazaną w szkicu 7.

Szkic 7:

#include <Wire.h> // biblioteka I2C
#include <RtcDS3231.h> // biblioteka dla DS3231
#include <OneWire.h> //biblioteka OneWire
#include <DallasTemperature.h> //bibloteka DS18B20
RtcDS3231<TwoWire> zegar(Wire); OneWire obiektOneWire(A0);
DallasTemperature czujniki(&obiektOneWire);
int liczbaCzujnikow; DeviceAddress tmpAdresyCzujnikow;
 
void setup(void) {zegar.Begin();Serial.begin(9600);czujniki.begin();
 RtcDateTime czasKompilacji = RtcDateTime(__DATE__, __TIME__);
 if (!zegar.IsDateTimeValid()) zegar.SetDateTime(czasKompilacji);
 if (!zegar.GetIsRunning())   zegar.SetIsRunning(true);
 RtcDateTime czasAktualny = zegar.GetDateTime(); 
 if(czasAktualny<czasKompilacji) zegar.SetDateTime(czasKompilacji);
 liczbaCzujnikow = czujniki.getDeviceCount();  }
 
void loop(void)  {  czujniki.requestTemperatures();
 RtcDateTime czasAktualny = zegar.GetDateTime();
 printDateTime(czasAktualny);  Serial.print(” „);
 for(int i=0;i<liczbaCzujnikow; i++)   {
 czujniki.getAddress(tmpAdresyCzujnikow, i);
 float tempC = czujniki.getTempC(tmpAdresyCzujnikow);
 Serial.print(„T”);   Serial.print(i,DEC);  Serial.print(„: „);
 Serial.print(tempC);Serial.print(„\xC2\xB0”);Serial.print(„C; „);}
 Serial.println(); delay(10000); } // czekaj 10 sekund
 
//poniżej definicja funkcji formatującej i drukującej czas i datę
#define countof(a) (sizeof(a) / sizeof(a[0]))
void printDateTime(const RtcDateTime& dt) { char datestring[26];
 snprintf_P(datestring, countof(datestring),
 PSTR(„Czas %04u.%02u.%02u %02u:%02u:%02u;”),
 dt.Year(),dt.Month(),dt.Day(),dt.Hour(),dt.Minute(),dt.Second());
 Serial.print(datestring); } //wyślij do konsoli

Efekty jej działania widoczne są na rysunku 15.

Rysunek 15

Jesteśmy już bardzo blisko rejestratora danych, ale zrealizujemy go w następnym odcinku. A wcześniej chciałbym zachęcić Cię do jeszcze dokładniejszego zapoznania się z czujnikami DS18B20, choćby za pomocą innych przykładowych programów dostarczonych z biblioteką DallasTemperature.

Jak widać na rysunkach 6, 9, 12, 13, 14, 15, moje trzy czujniki (pochodzące ze sklepu AVT) mają różne wskazania, ale różnice między nimi są stałe. Wszystko wskazuje, że z powodzeniem mieszczą się one w deklarowanej przez producenta dokładności: w zakresie temperatur od 10°C do +85°C dokładność nie jest gorsza niż ±0,5°C. Zwróć uwagę, że odczytywane wartości temperatury nie zmieniają się w czasie, co wskazuje na niski poziom szumów i dobrą stabilność. A to jak najbardziej pozwala wprowadzić indywidualne poprawki dla poszczególnych czujników. Inna kwestia, jak takie czujniki skalibrować? Otóż  jedyną „amatorską” metodą jest wykorzystanie mieszaniny wody z lodem (wody destylowanej lub demineralizowanej). Ale w praktyce wcale nie jest to takie proste.

To poprawi wskazania, ale niestety nie gwarantuje wysokiej dokładności w szerokim zakresie temperatur i przez dowolnie długi czas. Nawet przy uwzględnieniu indywidualnej poprawki dokładność w szerszym zakresie temperatur będzie zależeć od liniowości przetworników i ich dryftu związanego m.in. ze starzeniem.

Rysunek 16

Rysunek 16 pokazuje typowy zakres błędu czujników DS18B20. A jeśli chodzi o starzenie, w karcie katalogowej konkretnych informacji nie ma, jest tylko wzmianka, że 1000-godzinny stres w temperaturze +125°C typowo powoduje błąd (dryft) w zakresie ±0,2°C.

Czujniki DS18B20 idealne nie są, ale przy odrobinie wysiłku pozwalają zrealizować naprawdę dokładne pomiary temperatury. W Internecie można znaleźć sporo informacji na temat realizacji dokładnych termometrów z ich wykorzystaniem. Zachęcam do samodzielnych poszukiwań oraz eksperymentów. A teraz…

Podsumowanie

Jak na razie, wstępnie poznaliśmy praktyczne sposoby pomiaru czasu za pomocą modułu RTC i sposoby wykorzystania czujników DS18B20. I jak na razie wszystko idzie, a przynajmniej powinno iść gładko, gdy korzystamy z gotowych programów – szkiców. Aby je znaleźć, wystarczy przeszukać biblioteki Arduino. Można ściągnąć różne biblioteki do swojego komputera albo tylko je przeszukać na stronie www.github.com.

W przypadku czujników temperatury DS18B20 i łącza OneWire wykorzystaliśmy biblioteki najpopularniejsze, powszechnie używane. Ale w innych przypadkach trzeba, a przynajmniej warto sprawdzić, czy do właściwej biblioteki – klasy (pliki .h i .cpp) dołączone są takie przykłady, które nas interesują. A do tego wystarczy minimalna znajomość angielskiego albo nawet  jakiś internetowy tłumacz z angielskiego na polski (np. Google).

To wystarczy, by wykorzystać „gotowce”. Ale jeżeli chcemy coś zmienić w programie, często napotykamy poważne problemy. I tu najważniejsza zachęta:

nie zniechęcaj się, jeżeli napotkasz problemy i gdy program nie działa!

Kłopoty są nieuniknione! Niektórzy mówią, że Arduino to programowanie dla dzieci. Teoretycznie tak, ale jak  już mówiliśmy, tak naprawdę wykorzystany jest tam język C++. W analizowanych szkicach, zwłaszcza w poprzednim odcinku kursu, mieliśmy przykłady użycia zaawansowanych rozwiązań nie tylko w bibliotekach, ale i w proponowanych szkicach. Zapewne nie wpadłbyś na pomysł, żeby program wyglądał właśnie tak. Nawet analiza takich szkiców nie jest zadaniem dla dzieci. Trzeba to zaakceptować i pomału wgryzać się w bardzo obszerne, ale zwykle niezbyt trudne zagadnienia. W różne kwestie będziemy po trochu wgryzać się w ramach naszego kursu, ale do tego potrzebna jest podbudowa teoretyczna, która z kolei będzie przedstawiana w artykułach „Wokół Arduino”. Jeszcze raz powtarzam:

nie zniechęcaj się, jeśli (licznych) szczegółów nie rozumiesz!

Chciałbym też poruszyć jeszcze jeden temat. Otóż programy – szkice i biblioteki mają wielu autorów o różnym poziomie doświadczenia, umiejętności, staranności i chęci do wysiłku. Po pierwsze, niektóre biblioteki i szkice są „jakościowo dużo lepsze” od innych, czego często w pierwszej chwili nie widać. Niestety, nie ma dobrej reguły, jak znaleźć dobrą bibliotekę oraz dobre szkice – „gotowce”.

Wielu miłośników Arduino zaczyna od bibliotek udostępnianych przez wiodących wytwórców modułów: Adafruit i SparkFun. Ale bardzo często  „porządne” biblioteki pochodzą spod ręki „pojedynczych autorów”.

Pochodzące od różnych autorów biblioteki dla tego samego modułu, choćby układu DS3231, różnią się pod wieloma względami, także dostępnymi funkcjami-metodami i ich nazwami. Dlatego zawsze trzeba zaczynać od przykładów dołączonych do danej biblioteki.

Najlepiej byłoby przetestować kilka bibliotek i dołączonych do nich przykładów. Wprawdzie pochłania to sporo czasu, ale jest pożyteczne z różnych względów. Niestety, często po pierwszych próbach przyzwyczajamy się do pierwszej wybranej biblioteki i narzuconych tam reguł, a potem już nie chce się nam przebijać przez zawiłości innych bibliotek. Problem w tym, że jeśli zaczniemy od biblioteki słabej jakości, to niejako utkniemy i nie wykorzystamy wszystkich dostępnych możliwości. Dlatego zachęcam do zapoznawania się z różnymi bibliotekami i zawartymi w nich przykładami. A w następnym odcinku UR011 zbudujemy użyteczny logger.

Piotr Górecki