TGGR Lab · firmware 0.11.0

DIY cell culture CO₂ monitor

We built our own CO₂ monitor for our cell culture incubator. It was fun, and you can try too.

The main page reading 4.95 percent with status NORMAL
Calibrated and reading inside the target band.
The monitor in place, reading 6.66 percent with status HIGH CO2
In place beside the incubator, before calibration. It read 6.66 % in a 5 % chamber.

The parts are cheap

The whole build costs a small fraction of a commercial incubator CO₂ monitor.

Ready-made meters will not do

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.

Calibrate before you trust it

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.

Not tied to this board

The sensor needs one input pin, so any ESP32 will do, with or without a screen. Other sensors, such as humidity, can be added.

A bare ESP32-S3 on a breadboard wired to the MH-Z16
The first test ran on a bare ESP32-S3 with no screen.

Parts

PartQtyNotesFind it
ESP32-2432S028R display board1Usually sold with its JST pigtailsSearch AliExpress
Winsen MH-Z16, 0–10 % vol1Must be the 0–10 % rangeSearch AliExpress
DS18B20 waterproof probe1Red, black and yellow leadsSearch AliExpress
DS18B20 screw-terminal adapter1Carries the pull-up resistorSearch AliExpress
Lever connectors3One per MH-Z16 leadSearch AliExpress
Acrylic case for the board1Search AliExpress
USB-C cable and 5 V supply1Search AliExpress
Small box, pipette tip box1 eachJunction box and sensor holder
microSD cardoptionalFor loggingSearch AliExpress

Wiring map

SENSOR HEAD sits inside the incubator JUNCTION BOX on the bench, outside ESP32-2432S028R MH-Z16 CO₂, 0–10 % vol DS18B20 waterproof probe +5 V GND PWM GND DATA VCC lever connectors × 3 DS18B20 adapter pull-up resistor on the adapter GND DAT VCC sensor lead probe lead +5 V GND PWM, 1 cycle/s GND data 3V3 P1 VIN 5 V from USB TX serial console RX serial console GND ground P3 GND ground IO35 CO₂ PWM in IO22 not used IO21 backlight, leave CN1 GND ground IO22 not used IO27 1-Wire data 3V3 probe supply USB-C: power and programming +5 V 3V3 GND CO₂ PWM 1-Wire data
Dots show direction: power runs out to the sensors, signals run back to the board.
The back of the board with its three sockets
Pin names are printed beside each socket.
WireSocketPin
MH-Z16 +5 VP1VIN
MH-Z16 GNDP1GND
MH-Z16 PWMP3IO35
DS18B20 VCC, redCN13V3
DS18B20 GND, blackCN1GND
DS18B20 DATA, yellowCN1IO27

Build order

  1. Flash the firmware

    Do this on the bare board, before any wiring.

    arduino-cli lib install "LovyanGFX" "OneWire" "DallasTemperature"
    cd firmware/CO2_CYD
    ./build.sh upload
  2. Fit the pigtails

    Plug a JST pigtail into P1, P3 and CN1. Jumper pins do not grip these sockets.

    Pigtails in place on the cased board.
    Pigtails in place on the cased board.
  3. Wire the MH-Z16

    Red to VIN, black to GND, yellow to IO35, through the lever connectors. Tie back the other leads.

    Three leads in use, the rest tied back.
    Three leads in use, the rest tied back.
  4. Wire the DS18B20

    Clamp the probe into the adapter. Run VCC to 3V3, GND to GND, DAT to IO27.

  5. Close the junction box

    Sensor leads in one side, board wires out the other.

    Probe adapter on the left, lever connectors on the right.
    Probe adapter on the left, lever connectors on the right.
  6. Power up

    The start-up screen lists each part as it is found. Expect CO₂ SENSOR ONLINE and TEMP PROBE OK.

  7. Calibrate the touch panel

    Open the serial monitor, type c, tap the four targets.

    cd firmware/CO2_CYD
    ./build.sh monitor
  8. Place the sensor head

    Sensors inside the incubator, board and junction box outside.

    A pipette tip box holds both sensors.
    A pipette tip box holds both sensors.
    The cable comes out of the chamber and is strapped to the side panel.
    The cable comes out of the chamber and is strapped to the side panel.

Reading the screens

Tap anywhere to move to the next page. There are four: main, graph, diagnostics and connect.

Main page

Main page with numbered callouts
  1. LIVE while readings arrive
  2. Gauge, 0 to 10 %, with the target band shaded
  3. CO₂ in percent
  4. CO₂ in ppm, raw sensor value in brackets, then the trend
  5. Status
  6. Last 15 minutes: CO₂ in teal, temperature in orange
  7. Temperature

Graph page

Graph page with numbered callouts
  1. CO₂ axis
  2. Temperature axis
  3. Now, minimum, maximum and average
  4. Time span
Main page with two orange door markers above the plot
Two door openings, flagged in orange. CO₂ falls each time and climbs back.

How it measures

The sensor sends one pulse a second. The longer the pulse, the more CO₂.

one cycle, 1004 ms High for 502 ms 5.00 % CO₂ ppm = 100000 × (high − 2 ms) / (cycle − 4 ms)
Room air gives a pulse of a few milliseconds. At 5 % the pulse fills half the cycle.

From power-on to the main page

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"]

How one reading is made

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"]

How the status is decided

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"]

An hour in the incubator

A log from the monitor itself: 384 readings, ten seconds apart.

Noise at rest±17 ppm
Fastest fall2.7 % per min
Back in band4.7 min
Below the band26 of 64 min
CO₂, % 0 1 2 3 4 5 6 target band, 4.5 to 5.5 % door opened 1.84 % 1.86 % 5.02 % Temperature, °C 37.0 37.5 38.0 38.5 37.4 °C 0 10 20 30 40 50 60 minutes
Move the pointer over the chart, or use the arrow keys, to read any moment.

Recovery after the door closes

From 3.33 %From 1.86 %
0 1 2 3 4 5 6 CO₂, % target band from 3.33 % from 1.86 % 0 2 4 6 8 10 minutes after the lowest point
The deeper the drop, the longer the climb. Both are back in the band within five minutes.
Show the data as a table
MinuteCO₂, %Temperature, °CDoor
05.0838.1
15.0838.1
25.0738.1
35.0838.1
45.0838.1
55.0738.1
65.0738.1
75.0738.1
85.0738.1
95.0738.1
104.7538.1opened
112.1438.1opened
121.8738.0
132.7137.9
143.4438.0
153.1438.0opened
162.7838.0
173.1838.0
183.5438.0
193.3338.1
203.5038.1
213.9538.1
224.3738.1
234.6538.1
244.8238.1
254.9538.1
265.0038.2
275.0338.2
285.0438.2
295.0538.2
305.0538.2
315.0538.2
325.0538.2
335.0538.2
345.0538.2
355.0638.2
365.0738.2
375.0838.2
385.0838.2
395.0538.2opened
403.2838.2
413.0838.2
423.4438.2
433.9538.1
444.3738.1
453.9738.1opened
462.9338.0opened
472.5337.9opened
481.9437.9
492.0137.8
502.8337.8
513.5037.7
524.1137.6
534.4737.6
544.7037.6
554.8637.5
564.9437.5
574.9937.5
585.0137.4
595.0137.4
605.0237.4
615.0237.4
625.0237.4
635.0237.4

Ten days in the incubator

One unbroken log from the SD card: 91,739 readings, summarised by the hour.

Running without a restart10.6 days
Time inside the band97 %
Left the band50 times
Time above the bandnone
CO₂, % 0 1 2 3 4 5 6 5.06 % lowest 0.44 % 18 Sep 19 Sep 20 Sep 21 Sep 22 Sep 23 Sep 24 Sep 25 Sep 26 Sep 27 Sep 28 Sep
The line is each hour’s median. The shading spans that hour’s lowest and highest reading.

The sensor needs three days to settle

Resting CO₂, % 4.7 4.8 4.9 5.0 5.1 5.2 4.84 % at the start 5.06 % 18 Sep 19 Sep 20 Sep 21 Sep 22 Sep 23 Sep 24 Sep 25 Sep 26 Sep 27 Sep 28 Sep
Hourly median of readings taken while the door was shut. The axis starts at 4.7 %, not zero, to make the climb visible.

Web dashboard

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 web dashboard showing 5.02 percent, status NORMAL, a history chart and two log files
The dashboard after a door opening, with CO₂ back at 5.02 %.

Calibrate before trusting it

  1. Let the monitor run in an incubator at a known setpoint for three days. The reading climbs while the sensor settles.
  2. Read the raw value in brackets on the main page.
  3. Divide the known value by the raw value. For 5.0 % known and 6.7 % raw that is 0.75.
  4. Set CO2_CAL_GAIN to that number in Config.h and flash again.

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.

Checks and faults

Diagnostics rowHealthyIf not
PWM periodabout 1001 msNot an MH-Z16 signal
edges/s2.0Near 0: PWM wire is off
valid/rejrejected stays at 0Loose PWM or GND wire
DS18B20ok 12-bitProbe not found at power-on
Shows about 5 % at power-on
Normal. The sensor reports 50,000 ppm while it preheats.
Temperature shows n/a
Check the adapter's screw terminals, then power-cycle.
Taps land in the wrong place
Run the touch calibration again.
Cannot join the lab network
After 60 seconds it starts its own network, CO2-monitor. Join it and open http://192.168.4.1/.