Objective and use case
What you’ll build: This project involves creating a standalone NFC access control terminal using an ESP32 NodeMCU-32S, PN532 NFC reader, and a Waveshare 2.9″ E-Paper display. The system will authenticate MIFARE/ISO14443A cards and provide visual feedback on access status.
Why it matters / Use cases
- Implementing secure access control in residential or commercial buildings using NFC technology.
- Creating a user-friendly interface for managing access rights with an E-Paper display, reducing the need for traditional LCDs.
- Utilizing offline capabilities in environments where internet connectivity is unreliable or unavailable.
- Enhancing skills in microcontroller projects, focusing on SPI bus management and JSON data structures.
Expected outcome
- Successful authentication of NFC cards with a response time of less than 500ms.
- Reliable display of access status (“GRANTED” or “DENIED”) on the E-Paper with minimal flicker.
- Ability to manage an access control list (ACL) with at least 50 entries stored in LittleFS.
- Demonstration of dual SPI bus functionality with stable communication between the ESP32 and PN532.
Audience: Advanced hobbyists and developers; Level: Advanced
Architecture/flow: ESP32 NodeMCU-32S communicates with PN532 via SPI, processes NFC card data, updates ACL stored in LittleFS, and displays results on Waveshare 2.9″ E-Paper.
ESP32 Advanced Practical: NFC E‑Paper Access Control (ESP32 NodeMCU‑32S + Waveshare 2.9″ E‑Paper SSD1680 + PN532)
This hands‑on case builds a standalone NFC access control terminal that authenticates MIFARE/ISO14443A cards via a PN532 reader and displays clear “GRANTED”/“DENIED” feedback on a Waveshare 2.9″ E‑Paper (SSD1680), driven by an ESP32 NodeMCU‑32S. We’ll implement a persistent access control list (ACL) using LittleFS (flash) with enrollment via a “master” card and optional serial commands. The solution is entirely offline and focuses on robust wiring, clean SPI bus sharing, and reliable rendering on E‑Paper.
The project is targeted at an advanced level: you’ll use PlatformIO, dual SPI buses on the ESP32, a JSON-based ACL, and e‑paper partial/full refresh considerations. All steps are deterministic with exact versions, paths, and commands.
Prerequisites
- Skills: Comfortable with PlatformIO, ESP32 Arduino framework, SPI bus, JSON handling, and basic C++ on microcontrollers.
- OS: Windows 10/11, macOS 12+, or Linux (Ubuntu 22.04+).
- Drivers:
- CP210x (Silicon Labs) or CH34x depending on your NodeMCU‑32S USB‑UART bridge.
- Windows: Install Silicon Labs CP210x driver from silabs.com, or WCH CH34x driver from wch.cn.
- macOS: CP210x typically works natively on recent macOS; CH34x may need a signed driver.
- Linux: Usually built‑in; verify with dmesg and check /dev/ttyUSB0 or /dev/tty.SLAB_USBtoUART.
- PlatformIO Core:
- Python 3.9+ installed and in PATH.
- PlatformIO CLI installed: pipx install platformio (or pip install platformio).
- Basic familiarity with PN532 cards (MIFARE Classic, NTAG213/215/216, etc.).
Materials
- Microcontroller:
- ESP32 NodeMCU‑32S (exact board: “NodeMCU‑32S”, PlatformIO board ID: nodemcu-32s)
- Display:
- Waveshare 2.9″ E‑Paper (SSD1680), 296×128, 3‑wire/4‑wire SPI, 3.3V logic
- NFC reader:
- PN532 breakout (supports SPI mode, 3.3V logic)
- Cables/power:
- Micro‑USB cable for ESP32
- Jumper wires (female‑to‑female recommended)
- Stable 5V USB power source (≥1A recommended)
- NFC tags/cards:
- At least two ISO14443A tags
- One tag designated as the “master card” for enrollment
- Optional: 3D‑printed case or mounting materials
Setup / Connection
We will use two SPI buses to keep things deterministic and avoid shared bus timing surprises:
- VSPI (default) for the E‑Paper
- HSPI for the PN532
This separation is robust and avoids chip select conflicts. All logic must be 3.3V.
ESP32 NodeMCU‑32S to E‑Paper (SSD1680) on VSPI
- Bus:
- SCLK: GPIO 18
- MOSI: GPIO 23
- MISO: GPIO 19 (unused by e‑paper; leave unconnected)
- Control lines:
- CS: GPIO 5
- DC: GPIO 17
- RST: GPIO 16
- BUSY: GPIO 4
- Power:
- VCC: 3.3V
- GND: GND
ESP32 NodeMCU‑32S to PN532 (SPI) on HSPI
- Bus:
- SCLK: GPIO 14
- MOSI: GPIO 13
- MISO: GPIO 12
- Control:
- SS (NSS): GPIO 15
- RST (if breakout exposes it): GPIO 27 (optional but recommended)
- Power:
- VCC: 3.3V (ensure your PN532 breakout is 3.3V logic; many accept 3.3–5V, but logic must still be 3.3V)
- GND: GND
Pin Mapping Summary
| Peripheral | Signal | ESP32 NodeMCU‑32S Pin | Notes |
|---|---|---|---|
| E‑Paper (SSD1680) | SCLK | GPIO 18 | VSPI SCK |
| E‑Paper (SSD1680) | MOSI | GPIO 23 | VSPI MOSI |
| E‑Paper (SSD1680) | MISO | GPIO 19 | Not used by display |
| E‑Paper (SSD1680) | CS | GPIO 5 | Chip Select |
| E‑Paper (SSD1680) | DC | GPIO 17 | Data/Command |
| E‑Paper (SSD1680) | RST | GPIO 16 | Reset line |
| E‑Paper (SSD1680) | BUSY | GPIO 4 | Busy input |
| PN532 | SCLK | GPIO 14 | HSPI SCK |
| PN532 | MOSI | GPIO 13 | HSPI MOSI |
| PN532 | MISO | GPIO 12 | HSPI MISO |
| PN532 | SS (NSS) | GPIO 15 | Chip Select |
| PN532 | RST | GPIO 27 | Optional reset (active low) |
| Power | 3.3V | 3V3 | Power both modules at 3.3V |
| Power | GND | GND | Common ground |
Notes:
– Never feed 5V logic into ESP32 pins. Use 3.3V logic only.
– Keep the PN532 antenna away from the e‑paper ribbon cable to minimize RF coupling glitches.
Full Code
Create a new PlatformIO project and paste the following files. The project provides a robust access control loop with persistent ACL in LittleFS, master card enrollment, and serial command management.
platformio.ini
; File: platformio.ini
[env:nodemcu-32s]
platform = espressif32@6.6.0
board = nodemcu-32s
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
board_build.partitions = partitions.csv
build_flags =
-DCORE_DEBUG_LEVEL=0
lib_deps =
zinggjm/GxEPD2 @ ^1.5.9
adafruit/Adafruit GFX Library @ ^1.11.9
adafruit/Adafruit BusIO @ ^1.16.1
adafruit/Adafruit PN532 @ ^1.3.0
bblanchon/ArduinoJson @ ^7.0.4
partitions.csv
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xE000, 0x2000,
app0, app, ota_0, 0x10000, 0x180000,
app1, app, ota_1, 0x190000, 0x180000,
spiffs, data, spiffs, 0x310000, 0x0F0000,
src/main.cpp
// File: src/main.cpp
#include <Arduino.h>
#include <SPI.h>
#include <FS.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
// PN532
#include <PN532_SPI.h>
#include "PN532.h"
// E-Paper
#include <GxEPD2_BW.h>
#include <GxEPD2_3C.h> // not used, but harmless
#include <Adafruit_GFX.h>
// ------------------ Pin Assignments ------------------
// E-Paper on VSPI
#define EPAPER_CS 5
#define EPAPER_DC 17
#define EPAPER_RST 16
#define EPAPER_BUSY 4
// VSPI pins: SCLK=18, MISO=19, MOSI=23 (hardware default)
// PN532 on HSPI
#define PN532_SS 15
#define HSPI_SCK 14
#define HSPI_MISO 12
#define HSPI_MOSI 13
#define PN532_RST 27 // optional; tie to ESP32 for reliable resets
// ------------------ E-Paper Display ------------------
#include <GxEPD2_BW.h>
GxEPD2_BW<GxEPD2_290, GxEPD2_290::HEIGHT> display(GxEPD2_290(EPAPER_CS, EPAPER_DC, EPAPER_RST, EPAPER_BUSY));
// ------------------ PN532 Setup ----------------------
SPIClass hspi(HSPI);
PN532_SPI pn532spi(hspi, PN532_SS);
PN532 nfc(pn532spi);
// ------------------ ACL/Config -----------------------
static const char* ACL_PATH = "/config/acl.json";
// Change this to your own master card UID (HEX, no spaces). Use Serial monitor to read your cards.
// Example: "04AABBCCDD"
static const char* MASTER_UID_HEX = "04AABBCCDD";
// We'll hold ACL in RAM
static const size_t MAX_UIDS = 64;
String aclList[MAX_UIDS];
size_t aclCount = 0;
bool enrollMode = false;
unsigned long enrollModeUntil = 0;
uint32_t eventCounter = 0;
// ------------------ Helpers --------------------------
String toHex(const uint8_t* data, size_t len, bool upper = true) {
const char* p = upper ? "0123456789ABCDEF" : "0123456789abcdef";
String out; out.reserve(len * 2);
for (size_t i = 0; i < len; ++i) {
out += p[(data[i] >> 4) & 0xF];
out += p[data[i] & 0xF];
}
return out;
}
bool aclContains(const String& uidHex) {
for (size_t i = 0; i < aclCount; ++i) {
if (aclList[i].equalsIgnoreCase(uidHex)) return true;
}
return false;
}
bool aclAdd(const String& uidHex) {
if (aclCount >= MAX_UIDS) return false;
if (aclContains(uidHex)) return true; // idempotent
aclList[aclCount++] = uidHex;
return true;
}
bool aclRemove(const String& uidHex) {
for (size_t i = 0; i < aclCount; ++i) {
if (aclList[i].equalsIgnoreCase(uidHex)) {
// compact
for (size_t j = i + 1; j < aclCount; ++j) {
aclList[j - 1] = aclList[j];
}
aclCount--;
return true;
}
}
return false;
}
bool loadACL() {
if (!LittleFS.exists(ACL_PATH)) {
Serial.println(F("[ACL] No file, creating default."));
LittleFS.mkdir("/config");
File f = LittleFS.open(ACL_PATH, FILE_WRITE);
if (!f) return false;
// seed with an empty list
f.print("{\"uids\":[]}");
f.close();
}
File f = LittleFS.open(ACL_PATH, FILE_READ);
if (!f) {
Serial.println(F("[ACL] Failed to open ACL file."));
return false;
}
DynamicJsonDocument doc(2048);
auto err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.print(F("[ACL] JSON error: "));
Serial.println(err.c_str());
return false;
}
aclCount = 0;
if (doc.containsKey("uids") && doc["uids"].is<JsonArray>()) {
for (JsonVariant v : doc["uids"].as<JsonArray>()) {
if (aclCount < MAX_UIDS) {
aclList[aclCount++] = String(v.as<const char*>());
}
}
}
Serial.printf("[ACL] Loaded %u UIDs\n", (unsigned)aclCount);
return true;
}
bool saveACL() {
DynamicJsonDocument doc(2048);
JsonArray arr = doc.createNestedArray("uids");
for (size_t i = 0; i < aclCount; ++i) {
arr.add(aclList[i]);
}
File f = LittleFS.open(ACL_PATH, FILE_WRITE);
if (!f) {
Serial.println(F("[ACL] Failed to write ACL file."));
return false;
}
if (serializeJsonPretty(doc, f) == 0) {
Serial.println(F("[ACL] Failed to serialize JSON."));
f.close();
return false;
}
f.close();
Serial.println(F("[ACL] Saved."));
return true;
}
// ------------------ Display --------------------------
void drawBoot() {
display.setRotation(1); // landscape
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(GxEPD_BLACK);
display.setTextSize(2);
display.setCursor(10, 30);
display.print("NFC Access Control");
display.setTextSize(1);
display.setCursor(10, 55);
display.print("ESP32 NodeMCU-32S + PN532 + 2.9\" E-Paper");
display.setCursor(10, 75);
display.print("ACL loaded: ");
display.print(aclCount);
display.setCursor(10, 95);
display.print("Master: ");
display.print(MASTER_UID_HEX);
display.setCursor(10, 120);
display.print("Present tag to test...");
} while (display.nextPage());
}
// Renders status for each read
void drawEvent(const String& uidHex, bool granted, bool isMaster, bool updatedACL) {
display.setRotation(1);
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(GxEPD_BLACK);
// Header
display.setTextSize(2);
display.setCursor(10, 28);
display.print(granted ? "ACCESS GRANTED" : "ACCESS DENIED");
// UID
display.setTextSize(1);
display.setCursor(10, 55);
display.print("UID: ");
display.print(uidHex);
// Flags
display.setCursor(10, 75);
display.print("Role: ");
display.print(isMaster ? "MASTER" : (granted ? "AUTHORIZED" : "UNKNOWN"));
// Event counter
display.setCursor(10, 95);
display.print("Event #");
display.print(eventCounter);
// Enroll status
display.setCursor(10, 115);
if (enrollMode) {
display.print("Enroll mode ON (");
unsigned long remain = (enrollModeUntil > millis()) ? ((enrollModeUntil - millis()) / 1000) : 0;
display.print(remain);
display.print("s)");
if (updatedACL) {
display.print(" [ACL UPDATED]");
}
} else {
display.print("Enroll mode OFF");
}
} while (display.nextPage());
}
// ------------------ Serial Command Interface ---------
String lineBuf;
void handleSerial() {
while (Serial.available() > 0) {
char c = (char)Serial.read();
if (c == '\r') continue;
if (c == '\n') {
lineBuf.trim();
if (lineBuf.length() > 0) {
Serial.print(F("[CMD] "));
Serial.println(lineBuf);
// Commands: list, add <UIDHEX>, del <UIDHEX>, wipe, help
if (lineBuf.equalsIgnoreCase("list")) {
Serial.printf("ACL (%u):\n", (unsigned)aclCount);
for (size_t i = 0; i < aclCount; ++i) {
Serial.printf(" - %s\n", aclList[i].c_str());
}
} else if (lineBuf.startsWith("add ")) {
String u = lineBuf.substring(4);
u.trim();
u.toUpperCase();
if (u.length() >= 8 && u.length() <= 14) {
bool ok = aclAdd(u);
Serial.println(ok ? F("Added.") : F("Failed (full?) or already exists."));
saveACL();
} else {
Serial.println(F("Bad UID length."));
}
} else if (lineBuf.startsWith("del ")) {
String u = lineBuf.substring(4);
u.trim();
u.toUpperCase();
bool ok = aclRemove(u);
Serial.println(ok ? F("Removed.") : F("Not found."));
saveACL();
} else if (lineBuf.equalsIgnoreCase("wipe")) {
aclCount = 0;
saveACL();
Serial.println(F("ACL cleared."));
} else if (lineBuf.equalsIgnoreCase("help")) {
Serial.println(F("Commands: list | add <UIDHEX> | del <UIDHEX> | wipe | help"));
} else {
Serial.println(F("Unknown command. Type 'help'."));
}
}
lineBuf = "";
} else {
if (lineBuf.length() < 80) lineBuf += c;
}
}
}
// ------------------ Setup & Loop ---------------------
void setup() {
Serial.begin(115200);
delay(50);
Serial.println("\n[BOOT] NFC E-Paper Access Control");
if (!LittleFS.begin(true)) {
Serial.println("[FS] LittleFS mount failed.");
while (true) delay(1000);
}
loadACL();
// E-Paper
display.init(115200); // SPI speed for init debug
display.setRotation(1);
drawBoot();
// PN532 SPI begin on HSPI with explicit pins
hspi.begin(HSPI_SCK, HSPI_MISO, HSPI_MOSI, PN532_SS);
pinMode(PN532_RST, OUTPUT);
digitalWrite(PN532_RST, HIGH); // keep high
nfc.begin();
uint32_t ver = nfc.getFirmwareVersion();
if (!ver) {
Serial.println("[PN532] No reader found. Check wiring.");
} else {
Serial.printf("[PN532] Found. IC=0x%02X, Ver=%u.%u\n",
(unsigned)((ver >> 24) & 0xFF),
(unsigned)((ver >> 16) & 0xFF),
(unsigned)((ver >> 8) & 0xFF));
}
nfc.SAMConfig(); // normal mode
nfc.setPassiveActivationRetries(0xFF);
Serial.println("[READY] Present a card. 'help' for serial commands.");
}
void loop() {
handleSerial();
uint8_t uid[7]; // common max
uint8_t uidLen = 0;
// 1000ms timeout
bool ok = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, 1000);
if (ok && uidLen >= 4) {
eventCounter++;
String uidHex = toHex(uid, uidLen, true);
bool isMaster = uidHex.equalsIgnoreCase(MASTER_UID_HEX);
Serial.printf("[TAG] UID=%s len=%u\n", uidHex.c_str(), uidLen);
bool updatedACL = false;
// Master toggles enroll mode for 30 seconds
if (isMaster) {
enrollMode = !enrollMode;
enrollModeUntil = millis() + 30000;
Serial.printf("[MODE] Enroll mode %s for 30s\n", enrollMode ? "ENABLED" : "DISABLED");
drawEvent(uidHex, true, true, false);
delay(500);
return;
}
// If time elapsed, turn off enroll
if (enrollMode && millis() > enrollModeUntil) {
enrollMode = false;
}
// Enrollment logic
if (enrollMode) {
if (aclContains(uidHex)) {
// Remove existing
updatedACL = aclRemove(uidHex);
if (updatedACL) saveACL();
drawEvent(uidHex, false, false, updatedACL);
Serial.println("[ENROLL] Removed from ACL (toggle).");
} else {
updatedACL = aclAdd(uidHex);
if (updatedACL) saveACL();
drawEvent(uidHex, true, false, updatedACL);
Serial.println("[ENROLL] Added to ACL (toggle).");
}
} else {
bool granted = aclContains(uidHex);
drawEvent(uidHex, granted, false, false);
Serial.printf("[AUTH] %s\n", granted ? "GRANTED" : "DENIED");
}
delay(500); // small debounce between reads
}
}
data/config/acl.json (optional seed)
Create the following file so the firmware has a deterministic starting point (empty ACL). PlatformIO will upload it to LittleFS.
{
"uids": [
]
}
Directory structure to maintain:
- YourProject/
- platformio.ini
- partitions.csv
- src/
- main.cpp
- data/
- config/
- acl.json
Build, Flash, and Run Commands
Create and initialize the project (replace the path as needed):
# 1) Create project directory
mkdir -p ~/esp/nfc-epaper-access-control
cd ~/esp/nfc-epaper-access-control
# 2) Initialize PlatformIO project for NodeMCU-32S
pio project init --board nodemcu-32s
# 3) Place files
# - Save platformio.ini and partitions.csv into this directory
# - Create src/main.cpp with the code above
# - Create data/config/acl.json (optional seed)
# 4) Install libraries (optional; lib_deps in platformio.ini will auto-resolve)
pio pkg install
# 5) Build
pio run
# 6) Upload firmware
pio run -t upload
# 7) Upload LittleFS data (ACL file)
pio run -t uploadfs
# 8) Open serial monitor
pio device monitor -b 115200 --eol LF --filter time
If Windows assigns a different COM port, specify it:
pio device monitor -p COM7 -b 115200 --eol LF --filter time
On macOS:
pio device monitor -p /dev/tty.SLAB_USBtoUART -b 115200 --eol LF --filter time
Step‑by‑Step Validation
- Power and Cable Checks
- Confirm the ESP32 enumerates as a serial device.
-
Ensure PN532 VCC to 3.3V, not 5V. Common ground among ESP32, E‑Paper, and PN532.
-
PlatformIO Monitor
- Open the serial monitor at 115200 baud.
-
On boot, expect:
- [FS] mount success
- [ACL] Loaded 0 UIDs (unless you added some)
- PN532 firmware version print
- [READY] prompt
-
E‑Paper Boot Screen
- The display shows “NFC Access Control,” the number of loaded UIDs, and your master UID value.
-
If the screen is blank, check BUSY/DC/CS pin mapping and VCC.
-
Acquire a Card UID
- Present any ISO14443A card to the PN532 antenna.
- In the serial monitor, you should see the UID hex string.
-
The e‑paper refreshes with GRANTED or DENIED.
-
Set a Master UID
- For production, set MASTER_UID_HEX in src/main.cpp to your actual “master” card’s UID (read it first using the monitor).
-
Rebuild and upload. The boot screen will show the master UID.
-
Enrollment Mode
- Tap the master card once; you’ll see “Enroll mode ON (30s)” on the e‑paper and a console message.
- Present a standard card/tag:
- If it was not in the ACL, it will be ADDED (GRANTED displayed).
- If it was already in the ACL, it will be REMOVED (DENIED displayed).
-
Each toggle persists immediately to LittleFS.
-
Exit Enrollment
- Wait 30 seconds or tap the master card again to disable enrollment.
-
Present the newly authorized card: it should show GRANTED with UID and event counter incrementing.
-
Serial Commands (optional)
- In the monitor, type:
- help
- list
- add 04A1B2C3D4
- del 04A1B2C3D4
- wipe
-
Confirm ACL changes persist and reflect on the e‑paper after next scans.
-
Persistence Check
- Power‑cycle the ESP32.
- The boot screen should show the correct ACL count.
-
Authorized cards remain authorized.
-
Negative Test
- Present a random card not in the ACL (and not the master).
- Expect “ACCESS DENIED” on e‑paper.
-
Stress Test
- Perform repeated scans; the event counter increases.
- Confirm the e‑paper updates cleanly without ghosting. Occasional full updates via complete redraw are already used here; GxEPD2 handles waveforms.
Troubleshooting
- PN532 “No reader found. Check wiring.”
- Confirm HSPI wiring: SCK=14, MISO=12, MOSI=13, SS=15 to PN532.
- Ensure the PN532 is in SPI mode (some boards use jumpers to select SPI vs I2C/UART).
- Check VCC is 3.3V, solid ground, and that SS is not floating.
-
Try resetting PN532 by toggling PN532_RST low for >10ms at boot.
-
E‑Paper blank/stuck on BUSY
- Check BUSY is GPIO 4 and wired to the module’s BUSY pin (not miswired to RST).
- Confirm EPAPER_CS=5, DC=17, RST=16, BUSY=4.
- Ensure 3.3V rail can supply peak current during refresh (~30–60mA transient).
-
If ghosting is heavy, add a full refresh every N events (GxEPD2 supports full/partial; current code uses full refresh per event via firstPage/nextPage).
-
Serial Monitor shows gibberish
- Baud mismatch; use 115200.
-
Wrong port or noisy USB cable.
-
Filesystem write fails
- Confirm partitions.csv was applied (board_build.partitions) and that uploadfs was run.
-
Running out of space: the provided partition allocates ~960KB for LittleFS. Check size.
-
Master card not recognized
- UIDs are case‑insensitive but must be exact. Use the serial log to capture the correct UID and update MASTER_UID_HEX.
-
Some tags rotate random IDs (privacy mode). Use tags with stable UIDs (e.g., most MIFARE Classic/Ultralight/NTAG).
-
PN532 interference with E‑Paper
- Keep PN532 antenna and E‑Paper flex cable physically separated.
-
Use short, twisted‑pair jumpers for SPI where possible to reduce ringing.
-
NodeMCU‑32S not recognized by PC
- Install CP210x or CH34x driver depending on your board.
- Try a different USB cable/port. Some power‑only cables won’t carry data.
Improvements
- Secure ACL
- Store HMAC‑SHA256 hashes of UIDs rather than plain UIDs. Use a device‑unique key stored in NVS (Preferences). Compare HMAC(UID) on read.
-
Encrypt the ACL file using a symmetric key to prevent easy tampering if flash is read.
-
RTC/NTP Timekeeping
- Connect to Wi‑Fi and synchronize with NTP to timestamp events.
-
Render human‑readable timestamps on the e‑paper and log to LittleFS.
-
Event Logging
- Append structured logs (JSON lines) to /log/events.jsonl.
-
Provide a serial or Wi‑Fi endpoint to retrieve logs.
-
Visual UX
- Use larger fonts from Adafruit_GFX FreeFonts and custom icons for GRANTED/DENIED.
-
Implement partial updates for small regions to reduce flicker and power.
-
Hardware Robustness
- Add a MOSFET‑controlled power switch for PN532 to hard‑reset the reader if it becomes unresponsive.
-
Level‑shift only if required; keep all signals at 3.3V to the ESP32.
-
Integration
- Add MQTT or HTTPS to push events to a central server with TLS.
-
Implement OTA updates with signed binaries harnessing the dual OTA partitions defined.
-
Power Optimization
- Use light sleep between scans, wake on a periodic timer to poll PN532.
- Batch e‑paper updates or use partial refresh for minimal power draw.
Final Checklist
- Materials
- ESP32 NodeMCU‑32S, Waveshare 2.9″ E‑Paper (SSD1680), PN532 (SPI), NFC tags/cards
-
Verified 3.3V logic, common ground
-
Wiring
- E‑Paper on VSPI: 18/23/19 + CS=5, DC=17, RST=16, BUSY=4
- PN532 on HSPI: 14/13/12 + SS=15, RST=27
-
All modules powered from 3.3V, common ground
-
Software
- PlatformIO Core installed
- platformio.ini references:
- espressif32@6.6.0
- board nodemcu-32s
- LittleFS enabled with partitions.csv
- Libraries: GxEPD2, Adafruit GFX, Adafruit BusIO, Adafruit PN532, ArduinoJson
-
Build/upload success, serial monitor at 115200
-
Filesystem
- data/config/acl.json present (optional seed)
-
Upload LittleFS via: pio run -t uploadfs
-
Functionality
- PN532 firmware detected in logs
- E‑paper boot screen correct
- Master card toggles enroll mode (30s)
- Enrollment add/remove persists across reboots
-
GRANTED/DENIED displayed correctly with event counter
-
Validation
- Negative test with unauthorized card shows DENIED
- Power cycle retains ACL
- No bus contention; stable readings and display updates
You now have a robust, standalone NFC‑E‑Paper access control system on ESP32 NodeMCU‑32S with clean SPI separation, persistent ACL, and clear visual feedback suitable for prototyping access points, cabinets, or lab equipment.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



