CO2 Sensor MH-Z19: Indoor Air Quality Monitor Build
The MH-Z19 CO2 sensor is a NDIR (Non-Dispersive Infrared) sensor that accurately measures carbon dioxide concentration from 400-5000 ppm — essential data for indoor air quality monitoring in Indian offices, classrooms, and homes. CO2 is a direct indicator of ventilation adequacy and plays a significant role in productivity, cognitive performance, and COVID-19 aerosol risk mitigation. This guide builds a complete MH-Z19 indoor air quality monitor with Arduino, OLED display, and IoT connectivity, with specific guidance for India’s energy-efficient but often under-ventilated buildings.
CO2 and Indoor Air Quality in India
Outdoor CO2 concentration globally is approximately 420 ppm. Indoors, CO2 builds up from human respiration and can reach 2000-5000 ppm in under-ventilated spaces. The health and cognitive impacts:
- 400-600 ppm: Outdoor level / ideal indoor — full cognitive performance
- 600-1000 ppm: Good — typical well-ventilated office
- 1000-1500 ppm: Moderate — noticeable impairment in complex decision-making (studies show 15% performance reduction)
- 1500-2500 ppm: Poor — headaches, drowsiness, reduced concentration (common in Indian classrooms after 2 hours)
- 2500-5000 ppm: Very Poor — significant cognitive impairment, WHO recommends immediate ventilation
- >5000 ppm: Hazardous — occupational safety threshold (OSHA PEL)
Indian ASHRAE 62.1 equivalent: BIS IS 3103 recommends minimum 10 m³/hour/person ventilation, targeting CO2 below 1000 ppm above outdoor levels (~1420 ppm absolute). Many Indian offices and schools fail this standard in their AC-only (no fresh air exchange) operation — a common energy-saving measure that compromises air quality.
MH-Z19 Sensor Specifications
- Principle: NDIR (Non-Dispersive Infrared) — gold standard for CO2 measurement
- Measurement range: 0-5000 ppm (MH-Z19B) or 0-10000 ppm (MH-Z19C)
- Accuracy: ±50 ppm + 3% of reading (±75 ppm at 1000 ppm)
- Warm-up time: 3 minutes (readings stabilize after 3 min power-on)
- Response time: T90 ≤120 seconds
- Interfaces: UART (9600 baud) + PWM output + analog voltage (0.4-2V)
- Supply: 3.6V – 5.5V, 40mA average, 150mA peak
- Temperature range: 0-50°C (cover range for Indian indoor environments)
- Auto-calibration: ABC (Automatic Baseline Correction) — assumes lowest reading each week is outdoor 400 ppm. Must be disabled in non-outdoor-facing deployments!
- India price: ₹800-1500
UART and PWM Wiring with Arduino
/* MH-Z19B Wiring:
*
* MH-Z19B Pin Color Arduino
* Vin (5V) Red 5V
* GND Black GND
* TXD Green Pin 10 (SoftwareSerial RX)
* RXD Blue Pin 11 (SoftwareSerial TX)
* PWM Yellow Optional: Any digital pin (alternate reading method)
* AOUT White A0 (0.4-2V analog, ~30ppm accuracy)
*
* NOTE: MH-Z19 is 3.3V UART logic
* For Arduino Uno 5V: Add 1kΩ resistor in series on RXD (to sensor)
* TXD (from sensor) is fine — Arduino reads 3.3V as HIGH
*
* ESP32 (direct 3.3V connection):
* TXD → ESP32 GPIO16 (UART2 RX)
* RXD → ESP32 GPIO17 (UART2 TX)
*/
Arduino Code: UART and PWM Reading
// MH-Z19 CO2 Sensor - Arduino UART Interface
// Library: MHZ19 by Jonathan Dempsey (Library Manager)
// Or: Manual UART implementation (shown below for understanding)
#include <SoftwareSerial.h>
SoftwareSerial mhzSerial(10, 11); // RX (from sensor TXD), TX (to sensor RXD)
// Read CO2 via UART command
int readCO2_UART() {
// Command: 0xFF 0x01 0x86 0x00×5 0x79 (checksum)
byte cmd[] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};
// Clear input buffer
while (mhzSerial.available()) mhzSerial.read();
mhzSerial.write(cmd, 9);
delay(10);
if (mhzSerial.available() < 9) return -1;
byte response[9];
for (int i = 0; i < 9; i++) {
response[i] = mhzSerial.read();
}
// Verify: start byte = 0xFF, command = 0x86
if (response[0] != 0xFF || response[1] != 0x86) return -1;
// Verify checksum
byte checksum = 0;
for (int i = 1; i < 8; i++) checksum += response[i];
checksum = 0xFF - checksum + 1; // Two's complement
if (response[8] != checksum) {
Serial.println("CO2 checksum error!");
return -1;
}
// CO2 ppm = high byte × 256 + low byte
int co2 = (response[2] < 0) {
String category = getCO2Category(co2);
Serial.print(co2); Serial.print(" ppm | "); Serial.println(category);
}
delay(5000); // Read every 5 seconds
}
String getCO2Category(int co2) {
if (co2 < 600) return "Excellent";
if (co2 < 1000) return "Good";
if (co2 < 1500) return "Moderate - Open window";
if (co2 < 2500) return "Poor - Ventilation needed!";
if (co2 < 5000) return "Very Poor - Evacuate!";
return "HAZARDOUS - Leave immediately!";
}
// PWM reading method (simpler, no UART needed):
int readCO2_PWM(int pwmPin) {
// MH-Z19 PWM cycle: 1004ms total
// CO2 (ppm) = 5000 × (Th - 2ms) / 1000ms
// Where Th = HIGH time in milliseconds
long th = pulseIn(pwmPin, HIGH, 1500000L); // 1.5s timeout
if (th == 0) return -1;
int co2 = 5000 * (th/1000L - 2) / 1000;
return constrain(co2, 0, 5000);
}
Zero Calibration Procedure
Proper calibration is critical for accurate CO2 readings. The MH-Z19 factory calibration may drift over time. Re-calibrate annually:
// Calibration Steps:
// 1. Take the sensor outdoors (or near an open window)
// 2. Let it run for 15-20 minutes until stable
// 3. Verify serial readings approach 400-420 ppm
// 4. Only if readings are far off (>50 ppm from expected), run calibration:
// forceCalibration400ppm();
// Important for India:
// - Don't calibrate during Diwali (firecrackers → elevated CO2/particulates)
// - Don't calibrate during winter fog inversion (Delhi Dec-Jan)
// when even outdoor CO2 spikes to 450-500 ppm due to trapped emissions
// - Morning 5-6 AM typically has lowest outdoor CO2 (photosynthesis starts)
// - Best calibration time: Clean monsoon morning after heavy rain
Recommended Product
5V Active Buzzer Module for Arduino
Pair with MH-Z19 for audible CO2 alerts — buzzer triggers when CO2 exceeds 1000 ppm in classrooms and offices to prompt ventilation action.
Category: Audio & Sound Modules
Complete Monitor with OLED and Alerts
// Complete CO2 Monitor: MH-Z19 + DHT22 + SSD1306 OLED
#include <SoftwareSerial.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <DHT.h>
SoftwareSerial mhzSerial(10, 11);
U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0);
DHT dht(4, DHT22);
#define BUZZER_PIN 8
#define LED_GREEN 5
#define LED_YELLOW 6
#define LED_RED 7
void setup() {
mhzSerial.begin(9600);
oled.begin();
dht.begin();
pinMode(BUZZER_PIN, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_YELLOW, OUTPUT);
pinMode(LED_RED, OUTPUT);
delay(30000); // 30s initial warm-up
}
void loop() {
int co2 = readCO2_UART();
float temp = dht.readTemperature();
float hum = dht.readHumidity();
// Update LED indicator
digitalWrite(LED_GREEN, co2 = 1000 && co2 = 1500 ? HIGH : LOW);
// Buzzer alert for poor air quality
if (co2 >= 2000) {
tone(BUZZER_PIN, 880, 200); // Short beep every cycle
}
// OLED display
oled.clearBuffer();
oled.setFont(u8g2_font_logisoso28_tr);
char co2Str[10];
sprintf(co2Str, "%d", co2);
oled.drawStr(0, 35, co2Str);
oled.setFont(u8g2_font_6x10_tr);
oled.drawStr(75, 30, "ppm CO2");
char tempHum[25];
sprintf(tempHum, "%.1fC %.0f%%", temp, hum);
oled.drawStr(0, 50, tempHum);
oled.drawStr(0, 63, getCO2Category(co2).c_str());
oled.sendBuffer();
delay(5000);
}
Ventilation Control Automation
// Automatic ventilation control based on CO2 level
// Controls a exhaust fan relay
#define FAN_RELAY_PIN 3
#define CO2_HIGH_THRESHOLD 1200 // Turn fan ON above this
#define CO2_LOW_THRESHOLD 900 // Turn fan OFF below this (hysteresis)
bool fanRunning = false;
void controlVentilation(int co2) {
if (!fanRunning && co2 > CO2_HIGH_THRESHOLD) {
fanRunning = true;
digitalWrite(FAN_RELAY_PIN, HIGH); // Fan ON
Serial.println("Ventilation FAN ON");
} else if (fanRunning && co2 < CO2_LOW_THRESHOLD) {
fanRunning = false;
digitalWrite(FAN_RELAY_PIN, LOW); // Fan OFF
Serial.println("Ventilation FAN OFF");
}
}
// Connect relay to exhaust fan, window actuator, or HVAC fresh air damper
Recommended Product
8-Channel Solid State Relay Module for Arduino
Control exhaust fans, HVAC dampers, and fresh air systems based on CO2 readings — build a complete automated ventilation control system for offices and classrooms.
Category: Industrial Automation
Frequently Asked Questions
Q: Why does my MH-Z19 read 400 ppm even in a crowded room?
A: ABC (Automatic Baseline Correction) is likely enabled and incorrectly calibrating. If ABC is on and the sensor never experiences true outdoor 400 ppm air, it slowly shifts its zero point — making a 1500 ppm reading appear as 400 ppm. Solution: Disable ABC with the command in the code above, then perform a proper outdoor zero calibration.
Q: Is MH-Z19 accurate enough for COVID-19 ventilation assessment?
A: Yes — CO2 monitoring for ventilation adequacy (target <1000 ppm) matches well with aerosol infection risk. At CO2 levels below 700 ppm, outdoor-level ventilation significantly dilutes aerosols. At >1500 ppm, risk increases substantially. MH-Z19’s ±75 ppm accuracy at 1000 ppm is sufficient for making ventilation decisions. Several Indian states have recommended CO2 monitoring for post-COVID school and office reopening.
Q: How long does the MH-Z19 last?
A: Rated for 5 years / 30,000 hours continuous operation. The infrared source (typically a blackbody IR emitter) gradually weakens over time, causing the sensor to drift high. Some users report accurate readings at 8-10 years with recalibration. In dusty Indian environments, the optical path can get contaminated — clean with dry nitrogen annually for best longevity.
Q: Can I use MH-Z19 in the kitchen to detect CO2?
A: NDIR CO2 sensors like MH-Z19 are NOT suitable for kitchens — they cannot detect methane (LPG, CNG used in Indian kitchens), CO (carbon monoxide from incomplete combustion), or smoke. For kitchen safety, use an MQ-2 (LPG/smoke) or CO detector module. CO2 sensors measure ventilation quality, not combustion gas leaks.
Q: What’s the difference between MH-Z19, MH-Z19B, and MH-Z19C?
A: MH-Z19B: Original version (0-5000 ppm range), common in India. MH-Z19C: Updated version with same specs but improved temperature compensation algorithm and 0-10000 ppm option. Both use identical UART protocol and wiring. Prefer MH-Z19B/C over the original MH-Z19 which had a known firmware bug that caused incorrect readings in some batches.
Add comment