Dieses ESP32-CAM-MQTT-Tutorial zeigt, wie Sie auf einem AI-Thinker ESP32-CAM JPEG-Schnappschüsse aufnehmen und jedes Bild als rohe binäre MQTT-Payload veröffentlichen. Am Ende haben Sie eine Kamera, die per Timer oder auf Befehl ein Bild sendet, ein Python-Skript, das jedes Bild auf der Festplatte speichert, und ein klares Verständnis der Größengrenzen für Nachrichten, die darüber entscheiden, ob ein Bild überhaupt ankommt.
Warum Kamerabilder über MQTT senden?
MQTT wird meist mit winzigen Sensormesswerten verbunden, doch dem Protokoll selbst ist egal, was in einer Payload steckt. Ein JPEG ist nur ein Byte-Array, und die Spezifikation erlaubt Payloads, die weit größer sind als jeder Schnappschuss einer kleinen Kamera. Bilder über denselben Broker wie Ihre Telemetrie zu senden, hat echte Vorteile:
- Eine Verbindung, ein Sicherheitsmodell. Die Kamera verwendet dieselben Zugangsdaten, TLS-Einstellungen und Topic-Konventionen wie jedes andere Gerät.
- Ereignisgesteuerte Aufnahme. Ein Bewegungsmelder, ein Türkontakt oder eine Dashboard-Schaltfläche kann einen Befehl veröffentlichen, und die Kamera antwortet mit einem Bild. Kein Polling, kein HTTP-Server auf dem Gerät.
- Fan-out inklusive. Mehrere Subscriber (ein Archivskript, ein Dashboard, ein Alarm-Flow) können dasselbe Bild empfangen, ohne dass die Kamera zusätzliche Arbeit leistet.
Dasselbe Muster findet sich in der Fertigung: Eine günstige Kamera an einer Montagelinie kann bei jedem Takt oder auf Anforderung der SPS einen Schnappschuss zur Sichtprüfung oder Dokumentation senden, etwa unter acme/werk-stuttgart/montage/linie-1/station-4/snapshot. Mehr zur KI-gestützten Auswertung zeigt der Beitrag zur KI-gestützten visuellen Inspektion.
MQTT ist allerdings kein Videoprotokoll. Es eignet sich gut für Schnappschüsse alle paar Sekunden oder bei Ereignissen und schlecht für kontinuierliches Video-Streaming. Wenn Sie Live-Video benötigen, verwenden Sie ein spezielles Streaming-Protokoll und behalten Sie MQTT für Steuerung und Metadaten.
Benötigte Hardware und Software
| Komponente | Hinweise |
|---|---|
| AI-Thinker ESP32-CAM mit OV2640 | Das verbreitetste Modul; die folgende Pin-Belegung gilt für dieses Board |
| USB-Seriell-Adapter (FTDI oder ESP32-CAM-MB) | GPIO0 muss beim Flashen auf GND gezogen werden |
| Stabile 5-V-Versorgung, mindestens 1 A empfohlen | Spannungseinbrüche bei der Aufnahme sind die häufigste Ursache für Resets |
| Arduino IDE 2.x mit dem esp32-Board-Paket | Board: „AI Thinker ESP32-CAM“ |
| Bibliothek PubSubClient von Nick O'Leary | Installation über den Bibliotheksverwalter |
| Python 3 mit paho-mqtt 2.x | Für das Empfangsskript |
Wenn Sie noch nie einen ESP32 mit einem Broker verbunden haben, behandelt das ESP32-IoT-Tutorial WLAN, PubSubClient-Grundlagen und Reconnect-Logik ausführlicher.
Die Nachrichtengröße ist die eigentliche Einschränkung
Bevor Sie Code schreiben, legen Sie eine Auflösung fest, denn die Größe des JPEG entscheidet, an welchen Broker Sie es senden können und wie viel RAM Sie brauchen.
| Ziel der Veröffentlichung | Maximale Nachrichtengröße |
|---|---|
| Öffentlicher MQTT-Broker broker.connectmq.com | 64 KB |
| Serverless (Free) Plan | 256 KB |
| Serverless (Paid) Plan | 1 MB |
Die JPEG-Größe hängt von der Auflösung, der Einstellung jpeg_quality (eine niedrigere Zahl bedeutet höhere Qualität und größere Dateien) und der Szene selbst ab. Eine unruhige, detailreiche Szene komprimiert schlechter als eine weiße Wand. Praktische Ausgangspunkte:
FRAMESIZE_QVGA(320x240) erzeugt kleine Bilder, die problemlos unter 64 KB bleiben – ideal zum Testen mit dem öffentlichen MQTT-Broker.FRAMESIZE_VGA(640x480) ist ein guter Kompromiss für die Überwachung und passt meist in den kostenlosen Plan.FRAMESIZE_SVGAund höher können 64 KB überschreiten; prüfen Siefb->lenim seriellen Monitor, bevor Sie sich darauf verlassen.
Der folgende Sketch prüft die Bildlänge gegen ein Limit und überspringt zu große Bilder, statt den Broker die Verbindung trennen zu lassen.
Große Payloads mit PubSubClient streamen
PubSubClient kopiert Topic und Payload normalerweise in einen internen Puffer, der standardmäßig 256 Bytes groß ist. Sie könnten ihn mit setBufferSize() vergrößern, doch einen 50-KB-Puffer zusätzlich zum Bildpuffer der Kamera zu reservieren, verschwendet RAM. Besser ist die Streaming-API:
beginPublish(topic, length, retained)schreibt den MQTT-Header mit der endgültigen Länge.write(buffer, length)sendet die JPEG-Bytes direkt aus dem Bildpuffer der Kamera.endPublish()schließt das Paket ab.
Die Payload wird nie kopiert, sodass selbst große Bilder nur einen kleinen MQTT-Puffer benötigen. Beachten Sie, dass PubSubClient mit QoS 0 veröffentlicht, was für Schnappschüsse in Ordnung ist: Geht einer verloren, ist der nächste nur Sekunden entfernt. Mehr zur Puffereinstellung im Allgemeinen finden Sie unter PubSubClient-Puffergröße.
Der ESP32-CAM-MQTT-Sketch
Der Topic-Aufbau verwendet ein eindeutiges Präfix, damit Ihre Bilder nicht mit denen anderer Nutzer des öffentlichen MQTT-Brokers kollidieren:
| Topic | Richtung | Payload |
|---|---|---|
connectmq/demo/cam-7f3a/snapshot |
Kamera veröffentlicht | rohe JPEG-Bytes |
connectmq/demo/cam-7f3a/status |
Kamera veröffentlicht, retained | online / offline (Last Will) |
connectmq/demo/cam-7f3a/cmd |
Kamera abonniert | capture, flash_on, flash_off |
Ersetzen Sie cam-7f3a durch einen eigenen zufälligen String.
#include <WiFi.h>
#include <PubSubClient.h>
#include "esp_camera.h"
// ---------- Wi-Fi and MQTT settings ----------
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"; // muss nicht leer und eindeutig sein
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; // unter dem öffentlichen Limit von 64 KB bleiben
const uint32_t CAPTURE_INTERVAL = 30000; // zeitgesteuerter Schnappschuss alle 30 s
const int FLASH_LED_PIN = 4;
// ---------- AI-Thinker ESP32-CAM pin map ----------
#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; // QVGA versuchen, wenn Bilder zu groß sind
config.jpeg_quality = 12; // 10-63, niedriger = bessere Qualität, größere Datei
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: Broker veröffentlicht "offline" (retained), wenn die Kamera verschwindet
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); // große Schreibvorgänge brauchen mehr als den Standardwert
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();
}
}
Flashen Sie das Board mit GPIO0 auf GND, entfernen Sie dann die Brücke und drücken Sie Reset. Der serielle Monitor sollte die IP-Adresse, eine erfolgreiche MQTT-Verbindung und pro Schnappschuss eine Zeile mit seiner Größe in Bytes anzeigen.
Die Bilder in Python empfangen und speichern
Auf Ihrem Rechner abonniert dieses Skript das Snapshot-Topic und schreibt jedes Bild in eine Datei mit Zeitstempel. Es zeigt außerdem, wie Sie eine Aufnahme auf Anforderung auslösen.
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) # sofort ein Bild anfordern
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
client.loop_stop()
client.disconnect()
Die Prüfung auf die Bytes FF D8 (die JPEG-Startmarkierung) schützt das Skript davor, Unsinn zu speichern, wenn jemand anderes Text auf Ihrem Topic veröffentlicht. Sie können eine Aufnahme auch über die Kommandozeile auslösen:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cam-7f3a-cli -t connectmq/demo/cam-7f3a/cmd -m capture
Das ESP32-CAM-MQTT-Setup in die Produktion überführen
Der öffentliche MQTT-Broker ist für alle lesbar, richten Sie die Kamera dabei also nie auf etwas Privates. Für einen echten Einsatz wechseln Sie zu einem verwalteten MQTT-Broker und stellen den Sketch auf TLS um:
- Registrieren Sie sich bei ConnectMQ, erstellen Sie einen Broker und kopieren Sie den generierten Benutzernamen, das Passwort und die Client ID.
- Ersetzen Sie
WiFiClientdurchWiFiClientSecure, laden Sie das Zertifikat ISRG Root X1 mitsetCACert()und ändern Sie den Port auf 8883. - Rufen Sie
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline")auf. ConnectMQ bindet Client IDs an die ausgegebenen Zugangsdaten, verwenden Sie also die aus dem Portal. - Erhöhen Sie
MAX_PAYLOADpassend zu Ihrem Plan, zum Beispiel auf knapp unter 256 KB im kostenlosen Serverless-Plan.
TLS benötigt zusätzlich zu den Kamerapuffern weiteren RAM. Das AI-Thinker-Modul hat PSRAM, das die Bildpuffer aus dem internen Speicher heraushält, doch wenn Sie Allokationsfehler sehen, reduzieren Sie auf einen Bildpuffer und eine kleinere Bildgröße. Die Anleitung zu ESP32 MQTT SSL führt Schritt für Schritt durch das Laden von Zertifikaten.
Checkliste zur Fehlerbehebung
- „Brownout detector was triggered“ beim Booten oder bei der Aufnahme. Verwenden Sie eine solide 5-V-Versorgung und kurze Kabel; Kamera und WLAN-Funk ziehen gleichzeitig Stromspitzen.
Camera init failed: 0x105oder Ähnliches. Das Flachbandkabel sitzt locker, oder die Pin-Belegung passt nicht zu Ihrer Board-Variante.- Snapshot „FAILED“, aber kleine Nachrichten funktionieren. Das Bild überschreitet das Broker-Limit, oder der Socket ist mitten im Schreiben in einen Timeout gelaufen. Verringern Sie
frame_size, erhöhen Sie die Zahl beijpeg_qualityoder vergrößern SiesetSocketTimeout(). - Die Kamera trennt sich direkt nach einer großen Veröffentlichung. Auf dem öffentlichen MQTT-Broker wird eine Nachricht über 64 KB abgelehnt. Prüfen Sie die im seriellen Log ausgegebene Größe.
- Bilder wirken anfangs grün oder dunkel. Die OV2640 braucht einige Bilder, bis sich die Belichtungsautomatik eingependelt hat; nehmen Sie nach dem Booten ein Bild auf und verwerfen Sie es.
- Reconnect-Schleifen nach WLAN-Abbrüchen. Ergänzen Sie ein Backoff und behalten Sie den Last Will bei, damit Dashboards die Kamera als offline anzeigen; siehe ESP32 MQTT Reconnect.
Design-Tipps für Kamera-Topics
Halten Sie binäre Bilder auf einem eigenen Topic und senden Sie Metadaten separat. Eine kleine JSON-Nachricht auf einem Topic snapshot/meta mit Größe, Auflösung und Auslösegrund lässt Dashboards reagieren, ohne das Bild herunterzuladen. Vermeiden Sie es, Bildnachrichten auf geteilten Brokern als retained zu speichern, sofern Sie kein Verhalten „letztes Bild“ brauchen, denn jeder neue Subscriber erhält dann sofort das vollständige JPEG. Wenn mehrere Dienste die Bilder konsumieren, übernimmt das MQTT-Fan-out, und ein Cloud-MQTT-Broker erspart Ihnen den Betrieb dieser Infrastruktur.
Starten Sie mit ConnectMQ
Testen Sie Ihre Kamera zuerst auf dem kostenlosen öffentlichen MQTT-Broker und wechseln Sie zu einem privaten Broker, sobald die Bilder wichtig werden. ConnectMQ bietet Ihnen einen TLS-Endpunkt, generierte Zugangsdaten und einen Live-Topic-Explorer, in dem Sie den Snapshot-Verkehr eintreffen sehen. Erstellen Sie einen Broker im kostenlosen Serverless-Plan unter app.connectmq.com – keine Kreditkarte erforderlich – und tauschen Sie Host und Zugangsdaten im Sketch aus.
Häufig gestellte Fragen
Kann ein ESP32-CAM Video über MQTT streamen?
Er kann Bilder in schneller Folge senden, doch MQTT ist nicht für Video ausgelegt, und auf geteilten Brokern gelten Ratenlimits. Verwenden Sie MQTT für Schnappschüsse und ereignisgesteuerte Aufnahmen und ein Streaming-Protokoll für Live-Video.
Wie groß darf ein veröffentlichtes Bild höchstens sein?
Das hängt vom Broker ab: 64 KB auf dem öffentlichen MQTT-Broker, 256 KB im Serverless (Free) Plan und 1 MB im Serverless (Paid) Plan. Prüfen Sie fb->len und wählen Sie eine Bildgröße, die unter Ihrem Limit bleibt.
Muss ich den PubSubClient-Puffer für Bilder vergrößern?
Nicht, wenn Sie beginPublish(), write() und endPublish() verwenden. Diese Aufrufe streamen das Bild direkt, sodass der Standardpuffer für den Header ausreicht.
Sollten Snapshot-Nachrichten retained sein?
In der Regel nicht. Eine große binäre Nachricht als retained zu speichern, bedeutet, dass jeder neue Subscriber sie sofort erhält; speichern Sie nur das kleine Status-Topic als retained, sofern Sie nicht ausdrücklich ein Verhalten „neuestes Bild“ wünschen.



