PROJECT 4 · SENSOR + TIMERS

PIR Motion Sensor + Buzzer

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

⬇ Download the sketch Save Project_4_ESP32_PIR_Motion_Sensor.ino, then open it in the Arduino IDE (setup: Foundation 3).

🎯 Objective

By the end of this project, you will:

1 · How 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.
Warm‑up: after power‑on the PIR needs ~30–60 s to stabilize before it reads reliably.

2 · Non‑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.

3 · Parts Required

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

4 · Wiring 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.

5 · Code 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.

6 · Demonstration

PIR and buzzer wired to the ESP32 with serial output
Wave your hand → buzzer sounds & "MOTION detected" prints. Stay still → it stops after ~3 s.

7 · Wrapping Up: What You've Learned

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

8 · Troubleshooting

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
← Project 3 · PWM Project 5 · Switch Web Server →