Overview
In this beginner project you will connect an RC522 RFID module to the ESP32 via SPI and read MIFARE 13.56 MHz tag UIDs. When a tag is scanned, the UID and a timestamp are printed to Serial and stored as a line in a CSV file on an SD card. An LED flashes green for a known tag and red for an unknown tag. This demonstrates SPI RFID reading and SD card logging fundamentals.
Components
- 1× ESP32 DevKit V1
- 1× RC522 RFID reader module — 13.56 MHz MIFARE; SPI; 3.3 V
- 3× MIFARE 1K RFID tag or card — Classic key fobs or credit-card size tags
- 1× MicroSD card module (SPI)
- 1× Green and red LED + 220 ohm resistors
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| RC522 SDA/SCK/MOSI/MISO | GPIO 5/18/23/19 | SPI bus shared with SD |
| RC522 RST | GPIO 22 | |
| RC522 3.3 V / GND | 3.3 V / GND | Do NOT connect to 5 V |
| SD CS | GPIO 4 | Separate CS from RC522 |
| Green LED | GPIO 26 | 220 ohm series |
| Red LED | GPIO 27 | 220 ohm series |
Arduino Code
// ESP32 RFID Inventory Tracker - Beginner
// RC522 tag scan -> SD card CSV log with known/unknown LED feedback
// Libraries: MFRC522 by GithubCommunity, SD by Arduino
#include <SPI.h>
#include <MFRC522.h>
#include <SD.h>
#define RC522_SS 5
#define RC522_RST 22
#define SD_CS 4
#define LED_GREEN 26
#define LED_RED 27
MFRC522 rfid(RC522_SS, RC522_RST);
// Known tag UIDs (hex strings) mapped to asset names
const char* KNOWN_UIDS[] = {"A1B2C3D4", "11223344", "DEADBEEF"};
const char* ASSET_NAMES[] = {"Laptop-01", "Camera-01", "Toolbox-01"};
const int NUM_ASSETS = 3;
String uidToHex(MFRC522::Uid *uid){
String s="";
for(byte i=0;i<uid->size;i++){
if(uid->uidByte[i]<0x10) s+="0";
s+=String(uid->uidByte[i],HEX);
}
s.toUpperCase();
return s;
}
void flashLED(int pin){ digitalWrite(pin,HIGH); delay(300); digitalWrite(pin,LOW); }
void logScan(const String &uid, const char* name){
File f=SD.open("/inventory.csv",FILE_APPEND);
if(!f) return;
f.printf("%lu,%s,%sn",millis(),uid.c_str(),name);
f.close();
}
void setup(){
Serial.begin(115200);
SPI.begin();
rfid.PCD_Init();
SD.begin(SD_CS);
pinMode(LED_GREEN,OUTPUT); pinMode(LED_RED,OUTPUT);
Serial.println("RFID Inventory Tracker ready. Scan a tag...");
}
void loop(){
if(!rfid.PICC_IsNewCardPresent()||!rfid.PICC_ReadCardSerial()) return;
String uid=uidToHex(&rfid.uid);
const char* name="UNKNOWN";
bool known=false;
for(int i=0;i<NUM_ASSETS;i++){
if(uid==String(KNOWN_UIDS[i])){ name=ASSET_NAMES[i]; known=true; break; }
}
Serial.printf("UID: %s Asset: %sn",uid.c_str(),name);
logScan(uid,name);
flashLED(known?LED_GREEN:LED_RED);
rfid.PICC_HaltA();
rfid.PCD_StopCrypto1();
delay(1000);
}How It Works
RC522 SPI Communication: The RC522 uses SPI with a dedicated chip-select (SS) pin. The MFRC522 library manages the ISO 14443A protocol: it sends RF commands to detect tags (PICC_IsNewCardPresent), authenticate (for MIFARE Classic encrypted blocks), and read/write data. The 13.56 MHz field powers the passive tag and enables two-way communication.
UID as Asset Identifier: Each MIFARE tag has a factory-programmed UID (4-7 bytes) that is globally unique. The beginner project uses these UIDs directly as asset identifiers by comparing scanned UIDs against a hardcoded array. The uidToHex() function converts the raw byte array to an uppercase hex string for easy comparison.
Shared SPI Bus: The RC522 and SD card module share the SPI bus (SCK, MOSI, MISO) but each has its own chip-select pin (GPIO 5 for RC522, GPIO 4 for SD). Only one device is active at a time: when RC522_SS is LOW the SD_CS is HIGH and vice versa. The SPI library manages this automatically when different CS pins are used.
CSV Audit Log: Each scan appends a line to /inventory.csv: millis timestamp, UID, and asset name. The millis() timestamp can be replaced with an NTP timestamp in Wi-Fi enabled builds. The CSV format is directly importable into Excel or Google Sheets for reporting.
Applications
- Tool crib check-out logging in a workshop or factory
- Library book scan-in/scan-out system
- Equipment cage access and removal tracking
- School lab equipment loan tracking
Troubleshooting
PICC_IsNewCardPresent always returns false
Verify the RC522 is powered at exactly 3.3 V — it is not 5 V tolerant. Check all four SPI pins. Confirm RC522_SS and RC522_RST are correctly defined. Call rfid.PCD_DumpVersionToSerial() in setup() to verify the library can communicate with the RC522 chip.
SD card fails to initialise on the same SPI bus
The RC522 must be initialised first with SPI.begin() already called. Ensure SD.begin(SD_CS) uses a different CS pin from RC522_SS. Some SD modules require the CS pin to be explicitly pulled HIGH before SPI.begin() to prevent bus contention during initialisation.
Same tag triggers multiple scans in rapid succession
The 1000 ms delay at the end of loop() prevents rapid rescanning. Increase to 2000 ms if double-scanning still occurs. rfid.PICC_HaltA() puts the tag in HALT state; rfid.PCD_StopCrypto1() resets the crypto layer. Both must be called to properly terminate each card transaction.
Upgrades
- Add Wi-Fi and NTP timestamps to replace millis() in the CSV log
- Add a buzzer that plays different tones for known versus unknown tags
- Add a 16x2 LCD to display the scanned asset name without a computer
- Add MIFARE data block writing to store asset name directly on the tag
FAQ
You need an ESP32 DevKit, TODO: sensor, RC522 RST, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Industrial Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The intermediate build maintains a live inventory database in memory and on SD card. Each tag scan toggles the asset between checked-in and checked-out state. A web interface lists all assets with their current state, last-seen timestamp, and check-out count. Stock levels below a configurable minimum trigger a Telegram low-stock alert.
Components
- 1× ESP32 DevKit V1
- 1× RC522 RFID reader
- 1× MicroSD card module
- 10× MIFARE tags — One per tracked asset
- 1× Wi-Fi router — Web dashboard + Telegram
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| RC522 and SD | Same as beginner |
Arduino Code
// ESP32 RFID Inventory Tracker - Intermediate (check-in/out + web dashboard + alerts)
#include <SPI.h>
#include <MFRC522.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <time.h>
#define RC522_SS 5
#define RC522_RST 22
#define SD_CS 4
MFRC522 rfid(RC522_SS,RC522_RST);
WebServer server(80);
const char* SSID="YourSSID", *PASS="YourPass";
const char* BOT_TOKEN="YOUR_BOT_TOKEN";
const char* CHAT_ID="YOUR_CHAT_ID";
const int MIN_STOCK=2; // alert when fewer than 2 assets checked in
struct Asset { String uid,name; bool checkedIn; time_t lastSeen; int outCount; };
const int MAX_ASSETS=20;
Asset assets[MAX_ASSETS]; int assetCount=0;
void loadAssets(){
File f=SD.open("/assets.csv");
if(!f) return;
while(f.available()){
String line=f.readStringUntil((char)10); line.trim();
if(!line.length()) continue;
int c=line.indexOf(How It Works
Toggle Check-In/Check-Out State: Each tag scan flips the checkedIn boolean for that asset. The first scan of a previously checked-in asset marks it as checked out; scanning again marks it as checked in. This toggle model works well for simple tool crib scenarios where assets leave and return through one checkpoint.
Asset Database from SD Card: The /assets.csv file contains UID,AssetName pairs, one per line. loadAssets() reads this file at startup to populate the in-memory assets[] array. Adding a new asset requires editing the CSV on a computer and restarting the ESP32. The in-memory database is fast; the SD card provides persistence.
Real-Time Stock Count Alert: After each scan, the code counts how many assets are currently checked in. If this count falls below MIN_STOCK, a Telegram alert is sent. The alert fires on every scan that keeps stock below the threshold, so consider adding an alerted flag to prevent repeat messages until stock recovers.
Audit Log with Timestamps: Each check-in/out event appends a timestamped line to /log.csv with Unix timestamp, UID, asset name, and direction (IN/OUT). This log is the audit trail for compliance reporting. The NTP timestamp ensures log entries are correlated with real-world events even after ESP32 restarts.
Applications
- Tool crib management with check-out accountability
- Medical equipment tracking in a hospital ward
- Library equipment and AV loan system
- Warehouse sample and prototype tracking system
Troubleshooting
Asset state is lost after power cycle
The current implementation holds state in RAM. On restart loadAssets() reloads names and UIDs but not current check-in/out state. Add a /state.csv file that saves the checkedIn boolean and outCount for each asset and reads it on startup alongside assets.csv.
Unknown tags trigger no response
The current code silently ignores unregistered tags. Add an else branch after the for-loop to log unknown UIDs to a separate /unknown.csv file and flash a red LED. This helps identify unregistered tags that need to be added to assets.csv.
Web dashboard shows outdated data after multiple scans
The web server is synchronous; it serves the current in-memory state on each request. If the browser caches the response, add a Cache-Control: no-cache header: server.sendHeader("Cache-Control","no-cache") before server.send().
Upgrades
- Add a barcode scanner as an alternative to RFID for items without tags
- Add user authentication: scan a personal ID card first, then the asset, to track who took what
- Add SD card export endpoint to download the audit log as a CSV file from the browser
- Add MQTT publishing so Home Assistant tracks stock levels as sensors
FAQ
You need an ESP32 DevKit, TODO: sensor, RC522 RST, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Industrial Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced build stores the inventory database in an SQLite-equivalent structure using LittleFS and exposes a full REST API: GET /api/assets returns JSON, POST /api/scan processes a tag event, PUT /api/asset/:id updates an asset record. A React single-page dashboard served from LittleFS shows real-time stock, scan history charts, and an asset search field. MQTT events broadcast each scan to Home Assistant.
Components
- 1× ESP32 DevKit V1 — 4 MB flash for LittleFS
- 1× RC522 RFID reader
- 1× MQTT broker
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| RC522 SPI | GPIO 5/18/23/19 + RST GPIO 22 |
Arduino Code
// ESP32 RFID Inventory - Advanced (REST API + LittleFS JSON DB + MQTT)
#include <SPI.h>
#include <MFRC522.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <time.h>
MFRC522 rfid(5,22);
AsyncWebServer server(80);
WiFiClient wc; PubSubClient mqtt(wc);
const char* SSID="YourSSID", *PASS="YourPass";
const char* MQTT_HOST="192.168.1.100";
struct Asset { String uid,name; bool in; time_t last; int outCount; };
const int MAX_A=50; Asset assets[MAX_A]; int aCount=0;
void saveDB(){
DynamicJsonDocument doc(8192);
JsonArray arr=doc.createNestedArray("assets");
for(int i=0;i<aCount;i++){
JsonObject o=arr.createNestedObject();
o["uid"]=assets[i].uid; o["name"]=assets[i].name;
o["in"]=assets[i].in; o["last"]=(long)assets[i].last;
o["out"]=assets[i].outCount;
}
File f=LittleFS.open("/db.json","w");
serializeJson(doc,f); f.close();
}
void loadDB(){
if(!LittleFS.exists("/db.json")) return;
File f=LittleFS.open("/db.json","r");
DynamicJsonDocument doc(8192);
deserializeJson(doc,f); f.close();
JsonArray arr=doc["assets"];
for(JsonObject o:arr){
if(aCount>=MAX_A) break;
assets[aCount]={o["uid"].as<String>(),o["name"].as<String>(),
o["in"]|true,(time_t)(o["last"]|0),(int)(o["out"]|0)};
aCount++;
}
}
String uidStr(MFRC522::Uid *u){
String s="";
for(byte i=0;i<u->size;i++){if(u->uidByte[i]<0x10)s+="0";s+=String(u->uidByte[i],HEX);}
s.toUpperCase(); return s;
}
void mqttEvent(const String &uid, const String &name, bool in){
StaticJsonDocument<128> doc;
doc["uid"]=uid; doc["name"]=name; doc["in"]=in; doc["ts"]=(long)time(nullptr);
char buf[128]; serializeJson(doc,buf);
mqtt.publish("inventory/scan",buf);
}
void setup(){
Serial.begin(115200); SPI.begin(); rfid.PCD_Init();
LittleFS.begin(true);
loadDB();
WiFi.begin(SSID,PASS); while(WiFi.status()!=WL_CONNECTED) delay(500);
configTime(0,0,"pool.ntp.org");
mqtt.setServer(MQTT_HOST,1883);
// Serve React SPA from LittleFS /www/index.html
server.serveStatic("/",LittleFS,"/www/").setDefaultFile("index.html");
server.on("/api/assets",HTTP_GET,[](AsyncWebServerRequest *r){
DynamicJsonDocument doc(4096); JsonArray arr=doc.createNestedArray("assets");
for(int i=0;i<aCount;i++){
JsonObject o=arr.createNestedObject();
o["uid"]=assets[i].uid; o["name"]=assets[i].name;
o["in"]=assets[i].in; o["out"]=assets[i].outCount;
}
String out; serializeJson(doc,out);
r->send(200,"application/json",out);
});
server.on("/api/add",HTTP_POST,[](AsyncWebServerRequest *r){
if(!r->hasParam("uid",true)||!r->hasParam("name",true)){r->send(400); return;}
String uid=r->getParam("uid",true)->value();
String name=r->getParam("name",true)->value();
if(aCount<MAX_A){ assets[aCount++]={uid,name,true,time(nullptr),0}; saveDB(); }
r->send(200,"text/plain","OK");
});
server.begin();
Serial.printf("Inventory API: http://%s/n",WiFi.localIP().toString().c_str());
}
void loop(){
if(!mqtt.connected()) mqtt.connect("RFID");
mqtt.loop();
if(!rfid.PICC_IsNewCardPresent()||!rfid.PICC_ReadCardSerial()) return;
String uid=uidStr(&rfid.uid);
for(int i=0;i<aCount;i++){
if(assets[i].uid==uid){
assets[i].in=!assets[i].in;
assets[i].last=time(nullptr);
if(!assets[i].in) assets[i].outCount++;
saveDB();
mqttEvent(uid,assets[i].name,assets[i].in);
Serial.printf("%s -> %sn",assets[i].name.c_str(),assets[i].in?"IN":"OUT");
break;
}
}
rfid.PICC_HaltA(); rfid.PCD_StopCrypto1();
delay(1000);
}How It Works
LittleFS JSON Database: All asset records are stored as a JSON array in /db.json on LittleFS (the ESP32 internal flash filesystem). On each scan event, saveDB() rewrites the entire file. For 50 assets, the JSON is approximately 3 KB and write time is under 50 ms. LittleFS wear levelling distributes writes across flash blocks.
AsyncWebServer REST API: ESPAsyncWebServer handles concurrent HTTP requests without blocking the RFID scan loop. The /api/assets endpoint returns a JSON array of all assets. The /api/add endpoint adds a new asset by POST parameters. The React SPA makes fetch() calls to these endpoints and updates the UI without full page reloads.
Static SPA from LittleFS: A React single-page application minified to index.html, app.js, and app.css is uploaded to the /www/ directory on LittleFS using the Arduino LittleFS upload plugin. serveStatic() maps HTTP requests to these files. The SPA polls /api/assets every 5 seconds and renders a table with stock status and scan count charts.
MQTT Home Assistant Integration: Each scan publishes a JSON event to inventory/scan with uid, name, checked-in state, and timestamp. A Home Assistant MQTT sensor entity tracks each asset's state. Home Assistant automations can trigger notifications, update dashboard badges, or control room access based on specific asset check-out events.
Applications
- Enterprise asset management for IT equipment lifecycle tracking
- Hospital medical device location and availability monitoring
- Laboratory sample chain-of-custody RFID tracking
- Construction site equipment check-out with automated loss alerts
Troubleshooting
LittleFS fails to mount with begin(true)
The true parameter in LittleFS.begin(true) formats LittleFS on first run if not already formatted. On subsequent boots it mounts normally. If mounting fails repeatedly, check the flash partition scheme includes a LittleFS partition (use "Default 4MB with spiffs" or "Minimal SPIFFS" board configuration).
API returns stale data after adding a new asset
The /api/assets handler reads from the in-memory assets[] array which is always current. If the browser shows stale data, the issue is browser caching. Add response headers: res->addHeader("Cache-Control","no-store") to all API responses.
ESP32 crashes after many scan events
Repeated JSON serialisation with DynamicJsonDocument allocates heap memory. Monitor free heap with Serial.println(ESP.getFreeHeap()). If heap decreases over time, use a StaticJsonDocument with a fixed size or pre-allocate the document once globally rather than inside saveDB().
Upgrades
- Add HTTPS with a self-signed certificate for secure API access over the network
- Add a barcode scanner USB HID input as a fallback for items without RFID tags
- Add a Node-RED dashboard with a Sankey chart showing asset movement patterns over time
- Add role-based access: different tag types grant different permissions (admin vs read-only)
FAQ
You need an ESP32 DevKit, TODO: sensor, RC522 RST, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Industrial Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.