We built our own CO₂ monitor for our cell culture incubator. It was fun, and you can try too.
The whole build costs a small fraction of a commercial incubator CO₂ monitor.
Most ready-made CO₂ meters, including those on AliExpress, stop at 5,000 ppm. An incubator runs at 50,000 ppm, so they are useless here. The MH-Z16 measures up to 100,000 ppm.
Out of the box this sensor read about a third high inside the incubator. Compare it with a known incubator or a trusted meter first.
The sensor needs one input pin, so any ESP32 will do, with or without a screen. Other sensors, such as humidity, can be added.
| Part | Qty | Notes | Find it |
|---|---|---|---|
| ESP32-2432S028R display board | 1 | Usually sold with its JST pigtails | Search AliExpress |
| Winsen MH-Z16, 0–10 % vol | 1 | Must be the 0–10 % range | Search AliExpress |
| DS18B20 waterproof probe | 1 | Red, black and yellow leads | Search AliExpress |
| DS18B20 screw-terminal adapter | 1 | Carries the pull-up resistor | Search AliExpress |
| Lever connectors | 3 | One per MH-Z16 lead | Search AliExpress |
| Acrylic case for the board | 1 | Search AliExpress | |
| USB-C cable and 5 V supply | 1 | Search AliExpress | |
| Small box, pipette tip box | 1 each | Junction box and sensor holder | |
| microSD card | optional | For logging | Search AliExpress |
| Wire | Socket | Pin |
|---|---|---|
| MH-Z16 +5 V | P1 | VIN |
| MH-Z16 GND | P1 | GND |
| MH-Z16 PWM | P3 | IO35 |
| DS18B20 VCC, red | CN1 | 3V3 |
| DS18B20 GND, black | CN1 | GND |
| DS18B20 DATA, yellow | CN1 | IO27 |
Do this on the bare board, before any wiring.
arduino-cli lib install "LovyanGFX" "OneWire" "DallasTemperature" cd firmware/CO2_CYD ./build.sh upload
Plug a JST pigtail into P1, P3 and CN1. Jumper pins do not grip these sockets.
Red to VIN, black to GND, yellow to IO35, through the lever connectors. Tie back the other leads.
Clamp the probe into the adapter. Run VCC to 3V3, GND to GND, DAT to IO27.
Sensor leads in one side, board wires out the other.
The start-up screen lists each part as it is found. Expect CO₂ SENSOR ONLINE and TEMP PROBE OK.
Open the serial monitor, type c, tap the four targets.
cd firmware/CO2_CYD ./build.sh monitor
Sensors inside the incubator, board and junction box outside.
Tap anywhere to move to the next page. There are four: main, graph, diagnostics and connect.
The sensor sends one pulse a second. The longer the pulse, the more CO₂.
flowchart TD
A["Power on"] --> B["Start the display"]
B --> C["Load touch calibration"]
C --> D["Start CO2 capture on IO35"]
D --> E["Find the probe on IO27"]
E --> F["Look for SD card and Wi-Fi"]
F --> G{"First CO2 reading in,<br/>or 12 s passed?"}
G -- no --> G
G -- yes --> H["Main page"]flowchart TD
A["Pulse edge on IO35"] --> B{"Glitch, under 200 µs?"}
B -- yes --> X["Ignore"]
B -- no --> C["Time the full cycle"]
C --> D{"Cycle 900 to 1100 ms<br/>and timings agree?"}
D -- no --> R["Reject, keep the last value"]
D -- yes --> E["Convert pulse length to ppm"]
E --> F["Average 8 cycles"]
F --> K["Apply your calibration factor"]
K --> G{"Sensor warm?"}
G -- no --> H["Show only"]
G -- yes --> I["Show, log, watch for door events"]flowchart TD
A{"Valid reading in<br/>the last 10 s?"}
A -- no --> E["SENSOR ERROR"]
A -- yes --> D{"Sensor warm?"}
D -- no --> W["WARMING UP"]
D -- yes --> O{"Raw reading at or<br/>above 99,000 ppm?"}
O -- yes --> OR["OVER RANGE"]
O -- no --> F{"Corrected CO2"}
F -- "below 4.5 %" --> L["LOW CO2"]
F -- "4.5 to 5.5 %" --> N["NORMAL"]
F -- "above 5.5 %" --> H["HIGH CO2"]A log from the monitor itself: 384 readings, ten seconds apart.
| Minute | CO₂, % | Temperature, °C | Door |
|---|---|---|---|
| 0 | 5.08 | 38.1 | |
| 1 | 5.08 | 38.1 | |
| 2 | 5.07 | 38.1 | |
| 3 | 5.08 | 38.1 | |
| 4 | 5.08 | 38.1 | |
| 5 | 5.07 | 38.1 | |
| 6 | 5.07 | 38.1 | |
| 7 | 5.07 | 38.1 | |
| 8 | 5.07 | 38.1 | |
| 9 | 5.07 | 38.1 | |
| 10 | 4.75 | 38.1 | opened |
| 11 | 2.14 | 38.1 | opened |
| 12 | 1.87 | 38.0 | |
| 13 | 2.71 | 37.9 | |
| 14 | 3.44 | 38.0 | |
| 15 | 3.14 | 38.0 | opened |
| 16 | 2.78 | 38.0 | |
| 17 | 3.18 | 38.0 | |
| 18 | 3.54 | 38.0 | |
| 19 | 3.33 | 38.1 | |
| 20 | 3.50 | 38.1 | |
| 21 | 3.95 | 38.1 | |
| 22 | 4.37 | 38.1 | |
| 23 | 4.65 | 38.1 | |
| 24 | 4.82 | 38.1 | |
| 25 | 4.95 | 38.1 | |
| 26 | 5.00 | 38.2 | |
| 27 | 5.03 | 38.2 | |
| 28 | 5.04 | 38.2 | |
| 29 | 5.05 | 38.2 | |
| 30 | 5.05 | 38.2 | |
| 31 | 5.05 | 38.2 | |
| 32 | 5.05 | 38.2 | |
| 33 | 5.05 | 38.2 | |
| 34 | 5.05 | 38.2 | |
| 35 | 5.06 | 38.2 | |
| 36 | 5.07 | 38.2 | |
| 37 | 5.08 | 38.2 | |
| 38 | 5.08 | 38.2 | |
| 39 | 5.05 | 38.2 | opened |
| 40 | 3.28 | 38.2 | |
| 41 | 3.08 | 38.2 | |
| 42 | 3.44 | 38.2 | |
| 43 | 3.95 | 38.1 | |
| 44 | 4.37 | 38.1 | |
| 45 | 3.97 | 38.1 | opened |
| 46 | 2.93 | 38.0 | opened |
| 47 | 2.53 | 37.9 | opened |
| 48 | 1.94 | 37.9 | |
| 49 | 2.01 | 37.8 | |
| 50 | 2.83 | 37.8 | |
| 51 | 3.50 | 37.7 | |
| 52 | 4.11 | 37.6 | |
| 53 | 4.47 | 37.6 | |
| 54 | 4.70 | 37.6 | |
| 55 | 4.86 | 37.5 | |
| 56 | 4.94 | 37.5 | |
| 57 | 4.99 | 37.5 | |
| 58 | 5.01 | 37.4 | |
| 59 | 5.01 | 37.4 | |
| 60 | 5.02 | 37.4 | |
| 61 | 5.02 | 37.4 | |
| 62 | 5.02 | 37.4 | |
| 63 | 5.02 | 37.4 |
One unbroken log from the SD card: 91,739 readings, summarised by the hour.
Open the Connect page on the monitor and scan its QR codes with a phone. One joins the monitor's own network and the other opens the page. The page shows the live reading and the history, sets the monitor's clock from the phone, and offers every log file on the SD card for download.
The monitor starts a new numbered log file at each power-on, such as co2_0007.csv, with one row every ten seconds.
The firmware ships uncorrected, with a gain of 1.0. The unit in these photos needed 0.72. Repeat the check from time to time.
| Diagnostics row | Healthy | If not |
|---|---|---|
| PWM period | about 1001 ms | Not an MH-Z16 signal |
| edges/s | 2.0 | Near 0: PWM wire is off |
| valid/rej | rejected stays at 0 | Loose PWM or GND wire |
| DS18B20 | ok 12-bit | Probe not found at power-on |