ESP32-CAM security camera that messages your phone when something moves
- Build Logs
- 29 Jul, 2026
The ESP32-CAM is the best value in hobby electronics: a 32-bit dual-core WiFi microcontroller with a camera, for ₹629. It is also the most infuriating board most people ever buy, because it has no USB port, no reset button worth the name, and a habit of dying at boot with a message that sounds terminal.
None of that is a fault. All of it is documented behaviour that nobody tells you about first. This build gets past every bit of it and ends with a camera that photographs whatever set it off and pushes the picture to your phone.
What you will need
| Part | Qty | Approx. |
|---|---|---|
| ESP32-CAM with OV3660 3MP Camera | 1 | ₹629 |
| USB-TTL Programmer for ESP32-CAM | 1 | ₹142 |
| PIR Motion Sensor HC-SR501 | 1 | ₹78 |
Roughly ₹849. Buy the programmer board. You can flash an ESP32-CAM with a loose FTDI adapter and a jumper wire, and you will spend an evening on it.
Three things that will stop you before any code runs
GPIO0 must be grounded to flash. The ESP32 checks GPIO0 at boot: high means run your program, low means enter the bootloader. To upload, connect GPIO0 to GND, press reset, upload, then remove the jumper and reset again. Leave it grounded and the board sits in the bootloader looking dead. The programmer board has a switch for this; a bare FTDI needs the wire.
"Brownout detector was triggered". This is the message that convinces people their board is faulty. It is not — it means the 3.3V rail sagged. The ESP32-CAM pulls around 180 mA idle and spikes past 300 mA the moment the camera initialises and the WiFi radio transmits together. A laptop USB port through a thin cable cannot hold that up.
Power it from the 5V pin, not 3.3V, with a supply good for at least 500 mA, and use a short thick cable. If you must run from the programmer, plug it into a powered hub rather than a keyboard port.
The camera and the SD card share pins. The OV3660 uses most of the available GPIO. If you also mount the SD card in 4-bit mode, you lose several pins and the flash LED. For this build we do not use the SD card at all — the photo goes straight out over WiFi, which sidesteps the conflict entirely.
Wiring
| From | To | Note |
|---|---|---|
| Programmer 5V / GND | ESP32-CAM 5V / GND | 5V, not 3.3V |
| Programmer TX / RX | ESP32-CAM U0R / U0T | crossed |
| GPIO0 | GND | only while flashing |
| PIR VCC | 5V | HC-SR501 needs 5V |
| PIR GND | GND | |
| PIR OUT | GPIO13 | 3.3V logic out, safe |
Setting up the PIR properly
The HC-SR501 has two orange trimpots and a jumper, and out of the box all three are wrong for this job.
- Sensitivity (one pot) sets range, roughly 3–7 m. Start it at minimum. A PIR at full sensitivity pointed across a room will trigger on a curtain moving in a fan.
- Time delay (the other pot) is how long OUT stays high after a trigger, from about 3 seconds to 5 minutes. Set it near minimum — we handle the cooldown in software, where we can be smarter about it.
- The jumper selects single or repeat trigger. Set it to repeat (H). On single trigger the sensor ignores continued motion during its delay, so a person walking through gives you one photo of an empty corridor after they have gone.
PIRs also need roughly 60 seconds after power-on to stabilise. Trigger it during that window and you get false positives. That is why the sketch below waits before arming.
The sketch
Install the ESP32 boards package, select AI Thinker ESP32-CAM as the board, and create a Telegram bot by messaging @BotFather — it gives you a token. Message your new bot once, then visit api.telegram.org/bot<TOKEN>/getUpdates to find your chat ID.
#include "esp_camera.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
const char* SSID = "your-network";
const char* PASS = "your-password";
const char* BOT = "123456:ABC-your-bot-token";
const char* CHAT_ID = "987654321";
#define PIR_PIN 13
const unsigned long COOLDOWN_MS = 30000; // don't spam yourself
unsigned long lastAlert = 0;
// 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
void startCamera() {
camera_config_t c;
c.ledc_channel = LEDC_CHANNEL_0;
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = Y2_GPIO_NUM; c.pin_d1 = Y3_GPIO_NUM;
c.pin_d2 = Y4_GPIO_NUM; c.pin_d3 = Y5_GPIO_NUM;
c.pin_d4 = Y6_GPIO_NUM; c.pin_d5 = Y7_GPIO_NUM;
c.pin_d6 = Y8_GPIO_NUM; c.pin_d7 = Y9_GPIO_NUM;
c.pin_xclk = XCLK_GPIO_NUM; c.pin_pclk = PCLK_GPIO_NUM;
c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
c.pin_sccb_sda = SIOD_GPIO_NUM;
c.pin_sccb_scl = SIOC_GPIO_NUM;
c.pin_pwdn = PWDN_GPIO_NUM; c.pin_reset = RESET_GPIO_NUM;
c.xclk_freq_hz = 20000000;
c.pixel_format = PIXFORMAT_JPEG;
// PSRAM lets us use a bigger frame and two buffers.
if (psramFound()) {
c.frame_size = FRAMESIZE_SVGA; // 800x600 is plenty for an alert
c.jpeg_quality = 12; // lower number = better = bigger
c.fb_count = 2;
} else {
c.frame_size = FRAMESIZE_CIF;
c.jpeg_quality = 15;
c.fb_count = 1;
}
if (esp_camera_init(&c) != ESP_OK) {
Serial.println("Camera init failed - check the ribbon seating");
delay(2000);
ESP.restart();
}
}
void setup() {
// Disable the brownout detector. This is a workaround, NOT a fix --
// if the rail is genuinely sagging you still need a better supply.
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
startCamera();
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print('.'); }
Serial.println(WiFi.localIP());
Serial.println("PIR warming up (60s)...");
delay(60000); // PIR settling time
Serial.println("Armed");
}
void loop() {
if (digitalRead(PIR_PIN) != HIGH) return;
if (millis() - lastAlert < COOLDOWN_MS) return;
lastAlert = millis();
// Throw away one frame. The first buffer after an idle period is
// often stale or badly exposed because auto-exposure has not settled.
camera_fb_t* fb = esp_camera_fb_get();
if (fb) esp_camera_fb_return(fb);
delay(150);
fb = esp_camera_fb_get();
if (!fb) { Serial.println("capture failed"); return; }
sendToTelegram(fb->buf, fb->len);
esp_camera_fb_return(fb); // ALWAYS return the buffer or you leak
}
Two details worth calling out.
Every esp_camera_fb_get() must be matched by esp_camera_fb_return(). Frame buffers are a small fixed pool; forget to return one and after a couple of captures the camera simply stops responding. It looks like a hardware failure and it is a leak.
The discarded first frame. The sensor's auto-exposure and auto-white-balance need a frame or two to adapt after sitting idle. Send the very first one and half your alerts are a washed-out white rectangle.
Sending the photo
void sendToTelegram(uint8_t* buf, size_t len) {
WiFiClientSecure client;
client.setInsecure(); // skip cert validation
if (!client.connect("api.telegram.org", 443)) return;
String head = "--Robodium\r\nContent-Disposition: form-data;"
" name=\"chat_id\";\r\n\r\n" + String(CHAT_ID) +
"\r\n--Robodium\r\nContent-Disposition: form-data;"
" name=\"photo\"; filename=\"alert.jpg\"\r\n"
"Content-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--Robodium--\r\n";
client.println("POST /bot" + String(BOT) + "/sendPhoto HTTP/1.1");
client.println("Host: api.telegram.org");
client.println("Content-Length: " + String(head.length() + len + tail.length()));
client.println("Content-Type: multipart/form-data; boundary=Robodium");
client.println();
client.print(head);
// Send in chunks. Handing the whole buffer to write() at once
// can overrun the TLS layer's own buffer on a large frame.
for (size_t i = 0; i < len; i += 1024) {
size_t chunk = (i + 1024 < len) ? 1024 : len - i;
client.write(buf + i, chunk);
}
client.print(tail);
client.stop();
}
Things that go wrong
"Brownout detector was triggered", repeatedly. Power. Disabling the detector in software hides the symptom; if it still misbehaves, the rail really is sagging.
Upload fails with "Failed to connect". GPIO0 not grounded, or you did not press reset after grounding it.
Camera init failed. Nine times in ten the ribbon connector is not fully seated. Lift the black latch, push the ribbon fully home, close the latch.
Works for two photos then stops. Leaked frame buffers.
PIR triggers all night. Sensitivity too high, or it is pointed at something warm that cycles — a fridge compressor, an AC vent, direct sun moving across a wall. PIRs detect changes in infrared, so anything that heats and cools looks like a person.
Photos are dark or blurry. The OV3660 has a small sensor and no flash worth the name. It needs real ambient light; this is a daytime camera unless you add IR illumination.
Where to take it next
Add a relay and switch a light on when motion is detected — the wiring and the flyback warning are in our RFID lock build. Or drop the PIR and stream MJPEG to a browser instead, which turns the same hardware into a monitoring camera rather than an alerting one.
Ask our engineers if you hit the brownout loop — it is the most common support question we get on this board, and it is almost always the cable.