Objetivo y caso de uso
Qué construirás: Un sistema de streaming de video utilizando ESP32-CAM y control de pan-tilt mediante PCA9685.
Para qué sirve
- Monitoreo remoto de espacios utilizando video en tiempo real.
- Control de cámaras en aplicaciones de robótica y drones.
- Implementación de sistemas de seguridad con visualización dinámica.
- Proyectos de domótica para vigilancia y control de áreas específicas.
Resultado esperado
- Streaming de video a 30 FPS con latencia inferior a 200 ms.
- Control de posición del servo con precisión de 1 grado.
- Conexión estable a través de WebSocket con menos de 5% de pérdida de paquetes.
- Tiempo de respuesta en el control de pan-tilt menor a 100 ms.
Público objetivo: Desarrolladores y entusiastas de IoT; Nivel: Avanzado
Arquitectura/flujo: Comunicación entre ESP32-CAM y servidor WebSocket, control de servos mediante PCA9685.
Nivel: Avanzado
Prerrequisitos
Sistemas operativos soportados y herramientas
- Windows 10/11 (64-bit)
- macOS 12/13/14 (Intel o Apple Silicon)
- Ubuntu 22.04 LTS / Debian 12
Toolchain exacta (versionado cerrado)
- IDE/CLI:
- Visual Studio Code 1.93.x (opcional, recomendado para edición)
- PlatformIO Core 6.1.12 (CLI)
- Paquetes PlatformIO:
- Plataforma: espressif32@6.7.0
- Framework Arduino: framework-arduinoespressif32@3.20014.0 (equivale a Arduino-ESP32 2.0.14)
- Compilador: toolchain-xtensa32 provisto por espressif32@6.7.0 (instalado automáticamente por PlatformIO)
- Librerías (resueltas por PlatformIO):
- me-no-dev/AsyncTCP@1.1.1
- me-no-dev/ESP Async WebServer@1.2.3
- adafruit/Adafruit PWM Servo Driver Library@2.4.2
- adafruit/Adafruit BusIO@1.14.5
- Python 3.11.x (requerido por PlatformIO Core)
- Controladores USB-UART (según tu adaptador):
- CP210x (Silicon Labs) o CH340/CH34x (WCH). Instala el driver oficial correspondiente si tu sistema no lo reconoce de forma nativa.
Instalación rápida de PlatformIO Core (CLI)
- Windows/macOS/Linux (asumiendo Python 3.11 disponible en PATH):
- Instalar:
pip install -U platformio==6.1.12 - Verificar:
pio --version
Debe mostrar: PlatformIO Core, version 6.1.12
Materiales
- 1x ESP32-CAM (OV2640) — modelo AI-Thinker con cámara OV2640 integrada
- 1x Módulo PCA9685 16 canales (servo driver I2C)
- 2x Servos para pan-tilt (p. ej., SG90/MG90S). Si usas un soporte mecánico pan-tilt, típicamente canal 0 = pan, canal 1 = tilt
- 1x Adaptador USB–UART 3.3 V (FTDI/CP2102/CH340) para programar la ESP32-CAM
- 1x Fuente 5 V 2 A (para alimentar servos vía V+ del PCA9685)
- Protoboard o base de conexiones y cables Dupont
- 2–3 jumpers para modo de programación:
- GPIO0 a GND (para entrar en bootloader)
- RST a GND (botón o puente momentáneo para reinicio)
- Cables para I2C:
- SDA, SCL entre ESP32-CAM y PCA9685
- Cables para alimentación:
- VCC (3.3 V), 5 V, GND
- Opcional: Estructura mecánica pan-tilt (Smart Car PTZ o similar)
Nota: Este caso práctico está centrado estrictamente en el modelo “ESP32-CAM (OV2640) + PCA9685 servo driver” y el objetivo “ov2640-websocket-pan-tilt”.
Preparación y conexión
En la ESP32-CAM (AI-Thinker), la cámara utiliza una serie de GPIOs internos; evitaremos usar la ranura microSD para poder disponer de GPIO14 y GPIO15 como bus I2C. No usaremos la microSD en este proyecto.
- Bus I2C elegido:
- SDA → GPIO15 de la ESP32-CAM
-
SCL → GPIO14 de la ESP32-CAM
-
Conexiones USB–UART para programación:
- U0R (GPIO3) ← TX del adaptador USB–UART
- U0T (GPIO1) → RX del adaptador USB–UART
- 5V (ESP32-CAM) ← 5V del adaptador (si entrega suficiente) o fuente externa
- GND (ESP32-CAM) ← GND del adaptador
- GPIO0 ↔ GND (para entrar en modo de programación)
-
RST ↔ GND (pulsar momentáneamente para reiniciar)
-
PCA9685:
- VCC (lógica) ← 3.3 V de la ESP32-CAM (acepta 3.3–5 V; usar 3.3 V para lógica segura)
- V+ (potencia servos) ← 5 V de la fuente externa
- GND ← GND común (ESP32-CAM, adaptador USB–UART, fuente 5 V)
- SDA ← GPIO15 (ESP32-CAM)
- SCL ← GPIO14 (ESP32-CAM)
- Canales PWM:
- CH0 (PCA9685) → Señal servo PAN
- CH1 (PCA9685) → Señal servo TILT
- Alimentación de servos:
- Rojo (Vcc) → V+ 5 V del PCA9685
- Marrón/Negro (GND) → GND del PCA9685
- Amarillo/Blanco (Señal) → CH0/CH1 del PCA9685
Tabla de conexiones detallada:
| Componente | Pin/Señal | Conecta a | Notas |
|---|---|---|---|
| ESP32-CAM | U0R (GPIO3) | TXD (USB–UART) | Programación/monitor serie |
| ESP32-CAM | U0T (GPIO1) | RXD (USB–UART) | Programación/monitor serie |
| ESP32-CAM | 5V | 5V (USB–UART o fuente) | Puede alimentar módulo, NO a los servos |
| ESP32-CAM | GND | GND común | Referencia común |
| ESP32-CAM | GPIO0 | GND (solo al programar) | Bootloader |
| ESP32-CAM | RST | Pulsador a GND | Reset |
| ESP32-CAM | GPIO15 (SDA) | SDA (PCA9685) | Bus I2C, 3.3 V |
| ESP32-CAM | GPIO14 (SCL) | SCL (PCA9685) | Bus I2C, 3.3 V |
| PCA9685 | VCC (lógica) | 3.3 V (ESP32-CAM) | Lógica 3.3 V |
| PCA9685 | V+ (potencia) | 5 V (fuente externa) | Potencia de servos |
| PCA9685 | GND | GND común | Referencia común |
| PCA9685 CH0 | PWM | Señal servo PAN | Servo 1 (pan) |
| PCA9685 CH1 | PWM | Señal servo TILT | Servo 2 (tilt) |
Advertencias:
– No alimentes los servos desde el pin 5V de la ESP32-CAM. Usa el terminal V+ del PCA9685 con una fuente 5 V adecuada y GND común.
– Evita usar GPIO12 (MTDI) para señales externas; puede causar problemas de arranque.
– Si usas una fuente 5 V separada para V+, une las masas (GND) de fuente, PCA9685 y ESP32-CAM.
Código completo
A continuación se proporciona el proyecto completo con PlatformIO. El objetivo “ov2640-websocket-pan-tilt” crea un servidor WebSocket que:
– Transmite imágenes JPEG de la cámara OV2640 en binario a los clientes conectados.
– Recibe comandos WebSocket de texto para posicionar PAN/TILT mediante el PCA9685.
– Sirve una página HTML con un visor y controles para pan/tilt.
platformio.ini
Incluye versiones fijas y librerías necesarias.
; ov2640-websocket-pan-tilt/platformio.ini
[env:esp32cam]
platform = espressif32@6.7.0
board = esp32cam
framework = arduino
platform_packages =
framework-arduinoespressif32@3.20014.0
board_build.partitions = huge_app.csv
monitor_speed = 115200
upload_speed = 921600
build_flags =
-DCORE_DEBUG_LEVEL=1
lib_deps =
me-no-dev/AsyncTCP@1.1.1
me-no-dev/ESP Async WebServer@1.2.3
adafruit/Adafruit PWM Servo Driver Library@2.4.2
adafruit/Adafruit BusIO@1.14.5
src/main.cpp
Explicación rápida de las partes clave:
– Configuración de cámara para AI-Thinker (OV2640).
– Inicialización de WiFi STA con credenciales.
– Servidor HTTP (AsyncWebServer) y WebSocket en /ws.
– Tarea FreeRTOS que captura frames y los empuja a los clientes vía WebSocket usando buffers seguros.
– Control de servos con PCA9685 a 50 Hz, mapeando ángulos a microsegundos.
– Página HTML embebida con JS para visualizar el stream y emitir comandos PAN/TILT.
// ov2640-websocket-pan-tilt/src/main.cpp
#include <Arduino.h>
#include <WiFi.h>
#include <esp_camera.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
// ========= Configuración WiFi =========
static const char* WIFI_SSID = "TU_SSID";
static const char* WIFI_PASS = "TU_PASSWORD";
// ========= Pines cámara AI-Thinker (OV2640) =========
#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
// ========= I2C PCA9685 =========
// Usamos GPIO15 (SDA) y GPIO14 (SCL) en la ESP32-CAM
#define I2C_SDA 15
#define I2C_SCL 14
// ========= PCA9685 y servo =========
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40); // Dirección I2C por defecto
// Frecuencia típica servos
const float SERVO_FREQ_HZ = 50.0f;
// Canales
const uint8_t PAN_CH = 0;
const uint8_t TILT_CH = 1;
// Calibración µs (ajústalo a tus servos/mecánica)
const int SERVO_MIN_US = 500; // pulso mínimo
const int SERVO_MAX_US = 2500; // pulso máximo
// Almacenamos ángulos actuales/objetivo para suavizado
volatile int pan_target_deg = 90;
volatile int tilt_target_deg = 90;
volatile int pan_current_deg = 90;
volatile int tilt_current_deg = 90;
// ========= Web =========
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
static const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>ov2640-websocket-pan-tilt</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; color: #222; }
.row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.panel { border: 1px solid #ccc; border-radius: 8px; padding: 1rem; }
#img { width: 100%; max-width: 480px; background: #000; }
.ctrl label { display:block; margin-top: .5rem; }
.ctrl input[type=range] { width: 100%; }
.status { font-size: .9rem; color: #555; }
button { padding: .5rem 1rem; margin-top: .5rem; }
</style>
</head>
<body>
<h1>ov2640-websocket-pan-tilt</h1>
<div class="row">
<div class="panel">
<img id="img" alt="stream" />
<div class="status" id="st">Conectando…</div>
</div>
<div class="panel ctrl">
<label>Pan: <span id="panv">90</span>°
<input id="pan" type="range" min="0" max="180" value="90" />
</label>
<label>Tilt: <span id="tiltv">90</span>°
<input id="tilt" type="range" min="0" max="180" value="90" />
</label>
<button id="center">Centrar (90°, 90°)</button>
</div>
</div>
<script>
(() => {
const img = document.getElementById('img');
const st = document.getElementById('st');
const pan = document.getElementById('pan');
const tilt= document.getElementById('tilt');
const panv= document.getElementById('panv');
const tiltv= document.getElementById('tiltv');
const center = document.getElementById('center');
let ws;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => st.textContent = 'Conectado';
ws.onclose = () => { st.textContent = 'Desconectado. Reintentando…'; setTimeout(connect, 2000); };
ws.onerror = () => { st.textContent = 'Error WS'; };
ws.onmessage = (ev) => {
if (typeof ev.data !== 'string') {
const blob = new Blob([ev.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
img.onload = () => URL.revokeObjectURL(url);
img.src = url;
} else {
// Mensajes de texto del servidor (estado)
st.textContent = ev.data;
}
};
}
connect();
function sendCmd(cmd) {
if (ws && ws.readyState === 1) ws.send(cmd);
}
pan.addEventListener('input', () => {
panv.textContent = pan.value;
sendCmd(`PAN:${pan.value}`);
});
tilt.addEventListener('input', () => {
tiltv.textContent = tilt.value;
sendCmd(`TILT:${tilt.value}`);
});
center.addEventListener('click', () => {
pan.value = 90; tilt.value = 90;
panv.textContent = '90'; tiltv.textContent = '90';
sendCmd('PAN:90'); sendCmd('TILT:90');
});
})();
</script>
</body>
</html>
)HTML";
// ========= Utilidades =========
static inline int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }
void setServoAngle(uint8_t ch, int angleDeg) {
angleDeg = clampi(angleDeg, 0, 180);
const int us = SERVO_MIN_US + (int)((SERVO_MAX_US - SERVO_MIN_US) * (angleDeg / 180.0f));
pwm.writeMicroseconds(ch, us);
}
void servoSmootherTask(void* arg) {
const TickType_t interval = pdMS_TO_TICKS(15); // ~66 actualizaciones/s
for (;;) {
int p = pan_current_deg, pt = pan_target_deg;
int t = tilt_current_deg, tt = tilt_target_deg;
if (p != pt) {
p += (pt > p) ? 1 : -1;
pan_current_deg = p;
setServoAngle(PAN_CH, p);
}
if (t != tt) {
t += (tt > t) ? 1 : -1;
tilt_current_deg = t;
setServoAngle(TILT_CH, t);
}
vTaskDelay(interval);
}
}
void wsOnEvent(AsyncWebSocket * server, AsyncWebSocketClient * client,
AwsEventType type, void * arg, uint8_t * data, size_t len) {
if (type == WS_EVT_CONNECT) {
client->text("WS conectado. Envíe PAN:<0..180> / TILT:<0..180>");
} else if (type == WS_EVT_DATA) {
AwsFrameInfo* info = (AwsFrameInfo*)arg;
if (info->opcode == WS_TEXT) {
String msg;
msg.reserve(len);
for (size_t i = 0; i < len; i++) msg += (char)data[i];
// Formato esperado: "PAN:123" o "TILT:45"
msg.trim();
int sep = msg.indexOf(':');
if (sep > 0) {
String key = msg.substring(0, sep);
String val = msg.substring(sep + 1);
int angle = clampi(val.toInt(), 0, 180);
if (key.equalsIgnoreCase("PAN")) {
pan_target_deg = angle;
client->text("PAN->" + String(angle));
} else if (key.equalsIgnoreCase("TILT")) {
tilt_target_deg = angle;
client->text("TILT->" + String(angle));
} else {
client->text("CMD desconocido");
}
} else {
client->text("Formato inválido");
}
}
}
}
TaskHandle_t frameTaskHandle = nullptr;
void frameSenderTask(void* arg) {
const TickType_t period = pdMS_TO_TICKS(100); // ~10 fps
for (;;) {
if (ws.count() > 0) {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
// Notificar error eventual
ws.textAll("Error: no frame");
} else {
// Crear buffer gestionado por AsyncWebSocket
auto buf = ws.makeBuffer(fb->len);
if (buf) {
memcpy(buf->get(), fb->buf, fb->len);
ws.binaryAll(buf);
}
esp_camera_fb_return(fb);
}
}
vTaskDelay(period);
}
}
bool initCamera() {
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_sscb_sda = SIOD_GPIO_NUM;
config.pin_sscb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000; // 20 MHz típico
config.pixel_format = PIXFORMAT_JPEG;
if (psramFound()) {
config.frame_size = FRAMESIZE_QVGA; // 320x240
config.jpeg_quality = 12; // 10–20 es razonable
config.fb_count = 2;
config.grab_mode = CAMERA_GRAB_LATEST;
} else {
config.frame_size = FRAMESIZE_QQVGA; // por si no hay PSRAM
config.jpeg_quality = 15;
config.fb_count = 1;
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
}
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("camera_init failed: 0x%x\n", err);
return false;
}
sensor_t* s = esp_camera_sensor_get();
// Ajustes opcionales del sensor
s->set_brightness(s, 0);
s->set_contrast(s, 0);
s->set_saturation(s, 0);
s->set_framesize(s, config.frame_size);
return true;
}
void setup() {
Serial.begin(115200);
delay(200);
// I2C para PCA9685
Wire.begin(I2C_SDA, I2C_SCL, 400000); // 400 kHz
if (!pwm.begin()) {
Serial.println("ERROR: PCA9685 no responde en 0x40");
}
pwm.setOscillatorFrequency(27000000); // 27 MHz nominal
pwm.setPWMFreq(SERVO_FREQ_HZ);
delay(10);
setServoAngle(PAN_CH, pan_current_deg);
setServoAngle(TILT_CH, tilt_current_deg);
// Cámara OV2640
if (!initCamera()) {
Serial.println("Fallo al iniciar cámara. Revise cableado y PSRAM.");
// no return; permitimos que el sistema arranque para debug
}
// WiFi
WiFi.mode(WIFI_STA);
WiFi.setSleep(false);
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.printf("Conectando a WiFi SSID='%s'\n", WIFI_SSID);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
delay(300);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("WiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("WiFi no conectado (timeout). Continúa en AP del router o revise credenciales.");
}
// Servidor HTTP y WebSocket
ws.onEvent(wsOnEvent);
server.addHandler(&ws);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
AsyncWebServerResponse* res = req->beginResponse_P(200, "text/html; charset=utf-8", INDEX_HTML, strlen(INDEX_HTML));
res->addHeader("Cache-Control", "no-store");
req->send(res);
});
server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest* req){ req->send(204); });
server.begin();
Serial.println("Servidor HTTP/WS listo.");
// Tarea de suavizado servo
xTaskCreatePinnedToCore(servoSmootherTask, "servoSmoother", 2048, nullptr, 1, nullptr, 1);
// Tarea de envío de frames
xTaskCreatePinnedToCore(frameSenderTask, "frameSender", 4096, nullptr, 1, &frameTaskHandle, 1);
}
void loop() {
// AsyncWebServer no requiere loop pesado; el WS se atiende en callbacks
delay(50);
}
Puntos clave:
– Se usan GPIO14 (SCL) y GPIO15 (SDA) para I2C, liberados al no usar la microSD.
– La tarea frameSender copia cada JPEG a un buffer administrado por AsyncWebSocket (ws.makeBuffer) antes de enviar, evitando usar el buffer de la cámara tras esp_camera_fb_return.
– Los comandos de control PAN/TILT son texto simple “PAN:<0..180>” y “TILT:<0..180>”.
– Un “servoSmootherTask” evita saltos bruscos en mecánicas pan-tilt.
Compilación, flash y ejecución
Asegúrate de poner GPIO0 a GND y reiniciar (RST→GND momentáneo) para entrar en bootloader antes de cargar el firmware.
1) Crear el proyecto PlatformIO:
– Con PlatformIO Core CLI:
mkdir ov2640-websocket-pan-tilt
cd ov2640-websocket-pan-tilt
pio project init --board esp32cam
– Sustituye el contenido del platformio.ini generado por el provisto en este caso.
– Crea el árbol de fuentes:
mkdir -p src
# Coloca el main.cpp provisto en src/main.cpp
2) Compilar:
pio run
3) Detectar el puerto serie (opcional):
pio device list
4) Poner la ESP32-CAM en modo programación:
– Conectar GPIO0 a GND.
– Reiniciar pulsando RST a GND y soltando.
– Verifica que el adaptador USB–UART está a 3.3 V (nivel lógico), y que TX/RX están cruzados correctamente.
5) Subir firmware:
pio run -t upload
6) Desconectar el modo programación:
– Desconecta GPIO0 de GND.
– Reinicia la placa (RST a GND momentáneo).
7) Monitor serie:
pio device monitor -b 115200
Observa el IP asignado por tu router (p. ej., 192.168.1.123).
8) Abrir la aplicación:
– En tu navegador, accede a:
– http://
– Debes ver el visor con el streaming y sliders para PAN/TILT.
Notas:
– Si la subida falla, repite el paso 4 y 5. Algunos adaptadores requieren bajar la velocidad de upload:
pio run -t upload --upload-port COMx
o ajusta upload_speed en platformio.ini (p. ej., 460800).
Validación paso a paso
1) Alimentación y conexiones:
– La ESP32-CAM enciende y el LED rojo está estable.
– El PCA9685 recibe 3.3 V en VCC y 5 V en V+, con GND común.
– Los servos están conectados a CH0 y CH1 del PCA9685, con V+ a 5 V.
2) Arranque y logs:
– En el monitor serie, tras un reset sin GPIO0 a GND, verás:
– Mensajes de “Conectando a WiFi…” seguido de “WiFi OK. IP: x.x.x.x”.
– “Servidor HTTP/WS listo.”
– Si la cámara se inicializa: no aparece “camera_init failed”.
3) Acceso web:
– En la URL http://
– El estado pasa de “Conectando…” a “Conectado”.
– Debes ver refrescarse la imagen JPEG constantemente (≈10 fps).
4) Control pan-tilt:
– Al mover el slider de “Pan”, el servo correspondiente debe responder con un pequeño retardo (suavizado 1°/15 ms).
– Al mover el slider de “Tilt”, el segundo servo debe responder.
– El botón “Centrar” debe posicionar ambos a 90°.
5) WebSocket:
– Abre las herramientas de desarrollador del navegador (F12 → Network → WS):
– Observa que /ws está en estado abierto.
– Debe recibir frames binarios (tipo ArrayBuffer).
– Los mensajes de texto del servidor confirman cambios (“PAN->X”, “TILT->Y”).
6) Calidad de imagen:
– La imagen debe ser nítida en QVGA (320×240). Si necesitas más, puedes aumentar a VGA, pero observarás mayor latencia/uso de RAM.
7) Temperatura y consumo:
– La ESP32-CAM puede calentarse ligeramente; es normal.
– Los servos no deben zumbar en exceso en reposo. Si lo hacen, reduce el rango o calibra µs.
8) Persistencia:
– Desconecta y reconecta la alimentación. El sistema debe reanudar conexión al WiFi y servir la interfaz sin intervención adicional.
Troubleshooting
1) Error de alimentación/“Brownout detector”
– Síntomas: reinicios, mensajes “Brownout detector was triggered”.
– Causa: Caídas en 5 V cuando los servos arrancan o se bloquean mecánicamente.
– Solución:
– Alimenta los servos desde una fuente 5 V 2 A (mínimo) separada del 5 V del USB.
– Asegura GND común entre ESP32-CAM, PCA9685 y fuente 5 V.
– Añade condensadores cerca del PCA9685 y servos (p. ej., 470–1000 µF en V+ a GND).
2) “camera_init failed: 0x…” o imagen negra
– Revisa el mapeo de pines (este caso usa AI-Thinker por defecto).
– Asegúrate de no usar la ranura microSD en paralelo (hemos tomado GPIO14/15 para I2C).
– Reduce frame_size a QQVGA y/o baja jpeg_quality si hay poca RAM.
– Confirma que la cámara está bien insertada en su conector.
3) El PCA9685 no responde (“ERROR: PCA9685 no responde en 0x40”)
– Comprueba SDA=SDA y SCL=SCL (GPIO15 y GPIO14 respectivamente).
– Verifica VCC=3.3 V y GND.
– Si tu módulo PCA9685 tiene jumpers de dirección A0–A5, confirma que la dirección es 0x40.
– Usa un escáner I2C si es necesario (temporalmente) para listar direcciones.
4) El stream WebSocket se congela o es muy lento
– Baja la resolución a QQVGA o sube jpeg_quality (número mayor reduce tamaño).
– Asegúrate de tener buena señal WiFi; aproxima el router o usa otro canal menos congestionado.
– Controla el periodo de envío (frameSenderTask) para evitar saturación (p. ej., 100–150 ms).
5) Fallo al subir firmware (“A fatal error occurred: Failed to connect to ESP32”)
– Asegúrate de:
– GPIO0 a GND durante la carga.
– Pulsar RST (a GND) justo antes de “upload”.
– RX/TX están cruzados (TX del adaptador a U0R/GPIO3; RX a U0T/GPIO1).
– Baja upload_speed a 460800 o 115200 en platformio.ini si el adaptador es inestable.
6) Servos vibran, zumban o no se mueven correctamente
– Revisa SERVO_MIN_US y SERVO_MAX_US para tu modelo de servo; prueba 600–2400 µs.
– Elimina topes mecánicos: no fuerces el mecanismo más allá de su rango físico.
– Evita enviar comandos a 1000 Hz: el suavizado ya limita la cadencia.
– Asegúrate de V+ estable a 5 V y GND común.
7) La página web carga, pero no hay imagen
– Revisa que el browser no bloquea contenido mixto si accedes vía HTTPS en otra pestaña.
– Verifica en DevTools → Network → WS que /ws está abierto.
– Si hay errores CORS u otros, recarga y limpia cache (Ctrl+F5).
8) Reinicios aleatorios (WDT) al mover sliders
– Asegúrate de no bloquear callbacks WS con lógica pesada.
– El envío de frames usa buffers gestionados; si cambiaste a otra API, cuida la vida de los punteros.
– Verifica que la fuente 5 V no cae con el movimiento de los servos.
Mejoras/variantes
- Streaming MJPEG por HTTP:
- Alternativa a WebSocket binario para compatibilidad con visores MJPEG nativos de navegadores y herramientas. Aumenta la compatibilidad a costa de menos control bidireccional.
- WSS y autenticación:
- Añade TLS con un proxy inverso (Nginx/Caddy) y credenciales para restringir el acceso.
- Controles avanzados:
- Implementar trayectorias suaves (easing) y límites de velocidad/ aceleración para no castigar la mecánica.
- Resoluciones y ROI:
- Cambiar a FRAMESIZE_VGA con PSRAM y reducir fps si tu red lo permite. O implementar región de interés (ROI) si solo interesa una zona.
- Calibración interactiva:
- Permite guardar en NVS los parámetros SERVO_MIN_US/SERVO_MAX_US y ángulos máximos seguros por cada servo.
- OTA (Over-The-Air):
- Agregar ArduinoOTA o AsyncElegantOTA para actualizar sin cable una vez desplegado.
- Telemetría:
- Añade un canal WS de estado (temperatura, fps efectivo, latencia medida por marca de tiempo) para depuración avanzada.
- Node-RED / Control externo:
- Publicar un endpoint WS o HTTP con API para integrar con dashboards externos.
Checklist de verificación
- [ ] Toolchain instalado:
- [ ] PlatformIO Core 6.1.12 (pio –version)
- [ ] Plataforma espressif32@6.7.0 y Arduino-ESP32 2.0.14 (via platformio.ini)
- [ ] Drivers USB–UART instalados (CP210x/CH34x) y puerto visible en “pio device list”
- [ ] Cableado correcto:
- [ ] TX (USB–UART) → U0R (GPIO3), RX (USB–UART) ← U0T (GPIO1)
- [ ] GPIO0 a GND al programar; suelto para ejecutar
- [ ] PCA9685: VCC=3.3 V, V+=5 V, GND común
- [ ] I2C: SDA=GPIO15, SCL=GPIO14
- [ ] Servos: señal en CH0/CH1, V+ a 5 V, GND común
- [ ] Compilación OK: “pio run” sin errores
- [ ] Carga OK: “pio run -t upload” con GPIO0 a GND y reset
- [ ] Monitor serie (115200) muestra IP tras conectar al WiFi
- [ ] Página accesible en http://
/ - [ ] WebSocket conectado (“Conectado” en UI)
- [ ] Imagen de la cámara visible y actualizándose
- [ ] Deslizadores mueven PAN/TILT como se espera
- [ ] Sin “Brownout” ni reinicios al mover servos
- [ ] Fuente 5 V disipando adecuadamente y sin calentamiento excesivo
Con este caso práctico has construido un sistema “ov2640-websocket-pan-tilt” completamente reproducible sobre el modelo exacto “ESP32-CAM (OV2640) + PCA9685 servo driver”, usando PlatformIO con versiones fijadas. Has cubierto desde la preparación del entorno, cableado y programación hasta la validación, resolución de problemas y posibles mejoras.
Encuentra este producto y/o libros sobre este tema en Amazon
Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.



