Este tutorial ESP32-CAM MQTT muestra cómo capturar instantáneas JPEG con una ESP32-CAM de AI-Thinker y publicar cada imagen como un payload MQTT binario en bruto. Al terminar tendrás una cámara que envía una foto con un temporizador o bajo comando, un script de Python que guarda cada imagen en disco y una idea clara de los límites de tamaño de mensaje que deciden si una imagen llega o no.
¿Por qué enviar imágenes de cámara por MQTT?
MQTT suele asociarse con lecturas de sensores diminutas, pero al protocolo no le importa lo que haya dentro de un payload. Un JPEG no es más que un array de bytes, y la especificación permite payloads mucho más grandes que cualquier instantánea que pueda producir una cámara pequeña. Enviar imágenes por el mismo broker que tu telemetría tiene ventajas reales:
- Una conexión, un modelo de seguridad. La cámara usa las mismas credenciales, ajustes TLS y convenciones de topics que cualquier otro dispositivo.
- Captura dirigida por eventos. Un sensor de movimiento, un contacto de puerta o un botón del dashboard pueden publicar un comando y la cámara responde con una imagen. Sin sondeos ni servidor HTTP en el dispositivo.
- Difusión sin coste. Varios suscriptores (un script de archivo, un dashboard, un flujo de alertas) pueden recibir la misma imagen sin que la cámara haga trabajo extra.
Eso sí, MQTT no es un protocolo de vídeo. Encaja bien para instantáneas cada pocos segundos o por eventos, y mal para la transmisión continua de vídeo. Si necesitas vídeo en directo, usa un protocolo de streaming dedicado y reserva MQTT para el control y los metadatos.
Hardware y software necesarios
| Elemento | Notas |
|---|---|
| ESP32-CAM de AI-Thinker con OV2640 | El módulo más común; el mapa de pines siguiente es para esta placa |
| Adaptador USB a serie (FTDI o ESP32-CAM-MB) | GPIO0 debe estar conectado a GND durante la grabación |
| Alimentación estable de 5 V, se recomienda al menos 1 A | Las caídas de tensión durante la captura son la principal causa de reinicios |
| Arduino IDE 2.x con el paquete de placas esp32 | Placa: "AI Thinker ESP32-CAM" |
| Librería PubSubClient de Nick O'Leary | Se instala desde el Library Manager |
| Python 3 con paho-mqtt 2.x | Para el script receptor |
Si nunca has conectado un ESP32 a un broker, el tutorial de IoT con ESP32 cubre con más detalle el Wi-Fi, los fundamentos de PubSubClient y la lógica de reconexión.
El tamaño del mensaje es la verdadera limitación
Antes de escribir código, decide la resolución, porque el tamaño del JPEG determina a qué broker puedes enviarlo y cuánta RAM necesitas.
| Dónde publicas | Tamaño máximo de mensaje |
|---|---|
| Área pública de pruebas de broker.connectmq.com | 64 KB |
| Plan Serverless (Free) | 256 KB |
| Plan Serverless (Paid) | 1 MB |
El tamaño del JPEG depende de la resolución, del ajuste jpeg_quality (un número menor significa más calidad y archivos más grandes) y de la propia escena. Una escena recargada y detallada se comprime peor que una pared blanca. Puntos de partida prácticos:
FRAMESIZE_QVGA(320x240) produce imágenes pequeñas que caben sin problema por debajo de 64 KB, ideal para probar en el servidor MQTT público gratuito.FRAMESIZE_VGA(640x480) es un buen compromiso para vigilancia y normalmente cabe en el plan gratuito.FRAMESIZE_SVGAy superiores pueden superar los 64 KB; compruebafb->lenen el monitor serie antes de confiar en ellas.
El sketch siguiente compara la longitud de la imagen con un límite y descarta las imágenes demasiado grandes en lugar de dejar que el broker cierre la conexión.
Enviar payloads grandes por streaming con PubSubClient
PubSubClient normalmente copia el topic y el payload en un búfer interno que por defecto es de 256 bytes. Podrías ampliarlo con setBufferSize(), pero reservar un búfer de 50 KB además del frame buffer de la cámara desperdicia RAM. El mejor enfoque es la API de streaming:
beginPublish(topic, length, retained)escribe la cabecera MQTT con la longitud final.write(buffer, length)envía los bytes del JPEG directamente desde el frame buffer de la cámara.endPublish()completa el paquete.
El payload nunca se copia, así que incluso las imágenes grandes solo necesitan un búfer MQTT pequeño. Ten en cuenta que PubSubClient publica con QoS 0, lo que está bien para instantáneas: si se pierde una, la siguiente llega en unos segundos. Para saber más sobre el ajuste del búfer en general, consulta el tamaño del búfer de PubSubClient.
El sketch ESP32-CAM MQTT
La estructura de topics usa un prefijo único para que tus imágenes no choquen con las de nadie más en el broker público:
| Topic | Dirección | Payload |
|---|---|---|
connectmq/demo/cam-7f3a/snapshot |
publica la cámara | bytes JPEG en bruto |
connectmq/demo/cam-7f3a/status |
publica la cámara, retenido | online / offline (Last Will) |
connectmq/demo/cam-7f3a/cmd |
se suscribe la cámara | capture, flash_on, flash_off |
Sustituye cam-7f3a por tu propia cadena aleatoria.
#include <WiFi.h>
#include <PubSubClient.h>
#include "esp_camera.h"
// ---------- Ajustes de Wi-Fi y MQTT ----------
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* CLIENT_ID = "cam-7f3a-esp32"; // no puede estar vacío y debe ser único
const char* TOPIC_SNAP = "connectmq/demo/cam-7f3a/snapshot";
const char* TOPIC_STATUS = "connectmq/demo/cam-7f3a/status";
const char* TOPIC_CMD = "connectmq/demo/cam-7f3a/cmd";
const size_t MAX_PAYLOAD = 60 * 1024; // por debajo del límite público de 64 KB
const uint32_t CAPTURE_INTERVAL = 30000; // instantánea programada cada 30 s
const int FLASH_LED_PIN = 4;
// ---------- Mapa de pines de la ESP32-CAM de AI-Thinker ----------
#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
WiFiClient net;
PubSubClient mqtt(net);
uint32_t lastCapture = 0;
volatile bool captureRequested = false;
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_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; // prueba QVGA si las imágenes son demasiado grandes
config.jpeg_quality = 12; // 10-63, menor = más calidad, archivo más grande
config.fb_count = 1;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.grab_mode = CAMERA_GRAB_LATEST;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed: 0x%x\n", err);
return false;
}
return true;
}
void onMessage(char* topic, byte* payload, unsigned int length) {
String cmd;
for (unsigned int i = 0; i < length; i++) cmd += (char)payload[i];
cmd.trim();
if (cmd == "capture") captureRequested = true;
else if (cmd == "flash_on") digitalWrite(FLASH_LED_PIN, HIGH);
else if (cmd == "flash_off") digitalWrite(FLASH_LED_PIN, LOW);
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nWi-Fi OK, IP %s\n", WiFi.localIP().toString().c_str());
}
void connectMqtt() {
while (!mqtt.connected()) {
Serial.print("Connecting to MQTT... ");
// Last Will: el broker publica "offline" (retenido) si la cámara desaparece
if (mqtt.connect(CLIENT_ID, TOPIC_STATUS, 1, true, "offline")) {
Serial.println("connected");
mqtt.publish(TOPIC_STATUS, "online", true);
mqtt.subscribe(TOPIC_CMD);
} else {
Serial.printf("failed, state=%d, retrying in 5 s\n", mqtt.state());
delay(5000);
}
}
}
void publishSnapshot() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Capture failed");
return;
}
if (fb->len > MAX_PAYLOAD) {
Serial.printf("Frame %u bytes exceeds limit, skipped\n", (unsigned)fb->len);
esp_camera_fb_return(fb);
return;
}
bool ok = mqtt.beginPublish(TOPIC_SNAP, fb->len, false);
if (ok) {
size_t sent = mqtt.write(fb->buf, fb->len);
ok = mqtt.endPublish() && sent == fb->len;
}
Serial.printf("Snapshot %u bytes -> %s\n", (unsigned)fb->len, ok ? "sent" : "FAILED");
esp_camera_fb_return(fb);
}
void setup() {
Serial.begin(115200);
pinMode(FLASH_LED_PIN, OUTPUT);
digitalWrite(FLASH_LED_PIN, LOW);
if (!initCamera()) {
delay(5000);
ESP.restart();
}
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(10); // las escrituras grandes necesitan más que el valor por defecto
connectMqtt();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
uint32_t now = millis();
if (captureRequested || now - lastCapture >= CAPTURE_INTERVAL) {
captureRequested = false;
lastCapture = now;
publishSnapshot();
}
}
Graba la placa con GPIO0 conectado a GND, después retira el puente y pulsa reset. El monitor serie debería mostrar la dirección IP, una conexión MQTT correcta y una línea por instantánea con su tamaño en bytes.
Recibir y guardar las imágenes con Python
En tu equipo, este script se suscribe al topic de instantáneas y escribe cada imagen en un archivo con marca de tiempo. También muestra cómo solicitar una captura bajo demanda.
import time
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
PREFIX = "connectmq/demo/cam-7f3a"
OUT = Path("snapshots")
OUT.mkdir(exist_ok=True)
def on_connect(client, userdata, flags, reason_code, properties):
print("Connected:", reason_code)
client.subscribe(f"{PREFIX}/snapshot", qos=0)
client.subscribe(f"{PREFIX}/status", qos=1)
def on_message(client, userdata, msg):
if msg.topic.endswith("/status"):
print("Camera status:", msg.payload.decode())
return
if not msg.payload.startswith(b"\xff\xd8"):
print("Payload is not a JPEG, ignored")
return
name = OUT / f"{datetime.now():%Y%m%d-%H%M%S-%f}.jpg"
name.write_bytes(msg.payload)
print(f"Saved {name} ({len(msg.payload)} bytes)")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="cam-7f3a-viewer")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=60)
client.loop_start()
time.sleep(2)
client.publish(f"{PREFIX}/cmd", "capture", qos=1) # pide una imagen inmediata
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
client.loop_stop()
client.disconnect()
La comprobación de los bytes FF D8 (el marcador de inicio de imagen de JPEG) evita que el script guarde basura si otra persona publica texto en tu topic. También puedes disparar una captura desde la línea de comandos:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cam-7f3a-cli -t connectmq/demo/cam-7f3a/cmd -m capture
Llevar el montaje ESP32-CAM MQTT a producción
El área pública puede leerla cualquiera, así que nunca apuntes una cámara a nada privado mientras la uses. Para un despliegue real, crea un broker en un plan de broker MQTT gestionado y cambia el sketch a TLS:
- Regístrate en ConnectMQ, crea un broker y copia el usuario, la contraseña y el client ID generados.
- Sustituye
WiFiClientporWiFiClientSecure, carga el certificado ISRG Root X1 consetCACert()y cambia el puerto a 8883. - Llama a
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline"). ConnectMQ vincula los client IDs a las credenciales emitidas, así que usa el del portal. - Aumenta
MAX_PAYLOADsegún tu plan, por ejemplo justo por debajo de 256 KB en el plan gratuito Serverless.
TLS añade consumo de RAM además de los búferes de la cámara. El módulo de AI-Thinker tiene PSRAM, que mantiene los frame buffers fuera de la memoria interna, pero si ves fallos de reserva de memoria, baja a un solo frame buffer y a un tamaño de imagen menor. La guía de MQTT con SSL en ESP32 explica paso a paso la carga de certificados.
Lista de comprobación para resolver problemas
- "Brownout detector was triggered" al arrancar o capturar. Usa una alimentación sólida de 5 V y cables cortos; la cámara y la radio Wi-Fi provocan picos de corriente a la vez.
Camera init failed: 0x105o similar. El cable plano está suelto o el mapa de pines no coincide con tu variante de placa.- Instantánea "FAILED" pero los mensajes pequeños funcionan. La imagen supera el límite del broker o el socket agotó el tiempo a mitad de la escritura. Reduce
frame_size, sube el número dejpeg_qualityo aumentasetSocketTimeout(). - La cámara se desconecta justo después de una publicación grande. En el broker público, un mensaje de más de 64 KB se rechaza. Comprueba el tamaño impreso en el log serie.
- Las imágenes salen verdes u oscuras al principio. El OV2640 necesita unas cuantas imágenes para estabilizar la autoexposición; captura y descarta una imagen tras el arranque.
- Bucles de reconexión tras caídas del Wi-Fi. Añade backoff y mantén el Last Will para que los dashboards muestren la cámara desconectada; consulta reconexión MQTT en ESP32.
Consejos de diseño para los topics de cámara
Mantén las imágenes binarias en su propio topic y envía los metadatos por separado. Un pequeño mensaje JSON en un topic snapshot/meta con el tamaño, la resolución y el motivo del disparo permite a los dashboards reaccionar sin descargar la imagen. Evita retener mensajes de imagen en brokers compartidos salvo que necesites el comportamiento de "última foto", porque cada nuevo suscriptor recibe de inmediato el JPEG completo. Si varios servicios consumen las imágenes, la difusión de MQTT se encarga de ello, y un broker MQTT en la nube te ahorra tener que operar esa infraestructura.
Empieza con ConnectMQ
Prueba primero tu cámara en el área pública gratuita y pasa a un broker privado cuando las imágenes importen. ConnectMQ te ofrece un endpoint TLS, credenciales generadas y un explorador de topics en vivo para ver llegar el tráfico de instantáneas. Crea un broker en el plan gratuito Serverless en app.connectmq.com, sin tarjeta de crédito, y cambia el host y las credenciales en el sketch.
Preguntas frecuentes
¿Puede una ESP32-CAM transmitir vídeo por MQTT?
Puede enviar imágenes en rápida sucesión, pero MQTT no está diseñado para vídeo y en los brokers compartidos se aplican límites de frecuencia. Usa MQTT para instantáneas y capturas disparadas por eventos, y un protocolo de streaming para vídeo en directo.
¿Cuál es la imagen más grande que puedo publicar?
Depende del broker: 64 KB en el área pública de pruebas, 256 KB en el plan Serverless (Free) y 1 MB en Serverless (Paid). Comprueba fb->len y elige un tamaño de imagen que quede por debajo de tu límite.
¿Tengo que ampliar el búfer de PubSubClient para las imágenes?
No si usas beginPublish(), write() y endPublish(). Esas llamadas envían la imagen directamente, así que el búfer por defecto basta para la cabecera.
¿Deben ser retenidos los mensajes de instantáneas?
Normalmente no. Retener un mensaje binario grande implica que cada nuevo suscriptor lo recibe de inmediato; retén solo el pequeño topic de estado salvo que quieras específicamente un comportamiento de "última imagen".



