MH-Z19B with ESP32 and ESPHome: UART CO₂ Monitoring and Calibration

Connect an MH-Z19B CO2 sensor to ESP32 and ESPHome via 9600-baud UART. Covers safe 5 V wiring, warm-up, automatic baseline calibration, manual zero calibration, Home Assistant ventilation and troubleshooting.

The MH-Z19B is an optical NDIR CO₂ module with a simple 9600-baud UART interface. Pair it with an ESP32 and ESPHome and you can build a local Home Assistant monitor that measures carbon dioxide concentration in ppm, records overnight ventilation trends and switches a fan when the room becomes stuffy. Unlike the “eCO₂” values produced by VOC sensors, its output comes from a CO₂-specific infrared measurement.

There is one purchasing caveat: Winsen now labels the MH-Z19B discontinued. This guide remains useful if you already own one or have a clearly identified genuine unit, but for a new product you should also investigate currently supported Winsen successors and the Sensirion SCD40/SCD41 family. In this article the part number always means MH-Z19B; electrical and calibration details may differ on MH-Z19C, MH-Z19D or unmarked clones.

The practical success factors are a stable 5 V supply, correctly crossed RX/TX wires, allowing enough warm-up, and choosing an automatic-baseline-calibration policy that fits the room. A sensor that never sees fresh air can slowly teach itself the wrong “zero”.

MH-Z19B Specifications

ParameterManufacturer specification / practical meaning
Measurement principleNDIR — non-dispersive infrared CO₂ measurement
Sensor supply4.5–5.5 V DC; nominally 5 V
Electrical interfaceUART TTL, 3.3 V signalling; also a PWM output
Serial settings9600 baud, 8 data bits, no parity, 1 stop bit
Available factory ranges400–2,000 / 5,000 / 10,000 ppm versions; verify your unit
Published accuracyTypically specified as ±(50 ppm + 5% of reading)
Warm-upManufacturer preheat: about 3 minutes
ResponseT90 under approximately 120 seconds
Power budgetPeak current up to about 150 mA at 5 V
ESPHome platformmhz19 over uart:
Home AssistantNative ESPHome integration; CO₂ entity in ppm

Published values refer to the original MH-Z19B specification. Do not assume a low-cost listing with the same exterior shell is identical internally. A CO₂ graph from an unidentified clone should be verified before you automate ventilation or compare readings with another sensor.

MH-Z19B vs VOC-Derived eCO₂

An MH-Z19B measures infrared absorption associated with CO₂; the BME680/BME688 gas-resistance sensor measures something different and may produce a modelled CO₂-equivalent value. A spray of cleaning product can change a VOC-based eCO₂ estimate without causing the same change in actual CO₂ concentration.

For demand-controlled room ventilation, use a CO₂-specific sensor. It is still a hobby/home-monitoring instrument, not a certified life-safety device and not a carbon-monoxide (CO), smoke or combustible-gas detector. CO₂ and CO are entirely different gases.

See our SCD30 vs SCD40 vs SCD41 comparison for alternative optical CO₂ sensors, and the ESP32 indoor-air-quality station to see how CO₂, VOC, particles and humidity complement rather than replace one another.

ESP32 to MH-Z19B Wiring

The sensor needs a regulated 5 V supply, while its UART signalling is specified as 3.3 V TTL. Powering the MH-Z19B from the ESP32 board’s 3V3 pin is not a valid substitute. A convenient bench setup uses a 5 V USB-powered ESP32 DevKit whose 5 V rail can safely supply the additional sensor current; for a permanent design, verify the board’s power path and regulator/USB limits rather than assuming its 5V pin can supply any load.

MH-Z19B terminalClassic ESP32 DevKitPurpose
Vin / 5VRegulated 5 VSensor power — not 3V3
GNDGNDCommon electrical reference
TXDGPIO16 (ESP32 RX)Sensor transmits → ESP receives
RXDGPIO17 (ESP32 TX)ESP transmits → sensor receives
MH-Z19B           ESP32 DevKit
Vin (5V)  ------- regulated 5 V
GND       ------- GND
TXD       ------> GPIO16  (ESP RX)
RXD       <------ GPIO17  (ESP TX)

Use the printed pin names or the datasheet drawing for your particular module. TX and RX are named from the device’s own point of view, so TXD → ESP RX and RXD ← ESP TX. Reversing them is the most common cause of an apparently dead sensor.

An original MH-Z19B specifies a 3.3 V UART output compatible with ESP32 input levels even though it runs from 5 V. That is not a guarantee for arbitrary breakout boards or lookalike sensors: check their schematic or measure the idle TX voltage before connecting it to an ESP32 GPIO. ESP32 pins are not 5 V tolerant. If a particular board drives TX to 5 V, provide suitable level shifting.

Power-Supply Mistakes That Look Like UART Failures

Winsen specifies an approximately 150 mA peak current requirement. A nominal 5 V rail that droops during an infrared measurement can produce read failures, intermittent resets or implausible data. For a stable installation, use a supply with headroom for the ESP32’s Wi-Fi peaks and the sensor’s peaks, maintain a common ground, avoid very long thin USB leads, and keep motor/relay power transients away from the sensor rail.

Do not try to solve bad 5 V power by altering the CO₂ calibration. If ESPHome can see Wi-Fi but reports repeated UART read or checksum problems, measure the sensor voltage during operation and test with a known-good supply before changing firmware.

Minimal ESPHome Configuration

ESPHome includes a built-in mhz19 platform. The following complete example is for a classic ESP32 DevKit with GPIO16/GPIO17 free. Replace the Wi-Fi and API secrets with those used in your existing ESPHome setup.

esphome:
  name: bedroom-co2
  friendly_name: Bedroom CO2

esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

uart:
  rx_pin: GPIO16    # ESP RX <- sensor TXD
  tx_pin: GPIO17    # ESP TX -> sensor RXD
  baud_rate: 9600

sensor:
  - platform: mhz19
    id: bedroom_mhz19
    co2:
      name: "Bedroom CO2"
    update_interval: 60s
    warmup_time: 180s
    automatic_baseline_calibration: false

The temperature: field is intentionally omitted. ESPHome warns that the MH-Z19’s undocumented temperature output seems rather inaccurate; use an AHT20, SHT4x or BME280 when you want the actual room temperature. The automatic_baseline_calibration: false line is intentional for this example of a continuously occupied bedroom that may not see fresh-air conditions regularly. The ABC decision is explained below.

Why Use GPIO16 and GPIO17?

On the original ESP32 these are commonly convenient pins for a separate hardware UART, leaving the usual USB-to-serial console available for flashing and diagnostics. On an ESP32-C3, S3 or C6, the available pins and hardware UART assignments differ. Choose two suitable free GPIOs on your exact board and do not copy GPIO16/17 blindly.

If you intentionally use the same UART pins that ESPHome uses for serial logging, you may need to change the logger output or disable its UART logging with logger: baud_rate: 0. For a classic DevKit wired as above, no logger change should be needed. ESPHome’s UART documentation explains how TX and RX are assigned and how to debug communication.

Warm-Up Is Not Instant

Winsen specifies approximately three minutes of preheat for the MH-Z19B. ESPHome’s default warmup_time is 75 seconds, which may be enough to discard its worst initial readings, but it is shorter than the manufacturer’s three-minute preheat specification. For a stationary room monitor, the 180-second value shown above is a cautious starting point.

A warm-up filter does not repair an unstable power supply or a sensor that has been left in a highly humid enclosure. Give the unit time to acclimatise after moving it between environments and look at the stable trend, not the first value after power-up.

Automatic Baseline Calibration: Enable or Disable?

The MH-Z19B supports automatic baseline calibration (ABC). ESPHome documents an automatic calibration cycle approximately every 24 hours after power-on. The mechanism assumes that the sensor will periodically experience a low, fresh-air CO₂ concentration so the long-term baseline can be corrected.

Leave ABC enabled or at the sensor’s factory default when the device runs continuously and regularly sees genuinely fresh conditions, for example a room that is reliably aired out for long periods. Do not assume that briefly opening a window guarantees a stable reference at the sensor.

Disable ABC when the module operates in a bedroom, workshop, greenhouse or other setting that may remain above outdoor/background concentration for days. Otherwise the algorithm can eventually interpret elevated indoor CO₂ as its baseline and report misleadingly low readings. Disabling ABC prevents that particular auto-adjustment but does not eliminate sensor ageing or the need for a controlled reference check.

sensor:
  - platform: mhz19
    id: bedroom_mhz19
    co2:
      name: "Bedroom CO2"
    update_interval: 60s
    automatic_baseline_calibration: false

If you omit automatic_baseline_calibration entirely, ESPHome does not send a new ABC setting and the sensor keeps its existing internal state. If you set it explicitly, the command is sent on startup. Changing a YAML flag is not the same as performing a correct zero calibration.

Manual Zero-Point Calibration: Do Not Just Press the Button Outdoors

ESPHome exposes mhz19.calibrate_zero. Its component documentation states that the module should remain for more than 20 minutes in a stable 400 ppm CO₂ environment before that command is issued. The manufacturer’s calibration reference must be treated as a real reference condition, not as a guess.

Outdoor CO₂ is not a universal 400 ppm. Local traffic, nearby people, ventilation outlets and background atmospheric levels can all make a casual outdoor exposure different from the assumed reference. For accurate calibration, use verified reference gas or a controlled setting/known reference appropriate for the exact module and follow its manufacturer procedure. If you cannot establish that condition, do not force zero calibration merely because the numbers seem high.

If you have an appropriate reference setup and deliberately want manual control, ESPHome allows an API action:

api:
  encryption:
    key: !secret api_encryption_key
  actions:
    - action: mhz19_calibrate_zero
      then:
        - mhz19.calibrate_zero: bedroom_mhz19

This creates a callable Home Assistant action. Avoid putting it on an unprotected dashboard button or triggering it automatically on every boot. An erroneous zero command can make every subsequent measurement wrong; follow the sensor’s controlled-reference procedure first.

Should You Change detection_range?

ESPHome supports detection_range: 2000ppm, 5000ppm or 10000ppm. The selected setting persists in the MH-Z19’s non-volatile memory. Sensor variants have different factory-configured ranges, and the printed factory specification of your unit is more reliable than an online listing.

For an ordinary room, leave detection_range unset unless you have identified your sensor and have a clear reason to alter it. A 2,000 ppm full-scale setup may stop telling you how high CO₂ rises after a room exceeds its range; do not claim that changing the YAML magically upgrades the accuracy or optical hardware of a lower-range variant.

Reading the CO₂ Graph in Home Assistant

ESPHome will expose the concentration in ppm through the Native API. Put a current-value card beside a 24-hour history graph; a second graph for the last week makes recurring occupancy/ventilation patterns easier to see. The interesting measurements are the overnight maximum, the time spent above your ventilation threshold and how quickly the room returns toward outdoor/background levels after fresh air is introduced.

Observed behaviourWhat to check
CO₂ rises steadily while occupiedVentilation may be insufficient for that room and occupancy
CO₂ falls after opening a windowConsistent with air exchange; verify trend and placement
CO₂ never approaches outdoor/background levelsCheck ventilation and the ABC strategy
CO₂ sits at exactly one fixed readingInspect serial communication, sensor status and configured range
CO₂ shows long gaps after restartCheck warm-up and ESP32/network uptime
CO₂ changes implausibly fastLook for unstable 5 V supply, read errors or placement issues

One reading above 1,000 ppm is not a diagnosis of a health condition or proof that a room is unsafe. It is a useful ventilation-management threshold. CO₂ is not a direct measurement of every other indoor pollutant; a cooking-related PM2.5 event or a solvent VOC event can occur while CO₂ remains relatively normal.

Home Assistant Fan Automation with Hysteresis

Use separate “fan on” and “fan off” conditions so the fan does not chatter around one threshold. This example turns ventilation on after five minutes above 1,000 ppm and turns it off after five minutes below 800 ppm. Replace both entity IDs with the ones generated in your installation.

alias: Bedroom ventilation - CO2 high
triggers:
  - trigger: numeric_state
    entity_id: sensor.bedroom_co2
    above: 1000
    for: "00:05:00"
actions:
  - action: fan.turn_on
    target:
      entity_id: fan.bedroom_ventilation
mode: single
alias: Bedroom ventilation - CO2 recovered
triggers:
  - trigger: numeric_state
    entity_id: sensor.bedroom_co2
    below: 800
    for: "00:05:00"
actions:
  - action: fan.turn_off
    target:
      entity_id: fan.bedroom_ventilation
mode: single

These are examples for ordinary comfort ventilation, not a substitute for code-compliant commercial ventilation controls. Avoid switching fans or dampers in a way that compromises other HVAC safety requirements, and decide what should happen when the sensor becomes unavailable. A stale value should not be treated as a fresh CO₂ measurement.

Where to Mount the MH-Z19B

Air around the sensor must represent the room, not a person’s breath, the ESP32 regulator’s heated air or a draft from the very fan being controlled. Put it in a ventilated enclosure, away from direct sunlight, windows, radiators, kitchen vapours and local exhaust outlets. Keep the sensor’s air openings unobstructed and follow its non-condensing environmental limits.

The sensor’s onboard temperature value is not a reliable room thermometer. If the project also needs temperature/humidity or dew point, place a separate environmental sensor where it samples the same useful room air but is insulated from ESP32 heat. Our multi-sensor indoor-air-quality station guide covers this layout problem in more detail.

Does MH-Z19B Work on Battery Power?

The MH-Z19B’s infrared measurement and warm-up behaviour make it less suited to an ESP32 node that wakes briefly, measures CO₂ and immediately returns to deep sleep. The sensor requires a regulated 5 V rail, may draw around 150 mA at peaks and needs time after power-up before you should trust its readings.

It is easiest to run as a fixed USB/5 V powered monitor. For a battery-first project, compare the actual system power budget and modes of sensors designed for low-power duty cycling, such as the SCD41 single-shot approach, rather than assuming all CO₂ modules support the same sleep strategy.

Troubleshooting: Sensor Not Responding

SymptomMost useful first test
ESPHome says no response / timeoutVerify sensor has real 5 V, common GND, and crossed TXD/RXD
No UART activityCheck 9600 baud and correct GPIO mapping for your actual ESP32 board
Checksum or frame errorsShorten wiring; inspect supply noise and possible 5 V clone UART output
Serial console becomes corruptDo not share the logger’s UART pins with the sensor without reconfiguring logging
Values blank immediately after rebootExpected while warmup_time discards early measurements
Reported CO₂ implausibly low after weeksReview ABC and whether the room ever reaches fresh-air background
Reported CO₂ suddenly changed after calibrationRevisit the actual reference condition used for zero calibration
CO₂ stuck near top of sensor rangeCheck factory range and room ventilation before changing detection_range
CO₂ fine but temperature wrongUse a dedicated room-temperature sensor
All Home Assistant entities unavailableTest ESP32 uptime, Wi-Fi/API connectivity and the 5 V supply

Enable UART Debugging Temporarily

If the physical wiring is correct but replies are still missing, ESPHome can log UART frames in hexadecimal so you can check whether the ESP is transmitting and the sensor is answering:

uart:
  rx_pin: GPIO16
  tx_pin: GPIO17
  baud_rate: 9600
  debug:
    direction: BOTH

Keep UART debug as a temporary commissioning tool: verbose logs can flood the console, and a TX frame without a corresponding RX frame points toward power, wiring, the sensor or voltage levels rather than Home Assistant discovery. Disable verbose debugging once the node is stable.

MH-Z19B vs SCD40/SCD41: Which Fits the Project?

QuestionMH-Z19BSCD40 / SCD41
Electrical interfaceUART at 9600 baudI²C
Supply arrangementNeeds 5 VSmall low-voltage I²C breakout appropriate to its design
Warm-up/measurement workflowThree-minute manufacturer preheatDifferent periodic and low-power modes; see SCD4x guide
Size/integrationLarger module; easy for existing UART projectsVery compact sensor packages
New design availabilityB version marked discontinued by WinsenCurrent Sensirion family
Actual CO₂ measurementYes, optical NDIRYes, optical/photoacoustic NDIR family

If you already have a genuine MH-Z19B, ESPHome supports it well and it is a useful room monitor. For a fresh BOM, compare currently manufactured parts and their long-term availability rather than buying an old model simply because many tutorials still recommend it.

What to Remember Before Installing It

  • Power the MH-Z19B from a real regulated 5 V supply with peak-current headroom; connect ESP32 and sensor grounds.
  • Keep ESP32 RX connected to the sensor TXD, and ESP32 TX to sensor RXD; use 9600 baud.
  • The genuine B model specifies 3.3 V UART levels, but verify unknown breakout/clone outputs before wiring an ESP32 input.
  • Allow a realistic warm-up, keep the sensor ventilated, and treat its undocumented temperature reading as diagnostic at best.
  • Choose ABC based on whether the installation periodically reaches a genuine fresh-air baseline.
  • Never force zero calibration against an assumed 400 ppm condition you cannot establish.
  • Use two different Home Assistant thresholds for fan-on and fan-off, with a sustained trigger time.
  • Do not use this monitor as a certified fire, CO or other life-safety detector.

Related Guides

Official References

Share your love