¿Cuántas veces alguien te ha bloqueado la salida del garaje estacionando justo frente al portón? En vez de pagar por una cámara de seguridad de marca, en este proyecto vas a armar tu propio guardia digital con un módulo ESP32-CAM que cuesta poco más que un café. El sistema vigila la entrada, y cuando un vehículo se detiene frente a ella, reconoce que es un auto (no una persona ni un perro), enciende un buzzer y un LED de aviso local, y te manda una foto por WhatsApp.
Al terminar vas a entender cómo un microcontrolador de menos de US$10 puede detectar movimiento sin ningún sensor extra, cómo delegar el reconocimiento de objetos a la nube, y cómo cerrar el ciclo enviando una notificación real a tu teléfono.

Cómo funciona: la idea detrás del sistema
El truco central es que el ESP32-CAM no usa un sensor PIR ni un ultrasónico para detectar movimiento. En vez de eso, la propia cámara compara cuadros consecutivos: si suficientes píxeles cambian de un frame al siguiente, hay movimiento. Es visión por computador en su forma más simple, y corre entero dentro del chip.
El flujo completo es así:
- La cámara escanea en modo de bajo consumo (escala de grises, resolución QQVGA) comparando frames.
- Cuando detecta movimiento, espera 2 segundos a que el vehículo se estacione, cambia a JPEG en alta resolución y captura una foto.
- Esa foto se sube por una conexión segura (TLS) a la API de detección de objetos de CircuitDigest Cloud.
- Si la nube confirma que hay un auto, camión, bus o moto, se dispara la API de WhatsApp y llega la alerta con la imagen; en paralelo, el buzzer y el LED se activan 2 segundos como aviso local.
¿Por qué no procesar todo en el ESP32? Porque correr una red neuronal de detección de objetos sobre un chip de 240 MHz y 4 MB de PSRAM es demasiado. La estrategia clásica de IoT es dividir el trabajo: el borde (el ESP32-CAM) se encarga del gatillo barato. detectar que algo se movió. , y la nube hace el trabajo pesado de decidir qué se movió. Más abajo, en las variantes, verás cómo llevar también esa parte pesada al propio ESP32.
Materiales y montaje

El corazón es el ESP32-CAM. El resto son componentes de aviso local y de alimentación:
- ESP32-CAM: el procesador local con cámara integrada.
- Buzzer activo 5V: para el aviso sonoro. Importante que sea activo, no pasivo (más sobre esto abajo).
- LED 5mm y su portaled: aviso visual, montado en la carcasa.
- Módulo TP4056 o conector DC: interfaz de alimentación hacia el ESP32-CAM.
- Conversor USB a Serial TTL: imprescindible para programar el ESP32-CAM, que no trae puerto USB propio.
- Cables jumper, una lámina de cartón de 2.5mm para el chasis y unos tornillos M3 de 40mm con sus tuercas como separadores.
El circuito es de los más simples que vas a ver, justamente porque el ESP32-CAM ya integra las conexiones de la cámara. Alimenta la placa por los pines 5V y GND, conecta el LED a GPIO2 y el buzzer a GPIO4. Eso es todo.

Un detalle de hardware que conviene entender: en la placa ESP32-CAM el LED flash blanco de a bordo está cableado directamente a GPIO2. Como el código usa GPIO2 para el LED externo de aviso, ambos se encenderán juntos. No es un error, es el diseño de la placa. Y sobre el buzzer: usa uno activo, porque solo necesita un nivel alto estable en el pin para sonar; un buzzer pasivo requeriría generar la señal PWM tú mismo.

Para el armado, todo se monta entre dos láminas de cartón separadas por los tornillos M3. Se alimenta con cualquier fuente de 5V o un power bank USB, y en la cara frontal puedes pegar un cartel tipo "NO ESTACIONAR FRENTE AL PORTÓN".
Programar el ESP32-CAM

Como el módulo no tiene puerto USB para grabar, se usa un conversor USB-a-TTL. La regla de oro del conexionado:
- VCC y GND del conversor van a 5V y GND del ESP32-CAM.
- TX del conversor va a RX del ESP32-CAM, y RX va a TX. Si los inviertes, no graba.
- Para entrar en modo de grabación, conecta GPIO0 a GND. Una vez grabado, desconéctalo para que arranque normal.
Configuración, pines y estado global
Este primer bloque define las credenciales, los parámetros de sensibilidad y el mapa de pines de la cámara AI Thinker. El valor clave es MOTION_THRESHOLD (350): súbelo para hacer el sistema menos sensible, bájalo para que reaccione a movimientos más chicos.
#include "esp_camera.h"
#include "esp_tls.h"
#include "esp_crt_bundle.h"
#include "esp_sntp.h"
#include <WiFi.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
const char* WIFI_SSID = "your_ssid";
const char* WIFI_PASS = "your_password";
const char* API_KEY = "your_api_key";
const char* PHONE = "91---------";
// ─── Tuning Parameters ────────────────────────────────────────────
#define PIXEL_DIFF_THRESHOLD 30
#define MOTION_THRESHOLD 350
#define UPLOAD_COOLDOWN_MS 10000UL
#define BUZZER_PIN 4 // Assigned to GPIO 4
#define LED_PIN 2 // Added external/flash LED to GPIO 2
#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
// ─── Shared State ─────────────────────────────────────────────────
static uint8_t* g_imgBuf = nullptr;
static size_t g_imgLen = 0;
static uint8_t* previousFrame = NULL;
static size_t motionFrameSize = 0;
static char g_status[64] = "Unknown Vehicle";
static char g_imageUrl[256] = "";
static char g_jsonBuf[2048] = {};
static SemaphoreHandle_t g_startSem = nullptr;
static SemaphoreHandle_t g_doneSem = nullptr;
static unsigned long lastUploadTime = 0;
Motor de detección de movimiento
setupMotionBaseline() captura un frame de referencia contra el cual se comparan los siguientes. Es la base del algoritmo de diferencia de cuadros.
static bool setupMotionBaseline()
{
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
Serial.println("[CAM] Warning: Failed to acquire baseline frame. Retrying...");
return false;
}
motionFrameSize = fb->len;
if (previousFrame != NULL) {
free(previousFrame);
previousFrame = NULL;
}
previousFrame = (uint8_t *)malloc(motionFrameSize);
if (previousFrame) {
memcpy(previousFrame, fb->buf, motionFrameSize);
}
esp_camera_fb_return(fb);
return (previousFrame != NULL);
}
Helpers de red segura (TLS)
Estas funciones envuelven el socket crudo para escribir y leer sobre la conexión TLS sin perder paquetes: suben la imagen JPEG en fragmentos y leen la respuesta con timeout. Fíjate cómo openTLS() usa el bundle de certificados incluido en el ESP IDF.
static bool tlsWriteAll(esp_tls_t* tls, const uint8_t* buf, size_t len) {
size_t sent = 0;
while (sent < len) {
int n = esp_tls_conn_write(tls, buf + sent, len - sent);
if (n < 0) return false;
sent += n;
}
return true;
}
static size_t tlsReadAll(esp_tls_t* tls, char* buf, size_t maxLen) {
size_t total = 0;
uint32_t t = millis();
while (total < maxLen - 1) {
if (millis() - t > 15000) break;
int n = esp_tls_conn_read(tls, buf + total, maxLen - 1 - total);
if (n == 0) break;
if (n == ESP_TLS_ERR_SSL_WANT_READ) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }
if (n < 0) break;
total += n; t = millis();
}
buf[total] = '\0';
return total;
}
static esp_tls_t* openTLS(const char* host, int port) {
esp_tls_cfg_t cfg = {};
cfg.crt_bundle_attach = esp_crt_bundle_attach;
esp_tls_t* tls = esp_tls_init();
if (!tls) return nullptr;
if (esp_tls_conn_new_sync(host, strlen(host), port, &cfg, tls) == 1) return tls;
esp_tls_conn_destroy(tls);
return nullptr;
}
Enviar la imagen a la nube y detectar el vehículo
detectVehicle() arma manualmente un POST multipart/form-data, inyecta el buffer de la imagen y pide a la API que busque las clases car, truck, bus y motorcycle con una confianza mínima de 0.40. La respuesta JSON queda guardada para el siguiente paso.
static bool detectVehicle() {
const char* host = "www.circuitdigest.cloud";
const char* bnd = "----ESP32Boundary";
const char* classesParam = "[\"car\",\"truck\",\"bus\",\"motorcycle\"]";
const char* confidenceParam = "0.40";
char phHead[256];
int phLen = snprintf(phHead, sizeof(phHead),
"--%s\r\nContent-Disposition: form-data; name=\"imageFile\"; filename=\"photo.jpg\"\r\nContent-Type: image/jpeg\r\n\r\n", bnd);
char classesPart[256];
int cpLen = snprintf(classesPart, sizeof(classesPart),
"\r\n--%s\r\nContent-Disposition: form-data; name=\"classes\"\r\n\r\n%s\r\n", bnd, classesParam);
char confPart[128];
int coLen = snprintf(confPart, sizeof(confPart),
"--%s\r\nContent-Disposition: form-data; name=\"confidence\"\r\n\r\n%s\r\n", bnd, confidenceParam);
char ptTail[64];
int ptLen = snprintf(ptTail, sizeof(ptTail), "--%s--\r\n", bnd);
size_t contentLen = phLen + g_imgLen + cpLen + coLen + ptLen;
char reqHdr[512];
int reqLen = snprintf(reqHdr, sizeof(reqHdr),
"POST /api/v1/object-detection/detect HTTP/1.1\r\n"
"Host: %s\r\nAuthorization: %s\r\n"
"Content-Type: multipart/form-data; boundary=%s\r\n"
"Content-Length: %u\r\nConnection: close\r\n\r\n",
host, API_KEY, bnd, (unsigned)contentLen);
esp_tls_t* tls = openTLS(host, 443);
if (!tls) return false;
bool ok =
tlsWriteAll(tls, (const uint8_t*)reqHdr, reqLen) &&
tlsWriteAll(tls, (const uint8_t*)phHead, phLen) &&
tlsWriteAll(tls, g_imgBuf, g_imgLen) &&
tlsWriteAll(tls, (const uint8_t*)classesPart, cpLen) &&
tlsWriteAll(tls, (const uint8_t*)confPart, coLen) &&
tlsWriteAll(tls, (const uint8_t*)ptTail, ptLen);
static char respBuf[3072];
if (ok) tlsReadAll(tls, respBuf, sizeof(respBuf));
esp_tls_conn_destroy(tls);
if (!ok) return false;
char* bodyStart = strstr(respBuf, "\r\n\r\n");
if (!bodyStart) return false;
bodyStart += 4;
while (*bodyStart && ((*bodyStart >= '0' && *bodyStart <= '9') || (*bodyStart >= 'a' && *bodyStart <= 'f') || (*bodyStart >= 'A' && *bodyStart <= 'F'))) bodyStart++;
if (*bodyStart == '\r') bodyStart += 2;
size_t bodyLen = strlen(bodyStart);
if (bodyLen >= sizeof(g_jsonBuf)) bodyLen = sizeof(g_jsonBuf) - 1;
memcpy(g_jsonBuf, bodyStart, bodyLen);
g_jsonBuf[bodyLen] = '\0';
Serial.printf("[API Response] %s\n", g_jsonBuf);
return g_jsonBuf[0] != '\0';
}
parseVehicleDetails() revisa ese JSON: si encuentra alguna de las clases objetivo, arma el mensaje de estado y extrae la URL de la imagen anotada que devuelve la nube.
static bool parseVehicleDetails() {
const char* j = g_jsonBuf;
g_imageUrl[0] = '\0';
bool vehicleFound = false;
if (strstr(j, "\"class_name\":\"car\"")) { strncpy(g_status, "Car Arrived", sizeof(g_status)); vehicleFound = true; }
else if (strstr(j, "\"class_name\":\"truck\"")) { strncpy(g_status, "Truck Arrived", sizeof(g_status)); vehicleFound = true; }
else if (strstr(j, "\"class_name\":\"bus\"")) { strncpy(g_status, "Bus Arrived", sizeof(g_status)); vehicleFound = true; }
else if (strstr(j, "\"class_name\":\"motorcycle\"")){ strncpy(g_status, "Motorcycle Arrived", sizeof(g_status)); vehicleFound = true; }
if (!vehicleFound) {
return false;
}
const char* url_key = "\"annotated_image_url\":\"";
const char* ui = strstr(j, url_key);
if (ui) {
const char* vs = ui + strlen(url_key);
const char* ve = strchr(vs, '"');
if (ve && ve > vs) {
size_t len = ve - vs;
if (len >= sizeof(g_imageUrl)) len = sizeof(g_imageUrl) - 1;
strncpy(g_imageUrl, vs, len);
g_imageUrl[len] = '\0';
char* p = g_imageUrl; char* q = g_imageUrl;
while(*p) { if(*p == '\\' && *(p+1) == '/') p++; *q++ = *p++; }
*q = '\0';
}
}
return true;
}
Notificar por WhatsApp
Con el vehículo confirmado, sendWhatsApp() empaqueta el número de destino y la URL de la imagen en el formato que espera el gateway y lo dispara por internet.
static void sendWhatsApp() {
const char* host = "www.circuitdigest.cloud";
char ts[64] = "Time Unknown";
struct tm ti;
if (getLocalTime(&ti, 500)) strftime(ts, sizeof(ts), "%I:%M %p, %B %d, %Y", &ti);
char reqBody[1024];
int bodyLen = snprintf(reqBody, sizeof(reqBody),
"{"
"\"phone_number\":\"%s\","
"\"template_id\":\"image_capture_alert\","
"\"variables\":{"
"\"event_type\":\"%s\","
"\"location\":\"Driveway Intersection\","
"\"device_name\":\"ESP32-CAM Vehicle Node\","
"\"captured_time\":\"%s\","
"\"image_link\":\"%s\""
"}"
"}",
PHONE, g_status, ts, g_imageUrl);
char reqHdr[512];
int reqLen = snprintf(reqHdr, sizeof(reqHdr),
"POST /api/v1/whatsapp/send HTTP/1.1\r\n"
"Host: %s\r\nAuthorization: %s\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %u\r\nConnection: close\r\n\r\n",
host, API_KEY, (unsigned)bodyLen);
esp_tls_t* tls = openTLS(host, 443);
if (!tls) return;
tlsWriteAll(tls, (const uint8_t*)reqHdr, reqLen);
tlsWriteAll(tls, (const uint8_t*)reqBody, bodyLen);
static char respBuf[512];
tlsReadAll(tls, respBuf, sizeof(respBuf));
esp_tls_conn_destroy(tls);
Serial.printf("[WhatsApp Gateway Status] Success transmission logged\n");
}
Arranque y bucle principal
setup() configura los pines, conecta al WiFi y sincroniza la hora por SNTP. El loop() es donde vive la magia de la diferencia de cuadros: recorre el frame de a 8 píxeles (un "salto" que reduce el cómputo y acelera el ciclo) y, si los píxeles cambiados superan el umbral, gatilla la captura en alta resolución.
void setup() {
Serial.begin(115200);
vTaskDelay(pdMS_TO_TICKS(500));
Serial.println("\n=== Async Vehicle Tracker Deployed ===");
// Setup both Alert Pins cleanly
pinMode(BUZZER_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW);
digitalWrite(LED_PIN, LOW);
WiFi.mode(WIFI_STA);
WiFi.setSleep(false);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) { vTaskDelay(pdMS_TO_TICKS(500)); Serial.print("."); }
Serial.printf("\n[Network Connected] IP: %s\n", WiFi.localIP().toString().c_str());
configTime(19800, 0, "pool.ntp.org", "time.nist.gov");
struct tm timeinfo;
getLocalTime(&timeinfo, 5000);
g_startSem = xSemaphoreCreateBinary();
g_doneSem = xSemaphoreCreateBinary();
if (setCameraSensingMode(PIXFORMAT_GRAYSCALE, FRAMESIZE_QQVGA, 12)) {
Serial.println("[System Status] Waking up camera engine...");
vTaskDelay(pdMS_TO_TICKS(1000));
while (!setupMotionBaseline()) {
vTaskDelay(pdMS_TO_TICKS(500));
}
Serial.println("[System Status] Low-power scanning baseline monitoring armed...");
}
xTaskCreatePinnedToCore(networkTask, "net_task", 8192, nullptr, 5, nullptr, 0);
}
void loop() {
if (previousFrame == NULL || motionFrameSize == 0) {
vTaskDelay(pdMS_TO_TICKS(100));
return;
}
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { vTaskDelay(pdMS_TO_TICKS(100)); return; }
int changedPixels = 0;
for (size_t i = 0; i < motionFrameSize; i += 8) {
int diff = abs((int)fb->buf[i] - (int)previousFrame[i]);
if (diff > PIXEL_DIFF_THRESHOLD) { changedPixels++; }
}
bool coolingDown = (millis() - lastUploadTime < UPLOAD_COOLDOWN_MS);
if (!coolingDown && (changedPixels > MOTION_THRESHOLD)) {
esp_camera_fb_return(fb);
Serial.printf("\n[Alert] Tripwire activated (%d). Locking scan engine.\n", changedPixels);
Serial.println("Holding 2 seconds for target stabilization...");
vTaskDelay(pdMS_TO_TICKS(2000));
if (setCameraSensingMode(PIXFORMAT_JPEG, FRAMESIZE_VGA, 10)) {
vTaskDelay(pdMS_TO_TICKS(600));
camera_fb_t* flushFb = esp_camera_fb_get();
if(flushFb) esp_camera_fb_return(flushFb);
camera_fb_t* captureFb = esp_camera_fb_get();
if (captureFb) {
g_imgLen = captureFb->len;
g_imgBuf = (uint8_t*)malloc(g_imgLen);
if (g_imgBuf) {
memcpy(g_imgBuf, captureFb->buf, g_imgLen);
esp_camera_fb_return(captureFb);
xSemaphoreGive(g_startSem);
xSemaphoreTake(g_doneSem, portMAX_DELAY);
return;
}
esp_camera_fb_return(captureFb);
}
}
setCameraSensingMode(PIXFORMAT_GRAYSCALE, FRAMESIZE_QQVGA, 12);
while (!setupMotionBaseline()) { vTaskDelay(pdMS_TO_TICKS(200)); }
} else {
if (motionFrameSize == fb->len) {
memcpy(previousFrame, fb->buf, motionFrameSize);
}
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(150));
}
}
Credenciales de CircuitDigest Cloud

Para este proyecto necesitas una sola credencial: la API Key de CircuitDigest Cloud, que da acceso tanto a la API de detección de objetos como a la de notificación por WhatsApp. Inicia sesión, ve a la sección de WhatsApp Notification, enlaza y verifica tu número por OTP, y copia la API Key que aparece bajo "Try API". Pega esa clave y tu número (con el prefijo de país) en el código. La capa gratuita da 100 solicitudes al mes por servicio, con tope de 15 diarias.
Pruebas y depuración
Al energizar, el monitor serial (115200 baudios) debería mostrar la conexión WiFi y luego "baseline monitoring armed". Dos fallas típicas:
- "Failed to acquire baseline frame": casi siempre es el cable flex de la cámara mal encajado o una alimentación pobre. Reasienta el conector FPC y usa una fuente de 5V estable.
- Detecta movimiento pero la IA no encuentra vehículos: baja un poco
PIXEL_DIFF_THRESHOLDyMOTION_THRESHOLD, y revisa la iluminación. De noche sin luz, la cámara entrega frames borrosos y la nube no clasifica bien.
Variantes y mejoras
Este proyecto es una base excelente para extender. Tres ideas que no están en el tutorial original:
- Detección 100% local con Edge Impulse: en vez de depender de CircuitDigest Cloud, puedes entrenar un modelo liviano de detección de vehículos con Edge Impulse y correrlo directamente en el ESP32-CAM. Ganas privacidad y funcionamiento sin internet, a costa de una precisión algo menor. Es el salto natural desde "IoT + nube" hacia "IA en el borde".
- Confirmar la distancia con un sensor ultrasónico HC SR04: agregar un ultrasónico permite disparar la captura solo cuando el objeto está realmente cerca del portón, reduciendo falsas alarmas por autos que solo pasan.
- Cambiar WhatsApp por Telegram: si prefieres no depender de plantillas de WhatsApp, la librería Universal Telegram Bot te deja enviar la foto a un chat o grupo de Telegram con muy poco código extra.
Personalización para Chile
En Chile puedes conseguir todo lo necesario en MechatronicStore, con stock local:
- Módulo de Cámara ESP32-CAM (SKU N-703). $27.320 CLP
- Buzzer activo 5V (SKU GA3-2). $500 CLP
- LED 5mm (SKU GA1-5). $100 CLP
- Portaled 5mm (SKU GA6-15). $120 CLP
- Módulo cargador TP4056 Micro USB (SKU GP3-1). $1.390 CLP
- Conversor USB a Serial TTL CP2102 (SKU GP3-5). $3.000 CLP
- Cables macho hembra 30cm (SKU C-418). $1.990 CLP
- Módulo Power Bank 5V 2.4A (SKU N-117). $5.290 CLP (o usa cualquier power bank USB que tengas)
La lámina de cartón y los tornillos M3 los encuentras en cualquier ferretería. Si el tutorial original menciona un "USB to-TTL programmer" genérico, el conversor CP2102 del catálogo cumple exactamente esa función.
Recursos
- Tutorial original: Build an ESP32-CAM No Parking Alert System Using IoT & AI
- Documentación adicional: Object Detection API de CircuitDigest Cloud
Versión chilena con componentes en stock local en MechatronicStore, inspirada en el proyecto de Circuit Digest.




