ESP32 y Arduino

ESP32-CAM MQTT: captura instantáneas JPEG y publícalas como payloads binarios

Convierte una ESP32-CAM de AI-Thinker en una cámara MQTT que publica instantáneas JPEG bajo demanda o con un temporizador, respeta los límites de mensaje del broker y guarda cada imagen con un pequeño suscriptor en Python.

Placa ESP32-CAM de AI-Thinker publicando una instantánea JPEG en un broker en la nube en un proyecto esp32-cam mqtt
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

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_SVGA y superiores pueden superar los 64 KB; comprueba fb->len en 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:

  1. beginPublish(topic, length, retained) escribe la cabecera MQTT con la longitud final.
  2. write(buffer, length) envía los bytes del JPEG directamente desde el frame buffer de la cámara.
  3. 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:

  1. Regístrate en ConnectMQ, crea un broker y copia el usuario, la contraseña y el client ID generados.
  2. Sustituye WiFiClient por WiFiClientSecure, carga el certificado ISRG Root X1 con setCACert() y cambia el puerto a 8883.
  3. 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.
  4. Aumenta MAX_PAYLOAD segú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

  1. "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.
  2. Camera init failed: 0x105 o similar. El cable plano está suelto o el mapa de pines no coincide con tu variante de placa.
  3. 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 de jpeg_quality o aumenta setSocketTimeout().
  4. 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.
  5. 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.
  6. 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".

Artículos relacionados

Ejemplo de ESP-IDF MQTT: crea un cliente esp-mqtt desde cero

Construye un proyecto ESP-IDF desde una carpeta vacía con el componente esp-mqtt: configuración de Wi-Fi en modo estación, el manejador de eventos MQTT, publicación y suscripción, propiedades de MQTT 5 y TLS con el bundle de certificados.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.