An ESP32 PIR motion sensor MQTT node turns a cheap infrared detector into an event source any dashboard or automation can react to. In this tutorial you will wire an HC-SR501 or AM312, catch motion with an interrupt, debounce noisy triggers, publish events to broker.connectmq.com, and keep a retained last-motion timestamp that tells new subscribers exactly when something last moved.
How PIR Sensors Behave
A passive infrared (PIR) sensor detects changes in infrared radiation across its field of view, typically a warm body moving. The module turns that into a simple digital output: high while motion is detected, low otherwise. The details matter for MQTT, because the output is not a clean one-pulse-per-event signal.
| Module | Supply | Output | Adjustable | Notes |
|---|---|---|---|---|
| HC-SR501 | 5 V (VIN pin) | 3.3 V high | Sensitivity, hold time, trigger mode jumper | Large lens, a few metres range, needs warm-up |
| AM312 | 2.7 to 12 V | 3.3 V high | None | Tiny, fixed hold time of a couple of seconds, good for 3.3 V builds |
| HC-SR505 | 4.5 to 20 V | 3.3 V high | None | Compact, fixed hold time of several seconds |
Things to know before writing code:
- Warm-up. After power-on, PIR modules output random triggers for up to a minute while the sensor stabilises. Ignore motion during that window.
- Hold time. The output stays high for a configured time after the last detected movement. On the HC-SR501 a potentiometer sets it from a few seconds to several minutes.
- Retrigger mode. With the HC-SR501 jumper in the "H" (repeat) position, continued movement keeps the output high. In "L" mode the output drops after the hold time even if movement continues, then fires again. "H" is usually what you want.
- False triggers. Sunlight, heaters, air vents and Wi-Fi antennas placed right next to the sensor can all cause spurious pulses.
Wiring
For the HC-SR501: VCC to the ESP32 5V (VIN) pin, GND to GND, OUT to GPIO 27. The output is 3.3 V, so it connects directly to the ESP32 input. For the AM312: VCC to 3V3, GND to GND, OUT to GPIO 27. Keep the sensor a little away from the ESP32 antenna; close placement is a known source of phantom triggers.
Event Design: Debounce, Cooldown and Topics
Publishing every edge of the PIR output floods subscribers and wastes the per-client rate on the public broker. The sketch turns the raw signal into two clean states:
- Motion detected when the output goes high and the previous event is older than a cooldown period.
- Motion clear when the output has been low for a quiet period.
| Topic | Retained | Payload |
|---|---|---|
connectmq/demo/pir9d4k/motion |
Yes | {"state":"detected","ts":"2026-10-16T18:03:11Z","count":14} |
connectmq/demo/pir9d4k/motion/last |
Yes | 2026-10-16T18:03:11Z |
connectmq/demo/pir9d4k/status |
Yes | online / offline |
The motion/last topic is the key idea: any client that subscribes, even hours later, instantly receives the time of the last detection. The timestamps come from NTP; the ESP32 NTP MQTT timestamp guide explains the time-sync details used here.
The ESP32 PIR Motion Sensor MQTT Sketch
#include <WiFi.h>
#include <PubSubClient.h>
#include <time.h>
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* PREFIX = "connectmq/demo/pir9d4k"; // use your own random id
const uint8_t PIR_PIN = 27;
const uint32_t WARMUP_MS = 60000; // ignore triggers after power-on
const uint32_t COOLDOWN_MS = 10000; // min time between "detected" events
const uint32_t CLEAR_MS = 30000; // output low this long -> "clear"
WiFiClient net;
PubSubClient mqtt(net);
String clientId, tMotion, tLast, tStatus;
volatile bool edgeSeen = false;
volatile uint32_t lastEdgeMs = 0;
bool motionActive = false;
uint32_t lastDetectedMs = 0;
uint32_t lastHighMs = 0;
uint32_t motionCount = 0;
void IRAM_ATTR onPirRise() {
edgeSeen = true;
lastEdgeMs = millis();
}
bool isoNow(char* buf, size_t len) {
time_t now = time(nullptr);
if (now < 1700000000) return false; // not synced yet
struct tm utc;
gmtime_r(&now, &utc);
strftime(buf, len, "%Y-%m-%dT%H:%M:%SZ", &utc);
return true;
}
void publishState(const char* state) {
char ts[24] = "unknown";
bool synced = isoNow(ts, sizeof(ts));
char payload[128];
snprintf(payload, sizeof(payload), "{\"state\":\"%s\",\"ts\":\"%s\",\"count\":%lu}",
state, ts, (unsigned long)motionCount);
mqtt.publish(tMotion.c_str(), payload, true);
if (strcmp(state, "detected") == 0 && synced) {
mqtt.publish(tLast.c_str(), ts, true);
}
Serial.println(payload);
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(300);
Serial.printf("Wi-Fi OK, IP %s\n", WiFi.localIP().toString().c_str());
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(clientId.c_str(), tStatus.c_str(), 1, true, "offline")) {
mqtt.publish(tStatus.c_str(), "online", true);
Serial.println("MQTT connected");
} else {
Serial.printf("MQTT failed, state %d\n", mqtt.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT_PULLDOWN);
attachInterrupt(digitalPinToInterrupt(PIR_PIN), onPirRise, RISING);
clientId = "esp32-pir-" + String((uint32_t)ESP.getEfuseMac(), HEX);
tMotion = String(PREFIX) + "/motion";
tLast = String(PREFIX) + "/motion/last";
tStatus = String(PREFIX) + "/status";
connectWifi();
configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
mqtt.setServer(MQTT_HOST, MQTT_PORT);
connectMqtt();
Serial.println("Warming up PIR sensor...");
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
uint32_t now = millis();
bool pinHigh = digitalRead(PIR_PIN) == HIGH;
if (pinHigh) lastHighMs = now;
if (now < WARMUP_MS) { // discard warm-up noise
edgeSeen = false;
return;
}
// Rising edge (caught by the interrupt) or output still high -> motion
if (edgeSeen || pinHigh) {
edgeSeen = false;
if (!motionActive && now - lastDetectedMs >= COOLDOWN_MS) {
motionActive = true;
lastDetectedMs = now;
motionCount++;
publishState("detected");
}
}
// Output low long enough -> clear
if (motionActive && !pinHigh && now - lastHighMs >= CLEAR_MS) {
motionActive = false;
publishState("clear");
}
}
The interrupt service routine only sets a flag and a time. Never publish from inside an interrupt: networking code is not interrupt-safe and the ESP32 will crash. The flag guarantees that even a very short pulse from an AM312 is not missed while mqtt.loop() is busy.
Testing ESP32 PIR Motion Sensor MQTT Events
Subscribe to your prefix and wave a hand in front of the sensor after the one-minute warm-up:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pir-view-9d4k -t "connectmq/demo/pir9d4k/#" -v
You see a detected state, then clear about 30 seconds after the room goes quiet. Stop the subscriber, wait, and start it again: the retained motion/last value arrives immediately. The public area is shared and readable by everyone, so do not use it for a real home or office. Motion data reveals when a place is empty. See the public MQTT test broker page for its intended use.
Tuning the Timing
The three constants control behaviour:
- COOLDOWN_MS limits how often
detectedcan fire. Ten seconds suits a hallway light; for a security log you may want every new presence after a short gap. - CLEAR_MS decides when the room counts as empty. Add the module's own hold time to it: with an HC-SR501 hold time of 5 seconds and CLEAR_MS of 30 seconds,
cleararrives about 35 seconds after the last movement. - WARMUP_MS should cover the module's settle time. Sixty seconds is safe for the HC-SR501.
Turn the HC-SR501 hold-time potentiometer to its minimum and let the firmware do the timing. Software timing is easier to change remotely and consistent across modules.
Reacting to Motion Events
The broker delivers the events; something else decides what to do with them. Typical consumers are Home Assistant with an MQTT binary sensor, a Node-RED flow that switches a light and sends a notification, or a camera that captures a frame when motion is detected; the ESP32-CAM MQTT project pairs naturally with this sensor. Each consumer subscribes to motion as an ordinary MQTT client.
The retained offline will message is equally important for security uses. A motion sensor that silently dies looks exactly like a quiet room. The ESP32 MQTT last will tutorial explains how the broker publishes it on your behalf.
Troubleshooting
- Constant triggers. The sensor is too close to the ESP32 antenna or facing a heat source. Move it, lower sensitivity, and check the warm-up logic.
- No triggers at all. Check that the HC-SR501 is powered from 5 V, not 3.3 V, and that OUT goes to the configured pin.
- Detected but never clear. Retrigger mode with constant movement, or a hold time longer than expected. Reduce the hold potentiometer.
- ESP32 resets on motion. You are doing work in the interrupt handler. Keep it to setting flags.
- Timestamp shows unknown. NTP has not synced yet; allow outbound UDP 123.
Production Setup
For any real premises, use an authenticated, encrypted broker. Create one at app.connectmq.com, switch to WiFiClientSecure on port 8883 with the ISRG Root X1 certificate, and connect with YOUR_CLIENT_ID, YOUR_USERNAME and YOUR_PASSWORD. In the ConnectMQ portal you can watch the motion topic in the live topic explorer while you tune the sensor.
If you run several sensors, give each room its own prefix level, such as site/floor1/hallway/motion, rather than one flat topic. Consumers can then subscribe to one room or use a wildcard for the whole floor, and a threshold alert rule on the motion count of a sensitive room gives you a simple out-of-hours warning without extra backend code.
Run Your Motion Alerts on ConnectMQ
Motion alerts are only useful if the path from sensor to phone is reliable and private. ConnectMQ is a cloud MQTT broker with TLS, retained messages, last will support and a free Serverless plan that needs no credit card. Connect this node, wire up your automation tool of choice, and find more device projects in the ESP32 IoT tutorial collection.
Frequently Asked Questions
Why does my PIR sensor trigger right after power-on?
PIR modules need up to a minute to stabilise and produce random output during that time. Ignore triggers in a warm-up window, as the sketch does.
Should motion events be retained messages?
Retain the current state and the last-motion timestamp so new subscribers get context immediately. Do not retain one-off notifications that should only fire once.
Can I use deep sleep with a PIR sensor?
Yes. Configure the PIR output as an ext0 wake source, connect to Wi-Fi after waking, publish the event and go back to sleep. Expect a few seconds of delay before the message arrives.
Is an interrupt really necessary?
For an HC-SR501 with a long hold time, polling is enough. Short-pulse modules like the AM312 can be missed while the network code runs, so the interrupt flag makes detection reliable.



