ESP32-CAM QR Code Scanner

ESP32-CAMBeginnerIntermediateAdvanced

Use the ESP32-CAM camera and the Quirc QR decoding library to scan and decode QR codes in real time, displaying results on a web page, logging them to an SD card, and publishing them as MQTT access-control events.

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 PinESP32 PinNotes
FTDI TX/RX/GND/5VU0R/U0T/GND/5VStandard ESP32-CAM flash wiring
IO0GNDDuring upload only; remove for run
Arduino Code
esp32-cam-qr-scanner_beginner.ino
// 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
01

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.

02

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.

03

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.

04

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 PinESP32 PinNotes
All wiringSame as beginnerSD card in onboard slot; no extra GPIO
Arduino Code
esp32-cam-qr-scanner_intermediate.ino
// 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
01

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.

02

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.

03

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.

04

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 PinESP32 PinNotes
Relay INGPIO 12Active LOW on most relay modules
Green LEDGPIO 13220 ohm; access allowed
Red LEDGPIO 15220 ohm; access denied
BuzzerGPIO 14
Camera + SDStandard AI-Thinker map
Arduino Code
esp32-cam-qr-scanner_advanced.ino
// 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
01

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.

02

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.

03

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.

04

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.