Luftqualitätssensor Vindriktning mit ESP32

Momentan bietet IKEA für knapp EUR 10.- einen Luftqualitätssensor an, der mit drei verschiedenfarbigen LEDs die aktuelle Qualität der Raumlauft anzeigt. Dabei wird eine Partikelanalyse (PM2,5) durchgeführt und das entsprechende Resultat angezeigt.

Luftqualitätssensor Vindriktning
Abb.: Luftqualitätssensor Vindriktning (Quelle: ikea.com)

Spezifikationen laut Hersteller

Typ E2014 VINDRIKTNING
Eingang 5,0V DC; 2,0A; 0,5W; USB-C
Partikelerkennungsbereich 0~1000µg/m³
Max. USB-Kabellänge 3,0m
Betriebstemperatur 0°C bis 40°C
Betriebsfeuchtigkeit 0 bis 95% RH (empfohlen: 40-60%)

Vewendung

Bedeutung der LED-Anzeigen

Analyse/Aufbau

Betriebsbereites Gerät
Abb.: Betriebsbereiter Luftqualitätssensor Vindriktning
Unterseite mit Spezifikationen
Abb.: Auf der Unterseite des Gerätes befinden sich einige Angaben über das Gerät, sowie auch die Betriebsspannung (5V) und der ausgenommene Strom von 100mA.
Geöffnetes Gerät
Abb.: Geöffnetes Geräte mit Platine (links) und dem eigenlichen Sensormodul (rechts) und einem kleinen Ventilator darunter.
Mikrocontroller Eastsoft ES7P
Abb.: Gesteuert wird das Gerät mit einem Mikrocontroller von der Firma Eastsoft mit der Bezeichnung ES7P00IFGSA
LEDs zur Statusanzeige
Abb.: Das Gerät zeigt mit verschiedenfarbigen LEDs den Status der Luftqualität an.
Detail der Platine mit Anschlüssen/Lötpunkten
Abb.: Auf der Platine sind einige (hackerfreundliche) Anschlüsse untergebracht, die später in diesem Beitrag noch wichtig werden.
Luftqualitätssensor
Abb.: Der eigentliche Sensor (PM2,5) zur Messung der Luftqualität.

Auslesen der gemessenen Werte

Laut einiger Artikel im WWW scheint der hier verwendete Partikelsensor eng verwandt mit dem Cubic PM1006k zu sein.
Das Vindriktning bietet auf der Platine einige Test-Ausgänge, die man für weitere Analysen verwenden kann. Der gemessene Wert der Luftqualität wird im Folgenden mit einem ESP32 ausgelesen, verarbeitet und auf einem kleinen OLED angezeigt.

Anschlüsse am Vindriktning
Abb.: Eine Reihe von Lötpunkten sind die Ausgänge, die auch vom Geräte selbst verwendet werden, d.h. für die LEDs und den Ventilator. Diese werden wir nicht weiter betrachten.
Anschlüsse am Vindriktning
Abb.: Der zweite Satz von Anschlüssen sind gepaarte Lötpunkte, an denen man die Rohdaten des Sensors abgreifen kann.
Steckkabel anlöten
Abb.: Für die ersten Tests werden Steckkabel an die Anschlüsse GND, 5V und REST angelötet, so dass ein Aufbau auf einem Breadboard möglich ist.

Verwendete Bauteile

Aufbau der Schaltung

Im folgenden Bild wird gezeigt, wie die Schaltung aufgebaut ist. Zu beachten ist, dass die Eingänge des ESP32 nicht 5V-tolerant sind, d.h. mit 3,3V betrieben werden müssen. Daher wird hier ein einfacher Spannungsteiler benutzt. (es ist auch möglich einen Pegelwandler zu verwenden). Der Ausgang REST des Vindriktning wird am Eingang GPIO16 (U2_RXD) des ESP32 angeschlossen und kann als serielle Schnittstelle (9600 Baud, 8N1) ausgelesen werden.

Aufbau mit ESP32 und OLED

Sketch

In der Arduino-IDE wird (wie in Beitrag ESP32 beschrieben) auf das ESP32-Board umgestellt und die Libraries Adafruit_GFX, Adafruit_SSD1306 und EspSoftwareSerial über den Bibliotheks-Manager installiert.
Die Daten werden über eine serielle Schnittstelle ausgelesen, wobei ich mich nahe am Code von Hypfer/esp8266-vindriktning-particle-sensor orientiert habe. Der Sketch bindet noch zwei lokale header-Dateien ein, die im selben Ordner wie der Sketch liegen müssen.
Zum Übertragen des Codes wird der ESP32 zunächst ohne den Vindriktning per Mikro-USB-Kabel an den PC verbunden. Dieses Kabel nach der erfolgreichen Programmierung entfernt und dann der gesamte Aufbau per USB-C-Kabel vom Vindriktning mit Strom versorgt.

Types.h

struct SensorState
{
    uint16_t avgPM25 = 0;
    uint16_t measurements[5] = {0, 0, 0, 0, 0};
    uint8_t measurementIdx = 0;
    boolean valid = false;
};

SerialCom.h

#include <SoftwareSerial.h>
#include "Types.h"

namespace SerialCom
{
    constexpr static const uint8_t PIN_UART_RX = 16; // GPIO16 = U2_RXD (ESP32)
    constexpr static const uint8_t PIN_UART_TX = 17; // UNUSED

    SoftwareSerial sensorSerial(PIN_UART_RX, PIN_UART_TX);

    uint8_t serialRxBuf[255];
    uint8_t rxBufIdx = 0;

    void setup()
    {
        sensorSerial.begin(9600);
    }

    void clearRxBuf()
    {
        // Clear everything for the next message
        memset(serialRxBuf, 0, sizeof(serialRxBuf));
        rxBufIdx = 0;
    }

    void parseState(SensorState& state)
    {
        /**
         *         MSB  DF 3     DF 4  LSB
         * uint16_t = xxxxxxxx xxxxxxxx
         */
        const uint16_t pm25 = (serialRxBuf[5] << 8) | serialRxBuf[6];

        Serial.printf("Received PM 2.5 reading: %d\n", pm25);
        state.measurements[state.measurementIdx] = pm25;
        state.measurementIdx = (state.measurementIdx + 1) % 5;

        if (state.measurementIdx == 0) {
            float avgPM25 = 0.0f;
            for (uint8_t i = 0; i < 5; ++i) {
                avgPM25 += state.measurements[i] / 5.0f;
            }
            state.avgPM25 = avgPM25;
            state.valid = true;
            Serial.printf("New Avg PM25: %d\n", state.avgPM25);
        }

        clearRxBuf();
    }

    bool isValidHeader()
    {
        bool headerValid = serialRxBuf[0] == 0x16 && serialRxBuf[1] == 0x11 && serialRxBuf[2] == 0x0B;
        if (!headerValid) {
            Serial.println("Received message with invalid header.");
        }
        return headerValid;
    }

    bool isValidChecksum()
    {
        uint8_t checksum = 0;
        for (uint8_t i = 0; i < 20; i++) {
            checksum += serialRxBuf[i];
        }
        if (checksum != 0) {
            Serial.printf("Received message with invalid checksum. Expected: 0. Actual: %d\n", checksum);
        }
        return checksum == 0;
    }

    void handleUart(SensorState& state)
    {
        if (!sensorSerial.available()) {
            return;
        }

        Serial.print("Receiving:");
        while (sensorSerial.available()) {
            serialRxBuf[rxBufIdx++] = sensorSerial.read();
            Serial.print(".");

            // Without this delay, receiving data breaks for reasons that are beyond me
            delay(15);

            if (rxBufIdx >= 64) {
                clearRxBuf();
            }
        }
        Serial.println("Done.");

        if (isValidHeader() && isValidChecksum()) {
            parseState(state);

            Serial.printf(
                "Current measurements: %d, %d, %d, %d, %d\n",

                state.measurements[0],
                state.measurements[1],
                state.measurements[2],
                state.measurements[3],
                state.measurements[4]
            );
        } else {
            clearRxBuf();
        }
    }
}

vindrik1.ino

Der eigentliche Sketch initialisiert die softwareseitige, serielle Schnittstelle sowie das OLED über I²C und zeigt dann im loop() alle 10 Sekunden den vom Sensor gemessenen Wert der Luftqualität auf dem OLED an.

#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "SerialCom.h"

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32

#define OLED_RESET     4 // Reset pin # (or -1 if sharing Arduino reset pin)
#define SCREEN_ADDRESS 0x3C ///< See datasheet for Address; 0x3D for 128x64, 0x3C for 128x32
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
SensorState state;
uint32_t next_report_millis = 0, t = 0;

void setup()
{
  SerialCom::setup();
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    while (true);
  }
  display.clearDisplay();
  display.display();

  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Air quality");
  display.display();
  delay(2000);
}

void loop()
{
  SerialCom::handleUart(state);
  t = millis();
  if (t > next_report_millis) {
    display.clearDisplay();
    display.setCursor(0, 0);
    next_report_millis = t + 10000;
    if (state.valid) {
      display.println(state.avgPM25);
    } else {
      display.println("?");
    }
    display.display();
  }
}

Resultat

Anzeige der Luftqualität auf dem OLED
Abb.: So ähnlich sollte die Ausgabe der Luftqualität auf dem OLED ausssehen. (bis die Anzeige brauchbare Werte liefert kann u.U. einige Sekunden dauern...)
Die Werte sind hier in µg pro m³ Luft zu interpretieren.

Erweitung für die IoT-Verwendung

Um nicht nur die aktuelle Qualität der Raumluft zu messen und analysieren, sondern die Sensordaten über die Zeit zu sammeln und somit eine Historie der Luftqualität eines Raumes zu erstellen, wird im Folgenden zunächst das bestehende Modul angezapft, die Daten an eine ESP32-Modul weitergeleitet und dann über WLAN an den IoT-Service ThingSpeak gesendet. (siehe dazu den vorhergehenden Beitrag "Sensordaten auslesen und nach ThingSpeak senden".

Verwendete Bauteile

Aufbau der Schaltung

Der Aufbau der Schaltung ist dem verherigen sehr ähnlich, jedoch hab ich nicht mehr das Vindriktning über USB-Anschluss mit Spannung versorgt, sondern den ESP32. Das Vindriktning wurde dann über +5V auf VIN an die Stromversorgung gehängt. Diese Anpassung habe ich gemacht, da der ESP32 dauern neugestartet ist, denn anscheinend ich die Stromstärke durch den Sensor zu wenig für die zusätzliche Verwendung des WiFi-Moduls auf dem ESP32.
Außerdem habe ich den Spannungsteiler hier weggelassen, denn das Auslesen der Werte über die serielle Schnittstelle hat hier Probleme bereitet. Vermutlich werde ich bei einem längeren Lauf des Sensors einen Pegelwandler verwenden und hoffe, dass die Sensorwerte dann stabil über die serielle Schnittstelle ausgelesen werden können.

Sketch

Types.h

struct SensorState
{
    uint16_t avgPM25 = 0;
    uint16_t measurements[5] = {0, 0, 0, 0, 0};
    uint8_t measurementIdx = 0;
    boolean valid = false;
};

SerialCom.h

Das anscheinend das WiFi-Modul nicht gleichzeitig mit dem SoftwareSerial zusammenarbeiten will, habe ich hier auf die zweite hardwareseitige Serial-Schnittstelle umgestellt. Dies hat ohne Probleme funktioniert. Außerdem sind die Debug-Ausgabe der ersten seriellen Schnittstelle rausgefallen.

#include "Types.h"

namespace SerialCom
{
    constexpr static const uint8_t PIN_UART_RX = 16; // GPIO16 = U2_RXD (ESP32)
    constexpr static const uint8_t PIN_UART_TX = 17; // UNUSED

    uint8_t serialRxBuf[255];
    uint8_t rxBufIdx = 0;

    void setup()
    {
        Serial2.begin(9600, SERIAL_8N1, PIN_UART_RX, PIN_UART_TX);
    }

    void clearRxBuf()
    {
        memset(serialRxBuf, 0, sizeof(serialRxBuf));
        rxBufIdx = 0;
    }

    void parseState(SensorState& state)
    {
        const uint16_t pm25 = (serialRxBuf[5] << 8) | serialRxBuf[6];

        state.measurements[state.measurementIdx] = pm25;
        state.measurementIdx = (state.measurementIdx + 1) % 5;

        if (state.measurementIdx == 0) {
            float avgPM25 = 0.0f;
            for (uint8_t i = 0; i < 5; ++i) {
                avgPM25 += state.measurements[i] / 5.0f;
            }
            state.avgPM25 = avgPM25;
            state.valid = true;
        }

        clearRxBuf();
    }

    bool isValidHeader()
    {
        bool headerValid = serialRxBuf[0] == 0x16 && serialRxBuf[1] == 0x11 && serialRxBuf[2] == 0x0B;
        return headerValid;
    }

    bool isValidChecksum()
    {
        uint8_t checksum = 0;
        for (uint8_t i = 0; i < 20; i++) {
            checksum += serialRxBuf[i];
        }
        return checksum == 0;
    }

    void handleUart(SensorState& state)
    {
        if (!Serial2.available()) {
            return;
        }

        while (Serial2.available()) {
            serialRxBuf[rxBufIdx++] = Serial2.read();
            // Without this delay, receiving data breaks for reasons that are beyond me
            delay(15);

            if (rxBufIdx >= 64) {
                clearRxBuf();
            }
        }

        if (isValidHeader() && isValidChecksum()) {
            parseState(state);
        } else {
            clearRxBuf();
        }
    }
}

vindrik_iot.ino

Der Sketch ist im Vergleich zum Vorherigen nur um die WiFi- und HTTPClient-Library erweitert worden und es wird nun in regelmäßigen Abständen (alle 30 Sekunden) der Luftqualitätswert an ThingSpeak verschickt. Das OLED habe ich nur zur Überprüfung beibehalten und kann auch ohne Probleme weggelassen werden.

#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include "SerialCom.h"

#define OLED_WIDTH 128
#define OLED_HEIGHT 32
#define OLED_PIN_RESET 4
#define OLED_ADDRESS 0x3C

#define REPORT_DELAY 30 // in seconds

// WiFi settings
const char* WIFI_SSID     = "xxxxxxxxxxx";
const char* WIFI_PASSWORD = "xxxxxxxxxxx";

// ThingSpeak settings
const char* THINGSPEAK_URL = "http://api.thingspeak.com/update";
const char* WRITE_API_KEY  = "xxxxxxxxxxx";

#define lmillis() ((long)millis())

Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_PIN_RESET);
SensorState state;
long nextReport = 0;

void setup()
{
  SerialCom::setup();

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    while (true);
  }
  display.clearDisplay();
  display.setTextSize(2);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Init...");
  display.display();

  nextReport = lmillis() + REPORT_DELAY * 1000;
}

void loop()
{
  SerialCom::handleUart(state);
  if (lmillis() - nextReport >= 0) {
    nextReport = lmillis() + REPORT_DELAY * 1000;

    display.clearDisplay();
    display.setCursor(0, 0);

    if (state.valid) {
      display.println(state.avgPM25);
      requestIot();
    } else {
      display.println("-");
    }

    display.display();
  }
}


void connectWiFi()
{
  if (WiFi.status() == WL_CONNECTED) {
    return;
  }
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
}

void requestIot()
{
  connectWiFi();
  String request = String(THINGSPEAK_URL) + "?api_key=" + String(WRITE_API_KEY) + "&field1=" + String(state.avgPM25);

  HTTPClient http;
  http.begin(request.c_str());
  http.GET();
  http.end();
  WiFi.disconnect();
}

Resultat

Um den Sensor und die übertragenen Werte zu testen habe ich in dem Raum verschiedene Aktionen durchgeführt, die sich im nachfolgenden Diagram anhand der gemessenen Luftpartikelwerte wiederfinden lassen:

21:05 Ein japanisches Räucherstäbchen wird entzündet
21:20 Räucherstäbchen ist abgebrannt; Raum wird gelüftet
21:25 Ein Räucherkegel (Weihrauch) wird entzündet
21:50 Räucherkegel erloschen; Raum wird gelüftet
Chart in ThingSpeak

Man sieht, dass das Räucherstäbchen die Luftpartikelwerte nicht einmal auf 100µg/m³ hochgehen lässt und durch das Lüften schnell wieder die Werte abfallen. Der Räucherkegel lässt aber die Partikel schnell bis über 320µg/m³ ansteigen.

Hinweis zum Stromverbrauch: Ohne irgendwelche Optimierung bzgl. DeepSleep o.ä. beläuft sich die durchschnittliche Stromaufnahme auf ca. 150mA bis 190mA, wobei davon etwa 100mA für das Vindriktning und 50-90mA auf den ESP32 entfallen. Will man also eine längere Zeit die Raumluft analysieren, so sollte man entweder eine kräftige Powerbank oder besser einen 5V-Netzadapter verwenden.

Weiterführende Ideen

Links

zurück