PROJECT 4 · SENSOR + TIMERS

PIR Motion Sensor + Buzzer

Detect movement with an HC‑SR501 and sound a buzzer: using a non‑blocking millis() timer.

Builds on: Project 1's active-high digital input, now reading a sensor instead of a button.

⏱ ~25 min 📊 Beginner 🔖 Prerequisite: Project 1
⬇ Download the sketch Save Project_4_ESP32_PIR_Motion_Sensor.ino, then open it in the Arduino IDE (setup: Foundation 3).
🎲 Try it without hardware 📄 diagram.json Open the Wokwi simulator, then open or download diagram.json above and paste its wiring in to load the circuit.

🎯 Objective

By the end of this project, you will:

  • Understand how a PIR sensor detects movement, and what it cannot detect.
  • Wire a 5 V sensor to the ESP32 safely and know why it uses VIN rather than 3V3.
  • Use millis() to build a non-blocking timer and explain why delay() is the wrong tool for it.
  • Track state across loop iterations with a flag, so an event fires once instead of continuously.
  • Build a small alarm that holds its output for a few seconds after the last movement.

1How the PIR sensor works

HC-SR501 PIR sensor with pins and adjusters labeled
HC‑SR501: OUT goes HIGH on motion; two trimmers set range and hold time.
  • A PIR senses the infra‑red heat of living bodies and triggers on change, a warm, moving object.
  • OUT = HIGH on motion, LOW otherwise.
  • A person standing perfectly still may not be detected; a moving heat‑less object (robot, car) isn't either.

The two orange trimmers adjust sensitivity/range and time delay. The jumper (usually labeled H and L) selects retrigger mode:

  • H (repeatable trigger, the default): each new motion during the sensor's own delay window resets that window, so its output stays HIGH as long as something keeps moving.
  • L (single trigger): the output goes HIGH once, holds for the delay set by the time‑delay trimmer, then drops LOW and ignores further motion for about 2.5 s, even if something is still moving.

Defaults (H, trimmers centered) are fine for this project. If the buzzer holds on far longer than the sketch's own 3‑second holdTime, someone is still moving in front of the sensor and H mode keeps resetting the sensor's output: that's expected, not a bug (see Troubleshooting).

Warm‑up: after power‑on the PIR needs ~30–60 s to stabilize before it reads reliably.

2Non‑blocking timing: delay() vs millis()

delay(3000) freezes the whole program for 3 s. Instead we record when motion last happened and check elapsed time: keeping the loop responsive:

lastMotionTime = millis();                    // remember "now"
if (millis() - lastMotionTime >= holdTime) {  // has enough time passed?
  ...
}

This pattern scales to programs doing many things at once, a habit worth building early.

3Parts Required

QtyPartNotes
1ESP32 · breadboard · jumper wires-
1HC‑SR501 PIRPowered from 5 V
1Active buzzerBeeps on its own when powered

4Wiring the Circuit

PIR OUT to GPIO 27, buzzer to GPIO 26, PIR on VIN/5V
PIR OUT → GPIO 27 · Buzzer → GPIO 26 · PIR powered from VIN (5 V).
FromTo
PIR VCCVIN (5 V)
PIR GNDGND
PIR OUTGPIO 27
Buzzer +GPIO 26
Buzzer GND
Why VIN, not 3V3? The HC‑SR501 needs 5 V (on VIN when USB‑powered). Its OUT signal is still ESP32‑safe.

5Code Walkthrough: Understanding the Sketch

Let's walk through the sketch so you understand what each line does and why.

Step 1: Pins, the hold time, and the state we remember

const int buzzerPin    = 26;  // active buzzer -> GPIO 26
const int motionSensor = 27;  // PIR OUT       -> GPIO 27

const long holdTime = 3000;         // hold the buzzer this long (ms)
unsigned long lastMotionTime = 0;  // when motion was last seen
bool alarmActive = false;           // is the buzzer sounding right now?

Two variables carry the logic. lastMotionTime is a timestamp, not a countdown: it stores the moment motion was last seen. alarmActive is a flag recording what the program already did, which stops the buzzer being switched on again on every pass through loop().

unsigned long matters: millis() passes the range of a normal int in about 32 seconds, but an unsigned long holds roughly 49 days.

Step 2: one-time preparation in setup()

pinMode(buzzerPin, OUTPUT);      // the buzzer is driven by the ESP32
pinMode(motionSensor, INPUT);    // the PIR drives this pin
digitalWrite(buzzerPin, LOW);    // start silent

Note the explicit digitalWrite(buzzerPin, LOW). A freshly configured output pin is not guaranteed to be in the state you want, and starting an alarm project with an unexpected beep is a poor demo.

Step 3: loop() watches for motion and times the hold

void loop() {
  bool motion = (digitalRead(motionSensor) == HIGH);

  if (motion) {
    lastMotionTime = millis();          // remember when we last saw motion
    if (!alarmActive) {                 // only act on the RISING edge
      digitalWrite(buzzerPin, HIGH);
      alarmActive = true;
      Serial.println("[EVENT] MOTION detected  -> buzzer ON");
    }
  } else {
    // No motion now: stop once the hold time has elapsed.
    if (alarmActive && (millis() - lastMotionTime >= holdTime)) {
      digitalWrite(buzzerPin, LOW);
      alarmActive = false;
      Serial.println("[EVENT] No motion (hold elapsed) -> buzzer OFF");
    }
  }
}

Read it as two questions asked thousands of times per second: is there motion right now? and, if not, has it been quiet long enough to stop? Because the loop never blocks, the sensor is still read while the hold counts down, so fresh motion pushes lastMotionTime forward and extends the alarm.

Key concepts: what each line really does

CodeWhat it does
digitalRead(motionSensor) == HIGHThe HC-SR501 drives OUT high while it sees movement, so reading it is exactly like reading a button.
millis()Milliseconds since boot. It keeps counting no matter what your code is doing, which is what makes it usable as a clock.
lastMotionTime = millis()Stores "now" as a timestamp. Every fresh detection overwrites it, restarting the hold period.
millis() - lastMotionTime >= holdTimeThe non-blocking timer: subtract the stored moment from the current one to get elapsed time, then compare. No waiting involved.
if (!alarmActive)Edge detection in flag form. Without it the buzzer would be re-commanded ON and the message re-printed on every pass.
bool alarmActiveThe program's memory of its own output state, so it can tell "start the alarm" from "the alarm is already running".

Expected output on the Serial Monitor

==============================================
 Project 4: ESP32 PIR Motion Sensor + Buzzer
==============================================
PIR OUT -> GPIO 27
Buzzer  -> GPIO 26
Buzzer holds ON for 3000 ms after motion stops.
NOTE: the PIR needs ~30-60 s to warm up after power-on.
----------------------------------------------
[EVENT] MOTION detected  -> buzzer ON
[EVENT] No motion (hold elapsed) -> buzzer OFF
Improved from the original sketch: the kit's version re‑printed the same message every loop and had a confusing "stop alarm" line. This one logs only the two real events.

Going further: swap in the obstacle-avoidance module

The kit's obstacle‑avoidance module reads exactly like the PIR: a digital pin that goes HIGH or LOW depending on what its sensor sees, this time IR reflectance instead of body heat.

Obstacle module pinESP32
VCC3.3 V
GNDGND
OUTGPIO 27 (same pin as the PIR)
Power it from 3.3 V, not 5 V. Unlike the HC‑SR501 PIR, whose output stage is confirmed safe at 5 V by its own datasheet, this module's digital output tracks whatever you feed its VCC pin. Powering it at 5 V could put a 5 V HIGH on a GPIO built for 3.3 V. Running it from 3V3 instead keeps the logic level safe, and the sensor still works fine at that voltage, just with slightly shorter range.

The trimmer on the module sets detection distance: turn it to tune how close an object has to get before OUT goes HIGH. digitalRead(motionSensor) in the sketch above doesn't change at all: an obstacle module and a PIR are both "is something there right now" sensors, just tuned to different physics.

This is the sensor half of Project 13's Obstacle alarm idea.

Predict, then check. Predict: if you wave your hand once and then hold completely still, how many [EVENT] MOTION detected lines print while you stay still? Try it and count.

6Demonstration

Animated demo: motion triggers the buzzer and prints MOTION detected to serial
Wave your hand → buzzer sounds & "MOTION detected" prints. Stay still → it stops after ~3 s.

🎯 Knowledge Check

1. What does a PIR sensor detect?

2. Why should you use millis() instead of delay() to keep the alarm running?

3. What data type should you use for millis() variables?

4. The HC-SR501 PIR sensor needs 5 V power. Is its output signal safe for an ESP32 GPIO?

5. What does the "state flag" pattern (alarmActive) prevent?

8Wrapping Up: What You've Learned

You now have a sensor driving an actuator, with timing that does not freeze the program. The key takeaways:

  • A PIR sensor reports change in infra-red heat, so it sees a moving warm body but not a still one, and not a cold moving object. It also needs 30 to 60 s to warm up.
  • Some modules need 5 V. VIN supplies it while the board is USB-powered, and the HC-SR501's 3.3 V output is safe to feed back into a GPIO.
  • millis() gives you a clock that never stops. Storing a timestamp and subtracting it later is the standard non-blocking timer pattern.
  • delay() would have worked here but blocks everything. Once a project has two things to do at once (Projects 8 and 11 do), blocking code stops being an option.
  • A state flag like alarmActive lets the program tell a new event from an ongoing condition, which keeps the log clean and the output stable.

Now try

  • Countdown timer: Instead of a fixed hold time, display a countdown on the Serial Monitor ("Alarm active, 3s remaining... 2s... 1s...").
  • Obstacle-avoidance swap: Replace the PIR with the kit's obstacle-avoidance module. It reads the same way (digital HIGH/LOW) but must be powered from 3.3 V, not 5 V.

9Troubleshooting

SymptomLikely causeFix
Always triggersWarming up / sensitivity too highWait 60 s; lower the sensitivity trimmer
Never triggersPIR on 3.3 V, or OUT mis‑wiredPower VCC from VIN (5 V); OUT → GPIO 27
Buzzer silentReversed / wrong pin+ → GPIO 26, − → GND
Buzzer never stopsConstant motion / retrigger jumperHold still; check the jumper
Monitor blankWrong baudSet it to 115200