Some hardware links on this page are affiliate links. As an Amazon Associate I earn from qualifying purchases. We may earn a commission if you buy through them, at no extra cost to you. See the affiliate disclosure.
An MQ-7 does not report carbon monoxide while its heater sits at one voltage. The datasheet cycle is 60 seconds at 5 V (purge) and 90 seconds at 1.4 V (detect). You sample the sensing element at the end of the low phase, once per 150 second cycle. This pack drives that cycle with a logic-level MOSFET, publishes a ppm figure to Home Assistant, and sends a high-priority push when the reading stays above a threshold you choose.
Wi-Fi credentials stay in secrets.yaml. Never paste SSIDs, PSKs, API keys, or OTA passwords into a published YAML.
This is a supplemental sensor. Smoke and gas alarm linker ties listed smoke and gas detectors into one siren, light, and notify path. Point this node’s gas binary at that pack if you want the house-wide response. Neither page replaces a listed, battery-backed CO alarm.
Safety note
An MQ-7 needs calibration, drifts with temperature, humidity, and age, and false-triggers on alcohol, solvent, and cooking fumes. It goes silent when the ESP32 loses power or Wi-Fi. The ppm number is a relative, calibrated estimate, not a medical or code reading. Keep a listed carbon monoxide alarm that still sounds with Home Assistant off. Treat a push from this node as “go look at the listed alarm and the room,” not as the alarm itself.
What problem it solves
You want a CO trend in Home Assistant from a cheap sensor, with the heater actually cycling the way the part is specified, and a single high-priority alert when your threshold holds — without pretending the module is a certified detector.
How it works
- Purge — GPIO drives the MOSFET on for 60 seconds. The heater sees about 5 V and burns off gases adsorbed during the previous detect phase.
- Detect — MOSFET off for 90 seconds. Heater current falls through a series resistor so the heater sits near 1.4 V (a 75 Ω part lands around 1.4–1.5 V with a cold heater near 33 Ω; measure it).
- Sample — at the end of those 90 seconds the ADC is updated once. A template turns that voltage into sensor resistance, then into ppm using your clean-air resistance. The next purge starts on the following cycle. The first number appears about 150 seconds after boot, and the manufacturer wants 48 hours of powered cycling before you trust the baseline.
- Publish — one
sensorwithdevice_class: carbon_monoxideand unit ppm. Diagnostic voltage stays internal. The cycle script queues its own next run, so the sample stays at the end of the low phase instead of drifting. - Alert — a template
binary_sensorwithdevice_class: gasturns on only after ppm stays abovealert_ppm(pack default 50) for two minutes, so one noisy cycle does not page you. Home Assistant sends a critical Companion push from that binary. The threshold is yours. It is not a UL trip point.
Most breakout boards wire the heater straight to VCC. Their digital pin is a comparator, not a cycled reading. The cycle does not happen until the heater path can be switched. The adapter board does that on a PCB. The wiring table is the same circuit with a through-hole MOSFET.
Hardware notes
- Board. A 30-pin ESP32. Heater gate is GPIO25. ADC is GPIO34 (input only). Leave the flash pins and the boot straps alone. An ESP8266 can run the same timing, but A0 only accepts about 1 V, so the divider has to be stiffer and
ao_scalehas to match. The YAML below is the ESP32. - MOSFET. IRLZ44N or another N-channel that is actually on at 3.3 V. A non-logic-level part (a plain IRF520 module) will not pass the heater current from a 3.3 V GPIO. A 100 kΩ gate-source pulldown keeps the heater out of the 5 V phase while the pin is floating at boot.
- 5 V. The heater draws on the order of 150 mA during purge. Take 5 V from the dev board’s 5 V pin only if the USB supply can hold it. A laptop data port often browns the chip out. Do not power the heater from 3V3.
- ADC divider. Module AO and a bare sensor’s load node can swing toward 5 V. Two 100 kΩ resistors halve that before GPIO34. Set
ao_scaleto2.0so the template reconstructs the load voltage. If your measured AO never exceeds 3 V, use a scale of1.0and skip the divider. - Resistor wattage. The 75 Ω part dissipates under 0.2 W in the low phase and almost nothing in the high phase. Use at least 0.5 W so it stays cool in a closed box.
Wiring
Heater (purge = MOSFET on):
| Node | Connection |
|---|---|
| Heater pin 1 | 5 V |
| Heater pin 2 | MOSFET drain, and one end of the 75 Ω resistor |
| 75 Ω other end | GND |
| MOSFET source | GND |
| MOSFET gate | GPIO25 through 220 Ω, with 100 kΩ from gate to GND |
Sensing element (sample only while the heater is in the low phase):
| Node | Connection |
|---|---|
| Sensor supply | 5 V |
| Load node | 10 kΩ to GND. This voltage is AO |
| AO to GPIO34 | 100 kΩ series |
| GPIO34 to GND | 100 kΩ |
ESP32 GND and the 5 V supply GND are the same node.
Snippet
Substitutions, not a live house. Credentials stay in secrets.yaml.
substitutions:
device_name: mq7-co
friendly_name: MQ-7
heater_pin: GPIO25
adc_pin: GPIO34
ao_scale: "2.0"
load_ohms: "10000"
# Replace after a clean-air run. This starter is not a calibration.
clean_air_ohms: "20000"
alert_ppm: "50"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
on_boot:
priority: -100
then:
- script.execute: heater_cycle
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
output:
- platform: gpio
id: heater_out
pin: ${heater_pin}
switch:
- platform: output
id: heater
output: heater_out
name: "Heater purge"
internal: true
script:
- id: heater_cycle
mode: queued
then:
- switch.turn_on: heater
- delay: 60s
- switch.turn_off: heater
- delay: 90s
- component.update: mq7_adc
- component.update: mq7_co
- script.execute: heater_cycle
sensor:
- platform: adc
id: mq7_adc
pin: ${adc_pin}
attenuation: 12db
update_interval: never
internal: true
- platform: template
id: mq7_co
name: "Carbon monoxide"
unit_of_measurement: ppm
device_class: carbon_monoxide
state_class: measurement
accuracy_decimals: 0
update_interval: never
lambda: |-
// Fit published with the ESPHome MQ-7 device entry.
// x = ln(Rs / clean_air_ohms). Set clean_air_ohms from a real clean-air run.
float v = id(mq7_adc).state;
if (isnan(v) || v < 0.05f) return NAN;
float v_ao = v * ${ao_scale};
if (v_ao >= 4.95f) return NAN;
float rs = ${load_ohms} * (5.0f - v_ao) / v_ao;
float ratio = rs / ${clean_air_ohms};
if (ratio <= 0.0f) return NAN;
float x = logf(ratio);
return expf(-0.685204f - 2.67936f * x - 0.488075f * x * x - 0.07818f * x * x * x);
binary_sensor:
- platform: template
name: "CO above threshold"
device_class: gas
lambda: |-
if (isnan(id(mq7_co).state)) return false;
return id(mq7_co).state > ${alert_ppm};
filters:
- delayed_on: 120s
mode: queued plus the trailing script.execute starts the next purge only after the 90 second low phase and the ADC update. A second boot call while a cycle is running waits; it does not overlap the heater. The curve still depends on clean_air_ohms. Leave the starter value in and the ppm figure is only a shape demo.
High-priority push. Replace the entity id with the gas binary ESPHome created (the example name is binary_sensor.mq7_co_above_threshold once the device name is mq7-co):
trigger:
- platform: state
entity_id: binary_sensor.mq7_co_above_threshold
to: "on"
action:
- service: notify.mobile_app
data:
title: "Carbon monoxide sensor"
message: "MQ-7 ppm stayed above the threshold. Check a listed CO alarm."
data:
push:
interruption-level: critical
Setup outline
- Build the heater switch and the ADC divider. With the MOSFET forced off, measure the heater voltage and confirm it is near 1.4 V, not 5 V. With it forced on, confirm about 5 V, and confirm GPIO34 never sees 5 V.
- Copy the secrets keys into
secrets.yaml. Flash over USB. Watch one full cycle in the log: purge, then detect, then a ppm update. - Let it cycle in clean outdoor air for the manufacturer’s burn-in (at least 48 hours after long storage). Read the sensor resistance at the end of a low phase and put that number in
clean_air_ohms. Flash again. - Adopt the device in Home Assistant. Confirm ppm updates about every 150 seconds, not every second.
- Point the automation at the gas binary. Trip it with a high
alert_ppmyou can cross on purpose (isopropyl alcohol near the sensor will usually do it — ventilate, and do not use this as a sensitivity calibration). Confirm one critical push, then clear the vapor and confirm the binary returns off.
Troubleshooting
- ppm never updates. The script is not running, or the template updates before the ADC. Keep
component.updateon the ADC first. The first sample is one cycle after boot. - Heater is always at 5 V. The gate pulldown is missing or the GPIO is stuck high. The low phase is MOSFET off.
- Heater voltage in the low phase is nowhere near 1.4 V. The series resistor does not match this heater. Measure and change the 75 Ω part. Do not chase ppm until that voltage is right.
- GPIO or the ADC reads nonsense, or the board reboots during purge. AO is wired straight to a GPIO, or the USB supply sags. Fit the divider and use a charger that holds 5 V at 150 mA.
- ppm is a huge number in clean air.
clean_air_ohmsis still the starter. Measure Rs outdoors at the end of a low phase and substitute that. - Alert on toast or solvent. Expected for this sensor. Raise the threshold, move the module off the cooktop, and keep the listed alarm as the authority.
- You wanted every siren in the house. That is Smoke and gas alarm linker. This page only makes the reading and one push.
Credits
Heater timing and the ppm curve follow the ESPHome MQ-7 device entry. The same heater circuit is on the MQ-7 adapter board. This write-up is an original ESP32 cycle (60 seconds high, 90 seconds low, sample at the end) plus the Home Assistant alert. It is not a copy of those projects and it is not a certified CO alarm.