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_SVGAe acima podem passar de 64 KB; verifiquefb->lenno 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:
beginPublish(topic, length, retained)escreve o cabeçalho MQTT com o tamanho final.write(buffer, length)envia os bytes do JPEG diretamente do frame buffer da câmera.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:
- Cadastre-se no ConnectMQ, crie um broker e copie o usuário, a senha e o client ID gerados.
- Substitua
WiFiClientporWiFiClientSecure, carregue o certificado ISRG Root X1 comsetCACert()e mude a porta para 8883. - 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. - Aumente
MAX_PAYLOADde 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
- "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.
Camera init failed: 0x105ou semelhante. O cabo flat está solto ou o mapa de pinos não corresponde à variante da sua placa.- 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 dejpeg_qualityou aumentesetSocketTimeout(). - 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.
- 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.
- 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".



