Aprende a usar un sensor de movimiento PIR (Passive Infrared) con el ESP32 programado con Arduino IDE para detectar movimiento. En este tutorial crearemos un ejemplo práctico que te introducirá a dos conceptos fundamentales: las interrupciones y los timers.
En este ejemplo, cuando se detecta movimiento (se dispara una interrupción), el ESP32 inicia un temporizador y enciende un LED durante un número determinado de segundos. Cuando el temporizador termina la cuenta atrás, el LED se apaga automáticamente. Todo esto sin bloquear el resto del programa, gracias al uso de timers basados en millis().
¿Qué vas a aprender en este tutorial?
Este artículo cubre los siguientes temas: qué es un sensor de movimiento PIR y cómo funciona, qué son las interrupciones y cómo se usan en el ESP32, qué son los timers y por qué son mejores que delay(), y un ejemplo práctico completo que combina ambos conceptos para detectar movimiento y controlar un LED.
Requisitos previos
Antes de continuar con este tutorial, deberías tener el soporte de placas ESP32 instalado en tu Arduino IDE. Si aún no lo has hecho, sigue el tutorial de instalación correspondiente a tu sistema operativo (Windows, macOS o Linux).
Componentes necesarios
Para seguir este tutorial necesitas: una placa ESP32 DOIT DEVKIT V1, un sensor de movimiento PIR mini AM312 (o un HC-SR501 como alternativa), un LED de 5 mm, una resistencia de 330 Ω o similar, cables jumper y una protoboard.
Introducción al sensor de movimiento PIR
Un sensor de movimiento PIR (Passive Infrared) detecta cambios en la luz infrarroja dentro de su campo de visión, causados por el movimiento de objetos. Esto lo hace ideal para detectar personas o animales, porque capta seres vivos (u objetos que emiten calor) que se mueven dentro de su rango, pero no detecta objetos inanimados.
Puedes programar el ESP32 para reaccionar a esos cambios en la luz infrarroja disparando un evento: encender una luz, activar una alarma, enviar una notificación o cualquier otra tarea. En este tutorial, imprimiremos un mensaje en el Monitor Serie y encenderemos un LED durante un número predefinido de segundos.
Cómo funciona el PIR: pines y señal de salida
Existen diferentes módulos de sensor PIR, pero todos funcionan de forma similar. Normalmente tienen tres pines: VCC, GND y Data. El sensor PIR emite una señal HIGH en el pin de datos cuando detecta movimiento, y una señal LOW cuando no lo detecta.
El “dwell time” o tiempo de espera
Un concepto importante sobre los sensores PIR es el dwell time (tiempo de permanencia, tiempo de reset o delay del sensor): es la duración durante la cual la salida del sensor PIR permanece en HIGH después de detectar movimiento, antes de volver al estado LOW.
Algunos modelos como el HC-SR501 incluyen dos potenciómetros (esos dos componentes naranjas que se ven en la placa) para ajustar este comportamiento:
Potenciómetro de sensibilidad: ajusta el rango de detección del sensor. Girar en sentido horario aumenta la sensibilidad, girar en sentido antihorario la disminuye.
Potenciómetro de delay (tiempo): controla cuánto tiempo permanece activado el sensor después de detectar movimiento. Girar en sentido horario aumenta el delay, y en sentido antihorario lo disminuye.
Introducción a las interrupciones
Para disparar un evento con un sensor PIR, se usan interrupciones. Las interrupciones son fundamentales para que las cosas ocurran automáticamente en los programas de microcontroladores, y ayudan a resolver problemas de temporización.
Con interrupciones no necesitas comprobar constantemente el valor de un pin. En su lugar, cuando se detecta un cambio, se dispara un evento (se llama a una función). Esto es mucho más eficiente y libera al procesador para hacer otras tareas.
Cómo usar interrupciones con el ESP32
Para configurar una interrupción en Arduino IDE, se usa la función attachInterrupt(), que acepta como argumentos: el GPIO, el nombre de la función que se ejecutará y el modo:
attachInterrupt(GPIO, callback_function, mode);
Esta instrucción debe añadirse dentro del setup() de tu código Arduino. Veamos cada argumento en detalle.
El GPIO de la interrupción
El primer argumento es el número de GPIO donde se detectará el cambio. Por ejemplo, si quieres usar el GPIO 27 como interrupción:
digitalPinToInterrupt(27)
Con una placa ESP32, todos los pines que pueden actuar como entradas pueden configurarse como interrupciones. En este ejemplo, usaremos el GPIO 27 como interrupción, conectado al sensor de movimiento PIR.
Si estás usando un ESP32-S3, la asignación de pines es diferente. Consulta la guía de referencia de pines del ESP32-S3.
La función callback (ISR)
El segundo argumento es el nombre de la función que se llamará cuando se dispare la interrupción. Esta función también se conoce como ISR (Interrupt Service Routine) o rutina de servicio de interrupción.
Hay varias reglas importantes que debes conocer al definir tu ISR:
La ISR no debe devolver nada. Es una función void.
Las ISR deben ser lo más cortas y rápidas posible, porque detienen la ejecución normal del código.
Deben tener el atributo ARDUINO_ISR_ATTR, para que se ejecuten en la RAM interna del ESP32 y no en la Flash. El acceso a IRAM es mucho más rápido, lo cual es crítico para que las ISR se ejecuten de forma fiable sin problemas de timing ni cuelgues.
Las variables que se usan dentro de las ISR y a lo largo del código deben ser preferiblemente volatile. Esto evita que el compilador almacene en caché los valores en registros (y se salte el acceso a memoria), de modo que las lecturas y escrituras siempre accedan a la ubicación real en memoria y reflejen los cambios inesperados causados por la interrupción.
Un ejemplo de ISR con la sintaxis correcta sería:
void ARDUINO_ISR_ATTR my_callback() {
// Cualquier código que quieras ejecutar
}
Otra cosa importante sobre las ISR es que debes mantener su código lo más rápido y simple posible, y evitar cosas como operaciones complejas, escribir en el Monitor Serie o usar delay(). En su lugar, debes usar una bandera o un contador para indicar que la interrupción ocurrió, y luego manejar lo que necesites en el código principal o sección loop().
El modo de disparo
El tercer argumento es el modo. Hay cinco modos diferentes:
LOW: dispara la interrupción siempre que el pin esté en LOW.
HIGH: dispara la interrupción siempre que el pin esté en HIGH.
CHANGE: dispara la interrupción siempre que el pin cambie de valor, por ejemplo, de HIGH a LOW o de LOW a HIGH.
FALLING: dispara cuando el pin pasa de HIGH a LOW.
RISING: dispara cuando el pin pasa de LOW a HIGH.
Para este ejemplo usaremos el modo RISING, porque cuando el sensor de movimiento PIR detecta movimiento, el GPIO al que está conectado pasa de LOW a HIGH.
Introducción a los timers
En este ejemplo también introduciremos los timers. Queremos que el LED permanezca encendido durante un número predeterminado de segundos después de detectar movimiento. En lugar de usar la función delay() que bloquea tu código y no permite hacer nada más durante ese tiempo, debemos usar un timer.
La función delay() y sus problemas
Probablemente ya conozcas la función delay(), ya que se usa ampliamente. Acepta un solo número entero como argumento, que representa el tiempo en milisegundos que el programa debe esperar antes de pasar a la siguiente línea de código:
delay(tiempo en milisegundos)
Cuando llamas a delay(1000), tu programa se detiene en esa línea durante 1 segundo. delay() es una función bloqueante. Las funciones bloqueantes impiden que un programa haga cualquier otra cosa hasta que esa tarea concreta se complete. Si necesitas que ocurran múltiples tareas al mismo tiempo, no puedes usar delay().
Para la mayoría de proyectos, deberías evitar usar delays y usar timers en su lugar. Es una de las mejores prácticas en programación de microcontroladores.
La función millis(): la alternativa no bloqueante
Usar la función millis() es preferible a usar delay(). La función millis() devuelve el número de milisegundos que han pasado desde que el programa se inició por primera vez.
millis()
¿Por qué es útil esta función? Porque con un poco de matemáticas, puedes comprobar fácilmente cuánto tiempo ha pasado desde un evento determinado sin bloquear tu código. Esto te permite hacer varias cosas a la vez de forma coordinada.
Ejemplo: parpadear un LED con millis()
El siguiente fragmento de código muestra cómo puedes usar la función millis() para crear un proyecto de LED parpadeante que se enciende durante 1000 milisegundos y luego se apaga, sin bloquear el programa:
/*
Rui Santos
Detalles completos del proyecto en https://randomnerdtutorials.com
*/
// Las constantes no cambian. Se usan para definir un número de pin:
const int ledPin = 26; // El número del pin del LED
// Las variables cambiarán:
int ledState = LOW; // ledState se usa para establecer el LED
// Generalmente, deberías usar "unsigned long" para variables que guardan tiempo
// El valor se volverá demasiado grande rápidamente para un int
unsigned long previousMillis = 0; // Almacenará la última vez que se actualizó el LED
// Las constantes no cambian:
const long interval = 1000; // Intervalo de parpadeo (milisegundos)
void setup() {
// Configura el pin digital como salida:
pinMode(ledPin, OUTPUT);
}
void loop() {
// Aquí es donde pondrías código que necesita ejecutarse todo el tiempo.
// Comprueba si es hora de parpadear el LED; es decir, si la
// diferencia entre el tiempo actual y la última vez que parpadeaste
// el LED es mayor que el intervalo al que quieres parpadear.
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
// Guarda la última vez que parpadeaste el LED
previousMillis = currentMillis;
// Si el LED está apagado, enciéndelo y viceversa:
if (ledState == LOW) {
ledState = HIGH;
} else {
ledState = LOW;
}
// Establece el LED con el ledState de la variable:
digitalWrite(ledPin, ledState);
}
}
Cómo funciona este código paso a paso
Este sketch parpadea un LED sin usar delay(), valiéndose de millis() en su lugar. Básicamente, resta el tiempo previamente registrado (previousMillis) del tiempo actual (currentMillis). Si el resto es mayor que el intervalo (en este caso, 1000 milisegundos), el programa actualiza la variable previousMillis al tiempo actual y enciende o apaga el LED:
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// ...
}
Como este fragmento no bloquea, cualquier código que esté fuera de esa primera sentencia if funcionará con normalidad. Esto significa que puedes añadir otras tareas a tu función loop() y tu código seguirá parpadeando el LED cada segundo sin problemas.
Esta es la clave de la programación no bloqueante: el microcontrolador puede hacer múltiples cosas “a la vez” (cooperativamente) sin detenerse en ningún punto.
Proyecto: ESP32 con sensor PIR para detectar movimiento
Después de entender estos dos conceptos (interrupciones y timers), vamos a continuar con el proyecto. Crearemos un ejemplo simple que enciende un LED cuando se detecta movimiento. Una vez aprendas cómo funciona, la misma forma de pensar se puede aplicar a aplicaciones útiles: enviar un email, activar una alarma o encender las luces de casa.
Resumen del funcionamiento
Este es el flujo del ejemplo:
El sensor detecta movimiento. El ESP32 detecta este evento. Imprime en el Monitor Serie que se ha detectado movimiento. Enciende un LED durante 20 segundos. Durante esos 20 segundos, no imprime nada más en el Monitor Serie (aunque el programa sigue funcionando). Después de esos 20 segundos y si no se detectó movimiento de nuevo, apaga el LED y imprime un mensaje en el Monitor Serie indicando que el movimiento ha cesado.
Esquema de conexión
Para este ejemplo, necesitas conectar el sensor de movimiento PIR y un LED a tu placa:
El LED se conecta al GPIO 26 (ánodo al GPIO, cátodo a GND a través de una resistencia de 330 Ω). El sensor PIR mini AM312 se conecta al GPIO 27. Alimenta el sensor a 3,3 V y GND, y conecta su pin de datos al GPIO 27.
Importante: el Mini AM312 PIR usado en este proyecto opera a 3,3 V. Sin embargo, si estás usando otro sensor PIR como el HC-SR501, este opera a 5 V. Puedes modificar el HC-SR501 para que funcione a 3,3 V o simplemente alimentarlo usando el pin Vin del ESP32.
Código completo: ESP32 con sensor PIR para detectar movimiento
Después de cablear el circuito, copia el siguiente código en tu Arduino IDE. Puedes subir el código tal cual, o modificar el número de segundos que el LED permanece encendido después de detectar movimiento. Simplemente cambia la variable timeSeconds con el número de segundos que quieras:
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Detalles completos del proyecto en https://RandomNerdTutorials.com/esp32-pir-motion-sensor-interrupts-timers/
Interrupciones GPIO del ESP32 con Arduino IDE: https://RandomNerdTutorials.com/esp32-gpio-interrupts-arduino/
*/
#include <Arduino.h>
// Establece los GPIOs para el LED y el sensor de movimiento PIR
const uint8_t led = 26;
const uint8_t motionSensor = 27;
// Timer: variables auxiliares
unsigned long now;
volatile unsigned long lastTrigger = 0;
volatile bool startTimer = false;
bool printMotion = false;
const unsigned long timeSeconds = 20 * 1000UL; // 20 segundos en milisegundos
void ARDUINO_ISR_ATTR motionISR() {
lastTrigger = millis();
startTimer = true;
}
void setup() {
Serial.begin(115200);
pinMode(motionSensor, INPUT_PULLUP);
attachInterrupt(motionSensor, motionISR, RISING);
// Establece el LED a LOW
pinMode(led, OUTPUT);
digitalWrite(led, LOW);
}
void loop() {
now = millis();
// Enciende el LED inmediatamente en un nuevo disparo
if (startTimer && !printMotion) {
digitalWrite(led, HIGH);
Serial.println("MOTION DETECTED!!!");
printMotion = true;
}
// Apaga el LED después del timeout
if (startTimer && (now - lastTrigger > timeSeconds)) {
Serial.println("Motion stopped...");
digitalWrite(led, LOW);
startTimer = false;
printMotion = false;
}
}
Nota: si has experimentado algún problema al cargar código en tu ESP32, consulta la guía de solución de problemas del ESP32.
Cómo funciona el código paso a paso
Definición de variables
Empezamos definiendo los pines para el LED y el sensor PIR. Ajústalos si estás usando pines diferentes:
const uint8_t led = 26;
const uint8_t motionSensor = 27;
Creamos variables para rastrear la duración del LED en estado encendido. La variable now guarda el tiempo actual (tiempo transcurrido desde que el programa se inició), lastTrigger guarda la última vez que se detectó movimiento, y startTimer es una variable booleana que indica si el timer para encender el LED está actualmente en marcha o no:
unsigned long now;
volatile unsigned long lastTrigger = 0;
volatile bool startTimer = false;
También tenemos otra variable para rastrear si el texto “Motion Detected” ya se imprimió en el Monitor Serie:
bool printMotion = false;
La variable timeSeconds guarda cuánto tiempo queremos que el LED permanezca encendido después de detectar movimiento. Ajústala según tus preferencias:
const unsigned long timeSeconds = 20 * 1000UL; // 20 segundos en milisegundos
La función motionISR()
La función motionISR() se ejecutará cuando se detecte movimiento. Guardamos el tiempo actual en la variable lastTrigger para tener constancia de cuándo se detectó movimiento, y establecemos la variable startTimer a true para indicar que es hora de iniciar el timer para encender el LED:
void ARDUINO_ISR_ATTR motionISR() {
lastTrigger = millis();
startTimer = true;
}
Luego manejaremos estas variables en el loop() para hacer las tareas que queramos.
La función setup()
En el setup(), se configura el sensor de movimiento como interrupción en modo RISING (cuando se detecta movimiento, el sensor pone su pin de salida en HIGH):
pinMode(motionSensor, INPUT_PULLUP);
attachInterrupt(motionSensor, motionISR, RISING);
Y se configura el LED como OUTPUT y se pone a LOW:
pinMode(led, OUTPUT);
digitalWrite(led, LOW);
La función loop()
En el loop(), constantemente obtenemos el tiempo actual y lo guardamos en la variable now:
now = millis();
Luego, comprobamos si el timer del LED ha comenzado y si el mensaje de movimiento no se ha impreso ya. Si se cumplen estas condiciones, encendemos el LED, imprimimos un mensaje en el Monitor Serie y establecemos printMotion a true, porque ya hemos impreso el mensaje “Motion Detected”:
if (startTimer && !printMotion) {
digitalWrite(led, HIGH);
Serial.println("MOTION DETECTED!!!");
printMotion = true;
}
Y aquí está la parte clave: si el timer está activo y ha pasado el tiempo de espera desde el último disparo, apagamos el LED, imprimimos “Motion stopped…” y reiniciamos las banderas:
if (startTimer && (now - lastTrigger > timeSeconds)) {
Serial.println("Motion stopped...");
digitalWrite(led, LOW);
startTimer = false;
printMotion = false;
}
Subir el código y probar el ejemplo
Sube el código a tu placa ESP32. Asegúrate de tener la placa y el puerto COM correctos seleccionados.
Abre el Monitor Serie a una velocidad de 115200 baudios. Pulsa el botón RST del ESP32 para que empiece a ejecutar el código.
Mueve tu mano delante del sensor PIR. El LED debería encenderse y aparecer un mensaje en el Monitor Serie diciendo “MOTION DETECTED!!!”.
Después de 20 segundos, el LED debería apagarse (si no se detectó movimiento mientras tanto), y aparecerá el mensaje “Motion stopped…” en el Monitor Serie.
Ahora que entiendes cómo usar interrupciones para detectar movimiento con un sensor PIR, puedes ajustar fácilmente el código para hacer cualquier tarea útil en lugar de controlar un LED. Puedes, por ejemplo, enviar notificaciones a tu email o smartphone para indicar que se ha detectado movimiento.
Conclusión: lo que has aprendido
Las interrupciones se usan para detectar un cambio en el estado de un GPIO sin necesidad de leer constantemente su valor actual. Con interrupciones, cuando se detecta un cambio, se dispara una función. Esto libera al procesador para hacer otras tareas y es mucho más eficiente que un polling constante.
También has aprendido cómo configurar un timer simple que te permite comprobar si ha pasado un número predefinido de segundos sin bloquear tu código. Esta técnica, basada en millis(), es fundamental para cualquier proyecto serio que necesite hacer varias cosas “a la vez”.
Combinando interrupciones y timers no bloqueantes, puedes construir sistemas reactivos y eficientes que responden a eventos externos y gestionan tareas temporizadas sin sacrificar el rendimiento. Este es uno de los patrones de diseño más importantes en programación embebida, y dominarlo te abrirá las puertas a proyectos mucho más complejos y profesionales.
Descubre más desde CIBERED
Suscríbete y recibe las últimas entradas en tu correo electrónico.
