Своими рукамипрактическая инструкция источник: Фермозавр

Кто прошёл мимо? Собираем камеру-ловушку на ESP32-CAM

Редакция Фермозавра·9 апреля 2026 г.·6 мин чтения

PIR замечает движение, камера делает снимок, microSD хранит его, а Telegram показывает результат. Соберём первый кадр и проверим камеру на своём участке.

Кто прошёл мимо? Собираем камеру-ловушку на ESP32-CAM

У прохода на участок, рядом с оборудованием или возле загона хочется увидеть, что происходило в ваше отсутствие. Соберём лабораторную камеру-ловушку: PIR подаёт сигнал, AI Thinker ESP32-CAM сохраняет JPEG на microSD и отправляет его в Telegram. Начнём со съёмки на столе, затем подберём место и проверим реальные события.

Готовим камеру, датчик движения и карту

Понадобятся AI Thinker ESP32-CAM с модулем OV2640, PIR-модуль с выходом 3,3 В, microSD, стабильное питание и подходящий USB-UART для прошивки. У PIR, например HC-SR501, проверьте питание и уровни по документации конкретного изделия: датчик должен оставаться включённым во время сна ESP32.

PIR OUT → GPIO13; PIR GND → GND
PIR питание → источник по документации модуля
microSD → штатный слот, только 1-битный режим
Питание ESP32-CAM → вход платы по её документации

Карта использует GPIO14/CLK, GPIO15/CMD и GPIO2/D0. GPIO13 освобождается от функции D3, но остаётся физически связанным со слотом; проверьте совместную работу карты и PIR. Не оставляйте вход неопределённым при отключённом датчике: необходимость и номинал подтяжки зависят от схемы.

GPIO14 занят картой, GPIO12 участвует в загрузке, а белая вспышка GPIO4 в этом примере выключена. Сначала добейтесь уверенного дневного кадра. Ночную подсветку удобнее добавлять, когда основная цепочка уже работает.

Прошиваем и ждём первый снимок

Проверены Arduino-ESP32 3.3.12, плата AI Thinker ESP32-CAM и ArduinoJson 7.4.3. Создайте бота через BotFather, отправьте ему /start и получите chat.id через getUpdates. Заполните SSID, PASSWORD, BOT_TOKEN и CHAT_ID; токен храните как пароль.

Для первого кадра выбран JPEG QVGA — 320 × 240 точек. Этого удобно начать проверку карты и отправки. Затем можно поднимать разрешение, сопоставляя качество кадра, память, работу PSRAM и время передачи.

#include <Arduino.h>
#include "esp_camera.h"
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <SD_MMC.h>
#include <Preferences.h>
#include "driver/rtc_io.h"
#include <time.h>

constexpr char SSID[]="CHANGE_ME", PASSWORD[]="CHANGE_ME";
constexpr char BOT_TOKEN[]="CHANGE_ME", CHAT_ID[]="CHANGE_ME";
constexpr int PIR_PIN=13, PWDN_PIN=32;
constexpr uint32_t TIMELAPSE_SECONDS=0; // 21600 для интервала около 6 часов.
constexpr uint32_t PIR_RECHECK_SECONDS=30, PIR_STARTUP_MS=60000;
RTC_DATA_ATTR bool waitingRelease=false;
bool cameraReady=false,sdReady=false;

bool armMotion(bool wasWaiting,bool pirHigh){return !pirHigh;}
void sleepNow(){
  if(cameraReady){esp_camera_deinit();cameraReady=false;}
  if(sdReady){SD_MMC.end();sdReady=false;}
  WiFi.mode(WIFI_OFF);
  pinMode(PWDN_PIN,OUTPUT);digitalWrite(PWDN_PIN,HIGH);
  bool high=digitalRead(PIR_PIN)==HIGH;
  waitingRelease=!armMotion(waitingRelease,high);
  esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
  if(waitingRelease){
    esp_sleep_enable_timer_wakeup(uint64_t(PIR_RECHECK_SECONDS)*1000000ULL);
  }else{
    esp_sleep_enable_ext0_wakeup(GPIO_NUM_13,1);
    if(TIMELAPSE_SECONDS)esp_sleep_enable_timer_wakeup(uint64_t(TIMELAPSE_SECONDS)*1000000ULL);
  }
  Serial.println(waitingRelease?"PIR HIGH: timer-only recheck":"Sleep: motion armed");
  Serial.flush();esp_deep_sleep_start();
}
bool initCamera(){
  camera_config_t c={};
  c.ledc_channel=LEDC_CHANNEL_0;c.ledc_timer=LEDC_TIMER_0;
  c.pin_d0=5;c.pin_d1=18;c.pin_d2=19;c.pin_d3=21;
  c.pin_d4=36;c.pin_d5=39;c.pin_d6=34;c.pin_d7=35;
  c.pin_xclk=0;c.pin_pclk=22;c.pin_vsync=25;c.pin_href=23;
  c.pin_sccb_sda=26;c.pin_sccb_scl=27;c.pin_pwdn=32;c.pin_reset=-1;
  c.xclk_freq_hz=20000000;c.pixel_format=PIXFORMAT_JPEG;
  c.frame_size=FRAMESIZE_QVGA;c.jpeg_quality=12;c.fb_count=1;
  c.fb_location=psramFound()?CAMERA_FB_IN_PSRAM:CAMERA_FB_IN_DRAM;
  c.grab_mode=CAMERA_GRAB_WHEN_EMPTY;
  cameraReady=esp_camera_init(&c)==ESP_OK;return cameraReady;
}
bool savePhoto(const camera_fb_t* fb,String& path){
  if(!sdReady)return false;
  Preferences prefs;if(!prefs.begin("trap-files",false))return false;
  uint32_t number=prefs.getUInt("next",0);bool found=false;
  String temporary;
  for(int attempt=0;attempt<1000;attempt++){
    if(number==UINT32_MAX)break;
    path="/photo_"+String(number)+".jpg";temporary=path+".tmp";++number;
    if(!SD_MMC.exists(path) && !SD_MMC.exists(temporary)){found=true;break;}
  }
  if(!found || prefs.putUInt("next",number)!=sizeof(uint32_t)){prefs.end();return false;}
  prefs.end(); // Номер резервируется до записи файла; пропуски допустимы.
  File f=SD_MMC.open(temporary,FILE_WRITE);if(!f)return false;
  size_t n=f.write(fb->buf,fb->len);f.flush();f.close();
  if(n!=fb->len)return false;
  File check=SD_MMC.open(temporary,FILE_READ);
  bool ok=check && check.size()==fb->len;
  uint8_t chunk[256];size_t offset=0;
  while(ok && offset<fb->len){
    size_t want=fb->len-offset;if(want>sizeof(chunk))want=sizeof(chunk);
    size_t got=check.read(chunk,want);
    if(got!=want || memcmp(chunk,fb->buf+offset,want)!=0)ok=false;
    offset+=got;
  }
  check.close();
  return ok && SD_MMC.rename(temporary,path);
}
bool accepted(int code,const String& response){
  JsonDocument d;return code==200 && !deserializeJson(d,response)
    && d["ok"].is<bool>() && d["ok"].as<bool>();
}
bool sendPhoto(const camera_fb_t* fb){
  if(WiFi.status()!=WL_CONNECTED || time(nullptr)<1735689600)return false;
  if(String(BOT_TOKEN)=="CHANGE_ME" || String(CHAT_ID)=="CHANGE_ME")return false;
  String boundary="esp32trap-boundary";
  String head="--"+boundary+"\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n"+CHAT_ID+"\r\n--"+boundary+
    "\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"trap.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n";
  String tail="\r\n--"+boundary+"--\r\n";
  if(fb->len>150000)return false;
  size_t length=head.length()+fb->len+tail.length();
  uint8_t* body=(uint8_t*)malloc(length);if(!body)return false;
  memcpy(body,head.c_str(),head.length());memcpy(body+head.length(),fb->buf,fb->len);
  memcpy(body+head.length()+fb->len,tail.c_str(),tail.length());
  NetworkClientSecure client;client.useBuiltinCACertBundle();client.setHandshakeTimeout(5);
  HTTPClient http;http.setConnectTimeout(3000);http.setTimeout(5000);
  bool ok=false;
  if(http.begin(client,String("https://api.telegram.org/bot")+BOT_TOKEN+"/sendPhoto")){
    http.addHeader("Content-Type","multipart/form-data; boundary="+boundary);
    int code=http.POST(body,length);String response=http.getString();
    ok=accepted(code,response);http.end();
  }
  free(body);return ok;
}
void setup(){
  Serial.begin(115200);rtc_gpio_deinit(GPIO_NUM_13);pinMode(PIR_PIN,INPUT);
  pinMode(4,OUTPUT);digitalWrite(4,LOW); // Встроенная белая вспышка не используется.
  auto wake=esp_sleep_get_wakeup_cause();
  if(wake==ESP_SLEEP_WAKEUP_UNDEFINED){waitingRelease=false;delay(PIR_STARTUP_MS);}
  if(waitingRelease){sleepNow();return;} // До отпускания PIR повторных кадров нет.
  sdReady=SD_MMC.begin("/sdcard",true); // Только 1-битный режим.
  if(!initCamera()){Serial.println("Camera failed; no photo");sleepNow();return;}
  delay(300);
  // Несколько кадров для начальной настройки экспозиции.
  for(int i=0;i<3;i++){camera_fb_t* discard=esp_camera_fb_get();if(discard)esp_camera_fb_return(discard);delay(100);}
  camera_fb_t* fb=esp_camera_fb_get();
  if(!fb || fb->format!=PIXFORMAT_JPEG){
    if(fb)esp_camera_fb_return(fb);Serial.println("Capture failed");sleepNow();return;
  }
  String path;bool saved=savePhoto(fb,path);
  Serial.println(saved?"SD verified: "+path:"SD save FAILED; no local confirmation");
  WiFi.mode(WIFI_STA);WiFi.begin(SSID,PASSWORD);
  uint32_t start=millis();while(WiFi.status()!=WL_CONNECTED && uint32_t(millis()-start)<10000)delay(50);
  if(WiFi.status()==WL_CONNECTED){
    configTime(0,0,"pool.ntp.org","time.cloudflare.com");start=millis();
    while(time(nullptr)<1735689600 && uint32_t(millis()-start)<5000)delay(50);
  }
  bool sent=sendPhoto(fb);Serial.println(sent?"Telegram API accepted photo":"Telegram NOT confirmed");
  esp_camera_fb_return(fb);sleepNow();
}
void loop(){}

Что происходит после движения

После включения камера ждёт 60 секунд и делает тестовый кадр; время готовности своего PIR проверьте отдельно. Перед сном она смотрит на вход: при HIGH просыпается для повторной проверки через 30 секунд, а новую съёмку разрешает после LOW. Пройдите перед датчиком несколько раз и посмотрите, какие движения попадают в этот режим ожидания.

Имя файла резервируется заранее. Камера записывает временный файл, читает его для проверки и переименовывает в .jpg. После перебоя питания на карте могут остаться пропущенные номера и .tmp — осмотрите их во время испытаний. Поиск свободного имени ограничен тысячей попыток, поэтому архиву понадобится регулярное обслуживание.

Проверьте оба результата отдельно: JPEG на карте и сообщение в Telegram. При отключённой сети кадр остаётся на карте; автоматическую досылку можно добавить следующим этапом. Telegram подтверждает приём ответом HTTP 200 и ok=true. Если откажут и карта, и отправка, снимок после сна будет потерян.

Для HTTPS нужны доверенный сертификат и верные часы. Если бот замолчал, начните с питания, связи и журнала ошибок. Полезно периодически проверять саму камеру, чтобы тишина в чате оставалась понятной.

Выбираем место, свет и питание

С ночной съёмкой начните с пробного кадра на нужном расстоянии: ИК-фильтр конкретного OV2640 может отсекать подсветку. Удобный вариант без дополнительных GPIO — отдельный ИК-осветитель со своим управлением по освещённости. Его питание и расход энергии учитывайте вместе с камерой.

Для управления осветителем от ESP32 нужна отдельная проверенная схема ключа и свободного вывода. Пороговое напряжение затвора само по себе не подтверждает пригодность силового транзистора для управления от 3,3 В: учитывайте условия измерений в паспорте транзистора.

Начните со стабильного питания на столе. Для аккумуляторного варианта согласуйте химию, зарядник, защиту и преобразователь с пиковым током камеры и Wi-Fi. Не переносите TP4056 в схему LiFePO4 и не соединяйте случайные элементы параллельно по одной маркировке ёмкости.

Измерьте ток всей сборки во сне и при съёмке: вместе с PIR, картой и преобразователем. По этим данным рассчитывают аккумулятор и солнечную зарядку. У корпуса проверьте окно, блики, вводы и конденсат — от них качество кадра зависит не меньше, чем от настройки JPEG.

Пробуем съёмку по интервалу

Установив TIMELAPSE_SECONDS=21600, получите интервал около шести часов от очередного входа в сон. Движение и HIGH на входе PIR сдвигают этот отсчёт. Для опыта проследите несколько циклов; для календарного расписания позже добавьте отдельную логику часов. Имена кадров пока содержат порядковый номер.

Когда накопятся снимки, сравните освещение и положение камеры. Для автоматического поиска изменений понадобятся декодированные изображения одинакового размера. Начните с ручного просмотра: он покажет, что действительно стоит распознавать и какой объём архива получается при выбранной частоте.

  1. Проверьте старт, фокус и JPEG на карте, несколько циклов сна и полное отключение питания. Старые снимки должны сохраниться.
  2. Проверьте заполненную или отсутствующую карту и прерывание питания при записи. Незавершённый файл не должен считаться успешно сохранённым.
  3. Отключите Wi-Fi, задайте неверный токен или недоступный чат. Не должно появляться ложное подтверждение Telegram.
  4. Удерживайте PIR в HIGH, затем отпустите. Проверьте отсутствие бесконечной серии кадров и восстановление пробуждения.
  5. На месте установки проверьте совместную работу GPIO13 и карты, ток сна, просадку питания, ночной кадр, зону обнаружения и ложные срабатывания.

Направьте камеру на нужную зону и ограничьте доступ к архиву, избегая лишней съёмки людей и соседних участков. Затем проверьте проходы днём, карту после отключения питания и кадр ночью. Так появится собственный набор примеров для выбора места и режима.

Что добавить после первых кадров

Первоисточники: драйвер камеры Espressif, SD_MMC, режимы сна, Telegram sendPhoto, принцип работы ИК-фильтра.

После уверенной съёмки можно заняться очередью досылки или распознаванием. Нескольким камерам понадобятся свои идентификаторы, контроль доступности и правила хранения. Выберите одно расширение по результатам наблюдений: что именно вы хотели увидеть, но пока пропускаете?

Для места без розетки: Автономное питание ESP32.

Для сравнения работы уведомлений: Контроль микроклимата и доставка уведомлений.

Источник: Фермозавр

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи