Smart doorbell with ESP32-CAM: snap a photo and send it to Telegram
Commercial “smart” doorbells usually lock you into a proprietary app and the vendor’s cloud. With a sub-$5 ESP32-CAM board, a PIR motion sensor, and a free, unlimited Telegram bot, you can build your own alert system that snaps a photo and pushes it straight to your phone — with all the data under your own control.
How it works
- A PIR sensor detects motion (a change in infrared heat) near the door.
- The ESP32-CAM polls the PIR pin, and when it goes HIGH, it captures a JPEG frame from the camera.
- The ESP32-CAM connects to WiFi and pushes that frame to the Telegram Bot API via an HTTPS multipart POST to your chat.
- A cooldown window prevents flooding you with photos while someone lingers at the door.
Parts list
- ESP32-CAM board (the AI-Thinker module is the most common)
- A USB-to-serial adapter (FTDI or CP2102) for flashing — the ESP32-CAM has no onboard USB port
- PIR motion sensor (HC-SR501)
- Jumper wires, breadboard, a solid 5V supply (camera + WiFi draw enough current that the weak 3.3V rail on most FTDI adapters isn’t enough)
- A Telegram account and a bot created via @BotFather, plus your chat ID
Sample code
#include "esp_camera.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "camera_pins.h" // pin definitions for the AI-Thinker model
const char* WIFI_SSID = "your_wifi_name";
const char* WIFI_PASS = "your_wifi_password";
const String BOT_TOKEN = "123456789:AAExxxxxxxxxxxxxxxxxxxxxxxxxx";
const String CHAT_ID = "987654321";
const int PIR_PIN = 13;
unsigned long lastSent = 0;
const unsigned long COOLDOWN = 60000UL; // 60 seconds between sends
WiFiClientSecure client;
void setupCamera() {
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_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = FRAMESIZE_VGA; // 640x480, sharp enough without being too heavy
config.jpeg_quality = 12;
config.fb_count = 1;
esp_camera_init(&config);
}
void sendPhotoToTelegram() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) { Serial.println("Capture failed"); return; }
client.setInsecure(); // skip certificate validation to keep this simple
if (!client.connect("api.telegram.org", 443)) {
Serial.println("Could not connect to Telegram");
esp_camera_fb_return(fb);
return;
}
String boundary = "coderdiyBoundary";
String head = "--" + boundary + "\r\n"
"Content-Disposition: form-data; name=\"chat_id\"\r\n\r\n" + CHAT_ID + "\r\n"
"--" + boundary + "\r\n"
"Content-Disposition: form-data; name=\"photo\"; filename=\"door.jpg\"\r\n"
"Content-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--" + boundary + "--\r\n";
uint32_t contentLength = head.length() + fb->len + tail.length();
client.printf("POST /bot%s/sendPhoto HTTP/1.1\r\n", BOT_TOKEN.c_str());
client.println("Host: api.telegram.org");
client.println("Content-Type: multipart/form-data; boundary=" + boundary);
client.printf("Content-Length: %u\r\n\r\n", contentLength);
client.print(head);
client.write(fb->buf, fb->len);
client.print(tail);
esp_camera_fb_return(fb);
Serial.println("Photo sent to Telegram");
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
setupCamera();
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(300);
Serial.print(".");
}
Serial.println("\nWiFi connected");
}
void loop() {
if (digitalRead(PIR_PIN) == HIGH && millis() - lastSent > COOLDOWN) {
Serial.println("Motion detected!");
sendPhotoToTelegram();
lastSent = millis();
}
}
Get
BOT_TOKENfrom @BotFather after creating a new bot, and findCHAT_IDby messaging your bot once and then openinghttps://api.telegram.org/bot<TOKEN>/getUpdatesto read thechat.idfield.
Common pitfalls
- Forgetting to remove the GPIO0-to-GND wire after flashing: the ESP32-CAM enters flashing mode when GPIO0 is tied to GND. Leave that wire in place and the board will always boot into the bootloader instead of running your program — this is the most common reason beginners think their board is dead.
- Underpowered supply: camera + WiFi can spike past 300mA, and the weak 3.3V rail on most FTDI adapters can’t keep up, causing repeated brown-out resets. Use a separate, solid 5V supply into the board’s 5V pin instead.
- False PIR triggers from wind, sunlight changes, or insects flying past — tune the sensitivity trimmer on the PIR module and add a 30-60 second warm-up delay after power-up before reading the pin, since the sensor needs time to stabilize.
- Oversized
FRAMESIZEslows down capture and upload, which can cause connection timeouts — VGA (640x480) is a good balance of quality and speed.
Where to go from here
Add a physical push button so visitors can “ring” on purpose instead of relying only on PIR, or save photos to the board’s onboard SD card so you have a history to review even without network access. For a bigger upgrade, run lightweight face detection through Edge Impulse so you only get notified when an unfamiliar face shows up.
Comments