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.
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
| Setting | How it is used |
|---|---|
| Front-door visitor capture | The core case: know who called while you were out, with the image on your own storage. |
| Parcel delivery evidence | A timestamped image of the courier and where they left the parcel settles most delivery disputes. |
| Gate intercom for a compound | Combine with the smart door lock project so a recognised visitor can be admitted remotely. |
| Elderly care check-in | A motion event with an image confirms a carer arrived without any recording of the interior. |
| Workshop or store-room entry log | Cheap enough to put one on every door that matters. |
| Wildlife and pet monitoring | The 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹2,300 – ₹3,400 |
| Primary discipline | Smart Home |
| Reference platform | ESP32-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.
| Component | Key specification | Qty | Approx. 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 flash | 1 | ₹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 idle | 1 | ₹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 diameter | 1 | ₹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 % efficiency | 1 | ₹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 protection | 1 | ₹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 grid | 1 | ₹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 lugs | 1 | ₹260 |
| FTDI / CP2102 USB-serial adapter (3.3 V) | Programming only, not part of the finished unit | 1 | ₹250 |
| Doorbell push button, weatherproof | NO momentary, IP54, illuminated | 1 | ₹180 |
| 2.4 GHz external antenna + u.FL pigtail Only usable on the ESP32-CAM variant that has the u.FL connector. | 3 dBi, SMA | 1 | ₹220 |
| 1000 µF electrolytic + 100 nF ceramic Across the 5 V rail at the board. Not optional. | 10 V, low ESR | 1 | ₹40 |
| Second ESP32 + buzzer for the indoor chime | Any ESP32 or ESP8266 | 1 | ₹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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32-CAM (AI-Thinker) + OV2640 | ESP32-S chip, 4 MB PSRAM, 2 MP OV2640 camera, microSD slot, on-board LED flash | 5 V in / 3.3 V logic | UART (programmer required), SPI, I²C | Datasheet |
| HC-SR501 PIR motion sensor | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 4.5–20 V | Digital high on motion | Datasheet |
| Active piezo buzzer 5 V | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 3–5 V | Digital / PWM | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32-CAM (AI-Thinker) + OV2640 | 5 V in / 3.3 V logic | 220 | No USB-serial on board — needs an FTDI/CP2102 adapter to flash. |
| HC-SR501 PIR motion sensor | 4.5–20 V | 0.05 | Give it 60 s to settle after power-up or it fires false triggers. |
| Active piezo buzzer 5 V | 3–5 V | 30 | Active buzzers make tone on DC; passive ones need a PWM carrier. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
| HTTPClient bundled | REST 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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| Doorbell button | NO contact | GPIO 13 | Pull-up, active-low |
| HC-SR501 PIR | OUT | GPIO 12 | High on motion |
| OV2640 camera | Ribbon | On-board | Fixed camera bus |
| Chime relay / buzzer | IN | GPIO 2 | Local audible feedback |
| IR illuminator LEDs | Gate | GPIO 15 | MOSFET-driven, night only |
| On-board flash LED | — | GPIO 4 | Very 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.
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.
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
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
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.
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.
/* ═══════════════════════════════════════════════════════════════
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);
}
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_URLto 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_MSupward if a road or a tree is in frame. Sixty seconds is a starting point, not an answer. - Adjust
set_vflipandset_hmirrorto 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.
Frame the shot
Mount at about 1.4 m and tilt down roughly 15°. Open
/streamon 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.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 considers->set_wb_mode(s, 1)for a fixed sunny white balance.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
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 / endpoint | Direction | Payload |
|---|---|---|
home/doorbell/doorbell-front/event | device → broker | JSON: event, ts, bytes, stored, rssi |
home/doorbell/chime | device → broker | "ring" — consumed by the indoor chime node |
home/doorbell/doorbell-front/status | device → 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.
| Test | What you should see |
|---|---|
| Open <code>http://<ip>/jpg</code> | A single UXGA JPEG loads in a second or two. |
| Open <code>http://<ip>/stream</code> | Live VGA video at roughly 10–15 fps with about half a second of latency. |
| Press the button | Two images appear in your store 1.5 s apart, an MQTT event is published, and the chime node sounds. |
| Walk past the PIR | One motion image, no chime, and no further captures for the cooldown period. |
| Trigger during quiet hours | Image captured and stored, IR illuminator on, but no audible chime. |
| Check the first frame after a scene change | Correctly exposed, not black or washed out — this verifies the settle logic. |
| Measure supply voltage at the board during a capture | Stays above 4.7 V. A dip below that is why the board reboots. |
| Check RSSI at the mounted location | Better 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.
Troubleshooting: Common Errors & Fixes
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_LATESTwith 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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- ESP32 Camera Driver (esp32-camera) — API and configurationEspressif on GitHub
- OV2640 CMOS image sensor — datasheetOmniVision
- ESP32-CAM (AI Thinker) product specification and pin mapAI Thinker
- RFC 2046 — multipart media types (the basis of MJPEG streaming)IETF
- ESP-IDF HTTP Server componentEspressif
- Node-RED HTTP endpoints and file nodesNode-RED