Due to suspecting a slightly stuffy air in my office, having a CO2 sensor seemed like a good idea.

I decided to build it using an ESP32 module with an MH-Z19b CO2 sensor due to how easy it would be to integrate in Home Assistant with ESPHome in the future should I want to do so.

In a first stage the sensor is to be USB powered, with no integrated display and with a DHT22 added to also provide a temperatura readout (its relative humidity readout is not used at this point due to being borked on this specific sensor).

Perf board with DHT22 Perf board with DHT22 Schematic Particularyl horrible schematic

In a second stage I’d like to integrate an e-ink screen and possibly make it battery powered, heavily inspired by this project.

This may not be possible with the CO2 sensor: the one specifically used is designed to be kept powered on for a long time, and in fact should calibrate itself every 24 hours on the lowest reading detected by assuming that it should be equal to 400 ppm - this would not work well with a device that frequently goes to sleep, or it might compromise battery life too much by itself.

Here’s also the current version of the controller’s program, with a sample of the generated webpage.

#include <WiFi.h>
#include <DHT.h>
#include <MHZ19.h>


#define RX_PIN 16                       // Rx pin which the MHZ19 Tx pin is attached to
#define TX_PIN 17                       // Tx pin which the MHZ19 Rx pin is attached to
#define DHTTYPE DHT22                   // DHT 22 (AM2302)
#define DHTPIN 23
#define BAUDRATE 9600
#define WLAN_SSID "yourSSID"
#define WLAN_PASS "yourPASSWORD"

// Constructors
MHZ19 myMHZ19;                          // Constructor for CO2 sensor
DHT dht(DHTPIN, DHTTYPE);               // Constructor for temp and RH sensor

// SSID, Password, hostname
const char* ssid = WLAN_SSID;           // Enter your SSID here
const char* password = WLAN_PASS;       // Enter your Password here
String hostname = "co2sensor";

// Sensor readout
unsigned long currentMillis = 0;
const int co2Interval = 10000;
const int tempInterval = 10000;
const int rhInterval = 10000;
const int ipInterval = 10000;
unsigned long previousCo2 = 0;
unsigned long previousTemp = 0;
unsigned long previousRH = 0;
unsigned long previousIp = 0;
int co2 = 1234;
float temp = 10;
float rh = 10;
String HTML;

// Used for HTML page
int co2Level1 = 1000;
int co2Level2 = 1500;
int co2Level3 = 2000;
String string1 = "<" + String(co2Level1) + " ppm: Typical level for occupied spaces with good air exchange.";
String string2 = String(co2Level1) + "-" + String(co2Level2) + " ppm: Start ventilation of the room.";
String string3 = String(co2Level2) + "-" + String(co2Level3) + " ppm: Level associated with complaints of drowsiness and poor air. Ventilation recommended!";
String string4 = ">" + String(co2Level3) + " ppm: Level associated with headaches, sleepiness, and stagnant, stale, stuffy air. Ventilate!";


WiFiServer server(80);  // Object of WebServer(HTTP port, 80 is defult)

void setup() {
  Serial.begin(115200);
  Serial.println("Attempting connection to ");
  Serial.println(ssid);

  // Set hostname
  WiFi.setHostname(hostname.c_str());

  // Connect to your wi-fi modem
  WiFi.begin(ssid, password);

  // Check wi-fi is connected to wi-fi network
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected successfully");
  Serial.print("Got IP: ");
  Serial.println(WiFi.localIP());  //Show ESP32 IP on serial

  // Attempt at light power saving while preserving connectivity https://www.mischianti.org/it/2021/03/06/esp32-risparmio-energetico-pratico-gestire-wifi-e-cpu-1/
  setWiFiPowerSavingMode();

  // Start web server
  server.begin();
  Serial.println("HTTP server started");
  delay(100);

  Serial.print("hostname: ");
  Serial.println(WiFi.getHostname());

  // Setup second serial port (to co2 sensor) and start it
  Serial2.begin(BAUDRATE, SERIAL_8N1, RX_PIN, TX_PIN);                // (Uno example) device to MH-Z19 serial start
  myMHZ19.begin(Serial2);                                             // *Serial(Stream) refence must be passed to library begin().

  // Enable co2 sensor autocalibration - autoCalibration(false) to turn it off like in this case
  myMHZ19.autoCalibration(false);

  // Start temperature and humidity sensor
  dht.begin();
}

void loop() {
  currentMillis = millis();
  updateCo2();
  updateTemp();
  updateIp();
  // updateRH(); Deactivated due to faulty sensor


  WiFiClient client = server.available();
  if (client)
  {
    Serial.println("\n[Client connected]");
    while (client.connected())
    {
      // read line by line what the client (web browser) is requesting
      if (client.available())
      {
        String line = client.readStringUntil('\r');
        Serial.print(line);
        // wait for end of client's request, that is marked with an empty line
        if (line.length() == 1 && line[0] == '\n')
        {
          client.println(prepareHtmlPage());
          break;
        }
      }
    }

    while (client.available()) {
      // but first, let client finish its request
      // that's diplomatic compliance to protocols
      // (and otherwise some clients may complain, like curl)
      // (that is an example, prefer using a proper webserver library)
      client.read();
    }

    // close the connection:
    client.stop();
    Serial.println("[Client disconnected]");
  }
}

void setWiFiPowerSavingMode() {
  WiFi.setSleep(true);
}

void updateIp() {
  if (currentMillis - previousIp >= ipInterval) {
    previousIp += ipInterval;
    Serial.println(WiFi.localIP());
  }
}

void updateCo2()
{
  if (currentMillis - previousCo2 >= co2Interval) {
    previousCo2 += co2Interval;
    //co2 = 1500 + random(-1000, 1000);             // Fake data to test webpage
    co2 = myMHZ19.getCO2();                         // Request CO2 (as ppm)
    Serial.println("Co2 = " + String(co2));
    HTML = prepareHtmlPage();
  }
}

void updateTemp()
{
  if (currentMillis - previousTemp >= tempInterval) {
    previousTemp += tempInterval;
    temp = dht.readTemperature();
    Serial.println("Temp = " + String(temp));
  }
}

void updateRH()
{
  if (currentMillis - previousRH >= rhInterval) {
    previousRH += rhInterval;
    rh = dht.readHumidity();
    Serial.println("RH = " + String(rh));
  }
}

// Webpage preparation
// Bits and bobs stolen from https://randomnerdtutorials.com/esp32-web-server-arduino-ide/
String prepareHtmlPage()
{
  String htmlPage;
  htmlPage.reserve(1024);               // prevent ram fragmentation
  htmlPage = F("HTTP/1.1 200 OK\r\n"
               "Content-Type: text/html\r\n"
               "Connection: close\r\n"  // the connection will be closed after completion of the response
               "Refresh: 15\r\n"         // refresh the page automatically every 15 sec
               "\r\n"
               "<!DOCTYPE HTML>"
               "<html>"
               "<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
               "<style>html { font-family: Helvetica; text-decoration: none; font-size: 24px; margin: 2px; } </style>"
               "</head>"
               "<body style=""background-color:cornsilk;""><h1>CO<sub>2</sub> monitor</h1>"
               "<br> ");
  htmlPage += "<p>CO<sub>2</sub>: " + String(co2) + " ppm </p>";
  htmlPage += "<p>Temp: " + String(temp) + " &#xB&#x43 </p>"; // had to write ° and C as two separate hex characters
  // Advisory messages
  if (co2 < co2Level1) {  // Warning levels stolen from https://www.technoline-berlin.de/product/en/WL_1030
    htmlPage += "<p style=\"font-size:50px\">&#x1F44D;</p>";
    htmlPage += "<p>" + string1 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string2 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string3 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string4 + "</p>";
  }
  if (co2 >= co2Level1 && co2 < co2Level2) {
    htmlPage += "<p style=\"font-size:50px\">&#x1FA9F;</p>";
    htmlPage += "<p style=\"color:grey;\">" + string1 + "</p>";
    htmlPage += "<p>" + string2 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string3 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string4 + "</p>";
  }
  if (co2 >= co2Level2 && co2 < co2Level3) {
    htmlPage += "<p style=\"font-size:50px\">&#x1F44E;</p>";
    htmlPage += "<p style=\"color:grey;\">" + string1 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string2 + "</p>";
    htmlPage += "<p>" + string3 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string4 + "</p>";
  }
  if (co2 >= co2Level3) {
    htmlPage += "<p style=\"font-size:50px\">&#x1F480;</p>";
    htmlPage += "<p style=\"color:grey;\">" + string1 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string2 + "</p>";
    htmlPage += "<p style=\"color:grey;\">" + string3 + "</p>";
    htmlPage += "<p>" + string4 + "</p>";
  }
  htmlPage += F("</html>"
                "\r\n");
  return htmlPage;
}