Overview
In this beginner project you will point the ESP32-CAM at a QR code and have the ESP32 decode and print the payload to the Serial Monitor. The Quirc library is small enough to run on the ESP32 without PSRAM. The camera captures a GRAYSCALE frame, Quirc scans it for QR code patterns, and the decoded UTF-8 string is printed to Serial within a fraction of a second.
Components
- 1× ESP32-CAM module (AI-Thinker) — OV2640 camera
- 1× FTDI USB-to-serial adapter — Programming and Serial Monitor
- 1× 5 V 2 A power supply
- 1× Printed QR code or phone screen — For testing
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| FTDI TX/RX/GND/5V | U0R/U0T/GND/5V | Standard ESP32-CAM flash wiring |
| IO0 | GND | During upload only; remove for run |
Arduino Code
// ESP32-CAM QR Code Scanner - Beginner
// Library: https://github.com/zbar/zbar or Quirc port for ESP32
// Use: ESP32-CAM-QR library by martinius96
#include "esp_camera.h"
#include <quirc.h>
// AI-Thinker pin map
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
struct quirc *qr;
void setup() {
Serial.begin(115200);
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM; config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM; config.pin_href = HREF_GPIO_NUM;
config.pin_sscb_sda = SIOD_GPIO_NUM; config.pin_sscb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 10000000;
config.pixel_format = PIXFORMAT_GRAYSCALE; // Quirc needs grayscale
config.frame_size = FRAMESIZE_QVGA; // 320x240 for speed
config.jpeg_quality = 12;
config.fb_count = 1;
if (esp_camera_init(&config) != ESP_OK) {
Serial.println("Camera init failed"); return;
}
qr = quirc_new();
quirc_resize(qr, 320, 240);
Serial.println("QR Scanner ready — point at a QR code");
}
void loop() {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { delay(100); return; }
uint8_t *qr_buf = quirc_begin(qr, NULL, NULL);
memcpy(qr_buf, fb->buf, fb->len);
quirc_end(qr);
esp_camera_fb_return(fb);
int count = quirc_count(qr);
for (int i = 0; i < count; i++) {
struct quirc_code code;
struct quirc_data data;
quirc_extract(qr, i, &code);
if (quirc_decode(&code, &data) == QUIRC_SUCCESS) {
Serial.print("QR Decoded: ");
Serial.println((char*)data.payload);
}
}
delay(200);
}How It Works
Grayscale Frame Capture: PIXFORMAT_GRAYSCALE captures each frame as an 8-bit luminance array without colour information. Quirc only needs luminance to detect the QR finder patterns and alignment markers, so grayscale halves memory usage compared to RGB.
Quirc Scan Pipeline: quirc_begin() returns a pointer to the internal pixel buffer. After copying the camera frame into it, quirc_end() runs the full QR detection pipeline: thresholding, finder-pattern detection, perspective correction, and Reed-Solomon error correction.
Multiple QR Codes: quirc_count() returns the number of valid QR codes found in the frame. The loop iterates over all detected codes, so the scanner can decode multiple QR codes visible simultaneously in the same frame.
Error Correction: quirc_decode() applies Reed-Solomon error correction automatically. QR codes with up to 30 percent data damage (error correction level H) can still be decoded correctly, making the scanner tolerant of partially obscured or damaged codes.
Applications
- Event ticket scanning at venue entrances
- Product barcode reading for inventory management
- URL launcher: scan a QR code to open a web page
- Lab sample tracking with QR-coded specimen labels
Troubleshooting
quirc_decode returns QUIRC_ERROR_DATA_UNDERFLOW
The QR code is too small in the frame. Move the camera closer to the QR code or increase the frame size to FRAMESIZE_VGA (640x480) for better resolution at a distance.
No QR codes detected in good lighting
Ensure PIXFORMAT_GRAYSCALE is set (not JPEG or RGB). Also check that quirc_resize() matches the actual frame dimensions (320x240 for QVGA).
Scan works intermittently
Camera focus and angle matter. Hold the QR code steady and perpendicular to the camera. Slight perspective skew is corrected internally but extreme angles fail.
Memory allocation failed for Quirc
The ESP32-CAM has 520 KB SRAM. Reduce frame size to FRAMESIZE_QQVGA (160x120) if memory is tight, or enable PSRAM with config.fb_count=2.
Upgrades
- Add an OLED display to show decoded QR content without a computer
- Add a buzzer beep on successful decode
- Log decoded URLs to Serial and open them automatically via a companion app
- Add a green LED that lights for 1 second after a successful decode
FAQ
You need an ESP32 DevKit, TODO: sensor, TODO: output, 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 ESP32-CAM. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The intermediate build adds a web server that shows the live camera stream alongside the last decoded QR payload. Every unique scan is logged to a CSV file on the microSD card with a timestamp from NTP. A duplicate-filter suppresses repeat scans of the same code within 5 seconds to prevent log flooding when a code stays in the frame.
Components
- 1× ESP32-CAM module
- 1× MicroSD card — FAT32; inserted in onboard slot
- 1× 5 V 2 A supply
- 1× Wi-Fi router — NTP + web UI access
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| All wiring | Same as beginner | SD card in onboard slot; no extra GPIO |
Arduino Code
// ESP32-CAM QR Scanner - Intermediate (web UI + SD log + duplicate filter)
#include "esp_camera.h"
#include <quirc.h>
#include <WiFi.h>
#include <WebServer.h>
#include "FS.h"
#include "SD_MMC.h"
#include <time.h>
// Pin defines (same as beginner)
#define PWDN_GPIO_NUM 32
// ...
const char* SSID="YourSSID", *PASS="YourPass";
WebServer server(80);
struct quirc *qr;
String lastPayload="";
unsigned long lastScanTime=0;
const unsigned long DEBOUNCE_MS=5000;
String currentResult="Waiting for QR code...";
void logScan(const String &payload){
time_t now=time(nullptr);
String ts=ctime(&now); ts.trim();
File f=SD_MMC.open("/scans.csv",FILE_APPEND);
if(f){ f.println(ts+","+payload); f.close(); }
}
void setup(){
Serial.begin(115200);
// camera init with PIXFORMAT_GRAYSCALE, FRAMESIZE_QVGA
WiFi.begin(SSID,PASS);
while(WiFi.status()!=WL_CONNECTED) delay(500);
configTime(0,0,"pool.ntp.org");
SD_MMC.begin();
server.on("/",[](){
String body="<h2>QR Scanner</h2>"
"<p>Last result: <b>"+currentResult+"</b></p>"
"<img src="/stream" width=320>";
server.send(200,"text/html",body);
});
server.on("/log",[](){
File f=SD_MMC.open("/scans.csv");
String content=f?f.readString():"No log yet";
f.close();
server.send(200,"text/plain",content);
});
server.begin();
qr=quirc_new(); quirc_resize(qr,320,240);
}
void loop(){
server.handleClient();
camera_fb_t *fb=esp_camera_fb_get();
if(!fb){ delay(50); return; }
uint8_t *buf=quirc_begin(qr,NULL,NULL);
memcpy(buf,fb->buf,fb->len);
quirc_end(qr);
esp_camera_fb_return(fb);
int n=quirc_count(qr);
for(int i=0;i<n;i++){
struct quirc_code code; struct quirc_data data;
quirc_extract(qr,i,&code);
if(quirc_decode(&code,&data)==QUIRC_SUCCESS){
String payload=(char*)data.payload;
unsigned long now=millis();
if(payload!=lastPayload || now-lastScanTime>DEBOUNCE_MS){
lastPayload=payload; lastScanTime=now;
currentResult=payload;
logScan(payload);
Serial.println("Scanned: "+payload);
}
}
}
delay(100);
}How It Works
Duplicate Suppression: The debounce filter compares the current payload against lastPayload and checks whether DEBOUNCE_MS (5000 ms) has elapsed since the last scan. Only new payloads or payloads scanned after the debounce window are logged and displayed.
NTP-Timestamped CSV Log: configTime() syncs the ESP32 clock to pool.ntp.org. Each unique scan appends a row to /scans.csv containing the ctime()-formatted timestamp and the QR payload. The /log endpoint serves the file as plain text for easy download.
Web UI with Live Result: The root endpoint builds an HTML page showing the last decoded payload as bold text. An img tag pointing to /stream embeds the MJPEG stream from the camera server. The page auto-refreshes every 2 seconds to update the result display.
SD Append Mode: Opening the log file with FILE_APPEND adds new rows without overwriting existing data. The scan log accumulates indefinitely until the SD card is full or the file is manually deleted.
Applications
- Conference registration kiosk scanning ticket QR codes
- Library book check-in and check-out terminal
- Manufacturing traceability scanner logging part serial numbers
- Access control gate reading QR passes with timestamps
Troubleshooting
Log file grows too large over time
Add a daily log rotation: check the date at startup and create a new file named /scans_YYYYMMDD.csv if the date has changed since the last file was created.
Web page result does not update
Add a meta refresh tag or use JavaScript fetch() to poll a /result endpoint every 2 seconds for the latest payload string without reloading the full page.
NTP timestamp shows 1970
Adjust the GMT offset in configTime(). Also wait for NTP sync before the first scan: add while(time(nullptr)<1000000000UL) delay(500); after configTime().
Upgrades
- Add a Telegram bot to forward each new scan payload to your phone
- Generate a QR code directly on the web UI as a test code using a JavaScript QR generator
- Add per-scan LED colour: green for URL payloads, red for unknown format
- Add CSV download link on the /log page for easy data export
FAQ
You need an ESP32 DevKit, TODO: sensor, TODO: output, 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 ESP32-CAM. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced build turns the QR scanner into an MQTT-based access control reader. Scanned payloads are published to an MQTT topic; a subscriber (Node-RED or Home Assistant) validates the payload against an approved list and publishes an ALLOW or DENY response. The ESP32 then actuates a relay (door strike or barrier gate) based on the MQTT response and logs the decision with timestamp to the SD card.
Components
- 1× ESP32-CAM module
- 1× MicroSD card
- 1× 5 V relay module — Controls door strike or barrier
- 1× MQTT broker — Mosquitto local or cloud
- 1× Green and red LED — Access allowed/denied indicator
- 1× Active buzzer — Audio feedback
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| Relay IN | GPIO 12 | Active LOW on most relay modules |
| Green LED | GPIO 13 | 220 ohm; access allowed |
| Red LED | GPIO 15 | 220 ohm; access denied |
| Buzzer | GPIO 14 | |
| Camera + SD | Standard AI-Thinker map |
Arduino Code
// ESP32-CAM QR Scanner - Advanced (MQTT access control)
#include "esp_camera.h"
#include <quirc.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include "SD_MMC.h"
#include <time.h>
#define RELAY 12
#define LED_G 13
#define LED_R 15
#define BUZZER 14
#define PWDN_GPIO_NUM 32
// ... other pin defines
const char* SSID="YourSSID", *PASS="YourPass";
const char* MQTT_HOST="192.168.1.100";
const char* TOPIC_SCAN ="qr/scan";
const char* TOPIC_RESULT="qr/result";
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
struct quirc *qr;
bool waitingResult=false;
String pendingPayload="";
void grantAccess(){
digitalWrite(LED_G,HIGH); digitalWrite(RELAY,LOW); // relay active LOW
tone(BUZZER,1000,200);
delay(3000);
digitalWrite(LED_G,LOW); digitalWrite(RELAY,HIGH);
}
void denyAccess(){
digitalWrite(LED_R,HIGH);
tone(BUZZER,400,500);
delay(1000);
digitalWrite(LED_R,LOW);
}
void mqttCallback(char* topic, byte* payload, unsigned int len){
String result((char*)payload,len);
if(result=="ALLOW") grantAccess();
else denyAccess();
waitingResult=false;
// Log to SD
time_t now=time(nullptr); String ts=ctime(&now); ts.trim();
File f=SD_MMC.open("/access.csv",FILE_APPEND);
if(f){ f.println(ts+","+pendingPayload+","+result); f.close(); }
}
void setup(){
Serial.begin(115200);
pinMode(RELAY,OUTPUT); digitalWrite(RELAY,HIGH); // start locked
pinMode(LED_G,OUTPUT); pinMode(LED_R,OUTPUT); pinMode(BUZZER,OUTPUT);
// camera init GRAYSCALE QVGA
WiFi.begin(SSID,PASS);
while(WiFi.status()!=WL_CONNECTED) delay(500);
configTime(0,0,"pool.ntp.org");
SD_MMC.begin();
mqtt.setServer(MQTT_HOST,1883);
mqtt.setCallback(mqttCallback);
qr=quirc_new(); quirc_resize(qr,320,240);
}
void loop(){
if(!mqtt.connected()){ mqtt.connect("ESP32QR"); mqtt.subscribe(TOPIC_RESULT); }
mqtt.loop();
if(waitingResult){ delay(50); return; }
camera_fb_t *fb=esp_camera_fb_get();
if(!fb){ delay(50); return; }
uint8_t *buf=quirc_begin(qr,NULL,NULL);
memcpy(buf,fb->buf,fb->len);
quirc_end(qr);
esp_camera_fb_return(fb);
int n=quirc_count(qr);
for(int i=0;i<n;i++){
struct quirc_code code; struct quirc_data data;
quirc_extract(qr,i,&code);
if(quirc_decode(&code,&data)==QUIRC_SUCCESS){
pendingPayload=(char*)data.payload;
mqtt.publish(TOPIC_SCAN,pendingPayload.c_str());
waitingResult=true;
break;
}
}
delay(100);
}How It Works
MQTT Access Request: When a QR code is decoded, the payload string is published to qr/scan and waitingResult is set to true. The main loop pauses new scans while waiting for the broker to respond, preventing duplicate requests from a code held in view.
Broker-Side Validation: A Node-RED flow or Home Assistant automation subscribes to qr/scan, checks the payload against a database or list of valid codes, and publishes "ALLOW" or "DENY" back to qr/result. This keeps the validation logic off the ESP32 and easily updatable.
Hardware Actuation: grantAccess() drives the relay LOW (active LOW), opening the door strike for 3 seconds, lights the green LED, and beeps the buzzer once. denyAccess() lights the red LED and produces a long low-pitch buzz.
SD Access Log: Every decision is appended to /access.csv on the SD card with a timestamp, payload, and ALLOW or DENY result. This provides a local audit trail independent of the MQTT broker.
Applications
- Event venue entry control with QR ticket validation
- Car park barrier gate with QR permit codes
- School or office visitor management with one-time QR passes
- Smart locker system with QR unlock codes sent via SMS
Troubleshooting
System stays locked after valid QR scan
Check that the MQTT broker received the qr/scan message. Use an MQTT client (MQTT Explorer) to monitor traffic. Verify the Node-RED flow is publishing to qr/result with exactly "ALLOW" (case-sensitive).
Multiple scans fire for one code presentation
The waitingResult flag should prevent duplicate publishes. If it resets too fast check that mqttCallback is being called before waitingResult is cleared. Add a 100 ms minimum between scans as an additional guard.
Relay clicks but door does not open
Verify the relay NO (Normally Open) and COM terminals are connected to the door strike power circuit. Test the relay in isolation by driving the IN pin LOW and measuring continuity between NO and COM.
Upgrades
- Generate single-use QR codes on the server and invalidate after one scan
- Add a display above the scanner showing WELCOME or ACCESS DENIED messages
- Add a camera snapshot on each DENY event for security audit review
- Integrate with a visitor pre-registration web form that issues QR codes by email
FAQ
You need an ESP32 DevKit, TODO: sensor, TODO: output, 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 ESP32-CAM. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.