Chuông cửa ESP32-CAM gửi ảnh qua Telegram: bấm chuông là chụp, đèn flash ban đêm, lưu thẻ nhớ
Làm chuông cửa bằng ESP32-CAM: bấm nút thì chụp ảnh gửi về điện thoại qua Telegram kèm giờ, tự bật đèn flash ban đêm, lưu ảnh vào thẻ nhớ, nhắn /anh để xem cửa bất cứ lúc nào.
Chuông cửa có camera giúp biết ai đang đứng trước cửa ngay cả khi không ở nhà. Bài này dùng mạch ESP32-CAM (AI Thinker) làm chuông cửa: khách bấm nút thì ESP32-CAM chụp ảnh, gửi về điện thoại qua Telegram kèm giờ bấm chuông, đồng thời lưu bản sao vào thẻ nhớ. Buổi tối đèn flash trên bo tự bật khi chụp. Không cần máy chủ riêng hay dịch vụ trả phí: ESP32-CAM gọi thẳng Bot API của Telegram.
1. Chuông cửa hoạt động thế nào

- Nút chuông nối GPIO13 xuống GND. Chương trình dùng ngắt để không bỏ sót lần bấm trong lúc ESP32-CAM đang bận hỏi tin nhắn qua mạng.
- Chụp ảnh 800 × 600 điểm, nén JPEG ngay trong camera OV2640, mỗi ảnh khoảng vài chục KB.
- Đèn flash: giờ lấy từ Internet (NTP) theo múi giờ Việt Nam; từ 18 giờ tới 6 giờ sáng thì bật đèn lúc chụp.
- Gửi ảnh bằng phương thức
sendPhotocủa Telegram; bấm liên tục thì 10 giây mới gửi một ảnh. - Lệnh từ điện thoại: nhắn
/anhđể xem cửa bất cứ lúc nào,/anhdenđể chụp có đèn,/trangthaiđể xem tình trạng.
2. Chuẩn bị
- Mạch ESP32-CAM AI Thinker (có PSRAM, camera OV2640) và đế nạp ESP32-CAM-MB, hoặc mạch chuyển USB-TTL
- Dây USB để nạp chương trình
- Nút nhấn (nút chuông cửa loại thường đóng mở bằng tiếp điểm), tụ 100 nF, điện trở 10 kΩ
- Nguồn 5 V từ 2 A. Nếu đã có adapter 12 V ở cổng thì hạ xuống 5 V bằng mạch hạ áp LM2596.
- Thẻ nhớ micro SD (không bắt buộc), định dạng FAT32
- Breadboard và dây cắm để thử trên bàn
- Thư viện ArduinoJson (bản 7) cài qua Library Manager; camera, thẻ nhớ, HTTPS có sẵn trong gói esp32.
3. Nối dây

- ESP32-CAM có ít chân trống. Thẻ nhớ chạy chế độ 1 bit (chỉ dùng IO14, IO15, IO2) nên IO4, IO12, IO13 được để trống. IO4 nối sẵn đèn flash; IO12 là chân cấu hình điện áp flash lúc khởi động, nối nút nhấn có điện trở kéo lên vào đây có thể làm mạch không khởi động. Vì vậy nút chuông dùng IO13.
- Nguồn là chỗ hay hỏng nhất: lúc phát Wi-Fi và bật đèn flash, ESP32-CAM kéo dòng tăng vọt. Nguồn yếu hoặc dây mảnh dài sẽ làm mạch tự khởi động lại (Serial báo "Brownout detector was triggered"). Dùng nguồn 5 V từ 2 A, dây ngắn, thêm tụ hóa 470–1000 µF ở chân 5V nếu cần.
- Dây nút chuông kéo ra ngoài cửa dài vài mét dễ bắt nhiễu: thêm tụ 100 nF song song với nút và điện trở 10 kΩ từ IO13 lên 3V3. Không đưa 5 V vào IO13.
4. Tạo bot Telegram
- Trong Telegram, nhắn cho tài khoản BotFather lệnh
/newbot, đặt tên cho bot. BotFather trả về token dạng123456789:ABC…: chép vàoBOT_TOKEN. - Nạp chương trình lần đầu với
CHAT_ID = 0, mở Serial Monitor. - Mở cuộc trò chuyện với bot vừa tạo, nhắn
/start. Serial in raTin tu chat id … (bo qua): chép số đó vàoCHAT_IDrồi nạp lại.
Token là chìa khóa điều khiển bot: ai có token đều gửi tin thay bot được. Không đăng token lên mạng, không chụp màn hình có token. Chương trình chỉ làm theo tin nhắn từ đúng CHAT_ID, nên người lạ tìm thấy bot cũng không ra lệnh chụp ảnh được. Muốn cả nhà cùng nhận ảnh, tạo một nhóm Telegram, thêm bot vào nhóm và dùng chat id của nhóm (số âm).
5. Chương trình
Trong Arduino IDE chọn bo AI Thinker ESP32-CAM. Khi nạp bằng mạch USB-TTL thì nối IO0 xuống GND, bấm nút reset, nạp xong tháo IO0 ra rồi reset lại; đế ESP32-CAM-MB làm việc này tự động.
// PYWORLD.VN - Chuông cửa ESP32-CAM: bấm chuông thì chụp ảnh, gửi qua Telegram kèm giờ, lưu bản sao vào thẻ nhớ
// PYWORLD.VN - Bo AI Thinker ESP32-CAM; nút chuông nối GPIO13 xuống GND; từ 18 giờ tới 6 giờ bật đèn flash GPIO4 khi chụp
// PYWORLD.VN - Lệnh Telegram: /anh chụp ngay, /anhden chụp có đèn, /trangthai; đọc tin nhắn bằng ArduinoJson 7
#include "esp_camera.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "FS.h"
#include "SD_MMC.h"
#include <time.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const char *BOT_TOKEN = "123456789:MA_TOKEN_CUA_BOT"; // lấy từ BotFather
const long long CHAT_ID = 0; // để 0 lúc đầu: Serial in chat id của người nhắn, chép vào đây rồi nạp lại
const int CHAN_NUT = 13; // nút chuông nối xuống GND
const int CHAN_DEN_FLASH = 4;
const int CHAN_LED_DO = 33; // LED đỏ nhỏ trên bo, sáng khi mức thấp
const uint32_t CHO_GIUA_HAI_LAN_MS = 10000; // bấm liên tục thì 10 giây mới gửi một ảnh
const uint32_t CHU_KY_HOI_MS = 3000;
const int GIO_BAT_DEN = 18, GIO_TAT_DEN = 6;
// Chân camera OV2640 trên mạch AI Thinker ESP32-CAM
#define CHAN_PWDN 32
#define CHAN_RESET -1
#define CHAN_XCLK 0
#define CHAN_SIOD 26
#define CHAN_SIOC 27
#define CHAN_Y9 35
#define CHAN_Y8 34
#define CHAN_Y7 39
#define CHAN_Y6 36
#define CHAN_Y5 21
#define CHAN_Y4 19
#define CHAN_Y3 18
#define CHAN_Y2 5
#define CHAN_VSYNC 25
#define CHAN_HREF 23
#define CHAN_PCLK 22
WiFiClientSecure tls;
bool coTheNho = false, daBaoKhoiDong = false;
uint32_t soAnh = 0, lanHoi = 0, lanBao = 0, lanChuong = 0;
int soLanChuong = 0;
long long updateTiep = 0;
volatile bool coBam = false;
volatile uint32_t lucBam = 0;
bool khoiDongCamera() {
camera_config_t c = {};
c.pin_pwdn = CHAN_PWDN;
c.pin_reset = CHAN_RESET;
c.pin_xclk = CHAN_XCLK;
c.pin_sccb_sda = CHAN_SIOD;
c.pin_sccb_scl = CHAN_SIOC;
c.pin_d7 = CHAN_Y9;
c.pin_d6 = CHAN_Y8;
c.pin_d5 = CHAN_Y7;
c.pin_d4 = CHAN_Y6;
c.pin_d3 = CHAN_Y5;
c.pin_d2 = CHAN_Y4;
c.pin_d1 = CHAN_Y3;
c.pin_d0 = CHAN_Y2;
c.pin_vsync = CHAN_VSYNC;
c.pin_href = CHAN_HREF;
c.pin_pclk = CHAN_PCLK;
c.xclk_freq_hz = 20000000;
c.ledc_timer = LEDC_TIMER_0;
c.ledc_channel = LEDC_CHANNEL_0;
c.pixel_format = PIXFORMAT_JPEG;
c.frame_size = FRAMESIZE_SVGA; // 800 x 600: đủ nhìn mặt người, ảnh khoảng vài chục KB
c.jpeg_quality = 12;
c.fb_count = psramFound() ? 2 : 1;
c.fb_location = psramFound() ? CAMERA_FB_IN_PSRAM : CAMERA_FB_IN_DRAM;
c.grab_mode = CAMERA_GRAB_LATEST;
esp_err_t loi = esp_camera_init(&c);
if (loi != ESP_OK) {
Serial.printf("Loi khoi dong camera 0x%x: kiem tra cap camera, nguon 5V\n", loi);
return false;
}
return true;
}
uint32_t soAnhLonNhat() {
uint32_t lonNhat = 0;
File thuMuc = SD_MMC.open("/");
for (File f = thuMuc.openNextFile(); f; f = thuMuc.openNextFile()) {
unsigned so;
if (sscanf(f.name(), "chuong_%u.jpg", &so) == 1 && so > lonNhat) lonNhat = so;
f.close();
}
return lonNhat;
}
// Ngắt nút chuông: chỉ đặt cờ, bỏ qua các lần dội trong 200 ms.
void IRAM_ATTR ngatNut() {
uint32_t bay = millis();
if (bay - lucBam > 200) {
lucBam = bay;
coBam = true;
}
}
bool laBanDem(const struct tm &t) {
return t.tm_hour >= GIO_BAT_DEN || t.tm_hour < GIO_TAT_DEN;
}
camera_fb_t *chupAnh(bool den) {
if (den) {
digitalWrite(CHAN_DEN_FLASH, HIGH);
delay(150); // chờ cảm biến chỉnh lại độ sáng
}
camera_fb_t *cu = esp_camera_fb_get(); // bỏ khung chụp trước lúc bấm
if (cu) esp_camera_fb_return(cu);
camera_fb_t *khung = esp_camera_fb_get();
digitalWrite(CHAN_DEN_FLASH, LOW);
return khung;
}
void luuThe(camera_fb_t *khung) {
if (!coTheNho) return;
char ten[24];
snprintf(ten, sizeof(ten), "/chuong_%04lu.jpg", (unsigned long)++soAnh);
File f = SD_MMC.open(ten, FILE_WRITE);
bool duoc = f && f.write(khung->buf, khung->len) == khung->len;
if (f) f.close();
Serial.printf(duoc ? "Da luu %s\n" : "Loi ghi %s\n", ten);
}
bool guiTin(const String &noiDung) {
if (WiFi.status() != WL_CONNECTED || CHAT_ID == 0) return false;
HTTPClient http;
http.begin(tls, String("https://api.telegram.org/bot") + BOT_TOKEN + "/sendMessage");
http.addHeader("Content-Type", "application/json");
JsonDocument doc;
doc["chat_id"] = CHAT_ID;
doc["text"] = noiDung;
String than;
serializeJson(doc, than);
int ma = http.POST(than);
http.end();
Serial.printf("Gui tin Telegram: HTTP %d\n", ma);
return ma == 200;
}
// Gửi ảnh bằng sendPhoto: thân yêu cầu dạng multipart/form-data gồm chat_id, caption và file ảnh.
bool guiAnh(camera_fb_t *khung, const String &chuThich) {
if (WiFi.status() != WL_CONNECTED || CHAT_ID == 0) return false;
WiFiClientSecure k;
k.setInsecure();
k.setTimeout(20000);
if (!k.connect("api.telegram.org", 443)) {
Serial.println("Khong ket noi duoc api.telegram.org");
return false;
}
const char *BIEN = "PyworldBienAnh";
char chat[24];
snprintf(chat, sizeof(chat), "%lld", CHAT_ID);
String dau = String("--") + BIEN + "\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n" + chat + "\r\n"
+ "--" + BIEN + "\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\n" + chuThich + "\r\n"
+ "--" + BIEN + "\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"chuong_cua.jpg\"\r\n"
+ "Content-Type: image/jpeg\r\n\r\n";
String cuoi = String("\r\n--") + BIEN + "--\r\n";
size_t tong = dau.length() + khung->len + cuoi.length();
k.printf("POST /bot%s/sendPhoto HTTP/1.1\r\nHost: api.telegram.org\r\n", BOT_TOKEN);
k.printf("Content-Type: multipart/form-data; boundary=%s\r\nContent-Length: %u\r\nConnection: close\r\n\r\n", BIEN, (unsigned)tong);
k.print(dau);
for (size_t i = 0; i < khung->len; i += 2048) k.write(khung->buf + i, min((size_t)2048, khung->len - i));
k.print(cuoi);
String dong = k.readStringUntil('\n'); // ví dụ "HTTP/1.1 200 OK"
k.stop();
int ma = dong.length() > 12 ? dong.substring(9, 12).toInt() : -1;
Serial.printf("Gui anh %u byte: HTTP %d\n", (unsigned)khung->len, ma);
return ma == 200;
}
void chupVaGui(bool den, const String &chuThich) {
digitalWrite(CHAN_LED_DO, LOW);
camera_fb_t *khung = chupAnh(den);
if (!khung) {
Serial.println("Chup anh loi");
guiTin(chuThich + " (chụp ảnh lỗi)");
} else {
luuThe(khung);
if (!guiAnh(khung, chuThich)) guiTin(chuThich + " (gửi ảnh lỗi)");
esp_camera_fb_return(khung);
}
digitalWrite(CHAN_LED_DO, HIGH);
}
void baoChuong() {
struct tm t;
bool coGio = getLocalTime(&t, 0);
bool den = coGio && laBanDem(t);
char gio[24] = "";
if (coGio) strftime(gio, sizeof(gio), "%H:%M:%S %d/%m/%Y", &t);
soLanChuong++;
Serial.printf("Co nguoi bam chuong (lan %d) %s%s\n", soLanChuong, gio, den ? ", bat den" : "");
chupVaGui(den, String("Có người bấm chuông") + (coGio ? String(" lúc ") + gio : String("")));
}
void xuLyLenh(String lenh) {
lenh.trim();
lenh.toLowerCase();
Serial.printf("Lenh: %s\n", lenh.c_str());
if (lenh == "/anh" || lenh == "/anhden") {
chupVaGui(lenh == "/anhden", "Ảnh chụp theo yêu cầu");
} else if (lenh == "/trangthai") {
String s = "Chuông cửa đang chạy " + String(millis() / 60000) + " phút\n";
s += "Số lần bấm chuông từ lúc chạy: " + String(soLanChuong) + "\n";
s += "Thẻ nhớ: " + String(coTheNho ? "có, đã lưu " + String(soAnh) + " ảnh" : "không có") + "\n";
s += "Wi-Fi: " + String(WiFi.RSSI()) + " dBm";
guiTin(s);
} else {
guiTin("Lệnh dùng được:\n/anh: chụp ảnh ngay\n/anhden: chụp có bật đèn\n/trangthai: xem trạng thái");
}
}
void docTinMoi() {
HTTPClient http;
char so[24];
snprintf(so, sizeof(so), "%lld", updateTiep);
http.begin(tls, String("https://api.telegram.org/bot") + BOT_TOKEN + "/getUpdates?timeout=0&limit=5&offset=" + so);
int ma = http.GET();
if (ma != 200) {
Serial.printf("getUpdates loi: %d\n", ma);
http.end();
return;
}
String than = http.getString();
http.end();
JsonDocument doc;
if (deserializeJson(doc, than)) return;
for (JsonObject u : doc["result"].as<JsonArray>()) {
updateTiep = u["update_id"].as<long long>() + 1;
long long chat = u["message"]["chat"]["id"].as<long long>();
const char *chu = u["message"]["text"] | "";
if (CHAT_ID == 0 || chat != CHAT_ID) {
Serial.printf("Tin tu chat id %lld (bo qua): %s\n", chat, chu);
continue;
}
xuLyLenh(String(chu));
}
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_DEN_FLASH, OUTPUT);
digitalWrite(CHAN_DEN_FLASH, LOW);
pinMode(CHAN_LED_DO, OUTPUT);
digitalWrite(CHAN_LED_DO, HIGH);
pinMode(CHAN_NUT, INPUT_PULLUP);
if (!khoiDongCamera()) return;
coTheNho = SD_MMC.begin("/sdcard", true); // chế độ 1 bit: GPIO4 (flash) và GPIO13 (nút) không dính tới thẻ
if (coTheNho) soAnh = soAnhLonNhat();
Serial.println(coTheNho ? "Da gan the nho" : "Khong thay the nho (van gui anh duoc)");
attachInterrupt(digitalPinToInterrupt(CHAN_NUT), ngatNut, FALLING);
tls.setInsecure(); // có mã hóa nhưng không kiểm tra chứng chỉ (như bài Telegram)
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
configTzTime("ICT-7", "pool.ntp.org", "time.google.com"); // giờ Việt Nam UTC+7
Serial.println("Dang ket noi Wi-Fi...");
}
void loop() {
if (coBam) {
coBam = false;
if (lanChuong && millis() - lanChuong < CHO_GIUA_HAI_LAN_MS) {
Serial.println("Bam lai trong 10 giay: bo qua");
} else {
lanChuong = millis();
baoChuong();
}
}
if (WiFi.status() != WL_CONNECTED) return;
if (!daBaoKhoiDong && (lanBao == 0 || millis() - lanBao > 10000)) {
lanBao = millis();
Serial.printf("Wi-Fi OK, IP %s\n", WiFi.localIP().toString().c_str());
if (CHAT_ID == 0) Serial.println("Chua co CHAT_ID: nhan /start cho bot roi xem chat id in ra o day");
daBaoKhoiDong = guiTin("Chuông cửa đã sẵn sàng.") || CHAT_ID == 0;
}
if (millis() - lanHoi >= CHU_KY_HOI_MS) {
lanHoi = millis();
docTinMoi();
}
}
Giải thích
ngatNut()chạy ngay khi IO13 xuống thấp, chỉ ghi lại thời điểm và đặt cờcoBam; các lần dội trong 200 ms bị bỏ qua. Mọi việc nặng (chụp, gửi) làm trongloop(), không làm trong hàm ngắt.chupAnh()bật đèn trước 150 ms để cảm biến kịp chỉnh độ sáng, bỏ một khung cũ còn trong bộ đệm, rồi lấy khung mới.guiAnh()mở kết nối HTTPS tớiapi.telegram.orgvà tự ghép yêu cầumultipart/form-data: phầnchat_id, phầncaptionvà phần file ảnh. Ảnh gửi từng đoạn 2048 byte thẳng từ bộ đệm camera.docTinMoi()hỏi tin nhắn mới mỗi 3 giây bằnggetUpdates, giống bài Telegram cảnh báo trong loạt ESP32 với điện thoại;offsettăng dần để mỗi tin chỉ xử lý một lần.tls.setInsecure(): kết nối vẫn mã hóa nhưng không kiểm tra chứng chỉ máy chủ. Cách này đơn giản và đủ cho chuông cửa; muốn chặt hơn thì nạp chứng chỉ gốc của máy chủ Telegram bằngsetCACert().

6. Kết quả


Từ lúc bấm chuông tới khi điện thoại báo tin thường mất vài giây, tùy tốc độ mạng và độ lớn ảnh. Muốn nhanh hơn thì giảm cỡ ảnh xuống FRAMESIZE_VGA (640 × 480) hoặc tăng jpeg_quality (số lớn hơn là nén mạnh hơn).
7. Lắp đặt
- Đặt ESP32-CAM trong hộp kín có ô kính trước ống kính, che mưa nắng; hơi nước đọng trên kính làm ảnh mờ.
- Kiểm tra sóng Wi-Fi ở cổng bằng lệnh
/trangthai: dưới khoảng −75 dBm thì gửi ảnh chậm, hay lỗi. Bản ESP32-CAM có đầu nối anten ngoài giúp ích ở chỗ xa bộ phát. - Hướng camera vào khu vực trước cửa nhà mình, tránh quay sang nhà hàng xóm hay lòng đường.
- Chuông cửa này báo qua điện thoại; muốn có tiếng chuông trong nhà thì giữ chuông điện cũ song song, hoặc làm thêm một mạch ESP32 trong nhà nhận tin và phát tiếng.
8. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "Loi khoi dong camera 0x105" hoặc 0x20001 | Cáp camera cắm lỏng, chọn sai bo, nguồn yếu | Mở khóa đầu nối, cắm lại cáp; chọn đúng AI Thinker ESP32-CAM |
| Mạch tự khởi động lại khi gửi ảnh | Sụt áp nguồn (brownout) | Nguồn 5 V từ 2 A, dây ngắn, thêm tụ hóa ở chân 5V |
| Serial báo "Gui anh … HTTP 400" | Sai CHAT_ID | Lấy lại chat id bằng cách nhắn /start |
| "HTTP 401" hoặc 404 | Sai token | Chép lại token từ BotFather, không thừa dấu cách |
| Bấm chuông không có phản ứng | Nút nối nhầm chân, dây dài bị nhiễu | Đo IO13 xuống 0 V khi bấm; thêm tụ 100 nF và điện trở 10 kΩ |
| Ban đêm ảnh tối đen | Chưa lấy được giờ từ Internet nên không bật đèn | Kiểm tra Wi-Fi; nhắn /anhden để chụp có đèn |
| Ảnh có sọc hoặc ám màu | Ảnh đầu tiên sau khi bật camera chưa cân bằng sáng | Bình thường ở ảnh đầu; các ảnh sau sẽ ổn |
9. Xem thêm
- LM2596 là gì: cách chỉnh 5 V cho mạch dùng adapter 12 V
- Bài ESP32-CAM trong cùng loạt mô-đun: xem video trên trình duyệt, chụp ảnh, lưu thẻ nhớ.
- Bài ESP32 gửi cảnh báo qua Telegram trong loạt ESP32 với điện thoại: tạo bot, lấy chat id, nhận lệnh.
Xem thêm