Siddhant Kumar
Project 001 · Smart Home

Smart Door Lock.

A three-factor entry system — RFID card, keypad PIN, or a phone command over MQTT — driving an electric strike, with every unlock attempt timestamped and pushed to the cloud.

Intermediate 10–14 hours 65 min read ESP32RFIDApp
Jump to source Bill of materials
Smart Door Lock — reference build illustration ESP32 USB
Difficulty
Intermediate
Build time
10–14 hours
Indicative cost
₹4,200 – ₹5,400
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
Contents — 26 sections

Project Overview

A three-factor entry system — RFID card, keypad PIN, or a phone command over MQTT — driving an electric strike, with every unlock attempt timestamped and pushed to the cloud.

A mechanical lock answers exactly one question: does the person at the door hold a piece of shaped metal? It cannot tell you when the door opened, who opened it, or revoke a key that walked off in someone's pocket. This project replaces that single bit of information with an auditable access-control system small enough to fit inside a door frame.

The build centres on an ESP32, which is unusual for a lock: most hobby locks use an Arduino and a servo. The ESP32 earns its place because it does three jobs at once — it runs the 13.56 MHz RFID reader over SPI, scans a membrane keypad, and maintains a persistent TLS-secured MQTT session so an unlock event reaches your phone in under a second. Its second core keeps the network stack from ever stalling the reader loop, which is what makes the card read feel instant rather than laggy.

Credentials are never stored as plain card IDs. Each authorised UID is salted and hashed before it goes into non-volatile storage, so someone who dumps the flash still cannot clone a card from it. That is a small change in code and a large change in what the system is actually worth. The same principle applies to the PIN: it is compared as a hash, and the comparison is written to take constant time so an attacker cannot learn the PIN one digit at a time by measuring how long a rejection takes.

The result is a lock you can reason about. Every attempt — successful or not — produces a log line with a timestamp from NTP, the credential type, and the outcome. Revoking access is a single MQTT message rather than a locksmith visit.

An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board of the kind used as the lock controller — dual-core, Wi-Fi and BLE on one module. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Reads MIFARE Classic 13.56 MHz cards and fobs, and unlocks on a match against a hashed allow-list held in flash.
  • Accepts a 4–8 digit PIN on a 4×4 membrane keypad as an independent credential, with lockout after repeated failures.
  • Accepts a remote unlock command published over MQTT, so you can let someone in from anywhere.
  • Drives a 12 V electric strike through an opto-isolated relay, holding it open for a configurable dwell time.
  • Timestamps every attempt from NTP and publishes it as a JSON event to the broker.
  • Enrols and revokes cards over MQTT — no reflashing to add a housemate.
  • Falls back to local-only operation when the network is down, and replays buffered events when it returns.

Real-World Applications

SettingHow it is used
Shared housingGive each tenant a fob, revoke it the day they move out, and see exactly who came and went without a key handover.
Small office / co-workingPer-person access logs satisfy basic security-audit requirements at a fraction of the cost of a commercial access-control panel.
Laboratory and server roomsRestrict entry to a named list and get an immediate alert on any out-of-hours attempt.
Rental property turnoverRemote enrolment means a cleaner or guest can be granted access for a window of time with no physical key exchange.
Equipment cabinetsThe same electronics scaled down to a cabinet solenoid controls access to tools, medication or firearms storage.
School and hostel blocksCurfew logic — the same card that opens the door at 18:00 is refused at 02:00 and the attempt is logged.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Three independent credential paths — RFID, PIN, and remote — any of which can be disabled in configuration.
  • Salted SHA-256 credential storage in ESP32 NVS, so a flash dump does not yield clonable card IDs.
  • Constant-time comparison of PIN and card hashes to defeat timing side channels.
  • Brute-force lockout — five failed attempts in 60 s disables the keypad for five minutes and raises an alert.
  • Offline event buffer holding the last 64 events in RTC memory so a network outage never loses an audit record.
  • Fail-secure or fail-safe strike support — a single configuration constant flips the relay polarity for either strike type.
  • NTP-synchronised timestamps with a DS3231 RTC as backup so events remain ordered without a network.
  • Manual egress override on a dedicated input, wired so the door always opens from the inside regardless of firmware state.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time10–14 hours
Indicative build cost₹4,200 – ₹5,400
Primary disciplineSmart Home
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Basic C++ and the Arduino setup() / loop() model
  • SPI and how chip-select lines share a bus between devices
  • Reading a datasheet well enough to find a supply voltage and a logic level
  • Wiring a relay to switch a load that is on a different supply from the controller
  • Elementary MQTT — topics, publish, subscribe, retained messages
  • Enough security literacy to understand why you hash a credential instead of storing it

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32 DevKit V1 (ESP-WROOM-32)
Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC1₹450
MFRC522 13.56 MHz RFID reader + card + fob
Strictly 3.3 V — 5 V on the SPI pins kills the chip.
ISO/IEC 14443A, MIFARE Classic 1K, 0–60 mm range, up to 10 Mbit/s SPI1₹180
4×4 matrix membrane keypad
Debounce in software — 20 ms is enough for membrane keys.
16 keys, 8-wire matrix, 35 × 76 mm adhesive-backed1₹70
5 V single-channel opto-isolated relay module
Cut the JD-VCC jumper and feed the coil separately for true isolation.
SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input1₹90
LM2596 adjustable buck converter module
Set the output voltage with no load connected before wiring the board.
4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency1₹90
DS3231 precision RTC + CR2032 backup
Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor.
±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out1₹180
0.96″ SSD1306 OLED display
Static images burn in — invert or scroll the screen periodically.
128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C1₹250
Active piezo buzzer 5 V
Active buzzers make tone on DC; passive ones need a PWM carrier.
85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter1₹25
12 V 5 A SMPS adapter
Fuse the 12 V rail at ~1.5× your calculated draw.
100–240 VAC in, 12 V ±5 %, 5 A, 60 W, DC 5.5 × 2.1 mm barrel1₹650
Double-sided perfboard 7 × 9 cm + headers
Solder female headers so the MCU can be swapped without desoldering.
FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid1₹60
IP65 ABS junction enclosure 158 × 90 × 60 mm
Fit cable glands, not drilled holes, or the IP rating means nothing.
IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs1₹260
12 V fail-secure electric door strike
Fail-secure stays locked without power. Choose fail-safe only where fire code demands it.
12 V DC, 350–500 mA holding, ANSI-grade faceplate1₹1,400
Momentary push button (egress)NO contact, panel-mount 16 mm1₹60
1N4007 flyback diode
One across the strike coil, one across the relay coil if the module lacks it.
1000 V, 1 A rectifier2₹10
Assorted 22 AWG hookup wire + ferrulesStranded, 5 colours1₹200

Estimated total: ₹3,975, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter — continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32 DevKit V1 (ESP-WROOM-32)Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
MFRC522 13.56 MHz RFID reader + card + fobISO/IEC 14443A, MIFARE Classic 1K, 0–60 mm range, up to 10 Mbit/s SPI2.5–3.6 VSPI (also I²C/UART)Datasheet
4×4 matrix membrane keypad16 keys, 8-wire matrix, 35 × 76 mm adhesive-backedlogic level8 GPIO matrix scanDatasheet
5 V single-channel opto-isolated relay moduleSPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input5 V coilDigital (active-low)Datasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
DS3231 precision RTC + CR2032 backup±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out2.3–5.5 VI²C (0x68)Datasheet
0.96″ SSD1306 OLED display128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C3.3–5 VI²C (0x3C)Datasheet
Active piezo buzzer 5 V85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter3–5 VDigital / PWMDatasheet
12 V 5 A SMPS adapter100–240 VAC in, 12 V ±5 %, 5 A, 60 W, DC 5.5 × 2.1 mm barrel12 VDC barrelDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
MFRC522 13.56 MHz RFID reader + card + fob2.5–3.6 V26Strictly 3.3 V — 5 V on the SPI pins kills the chip.
4×4 matrix membrane keypadlogic level1Debounce in software — 20 ms is enough for membrane keys.
5 V single-channel opto-isolated relay module5 V coil70Cut the JD-VCC jumper and feed the coil separately for true isolation.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
DS3231 precision RTC + CR2032 backup2.3–5.5 V0.2Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor.
0.96″ SSD1306 OLED display3.3–5 V20Static images burn in — invert or scroll the screen periodically.
Active piezo buzzer 5 V3–5 V30Active buzzers make tone on DC; passive ones need a PWM carrier.
12 V 5 A SMPS adapter12 V5000Fuse the 12 V rail at ~1.5× your calculated draw.

Summed typical draw is 5315.2 mA. With a 1.5× design margin the supply should deliver at least 8000 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools → Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
  • Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
MFRC522 1.4.xSPI driver and MIFARE authentication for the RC522 reader.Library Manager → "MFRC522" by GithubCommunity
Adafruit SSD1306 + GFX 2.5.xFramebuffer and text/graphics primitives for the OLED.Library Manager → "Adafruit SSD1306"
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)
WiFiManager 2.0.xCaptive-portal Wi-Fi provisioning — no hard-coded credentials.Library Manager → "WiFiManager" by tzapu

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Smart Door Lock — system block diagramFunctional block diagram of the Smart Door Lock system. Credential inputMFRC522 reader13.56 MHz MIFARE4×4 keypadPIN entryMQTT commandremote unlockDecisionESP32 core 1credential checkNVS allow-listsalted SHA-256ActionRelay driveropto-isolatedOLED + buzzeruser feedbackAuditESP32 core 0MQTT clientBroker + logtimestampedhash + compareunlock signalJSON event
Smart Door Lock — system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

Smart Door Lock — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBMFRC522 RFID readerGPIO 5SPI chip selectMFRC522 RFID readerGPIO 18 / 23 / 19SPI bus (3.3 V only)MFRC522 RFID readerGPIO 27Reader reset4×4 keypad rowsGPIO 13 32 33 25Driven low duringscan4×4 keypad columnsGPIO 26 14 12 4Read with internalpull-upEgress buttonGPIO 34Input-only, external10 kΩ pull-upDS3231 RTC + SSD1306 OLEDGPIO 21 / 22Shared I²C busRelay module → electricstrikeGPIO 2Active-low,opto-isolatedPiezo buzzerGPIO 15Feedback tonesStatus LED (green/red)GPIO 16 / 17Through 330 Ω
Smart Door Lock — wiring schematic
PeripheralPeripheral pinController pinSignal
MFRC522 RFID readerSDA / SSGPIO 5SPI chip select
MFRC522 RFID readerSCK / MOSI / MISOGPIO 18 / 23 / 19SPI bus (3.3 V only)
MFRC522 RFID readerRSTGPIO 27Reader reset
4×4 keypad rowsR1–R4GPIO 13 32 33 25Driven low during scan
4×4 keypad columnsC1–C4GPIO 26 14 12 4Read with internal pull-up
Egress buttonNO contactGPIO 34Input-only, external 10 kΩ pull-up
DS3231 RTC + SSD1306 OLEDSDA / SCLGPIO 21 / 22Shared I²C bus
Relay module → electric strikeINGPIO 2Active-low, opto-isolated
Piezo buzzer+GPIO 15Feedback tones
Status LED (green/red)AnodeGPIO 16 / 17Through 330 Ω

Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • The RC522 is a 3.3 V part and is not 5 V tolerant. Power it from the ESP32 3V3 pin, never from 5 V. This is the single most common way people destroy this module.
  • The SPI bus is shared. If you later add an SD card, give it its own chip-select pin and pull both CS lines high at boot before either device is initialised.
  • GPIO 34–39 on the ESP32 are input only and have no internal pull-ups. The egress button on GPIO 34 therefore needs an external 10 kΩ resistor to 3V3, with the button pulling the pin to ground.
  • GPIO 12 is a strapping pin — if it is held high at boot the ESP32 selects a 1.8 V flash voltage and will not start. The keypad column driving it must be left floating or low during reset, which it is with the internal pull-up scheme used here.
  • The strike and the ESP32 share a ground but not a supply. A 12 V 2 A adapter feeds the strike directly and an LM2596 buck converter drops the same 12 V to 5 V for the ESP32. One adapter, two rails, one common ground reference.
  • Fit a 1N4007 across the strike coil, cathode to the positive terminal. An electric strike is a large inductor; without the diode the collapse spike will weld the relay contacts within weeks.
  • Route the RFID antenna away from the relay and the strike wiring. A 13.56 MHz reader sitting on top of a switching coil will read intermittently and you will blame the card.
A single-channel relay module with screw terminals
An opto-isolated relay module. The optocoupler package between the input header and the coil is what keeps the strike's inductive kick away from the microcontroller. Photograph sourced from Wikimedia Commons — Relay module.jpg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

Smart Door Lock — architecture stackLayered architecture from hardware to user interface. HardwareESP32-WROOM-32 · MFRC522 · 4×4 keypad · 12 V strike + relay · DS3231Driver layerSPI (RFID) · GPIO matrix scan · I²C (RTC, OLED) · PWM (buzzer)Security layermbedTLS SHA-256 · per-device salt in NVS · constant-time compare · lockoutstate machineTransport layerWi-Fi station · MQTT over TLS 8883 · NTP · exponential reconnect backoffPresentationOLED status · phone notification · Grafana access log
Smart Door Lock — architecture stack

Working Principle

The RFID half rests on inductive coupling. The MFRC522 drives its antenna coil at 13.56 MHz, creating an alternating magnetic field. A passive MIFARE card holds a coil of its own; when it enters the field, that coil develops enough induced voltage to power the chip inside it — the card has no battery. The card then replies by load modulation: it switches a resistance across its own coil, which the reader detects as a tiny change in the current drawn by its own antenna. That is the entire physical layer, and it is why range is measured in centimetres rather than metres.

What comes back is a UID — typically four or seven bytes. It is important to understand what a UID is and is not. It is a serial number, not a secret; a MIFARE Classic UID is readable by any phone with NFC and cloneable onto blank "magic" cards for a few rupees. This project therefore treats the UID as an identifier, and treats possession of the physical card as the actual factor. If you need genuine cryptographic authentication, the same reader supports MIFARE DESFire, which performs a challenge-response using a key that never crosses the air gap.

The keypad works on a completely different principle: matrix scanning. Sixteen keys would need sixteen GPIO if wired individually. Instead they sit at the intersections of four row lines and four column lines. The firmware drives one row low at a time and reads all four columns; if a column reads low, the key at that intersection is pressed. A full scan of the 4×4 matrix takes about 40 µs, so scanning at 200 Hz costs less than 1 % of the CPU while feeling instantaneous. Contacts bounce for 5–15 ms, so a key is only accepted once it has read the same state across two scans 20 ms apart.

The security model is where this differs from a typical tutorial build. A credential is never stored or compared in the clear. At enrolment the firmware generates a 16-byte random salt (once per device, kept in NVS), appends it to the credential, hashes the result with SHA-256 from the ESP32's bundled mbedTLS, and stores only the 32-byte digest. At verification it repeats the operation and compares digests byte by byte without early exit. A naive memcmp returns as soon as it finds a difference, so a rejected guess that shares the first byte takes measurably longer than one that does not — over enough attempts that leaks the secret. The constant-time loop XORs every byte and ORs the results, so the timing is identical whatever the input.

Finally, the relay. The ESP32 cannot switch 12 V at 500 mA, and more importantly you do not want the strike's inductive kick anywhere near the microcontroller. The opto-isolated relay module breaks the electrical path entirely: the GPIO drives an LED inside an optocoupler, light crosses an air gap, and a phototransistor on the far side switches the relay coil from a separate supply. The two halves share no copper, so a spike on the strike side cannot reach the ESP32 at all.

The maths behind it

Lockout back-off

plainLockout back-off
lockout_seconds = base × 2^(failures − threshold)

base       = 30 s
threshold  = 5 failed attempts inside 60 s

failures =  5 →   30 s
failures =  6 →   60 s
failures =  7 →  120 s
failures =  8 →  240 s   (capped at 900 s)

Exponential back-off makes online brute force useless: guessing a 4-digit PIN needs 10 000 attempts, and after the eighth failure each further guess costs a quarter of an hour.

Strike power and relay margin

plainStrike power and relay margin
Strike holding current  I  = 0.45 A at 12 V
Power                   P  = 12 V × 0.45 A = 5.4 W
Relay contact rating       = 10 A @ 30 VDC
Utilisation                = 0.45 / 10 = 4.5 %

Inrush (coil energising) ≈ 3 × I = 1.35 A for ~20 ms

The relay is enormously over-specified for the load, which is exactly what you want — contact life is dominated by inrush, and running at under 5 % of rating means the contacts will outlast the strike.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Smart Door Lock — firmware flowchartControl flow through the main program loop. Boot: mount NVS, join Wi-Fi,sync NTPScan keypad and poll RFIDreaderCredential presented?yesidleHash credential with storedsaltMatch in allow-list?granteddenied → log + lockout counterEnergise strike for dwell timePublish JSON audit event overMQTTRe-lock and return to scanning
Smart Door Lock — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

  1. Bench-test every module on its own

    Before anything is soldered, wire the RC522 to the ESP32 on a breadboard and run the library's DumpInfo example. You should see a UID printed when you tap a card. Do the same for the OLED with an I²C scanner, and the keypad with a sketch that prints the pressed key. Fixing one module at a time takes an hour; debugging five at once takes a weekend.

  2. Set the buck converter before it powers anything

    Connect the LM2596 input to the 12 V adapter with nothing on the output. Measure the output with a multimeter and turn the trimmer until it reads 5.00 V. Only then connect the ESP32.

  3. Build the low-voltage side on perfboard

    Solder female headers for the ESP32 rather than the board itself, so it can be swapped without desoldering. Run a solid ground pour or at least a heavy ground bus — the RFID reader is sensitive to a noisy ground.

    Keep the SPI runs to the RC522 under 10 cm. At 4 MHz on flying leads, longer than that starts producing intermittent read failures that look exactly like a faulty card.

  4. Wire the mains-free 12 V side

    The strike, its flyback diode, and the relay common/NO contacts form a simple series loop with the 12 V supply. Confirm the loop with a continuity test before connecting the adapter, then energise the relay manually by shorting the module IN pin to ground and listen for the strike to click.

  5. Mount the reader and keypad on the outside face

    The RC522 antenna must sit behind a non-metallic panel — ABS, acrylic and wood are all fine, aluminium is not. Keep at least 30 mm between the antenna and any steel in the door frame or the read range collapses.

  6. Install the egress button on the inside

    Wire it in parallel with the relay contacts, not into the ESP32 only. This is deliberate: even if the firmware crashes or the ESP32 loses power, pressing the button still releases the strike. A lock that can trap someone inside because of a software fault is not an acceptable design.

  7. Close it up with strain relief

    Use cable glands into the IP65 enclosure, leave a drip loop on any cable that runs downward, and secure the internal wiring so nothing rests against the buck converter's inductor, which gets warm.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Get the RFID reader talking

    Install the MFRC522 library, open the DumpInfo example, and set the SS and RST pins to 5 and 27. Upload and tap a card. A working reader prints a card UID and a sector dump; a reader that prints WARNING: Communication failure is almost always mis-wired SPI or being fed 5 V.

    cpp01-rfid-check.ino
    #include <SPI.h>
    #include <MFRC522.h>
    
    #define RC522_SS   5
    #define RC522_RST  27
    
    MFRC522 rfid(RC522_SS, RC522_RST);
    
    void setup() {
      Serial.begin(115200);
      SPI.begin();                 // SCK 18, MISO 19, MOSI 23 on ESP32
      rfid.PCD_Init();
      delay(50);
    
      // Version 0x91/0x92 = genuine MFRC522. 0x00 or 0xFF means the reader
      // is not responding at all — check wiring and that VCC is 3.3 V.
      byte v = rfid.PCD_ReadRegister(MFRC522::VersionReg);
      Serial.printf("MFRC522 version: 0x%02X\n", v);
      if (v == 0x00 || v == 0xFF) Serial.println("!! Reader not detected");
    }
    
    void loop() {
      if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) return;
    
      Serial.print("UID:");
      for (byte i = 0; i < rfid.uid.size; i++)
        Serial.printf(" %02X", rfid.uid.uidByte[i]);
      Serial.println();
    
      rfid.PICC_HaltA();           // stop talking to this card
      rfid.PCD_StopCrypto1();      // and clear the crypto state machine
    }
    PCD_ReadRegisterReading the version register is the fastest possible go/no-go test. A real MFRC522 answers 0x91 or 0x92; a dead SPI link reads back all-zeros or all-ones because nothing is driving MISO.
    PICC_IsNewCardPresentSends a REQA command and listens for an ATQA response. It returns false almost instantly when no card is in the field, so it is cheap to poll every loop.
    PICC_HaltA / PCD_StopCrypto1Omitting these two calls is the classic bug: the card stays selected, so the same tap is never seen again until you remove and re-present it.
  2. Add the keypad with proper debouncing

    Rather than pulling in a keypad library, the scan is written out explicitly here — it is fifteen lines and it makes the debouncing behaviour visible instead of hidden.

    cpp02-keypad-scan.ino
    const uint8_t ROWS[4] = {13, 32, 33, 25};
    const uint8_t COLS[4] = {26, 14, 12, 4};
    const char KEYMAP[4][4] = {
      {'1','2','3','A'}, {'4','5','6','B'},
      {'7','8','9','C'}, {'*','0','#','D'}
    };
    
    void keypadBegin() {
      for (uint8_t r = 0; r < 4; r++) { pinMode(ROWS[r], OUTPUT); digitalWrite(ROWS[r], HIGH); }
      for (uint8_t c = 0; c < 4; c++) pinMode(COLS[c], INPUT_PULLUP);
    }
    
    // Returns 0 when nothing new is pressed, otherwise the key character.
    char keypadRead() {
      static char lastKey = 0;
      static uint32_t lastChange = 0;
      char found = 0;
    
      for (uint8_t r = 0; r < 4 && !found; r++) {
        digitalWrite(ROWS[r], LOW);            // drive this row low
        delayMicroseconds(5);                  // let the line settle
        for (uint8_t c = 0; c < 4; c++)
          if (digitalRead(COLS[c]) == LOW) { found = KEYMAP[r][c]; break; }
        digitalWrite(ROWS[r], HIGH);           // release before the next row
      }
    
      uint32_t now = millis();
      if (found != lastKey) { lastKey = found; lastChange = now; return 0; }
      if (found && now - lastChange > 25 && now - lastChange < 40) return found;
      return 0;                                // held keys do not auto-repeat
    }
    INPUT_PULLUP on columnsColumns idle high. Driving one row low pulls only the column of a pressed key in that row down, so a low column read uniquely identifies the intersection.
    delayMicroseconds(5)The membrane and the wiring have real capacitance. Without this settle time the first column read after switching rows occasionally catches the old level.
    now - lastChange > 25 && < 40The key must have read the same for 25 ms (past the bounce window) but the report fires only once inside a 15 ms slot, which is what stops a held key from repeating.
    digitalWrite(ROWS[r], HIGH)Every row is released before the next is driven. Two rows low at once shorts them together through a pressed key and produces phantom key reports.
  3. Hash and store credentials in NVS

    Enrolment is a one-time operation per credential. The device generates its salt on first boot using the ESP32 hardware RNG, then stores only digests.

    cpp03-credential-store.ino
    #include <Preferences.h>
    #include <mbedtls/sha256.h>
    #include <esp_random.h>
    
    Preferences prefs;
    uint8_t deviceSalt[16];
    
    void saltBegin() {
      prefs.begin("lock", false);
      if (prefs.getBytesLength("salt") != sizeof(deviceSalt)) {
        esp_fill_random(deviceSalt, sizeof(deviceSalt));   // hardware RNG
        prefs.putBytes("salt", deviceSalt, sizeof(deviceSalt));
        Serial.println("Generated a new device salt");
      } else {
        prefs.getBytes("salt", deviceSalt, sizeof(deviceSalt));
      }
    }
    
    // digest = SHA-256(salt || credential)
    void hashCredential(const uint8_t *cred, size_t len, uint8_t out[32]) {
      mbedtls_sha256_context ctx;
      mbedtls_sha256_init(&ctx);
      mbedtls_sha256_starts(&ctx, 0);          // 0 = SHA-256, not SHA-224
      mbedtls_sha256_update(&ctx, deviceSalt, sizeof(deviceSalt));
      mbedtls_sha256_update(&ctx, cred, len);
      mbedtls_sha256_finish(&ctx, out);
      mbedtls_sha256_free(&ctx);
    }
    
    // Comparison that always touches all 32 bytes, whatever the input.
    bool constantTimeEqual(const uint8_t *a, const uint8_t *b, size_t n) {
      uint8_t diff = 0;
      for (size_t i = 0; i < n; i++) diff |= (uint8_t)(a[i] ^ b[i]);
      return diff == 0;
    }
    
    bool credentialAllowed(const uint8_t *cred, size_t len) {
      uint8_t digest[32];
      hashCredential(cred, len, digest);
    
      uint8_t count = prefs.getUChar("n", 0);
      uint8_t stored[32];
      bool match = false;
      for (uint8_t i = 0; i < count; i++) {                // no early break —
        char key[8]; snprintf(key, sizeof(key), "c%u", i); // scan every slot so
        if (prefs.getBytes(key, stored, 32) == 32)         // timing does not
          match |= constantTimeEqual(digest, stored, 32);  // reveal position
      }
      return match;
    }
    
    bool enrolCredential(const uint8_t *cred, size_t len) {
      uint8_t count = prefs.getUChar("n", 0);
      if (count >= 32) return false;                        // slot limit
      uint8_t digest[32];
      hashCredential(cred, len, digest);
      char key[8]; snprintf(key, sizeof(key), "c%u", count);
      prefs.putBytes(key, digest, 32);
      prefs.putUChar("n", count + 1);
      return true;
    }
    esp_fill_randomUses the ESP32 hardware entropy source. Never seed a salt from millis() or a fixed constant — a predictable salt makes the hash no better than storing the raw UID.
    salt || credentialThe salt means two devices holding the same card produce different digests, so a stolen digest from one lock cannot be replayed against another.
    match |= ... (no break)The loop deliberately continues after a match. Breaking early would make a valid card in slot 0 verify faster than one in slot 30, which leaks how many credentials are enrolled and where.
    Preferences / NVSNVS is wear-levelled flash. It survives reboots and reflashing the sketch, which is what you want — you do not lose the allow-list every time you tweak the code.
  4. Drive the strike safely

    A single function owns the relay so the dwell time and the fail-secure polarity live in exactly one place. Everything else calls unlock() and forgets about the hardware.

    cpp04-strike-control.ino
    #define PIN_RELAY      2
    #define RELAY_ACTIVE_LOW  true    // most opto modules are active-low
    #define UNLOCK_MS      4000       // how long the strike stays released
    
    static uint32_t unlockUntil = 0;
    
    void strikeBegin() {
      pinMode(PIN_RELAY, OUTPUT);
      digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? HIGH : LOW);  // locked
    }
    
    void unlock(uint32_t ms = UNLOCK_MS) {
      digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? LOW : HIGH);
      unlockUntil = millis() + ms;
    }
    
    // Called every loop; never blocks.
    void strikeService() {
      if (unlockUntil && (int32_t)(millis() - unlockUntil) >= 0) {
        digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? HIGH : LOW);
        unlockUntil = 0;
      }
    }
    digitalWrite before pinModeOrder matters here. The pin is configured to the locked level in strikeBegin() so the strike never twitches during boot — an ESP32 GPIO floats for a few milliseconds after reset.
    (int32_t)(millis() - unlockUntil) >= 0Signed subtraction is the rollover-safe way to compare millis() values. A plain `millis() > unlockUntil` breaks once every 49.7 days when the counter wraps.
    strikeService()Non-blocking by design. Using delay(4000) here would freeze the RFID reader, the keypad and the MQTT keep-alive for four seconds after every unlock.
  5. Publish the audit trail

    Every decision produces one JSON message. Keeping the schema flat and stable means a dashboard written today still parses events from firmware you write next year.

    cpp05-audit-event.ino
    #include <ArduinoJson.h>
    #include <PubSubClient.h>
    #include <time.h>
    
    extern PubSubClient mqtt;
    const char *TOPIC_EVENT = "home/door/front/event";
    
    void publishEvent(const char *method, const char *result, const char *who) {
      JsonDocument doc;                       // ArduinoJson 7: sizes itself
      doc["ts"]     = (uint32_t)time(nullptr);  // seconds since epoch, NTP-set
      doc["device"] = "front-door";
      doc["method"] = method;                   // "rfid" | "pin" | "remote" | "egress"
      doc["result"] = result;                   // "granted" | "denied" | "lockout"
      doc["who"]    = who;                      // short label, never the credential
      doc["rssi"]   = WiFi.RSSI();
    
      char buf[192];
      size_t n = serializeJson(doc, buf, sizeof(buf));
    
      // retain=false: an access event is a fact about a moment, not a state.
      if (!mqtt.publish(TOPIC_EVENT, (const uint8_t *)buf, n, false))
        bufferOffline(buf, n);                 // keep it for the next reconnect
    }
    doc["who"]Carries a human label such as "fob-3" or "pin-user-2", never the UID or the PIN. Audit logs get shipped to dashboards and screenshotted into group chats; keep credentials out of them.
    retain = falseA retained message is delivered to every future subscriber, so a retained unlock event would announce the last entry to anyone who connects later. Events are not state.
    bufferOffline()MQTT publish returns false when the socket is down. Rather than dropping the record, it goes into an RTC-memory ring buffer that survives deep sleep and is drained on reconnect.

Complete Source Code

The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.

cppsmart-door-lock.ino
/* ═══════════════════════════════════════════════════════════════
   Smart Door Lock — ESP32 + MFRC522 + 4x4 keypad + MQTT audit trail

   Credentials are stored as salted SHA-256 digests in NVS and compared
   in constant time. The strike is driven through an opto-isolated
   relay from a separate 12 V rail; the inside egress button is also
   wired in parallel with the relay contacts in hardware, so the door
   always opens from inside even if this firmware is dead.

   Board: ESP32 Dev Module        Monitor: 115200 baud
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <SPI.h>
#include <MFRC522.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <mbedtls/sha256.h>
#include <esp_random.h>
#include <time.h>

/* ── configuration ──────────────────────────────────────────── */
#define WIFI_SSID      "YOUR_WIFI"
#define WIFI_PASS      "YOUR_PASSWORD"
#define MQTT_HOST      "192.168.1.50"
#define MQTT_PORT      1883
#define MQTT_USER      "door"
#define MQTT_PASS      "change-me"
#define DEVICE_ID      "front-door"

#define PIN_RC522_SS   5
#define PIN_RC522_RST  27
#define PIN_RELAY      2
#define PIN_BUZZER     15
#define PIN_LED_OK     16
#define PIN_LED_NO     17
#define PIN_EGRESS     34            // input-only pin, external pull-up

#define RELAY_ACTIVE_LOW  true
#define UNLOCK_MS      4000
#define PIN_MIN_LEN    4
#define PIN_MAX_LEN    8
#define FAIL_THRESHOLD 5             // failures inside FAIL_WINDOW_MS
#define FAIL_WINDOW_MS 60000UL
#define LOCKOUT_BASE_S 30

const uint8_t ROWS[4] = {13, 32, 33, 25};
const uint8_t COLS[4] = {26, 14, 12, 4};
const char KEYMAP[4][4] = {
  {'1','2','3','A'}, {'4','5','6','B'},
  {'7','8','9','C'}, {'*','0','#','D'}
};

const char *T_EVENT  = "home/door/" DEVICE_ID "/event";
const char *T_CMD    = "home/door/" DEVICE_ID "/cmd";
const char *T_STATUS = "home/door/" DEVICE_ID "/status";

/* ── globals ────────────────────────────────────────────────── */
WiFiClient      net;
PubSubClient    mqtt(net);
MFRC522         rfid(PIN_RC522_SS, PIN_RC522_RST);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Preferences     prefs;

uint8_t  deviceSalt[16];
char     pinBuf[PIN_MAX_LEN + 1];
uint8_t  pinLen        = 0;
uint32_t unlockUntil   = 0;
uint32_t lockoutUntil  = 0;
uint8_t  failCount     = 0;
uint32_t failWindowEnd = 0;
uint32_t lastMqttTry   = 0;

/* offline event ring buffer survives brown-outs and deep sleep */
RTC_DATA_ATTR char     evtRing[8][192];
RTC_DATA_ATTR uint8_t  evtHead = 0, evtCount = 0;

/* ── credential storage ─────────────────────────────────────── */
void saltBegin() {
  prefs.begin("lock", false);
  if (prefs.getBytesLength("salt") != sizeof(deviceSalt)) {
    esp_fill_random(deviceSalt, sizeof(deviceSalt));
    prefs.putBytes("salt", deviceSalt, sizeof(deviceSalt));
  } else {
    prefs.getBytes("salt", deviceSalt, sizeof(deviceSalt));
  }
}

void hashCredential(const uint8_t *cred, size_t len, uint8_t out[32]) {
  mbedtls_sha256_context ctx;
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);
  mbedtls_sha256_update(&ctx, deviceSalt, sizeof(deviceSalt));
  mbedtls_sha256_update(&ctx, cred, len);
  mbedtls_sha256_finish(&ctx, out);
  mbedtls_sha256_free(&ctx);
}

bool constantTimeEqual(const uint8_t *a, const uint8_t *b, size_t n) {
  uint8_t diff = 0;
  for (size_t i = 0; i < n; i++) diff |= (uint8_t)(a[i] ^ b[i]);
  return diff == 0;
}

bool credentialAllowed(const uint8_t *cred, size_t len) {
  uint8_t digest[32], stored[32];
  hashCredential(cred, len, digest);
  uint8_t count = prefs.getUChar("n", 0);
  bool match = false;
  for (uint8_t i = 0; i < count; i++) {
    char key[8]; snprintf(key, sizeof(key), "c%u", i);
    if (prefs.getBytes(key, stored, 32) == 32)
      match |= constantTimeEqual(digest, stored, 32);
  }
  return match;
}

bool enrolCredential(const uint8_t *cred, size_t len) {
  uint8_t count = prefs.getUChar("n", 0);
  if (count >= 32) return false;
  uint8_t digest[32];
  hashCredential(cred, len, digest);
  char key[8]; snprintf(key, sizeof(key), "c%u", count);
  prefs.putBytes(key, digest, 32);
  prefs.putUChar("n", count + 1);
  return true;
}

/* ── feedback ───────────────────────────────────────────────── */
void beep(uint16_t freq, uint16_t ms) {
  tone(PIN_BUZZER, freq, ms);
}

void showLine(const char *a, const char *b) {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  oled.setCursor(0, 0);  oled.println(DEVICE_ID);
  oled.setTextSize(2);
  oled.setCursor(0, 20); oled.println(a);
  oled.setTextSize(1);
  oled.setCursor(0, 50); oled.println(b);
  oled.display();
}

/* ── strike ─────────────────────────────────────────────────── */
void strikeBegin() {
  pinMode(PIN_RELAY, OUTPUT);
  digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? HIGH : LOW);
}

void unlock(uint32_t ms = UNLOCK_MS) {
  digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? LOW : HIGH);
  unlockUntil = millis() + ms;
  digitalWrite(PIN_LED_OK, HIGH);
  beep(2200, 120);
  showLine("UNLOCKED", "welcome");
}

void strikeService() {
  if (unlockUntil && (int32_t)(millis() - unlockUntil) >= 0) {
    digitalWrite(PIN_RELAY, RELAY_ACTIVE_LOW ? HIGH : LOW);
    digitalWrite(PIN_LED_OK, LOW);
    unlockUntil = 0;
    showLine("LOCKED", "tap card or enter PIN");
  }
}

/* ── audit events ───────────────────────────────────────────── */
void bufferOffline(const char *json, size_t n) {
  if (n >= sizeof(evtRing[0])) return;
  memcpy(evtRing[evtHead], json, n);
  evtRing[evtHead][n] = 0;
  evtHead = (evtHead + 1) % 8;
  if (evtCount < 8) evtCount++;
}

void flushOffline() {
  while (evtCount && mqtt.connected()) {
    uint8_t idx = (evtHead + 8 - evtCount) % 8;
    if (!mqtt.publish(T_EVENT, evtRing[idx])) break;
    evtCount--;
  }
}

void publishEvent(const char *method, const char *result, const char *who) {
  JsonDocument doc;
  doc["ts"]     = (uint32_t)time(nullptr);
  doc["device"] = DEVICE_ID;
  doc["method"] = method;
  doc["result"] = result;
  doc["who"]    = who;
  doc["rssi"]   = WiFi.RSSI();

  char buf[192];
  size_t n = serializeJson(doc, buf, sizeof(buf));
  Serial.println(buf);
  if (!mqtt.connected() || !mqtt.publish(T_EVENT, (const uint8_t *)buf, n, false))
    bufferOffline(buf, n);
}

/* ── lockout ────────────────────────────────────────────────── */
bool lockedOut() {
  return lockoutUntil && (int32_t)(millis() - lockoutUntil) < 0;
}

void registerFailure() {
  uint32_t now = millis();
  if (!failWindowEnd || (int32_t)(now - failWindowEnd) >= 0) {
    failCount = 0;
    failWindowEnd = now + FAIL_WINDOW_MS;
  }
  failCount++;
  if (failCount >= FAIL_THRESHOLD) {
    uint32_t secs = LOCKOUT_BASE_S << (failCount - FAIL_THRESHOLD);
    if (secs > 900) secs = 900;
    lockoutUntil = now + secs * 1000UL;
    publishEvent("system", "lockout", "brute-force");
    char msg[24]; snprintf(msg, sizeof(msg), "wait %lus", (unsigned long)secs);
    showLine("LOCKED OUT", msg);
  }
}

/* ── keypad ─────────────────────────────────────────────────── */
void keypadBegin() {
  for (uint8_t r = 0; r < 4; r++) { pinMode(ROWS[r], OUTPUT); digitalWrite(ROWS[r], HIGH); }
  for (uint8_t c = 0; c < 4; c++) pinMode(COLS[c], INPUT_PULLUP);
}

char keypadRead() {
  static char lastKey = 0;
  static uint32_t lastChange = 0;
  char found = 0;
  for (uint8_t r = 0; r < 4 && !found; r++) {
    digitalWrite(ROWS[r], LOW);
    delayMicroseconds(5);
    for (uint8_t c = 0; c < 4; c++)
      if (digitalRead(COLS[c]) == LOW) { found = KEYMAP[r][c]; break; }
    digitalWrite(ROWS[r], HIGH);
  }
  uint32_t now = millis();
  if (found != lastKey) { lastKey = found; lastChange = now; return 0; }
  if (found && now - lastChange > 25 && now - lastChange < 40) return found;
  return 0;
}

void handleKey(char k) {
  if (lockedOut()) { beep(300, 200); return; }
  beep(1600, 30);

  if (k == '*') { pinLen = 0; showLine("PIN", "cleared"); return; }

  if (k == '#') {
    if (pinLen < PIN_MIN_LEN) { pinLen = 0; showLine("PIN", "too short"); return; }
    pinBuf[pinLen] = 0;
    if (credentialAllowed((const uint8_t *)pinBuf, pinLen)) {
      publishEvent("pin", "granted", "keypad");
      unlock();
      failCount = 0;
    } else {
      publishEvent("pin", "denied", "keypad");
      digitalWrite(PIN_LED_NO, HIGH); beep(400, 350);
      showLine("DENIED", "wrong PIN");
      delay(250); digitalWrite(PIN_LED_NO, LOW);
      registerFailure();
    }
    memset(pinBuf, 0, sizeof(pinBuf));   // do not leave the PIN in RAM
    pinLen = 0;
    return;
  }

  if (k >= '0' && k <= '9' && pinLen < PIN_MAX_LEN) {
    pinBuf[pinLen++] = k;
    char mask[PIN_MAX_LEN + 1];
    memset(mask, '*', pinLen); mask[pinLen] = 0;
    showLine(mask, "# to submit");
  }
}

/* ── RFID ───────────────────────────────────────────────────── */
void handleCard() {
  if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) return;

  if (lockedOut()) {
    publishEvent("rfid", "lockout", "card");
    beep(300, 200);
  } else if (credentialAllowed(rfid.uid.uidByte, rfid.uid.size)) {
    publishEvent("rfid", "granted", "card");
    unlock();
    failCount = 0;
  } else {
    publishEvent("rfid", "denied", "unknown-card");
    digitalWrite(PIN_LED_NO, HIGH); beep(400, 350);
    showLine("DENIED", "unknown card");
    delay(250); digitalWrite(PIN_LED_NO, LOW);
    registerFailure();
  }

  rfid.PICC_HaltA();
  rfid.PCD_StopCrypto1();
}

/* ── MQTT ───────────────────────────────────────────────────── */
void onMessage(char *topic, byte *payload, unsigned int len) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, len)) return;

  const char *action = doc["action"] | "";

  if (!strcmp(action, "unlock")) {
    publishEvent("remote", "granted", doc["by"] | "remote");
    unlock(doc["ms"] | UNLOCK_MS);
  } else if (!strcmp(action, "enrol_pin")) {
    const char *p = doc["pin"] | "";
    if (strlen(p) >= PIN_MIN_LEN && enrolCredential((const uint8_t *)p, strlen(p)))
      publishEvent("admin", "granted", "pin-enrolled");
  } else if (!strcmp(action, "wipe")) {
    prefs.clear(); saltBegin();
    publishEvent("admin", "granted", "credentials-wiped");
  }
}

void mqttConnect() {
  if (mqtt.connected() || millis() - lastMqttTry < 5000) return;
  lastMqttTry = millis();
  if (mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS, T_STATUS, 0, true, "offline")) {
    mqtt.publish(T_STATUS, "online", true);   // retained: this IS state
    mqtt.subscribe(T_CMD);
    flushOffline();
  }
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_LED_OK, OUTPUT);
  pinMode(PIN_LED_NO, OUTPUT);
  pinMode(PIN_EGRESS, INPUT);
  strikeBegin();
  keypadBegin();

  Wire.begin(21, 22);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  showLine("BOOT", "starting up");

  SPI.begin();
  rfid.PCD_Init();
  saltBegin();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);

  configTime(19800, 0, "pool.ntp.org", "time.google.com");   // IST = UTC+5:30
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setBufferSize(512);

  showLine("LOCKED", "tap card or enter PIN");
  Serial.println("Smart door lock ready");
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) WiFi.reconnect();
  mqttConnect();
  mqtt.loop();

  handleCard();
  char k = keypadRead();
  if (k) handleKey(k);

  if (digitalRead(PIN_EGRESS) == LOW) {     // inside button, active-low
    publishEvent("egress", "granted", "inside-button");
    unlock();
    while (digitalRead(PIN_EGRESS) == LOW) delay(10);
  }

  strikeService();
}
RTC_DATA_ATTR evtRingVariables marked RTC_DATA_ATTR live in the 8 KB RTC slow memory, which keeps its contents through deep sleep and a soft reset. That is what lets buffered audit events survive a brown-out.
mqtt.connect(..., T_STATUS, 0, true, "offline")This registers a Last Will and Testament. If the device dies without a clean disconnect, the broker publishes "offline" to the status topic on its behalf — so a dashboard can tell "no events" apart from "device is dead".
memset(pinBuf, 0, ...)The entered PIN is wiped from RAM immediately after comparison. It is a small thing, but it means a crash dump or a later heap read cannot recover it.
configTime(19800, 0, ...)19800 seconds is UTC+5:30 for Indian Standard Time. Timestamps must be absolute, not uptime-relative, or the audit log is worthless once the device reboots.
while (digitalRead(PIN_EGRESS) == LOW)Waits for the egress button to be released so one press produces one event. This is the only intentional blocking wait in the loop, and it is bounded by a human finger.
mqtt.setBufferSize(512)PubSubClient defaults to a 256-byte buffer, which silently drops larger publishes. Raising it is essential once JSON payloads carry more than a few fields.
pythonenrol-card.py
#!/usr/bin/env python3
"""Enrol or revoke a door credential over MQTT.

    python3 enrol-card.py --broker 192.168.1.50 --pin 481902
    python3 enrol-card.py --broker 192.168.1.50 --unlock
    python3 enrol-card.py --broker 192.168.1.50 --watch
"""
import argparse
import json
import time

import paho.mqtt.client as mqtt

DEVICE = "front-door"
T_CMD = f"home/door/{DEVICE}/cmd"
T_EVENT = f"home/door/{DEVICE}/event"


def on_event(_client, _userdata, msg):
    e = json.loads(msg.payload)
    when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(e["ts"]))
    icon = {"granted": "OK ", "denied": "NO ", "lockout": "!! "}.get(e["result"], "   ")
    print(f'{icon}{when}  {e["method"]:<7} {e["result"]:<8} {e.get("who","")}')


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--broker", required=True)
    ap.add_argument("--port", type=int, default=1883)
    ap.add_argument("--user", default="door")
    ap.add_argument("--password", default="change-me")
    ap.add_argument("--pin", help="enrol this PIN as a new credential")
    ap.add_argument("--unlock", action="store_true", help="release the strike now")
    ap.add_argument("--wipe", action="store_true", help="erase every credential")
    ap.add_argument("--watch", action="store_true", help="stream the audit log")
    args = ap.parse_args()

    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    client.username_pw_set(args.user, args.password)
    client.connect(args.broker, args.port, 60)

    if args.pin:
        client.publish(T_CMD, json.dumps({"action": "enrol_pin", "pin": args.pin}), qos=1)
        print(f"enrolled PIN of length {len(args.pin)}")
    if args.unlock:
        client.publish(T_CMD, json.dumps({"action": "unlock", "by": "cli"}), qos=1)
        print("unlock command sent")
    if args.wipe:
        if input("wipe ALL credentials? type YES: ") == "YES":
            client.publish(T_CMD, json.dumps({"action": "wipe"}), qos=1)

    if args.watch:
        client.on_message = on_event
        client.subscribe(T_EVENT, qos=1)
        print(f"watching {T_EVENT} — Ctrl-C to stop")
        client.loop_forever()
    else:
        client.loop(timeout=2.0)
        client.disconnect()


if __name__ == "__main__":
    main()
CallbackAPIVersion.VERSION2paho-mqtt 2.x requires this argument explicitly. Code written for paho 1.x raises a TypeError on 2.x, which is the most common breakage in older tutorials.
qos=1At-least-once delivery. A command that quietly vanishes because the broker was momentarily busy is much worse than one delivered twice — the firmware treats repeat unlocks as idempotent.
input("wipe ALL credentials?")A deliberate speed bump. The wipe command is unrecoverable and there is no undo, so it should never be a single keystroke away.

Configuration & Calibration

Configuration steps

  • Set WIFI_SSID, WIFI_PASS, MQTT_HOST, MQTT_USER and MQTT_PASS at the top of the sketch. For anything beyond a bench test, move these into WiFiManager's captive portal so credentials are not compiled into the binary.
  • Set RELAY_ACTIVE_LOW to match your relay module. Test it: with the pin configured but the sketch idle, the strike should be locked. If it sits released, flip the constant.
  • Set UNLOCK_MS. Four seconds suits a front door; a gate needs longer, a cabinet less.
  • Adjust the NTP offset in configTime() if you are not on IST — the first argument is the offset in seconds (UTC+5:30 = 19800).
  • Enrol the first credential before mounting anything. Publish {"action":"enrol_pin","pin":"481902"} to the command topic, or add a temporary enrolment branch that stores the next card tapped.
  • Once it works on plain MQTT, switch to WiFiClientSecure on port 8883 and load your broker's CA certificate. Access-control events are exactly the kind of traffic that should never cross a network in the clear.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Set the RFID read range

    The RC522 antenna gain is set in the RFCfgReg register. The library defaults to a middle setting; rfid.PCD_SetAntennaGain(MFRC522::RxGain_max) raises it to maximum. Test with the card at the exact distance your enclosure imposes — more gain is not always better, because an over-driven field makes reads unstable at very close range.

  2. Measure the actual strike current

    Put a multimeter in series with the strike and trigger an unlock. Note the holding current and confirm it is comfortably below the relay contact rating and the adapter capacity. A strike drawing more than about 700 mA needs a bigger supply than the 12 V 2 A specified here.

  3. Tune the debounce window

    If keys occasionally register twice, raise the 25 ms threshold in keypadRead() to 35 ms. If keys feel sluggish, lower it to 15 ms. Membrane keypads vary; there is no universally correct value.

Network Architecture & Connectivity

The lock is a leaf node: it holds one outbound TCP connection to a broker and never accepts inbound connections. That is deliberate. A device that listens on a port is a device with an attack surface; a device that only dials out can sit behind NAT with no port forwarding and no exposure.

Smart Door Lock — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsFront door lockESP32Back gate lockoptional 2nd nodeWi-Fi 2.4 GHzHome routerWPA2, IoT VLANMQTT/TLS 8883Mosquitto brokerACL per devicePhone apppush alertsGrafanaaccess historyHome Assistantautomation
Smart Door Lock — network topology

Communication protocol

MQTT is the right fit here for three reasons. First, the connection is persistent, so an unlock command reaches the device in tens of milliseconds rather than waiting for a polling interval. Second, the Last Will and Testament gives you free liveness detection — if the lock loses power, the broker announces it. Third, the protocol overhead is tiny: a publish is two bytes of fixed header plus the topic and payload, which matters when the device spends most of its life on battery-backed standby.

Quality of service is chosen per message. Audit events use QoS 1 (at least once) because losing an entry defeats the point of an audit trail; a duplicate is harmless since each carries a timestamp. The status topic uses QoS 0 with the retain flag, because only the latest value matters and a subscriber should learn it immediately on connect.

Topic / endpointDirectionPayload
home/door/front-door/eventdevice → brokerJSON: ts, device, method, result, who, rssi
home/door/front-door/statusdevice → broker (retained)"online" / "offline" (LWT)
home/door/front-door/cmdbroker → deviceJSON: action = unlock | enrol_pin | wipe

Message contract between the device and the broker.

Cloud platform configuration

Mosquitto on a Raspberry Pi is the recommended broker: it is a 3 MB install, it runs on the same LAN as the lock so an internet outage does not lock you out, and its ACL file gives per-user topic permissions in about six lines.

Create a dedicated user for the device that can publish only to its own event and status topics and subscribe only to its own command topic. If the device is ever compromised, that credential cannot be used to read anything else on the broker.

plainmosquitto.acl
# /etc/mosquitto/aclfile — per-device least privilege
#
# The lock may publish its own telemetry and read only its own commands.
user door
topic write home/door/front-door/event
topic write home/door/front-door/status
topic read  home/door/front-door/cmd

# The dashboard reads everything under home/ but may only issue commands.
user dashboard
topic read  home/#
topic write home/door/+/cmd

# Apply with:
#   sudo mosquitto_passwd -c /etc/mosquitto/passwd door
#   sudo systemctl restart mosquitto

Dashboard setup

Point Node-RED at the event topic and write each message into InfluxDB, then build two Grafana panels: a table of the last fifty events, and a bar chart of grants versus denials per day. The denial chart is the useful one — a sudden cluster of denials at 3 a.m. is exactly the signal you want an alert on.

Mobile app integration

The simplest reliable mobile path is a Node-RED flow subscribed to the event topic that calls the ntfy.sh or Pushover HTTP API on any denied or lockout result. That gives you push notifications on iOS and Android without writing an app. If you already run Home Assistant, add the lock as an MQTT device and you get the app, the history and the automations for free.

Security considerations

  • Use TLS (port 8883) with a broker certificate pinned in the firmware. Plain 1883 is for bench testing only.
  • Give every device its own broker credential with a topic ACL restricted to its own subtree.
  • Never publish the raw UID or the PIN in an event payload — publish a label instead.
  • Put IoT devices on a separate VLAN or guest network so a compromised device cannot reach your file server.
  • Enable ESP32 flash encryption and secure boot before deploying somewhere that matters; without them, anyone with physical access and a USB cable can read the firmware image out of the device.
  • Treat the RFID UID as an identifier and not a secret — MIFARE Classic UIDs are trivially cloneable. If the threat model includes a determined attacker, move to DESFire EV2 with mutual authentication.
  • Rate-limit remote unlock commands at the broker as well as on the device, so a compromised dashboard credential cannot hold the door open indefinitely.

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.

TestWhat you should see
Power-on with no credentials enrolledOLED shows LOCKED; the strike stays engaged; serial prints "Smart door lock ready" and the MQTT status topic reads online.
Tap an unknown cardRed LED, a low 400 Hz beep, OLED shows DENIED, and an event with "result":"denied" appears on the broker within about 200 ms.
Enrol a PIN and enter itGreen LED, a 2.2 kHz beep, the strike releases audibly, and an event with "method":"pin","result":"granted" is published.
Enter a wrong PIN five times inside a minuteOLED shows LOCKED OUT — wait 30s, further keys produce only the error beep, and a lockout event is published.
Publish a remote unlock commandThe strike releases within roughly 100 ms of the publish, with "method":"remote" in the event.
Press the inside egress buttonThe strike releases immediately. Repeat with the ESP32 powered down — it must still release, proving the parallel hardware path works.
Unplug the network, tap a valid card, then restore the networkThe door still unlocks (local credentials are authoritative) and the buffered event appears on the broker within a few seconds of reconnection.
Measure standby and unlock currentRoughly 90–140 mA idle on the 5 V rail; a brief rise to about 500 mA on the 12 V rail while the strike is energised.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

With a card tapped and the network up, the serial console and the broker both show the same JSON records:

plainserial-monitor.txt
Smart door lock ready
MFRC522 version: 0x92
WiFi connected, RSSI -54 dBm, IP 192.168.1.87
NTP synced: 2026-07-27 09:14:02 IST
MQTT connected to 192.168.1.50:1883

{"ts":1785488042,"device":"front-door","method":"rfid","result":"granted","who":"card","rssi":-54}
{"ts":1785488061,"device":"front-door","method":"pin","result":"denied","who":"keypad","rssi":-55}
{"ts":1785488068,"device":"front-door","method":"pin","result":"granted","who":"keypad","rssi":-55}
{"ts":1785488230,"device":"front-door","method":"remote","result":"granted","who":"cli","rssi":-56}
{"ts":1785488519,"device":"front-door","method":"egress","result":"granted","who":"inside-button","rssi":-53}

And the same stream through the Python watcher:

plainenrol-card.py --watch
watching home/door/front-door/event — Ctrl-C to stop
OK 2026-07-27 09:14:02  rfid    granted  card
NO 2026-07-27 09:14:21  pin     denied   keypad
OK 2026-07-27 09:14:28  pin     granted  keypad
OK 2026-07-27 09:17:10  remote  granted  cli
OK 2026-07-27 09:21:59  egress  granted  inside-button
A Grafana time-series dashboard
A time-series dashboard of the kind used to chart access grants and denials over time. Photograph sourced from Wikimedia Commons — Grafana dashboard.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

The RC522 prints version 0x00 or 0xFF and never reads a card

Likely cause. Almost always power or SPI wiring. The module is 3.3 V only, and its SPI lines must go to the ESP32 hardware SPI pins.

Fix. Confirm VCC reads 3.3 V, not 5 V, at the module pin. Check SCK→18, MISO→19, MOSI→23, SS→5, RST→27. Re-solder the module's header pins — these boards ship with poorly-tinned headers and a cold joint on MISO is extremely common. If it still fails, the reader is dead; feeding it 5 V even briefly destroys it.

Cards read on the bench but not once mounted in the door

Likely cause. Metal near the antenna. A 13.56 MHz field induces eddy currents in nearby steel, which detunes the antenna and collapses the range.

Fix. Move the reader at least 30 mm away from any steel in the frame, or place a ferrite sheet between the antenna and the metal. Mount behind ABS, acrylic or wood only. Also check the strike wiring is not running alongside the antenna.

Pressing one key registers two or three different keys

Likely cause. Two row lines driven low simultaneously, or missing pull-ups on the columns.

Fix. Confirm every row is set HIGH again before the next row is driven LOW — this is the digitalWrite(ROWS[r], HIGH) at the end of the inner loop. Confirm the columns are INPUT_PULLUP, not plain INPUT. If GPIO 12 is in use as a column, verify the board still boots; it is a strapping pin.

The ESP32 resets every time the strike fires

Likely cause. The inductive kick from the strike coil is coupling back into the 5 V rail, or both loads share one undersized supply.

Fix. Fit the 1N4007 flyback diode across the strike coil (stripe to +12 V). Feed the ESP32 from its own buck converter rather than the same rail as the strike, keep the grounds joined at exactly one point, and add a 470 µF capacitor across the ESP32 5 V input.

The strike releases briefly when the ESP32 boots or is reset

Likely cause. GPIO 2 floats during reset, and the opto-isolated relay reads a floating input as active.

Fix. Add a 10 kΩ pull-up from GPIO 2 to 3V3 for an active-low module (or pull-down for active-high) so the relay input is held in the locked state through the whole boot sequence. GPIO 2 is also a strapping pin — GPIO 33 is a cleaner choice if you can rewire.

Timestamps show 1970 or jump backwards

Likely cause. NTP has not synced — usually because the device has no internet route, or a firewall is blocking UDP 123.

Fix. Print time(nullptr) after configTime() and wait until it exceeds 1 700 000 000 before publishing. Fit the DS3231 so the device holds correct time through an outage, and read the RTC at boot as the initial clock value.

MQTT connects then drops every few minutes

Likely cause. Two devices connected with the same client ID, so the broker evicts one each time the other connects.

Fix. Give every device a unique client ID — append the MAC address suffix. Also make sure only one copy of the firmware is running; a spare ESP32 still plugged in on your desk with the same ID will fight the deployed one forever.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • Poll the RFID reader every loop but the keypad only every 5 ms — the reader's IsNewCardPresent() is cheap, the matrix scan is not free at 200 Hz.
  • Cache the credential count from NVS at boot instead of re-reading it on every verification; NVS reads are flash reads and cost tens of microseconds each.
  • Keep the OLED updates event-driven. Refreshing a 128×64 framebuffer over I²C at 100 kHz takes about 10 ms — doing that every loop halves your card-read responsiveness.
  • Replace every delay() with a millis() comparison — blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
  • Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
  • Profile before optimising — print micros() deltas around each stage and fix the slowest one first.

Safety Precautions

  • Egress must never depend on this firmware. Wire the inside release button in parallel with the relay contacts so it works with the electronics dead. In many jurisdictions this is a legal requirement for an occupied space.
  • Understand fail-secure versus fail-safe before you buy the strike. Fail-secure stays locked in a power cut (better for security); fail-safe releases (required on some fire escape routes). Choose deliberately, and check local fire regulations.
  • Never fit this on the only exit of a room that could be occupied without also fitting a mechanical override.
  • Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
  • Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
  • Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
  • Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Test the egress button monthly with the controller powered down. It is the one part of the system whose failure mode is someone trapped inside.
  • Review the access log monthly for denials you cannot account for.
  • Replace the DS3231 CR2032 backup cell every three to four years.
  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Move to MIFARE DESFire EV2 cards with AES mutual authentication. The RC522 supports it, and it upgrades the card from an identifier to a genuine cryptographic credential that cannot be cloned by reading it.
  • Add a time-window policy per credential — a cleaner's fob that works only on Tuesdays between 09:00 and 12:00 is a few extra bytes per NVS entry.
  • Fit an ESP32-CAM to capture a photo on every denied attempt and attach it to the notification.
  • Add a door position sensor (a simple reed switch) so you can distinguish "unlocked" from "actually opened" and alert on a door left ajar.
  • Implement OTA firmware updates over MQTT so a lock mounted in a door frame never needs a USB cable again.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add over-the-air firmware updates so you never have to physically reach a deployed node again.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Can I use an Arduino Uno instead of an ESP32?

For the RFID and keypad half, yes — the RC522 library runs happily on an Uno. But you lose everything networked: no Wi-Fi, no MQTT, no NTP timestamps, and 2 KB of SRAM is not enough for SHA-256 plus a JSON buffer. If you have no need for remote access or an audit trail, an Uno build is a legitimate simpler project. If you want the audit trail, the ESP32 is not a luxury here.

Is storing a hash actually worth it if MIFARE UIDs can be cloned anyway?

Yes, but for a different reason than people assume. Hashing does not stop card cloning — nothing at the UID layer can. What it stops is a flash dump becoming a credential dump. If someone gets five minutes alone with the device and a USB cable, plain-text UIDs in NVS hand them every authorised card in the building. Hashed and salted, they get nothing useful. Different attack, real mitigation.

What happens during a power cut?

With a fail-secure strike, the door stays locked and the inside egress button still works mechanically once power returns — so plan a mechanical key override or a UPS if that is unacceptable. With a fail-safe strike the door releases, which is the correct behaviour on a fire escape route and the wrong behaviour on a front door. A small 12 V UPS or a sealed lead-acid battery with a trickle charger removes the question entirely for about ₹1,500.

Do I need the OLED and the RTC?

Neither is strictly required. The OLED earns its place because a lock with no feedback is genuinely unpleasant to use — you cannot tell a slow read from a rejected card. The DS3231 matters only if you care about the audit log staying correctly ordered through a network outage; without it, events during an outage carry uptime-relative timestamps.

Can two people share one PIN?

Technically yes — but do not. The entire value of this system is knowing who came in. Enrol a separate PIN per person and label the events accordingly; the storage cost is 32 bytes each.

How do I stop someone just pulling the reader off the wall and shorting the wires?

You cannot, with this architecture, and it is important to be honest about that. The reader is outside and the wires behind it are the weak point. Commercial systems solve this by putting the controller and relay entirely on the secure side of the door, with only the reader outside communicating over an authenticated bus such as OSDP. If your threat model includes a determined attacker with a screwdriver, mount the ESP32, the relay and the strike wiring inside, and run only the RC522 SPI lines out — better still, use an OSDP reader.

Why MQTT rather than a simple HTTP call?

HTTP requires the device to either poll (adding latency and traffic) or listen on a port (adding attack surface and needing port forwarding). MQTT keeps one outbound connection open, so a remote unlock arrives in tens of milliseconds through NAT with no inbound firewall rule at all. The Last Will and Testament also gives you device-death detection for free, which HTTP simply cannot do.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. MFRC522 Standard Performance MIFARE and NTAG Frontend — datasheetNXP Semiconductors
  2. ESP32 Series Datasheet — GPIO, strapping pins and boot modesEspressif Systems
  3. ESP32 Non-Volatile Storage (NVS) library documentationEspressif ESP-IDF Programming Guide
  4. MQTT Version 3.1.1 — OASIS Standard (Last Will, QoS, retained messages)OASIS
  5. Mosquitto broker — ACL and authentication configurationEclipse Mosquitto
  6. ISO/IEC 14443 — Identification cards, proximity cards (overview)ISO
  7. mbedTLS SHA-256 API referenceMbed TLS
  8. Timing attacks and constant-time comparison — a practical explanationCoda Hale