ESP32 et Arduino

ESP32-CAM MQTT : capturer des images JPEG et les publier en payload binaire

Transformez un ESP32-CAM AI-Thinker en caméra MQTT qui publie des images JPEG à la demande ou à intervalle régulier, respecte les limites de taille des messages du broker et enregistre chaque image grâce à un petit abonné Python.

Carte ESP32-CAM AI-Thinker publiant une image JPEG vers un broker cloud dans un projet esp32-cam mqtt
Essayez-le sur votre propre broker MQTTObtenez un broker MQTT privé et managé avec l’offre Free et partez d’exemples de code prêts à l’emploi.
Essayer gratuitement Exemples de code Aucune carte bancaire requise

Ce tutoriel ESP32-CAM MQTT montre comment capturer des images JPEG sur un ESP32-CAM AI-Thinker et publier chaque image sous forme de payload MQTT binaire brut. À la fin, vous disposerez d'une caméra qui envoie une photo à intervalle régulier ou sur commande, d'un script Python qui enregistre chaque image sur disque, et d'une vision claire des limites de taille de message qui déterminent si une image arrive ou non.

Pourquoi envoyer des images de caméra via MQTT ?

On associe généralement MQTT à de minuscules mesures de capteurs, mais le protocole lui-même ne se soucie pas du contenu d'un payload. Un JPEG n'est qu'un tableau d'octets, et la spécification autorise des payloads bien plus volumineux que toute image produite par une petite caméra. Envoyer des images via le même broker que votre télémétrie présente de vrais avantages :

  • Une connexion, un modèle de sécurité. La caméra utilise les mêmes identifiants, paramètres TLS et conventions de topics que tous les autres appareils.
  • Capture pilotée par les événements. Un détecteur de mouvement, un contact de porte ou un bouton de tableau de bord peut publier une commande, et la caméra répond par une image. Pas de scrutation, pas de serveur HTTP sur l'appareil.
  • Diffusion multiple gratuite. Plusieurs abonnés (un script d'archivage, un tableau de bord, un flow d'alerte) peuvent recevoir la même image sans travail supplémentaire pour la caméra.

MQTT n'est cependant pas un protocole vidéo. Il convient bien à des images toutes les quelques secondes ou sur événement, et mal au streaming vidéo continu. Si vous avez besoin de vidéo en direct, utilisez un protocole de streaming dédié et réservez MQTT au pilotage et aux métadonnées.

Matériel et logiciels nécessaires

Élément Remarques
ESP32-CAM AI-Thinker avec OV2640 Le module le plus courant ; le brochage ci-dessous concerne cette carte
Adaptateur USB-série (FTDI ou ESP32-CAM-MB) GPIO0 doit être reliée à GND pendant le flashage
Alimentation 5 V stable, 1 A minimum recommandé Les chutes de tension pendant la capture sont la première cause de redémarrage
Arduino IDE 2.x avec le paquet de cartes esp32 Carte : « AI Thinker ESP32-CAM »
Bibliothèque PubSubClient de Nick O'Leary Installée depuis le gestionnaire de bibliothèques
Python 3 avec paho-mqtt 2.x Pour le script de réception

Si vous n'avez jamais connecté d'ESP32 à un broker, le tutoriel IoT ESP32 détaille le Wi-Fi, les bases de PubSubClient et la logique de reconnexion.

La taille des messages, véritable contrainte

Avant d'écrire la moindre ligne de code, choisissez une résolution, car la taille du JPEG détermine le broker vers lequel vous pouvez l'envoyer et la quantité de RAM nécessaire.

Destination de la publication Taille maximale des messages
Serveur MQTT public gratuit (broker.connectmq.com) 64 Ko
Offre Serverless (Free) 256 Ko
Offre Serverless (Paid) 1 Mo

La taille d'un JPEG dépend de la résolution, du paramètre jpeg_quality (un nombre plus bas signifie une meilleure qualité et des fichiers plus gros) et de la scène elle-même. Une scène chargée et détaillée se compresse moins bien qu'un mur blanc. Points de départ pratiques :

  • FRAMESIZE_QVGA (320x240) produit de petites images qui tiennent facilement sous 64 Ko, idéales pour tester sur le serveur MQTT public gratuit.
  • FRAMESIZE_VGA (640x480) est un bon compromis pour la surveillance et tient généralement dans l'offre gratuite.
  • FRAMESIZE_SVGA et au-delà peuvent dépasser 64 Ko ; vérifiez fb->len dans le moniteur série avant de vous y fier.

Le sketch ci-dessous compare la taille de l'image à une limite et ignore les images trop volumineuses au lieu de laisser le broker couper la connexion.

Envoyer de gros payloads en streaming avec PubSubClient

PubSubClient copie normalement le topic et le payload dans un buffer interne, de 256 octets par défaut. Vous pourriez l'agrandir avec setBufferSize(), mais allouer un buffer de 50 Ko en plus du frame buffer de la caméra gaspille de la RAM. La meilleure approche est l'API de streaming :

  1. beginPublish(topic, length, retained) écrit l'en-tête MQTT avec la longueur finale.
  2. write(buffer, length) envoie les octets du JPEG directement depuis le frame buffer de la caméra.
  3. endPublish() termine le paquet.

Le payload n'est jamais copié : même les grandes images ne nécessitent qu'un petit buffer MQTT. Notez que PubSubClient publie en QoS 0, ce qui convient aux images : si l'une se perd, la suivante n'est qu'à quelques secondes. Pour en savoir plus sur le réglage des buffers en général, consultez la taille du buffer de PubSubClient.

Le sketch ESP32-CAM MQTT

L'organisation des topics utilise un préfixe unique pour que vos images n'entrent pas en collision avec celles des autres utilisateurs du serveur MQTT public gratuit :

Topic Sens Payload
connectmq/demo/cam-7f3a/snapshot la caméra publie octets JPEG bruts
connectmq/demo/cam-7f3a/status la caméra publie, retained online / offline (Last Will)
connectmq/demo/cam-7f3a/cmd la caméra s'abonne capture, flash_on, flash_off

Remplacez cam-7f3a par votre propre chaîne aléatoire.

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

// ---------- paramètres Wi-Fi et 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";           // doit être non vide et unique
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;   // rester sous la limite publique de 64 Ko
const uint32_t CAPTURE_INTERVAL = 30000;       // image programmée toutes les 30 s
const int      FLASH_LED_PIN    = 4;

// ---------- brochage de l'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;   // essayez QVGA si les images sont trop grosses
  config.jpeg_quality = 12;              // 10-63, plus bas = meilleure qualité, fichier plus gros
  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 : le broker publie « offline » (retained) si la caméra disparaît
    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);   // les grosses écritures demandent plus que la valeur par défaut
  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();
  }
}

Flashez la carte avec GPIO0 reliée à GND, puis retirez le cavalier et appuyez sur reset. Le moniteur série doit afficher l'adresse IP, une connexion MQTT réussie et une ligne par image avec sa taille en octets.

Recevoir et enregistrer les images en Python

Sur votre ordinateur, ce script s'abonne au topic des images et écrit chacune dans un fichier horodaté. Il montre aussi comment demander une capture à la demande.

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)   # demander une image immédiate

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

La vérification des octets FF D8 (le marqueur de début d'image JPEG) évite au script d'enregistrer n'importe quoi si quelqu'un d'autre publie du texte sur votre topic. Vous pouvez aussi déclencher une capture depuis la ligne de commande :

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

Passer le montage ESP32-CAM MQTT en production

La zone publique est lisible par tous : ne braquez donc jamais une caméra sur quoi que ce soit de privé pendant que vous l'utilisez. Pour un vrai déploiement, créez un broker avec une offre de broker MQTT managé et passez le sketch en TLS :

  1. Inscrivez-vous à ConnectMQ, créez un broker et copiez le nom d'utilisateur, le mot de passe et le client ID générés.
  2. Remplacez WiFiClient par WiFiClientSecure, chargez le certificat ISRG Root X1 avec setCACert() et passez au port 8883.
  3. Appelez mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline"). ConnectMQ lie les client IDs aux identifiants émis : utilisez celui fourni par le portail.
  4. Augmentez MAX_PAYLOAD en fonction de votre offre, par exemple juste sous 256 Ko avec l'offre gratuite Serverless.

TLS consomme de la RAM en plus des buffers de la caméra. Le module AI-Thinker dispose de PSRAM, ce qui garde les frame buffers hors de la mémoire interne, mais si vous constatez des échecs d'allocation, passez à un seul frame buffer et à une taille d'image plus petite. Le guide ESP32 MQTT SSL détaille pas à pas le chargement des certificats.

Liste de vérification pour le dépannage

  1. « Brownout detector was triggered » au démarrage ou à la capture. Utilisez une alimentation 5 V solide et des câbles courts ; la caméra et la radio Wi-Fi provoquent ensemble des pics de courant.
  2. Camera init failed: 0x105 ou similaire. La nappe est mal enfoncée ou le brochage ne correspond pas à votre variante de carte.
  3. Image « FAILED » alors que les petits messages passent. L'image dépasse la limite du broker ou le socket a expiré en cours d'écriture. Réduisez frame_size, augmentez la valeur de jpeg_quality ou augmentez setSocketTimeout().
  4. La caméra se déconnecte juste après une grosse publication. Sur le serveur MQTT public gratuit, un message de plus de 64 Ko est rejeté. Vérifiez la taille affichée dans le log série.
  5. Les premières images sont vertes ou sombres. L'OV2640 a besoin de quelques images pour stabiliser l'exposition automatique ; capturez et jetez une image après le démarrage.
  6. Boucles de reconnexion après une coupure Wi-Fi. Ajoutez un backoff et conservez le Last Will pour que les tableaux de bord affichent la caméra hors ligne ; voir la reconnexion MQTT de l'ESP32.

Conseils de conception pour les topics de caméra

Gardez les images binaires sur leur propre topic et envoyez les métadonnées séparément. Un petit message JSON sur un topic snapshot/meta avec la taille, la résolution et la raison du déclenchement permet aux tableaux de bord de réagir sans télécharger l'image. Évitez de rendre les messages d'image retained sur des brokers partagés, sauf si vous avez besoin d'un comportement « dernière photo », car chaque nouvel abonné reçoit immédiatement le JPEG complet. Si plusieurs services consomment les images, la diffusion multiple de MQTT s'en charge, et un broker MQTT cloud vous évite d'exploiter vous-même cette infrastructure.

Démarrer avec ConnectMQ

Testez d'abord votre caméra sur la zone publique gratuite, puis passez à un broker privé lorsque les images comptent. ConnectMQ vous fournit un endpoint TLS, des identifiants générés et un explorateur de topics en direct pour observer l'arrivée du trafic d'images. Créez un broker avec l'offre gratuite Serverless sur app.connectmq.com, sans carte bancaire, puis remplacez l'hôte et les identifiants dans le sketch.

Questions fréquentes

Un ESP32-CAM peut-il diffuser de la vidéo via MQTT ?

Il peut envoyer des images en succession rapide, mais MQTT n'est pas conçu pour la vidéo et des limites de débit s'appliquent sur les brokers partagés. Utilisez MQTT pour les images ponctuelles et les captures déclenchées par événement, et un protocole de streaming pour la vidéo en direct.

Quelle est la taille maximale d'image que je peux publier ?

Cela dépend du broker : 64 Ko sur le serveur MQTT public gratuit, 256 Ko sur l'offre Serverless (Free) et 1 Mo sur Serverless (Paid). Vérifiez fb->len et choisissez une taille d'image qui reste sous votre limite.

Faut-il augmenter le buffer de PubSubClient pour les images ?

Pas si vous utilisez beginPublish(), write() et endPublish(). Ces appels envoient l'image directement en streaming : le buffer par défaut suffit pour l'en-tête.

Les messages d'image doivent-ils être retained ?

Généralement non. Rendre retained un gros message binaire signifie que chaque nouvel abonné le reçoit immédiatement ; ne conservez en retained que le petit topic de statut, sauf si vous voulez précisément un comportement « dernière image ».

Articles liés

Exemple MQTT ESP-IDF : construire un client esp-mqtt à partir de zéro

Construisez un projet ESP-IDF à partir d'un dossier vide avec le composant esp-mqtt : configuration du Wi-Fi en mode station, gestionnaire d'événements MQTT, publication et abonnement, propriétés MQTT 5 et TLS avec le bundle de certificats.

9 min de lecture

Horodatage NTP ESP32 MQTT : ajouter une heure précise à chaque message

Synchronisez l'horloge de l'ESP32 avec NTP, formatez des horodatages ISO 8601 et epoch en millisecondes, et joignez-les aux payloads MQTT pour que les mesures conservent leur véritable heure de capture même lorsqu'elles sont publiées en retard.

8 min de lecture

Tous les articles →

Votre broker MQTT privé, à quelques clics

Créez un compte, récupérez les identifiants de votre broker et connectez votre premier appareil — aucun serveur à installer, mettre à jour ou dimensionner.