ESP32 e Arduino

ESP32-CAM MQTT: Capture Fotos JPEG e Publique-as como Payloads Binários

Transforme um ESP32-CAM AI-Thinker em uma câmera MQTT que publica fotos JPEG sob demanda ou por timer, respeita os limites de mensagem do broker e salva cada quadro com um pequeno assinante em Python.

Placa ESP32-CAM AI-Thinker publicando uma foto JPEG em um broker na nuvem em um projeto esp32-cam mqtt
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

Este tutorial ESP32-CAM MQTT mostra como capturar fotos JPEG em um ESP32-CAM AI-Thinker e publicar cada quadro como um payload MQTT binário bruto. Ao final, você terá uma câmera que envia uma imagem por timer ou por comando, um script Python que salva cada imagem em disco e uma visão clara dos limites de tamanho de mensagem que decidem se um quadro chega ou não.

Por que enviar imagens de câmera via MQTT?

O MQTT costuma ser associado a pequenas leituras de sensores, mas o protocolo em si não se importa com o conteúdo do payload. Um JPEG é apenas um array de bytes, e a especificação permite payloads muito maiores que qualquer foto que uma câmera pequena vá produzir. Enviar imagens pelo mesmo broker da sua telemetria traz vantagens reais:

  • Uma conexão, um modelo de segurança. A câmera usa as mesmas credenciais, configurações TLS e convenções de topic que todos os outros dispositivos.
  • Captura orientada a eventos. Um sensor de movimento, um contato de porta ou um botão no dashboard pode publicar um comando e a câmera responde com um quadro. Sem polling, sem servidor HTTP no dispositivo.
  • Distribuição sem custo extra. Vários assinantes (um script de arquivamento, um dashboard, um fluxo de alertas) podem receber o mesmo quadro sem trabalho adicional da câmera.

O MQTT, porém, não é um protocolo de vídeo. Ele se encaixa bem em fotos a cada poucos segundos ou por eventos, e mal em streaming contínuo de vídeo. Se você precisa de vídeo ao vivo, use um protocolo de streaming dedicado e mantenha o MQTT para controle e metadados.

Hardware e software necessários

Item Observações
ESP32-CAM AI-Thinker com OV2640 O módulo mais comum; o mapa de pinos abaixo é para esta placa
Adaptador USB-serial (FTDI ou ESP32-CAM-MB) O GPIO0 precisa estar ligado ao GND durante a gravação
Fonte estável de 5 V, recomendado pelo menos 1 A Brownouts durante a captura são a principal causa de reinicializações
Arduino IDE 2.x com o pacote de placas esp32 Placa: "AI Thinker ESP32-CAM"
Biblioteca PubSubClient de Nick O'Leary Instalada pelo Library Manager
Python 3 com paho-mqtt 2.x Para o script receptor

Se você nunca conectou um ESP32 a um broker, o tutorial de IoT com ESP32 aborda Wi-Fi, o básico do PubSubClient e a lógica de reconexão com mais profundidade.

O tamanho da mensagem é a verdadeira restrição

Antes de escrever qualquer código, defina uma resolução, porque o tamanho do JPEG determina para qual broker você pode enviá-lo e quanta RAM você precisa.

Onde você publica Tamanho máximo de mensagem
Área pública de testes do broker.connectmq.com 64 KB
Plano Serverless (Free) 256 KB
Plano Serverless (Paid) 1 MB

O tamanho do JPEG depende da resolução, da configuração jpeg_quality (número menor significa mais qualidade e arquivos maiores) e da própria cena. Uma cena movimentada e cheia de detalhes comprime pior que uma parede branca. Pontos de partida práticos:

  • FRAMESIZE_QVGA (320x240) gera quadros pequenos que cabem facilmente em menos de 64 KB, ideal para testar no servidor MQTT público (gratuito).
  • FRAMESIZE_VGA (640x480) é um bom meio-termo para monitoramento e normalmente cabe no plano gratuito.
  • FRAMESIZE_SVGA e acima podem passar de 64 KB; verifique fb->len no monitor serial antes de depender deles.

O sketch abaixo compara o tamanho do quadro com um limite e descarta quadros grandes demais, em vez de deixar o broker derrubar a conexão.

Enviando payloads grandes em streaming com PubSubClient

O PubSubClient normalmente copia o topic e o payload para um buffer interno, que tem 256 bytes por padrão. Você poderia aumentá-lo com setBufferSize(), mas alocar um buffer de 50 KB além do frame buffer da câmera desperdiça RAM. A melhor abordagem é a API de streaming:

  1. beginPublish(topic, length, retained) escreve o cabeçalho MQTT com o tamanho final.
  2. write(buffer, length) envia os bytes do JPEG diretamente do frame buffer da câmera.
  3. endPublish() conclui o pacote.

O payload nunca é copiado, então mesmo quadros grandes precisam apenas de um pequeno buffer MQTT. Observe que o PubSubClient publica em QoS 0, o que é aceitável para fotos: se uma se perder, a próxima está a segundos de distância. Para mais sobre ajuste de buffer em geral, veja tamanho do buffer do PubSubClient.

O sketch ESP32-CAM MQTT

A organização de topics usa um prefixo único para que seus quadros não colidam com os de mais ninguém no servidor MQTT público:

Topic Direção Payload
connectmq/demo/cam-7f3a/snapshot câmera publica bytes JPEG brutos
connectmq/demo/cam-7f3a/status câmera publica, retido online / offline (Last Will)
connectmq/demo/cam-7f3a/cmd câmera assina capture, flash_on, flash_off

Substitua cam-7f3a pela sua própria string aleatória.

#include <WiFi.h>
#include <PubSubClient.h>
#include "esp_camera.h"

// ---------- configurações de Wi-Fi e 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";           // deve ser não vazio e ú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;   // fica abaixo do limite público de 64 KB
const uint32_t CAPTURE_INTERVAL = 30000;       // foto por timer a cada 30 s
const int      FLASH_LED_PIN    = 4;

// ---------- mapa de pinos do ESP32-CAM 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;   // tente QVGA se os quadros forem grandes demais
  config.jpeg_quality = 12;              // 10-63, menor = mais qualidade, arquivo maior
  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: o broker publica "offline" (retido) se a câmera desaparecer
    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);   // escritas grandes precisam de mais que o padrão
  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();
  }
}

Grave a placa com o GPIO0 ligado ao GND, depois remova o jumper e pressione reset. O monitor serial deve mostrar o endereço IP, uma conexão MQTT bem-sucedida e uma linha por foto com seu tamanho em bytes.

Receba e salve as imagens em Python

No seu computador, este script assina o topic de fotos e grava cada quadro em um arquivo com timestamp. Ele também mostra como solicitar uma captura sob 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)   # pede um quadro imediato

try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    client.loop_stop()
    client.disconnect()

A verificação dos bytes FF D8 (o marcador de início de imagem do JPEG) impede que o script salve lixo se outra pessoa publicar texto no seu topic. Você também pode disparar uma captura pela linha de comando:

mosquitto_pub -h broker.connectmq.com -p 1883 -i cam-7f3a-cli -t connectmq/demo/cam-7f3a/cmd -m capture

Levando a configuração ESP32-CAM MQTT para produção

O servidor MQTT público pode ser lido por todos, então nunca aponte uma câmera para algo privado enquanto o usa; imagens de pessoas também são dados pessoais sob a LGPD. Para uma implantação real, crie um broker em um plano de broker MQTT gerenciado e mude o sketch para TLS:

  1. Cadastre-se no ConnectMQ, crie um broker e copie o usuário, a senha e o client ID gerados.
  2. Substitua WiFiClient por WiFiClientSecure, carregue o certificado ISRG Root X1 com setCACert() e mude a porta para 8883.
  3. Chame mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline"). O ConnectMQ vincula os client IDs às credenciais emitidas, então use o do portal.
  4. Aumente MAX_PAYLOAD de acordo com o seu plano, por exemplo um pouco abaixo de 256 KB no plano gratuito Serverless.

O TLS aumenta o uso de RAM além dos buffers da câmera. O módulo AI-Thinker tem PSRAM, o que mantém os frame buffers fora da memória interna, mas se você vir falhas de alocação, reduza para um frame buffer e um tamanho de quadro menor. O guia de MQTT com SSL no ESP32 mostra o carregamento de certificados passo a passo.

Checklist de solução de problemas

  1. "Brownout detector was triggered" no boot ou na captura. Use uma fonte de 5 V estável e cabos curtos; a câmera e o rádio Wi-Fi geram picos de corrente ao mesmo tempo.
  2. Camera init failed: 0x105 ou semelhante. O cabo flat está solto ou o mapa de pinos não corresponde à variante da sua placa.
  3. Foto "FAILED", mas mensagens pequenas funcionam. O quadro ultrapassa o limite do broker ou o socket deu timeout no meio da escrita. Reduza frame_size, aumente o número de jpeg_quality ou aumente setSocketTimeout().
  4. A câmera desconecta logo após uma publicação grande. No servidor MQTT público, uma mensagem maior que 64 KB é rejeitada. Confira o tamanho impresso no log serial.
  5. As imagens saem esverdeadas ou escuras no início. O OV2640 precisa de alguns quadros para estabilizar a exposição automática; capture e descarte um quadro após o boot.
  6. Loops de reconexão após quedas de Wi-Fi. Adicione backoff e mantenha o Last Will para que os dashboards mostrem a câmera offline; veja reconexão MQTT no ESP32.

Dicas de design para topics de câmera

Mantenha os quadros binários em um topic próprio e envie os metadados separadamente. Uma pequena mensagem JSON em um topic snapshot/meta com tamanho, resolução e motivo do disparo permite que os dashboards reajam sem baixar a imagem. Evite reter mensagens de imagem em brokers compartilhados, a menos que você precise do comportamento de "última foto", porque cada novo assinante recebe imediatamente o JPEG completo. Se vários serviços consomem os quadros, a distribuição do MQTT cuida disso, e um broker MQTT na nuvem elimina a necessidade de operar essa infraestrutura você mesmo.

Comece com o ConnectMQ

Teste sua câmera primeiro no servidor MQTT público e depois migre para um broker privado quando as imagens forem importantes. O ConnectMQ oferece um endpoint TLS, credenciais geradas e um explorador de topics ao vivo para acompanhar a chegada do tráfego de fotos. Crie um broker no plano gratuito Serverless em app.connectmq.com, sem cartão de crédito, e troque o host e as credenciais no sketch.

Perguntas frequentes

Um ESP32-CAM consegue transmitir vídeo via MQTT?

Ele pode enviar quadros em sequência rápida, mas o MQTT não foi projetado para vídeo e há limites de taxa em brokers compartilhados. Use MQTT para fotos e capturas disparadas por eventos, e um protocolo de streaming para vídeo ao vivo.

Qual é a maior imagem que posso publicar?

Depende do broker: 64 KB no servidor MQTT público, 256 KB no plano Serverless (Free) e 1 MB no Serverless (Paid). Verifique fb->len e escolha um tamanho de quadro que fique abaixo do seu limite.

Preciso aumentar o buffer do PubSubClient para imagens?

Não, se você usar beginPublish(), write() e endPublish(). Essas chamadas enviam o quadro diretamente, então o buffer padrão basta para o cabeçalho.

Mensagens de foto devem ser retidas?

Normalmente não. Reter uma mensagem binária grande significa que todo novo assinante a recebe imediatamente; retenha apenas o pequeno topic de status, a menos que você queira especificamente o comportamento de "imagem mais recente".

Artigos relacionados

Exemplo de MQTT com ESP-IDF: Monte um Cliente esp-mqtt do Zero

Monte um projeto ESP-IDF a partir de uma pasta vazia com o componente esp-mqtt: configuração do Wi-Fi em modo station, o handler de eventos MQTT, publicação e inscrição, propriedades do MQTT 5 e TLS com o bundle de certificados.

8 min de leitura

Usando os Recursos do MQTT 5 do esp-mqtt no ESP32 com ESP-IDF

Um projeto ESP-IDF prático que ativa o MQTT 5.0 no esp-mqtt e usa user properties, expiração de mensagens, topic aliases, expiração de sessão e response topics com um broker MQTT 5 real.

8 min de leitura

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.