Saltar al contenido

Detector de gas MQ-2 en ESP32 con Micropython

El sensor costó Q35. Es un MQ-2, uno de esos módulos azules que detectan gas LP, propano, butano, hidrógeno, alcohol y humo diseñados para Arduino. La idea era sencilla: conectarlo a mi ESP32, mostrar las lecturas en la pantalla OLED que ya había usado en otro proyecto y ver qué pasaba. Todo en MicroPython y sin librerías externas e intentar que fuera ligero.

Lo que empezó como “conectar un sensor y mostrar un número” terminó incluyendo un reloj con batería, un sensor de temperatura y humedad, un registro en CSV, una alarma con LED y buzzer, y una tarde de pruebas bajo tormenta revisando mi estufa de mesa. Este post cuenta cómo llegué ahí, con los tropiezos incluidos.

Primer tropiezo: Ubuntu no me dejaba entrar

Antes de tocar el sensor, Thonny en un Ubuntu nuevo que instalé en mi laptop me recibió con un error de permisos sobre /dev/ttyUSB0. En Ubuntu los puertos seriales pertenecen al grupo dialout, y mi usuario no estaba ahí:

sudo usermod -a -G dialout $USER

Hay que cerrar sesión y volver a entrar para que surta efecto. Resuelto eso, apareció otro mensaje: el dispositivo estaba ocupado o no respondía. Para ver qué pasaba realmente, cerré Thonny y abrí el puerto con picocom. Al pulsar el botón EN solo aparecía el texto del cargador de arranque y nada más. Después de unos cuantos Ctrl+C y Ctrl+B apareció por fin MicroPython v1.29.0 y el >>>. Había un programa viejo corriendo en silencio que no dejaba entrar a Thonny.

La pantalla primero

Antes de conectar el sensor probé la pantalla sola: una OLED SSD1306 de 128x64 por SPI, la misma que he usado en otros proyectos. Sus pines dicen GND, VDD, SCK, SDA, RES, DC y CS. Ojo con ese SDA: en un módulo SPI no es la línea de datos de I2C, sino MOSI. El código era el de ocasiones anteriores incluida con la prueba de pantalla.

El MQ-2 trabaja a 5V y el ESP32 a 3.3V

El MQ-2 tiene un calentador interno que necesita 5V y consume hasta 150 mA. Su salida analógica (AO) puede subir casi hasta esos 5V, y el ESP32 no tolera más de 3.3V en sus pines. La solución es un divisor de voltaje: una resistencia de 10 kΩ y otra de 20 kΩ convierten 5V en 3.33V. Con resistencias de 1⁄4 W, porque por el divisor pasan menos de 0.2 mA. Y si en la tienda no hay de 20 kΩ, dos de 10 kΩ en serie hacen lo mismo.

Todo se alimenta con un solo cable USB conectado al ESP32. Los 5V para el sensor salen de la isla de alimentación del shield, que está unida al pin VIN, y la tierra se comparte entre todo. Conectar un segundo USB al shield no hace falta y es mejor evitarlo: dos fuentes terminarían unidas en la misma línea de 5V.

El problema de la salida digital

El módulo también tiene una salida digital (DO), que se activa cuando la concentración supera el nivel que fijás con un potenciómetro azul. Le puse su propio divisor y la conecté a un GPIO. El resultado: el programa decía que había gas todo el tiempo, aunque el LED verde del módulo, que es justamente el indicador de DO, estaba apagado.

El módulo sabía que no había gas, pero el ESP32 leía un 0. Resulta que DO no entrega 5V “fuertes”: se sostiene en alto a través de una resistencia interna, y al colgarle el divisor el voltaje cae más de lo calculado. Llegaba al pin con unos 2.3V, y según su hoja de datos el ESP32 necesita al menos el 75 % de su alimentación, unos 2.5V, para leer un 1 digital.

La solución fue leer DO como señal analógica y decidir en el código. Cuando el comparador se activa, DO cae casi a 0V; cuando no, queda arriba de 2V. Un umbral de 1V separa los dos estados con mucho margen. Para eso moví la conexión al GPIO 35, que pertenece al ADC1. El GPIO 4, donde estaba antes, también tiene ADC, pero del ADC2, que no se puede usar mientras el WiFi del ESP32 está activo.

def do_activo(self, v_do=None):
    if config.DO_MODO == "analogico":
        if v_do is None:
            v_do = self.voltaje_do()
        valor = 1 if v_do >= config.DO_UMBRAL_V else 0
    ...
    return valor == 0 if config.DO_ACTIVO_EN_BAJO else valor == 1

Ese v_do como parámetro también tiene su historia: en una prueba vi una fila del registro que marcaba DO activo con un voltaje alto. El código leía el pin dos veces, una para decidir y otra para mostrar, y justo en la transición las dos lecturas no coincidían. Ahora se lee una sola vez.

Un umbral fijo no sirve

Mi primera alerta era simple: si el valor del ADC superaba cierto número, alarma. El problema apareció en cuanto hice varias sesiones. En aire “limpio”, la línea base del ADC andaba en 650 un día, en 450 otro y en 890 otro, según la temperatura, la humedad y el propio sensor, que todavía se estaba asentando. Un mismo umbral quedaba muy holgado una vez y demasiado ajustado otra.

La solución está en cómo funciona el sensor. El MQ-2 cambia su resistencia interna (Rs) según la concentración de gas. Al arrancar, el programa la mide en aire limpio y calcula una referencia, Ro. A partir de ahí, la razón Rs/Ro en aire limpio ronda siempre 9.8, que es lo que indican las curvas del datasheet, sin importar si el ADC está en 450 o en 890. Así que la alerta ahora se dispara cuando Rs/Ro baja de 5, es decir, cuando la resistencia del sensor cae a la mitad de lo normal para las condiciones de ese momento.

def calcular_rs(self, v_sensor):
    # Rs = RL * (VC - Vout) / Vout
    return config.RL * (config.VC - v_sensor) / v_sensor

def evaluar_alerta(crudo, razon):
    por_razon = razon is not None and razon < config.UMBRAL_RS_RO
    por_adc = crudo >= config.UMBRAL_ADC_ALERTA
    if config.ALERTA_MODO == "razon":
        return por_razon
    if config.ALERTA_MODO == "adc":
        return por_adc
    return por_razon or por_adc

La única condición es que el sensor arranque en aire limpio, porque si no, la calibración toma el aire contaminado como normal. Por eso dejé tres modos en la configuración: por razón, por ADC o ambos, donde el umbral de ADC queda como respaldo.

Las estimaciones en ppm salen de la misma razón, con curvas del tipo ppm = A·(Rs/Ro)^B para GLP, propano, alcohol, hidrógeno y monóxido de carbono. Son orientativas: un sensor de estos sin calibración de laboratorio sirve para ver tendencias, no para medir concentraciones exactas.

Registrar todo

Copiar la consola de Thonny después de cada prueba se volvió tedioso, así que agregué un registro en CSV dentro de la flash del ESP32. Después de MicroPython quedan unos 2 MB libres, y cada hora de lecturas poco más de unos 300 KB, así que hay para varias horas. Las filas se acumulan en memoria y se escriben en bloques de diez, para no desgastar la flash con una escritura por segundo. Para descargar los archivos, en Thonny basta con Ver > Archivos y clic derecho, Descargar.

El botón BOOT de la placa sirve para marcar pruebas: cada pulsación sube un número en la columna prueba del CSV y, de paso, escribe en la flash lo que estaba pendiente.

Un reloj con batería

Un registro sin fecha y hora sirve de poco, y el ESP32 tiene reloj interno pero no batería: al desconectarlo vuelve al 1 de enero de 2000. Thonny sincroniza la hora al conectarse, pero yo quería hacer pruebas lejos de la computadora, con un powerbank. Por eso compré un módulo DS3231 (Q55) y una batería CR2032 (Q69). Va por I2C y lo alimento a 3.3V, no a 5V: estos módulos traen un circuito para cargar la batería, pensado para baterías recargables, y la CR2032 no lo es. A 3.3V esa corriente de carga es prácticamente nula.

La hora se configura una sola vez con un script que la copia desde el reloj del ESP32 que Thonny sincronizó. También dejé configurable la opción de obtenerla por internet (NTP), para cuando haya WiFi.

El módulo perdía la hora cada vez que lo desconectaba. Revisé el registro de control por si el bit EOSC estaba apagando el oscilador al funcionar con batería, que es lo que hace ese bit cuando está en 1, pero estaba bien. La bandera OSF indicaba que el oscilador se detenía al quedar sin alimentación: la batería no llegaba al chip. La saqué para medirla con el multímetro (3.27V, correcta para una nueva) y al volver a colocar quedó bien asentada en el portabaterías, y desde entonces conserva la hora. Mal contacto, nada más. Y algo que me confundió porque nunca había usado este módulo, el LED del mismo no enciende con la batería: solo indica que hay alimentación por VCC.

Los sensores y módulos

El DS3231 tiene su propio sensor de temperatura, pero sirve para compensar su oscilador, no como termómetro: ±3 °C de precisión y una actualización cada 64 segundos. En cambio, tenía guardado el módulo AHT20 + BMP280 de mi estación del clima en progreso, que da temperatura, humedad y presión. Y la humedad le importa al MQ-2 tanto como la temperatura.

Va en el mismo bus I2C que el reloj, en los pines 21 y 22. I2C está hecho justo para eso: cada dispositivo tiene su dirección y no chocan (0x38 el AHT20, 0x77 el BMP280, 0x68 el DS3231 y 0x57 la memoria EEPROM que trae el módulo del reloj).

Para probarlo acerqué el cautín encendido a un centímetro. El BMP280 empezó a repetir exactamente 25.30 °C y 741.8 hPa en todas las lecturas, cuando la presión real en la Ciudad de Guatemala ronda los 850. No se había saturado: se había reiniciado, probablemente por alguna interferencia del cautín, y esos son los valores que el chip tiene en sus registros antes de su primera medición. El AHT20, por su parte, empezó a fallar lecturas. Agregué detección de esos reinicios y reintentos automáticos, y repetí la prueba con el cautín a 8 o 10 centímetros. Esa vez todo se comportó como debía: la temperatura subió despacio y la humedad relativa bajó al mismo ritmo, porque el aire caliente admite más vapor.

Pensé en poner el MQ-2 en la punta de cables Dupont de 70 cm para moverlo libremente. Lo descarté: los conectores se aflojan con el movimiento, y un sensor de temperatura lejos del MQ-2 no mediría las condiciones donde está el sensor.

La alarma

Faltaba que la alerta se viera fuera de la pantalla. Un LED con su resistencia de 220 Ω y un buzzer activo, en los GPIO 26 y 25. El patrón intermitente lo maneja un temporizador de hardware del ESP32, para que el pitido sea regular sin depender del ciclo de lectura de un segundo. Al encender, suena un pitido corto: es una prueba para confirmar que ambos funcionan antes de salir a medir.

Cómo quedó el circuito

Componente Pin del módulo Conexión
MQ-2 VCC 5V de la isla del shield
MQ-2 GND GND
MQ-2 AO 10 kΩ -> nodo -> GPIO 34; nodo -> 20 kΩ -> GND
MQ-2 DO 10 kΩ -> nodo -> GPIO 35; nodo -> 20 kΩ -> GND
OLED SSD1306 SPI VDD / GND 3.3V / GND
OLED SSD1306 SPI SCK GPIO 14
OLED SSD1306 SPI SDA (MOSI) GPIO 13
OLED SSD1306 SPI RES / DC / CS GPIO 33 / GPIO 32 / GPIO 15
DS3231 VCC / GND 3.3V / GND
DS3231 SDA / SCL GPIO 21 / GPIO 22
AHT20 + BMP280 VCC / GND 3.3V / GND (nunca 5V: no tiene regulador)
AHT20 + BMP280 SDA / SCL GPIO 21 / GPIO 22
LED de alarma ánodo (pata larga) 220 Ω -> GPIO 26
LED de alarma cátodo (pata corta) GND
Buzzer activo + / − GPIO 25 / GND
Botón BOOT de la placa GPIO 0

Dos detalles: el bus SPI reserva el GPIO 12 como MISO aunque la pantalla no lo usa, así que no hay que conectar nada en ese pin, que además interviene en el arranque del ESP32. Y el botón BOOT no debe mantenerse presionado al encender o al pulsar EN, porque la placa entraría en modo de carga de firmware.

Diagrama de conexiones

El código está dividido en módulos: config.py con todos los parámetros, main.py con la lógica, drivers propios para la OLED, el DS3231 y el AHT20 + BMP280, el registro CSV y el manejo de la hora, más scripts de prueba para cada parte. Todo está en Bitbucket. En el repositorio también están los dos archivos CSV de las pruebas del 3 de octubre y el listado de control con la hora de inicio de cada prueba, para que podás revisar los datos por tu cuenta. Las pruebas que hice días antes en el escritorio no las guardé por error.

En la pantalla, durante las mediciones, se ve algo así:

ADC: 948   15:23
R:9.8 22.7C 85%
GLP        3ppm
DO:NO P1      OK
[gráfica de las últimas 128 lecturas]

Las pruebas

El 3 de octubre hice la ronda de pruebas reales en un espacio abierto, con dos paredes y un techo que está a más de tres metros. Había tormenta: la temperatura bajó de 23 a 21.5 °C y la humedad estuvo entre 83 y 95 %. Acerqué cada fuente a unos 5 centímetros o menos. Olvidé pulsar BOOT entre pruebas, pero anoté en papel la hora de cada una, y al cruzarlas con el CSV la respuesta del sensor aparecía entre 10 y 30 segundos después de cada minuto anotado. En medio de la sesión desconecté el powerbank para hacer un cambio en la configuración, por eso los datos quedaron en dos archivos.

Prueba Rs/Ro previo Mínimo Caída
Alcohol en tela 9.96 4.92 51 %
Encendedor sin encender 9.29 5.26 43 %
Aerosol 8.71 4.99 43 %
Incienso 8.94 6.06 32 %
Perfume atomizado 7.88 6.06 23 %
Fósforo 8.97 8.89 sin respuesta
Vela encendida y apagada 9.23 9.21 sin respuesta

El aliento funcionó como control: la humedad subió a 90 % y el sensor apenas se movió, así que un cambio pasajero de humedad no lo engaña.

El alcohol dejó residuos por mucho tiempo: Rs/Ro tardó unos quince minutos en volver cerca de su línea base. Con el perfume y el aerosol, algunas gotas cayeron sobre la pantalla, y seguramente también sobre el sensor, que siguió detectando el alcohol mientras se evaporaba. Lección: rociar hacia un lado y dejar que llegue la nube, nunca apuntar al sensor.

Lo más revelador fue comparar con unas pruebas rápidas que había hecho días antes en el escritorio, en una habitación cerrada. Ahí, un fósforo bajó Rs/Ro a 7.3 y hasta el AHT20 registró el calor de la llama. En un lugar no cerrado y con la tormenta, ni el fósforo ni la vela aparecieron. El humo se dispersó antes de llegar al sensor. Medir gases en un cuarto y medirlos afuera son dos experimentos distintos.

La estufa

La última parte fue con mi estufa de mesa de tres quemadores, conectada a un cilindro de 25 libras. Es una estufa barata, y la manguera, las abrazaderas y la válvula reguladora las instalé yo mismo hace años.

Al recorrer las conexiones hubo caídas breves de Rs/Ro en varios puntos, aunque algunas coincidieron con subidas de humedad, probablemente mi respiración al acercarme. Lo que me llamó la atención fue el quemador con la perilla cerrada: Rs/Ro bajó a 5.0 y se mantuvo alrededor de 6.6 por más de un minuto, una respuesta comparable a la del encendedor. Puede ser gas que quedó en el quemador de un uso anterior o residuos de las pruebas previas, pero también es compatible con una pequeña fuga en la válvula. Después de abrir la perilla unos segundos, a unos 8 centímetros porque no pude acercarme más, Rs/Ro no volvió a su base en los quince minutos siguientes. La estufa encendida, en cambio, apenas lo movió: el gas se consume en la llama.

Un MQ-2 casero no es un detector certificado y no puede confirmar una fuga. Pero lo que mostró es suficiente para no ignorarlo. Toca revisar las uniones con agua jabonosa, que forma burbujas donde hay fuga, y, si persiste la duda, que un técnico revise la estufa. Si tu instalación también tiene años, vale la pena que hagás lo mismo, tengás o no un sensor.

Lo que mejoraría

Los datos dejaron una lista clara. El calentamiento de 60 segundos es corto: en la primera sesión, sin gas presente, Rs/Ro siguió subiendo de 9.9 a 11.6 durante tres minutos, así que la calibración se hizo con el sensor todavía inestable. Unos cuatro minutos serían más confiables. El potenciómetro (que nunca lo ajusté) de DO quedó demasiado insensible y nunca se activó en las pruebas reales. En un espacio no cerrado, un umbral de 6.5 en lugar de 5 habría detectado más eventos, a cambio de alertas más largas con los residuos de alcohol. Y el datasheet del MQ-2 trae curvas de corrección por temperatura y humedad que, ahora que registro ambas, podría aplicar.

Para qué sirvió todo esto

Quería medir gas y terminé aprendiendo a dividir voltajes para que un ESP32 no se queme, a desconfiar de una salida digital que parece sencilla, a normalizar una lectura en lugar de perseguir umbrales, a llevar la hora con un módulo de reloj de batería (RTC) y a usar en otra cosa un sensor que solo había usado para el clima.

Aprendí también que un sensor en el escritorio y un sensor al aire libre no miden lo mismo, aunque sean el mismo sensor.

Y aprendí algo de mi estufa. Eso y que la debo limpiar más seguido.

Fuentes