Contents — 25 sections
Project Overview
An LPG and CO detector that does the thing most projects skip — it acts. On a confirmed leak it closes the gas valve, kills the ignition source, sounds a 100 dB alarm and ventilates, all before it thinks about the network.
A gas alarm that only beeps is a partial solution. The dangerous window with LPG is short: the lower explosive limit is around 1.8 % by volume, and a leaking regulator can reach that in a small kitchen in minutes. What matters is closing the source and removing the ignition risk, and doing both without waiting for a phone, a router or a person.
The sensing uses two different sensors because they detect different threats. An MQ-2 responds to LPG, propane, methane and hydrogen — the explosion risk. An MQ-7 measures carbon monoxide, which is the poisoning risk from incomplete combustion and is odourless, colourless and kills people in their sleep. Neither substitutes for the other, and a single-sensor build only covers half the problem.
MQ-series sensors are the right tool here despite their limitations, because for a threshold alarm you do not need quantitative accuracy — you need a reliable "concentration is rising fast" signal, which they provide well. But they demand things people routinely skip: a 24–48 hour burn-in when new, a clean-air R0 calibration, a stable 5 V for the heater, and — for the MQ-7 — an alternating heater cycle that most tutorials get wrong.
The action chain is ordered by how fast each step reduces risk and how likely it is to work. Valve closed first, ignition sources cut second, siren third, extraction fan fourth, and only then the network. Every one of the first four works with the Wi-Fi down.
What this project does
- Detects combustible gas (LPG, methane, propane) and carbon monoxide with separate dedicated sensors.
- Closes a motorised gas valve within about two seconds of a confirmed leak.
- Cuts power to a relay-controlled ignition source (electric hob, extractor motor) before ventilating.
- Sounds a 100 dB siren locally, independent of any network.
- Runs an extraction fan on a non-sparking circuit to clear the space.
- Runs the MQ-7 heater on its correct 60 s / 90 s alternating cycle.
- Publishes alerts and continuous readings over MQTT, and retries until acknowledged.
Real-World Applications
| Setting | How it is used |
|---|---|
| Domestic kitchens | The primary case, especially where an LPG cylinder is used indoors. |
| Small restaurants and food carts | Multiple cylinders, high usage and often poor ventilation — the highest-risk environment of all. |
| Boiler and water-heater rooms | CO from incomplete combustion is the specific threat here, and it gives no warning. |
| Basements and voids | LPG is heavier than air and collects at low points, so a detector at ceiling height would never see it. |
| Workshops using propane or acetylene | Combined with a ventilation interlock so the extractor is proven running before the torch can be lit. |
| Caravans and boats | Small sealed spaces with bottled gas, where a leak concentrates very quickly. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Dual-gas detection — explosive and toxic threats have separate sensors and separate thresholds.
- Rate-of-rise detection alongside absolute thresholds, catching a fast leak before it reaches the alarm level.
- Correct MQ-7 dual-heater cycle (1.4 V for 90 s, 5 V for 60 s) driven by PWM.
- Clean-air R0 calibration stored in NVS, with a documented recalibration procedure.
- Ordered action chain with the network last, so nothing safety-critical depends on it.
- Self-test on boot that verifies each sensor responds and each actuator moves.
- Sensor-failure detection — a reading pinned at either rail is treated as a fault, not as clean air.
- Latching alarm requiring a manual reset after a real event.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 8–12 hours |
| Indicative build cost | ₹5,200 – ₹8,000 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Arduino C++ and analogue reading
- Understanding of PWM used to control a heater voltage
- Relay wiring and load switching
- Basic gas safety awareness — this project touches a real hazard
- MQTT basics
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| MQ-2 combustible gas / smoke sensor Needs 24–48 h burn-in and a stable 5 V; the heater alone draws ~150 mA. | 300–10000 ppm LPG, propane, methane, hydrogen, smoke; analogue + digital output | 1 | ₹150 |
| MQ-7 carbon monoxide sensor Requires an alternating 60 s / 90 s heater cycle to read correctly — a constant 5 V gives wrong numbers. | 20–2000 ppm CO, dual-heater cycle (1.4 V / 5 V), analogue output | 1 | ₹260 |
| 4-channel opto-isolated relay board All four coils energised draw ~280 mA — do not power from the MCU 5 V pin. | 4 × SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel | 1 | ₹280 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| 12 V 5 A SMPS adapter Fuse the 12 V rail at ~1.5× your calculated draw. | 100–240 VAC in, 12 V ±5 %, 5 A, 60 W, DC 5.5 × 2.1 mm barrel | 1 | ₹650 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| 12 V motorised gas ball valve Must be rated for gas, not just water. A water valve's seals will not do. | DN15/DN20, brass, 8 W, with limit switches | 1 | ₹2,800 |
| 100 dB piezo siren | 12 V, 110 mA | 1 | ₹320 |
| Explosion-rated extraction fan (or existing hood) A standard fan motor brush can be the ignition source you are trying to avoid. | Non-sparking motor | 1 | ₹1,800 |
| Manual reset button (key or recessed) | NO momentary | 1 | ₹90 |
Estimated total: ₹7,460, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| MQ-2 combustible gas / smoke sensor | 300–10000 ppm LPG, propane, methane, hydrogen, smoke; analogue + digital output | 5 V (heater) | Analogue + comparator digital | Datasheet |
| MQ-7 carbon monoxide sensor | 20–2000 ppm CO, dual-heater cycle (1.4 V / 5 V), analogue output | 5 V | Analogue (PWM heater) | Datasheet |
| 4-channel opto-isolated relay board | 4 × SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel | 5 V coil | 4× digital | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 12 V 5 A SMPS adapter | 100–240 VAC in, 12 V ±5 %, 5 A, 60 W, DC 5.5 × 2.1 mm barrel | 12 V | DC barrel | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| MQ-2 combustible gas / smoke sensor | 5 V (heater) | 150 | Needs 24–48 h burn-in and a stable 5 V; the heater alone draws ~150 mA. |
| MQ-7 carbon monoxide sensor | 5 V | 150 | Requires an alternating 60 s / 90 s heater cycle to read correctly — a constant 5 V gives wrong numbers. |
| 4-channel opto-isolated relay board | 5 V coil | 280 | All four coils energised draw ~280 mA — do not power from the MCU 5 V pin. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 12 V 5 A SMPS adapter | 12 V | 5000 | Fuse the 12 V rail at ~1.5× your calculated draw. |
Summed typical draw is 5768 mA. With a 1.5× design margin the supply should deliver at least 8700 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| MQ-2 combustible gas | AO | GPIO 34 | Analogue, 0–3.3 V via divider |
| MQ-7 carbon monoxide | AO | GPIO 35 | Analogue, read at end of low-heat phase |
| Manual reset | NO | GPIO 32 | Clears a latched alarm |
| Valve limit switch | Closed position | GPIO 33 | Confirms the valve actually moved |
| MQ-7 heater control | Heater + | GPIO 25 (PWM) | MOSFET, 1.4 V / 5 V cycle |
| Relay 1 → gas valve close | IN1 | GPIO 26 | Momentary drive |
| Relay 2 → ignition source cut | IN2 | GPIO 27 | Normally closed contact |
| Relay 3 → extraction fan | IN3 | GPIO 14 | After ignition sources are cut |
| Relay 4 → 100 dB siren | IN4 | GPIO 12 | Immediate |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The MQ sensors output up to 5 V on their analogue pin. The ESP32 ADC is 3.3 V maximum — use a 10 kΩ / 20 kΩ divider on each, or you will destroy the input.
- Both sensors have heaters drawing about 150 mA each. Power them from the 5 V rail directly, never from the ESP32.
- The MQ-7 heater must alternate between 1.4 V (90 s) and 5 V (60 s). Generate the 1.4 V with PWM at about 28 % duty through a logic-level MOSFET plus a small RC filter — a bare digital pin cannot do this and running it at a constant 5 V gives readings that are simply wrong.
- The ignition-source relay must open before the extraction fan starts. A fan motor spinning up in a gas-filled room is exactly the spark you are trying to avoid. The firmware enforces this ordering, and your wiring must not defeat it.
- Mount the MQ-2 low — LPG is heavier than air and pools at floor level. Mount a methane-focused sensor high, since methane is lighter than air. If you use piped natural gas rather than cylinders, the mounting height inverts.
- The sensors must be in free air with ventilation on all sides. Do not enclose them; the heater needs to reach thermal equilibrium with the ambient air, not with a sealed box.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
An MQ-series sensor is a heated tin-dioxide semiconductor. At around 300 °C, atmospheric oxygen adsorbs onto the SnO₂ surface and traps electrons, raising the material's resistance. When a reducing gas such as LPG or CO arrives, it reacts with that adsorbed oxygen, releasing the trapped electrons and lowering the resistance. The sensor is therefore a variable resistor whose value falls as gas concentration rises.
The measurement is a ratio, not an absolute. The datasheet curves plot Rs/R0 against concentration, where Rs is the current sensing resistance and R0 is that same sensor's resistance in clean air. Because sensor-to-sensor variation is large, R0 must be measured for your specific unit — using a datasheet nominal value can put you off by a factor of three. That is the calibration step almost every tutorial omits, and it is why so many builds either never alarm or alarm constantly.
The MQ-7 is more involved because carbon monoxide detection requires a dual heater cycle. At the full 5 V heating phase (60 s) the element is cleaned of accumulated contaminants; at the low 1.4 V phase (90 s) the surface temperature drops to a range where CO adsorption dominates, and the reading is taken at the end of that phase. Running the MQ-7 at a constant 5 V, as most examples do, gives a number that responds to something but is not a CO measurement.
Rate of rise is what turns a slow alarm into a fast one. A leak from a failed regulator can go from background to the lower explosive limit in a couple of minutes. Waiting for an absolute threshold spends much of that window. Tracking the derivative — more than about 200 ppm-equivalent of change in 30 seconds — triggers action while the concentration is still well below anything dangerous.
Finally, sensor-failure detection. A disconnected analogue pin floats and can read anywhere; a shorted sensor reads at a rail. Both look like plausible values. Treating a reading pinned at 0 or at full scale for more than a few seconds as a fault rather than as clean air is the difference between a detector that fails safe and one that fails silently.
The maths behind it
Sensor resistance from the ADC
Voltage divider: MQ sensor Rs in series with load RL (usually 10 kΩ)
V_out = Vcc × RL / (Rs + RL)
Rs = RL × (Vcc − V_out) / V_out
With Vcc = 5 V, RL = 10 kΩ, measured V_out = 1.2 V
Rs = 10000 × (5 − 1.2) / 1.2 = 31.7 kΩ
If clean-air R0 was measured as 9.8 kΩ:
Rs/R0 = 3.23 → read ppm from the datasheet curve
LPG concentration from the MQ-2 curve
The MQ-2 LPG curve is approximately a power law on log-log axes:
ppm = a × (Rs/R0)^b with a ≈ 574.25, b ≈ −2.222
Rs/R0 = 3.23 → ppm = 574.25 × 3.23^(−2.222) ≈ 43 ppm
Rs/R0 = 1.00 → ppm = 574 ppm
Rs/R0 = 0.50 → ppm = 2670 ppm
Lower explosive limit for LPG ≈ 18 000 ppm (1.8 %).
Alarm well below that — 2000 ppm is a sensible threshold.
MQ-7 heater duty cycle
High phase: 5.0 V for 60 s (cleaning)
Low phase: 1.4 V for 90 s (measurement)
PWM duty for 1.4 V from a 5 V rail:
D = 1.4 / 5.0 = 0.28 = 28 %
Filter with R = 100 Ω, C = 100 µF:
τ = RC = 10 ms, at 5 kHz PWM ripple is negligible.
Read the ADC in the final 5 s of the low phase,
when the surface temperature has stabilised.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Gas Leak Detector with Automatic Shutoff — ESP32 + MQ-2 + MQ-7
Detects combustible gas and carbon monoxide, and on a confirmed
event closes the gas valve, cuts ignition sources, sounds a siren
and ventilates — in that order, all before touching the network.
This supplements a certified alarm. It does not replace one.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <math.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "gas-kitchen"
#define PIN_MQ2 34
#define PIN_MQ7 35
#define PIN_MQ7_HEAT 25
#define PIN_RESET 32
#define PIN_VALVE_LIM 33
#define PIN_R_VALVE 26
#define PIN_R_IGNITION 27
#define PIN_R_FAN 14
#define PIN_R_SIREN 12
#define RL_OHMS 10000.0f
#define VCC 5.0f
#define DIVIDER 1.5f // 10k/20k divider: Vadc = Vsensor / 1.5
#define LPG_WARN_PPM 1000
#define LPG_ALARM_PPM 2000 // ~11 % of the lower explosive limit
#define CO_WARN_PPM 35 // 8-hour exposure guideline
#define CO_ALARM_PPM 100
#define RISE_PPM_30S 200 // rate-of-rise trigger
#define MQ7_HIGH_MS 60000UL
#define MQ7_LOW_MS 90000UL
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;
float r0_mq2 = 9800, r0_mq7 = 10000;
float lpgPpm = 0, coPpm = 0;
float lpgHistory[30] = {0};
uint8_t histIdx = 0;
bool alarmLatched = false, faultDetected = false, acknowledged = false;
uint32_t mq7PhaseStart = 0;
bool mq7HighPhase = true;
/* ── sensor maths ───────────────────────────────────────────── */
float adcToRs(int pin) {
uint32_t acc = 0;
for (int i = 0; i < 16; i++) { acc += analogRead(pin); delayMicroseconds(200); }
float adc = acc / 16.0f;
if (adc < 20 || adc > 4075) { faultDetected = true; return -1; } // rail = fault
float vAdc = (adc / 4095.0f) * 3.3f;
float vSensor = vAdc * DIVIDER;
if (vSensor <= 0.01f) { faultDetected = true; return -1; }
return RL_OHMS * (VCC - vSensor) / vSensor;
}
float lpgFromRatio(float ratio) { // MQ-2 LPG curve
if (ratio <= 0) return 0;
return 574.25f * powf(ratio, -2.222f);
}
float coFromRatio(float ratio) { // MQ-7 CO curve
if (ratio <= 0) return 0;
return 99.042f * powf(ratio, -1.518f);
}
/* ── MQ-7 heater cycle ──────────────────────────────────────── */
void mq7Service() {
uint32_t now = millis();
uint32_t phaseLen = mq7HighPhase ? MQ7_HIGH_MS : MQ7_LOW_MS;
if (now - mq7PhaseStart >= phaseLen) {
mq7HighPhase = !mq7HighPhase;
mq7PhaseStart = now;
// 100 % duty = 5.0 V cleaning; 28 % duty = 1.4 V measuring
ledcWrite(0, mq7HighPhase ? 255 : 71);
}
// Sample only in the last 5 s of the low phase, once stabilised.
if (!mq7HighPhase && now - mq7PhaseStart > MQ7_LOW_MS - 5000) {
float rs = adcToRs(PIN_MQ7);
if (rs > 0) coPpm = coFromRatio(rs / r0_mq7);
}
}
/* ── calibration ────────────────────────────────────────────── */
void calibrateR0() {
Serial.println("Calibrating in clean air — do not breathe on the sensors");
float acc2 = 0;
int n = 0;
for (int i = 0; i < 50; i++) {
float rs = adcToRs(PIN_MQ2);
if (rs > 0) { acc2 += rs; n++; }
delay(200);
}
if (n > 20) {
r0_mq2 = (acc2 / n) / 9.83f; // MQ-2 clean-air Rs/R0 ratio is 9.83
prefs.putFloat("r0_mq2", r0_mq2);
Serial.printf("MQ-2 R0 = %.0f ohm\n", r0_mq2);
}
}
/* ── the action chain ───────────────────────────────────────── */
void emergencyShutdown(const char *reason) {
if (alarmLatched) return;
alarmLatched = true;
// 1. Close the gas valve — removes the source.
digitalWrite(PIN_R_VALVE, LOW);
// 2. Cut ignition sources BEFORE anything else starts moving.
digitalWrite(PIN_R_IGNITION, LOW);
// 3. Siren — works with no network, no phone, no broker.
digitalWrite(PIN_R_SIREN, LOW);
uint32_t t0 = millis();
while (millis() - t0 < 3000 && digitalRead(PIN_VALVE_LIM) == HIGH) delay(50);
digitalWrite(PIN_R_VALVE, HIGH); // stop driving the valve motor
// 4. Ventilate only after ignition sources are confirmed cut.
delay(500);
digitalWrite(PIN_R_FAN, LOW);
Serial.printf("EMERGENCY SHUTDOWN: %s\n", reason);
}
void publishAlert(const char *reason) {
JsonDocument d;
d["device"] = DEVICE_ID;
d["alarm"] = reason;
d["lpg_ppm"] = (int)lpgPpm;
d["co_ppm"] = (int)coPpm;
d["valve_closed"] = digitalRead(PIN_VALVE_LIM) == LOW;
d["fault"] = faultDetected;
char buf[224]; size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish("home/gas/" DEVICE_ID "/alert", (uint8_t *)buf, n, true);
}
/* ── detection ──────────────────────────────────────────────── */
bool rateOfRise() {
float oldest = lpgHistory[histIdx]; // 30 s ago
float newest = lpgHistory[(histIdx + 29) % 30];
return oldest > 0 && (newest - oldest) > RISE_PPM_30S;
}
void detectionService() {
faultDetected = false;
float rs2 = adcToRs(PIN_MQ2);
if (rs2 > 0) lpgPpm = lpgFromRatio(rs2 / r0_mq2);
lpgHistory[histIdx] = lpgPpm;
histIdx = (histIdx + 1) % 30;
static uint8_t confirm = 0;
bool trigger = lpgPpm > LPG_ALARM_PPM || coPpm > CO_ALARM_PPM || rateOfRise();
if (trigger) {
if (++confirm >= 3) { // 3 s of confirmation
const char *why = coPpm > CO_ALARM_PPM ? "carbon-monoxide"
: rateOfRise() ? "rapid-rise"
: "combustible-gas";
emergencyShutdown(why);
publishAlert(why);
}
} else {
confirm = 0;
}
if (faultDetected) {
static uint32_t faultSince = 0;
if (!faultSince) faultSince = millis();
if (millis() - faultSince > 10000) publishAlert("sensor-fault");
}
}
void onMessage(char *topic, byte *payload, unsigned int len) {
if (len >= 3 && !strncmp((char *)payload, "ACK", 3)) acknowledged = true;
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_RESET, INPUT_PULLUP);
pinMode(PIN_VALVE_LIM, INPUT_PULLUP);
int outs[] = { PIN_R_VALVE, PIN_R_IGNITION, PIN_R_FAN, PIN_R_SIREN };
for (int p : outs) { pinMode(p, OUTPUT); digitalWrite(p, HIGH); } // active-low idle
ledcSetup(0, 5000, 8);
ledcAttachPin(PIN_MQ7_HEAT, 0);
ledcWrite(0, 255); // start in the high-heat phase
mq7PhaseStart = millis();
analogSetPinAttenuation(PIN_MQ2, ADC_11db);
analogSetPinAttenuation(PIN_MQ7, ADC_11db);
Wire.begin(21, 22);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
prefs.begin("gas", false);
r0_mq2 = prefs.getFloat("r0_mq2", 9800);
r0_mq7 = prefs.getFloat("r0_mq7", 10000);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
// Sensors need 3 minutes of heater warm-up before readings mean anything.
Serial.println("Warming up sensors — 180 s");
for (int i = 180; i > 0; i--) {
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1); oled.setCursor(0, 20);
oled.printf("Warming up\n%d s remaining", i);
oled.display();
delay(1000);
}
Serial.println("Gas detector armed");
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
if (mqtt.connect(DEVICE_ID)) mqtt.subscribe("home/gas/" DEVICE_ID "/cmd");
}
mqtt.loop();
mq7Service();
static uint32_t last = 0;
if (millis() - last >= 1000) {
last = millis();
if (!alarmLatched) detectionService();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(0, 0); oled.printf("LPG %5.0f ppm", lpgPpm);
oled.setCursor(0, 12); oled.printf("CO %5.0f ppm", coPpm);
oled.setCursor(0, 24); oled.printf("MQ7 %s phase", mq7HighPhase ? "clean" : "measure");
oled.setCursor(0, 40); oled.setTextSize(2);
oled.print(alarmLatched ? "ALARM" : faultDetected ? "FAULT" : "OK");
oled.display();
if (!alarmLatched) {
JsonDocument d;
d["lpg_ppm"] = (int)lpgPpm;
d["co_ppm"] = (int)coPpm;
d["fault"] = faultDetected;
char b[128]; size_t n = serializeJson(d, b, sizeof(b));
mqtt.publish("home/gas/" DEVICE_ID "/state", (uint8_t *)b, n, true);
}
}
if (alarmLatched && !acknowledged) {
static uint32_t lastRepeat = 0;
if (millis() - lastRepeat > 30000) { lastRepeat = millis(); publishAlert("still-active"); }
}
// Manual reset requires the gas to have cleared first.
if (alarmLatched && digitalRead(PIN_RESET) == LOW && lpgPpm < LPG_WARN_PPM && coPpm < CO_WARN_PPM) {
delay(50);
if (digitalRead(PIN_RESET) == LOW) {
alarmLatched = false; acknowledged = false;
for (int p : { PIN_R_IGNITION, PIN_R_FAN, PIN_R_SIREN }) digitalWrite(p, HIGH);
Serial.println("Alarm reset");
}
}
}
Configuration & Calibration
Configuration steps
- Run
calibrateR0()once, in genuinely clean air, after the sensor has had at least 24 hours of continuous burn-in. A new sensor straight from the bag reads high for a day or two. - Set
LPG_ALARM_PPMwell below the lower explosive limit. 2000 ppm is about 11 % of the LEL — early enough to act, high enough to avoid nuisance alarms from a briefly unlit hob. - Set CO thresholds against published guidance: 35 ppm is the 8-hour exposure limit, 100 ppm warrants immediate action.
- Verify the valve limit switch polarity. The firmware waits for it to confirm closure and gives up after three seconds — if the polarity is inverted it will drive the valve for the full timeout every time.
- Test the whole chain with a butane lighter (unlit, gas released near the sensor) at least twice a year.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Burn in the sensors
New MQ sensors need 24–48 hours of continuous power before their readings stabilise. Skipping this makes your R0 measurement wrong and every subsequent ppm figure wrong with it.
Measure R0 in clean air
With the sensor warmed up and the room ventilated, run the calibration routine. It averages 50 samples and divides by the MQ-2's documented clean-air ratio of 9.83. Record the value; if it changes by more than about 30 % year on year, the sensor is ageing out.
Verify the response
Release a small amount of butane from an unlit lighter about 30 cm from the MQ-2. The reading should climb sharply within a few seconds and recover over a minute or two. No response means the sensor is dead or the divider is wrong.
Time the valve
Trigger a manual close and time it. Set the drive timeout in firmware to about 150 % of that, and verify the limit switch reports closure.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Power on | A 180-second warm-up countdown, then "armed". No alarm during warm-up. |
| Read the baseline after warm-up | LPG under about 200 ppm and CO under 10 ppm in clean air. Much higher means R0 is wrong. |
| Watch the MQ-7 phase indicator | Alternating between "clean" for 60 s and "measure" for 90 s, with CO updating at the end of each measure phase. |
| Release unlit butane near the MQ-2 | LPG ppm rises within seconds; after three consecutive samples above threshold the full action chain fires. |
| Observe the action order | Valve drives closed first, ignition relay opens, siren sounds, then the fan starts about half a second later. Any other order is a wiring or firmware error. |
| Try to reset while gas is still present | The reset is refused until readings fall below the warning thresholds. |
| Disconnect an MQ sensor | "FAULT" on the display and a sensor-fault alert published within ten seconds — not a reading of zero. |
| Unplug the network and trigger again | Valve, ignition cut, siren and fan all still operate. Only the MQTT alert is lost. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Average 16 ADC samples per reading. The ESP32 ADC is noisy and a single sample can swing several percent, which on a power-law curve becomes a large ppm error.
- Keep the detection loop at 1 Hz. MQ sensors have a response time of seconds; faster sampling adds noise, not information.
- Store R0 in NVS so a reboot does not require recalibration — and log it, so you can see the sensor ageing.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- This is not a certified safety device. Fit a professionally certified gas alarm as your primary protection and treat this as an additional layer.
- Never test with a naked flame. Use unlit gas from a lighter, well away from any ignition source, and ventilate afterwards.
- The extraction fan must have a non-sparking motor and must never start before ignition sources are cut. A brushed motor spinning up in a gas-filled room is an ignition source.
- Gas valve installation on a real supply line must be done by a licensed gas fitter. In most jurisdictions this is a legal requirement.
- If you smell gas: do not operate any electrical switch, ventilate, close the cylinder valve by hand, and leave. Trust your nose over any electronics, including this.
- Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
- Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
- Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
- Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
- MQ-series sensors run a hot element. They get genuinely hot, need ventilation, and must never be enclosed in a sealed plastic box.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Replace the MQ-2 with an infrared LPG sensor, which is selective, does not drift, and has no cross-sensitivity to cooking fumes.
- Add an electrochemical CO sensor for genuinely accurate carbon monoxide measurement — MQ-7 is a threshold detector, not an instrument.
- Add a battery backup so the detector works through a power cut, which is exactly when a gas appliance is most likely to be relit incorrectly.
- Add flame detection with an IR sensor for a second independent confirmation of a fire.
- Add a mesh of detectors so a leak in one room triggers valve closure at the source regardless of where the sensor is.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add connectivity — an ESP32 and an MQTT publish turn a local gadget into something you can graph, alert on and analyse over months.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- MQ-2 semiconductor sensor for combustible gas — datasheet and curvesHanwei Electronics
- MQ-7 carbon monoxide sensor — datasheet and heater cycle specificationHanwei Electronics
- Flammability limits of LPG and natural gasEngineering ToolBox
- Carbon monoxide exposure limits and health effectsUS CDC / NIOSH
- EN 50194 — electrical apparatus for the detection of combustible gases in domestic premisesCEN
- Metal oxide semiconductor gas sensors — operating principleFigaro Engineering