Luftikus - Raumklimaüberwachung

Der Luftikus ist ein selbst entwickeltes, kompaktes Messgerät zur Überwachung der Raumluftqualität. Er misst kontinuierlich den CO₂-Gehalt, die Temperatur, die Luftfeuchtigkeit und den Luftdruck – und stellt die Daten visuell und digital dar. Ziel ist es, Luftqualität verständlich zu machen und auf ungünstige Bedingungen frühzeitig hinzuweisen.
Im Inneren des Geräts arbeiten mehrere präzise Sensoren:
Datenerfassung:
Jede Sekunde werden neue Sensorwerte ausgelesen. Falls ein Sensor zu diesem Zeitpunkt noch keinen neuen Wert bereitstellt (z. B. beim CO₂-Sensor), wird automatisch der letzte gültige Messwert erneut verwendet.

Direkte Anzeige:
Die aktuellen Sekundenwerte werden in Echtzeit auf dem OLED-Display dargestellt und über den LED-Ring visuell codiert (z. B. Farbverlauf entsprechend dem CO₂-Gehalt). Farbe „läuft“ dabei über den Ring und hinterlässt eine leicht leuchtende Spur – so wird nicht nur die Luft, sondern auch der Verlauf spürbar sichtbar.

WLAN-Verbindung:
Sobald der Luftikus eingeschaltet wird, prüft er automatisch, ob bereits bekannte WLAN-Zugangsdaten (SSID und Passwort) gespeichert sind. Wenn keine Verbindung zu einem bekannten WLAN erfolgreich aufgebaut werden kann, beginnen die LEDs in Rosa zu leuchten. Zusätzlich startet der Luftikus für 3 Minuten einen eigenen Access Point (Hotspot).

➤ Die SSID und das Passwort des Access Points werden auf dem OLED-Display angezeigt.
➤ Über eine einfache Weboberfläche können SSID und Passwort eingeben werden.

Sobald eine erfolgreiche WLAN-Verbindung besteht, wird dies durch ein kleines Symbol auf dem Display signalisiert. Damit ist sofort erkennbar, ob der Luftikus aktuell online ist oder nur lokal misst. Wenn keine Verbindung zum WLAN aufgebaut wird, läuft der Luftikus im Anzeigemodus: Die Messwerte werden wie gewohnt auf dem Display und über die LEDs angezeigt, jedoch ohne Datenübertragung.

Datenübertragung:
Alle 10 Sekunden wird ein Mittelwert dieser Kurzzeitdaten gebildet und über WLAN an einen Server übermittelt.

Datenverarbeitung am Server:
Auf dem Server werden die übermittelten Werte weiterverarbeitet: Es entstehen Minuten-, Stunden- und Tagesmittelwerte, die in einem interaktiven Dashboard dargestellt werden. Auch der Download als CSV-Datei st möglich. (Code wird hier nicht zur Verfügung gestellt.)

Bauteile

Folgende Bauteile wurden für meinen Luftikus (V1) verwendet: Kleinkram:

Skizzen, Fotos und Dateien

// ---------------------------------------------------------------------------------------------100
// Title:    Luftikus - V1
// Purpose:  Monitor indoor air quality using CO₂, temperature, humidity, and pressure sensors.
// Author:   Simon "Saasi" Burgener
// Created:  Jan - May 2025
// Hardware: ESP32, AHT20, BMP280, SCD40, SSD1306 OLED, NeoPixel (WS2812)
// Notes:    - Sends averaged data every 10 seconds to a remote server
//           - Displays live readings and WiFi status on OLED
//           - Visual feedback via NeoPixel color gradient based on CO₂ level
// ---------------------------------------------------------------------------------------------100

// libraries -----------------------------------------------------------------------------------100
#include  // I²C library / SDA+SCL communication
#include  // AHT20: temperature, rel. humidity
#include  // BMP280: air pressure, temperature
#include  // SCD40: CO2 (NDIR), temperatur, rel. humidity
#include  // LED strip
#include  // 0.98" OLED 
#include  // graphics

#include  // for SSID and PW input
#include  // connection to wifi
#include  // send data via wlan

// global variables ----------------------------------------------------------------------------100
// I²C connection
#define SDA_PIN       5 // I²C pin: SDA (green)
#define SCL_PIN       6 // I²C pin: SCL (blue)

// neopixel 
#define NEOPIXEL_PIN  4
#define NUMPIXELS     17 // number of neopixels

// 0.98" OLED
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET    -1 // reset-pin not connected (-1)

// runtime variables
unsigned long previousMillis = 0;
const long interval = 1000;

// Variables to store sensor values
float temp_aht = 0.0; // (measured value)_(sensor)
float hum_aht = 0.0;
float temp_bmp = 0.0;
float baro_bmp = 0.0;
float CO2_scd = 0.0;
float temp_scd = 0.0;
float hum_scd = 0.0;

float temp_avg = 0.0; // average over all measured temperatures (3 sensors)
float hum_avg = 0.0; // average over all measured humidities (2 sensors)

float temp_corr = 0.0; // temperatur offset
float hum_corr = 0.0; // humidity offset
float CO2_corr_m = 1.00; // CO2 correction facrtor

// export variables
const int nrVal = 10; // number of measured for 1 mean
int bufferIndex = 0; // counter for buffers

float co2Buffer = 0.0; // buffers: add up 10 values 
float tempBuffer = 0.0;
float humBuffer = 0.0;
float pressBuffer = 0.0;

float avgCO2 = 0.0; // exported values
float avgTemp = 0.0;
float avgHum = 0.0;
float avgPress = 0.0;

// adress (URL) off the upload php file
const char* serverURL = "https://server.add/upload_data.php";
int httpCode = 0;
bool iscon = false;

// objects -------------------------------------------------------------------------------------100
Adafruit_AHTX0 aht;
Adafruit_BMP280 bmp;
SCD4x scd40;
Adafruit_NeoPixel strip(NUMPIXELS, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// I²C-adresses --------------------------------------------------------------------------------100
const uint8_t AHT20_ADDR = 0x38; // 0x38: AHT20 (temp, hum)
const uint8_t BMP280_ADDR = 0x77; // 0x77: BMP280 (baro, temp)
// SCD40: 0x62 (CO₂, hum, temp)
const uint8_t DISPLAY_ADDR = 0x3C; // 0x3C: 0.98" OLED I²C display

// Neopixel ------------------------------------------------------------------------------------100
#define TRAIL_FADE  0.95 // neopixel fading rate (1: no fading, 0: instant fading)

unsigned long lastUpdate = 0;
int currentPos = 0;

// RGB-color of current neopixel
float red[NUMPIXELS];
float green[NUMPIXELS];
float blue[NUMPIXELS];

// color changing limits (ppm CO₂)
const int CO2_GREEN_MAX = 1000;
const int CO2_YELLOW_MAX = 1500;
const int CO2_RED_MAX = 2500;
const int CO2_LILA_MAX = 5000;



// Bitmap --------------------------------------------------------------------------------------100
// wifi on [8x10]
const uint8_t wifi_on_icon [] PROGMEM = {
  0b00000000,
  0b00000000,
  0b00000001,
  0b00000001,
  0b00000101,
  0b00000101,
  0b00010101,
  0b00010101,
  0b01010101,
  0b01010101
};

const uint8_t wifi_off_icon [] PROGMEM = {
  0b10001000,
  0b01010000,
  0b00100001,
  0b01010001,
  0b10001101,
  0b00000101,
  0b00010101,
  0b00010101,
  0b01010101,
  0b01010101
};

// creeper [8x10]
const uint8_t creeper_icon [] PROGMEM = {
  0b11111111,
  0b10011001,
  0b10011001,
  0b11100111,
  0b11000011,
  0b01011010,
  0b01111110,
  0b11111111,
  0b11111111,
  0b11100111
};

// envelope [8x10]
const uint8_t mail_icon [] PROGMEM = {
  0b11111111,
  0b10000001,
  0b11000011,
  0b10100101,
  0b10011001,
  0b10000001,
  0b10000001,
  0b11111111,
  0b00000000,
  0b00000000
};

// empty [8x10]
const uint8_t empty_icon [] PROGMEM = {
  0b11111111,
  0b10000001,
  0b11000011,
  0b10100101,
  0b10011001,
  0b10000001,
  0b10000001,
  0b11111111,
  0b00000000,
  0b00000000
};

// check mark (Häkchen) [6x8]
const uint8_t checkmark_bitmap [] PROGMEM = {
  0b00000001,
  0b00000010,
  0b00000100,
  0b00001000,
  0b00010000,
  0b10100000,
  0b01000000,
  0b00000000
};

// funktions I ---------------------------------------------------------------------------------100
void blinkColor(uint32_t color, int times, int delayTime = 300) {
  for (int i = 0; i < times; i++) {
    strip.fill(color);
    strip.show();
    delay(delayTime);
    strip.clear();
    strip.show();
    delay(delayTime);
  }
} // end blinkColor

void drawVerticalText(const char* text, int x, int yStart, int lineSpacing = 8) {
  for (int i = 0; text[i] != '\0'; i++) {
    display.setCursor(x, yStart + i * lineSpacing);
    display.write(text[i]);
  }
}

// setup loop ##################################################################################100
void setup() {
  Serial.begin(115200); // start connection to PC
  delay(1000);

  Serial.println("");
  Serial.println("<<< starting >>>");

  // start I²C communication
  Wire.begin(SDA_PIN, SCL_PIN);
  delay(250);

  // optical feedback --------------------------------------------------------------------------100
  // start/prepare NeoPixels
  strip.begin();
  strip.setBrightness(40); // set base brightness (0-255)
  strip.clear(); // turn off all neopixels
  strip.show();
  delay(500);
  blinkColor(strip.Color(255, 255, 255), 1);  // wight light on/off

  // display
  if (!display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDR)) {  // 0x3C ist meist I2C-Adresse
    Serial.println(F("OLED nicht gefunden!"));
    for (;;); // Hängen bleiben
  }
  display.setRotation(2); // upside down rotation (180°)
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println(F("< Luftikus          >"));
  display.println(F("<------------------->"));
  display.display();
  delay(500);

  // initialize sensors ------------------------------------------------------------------------100
  if (!aht.begin(&Wire, AHT20_ADDR)) {
    display.println(F(" [x] AHT20 - ERROR"));
  } else {
    display.print(F(" ["));
    display.drawBitmap(12, display.getCursorY(), checkmark_bitmap, 6, 8, SSD1306_WHITE);
    display.setCursor(18, display.getCursorY());
    display.println(F("] AHT20"));
  }
  display.display();

  if (!bmp.begin(BMP280_ADDR)) {
    display.println(F(" [x] BMP280 - ERROR")); // F() saves string in FLASH and not RAM
  } else {
    display.print(F(" ["));
    display.drawBitmap(12, display.getCursorY(), checkmark_bitmap, 6, 8, SSD1306_WHITE);
    display.setCursor(18, display.getCursorY());
    display.println(F("] BMP280"));
  }
  display.display();

  if (scd40.begin() == false) { // default I2C address: 0x61
    display.println(F(" [x] SCD40 - ERROR"));
  }  else {
    display.print(F(" ["));
    display.drawBitmap(12, display.getCursorY(), checkmark_bitmap, 6, 8, SSD1306_WHITE);
    display.setCursor(18, display.getCursorY());
    display.println(F("] SCD40"));
  }
  display.display();
  delay(1000);


  // WLAN setup --------------------------------------------------------------------------------100
  display.clearDisplay();
  display.setCursor(0, 0);
  display.println(F(" WLAN setup"));
  display.println(F("<------------------->"));
  display.display();

  blinkColor(strip.Color(0, 0, 255), 2);  // blue LEd blinking 3x
  
  WiFiManager wm; // start wifi-manager
  //wm.resetSettings(); // delete all known SSIDs (just for testing)

  // check for known/saved SSIDs
  WiFi.mode(WIFI_STA);
  WiFi.begin();

  unsigned long startAttempt = millis();
  bool connected = false;

  strip.fill(strip.Color(0, 0, 255));
  strip.show();
  display.println(F("Teste bek. SSIDs ..."));
  display.println(F(" > 10s"));
  display.display();

  // wait untill connected or timeout
  while (millis() - startAttempt < 10000) {
    if (WiFi.status() == WL_CONNECTED) {
      connected = true;
      iscon = true;
      break;
    }
    delay(500);
  }

  if (connected) {
    display.println(F(""));
    display.println(WiFi.SSID()); // show SSID / IP
    display.println(WiFi.localIP());
    display.display();
    blinkColor(strip.Color(0, 255, 0), 3);
    delay(2000);
  } else {
    // no known SSID works
    display.clearDisplay();
    display.setCursor(0, 0);
    display.println(F("Neue SSID:"));
    display.println(F(" WLAN: Luftikus-Setup"));
    display.println(F(" PW:   messtechnik"));
    strip.fill(strip.Color(128, 0, 128));
    display.display();
    strip.show();

    unsigned long setupStart = millis();
    wm.setTimeout(180); // 3 minutes open Setup-window

    bool res = wm.autoConnect("Luftikus-Setup", "messtechnik");

    if (res && WiFi.status() == WL_CONNECTED) {
      // if connected
      iscon = true;
      blinkColor(strip.Color(0, 255, 0), 3); // 3x green
      display.println(F("WLAN verbunden!"));
      display.println(WiFi.SSID());
      display.println(WiFi.localIP());
      display.display();
      delay(1000);
    } else {
      // in not connected → offline
      blinkColor(strip.Color(255, 0, 0), 3); // 3x red
      display.clearDisplay();
      display.println(F("Setup fehlgeschlagen."));
      display.println(F("Starte offline."));
      display.display();

      WiFi.disconnect(true);
      WiFi.mode(WIFI_OFF);
      delay(1000);
    }
  }

} // end of setup loop
// #############################################################################################100

// runnung loop ################################################################################100
void loop() {
  unsigned long currentMillis = millis(); // save current running time in ms
  if (currentMillis - previousMillis >= interval) { // passed time since last loop
    previousMillis = currentMillis;

    // measured data ---------------------------------------------------------------------------100
    // Read sensor values
    sensors_event_t humidity, temp;
    aht.getEvent(&humidity, &temp);
    temp_aht = temp.temperature;
    hum_aht = humidity.relative_humidity;

    temp_bmp = bmp.readTemperature();
    baro_bmp = bmp.readPressure() / 100.0F;

    if (scd40.readMeasurement()) {
      CO2_scd = scd40.getCO2();
      CO2_scd = CO2_corr_m * CO2_scd;
      temp_scd = scd40.getTemperature();
      hum_scd = scd40.getHumidity();
    }

    // calculate average and offset-correction
    temp_avg = ((temp_aht + temp_bmp + temp_scd) / 3.0) + temp_corr;
    hum_avg = ((hum_aht + hum_scd) / 2.0) + hum_corr;

    // buffer adding up
    co2Buffer = co2Buffer + CO2_scd;
    tempBuffer = tempBuffer + temp_avg;
    humBuffer = humBuffer + hum_avg;
    pressBuffer = pressBuffer + baro_bmp;
    bufferIndex++;

    // Neopixel display ------------------------------------------------------------------------100
    uint8_t r, g, b;
    getCO2Color(CO2_scd, r, g, b);
    // set color on current NeoPixel position (x out of 17)
    red[currentPos] = r;
    green[currentPos] = g;
    blue[currentPos] = b;

    // show pixel an add fading to old ones
    for (int i = 0; i < NUMPIXELS; i++) {
      // Fade
      red[i] *= TRAIL_FADE;
      green[i] *= TRAIL_FADE;
      blue[i] *= TRAIL_FADE;

      strip.setPixelColor(i, (int)red[i], (int)green[i], (int)blue[i]);
    }
    strip.show();

    currentPos = (currentPos + 1) % NUMPIXELS;

    // export data -----------------------------------------------------------------------------100
    if (bufferIndex >= nrVal) {
      float avgCO2 = co2Buffer/10.0;
      float avgTemp = tempBuffer/10.0;
      float avgHum = humBuffer/10.0;
      float avgPress = pressBuffer/10.0;

      sendToServer(avgCO2, avgTemp, avgHum, avgPress);
      bufferIndex = 0; // zurücksetzen
      co2Buffer = tempBuffer = humBuffer = pressBuffer = 0.0;
      avgCO2 = avgTemp = avgHum = avgPress = 0.0;
    }

    // display data on OLED --------------------------------------------------------------------100
    display.clearDisplay();
    display.setCursor(0, 0);
    display.print("CO");
      // lower case 2 in CO₂
      display.setCursor(display.getCursorX(), display.getCursorY() + 3); // etwas tiefer
      display.print("2");display.setCursor(18, 0); display.print(":  ");
    display.print(CO2_scd, 0); display.println(" ppm   ");
    display.println(" ");
    display.print("Temp: "); display.print(temp_avg, 1); display.print(" "); display.print((char)247); display.println("C   ");
    display.println(" ");
    display.print("Hum:  "); display.print(hum_avg, 0); display.println(" %r   ");
    display.println(" ");
    display.print("Baro: "); display.print(baro_bmp, 0); display.println(" mbar   ");

    if (httpCode == 200) {
      display.drawBitmap(120, 15, mail_icon, 8, 10, SSD1306_WHITE);
    } else {
      display.drawBitmap(120, 15, creeper_icon, 8, 10, SSD1306_WHITE);
    }

    if (iscon) {
      display.drawBitmap(120, 0, wifi_on_icon, 8, 10, SSD1306_WHITE);
    } else {
      display.drawBitmap(120, 0, wifi_off_icon, 8, 10, SSD1306_WHITE);
    }
    display.display();

    // seriell output --------------------------------------------------------------------------100
    Serial.print("Temp: "); Serial.print(temp_aht); Serial.print(" | ");
      Serial.print(temp_bmp); Serial.print(" | ");
      Serial.println (temp_scd);
    Serial.print("Feuchte: "); Serial.print(hum_aht); Serial.print(" | ");
      Serial.println(hum_scd);
    Serial.print("Druck: "); Serial.print(baro_bmp); Serial.println(" mbar");
    Serial.print("CO2: "); Serial.print(CO2_scd); Serial.println("ppm");

    Serial.print("Free heap: "); Serial.println(ESP.getFreeHeap());
    Serial.print("http code: "); Serial.println(httpCode);
    Serial.print("iscon:     "); Serial.println(iscon);

    Serial.println("");
  }

} // end of running loop
// #############################################################################################100

// functions -----------------------------------------------------------------------------------100
void getCO2Color(int ppm, uint8_t &r, uint8_t &g, uint8_t &b) {
  if (ppm == 0) {
    // Sensor noch nicht bereit → schwaches Blau
    r = 0;
    g = 0;
    b = 100;
  }
  else if (ppm <= CO2_GREEN_MAX) {
    r = 0;
    g = 255;
    b = 50;
  }
  else if (ppm <= CO2_YELLOW_MAX) {
    float t = (ppm - CO2_GREEN_MAX) / (float)(CO2_YELLOW_MAX - CO2_GREEN_MAX); // 0–1
    r = (uint8_t)(255 * t);
    g = 255;
    b = 0;
  }
  else if (ppm <= CO2_RED_MAX) {
    float t = (ppm - CO2_YELLOW_MAX) / (float)(CO2_RED_MAX - CO2_YELLOW_MAX); // 0–1
    r = 255;
    g = (uint8_t)(255 * (1.0 - t));
    b = 0;
  }
  else if (ppm <= CO2_LILA_MAX) {
    float t = (ppm - CO2_RED_MAX) / (float)(CO2_LILA_MAX - CO2_RED_MAX); // 0–1
    r = 255;
    g = 0;
    b = (uint8_t)(255 * t); // von rot zu lila
  }
  else {
    // MAK-Wert überschritten → tiefes Lila
    r = 180;
    g = 0;
    b = 255;
  }
}

void sendToServer(float co2, float temp, float hum, float press) {
  if (WiFi.status() == WL_CONNECTED) {
    iscon = true;
    HTTPClient http;
    String url;
    url.reserve(200);  // Heap-Fragmentierung vermeiden
    url += serverURL;
    url += "?co2=" + String(co2, 1);
    url += "&temp=" + String(temp, 1);
    url += "&hum=" + String(hum, 1);
    url += "&press=" + String(press, 1);
    
    http.begin(url);
    httpCode = http.GET();
    String payload = http.getString();

    Serial.print("Sende Daten: ");
    Serial.println(url);
    Serial.print("Antwort-Code: ");
    Serial.println(httpCode);
    Serial.print("Antwort: ");
    Serial.println(payload);

    http.end();
  } else {
    iscon = false;
    httpCode = 0;
    display.drawBitmap(120, 0, wifi_off_icon, 8, 10, SSD1306_WHITE);
    display.display();
    Serial.println("WLAN getrennt!");
  }
}