Siddhant Kumar
Project 007 · Smart Home

Smart Video Doorbell.

An ESP32-CAM doorbell that captures a visitor snapshot on the button press or on motion, streams MJPEG on demand, and pushes the image straight to your phone — with everything stored on your own hardware.

Intermediate 12–18 hours 37 min read CameraAudioApp
Jump to source Bill of materials
Smart Video Doorbell — reference build illustration
Difficulty
Intermediate
Build time
12–18 hours
Indicative cost
₹2,300 – ₹3,400
Platform
ESP32-CAM (AI-Thinker) + OV2640
Category
Smart Home
Last updated
28 July 2026
Contents — 26 sections

Project Overview

An ESP32-CAM doorbell that captures a visitor snapshot on the button press or on motion, streams MJPEG on demand, and pushes the image straight to your phone — with everything stored on your own hardware.

Commercial video doorbells work well and put a permanently-connected camera pointed at your front door under someone else's control, with the recordings behind a subscription. This build does the same job for under ₹2,500, keeps every frame on a machine you own, and — because the whole thing is about 300 lines of firmware — you can see exactly what it does.

The ESP32-CAM is a compromise and it is worth being honest about which parts. It has a 2 MP OV2640, 4 MB of PSRAM, and enough processing power to serve MJPEG at around 12–15 fps at VGA. It cannot do H.264, it cannot do continuous 24/7 recording, and it will not match a commercial unit for night performance. What it does well is exactly what a doorbell needs: capture a good still when something happens, and serve a short live view when you ask for it.

The design therefore centres on event capture rather than continuous streaming. A button press or a PIR trigger causes the board to grab several JPEG frames, publish the best one over MQTT (or POST it to a local endpoint), and sound a chime inside the house. Live view is opt-in: the stream endpoint exists but nothing subscribes to it unless you open the page.

Two practical problems dominate real installs. The first is power: the ESP32-CAM draws 180–310 mA while streaming and browns out on a weak supply, which is the cause of the vast majority of "my ESP32-CAM keeps rebooting" reports. The second is Wi-Fi range, because a front door is usually the furthest point from the router and often behind a masonry wall — which is why this build specifies the antenna-connector variant of the board and an external antenna.

A USB webcam
A USB webcam — the same class of small CMOS sensor and lens assembly used in the ESP32-CAM module. Photograph sourced from Wikimedia Commons — Webcam.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Captures a JPEG snapshot when the doorbell button is pressed, and again 1.5 s later to catch a moving visitor.
  • Captures on PIR motion with a configurable cooldown so a windy tree does not fill your phone.
  • Publishes images over MQTT as base64, or POSTs them to a local HTTP endpoint for storage.
  • Serves an MJPEG live stream and a single-shot JPEG endpoint on demand.
  • Rings a separate mains-powered chime node over MQTT, so the doorbell works even if your phone does not.
  • Timestamps every event from NTP and names the stored files accordingly.
  • Sleeps between events to keep the module cool and the power draw low.

Real-World Applications

SettingHow it is used
Front-door visitor captureThe core case: know who called while you were out, with the image on your own storage.
Parcel delivery evidenceA timestamped image of the courier and where they left the parcel settles most delivery disputes.
Gate intercom for a compoundCombine with the smart door lock project so a recognised visitor can be admitted remotely.
Elderly care check-inA motion event with an image confirms a carer arrived without any recording of the interior.
Workshop or store-room entry logCheap enough to put one on every door that matters.
Wildlife and pet monitoringThe same firmware pointed at a garden captures whatever triggers the PIR.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Dual capture on press — one immediate, one 1.5 s later — which massively improves the odds of a usable face.
  • Frame quality ramp: the sensor is given three throwaway frames to settle exposure before the kept frame.
  • PSRAM frame buffering allowing UXGA (1600×1200) stills alongside VGA streaming.
  • Motion cooldown and quiet hours to keep notification volume sane.
  • External antenna support with the on-board jumper resistor moved — worth 10–15 dB at the door.
  • Separate chime node so the audible bell does not depend on the camera board.
  • Local-only storage via an HTTP POST to a Node-RED or Python endpoint on your own machine.
  • Brown-out detector disabled deliberately, with a properly sized supply instead — see the notes.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time12–18 hours
Indicative build cost₹2,300 – ₹3,400
Primary disciplineSmart Home
Reference platformESP32-CAM (AI-Thinker) + OV2640

Skills you should have (or will pick up)

  • Arduino C++ and the ESP32 camera driver API
  • Flashing a board that has no USB — using an FTDI adapter and the BOOT/GND jumper
  • Basic HTTP: multipart MJPEG and POST with a binary body
  • MQTT publish with a large payload
  • Power supply sizing and decoupling

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.

ComponentKey specificationQtyApprox. cost
ESP32-CAM (AI-Thinker) + OV2640
No USB-serial on board — needs an FTDI/CP2102 adapter to flash.
ESP32-S chip, 4 MB PSRAM, 2 MP OV2640 camera, microSD slot, on-board LED flash1₹620
HC-SR501 PIR motion sensor
Give it 60 s to settle after power-up or it fires false triggers.
3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle1₹80
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 diameter1₹25
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 % efficiency1₹90
5 V 3 A regulated SMPS adapter
Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.
100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection1₹350
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 grid1₹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 lugs1₹260
FTDI / CP2102 USB-serial adapter (3.3 V)Programming only, not part of the finished unit1₹250
Doorbell push button, weatherproofNO momentary, IP54, illuminated1₹180
2.4 GHz external antenna + u.FL pigtail
Only usable on the ESP32-CAM variant that has the u.FL connector.
3 dBi, SMA1₹220
1000 µF electrolytic + 100 nF ceramic
Across the 5 V rail at the board. Not optional.
10 V, low ESR1₹40
Second ESP32 + buzzer for the indoor chimeAny ESP32 or ESP82661₹400

Estimated total: ₹2,575, 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

PartSpecificationSupplyInterfaceReference
ESP32-CAM (AI-Thinker) + OV2640ESP32-S chip, 4 MB PSRAM, 2 MP OV2640 camera, microSD slot, on-board LED flash5 V in / 3.3 V logicUART (programmer required), SPI, I²CDatasheet
HC-SR501 PIR motion sensor3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle4.5–20 VDigital high on motionDatasheet
Active piezo buzzer 5 V85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter3–5 VDigital / PWMDatasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32-CAM (AI-Thinker) + OV26405 V in / 3.3 V logic220No USB-serial on board — needs an FTDI/CP2102 adapter to flash.
HC-SR501 PIR motion sensor4.5–20 V0.05Give it 60 s to settle after power-up or it fires false triggers.
Active piezo buzzer 5 V3–5 V30Active buzzers make tone on DC; passive ones need a PWM carrier.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
5 V 3 A regulated SMPS adapter5 V3000Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.

Summed typical draw is 3258.05 mA. With a 1.5× design margin the supply should deliver at least 4900 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x, board: AI Thinker ESP32-CAM, PSRAM enabled. 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)
HTTPClient bundledREST calls to cloud endpoints over HTTP/HTTPS.Bundled with the ESP32 core

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Smart Video Doorbell — system block diagramFunctional block diagram of the Smart Video Doorbell system. TriggerDoorbell buttonGPIO 13PIR motionGPIO 12, cooldownCaptureOV2640 + PSRAMsettle then grabJPEG encodeon-sensorDeliverHTTP POSTlocal storageMQTT eventnotify + chimeConsumePhone pushwith imageChime nodeindoor belleventJPEG buffernotification
Smart Video Doorbell — system block diagram

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.

Smart Video Doorbell — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32-CAM(AI-Thinker) +OV26405 V in / 3.3 V logicDoorbell buttonGPIO 13Pull-up, active-lowHC-SR501 PIRGPIO 12High on motionOV2640 cameraOn-boardFixed camera busChime relay / buzzerGPIO 2Local audiblefeedbackIR illuminator LEDsGPIO 15MOSFET-driven, nightonlyOn-board flash LEDGPIO 4Very bright — usesparingly
Smart Video Doorbell — wiring schematic
PeripheralPeripheral pinController pinSignal
Doorbell buttonNO contactGPIO 13Pull-up, active-low
HC-SR501 PIROUTGPIO 12High on motion
OV2640 cameraRibbonOn-boardFixed camera bus
Chime relay / buzzerINGPIO 2Local audible feedback
IR illuminator LEDsGateGPIO 15MOSFET-driven, night only
On-board flash LEDGPIO 4Very bright — use sparingly

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

  • GPIO 0 must be free at boot. It is the bootloader strap; anything holding it low keeps the board in flash mode. Do not use it for a peripheral.
  • GPIO 1 and 3 are the UART. Keep them clear or you cannot see serial output or flash the board.
  • GPIO 4 drives the on-board flash LED, which is genuinely dazzling and draws about 250 mA. It is also shared with the SD card data line, so you cannot use both the flash and the SD slot in 4-bit mode.
  • The available spare pins on an AI-Thinker ESP32-CAM are 2, 12, 13, 14, 15 and 16 — and 12 is a strapping pin that must not be high at boot. Plan the wiring around those six.
  • Power is the number one failure cause. Feed 5 V into the 5V pin from a supply rated at 1 A or more, with a 1000 µF capacitor physically at the board. A 3.3 V feed from an FTDI adapter cannot supply the peak current and will brown out mid-capture.
  • To use an external antenna, move the tiny zero-ohm resistor next to the u.FL connector from the PCB-trace position to the connector position. This is fiddly SMD work but worth 10–15 dB at a front door.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board. The ESP32-CAM uses the same SoC with a camera interface and PSRAM added. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

Smart Video Doorbell — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32-CAM (AI-Thinker) + OV2640 · HC-SR501 PIR motion sensorDriver layerwifi · pubsub · arduinojson · ntpApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT + HTTP · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Smart Video Doorbell — architecture stack

Working Principle

The OV2640 does the hard work. It is not a raw image sensor that hands you pixels — it contains a full image pipeline including automatic exposure, white balance, and a JPEG encoder. The ESP32 configures it over SCCB (an I²C variant) and then receives already-compressed JPEG frames over a parallel bus into a DMA buffer. That is why an 8-bit-era microcontroller-class chip can serve video at all: it never touches the pixels.

The consequence is that the first frame after a trigger is almost always bad. The sensor's automatic exposure loop needs several frames to converge, especially when the scene changes from an empty doorway to a person filling it. Grabbing and discarding three frames before keeping one costs about 200 ms and transforms the hit rate from roughly half usable to nearly all usable.

PSRAM is what makes higher resolutions possible. A UXGA JPEG frame buffer needs a few hundred kilobytes, which does not fit in the ESP32's internal SRAM alongside the Wi-Fi stack. With PSRAM enabled the driver allocates the frame buffers externally and you can capture at 1600×1200 while still streaming VGA. Without PSRAM the driver silently falls back and refuses anything above SVGA.

MJPEG streaming is deliberately primitive: the server sends a multipart/x-mixed-replace response and simply keeps appending JPEG frames separated by a boundary marker. Every browser has supported this since the 1990s, there is no negotiation, no codec, and no latency budget beyond the frame time. It is bandwidth-hungry — VGA at 12 fps is roughly 2–3 Mbit/s — which is exactly why this design streams on demand rather than continuously.

The chime being a separate node is a design decision worth defending. A doorbell that only notifies a phone fails when the phone is silent, out of battery, or in another room. Splitting the audible bell onto a cheap mains-powered ESP32 inside the house means the doorbell function survives a flat phone, and it also means the outdoor unit does not need a speaker or the power to drive one.

The maths behind it

Stream bandwidth

plainStream bandwidth
VGA (640×480) JPEG at quality 12 ≈ 25 kB/frame

12 fps  → 25 kB × 12 = 300 kB/s = 2.4 Mbit/s

UXGA (1600×1200) at quality 10 ≈ 180 kB/frame
  single snapshot over MQTT, base64 encoded:
  180 kB × 4/3 = 240 kB payload

This is why stills go over HTTP POST and only a small
thumbnail goes over MQTT — a 240 kB MQTT publish will
stall a broker that other devices depend on.

Power budget

plainPower budget
Idle, Wi-Fi connected       :  80 mA
Streaming VGA               : 180 mA
Capture UXGA (peak)         : 250 mA
Flash LED on                : +250 mA
Wi-Fi TX burst              : +200 mA (few ms)

Worst case (capture + flash + TX) ≈ 700 mA

Supply must be ≥ 1 A with a 1000 uF bulk capacitor.
The classic failure — "Brownout detector was triggered" —
is this peak, not an average problem.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Smart Video Doorbell — firmware flowchartControl flow through the main program loop. Boot: init camera, join Wi-Fi,sync NTPServe HTTP, poll button andPIRButton pressed or motion?yeskeep servingDiscard 3 frames to settleexposureCapture JPEG at UXGAPOST image to local storePublish MQTT event, ring chimenodeStart cooldown, return toserving
Smart Video Doorbell — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

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.

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.

cppvideo-doorbell.ino
/* ═══════════════════════════════════════════════════════════════
   Smart Video Doorbell — ESP32-CAM (AI Thinker) + PIR + MQTT

   Captures a still on button press or motion, POSTs it to a local
   store, publishes an MQTT event that rings a separate chime node,
   and serves an on-demand MJPEG stream.

   Board: AI Thinker ESP32-CAM   PSRAM: enabled   Partition: Huge APP
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <esp_camera.h>
#include <esp_http_server.h>
#include <time.h>

#define WIFI_SSID  "YOUR_WIFI"
#define WIFI_PASS  "YOUR_PASSWORD"
#define MQTT_HOST  "192.168.1.50"
#define STORE_URL  "http://192.168.1.50:1880/doorbell"   // Node-RED endpoint
#define DEVICE_ID  "doorbell-front"

#define PIN_BUTTON 13
#define PIN_PIR    12
#define PIN_CHIME   2
#define PIN_IR_LED 15

#define MOTION_COOLDOWN_MS 60000UL
#define QUIET_START_H 22
#define QUIET_END_H    7

/* AI Thinker ESP32-CAM pin map — do not change for this board */
#define PWDN_GPIO 32
#define RESET_GPIO -1
#define XCLK_GPIO  0
#define SIOD_GPIO 26
#define SIOC_GPIO 27
#define Y9_GPIO   35
#define Y8_GPIO   34
#define Y7_GPIO   39
#define Y6_GPIO   36
#define Y5_GPIO   21
#define Y4_GPIO   19
#define Y3_GPIO   18
#define Y2_GPIO    5
#define VSYNC_GPIO 25
#define HREF_GPIO  23
#define PCLK_GPIO  22

WiFiClient   net;
PubSubClient mqtt(net);
httpd_handle_t server = NULL;
uint32_t lastMotion = 0;

/* ── camera ─────────────────────────────────────────────────── */
bool cameraBegin() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO;  c.pin_d1 = Y3_GPIO;  c.pin_d2 = Y4_GPIO;  c.pin_d3 = Y5_GPIO;
  c.pin_d4 = Y6_GPIO;  c.pin_d5 = Y7_GPIO;  c.pin_d6 = Y8_GPIO;  c.pin_d7 = Y9_GPIO;
  c.pin_xclk = XCLK_GPIO;  c.pin_pclk = PCLK_GPIO;
  c.pin_vsync = VSYNC_GPIO; c.pin_href = HREF_GPIO;
  c.pin_sccb_sda = SIOD_GPIO; c.pin_sccb_scl = SIOC_GPIO;
  c.pin_pwdn = PWDN_GPIO;  c.pin_reset = RESET_GPIO;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;

  if (psramFound()) {
    c.frame_size   = FRAMESIZE_UXGA;   // 1600x1200 for stills
    c.jpeg_quality = 10;               // lower number = better quality
    c.fb_count     = 2;                // double buffer
    c.fb_location  = CAMERA_FB_IN_PSRAM;
    c.grab_mode    = CAMERA_GRAB_LATEST;
  } else {
    c.frame_size   = FRAMESIZE_SVGA;   // fall back without PSRAM
    c.jpeg_quality = 14;
    c.fb_count     = 1;
  }

  if (esp_camera_init(&c) != ESP_OK) { Serial.println("camera init failed"); return false; }

  sensor_t *s = esp_camera_sensor_get();
  s->set_vflip(s, 1);            // most modules mount the sensor inverted
  s->set_brightness(s, 1);
  s->set_saturation(s, -1);      // slightly desaturated reads better at a door
  return true;
}

/* Throw away frames so auto-exposure converges before we keep one. */
camera_fb_t *captureSettled(int discard = 3) {
  for (int i = 0; i < discard; i++) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (fb) esp_camera_fb_return(fb);
    delay(60);
  }
  return esp_camera_fb_get();
}

/* ── delivery ───────────────────────────────────────────────── */
bool postImage(camera_fb_t *fb, const char *reason) {
  HTTPClient http;
  char url[192];
  time_t now = time(nullptr);
  struct tm t; localtime_r(&now, &t);
  snprintf(url, sizeof(url), "%s?device=%s&reason=%s&ts=%04d%02d%02d-%02d%02d%02d",
           STORE_URL, DEVICE_ID, reason,
           t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec);

  http.begin(url);
  http.addHeader("Content-Type", "image/jpeg");
  http.setTimeout(8000);
  int code = http.POST(fb->buf, fb->len);
  http.end();

  Serial.printf("POST %s -> %d (%u bytes)\n", reason, code, (unsigned)fb->len);
  return code > 0 && code < 300;
}

void publishEvent(const char *reason, size_t bytes, bool stored) {
  JsonDocument d;
  d["device"] = DEVICE_ID;
  d["event"]  = reason;
  d["ts"]     = (uint32_t)time(nullptr);
  d["bytes"]  = bytes;
  d["stored"] = stored;
  d["rssi"]   = WiFi.RSSI();
  char buf[192];
  size_t n = serializeJson(d, buf, sizeof(buf));
  mqtt.publish("home/doorbell/" DEVICE_ID "/event", (uint8_t *)buf, n, false);
}

bool inQuietHours() {
  time_t now = time(nullptr);
  struct tm t; localtime_r(&now, &t);
  return QUIET_START_H > QUIET_END_H
       ? (t.tm_hour >= QUIET_START_H || t.tm_hour < QUIET_END_H)
       : (t.tm_hour >= QUIET_START_H && t.tm_hour < QUIET_END_H);
}

void handleTrigger(const char *reason, bool chime) {
  digitalWrite(PIN_IR_LED, inQuietHours() ? HIGH : LOW);   // IR fill at night
  delay(40);

  camera_fb_t *fb = captureSettled();
  if (!fb) { Serial.println("capture failed"); return; }
  bool ok = postImage(fb, reason);
  esp_camera_fb_return(fb);

  // A second frame 1.5 s later catches a visitor who was still moving.
  if (!strcmp(reason, "button")) {
    delay(1500);
    camera_fb_t *fb2 = esp_camera_fb_get();
    if (fb2) { postImage(fb2, "button-2"); esp_camera_fb_return(fb2); }
  }

  digitalWrite(PIN_IR_LED, LOW);
  publishEvent(reason, fb ? fb->len : 0, ok);

  if (chime && !inQuietHours()) {
    mqtt.publish("home/doorbell/chime", "ring", false);
    for (int i = 0; i < 2; i++) { tone(PIN_CHIME, 1800, 180); delay(240); }
  }
}

/* ── HTTP: single shot and MJPEG stream ─────────────────────── */
static esp_err_t jpgHandler(httpd_req_t *req) {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return httpd_resp_send_500(req);
  httpd_resp_set_type(req, "image/jpeg");
  httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=door.jpg");
  esp_err_t r = httpd_resp_send(req, (const char *)fb->buf, fb->len);
  esp_camera_fb_return(fb);
  return r;
}

static esp_err_t streamHandler(httpd_req_t *req) {
  static const char *BOUNDARY = "--frameboundary";
  httpd_resp_set_type(req, "multipart/x-mixed-replace;boundary=frameboundary");

  sensor_t *s = esp_camera_sensor_get();
  s->set_framesize(s, FRAMESIZE_VGA);        // stream small, capture large
  char part[80];

  while (true) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) break;
    size_t hl = snprintf(part, sizeof(part),
      "\r\n%s\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n",
      BOUNDARY, (unsigned)fb->len);
    esp_err_t e = httpd_resp_send_chunk(req, part, hl);
    if (e == ESP_OK) e = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
    esp_camera_fb_return(fb);
    if (e != ESP_OK) break;                  // client disconnected
  }
  s->set_framesize(s, FRAMESIZE_UXGA);       // restore stills resolution
  return ESP_OK;
}

void httpBegin() {
  httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
  cfg.server_port = 80;
  if (httpd_start(&server, &cfg) != ESP_OK) return;
  httpd_uri_t jpg    = { "/jpg",    HTTP_GET, jpgHandler,    NULL };
  httpd_uri_t stream = { "/stream", HTTP_GET, streamHandler, NULL };
  httpd_register_uri_handler(server, &jpg);
  httpd_register_uri_handler(server, &stream);
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_BUTTON, INPUT_PULLUP);
  pinMode(PIN_PIR, INPUT);
  pinMode(PIN_CHIME, OUTPUT);
  pinMode(PIN_IR_LED, OUTPUT);
  digitalWrite(PIN_IR_LED, LOW);

  if (!cameraBegin()) { delay(3000); ESP.restart(); }

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);                       // sleep kills stream latency
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  Serial.printf("IP %s  RSSI %d dBm\n", WiFi.localIP().toString().c_str(), WiFi.RSSI());

  configTime(19800, 0, "pool.ntp.org");
  mqtt.setServer(MQTT_HOST, 1883);
  httpBegin();
  Serial.println("Doorbell ready — /jpg and /stream available");
}

void loop() {
  if (!mqtt.connected() && WiFi.status() == WL_CONNECTED)
    mqtt.connect(DEVICE_ID, NULL, NULL,
                 "home/doorbell/" DEVICE_ID "/status", 0, true, "offline");
  mqtt.loop();

  if (digitalRead(PIN_BUTTON) == LOW) {
    delay(30);
    if (digitalRead(PIN_BUTTON) == LOW) {
      handleTrigger("button", true);
      while (digitalRead(PIN_BUTTON) == LOW) delay(10);
    }
  }

  if (digitalRead(PIN_PIR) == HIGH && millis() - lastMotion > MOTION_COOLDOWN_MS) {
    lastMotion = millis();
    handleTrigger("motion", false);
  }

  delay(20);
}
CAMERA_FB_IN_PSRAM + fb_count 2Double buffering in PSRAM lets the sensor DMA the next frame while the current one is being sent. Without it, streaming frame rate roughly halves.
CAMERA_GRAB_LATESTReturns the newest frame rather than the oldest queued one. For a live view that is what you want — an MJPEG stream that falls behind should drop frames, not accumulate lag.
captureSettled(3)The single most valuable twelve lines in this sketch. Auto-exposure needs several frames to converge after the scene changes; keeping the first frame gives you a washed-out or black image roughly half the time.
set_framesize VGA then UXGAThe stream drops to VGA while a client is watching, then restores UXGA for stills. Streaming UXGA is technically possible and yields about 2 fps, which is worse than useless.
WiFi.setSleep(false)Modem sleep saves power but adds up to 100 ms of latency to every packet, which makes an MJPEG stream stutter visibly. This board is mains powered, so the trade is easy.
POST rather than MQTT for the imageA 180 kB base64 MQTT publish will block a shared broker for a noticeable time. HTTP POST to a storage endpoint keeps the large payload off the message bus entirely, and MQTT carries only the small event notification.
LWT on the status topicA doorbell that has silently died looks exactly like a doorbell nobody has rung. The Last Will makes the difference visible.

Configuration & Calibration

Configuration steps

  • Select Board: AI Thinker ESP32-CAM, PSRAM: Enabled, and Partition Scheme: Huge APP (3 MB). The camera driver plus the HTTP server does not fit in the default partition.
  • To flash: connect FTDI TX→U0R, RX→U0T, 5 V→5V, GND→GND, and bridge IO0 to GND. Press reset, upload, then remove the IO0 jumper and reset again.
  • Set STORE_URL to a local endpoint. A three-node Node-RED flow (HTTP In → File → HTTP Response) is enough to write timestamped JPEGs to disk.
  • Set the NTP offset for your timezone; the stored filenames depend on it.
  • Tune MOTION_COOLDOWN_MS upward if a road or a tree is in frame. Sixty seconds is a starting point, not an answer.
  • Adjust set_vflip and set_hmirror to match how you physically mount the module.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Frame the shot

    Mount at about 1.4 m and tilt down roughly 15°. Open /stream on a laptop and adjust until a person standing at the door fills the middle third of the frame. Too high and you photograph the tops of heads; too low and tall visitors are decapitated.

  2. Set exposure for backlight

    A doorway with bright sky behind it is the worst case for auto-exposure. If faces come out as silhouettes, set s->set_ae_level(s, 2) to bias exposure brighter, and consider s->set_wb_mode(s, 1) for a fixed sunny white balance.

  3. Tune the PIR

    Set the sensitivity trimmer to roughly mid-travel, the time-delay trimmer to minimum, and the jumper to H (retrigger). Then walk the approach path and check that you trigger at the range you want and not from the pavement.

Network Architecture & Connectivity

Smart Video Doorbell — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsDoorbellESP32-CAMChime nodeindoor ESP32Wi-Fi 2.4 GHzRouterexternal antenna advisedMQTT + HTTP POSTNode-RED + brokeron a local PiPhone pushimage attachedWeb view/stream
Smart Video Doorbell — network topology

Communication protocol

Two transports, deliberately. Small, frequent messages (events, chime commands, status) go over MQTT where the persistent connection gives low latency. Large, infrequent payloads (JPEG images) go over HTTP POST, where a slow transfer inconveniences nobody else.

Mixing these is a common mistake: publishing a 240 kB base64 image over MQTT works on a bench and starves every other device on a real broker.

Topic / endpointDirectionPayload
home/doorbell/doorbell-front/eventdevice → brokerJSON: event, ts, bytes, stored, rssi
home/doorbell/chimedevice → broker"ring" — consumed by the indoor chime node
home/doorbell/doorbell-front/statusdevice → broker (retained)"online" / "offline" (LWT)

Message contract between the device and the broker.

Cloud platform configuration

A Node-RED flow of four nodes does the whole backend: an HTTP In node at /doorbell, a File node writing /srv/doorbell/{{ts}}.jpg, an HTTP Response node, and a call to ntfy.sh with the image attached. Nothing leaves your network unless you choose to send the notification.

Mobile app integration

ntfy.sh supports file attachments, so the push notification itself can carry the visitor image. That is the difference between "someone rang the bell" and a doorbell you actually rely on.

Security considerations

  • The MJPEG endpoint has no authentication. Do not port-forward it. Reach it through a VPN or a reverse proxy with authentication if you need remote access.
  • Put the camera on an IoT VLAN with no route to your file server or NAS management interfaces.
  • Consider what the camera can see. A doorbell that also frames a neighbour's window or a public footpath raises real privacy and, in some jurisdictions, legal issues — angle it down and mask what you do not need.
  • Store images locally. The whole point of this build over a commercial unit is that the footage stays on hardware you control.

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.

TestWhat you should see
Open <code>http://&lt;ip&gt;/jpg</code>A single UXGA JPEG loads in a second or two.
Open <code>http://&lt;ip&gt;/stream</code>Live VGA video at roughly 10–15 fps with about half a second of latency.
Press the buttonTwo images appear in your store 1.5 s apart, an MQTT event is published, and the chime node sounds.
Walk past the PIROne motion image, no chime, and no further captures for the cooldown period.
Trigger during quiet hoursImage captured and stored, IR illuminator on, but no audible chime.
Check the first frame after a scene changeCorrectly exposed, not black or washed out — this verifies the settle logic.
Measure supply voltage at the board during a captureStays above 4.7 V. A dip below that is why the board reboots.
Check RSSI at the mounted locationBetter than −70 dBm. Worse and you will get intermittent stream failures no amount of firmware fixes.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.

A wall-mounted CCTV surveillance camera
A wall-mounted surveillance camera. This build targets the same job at a doorway, with the recordings kept on your own hardware. Photograph sourced from Wikimedia Commons — CCTV camera.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

"Brownout detector was triggered" and constant rebooting

Likely cause. The supply cannot deliver peak current — this accounts for the large majority of ESP32-CAM problems.

Fix. Feed 5 V from a supply rated at 1 A or more, use short thick wires, and fit a 1000 µF electrolytic capacitor at the board. Do not power from an FTDI adapter's 3.3 V pin. Disabling the brown-out detector in software hides the symptom and leaves you with corrupted frames instead.

Camera init fails with 0x105 or 0x20004

Likely cause. Wrong board selected, PSRAM disabled, or the camera ribbon is not seated.

Fix. Select AI Thinker ESP32-CAM and enable PSRAM. Then reseat the ribbon — lift the black retainer, insert the cable fully with contacts facing the board, and press the retainer down. A partially seated ribbon gives exactly this error.

Images are green, pink or heavily striped

Likely cause. XCLK too fast for the wiring, or a marginal supply.

Fix. Reduce xclk_freq_hz from 20 MHz to 10 MHz. If that fixes it, the parallel bus is marginal — usually a supply or a solder-joint problem on the module.

The board will not enter flash mode

Likely cause. IO0 not held low at reset, or FTDI at 5 V logic.

Fix. Bridge IO0 to GND before pressing reset, and hold it until the IDE prints "Connecting". Set the FTDI jumper to 3.3 V logic — 5 V on the UART pins damages the module.

Stream works on the bench, fails at the door

Likely cause. Wi-Fi signal. A masonry wall between the router and the door costs 10–20 dB.

Fix. Check RSSI in the boot log. Below −75 dBm you need an external antenna (move the zero-ohm resistor to the u.FL position) or a mesh node nearer the door. No firmware change fixes a link budget problem.

Captured images are always dark or always blown out

Likely cause. Auto-exposure has not converged, or the scene is strongly backlit.

Fix. Confirm the settle-and-discard loop is running. For backlight, raise set_ae_level and consider a fixed exposure with set_aec2 disabled and a manual set_aec_value chosen for the daylight case.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • Stream at VGA and capture at UXGA. Streaming full resolution gives about 2 fps and no benefit at a doorbell viewing distance.
  • Use CAMERA_GRAB_LATEST with two frame buffers — the combination keeps latency low without dropping to single buffering.
  • Keep the MJPEG handler free of any MQTT or file work; every millisecond in that loop is a frame you do not send.
  • Replace every delay() with a millis() 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_t where 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.
  • Profile before optimising — print micros() deltas around each stage and fix the slowest one first.

Safety Precautions

  • A camera pointed at a public footpath or a neighbour's property has legal implications in many countries. Angle it to cover your own threshold and mask the rest.
  • Mount the outdoor unit so no mains wiring is accessible, and use a proper IP-rated gland for the cable entry.
  • IR illuminator LEDs are invisible and still emit real optical power. Do not look into them at close range.
  • 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

  • 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.

  • Add on-device person detection with a small TFLite model so motion events only fire for people, not cats and headlights.
  • Add two-way audio with an INMP441 microphone and a small amplifier, streamed over a WebRTC or simple UDP path.
  • Add face recognition on the receiving server (not the ESP32) to label known visitors.
  • Integrate with the smart door lock project so a recognised visitor can be admitted from the notification.
  • Move to an ESP32-S3 with a better sensor for genuinely usable low-light performance.
  • 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

Can this record continuously like a commercial doorbell?

Not usefully. The ESP32-CAM has no H.264 encoder, so continuous recording means storing MJPEG at roughly 300 kB/s — around 25 GB a day. Event-based capture is not a compromise forced by the hardware so much as the right design for a doorbell: you want the moment someone arrived, not eight hours of an empty porch.

Why does everyone say the ESP32-CAM is unreliable?

Because almost every reliability report traces to power. The board has genuinely high peak current, a marginal on-board regulator, and no bulk capacitance. Give it a real 1 A supply and a 1000 µF capacitor and it becomes a well-behaved device. The second most common cause is Wi-Fi signal at a front door, which is a physics problem, not a board problem.

How good is night vision?

Poor without help. The OV2640 has a small sensor and no IR-cut filter removal on most modules. Adding IR illuminator LEDs helps if your module's IR filter has been removed (some "night vision" variants ship this way); on a standard module the IR filter blocks most of what the illuminator emits. Be realistic: this is a daytime-good, night-marginal camera.

Can I run it on battery?

Only with deep sleep between events, and then you lose the live stream and the PIR must be the wake source. A doorbell that takes four seconds to boot and connect before capturing will miss the visitor. Mains power, ideally from an existing doorbell transformer stepped to 5 V, is the right answer.

Why a separate chime node instead of a speaker on the doorbell?

Because the audible bell is the one function that must never depend on a phone, an app, or an internet connection — and putting the speaker outdoors means the visitor hears it and you may not. A ₹400 ESP32 with a buzzer inside the house subscribed to one MQTT topic solves it completely.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. ESP32 Camera Driver (esp32-camera) — API and configurationEspressif on GitHub
  2. OV2640 CMOS image sensor — datasheetOmniVision
  3. ESP32-CAM (AI Thinker) product specification and pin mapAI Thinker
  4. RFC 2046 — multipart media types (the basis of MJPEG streaming)IETF
  5. ESP-IDF HTTP Server componentEspressif
  6. Node-RED HTTP endpoints and file nodesNode-RED