NodeMCU i SDS011 jako badacz jakości powietrza

SDS011 to dobry czujnik pyłów zawieszonych, podaje dane dla PM2.5 i PM10. Ostatnio wśród nas wzrosła świadomość zanieczyszczeń pyłami, więc pojawiło się trochę zapytań jak korzystać z tego czujnika. Większość przykładów jakie można znaleźć jednak wykorzystują Raspberry Pi. Koszt Raspberry to co najmniej drugie tyle co sam sensor, tymczasem w wielu przypadkach zależy nam na pomiarze i przekazaniu danych do chmury. W takim przypadku nie potrzebujemy całego Raspberry, to co ma od zaoferowania nodeMCU w zupełności /wystarczy.

NodeMCU, SDS011 – prosta stacja pomiaru jakości powietrza

Dlatego tym razem pokaże Wam jak zbudować taki sensor w oparciu o te dwa elementy – nodeMCU i SDS011. Planuję w przyszłości projekt poprawić, bo ma teraz kilka wad. Podstawowa kwestia to jest wilgotność – czujnik przy wilgotności ponad 70% przekłamuje wyniki, dlatego by w dokonywać wiarygodnych pomiarów, nasz układ potrzebuje elementu ogrzewającego napływające powietrze. W tej wersji zupełnie to pomijamy, jedyne co potrzebujemy to wiedza jaka jest wilgotność, by móc odrzucić pomiary przy wilgotności przekraczającej 70%. Ale z pominięciem tego wystarczy powiesić na uchylonym oknie by mieć z grubsza świadomość tego co się dzieje…

Drugim ważnym elementem jest to, jak wysyłać dane w chmurę. Tutaj zdecydowałem się na użycie serwisu ThingSpeak (w projektach tutaj na blogu kilka lat temu też go używałem). Od tamtego czasu został wykupiony przez właścicieli MATLABa i dla większej liczby zapisywanych komunikatów jest płatny, ale darmowy plan (~8k zapisów dziennie, co daje rocznie jakieś 3 mln) dla naszych hobbistycznych celów w zupełności wystarcza.

Miałem nadzieję na szybki prototyp – czytaj – że znajdę gotowca w internecie. Ku mojemu zdumieniu, nie znalazłem niczego satysfakcjonującego. Początkowo wydawało mi się, że ten projekt się nada https://github.com/opendata-stuttgart ale… kod był zbyt specyficzny, skrojony na ichnie potrzeby, więc… szybko poszło wielkie cięcie gięcie i zostało tylko niewiele z oryginalnej kobyły. Także, ten… kod, który tutaj pokazuję nie jest optymalny, ale działa i o to chodziło  w szybkim projekcie.

Jak pracuje sensor SDS011

Nim jednak zaczniemy omawiać szczegóły trzeba powiedzieć sobie kilka słów o trybach pracy SDS011. Gdy przyjdzie do Ciebie zamówiony sensor, po zasileniu go, zaczyna pracę. Wentylator startuje, a na UART podawane są odczyty. Dobre to jest dla przetestowania czy działa, ale nie nadaje się taki tryb pracy do zastosowania w produkcji. Co w nim złego? Ano, to że sensor pracuje cały czas. A jego kluczowy element, czyli laser ma żywotność około 8000 godzin pracy – w trybie ciągłym niecały rok. Zdecydowanie za mało.

Jeśli planujesz użyć sensor do pomiaru stanu powietrza, którym oddychasz, dopuszczalne jest pominięcie jakiś skrajnych, krótkotrwałych odczytów. Potrzebujesz wartości średnich, a to znaczy, że pomiary można dokonywać co kilka minut.

Pierwszy pomysł na taki tryb pracy to po prostu wyłączanie czujnika i włączanie go w momencie gdy chcesz dokonać pomiaru. Trzeba tylko pamiętać, że producent zastrzega sobie czas 30 sekund na ustabilizowanie odczytu. Da się tak zrobić ( i pierwsza wersja mojego firmware tak pracowała). Jednak takie podejście stwarza pewne problemy. Czekając 30 sekund na ustabilizowanie się pracy SDS011, nodeMCU musi przetwarzać cały czas przychodzące odczyty (bo SDS011 wysyła dane cały czas po włączeniu) i je ignorować. Dopiero po upływie 30 sekund można odczytać dane i wysłać komendę uśpienia sensora.

O wiele wygodniejsze by było, gdyby SDS sam z siebie odczekał określony czas i sam się włączył. Po upływie 30 sekund powinien zrobić pomiar i jego rezultat wysłać do nodeMCU. I taki tryb pracy SDS011 ma wbudowany! Wystarczy wysłać komendę ustawiającą duty cycle na wartość od jednej minuty do trzydziestu. SDS wyśle co określony czas wynik jednego pomiaru, sam dbając o włączeniu wentylatora przed pomiarami i odczekaniem odpowiedniego czasu na rozruch całości. Idealnie, nodeMCU musi tylko odebrać jeden komunikat.

Podłączamy SDS011 do nodeMCU

Cały kod możesz przejrzeć na tym giscie https://gist.github.com/netmaniac/97c25309988172f71b728ada2fa01d7b ale na końcu wpisu jest też kod do pobrania jako zip.

SoftwareSerial na ratunek

Dobra, po kolei. Mamy nodeMCU na płytce stykowej, przyczepiony SDS011 (nieśmiertelna recepturka), kilka zworek i przewodów męsko-męskich i męsko-żeńskich.

#define SDS_PIN_RX D1
#define SDS_PIN_TX D2

SoftwareSerial serialSDS(SDS_PIN_RX, SDS_PIN_TX, false, 1024);

Pin D1 podłączamy do wyjścia Tx na SDS, a pin D2 do Rx na SDS. D1/D2 tak jak opisane na PCB nodeMCU. Do działania sensor potrzebuje jeszcze 5V i GND. serialSDS to obiekt, którym będziemy się posługiwać jak zwykłym serialem do komunikacji z SDS. W setup musimy jeszcze dodać serialSDS.begin(9600); by nasz obiekt wiedział z jaką prędkością ma się komunikować z SDS011.

Dobra, teraz o komunikacji. Protokół jest opisany dokładnie w PDFach do ściągnięcia ze strony produktu SDS011 na Nettigo.

Struktura komend

W skrócie – każda komenda składa się z 19 bajtów. Pierwszy to 0xAA, drugi 0xB4, potem kod polecenia (np duty cycle ma 0x08) i dalej bajty komendy, różne w zależności od polecenia. W ustawianiu duty cycle, następny bajt to 0x01. Znaczy to 'ustaw’ duty cycle (0 znaczyłoby, że chcemy odczytać aktualne ustawienie). Kolejny bajt to wartość na jaki ma być ustawiony duty cycle – od 0 do 30. Zero – działaj ciągle.

Dalej zera, z wyjątkiem ostatnich czterech bajtów. Otóż bajty 16 i 17 to kod (ID) sensora do którego wysyłamy komendę. W sumie, to nie wiem dlaczego, UART i tak jest komunikacją tylko między dwoma urządzeniami. Jak mi recenzenci donieśli może to mieć jednak sens – użycie szyny RS485 i konwertera UART dawałoby nadzieję na działanie takiej szyny…

ID musimy odczytać, albo skorzystać z wartości [0xFF, 0xFF] – dwa takie bajty znaczą – dowolny sensor. W układzie gdy podłączeni jesteśmy do sensora bezpośrednio używanie 0xFF wydaje się najłatwiejsze.

Bajt 18 to suma kontrolna, a bajt 19-ty to zawsze 0xAB. Sumę kontrolną wyznacza się z bajtów od 3 do 17 włącznie. Jest to po prostu suma, z której bierze się mniej znaczący bajt (skoro suma 14 bajtów, to jej wynik przekracza zazwyczaj wartość jaką można przechować w jednym bajcie). Mniej znaczący bajt to prostu reszta z dzielenia sumy przez liczbę wartości jaką można zapisać w bajcie. Mając to w głowie, możemy napisać funkcję, która przygotuje komendę ustawienia duty cucle na podaną wartość. W tym celu do szablonu takiego polecenia wstawiamy liczbę na jaką ustawić duty cycle na 5 pozycji (bajt startu, bajt znacznika polecenia, kod polecenia, kod ustawiania wartości i na piątej pozycji sama wartość).

// set duty cycle
void set_SDS_duty(uint8_t d)
{
  uint8_t cmd[] =
      {
          0xaa, 0xb4, 0x08, 0x01, 0x03,
          0x00, 0x00, 0x00, 0x00, 0x00,
          0x00, 0x00, 0x00, 0x00, 0x00,
          0xFF, 0xFF, 0x0a, 0xab};
  cmd[4] = d;
  unsigned int checksum = 0;
  for (int i = 2; i <= 16; i++)
    checksum += cmd[i];
  checksum = checksum % 0x100;
  cmd[17] = checksum;

  serialSDS.write(cmd, sizeof(cmd));
}

W linii 10 używamy indeksu 4, bo tablice Arduino numeruje od zera. Czyli cmd[4] oznacza piąty element. Podobnie pętla – przebiega przez tablicę od elementu 3 do 17 (czyli indeksy w tablicy od 2 do 16) sumuje wszystkie wartości i zapisujemy resztę z dzielenia w tablicy. Pewnie można zrezygnować z dzielenia przez 256 – bo zapis do bajtu zmiennej typu unsigned i tak skończy się na zapisaniu tylko mniej znaczącego bajtu. Ale odradzam stosowania takiej optymalizacji. Oszczędza pewnie jedną czy dwie instrukcje kod maszynowego, ale skutkuje zmniejszeniem czytelności kodu w C. Nie będzie jasne czy to rzutowanie z unsgined do byte to celowe czy błąd…

Gdy mamy gotową tablicę cmd wysyłamy ją na przez UART. Dzięki temu, że rozmiar tablicy jest stały (określony przez liczbę elementów między { a } w definicji tablicy cmd, kompilator zna wartość sizeof(cmd) i wie ile bajtów trzeba wysłać.

Teraz uwaga – jeśli zajrzałeś do PDFa na stronie SDS, powinieneś wiedzieć, że w zasadzie powinniśmy sprawdzać co odpowiedział nam czujnik. Nie robię tego, bo działa ;) jeśli natkniesz się na problemy, sprawdź jaką odpowiedź dostajesz od czujnika i czy zgadza się z tym co powinno być. Dobra, co dalej?

Odpowiedzi czujnika SDS011, czyli ile jest tych pyłów?

Najważniejsza sprawa, czyli analiza tego co przychodzi z czujnika. Czujnik wysyłając dane, wysyła dwa bajty oznaczające start 0xAA i 0xC0. Następnie są po dwa bajty odczytu PM2.5 i dwa PM10. Kolejne dwa bajty to ID czujnika, następnie suma kontrolna i znak końca bajt o wartości 0xAB.

Funkcja sensorSDS() czeka na na ciąg bajtów startu, czyli 0xAA i 0xC0 następnie zapisuje wartości PM2.5 i PM10 korzystając z pomocniczych zmiennych pm25_serial i pm10_serial.

Aby odczytać wartość podawaną przez sensor, trzeba pierwszy bajt przemnożyć przez 256. W przypadku systemu dwójkowego 256 to 'okrągła’ liczba tak jak dla nas 100 czy 1000. Mnożenie 100 to dopisanie dwóch zer, mnożenie przez 256 to przesunięcie o 8 bitów. Jest to operacja znacznie prostsza obliczeniowo niż mnożenie, więc zwłaszcza w mikrokontrolerach często stosuje się taką operację zamiast mnożenia: pm25_serial += (value << 8); Tutaj nie ma to dużego znaczenia dla wydajności, ale warto o tym pamiętać.

Dzielenie przez 256 to byłby przesuwanie w drugą stronę (czyli value >> 8). Oczywiście mowa tutaj o dzieleniu i mnożeniu w zakresie liczb całkowitych, jeśli potrzebujesz liczb zmiennoprzecinkowych ten skrót nie działa.

Wartość tak wyliczona nie wskazuje jeszcze wprost na stężenie w µg/m3. By uzyskać ten parametr obliczoną liczbę trzeba podzielić przez 10. Ponieważ pm25_serial to wartość typu całkowitoliczbowego to kompilator wynik dzielenia pm25_serial/10 zamieni na też na liczbę całkowitą (obetnie część dziesiętną). Dlatego trzeba mu kazać traktować taką operację jako dzielenie zmiennoprzecinkowe. Są dwie metody. Pierwsza, to potraktowanie pm25_serial jako liczby zmiennoprzecinkowej (tzw rzutowanie) – robi to konstrukcja float(pm25_serial). Drugą byłoby zamienienie stałej 10 na 10.0 – niby to samo, ale kompilator już wie, że chodzi nam o wynik dzielenia z częścią ułamkową.

Początkowo dane były tylko wysyłane do komputera, przez obiekt Serial. My jednak chcemy wysłać dane do tzw. chmury. Skoro korzystamy z nodeMCU i mamy łączność ze światem…

Wysyłanie danych do ThingSpeak

Dochodzimy teraz do ThingSpeak.com – serwisu do akwizycji danych. Kiedyś już pisaliśmy o tym serwisie, przy okazji monitoringu na Arduino Yun, ale że całość się mocno zdezaktualizowała…

ThingSpeak udostępnia API, z którego może korzystać każde urządzenie, które ma dostęp do internetu. API to gromadzi dane, a interfejs WWW pozwala prezentować te dane:

Interfejs WWW ThingSpeak
Interfejs WWW ThingSpeak

ThingSpeak to nie tylko prosta wizualizacja. Odkąd serwis został kupiony przez The MathWorks (właściciela Matlaba, marki której nie trzeba chyba specjalnie przedstawiać) oferuje integracje z Matlabem i pozwala bezpośrednio w programie korzystać z danych zgromadzonych we własnych kanałach. Ale, ale…

Matlab jest cholernie drogim oprogramowaniem. Nie mówię, że nie jest tego wart, tylko, że ThingSpeak jest opcją jeśli chcesz na szybko zacząć zbierać dane i mieć gotowa wizualizację. Chyba, że masz dostęp do Matlaba, to masz znacznie więcej możliwości.

Dane zgromadzone w ThingSpeak można eksportować (w zakładce Data Import/Export w konfiguracji kanału można pobrać CSV ze wszystkimi pomiarami). TS ma w wersji darmowej obecnie limit na 8k komunikatów dziennie (3 mln rocznie) a minimalny odstęp  między aktualizacjami to 15 sekund.

Dobra, dość tego narzekania (zresztą dodam – doskonale rozumiem, że TS jest 'bonusem’ podnoszącym wartość Matlaba, dla tych którzy zapłacili za jego licencję). Korzystanie z TS jest łatwe. Tworzycie konto, logujecie się i wchodzicie na https://thingspeak.com/channels klikacie 'New Channel’ i wypełniacie:

Każdy kanał ma maksymalnie 8 pól (wykresów). Opisujecie sobie ich nazwy i trzeba zapamiętać numery. W zakładce API Keys znajdujesz Write API Key i możesz przejść do Arduino IDE.

Biblioteka ThingSpeak

Przez menedżera bibliotek w Arduino IDE instalujemy bibliotekę ThingSpeak. Gdy już jest gotowa, konfigurujemy ją do użycia w naszym szkicu (poniżej najważniejsze fragmenty):

#include <ThingSpeak.h>

unsigned long myChannelNumber = 276259;
const char *myWriteAPIKey = "MAJ3ZZ85N3AVG3GA";

WiFiClient client;
char ssid[] = "xxxx"; // your network SSID (name)
char pass[] = "********";
void setup() {
  WiFi.begin(ssid, pass);
  while (WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }

  ThingSpeak.begin(client);
}

Skoro nodeMCU, to mamy client’a WiFi, którego konfigurujemy  i czekamy aż się podłączy do sieci. Potem ThingSpeak.begin(client); inicjuje bibliotekę, tak by mogła wysyłać dane.

Pozostaje teraz wysłać dane. Gdy nasz czujnik już poda nam PM2.5 i PM10 wysyłamy te dane:

ThingSpeak.setField(1, float(pm10_serial) / 10);
ThingSpeak.setField(2, float(pm25_serial) / 10);
ThingSpeak.setField(3, DHT_readings.temp);
ThingSpeak.setField(4, DHT_readings.hum);
ThingSpeak.writeFields(myChannelNumber, myWriteAPIKey);

ThingSpeak.setField ustawia nam kolejne wartośc do zapisu. Numery muszą się zgadzać z tym co ustawiliśmy w konfiguracji kanału na ThingSpeak. Jeśli tutaj się pomylimy, wówczas np stężenie PM2.5 będzie raportowane jako temperatura.

writeFields wysyła dane do podanego kanału (jego numer) korzystając z klucza do API. Gotowe. Możemy iść oglądać dane na ThingSpeak. Każdy kanał możemy skonfigurować – ile danych będzie wyświetlanych i jak będą przetworzone.

Średnia, mediana i inne. Można sobie trochę dostosować widok do tego co potrzebujemy.

DHT22 jako strażnik wilgotności

Dobra, co dalej? Już i na wykresach i w kodzie pojawiają się sugestie, że to nie jest sam SDS011. Również DHT22. Dlaczego? DHT22 jest sensorem podającym wilgotność, a jak już wspominaliśmy przy wilgotności powyżej 70% czujnik SDS011 będzie zawyżał wskazania (wilgoć w powietrzu powoduje odbicia światła lasera). Na Nettigo jest do kupienia sensor DHT22 idealnie pasujący do płytki stykowej więc możemy mierzyć temperaturę i wilgotność tuż przy wlocie do SDS011. Po co? Wtedy możemy nie przejmować się, jeśli sensor wskazuje wysoki stężenie PMów, a wilgotność wysoka.

Docelowo to trzebaby zamknąć SDS011 (przynajmniej wlot) w małej puszce z DHT22 obok i wlot powietrza przepuszczać przez grzałkę. Jak wilgotność zbliża się do 70% – zacząć ogrzewać powietrze – powinno pomóc. Ale to może w następnej wersji. Teraz lato się zbliża i mam nadzieję, że jednak wilgotność będzie poniżej 70% przez większość czasu.

Ale skoro o DHT mowa – to pozostaje nam podłączenie do nodeMCU tego sensora. Na Nettigo już od dawna jest dostępny moduł DHT22 gotowy do wpięcia w płytkę stykową. Nie ma już potrzeby dokładania rezystora pull-up, jest on na module. Podłączyć zasilanie, masę i podłączyć do nodeMCU. Korzystam w tym szkicu z biblioteki DHT22 dostępną w managerze napisaną przez Adafruit. Wybrałem pin D7 i dla ułatwienia całość dotycząca DHT znajduje się w odrębnym pliku. Warto sobie tak szkic porządkować, wtedy łatwiej ogarnąć całość.

Jeśli chcesz dodać nową zakładkę to kliknij ikonkę trójkąta w prawym górnym rogu (pod lupą symbolizującą monitor portu szeregowego). Tam jest opcja New Tab. Możesz też użyć skrótu klawiszowego Ctrl+Shift+N. Podajesz nazwę pliku z końcówką .h (tutaj to było dht_sensor.h) i w głównym szkicu na początku dodajesz #include "dht_sensor.h", by mieć dostęp do zdefiniowanych tam funkcji i zmiennych.

By ułatwić sobie przechowywanie zmiennych z temperaturą i wilgotnością deklaruję strukturę:

struct DHT_data {
  float temp;
  float hum;
} DHT_readings;

DHT_data to jest nazwa typu zmiennej (tak jak int czy byte), a DHT_readings to nazwa zmiennej, którą od razu w ten sposób deklaruję. Mogę teraz używać zapisu DHT_readings.temp by zapisywać i odczytywać temperaturę. Jest to zmienna globalna, która będzie dostępna w całym programie.

Szkic w dht_sensor.h to znowu lekko przerobiony przykład z biblioteki. Funkcję setup przemianowałem na setup_dht() (bo nie bardzo mogą być funkcje o tej samej nazwie i argumentach) i wywołujemy ją z głównego setup. Funkcję loop przerobiłem na read_DHT, która aktualizuje wartości zapisane w DHT_readings.

Całość na płytce stykowej

Możemy teraz podsumować wszystko. Schemat połączeń zobrazowany przez Fritzing:

Schemat połączenia SDS011 do nodeMCU

Kod do pobrania tutaj: nodeMCU_SDS011

Może, jeszcze raz poglądowy wygląd płytki stykowej:

Spis części: