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.
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
| Setting | How it is used |
|---|---|
| Shared housing | Give 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-working | Per-person access logs satisfy basic security-audit requirements at a fraction of the cost of a commercial access-control panel. |
| Laboratory and server rooms | Restrict entry to a named list and get an immediate alert on any out-of-hours attempt. |
| Rental property turnover | Remote enrolment means a cleaner or guest can be granted access for a window of time with no physical key exchange. |
| Equipment cabinets | The same electronics scaled down to a cabinet solenoid controls access to tools, medication or firearms storage. |
| School and hostel blocks | Curfew 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 10–14 hours |
| Indicative build cost | ₹4,200 – ₹5,400 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 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.
| Component | Key specification | Qty | Approx. 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 DAC | 1 | ₹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 SPI | 1 | ₹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-backed | 1 | ₹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 input | 1 | ₹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 % efficiency | 1 | ₹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 out | 1 | ₹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²C | 1 | ₹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 diameter | 1 | ₹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 barrel | 1 | ₹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 grid | 1 | ₹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 lugs | 1 | ₹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 faceplate | 1 | ₹1,400 |
| Momentary push button (egress) | NO contact, panel-mount 16 mm | 1 | ₹60 |
| 1N4007 flyback diode One across the strike coil, one across the relay coil if the module lacks it. | 1000 V, 1 A rectifier | 2 | ₹10 |
| Assorted 22 AWG hookup wire + ferrules | Stranded, 5 colours | 1 | ₹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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| 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 DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| MFRC522 13.56 MHz RFID reader + card + fob | ISO/IEC 14443A, MIFARE Classic 1K, 0–60 mm range, up to 10 Mbit/s SPI | 2.5–3.6 V | SPI (also I²C/UART) | Datasheet |
| 4×4 matrix membrane keypad | 16 keys, 8-wire matrix, 35 × 76 mm adhesive-backed | logic level | 8 GPIO matrix scan | Datasheet |
| 5 V single-channel opto-isolated relay module | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 5 V coil | Digital (active-low) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| DS3231 precision RTC + CR2032 backup | ±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out | 2.3–5.5 V | I²C (0x68) | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| Active piezo buzzer 5 V | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 3–5 V | Digital / PWM | Datasheet |
| 12 V 5 A SMPS adapter | 100–240 VAC in, 12 V ±5 %, 5 A, 60 W, DC 5.5 × 2.1 mm barrel | 12 V | DC barrel | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| MFRC522 13.56 MHz RFID reader + card + fob | 2.5–3.6 V | 26 | Strictly 3.3 V — 5 V on the SPI pins kills the chip. |
| 4×4 matrix membrane keypad | logic level | 1 | Debounce in software — 20 ms is enough for membrane keys. |
| 5 V single-channel opto-isolated relay module | 5 V coil | 70 | Cut the JD-VCC jumper and feed the coil separately for true isolation. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| DS3231 precision RTC + CR2032 backup | 2.3–5.5 V | 0.2 | Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| Active piezo buzzer 5 V | 3–5 V | 30 | Active buzzers make tone on DC; passive ones need a PWM carrier. |
| 12 V 5 A SMPS adapter | 12 V | 5000 | Fuse 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| MFRC522 1.4.x | SPI driver and MIFARE authentication for the RC522 reader. | Library Manager → "MFRC522" by GithubCommunity |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
| WiFiManager 2.0.x | Captive-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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| MFRC522 RFID reader | SDA / SS | GPIO 5 | SPI chip select |
| MFRC522 RFID reader | SCK / MOSI / MISO | GPIO 18 / 23 / 19 | SPI bus (3.3 V only) |
| MFRC522 RFID reader | RST | GPIO 27 | Reader reset |
| 4×4 keypad rows | R1–R4 | GPIO 13 32 33 25 | Driven low during scan |
| 4×4 keypad columns | C1–C4 | GPIO 26 14 12 4 | Read with internal pull-up |
| Egress button | NO contact | GPIO 34 | Input-only, external 10 kΩ pull-up |
| DS3231 RTC + SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Shared I²C bus |
| Relay module → electric strike | IN | GPIO 2 | Active-low, opto-isolated |
| Piezo buzzer | + | GPIO 15 | Feedback tones |
| Status LED (green/red) | Anode | GPIO 16 / 17 | Through 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.
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.
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
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
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.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
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
DumpInfoexample. 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.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.
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.
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.
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.
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.
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.
Get the RFID reader talking
Install the MFRC522 library, open the
DumpInfoexample, 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 printsWARNING: Communication failureis 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.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.inoconst 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.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.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.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.
/* ═══════════════════════════════════════════════════════════════
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();
}
#!/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()
Configuration & Calibration
Configuration steps
- Set
WIFI_SSID,WIFI_PASS,MQTT_HOST,MQTT_USERandMQTT_PASSat 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_LOWto 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
WiFiClientSecureon 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.
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.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.
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.
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 / endpoint | Direction | Payload |
|---|---|---|
home/door/front-door/event | device → broker | JSON: ts, device, method, result, who, rssi |
home/door/front-door/status | device → broker (retained) | "online" / "offline" (LWT) |
home/door/front-door/cmd | broker → device | JSON: 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.
# /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.
| Test | What you should see |
|---|---|
| Power-on with no credentials enrolled | OLED shows LOCKED; the strike stays engaged; serial prints "Smart door lock ready" and the MQTT status topic reads online. |
| Tap an unknown card | Red 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 it | Green 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 minute | OLED shows LOCKED OUT — wait 30s, further keys produce only the error beep, and a lockout event is published. |
| Publish a remote unlock command | The strike releases within roughly 100 ms of the publish, with "method":"remote" in the event. |
| Press the inside egress button | The 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 network | The 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 current | Roughly 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:
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:
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
Troubleshooting: Common Errors & Fixes
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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- MFRC522 Standard Performance MIFARE and NTAG Frontend — datasheetNXP Semiconductors
- ESP32 Series Datasheet — GPIO, strapping pins and boot modesEspressif Systems
- ESP32 Non-Volatile Storage (NVS) library documentationEspressif ESP-IDF Programming Guide
- MQTT Version 3.1.1 — OASIS Standard (Last Will, QoS, retained messages)OASIS
- Mosquitto broker — ACL and authentication configurationEclipse Mosquitto
- ISO/IEC 14443 — Identification cards, proximity cards (overview)ISO
- mbedTLS SHA-256 API referenceMbed TLS
- Timing attacks and constant-time comparison — a practical explanationCoda Hale