Community project

Occupancy-Aware Room Lock

ESP32
Photo of Occupancy-Aware Room Lock
Generated with AI

Florian Beer

Published October 12, 2026

This occupancy-aware room lock uses an ESP32 microcontroller to automatically secure a door based on motion detection and manual controls. The system combines a servo-driven bolt mechanism with a PIR motion sensor to lock the room when unoccupied and unlock it when activity is detected, while allowing manual override via a release button and door reed switch.

Builders will receive a complete wiring diagram showing how to connect the SG90 servo, HC-SR501 PIR sensor, reed switch, release button, and status LEDs to the ESP32 breadboard setup. The guide includes a full parts list, step-by-step assembly instructions with power-supply configuration, and Arduino firmware that implements multiple operating modes (home, night, away, lock-away, alarm, smoke) with configurable motion timeouts and servo control logic.

Wiring diagram

Wiring diagram for Occupancy-Aware Room Lock

Gather all the parts

QtyComponent
1

SG90 Servo

Micro servo motor (SG90)

1

HC-SR501 PIR Motion Sensor

Passive Infrared (PIR) motion detection module with adjustable sensitivity and delay potentiometers. Operates on 5V supply; digital output is nominally 3.3V or 5V depending on module variant (verify before connecting directly to ESP32 3.3V GPIO — use a voltage divider if output is 5V). Outputs HIGH when motion is detected, LOW when idle. Ideal for triggering countdown timer resets in Focus Mode applications. No firmware library required — output read via standard GPIO digitalRead().

1

Reed Switch

MC-38 door-shut sensor

Glass-encapsulated magnetic reed switch -- contacts close in the presence of a magnetic field. Standard door/window sensor for alarms. Wire as a digital input with internal pull-up; debounce in firmware.

1

Momentary rocker release button

A normally-open inside button that immediately asks the lock to open when pressed.

1

LED

Green 5 mm

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

LED

Red 5 mm

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

MB102 breadboard power-supply module

A plug-in breadboard supply module that provides the separate 5 V rail for the servo and PIR.

1

470 µF electrolytic capacitor

A polarized capacitor that cushions the 5 V rail when the servo starts moving.

Assemble it in 7 steps

1. Make a shared ground rail

Put the WeAct ESP32-C6-MiNi across the breadboard center gap. Run one black jumper from any C6 GND pin to the breadboard ground rail. Set the MB102 output selector used for this rail to 5 V, then run a second black jumper from MB102 GND to that same ground rail. This shared ground gives every signal the same reference.

  • Power the WeAct board through its own USB-C port. The MB102 is a separate 5 V supply only for the servo and PIR.
  • Do not connect the MB102 5 V rail to the C6 3V3 pin — 5 V can damage the 3.3 V board.

2. Add the servo power cushion

Put capacitor_470uf across the MB102 5 V and ground rails close to the servo connection. Connect its long positive lead, marked +, to 5 V and its striped negative lead to GND. This helps stop a servo twitch from making the board reset.

  • Do not reverse the capacitor: putting its striped negative lead on 5 V can overheat or damage it.

3. Wire the lock servo

Connect servo_sg90 red to the MB102 5 V rail (power), brown or black to the shared GND rail (ground), and orange or yellow to C6 GPIO18 (signal). Mount the horn and bolt so 0 degrees is fully clear of the strike before trying 90 degrees as the locked position.

  • The servo must use the MB102 5 V rail, never the C6 3V3 pin. Make sure its ground remains joined to the C6 ground or the signal will not be reliable.

4. Wire the room motion sensor

Connect pir_hcsr501 VCC to MB102 5 V (power), GND to the shared ground rail (ground), and OUT to C6 GPIO2 (signal). Move the small H/L jumper on the PIR to H so movement restarts its output time. Turn the delay knob low for this prototype, then wait through the 30-second warm-up after power is applied.

  • The HC-SR501 output is normally about 3.3 V high even when its VCC is 5 V, so it can go directly to GPIO2; do not add a divider for this module.
  • Do not use a PIR module with a 5 V logic output on GPIO2 — the ESP32-C6 input is not 5 V tolerant.

5. Fit the door and inside-release switches

Connect one reed_mc38 wire to GPIO10 and the other to GND (door-shut signal). Put the magnet on the moving door and the reed body on the frame, then check that the closed door brings them close together. Connect release_button NO to GPIO11 and COM to GND (inside-release signal). The code uses its built-in pull-up resistors, so no extra resistors are needed for either switch.

  • When the door is shut, Serial state should show door as shut. Pressing the inside button should always request an immediate unlock.
  • Keep the reed and magnet aligned; a large gap can make the lock think the door is open.

6. Add the two status lights

For the green unlocked light, connect GPIO6 to resistor_green P1, resistor_green P2 to led_green ANODE, and the LED short leg to GND. For the red locked light, connect GPIO7 to resistor_red P1, resistor_red P2 to led_red ANODE, and the LED short leg to GND. Each 330 ohm resistor limits the LED current.

  • Do not omit either 330 ohm resistor — a direct GPIO-to-LED connection can damage the LED or overload the board pin.

7. Place and test the PIR coverage

Aim the PIR across the reading area, roughly chest height, rather than straight at the chair. Open Schematik’s serial output after deploying. Sit in the normal reading position, then make the tiny movements a reader normally makes: turn a page, shift posture, move a hand. Watch the printed seconds_since_last_motion number; it should reset to 0 during those movements. Stay still for the demo 30 seconds only if you want to confirm that the lock becomes eligible when the door is shut and the mode is away.

  • A PIR detects changes in body heat, not a motionless person. It will often miss someone sitting perfectly still, so use its test result to decide whether the final product needs another occupancy method.
  • Keep the prototype bolt where it cannot trap anyone. The inside button and smoke command are fail-open, but this breadboard prototype is not a certified door lock.

Review all connections

1. Connections between "mb102_5v" and "ESP32"

Functionmb102_5vESP32
powerUSB 5V IN → USB 5 V supplyEXT
power5V OUT5V
groundGNDGND

2. Connections between "capacitor_470uf" and "ESP32"

Functioncapacitor_470ufESP32
power+5V
ground-GND

3. Connections between "servo_sg90" and "ESP32"

Functionservo_sg90ESP32
powerVCC5V
groundGNDGND
pwmSIGNALGPIO 18

4. Connections between "pir_hcsr501" and "ESP32"

Functionpir_hcsr501ESP32
powerVCC5V
groundGNDGND
digitalOUTGPIO 2

5. Connections between "reed_mc38" and "ESP32"

Functionreed_mc38ESP32
dataPIN1GPIO 10
groundPIN2GND

6. Connections between "release_button" and "ESP32"

Functionrelease_buttonESP32
digitalNOGPIO 11
groundCOMGND

7. Connections between "resistor_green" and "ESP32"

Functionresistor_greenESP32
digitalP1GPIO 6
digitalP2 → LED ANODEEXT

8. Connections between "led_green" and "ESP32"

Functionled_greenESP32
groundGNDGND

9. Connections between "resistor_red" and "ESP32"

Functionresistor_redESP32
digitalP1GPIO 7
digitalP2 → LED ANODEEXT

10. Connections between "led_red" and "ESP32"

Functionled_redESP32
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <ESP32Servo.h>
#include "thread_stubs.h"

constexpr uint8_t SERVO_PIN = 18;
constexpr uint8_t PIR_PIN = 2;
constexpr uint8_t DOOR_REED_PIN = 10;
constexpr uint8_t RELEASE_BUTTON_PIN = 11;
constexpr uint8_t GREEN_LED_PIN = 6;
constexpr uint8_t RED_LED_PIN = 7;
constexpr uint8_t BUZZER_PIN = 3;  // Reserved only; connect an active buzzer later if wanted.

constexpr int UNLOCKED_ANGLE = 0;
constexpr int LOCKED_ANGLE = 90;
constexpr unsigned long PIR_WARMUP_MS = 30000UL;
constexpr unsigned long VACANCY_TIMEOUT_MS = 30000UL;  // Demo: use 600000UL for the final 10-minute timeout.
constexpr unsigned long SERVO_SETTLE_MS = 450UL;
constexpr unsigned long BUTTON_DEBOUNCE_MS = 35UL;
constexpr unsigned long STATUS_LOG_MS = 1000UL;

enum Mode { HOME, NIGHT, AWAY, LOCK_AWAY, ALARM, SMOKE };

Servo boltServo;
Mode currentMode = HOME;
bool locked = false;
bool roomOccupied = true;
bool previousPir = false;
bool previousButton = true;
bool servoSignalAttached = false;
unsigned long servoDetachAtMs = 0;
unsigned long bootMs = 0;
unsigned long lastMotionMs = 0;
unsigned long lastButtonEdgeMs = 0;
unsigned long lastStatusLogMs = 0;

const char *modeName(Mode mode) {
  switch (mode) {
    case HOME: return "home";
    case NIGHT: return "night";
    case AWAY: return "away";
    case LOCK_AWAY: return "lock-away";
    case ALARM: return "alarm";
    case SMOKE: return "smoke";
  }
  return "home";
}

bool doorShut() {
  // With INPUT_PULLUP, a closed reed wired to GND reads LOW.
  return digitalRead(DOOR_REED_PIN) == LOW;
}

bool modePermitsLock() {
  return currentMode == AWAY || currentMode == LOCK_AWAY || currentMode == ALARM;
}

void updateIndicators() {
  digitalWrite(GREEN_LED_PIN, locked ? LOW : HIGH);
  digitalWrite(RED_LED_PIN, locked ? HIGH : LOW);
}

void moveBolt(int angle) {
  // Attach only while moving; loop() detaches it after the settling time.
  boltServo.setPeriodHertz(50);
  boltServo.attach(SERVO_PIN, 500, 2400);
  boltServo.write(angle);
  servoSignalAttached = true;
  servoDetachAtMs = millis() + SERVO_SETTLE_MS;
}

void lockBolt() {
  if (locked) return;
  if (!doorShut()) {
    Serial.println("LOCK skipped: door is open");
    return;
  }
  moveBolt(LOCKED_ANGLE);
  locked = true;
  updateIndicators();
  sendEvent("locked");
  Serial.println("LOCKED");
}

void requestUnlock(const char *reason) {
  if (locked) {
    moveBolt(UNLOCKED_ANGLE);
    locked = false;
    updateIndicators();
    Serial.print("UNLOCKED: ");
    Serial.println(reason);
  }
}

void setModeFromText(const char *text) {
  if (strcmp(text, "home") == 0) currentMode = HOME;
  else if (strcmp(text, "night") == 0) currentMode = NIGHT;
  else if (strcmp(text, "away") == 0) currentMode = AWAY;
  else if (strcmp(text, "lock-away") == 0) currentMode = LOCK_AWAY;
  else if (strcmp(text, "alarm") == 0) currentMode = ALARM;
  else if (strcmp(text, "smoke") == 0) currentMode = SMOKE;
  else {
    Serial.println("ERR mode must be home, night, away, lock-away, alarm, or smoke");
    return;
  }

  Serial.print("MODE ");
  Serial.println(modeName(currentMode));
  if (currentMode == SMOKE) requestUnlock("smoke");
}

String currentStateJson() {
  String state = "{\"lock\":\"";
  state += locked ? "locked" : "unlocked";
  state += "\",\"door\":\"";
  state += doorShut() ? "shut" : "open";
  state += "\",\"room_occupied\":\"";
  state += roomOccupied ? "yes" : "no";
  state += "\",\"mode\":\"";
  state += modeName(currentMode);
  state += "\"}";
  return state;
}

void processSerial() {
  static String line;
  while (Serial.available()) {
    char c = static_cast<char>(Serial.read());
    if (c == '\n' || c == '\r') {
      line.trim();
      if (line.length()) onCommand(line.c_str());
      line = "";
    } else {
      line += c;
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);
  pinMode(DOOR_REED_PIN, INPUT_PULLUP);
  pinMode(RELEASE_BUTTON_PIN, INPUT_PULLUP);
  pinMode(GREEN_LED_PIN, OUTPUT);
  pinMode(RED_LED_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  bootMs = millis();
  lastMotionMs = bootMs;
  previousPir = digitalRead(PIR_PIN) == HIGH;
  previousButton = digitalRead(RELEASE_BUTTON_PIN) == HIGH;
  updateIndicators();

  Serial.println("Nivasa Sentinel prototype started");
  Serial.println("PIR warm-up: 30 seconds. Serial commands: mode away | mode smoke | unlock | state");
}

void loop() {
  processSerial();
  const unsigned long now = millis();

  if (servoSignalAttached && static_cast<long>(now - servoDetachAtMs) >= 0) {
    boltServo.detach();
    servoSignalAttached = false;
  }

  // The inside release path is checked every pass through loop and is never delayed by lock decisions.
  const bool buttonHigh = digitalRead(RELEASE_BUTTON_PIN) == HIGH;
  if (previousButton && !buttonHigh && now - lastButtonEdgeMs >= BUTTON_DEBOUNCE_MS) {
    lastButtonEdgeMs = now;
    requestUnlock("manual_release");
    sendEvent("manual_release");
  }
  previousButton = buttonHigh;

  const bool pirHigh = digitalRead(PIR_PIN) == HIGH;
  if (now - bootMs >= PIR_WARMUP_MS && pirHigh && !previousPir) {
    lastMotionMs = now;
    roomOccupied = true;
    Serial.println("PIR motion");
    if (locked) {
      requestUnlock("person_inside");
      sendEvent("person_inside");
    }
  }
  previousPir = pirHigh;

  if (!doorShut() && locked) {
    requestUnlock("door_opened");
  }

  if (currentMode == SMOKE) {
    requestUnlock("smoke");
  }

  const unsigned long quietMs = now - lastMotionMs;
  if (!locked && modePermitsLock() && doorShut() && now - bootMs >= PIR_WARMUP_MS && quietMs >= VACANCY_TIMEOUT_MS) {
    roomOccupied = false;
    lockBolt();
  }

  if (now - lastStatusLogMs >= STATUS_LOG_MS) {
    lastStatusLogMs = now;
    Serial.print("seconds_since_last_motion=");
    Serial.print(quietMs / 1000UL);
    Serial.print(" ");
    Serial.println(currentStateJson());
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik