Jumeau numérique

Jumeau numérique ESP32 : un projet débutant avec MQTT et un modèle 3D dans le navigateur

Un jumeau numérique ESP32 accessible aux débutants : la carte publie un potentiomètre, un bouton et l'état d'une LED via MQTT, un modèle Three.js dans le navigateur les reflète en temps réel, et un clic sur la LED 3D allume ou éteint la vraie.

Projet de jumeau numérique ESP32 avec une carte physique et son modèle 3D dans le navigateur reflétant un potentiomètre, un bouton et une LED
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

Un jumeau numérique ESP32 est le moyen le plus concret de comprendre ce qu'est réellement un jumeau numérique : vous tournez un potentiomètre sur votre bureau et un modèle 3D dans le navigateur tourne avec lui. Dans ce projet pour débutants, un ESP32 publie un potentiomètre, un bouton-poussoir et l'état d'une LED via MQTT sur broker.connectmq.com, le serveur MQTT public gratuit réservé aux tests et aux prototypes, une page Three.js les reflète en temps réel, et un clic sur la LED virtuelle allume ou éteint la vraie. Comptez environ une heure, avec des composants que la plupart des makers ont déjà.

Ce que vous allez apprendre

Ce projet est modeste, mais il couvre tous les concepts sur lesquels reposent les jumeaux industriels plus importants :

  1. La publication d'état : l'appareil indique ce qu'il est, pas seulement des événements.
  2. Les bandes mortes et limites de débit : envoyer les changements qui comptent, pas le bruit.
  3. Les messages retenus : un jumeau correct dès son ouverture.
  4. Le last will : le jumeau sait quand l'appareil disparaît.
  5. Le pilotage bidirectionnel : les commandes descendent, l'état confirmé remonte.

Si vous n'avez jamais connecté un ESP32 à MQTT, suivez d'abord notre tutoriel ESP32 MQTT ; cet article suppose que vous savez flasher un sketch depuis l'IDE Arduino. Pour la théorie des jumeaux, consultez le guide de la technologie du jumeau numérique.

Composants et câblage

Composant Broche ESP32 Remarques
Potentiomètre 10 k, curseur GPIO34 Pattes extérieures sur 3V3 et GND. GPIO34 est une broche ADC1, qui fonctionne pendant que le Wi-Fi est actif
Bouton-poussoir GPIO4 Autre patte sur GND ; le sketch active la résistance de pull-up interne
LED + résistance de 330 ohms GPIO2 De nombreuses cartes DevKit ont déjà une LED sur GPIO2
ESP32 DevKit USB N'importe quelle carte ESP32 classique convient

Évitez les broches ADC2 (comme GPIO25 à GPIO27) pour le potentiomètre : l'ADC2 ne peut pas être lu pendant que le Wi-Fi fonctionne, une source classique de « la valeur est toujours à zéro ». La documentation ADC d'Espressif indique quelles broches appartiennent à quel ADC.

Conception des topics

Utilisez un identifiant d'appareil unique dans le préfixe, par exemple esp32-7f3a :

Topic Sens Payload Retenu
connectmq/demo/esp32-7f3a/state L'ESP32 publie {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} Oui
connectmq/demo/esp32-7f3a/led/set Le jumeau publie ON, OFF, TOGGLE Non
connectmq/demo/esp32-7f3a/status L'ESP32 publie, last will online / offline Oui

Un seul objet d'état combiné garde le jumeau cohérent : le potentiomètre, le bouton et la LED proviennent toujours du même instantané de l'appareil.

Étape 1 : le sketch ESP32

Installez la bibliothèque PubSubClient de Nick O'Leary depuis le gestionnaire de bibliothèques, puis flashez ce sketch après avoir renseigné vos paramètres Wi-Fi et l'identifiant de l'appareil.

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

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* DEVICE_ID = "esp32-7f3a";  // remplacez par votre propre suffixe aléatoire

const int PIN_KNOB = 34;
const int PIN_BUTTON = 4;
const int PIN_LED = 2;

String topicState, topicLedSet, topicStatus;

WiFiClient net;
PubSubClient mqtt(net);

bool ledOn = false;
int lastKnob = -100;
bool lastButton = false;
unsigned long lastPublish = 0;
unsigned long lastHeartbeat = 0;
bool dirty = true;

void publishState() {
  char json[128];
  snprintf(json, sizeof(json),
           "{\"knob\":%d,\"button\":%s,\"led\":%s,\"rssi\":%d,\"uptime\":%lu}",
           lastKnob, lastButton ? "true" : "false", ledOn ? "true" : "false",
           WiFi.RSSI(), millis() / 1000);
  mqtt.publish(topicState.c_str(), json, true);  // retenu
  lastPublish = millis();
  lastHeartbeat = lastPublish;
  dirty = false;
}

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();
  cmd.toUpperCase();
  if (cmd == "ON") ledOn = true;
  else if (cmd == "OFF") ledOn = false;
  else if (cmd == "TOGGLE") ledOn = !ledOn;
  else return;
  digitalWrite(PIN_LED, ledOn ? HIGH : LOW);
  dirty = true;  // signaler le nouvel état réel
}

void connectWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.printf("\nWi-Fi connected, IP %s\n", WiFi.localIP().toString().c_str());
}

void connectMqtt() {
  while (!mqtt.connected()) {
    Serial.print("Connecting to MQTT... ");
    // id, willTopic, willQos, willRetain, willMessage
    if (mqtt.connect(DEVICE_ID, topicStatus.c_str(), 1, true, "offline")) {
      Serial.println("connected");
      mqtt.publish(topicStatus.c_str(), "online", true);
      mqtt.subscribe(topicLedSet.c_str(), 1);
      dirty = true;
    } else {
      Serial.printf("failed, rc=%d, retrying in 3 s\n", mqtt.state());
      delay(3000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_BUTTON, INPUT_PULLUP);
  pinMode(PIN_LED, OUTPUT);
  analogReadResolution(12);

  String prefix = String("connectmq/demo/") + DEVICE_ID;
  topicState = prefix + "/state";
  topicLedSet = prefix + "/led/set";
  topicStatus = prefix + "/status";

  connectWifi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setKeepAlive(30);
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) connectWifi();
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  // Moyenne de quelques échantillons, convertie en 0..100 %
  long sum = 0;
  for (int i = 0; i < 8; i++) sum += analogRead(PIN_KNOB);
  int knob = map(sum / 8, 0, 4095, 0, 100);
  if (abs(knob - lastKnob) >= 2) { lastKnob = knob; dirty = true; }  // bande morte de 2 %

  bool button = digitalRead(PIN_BUTTON) == LOW;
  if (button != lastButton) { lastButton = button; dirty = true; }

  unsigned long now = millis();
  bool rateOk = now - lastPublish >= 200;        // au plus 5 messages/s
  bool heartbeat = now - lastHeartbeat >= 10000;  // rafraîchir toutes les 10 s quoi qu'il arrive
  if ((dirty && rateOk) || heartbeat) publishState();
}

Ouvrez le moniteur série à 115200 bauds. Dès qu'il affiche « connected », vérifiez le flux depuis votre ordinateur :

mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-esp32-7f3a -v -t "connectmq/demo/esp32-7f3a/#"

Tournez le potentiomètre et appuyez sur le bouton : un nouveau message d'état apparaît en une fraction de seconde.

Pourquoi le sketch publie de cette façon

La bande morte. Les lectures analogiques fluctuent d'une ou deux unités même quand personne ne touche au potentiomètre. Sans la bande morte de 2 %, la carte publierait en permanence. Les vrais capteurs exigent le même traitement.

La limite de débit. Tourner rapidement le potentiomètre produit de nombreux changements. Plafonner à cinq messages par seconde maintient l'appareil bien en deçà de la limite de 10 messages par seconde et par client du serveur MQTT public gratuit, tout en donnant une impression d'instantanéité.

Le heartbeat. Un message d'état toutes les dix secondes, même sans changement, permet au jumeau de distinguer un appareil silencieux d'un appareil en panne, et rafraîchit le RSSI et l'uptime.

L'état retenu et le last will. Le jumeau obtient l'état actuel dès le chargement, et le broker annonce offline si la carte perd son alimentation.

L'état après la commande. Quand une commande arrive, le sketch modifie la LED puis signale le nouvel état. Le jumeau affiche ce rapport, pas le clic : c'est l'essence même de la synchronisation d'état d'un jumeau numérique.

Étape 2 : le jumeau 3D dans le navigateur

Enregistrez cette page sous index.html et servez-la avec npx serve .. Elle construit une carte ESP32 simplifiée avec un potentiomètre, un bouton et une LED, et les lie au topic d'état.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>ESP32 Digital Twin</title>
  <style>
    body { margin: 0; overflow: hidden; background: #0f172a; font-family: system-ui, sans-serif; }
    #hud { position: absolute; top: 10px; left: 10px; color: #fff; background: rgba(0,0,0,.6);
           padding: 8px 12px; border-radius: 6px; font-size: 14px; }
  </style>
  <script type="importmap">
  { "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
      "mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
  } }
  </script>
</head>
<body>
  <div id="hud">Connecting...</div>
  <script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "three/addons/controls/OrbitControls.js";
    import mqtt from "mqtt";

    const P = "connectmq/demo/esp32-7f3a";
    const hud = document.getElementById("hud");

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x0f172a);
    const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 50);
    camera.position.set(0, 6, 7);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);
    new OrbitControls(camera, renderer.domElement);
    scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 1.4));
    const key = new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(3, 8, 4); scene.add(key);

    // Base façon breadboard et module ESP32
    const base = new THREE.Mesh(new THREE.BoxGeometry(7, 0.2, 4), new THREE.MeshStandardMaterial({ color: 0xf1f5f9 }));
    const pcbMat = new THREE.MeshStandardMaterial({ color: 0x14532d });
    const pcb = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.1, 1.4), pcbMat);
    pcb.position.set(-1.8, 0.15, 0);
    const shield = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.12, 0.9), new THREE.MeshStandardMaterial({ color: 0xcbd5e1, metalness: 0.8, roughness: 0.3 }));
    shield.position.set(-1.4, 0.26, 0);
    scene.add(base, pcb, shield);

    // Bouton du potentiomètre avec un repère
    const knob = new THREE.Group();
    knob.add(new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.4, 32), new THREE.MeshStandardMaterial({ color: 0x1e3a8a })));
    const pointer = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.05, 0.4), new THREE.MeshStandardMaterial({ color: 0xffffff }));
    pointer.position.set(0, 0.22, -0.2);
    knob.add(pointer);
    knob.position.set(1.0, 0.3, 0);
    scene.add(knob);

    // Bouton-poussoir (le capuchon descend quand on appuie)
    const btnBase = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.2, 0.6), new THREE.MeshStandardMaterial({ color: 0x111827 }));
    btnBase.position.set(2.4, 0.2, -0.8);
    const btnCap = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.15, 24), new THREE.MeshStandardMaterial({ color: 0xdc2626 }));
    btnCap.position.set(2.4, 0.37, -0.8);
    scene.add(btnBase, btnCap);

    // LED (cliquable)
    const ledMat = new THREE.MeshStandardMaterial({ color: 0x7f1d1d, emissive: 0xff2d2d, emissiveIntensity: 0, transparent: true, opacity: 0.9 });
    const led = new THREE.Mesh(new THREE.SphereGeometry(0.22, 24, 16), ledMat);
    led.position.set(2.4, 0.4, 0.8);
    scene.add(led);

    // ---------- État et MQTT ----------
    const s = { knob: 0, button: false, led: false, rssi: null, online: false, at: 0 };
    let knobAngle = 0, glow = 0;

    const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
      clientId: "esp32-twin-" + Math.random().toString(16).slice(2, 10),
      protocolVersion: 5,
    });
    const dec = new TextDecoder();
    client.on("connect", () => client.subscribe([`${P}/state`, `${P}/status`], { qos: 1 }));
    client.on("message", (topic, payload) => {
      const text = dec.decode(payload);
      if (topic.endsWith("/status")) { s.online = text === "online"; return; }
      try { Object.assign(s, JSON.parse(text), { at: performance.now() }); } catch {}
    });

    // Cliquer sur la LED virtuelle pour basculer la vraie
    const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();
    renderer.domElement.addEventListener("click", (e) => {
      ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
      ray.setFromCamera(ndc, camera);
      if (ray.intersectObject(led).length) client.publish(`${P}/led/set`, "TOGGLE", { qos: 1 });
    });

    const clock = new THREE.Clock();
    renderer.setAnimationLoop(() => {
      const dt = clock.getDelta();
      const k = 1 - Math.exp(-dt * 10);
      // 0..100 % correspond à -135..+135 degrés, comme un vrai potentiomètre
      const targetAngle = THREE.MathUtils.degToRad(135 - (s.knob / 100) * 270);
      knobAngle += (targetAngle - knobAngle) * k;
      knob.rotation.y = knobAngle;

      btnCap.position.y = s.button ? 0.31 : 0.37;  // pas de lissage pour les états discrets
      glow += ((s.led ? 2 : 0) - glow) * k;
      ledMat.emissiveIntensity = glow;

      const stale = !s.online || performance.now() - s.at > 15000;
      pcbMat.color.setHex(stale ? 0x4b5563 : 0x14532d);
      hud.textContent = stale ? "ESP32 offline or no data" :
        `knob ${s.knob} % | button ${s.button ? "pressed" : "released"} | LED ${s.led ? "on" : "off"} | RSSI ${s.rssi} dBm`;
      renderer.render(scene, camera);
    });
    addEventListener("resize", () => {
      camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
    });
  </script>
</body>
</html>

Tournez le vrai potentiomètre et le bouton 3D suit. Appuyez sur le bouton-poussoir et le capuchon rouge s'enfonce. Cliquez sur la LED 3D et, un instant plus tard, la vraie LED comme la virtuelle s'allument. Débranchez l'ESP32 : à l'expiration du keepalive, la carte dans le navigateur devient grise.

Dépannage du jumeau numérique ESP32

Problème Cause Solution
Le moniteur série affiche rc=-2 en boucle Échec réseau ou DNS Vérifiez les identifiants Wi-Fi et que le port 1883 n'est pas bloqué
Potentiomètre toujours à 0 ou 4095 Mauvaise broche ou mauvais câblage Utilisez GPIO34 (ADC1), curseur sur la patte du milieu
Le jumeau affiche d'anciennes valeurs, pas le direct Préfixe de topic différent Comparez DEVICE_ID dans le sketch et P dans la page
Le clic sur la LED ne fait rien Carte non réabonnée après reconnexion Abonnez-vous dans connectMqtt() comme indiqué
Carte et jumeau oscillent entre en ligne et hors ligne Deux cartes avec le même client ID Donnez un identifiant unique à chaque appareil

Pour en savoir plus sur la conception des payloads et le nommage à mesure que votre projet grandit, consultez MQTT pour les jumeaux numériques. Pour remplacer les formes primitives par un vrai modèle de carte, lisez lier des données IoT à un modèle 3D, et pour une scène plus avancée, essayez le jumeau numérique avec Three.js avec une cuve et un moteur.

Étape suivante : sécuriser le tout avec votre propre broker

Le serveur MQTT public gratuit est parfait pour ce projet, mais quiconque connaît votre topic peut le lire, voire basculer votre LED. Pour le rendre privé, créez votre propre broker MQTT managé et passez le sketch en TLS sur le port 8883 avec WiFiClientSecure et les identifiants du portail : mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). Avec ConnectMQ, le certificat provient de Let's Encrypt : vous pouvez donc charger le certificat ISRG Root X1 dans WiFiClientSecure avec setCACert(). Donnez au jumeau dans le navigateur ses propres identifiants et utilisez-les dans les options de mqtt.connect. Le tutoriel ESP32 IoT détaille la configuration TLS complète pas à pas.

Démarrer avec ConnectMQ

Votre jumeau numérique ESP32 fonctionne dès aujourd'hui sur le serveur MQTT public gratuit. Quand vous voudrez le rendre privé, créez un broker gratuit sur app.connectmq.com : le plan Serverless ne demande aucune carte bancaire, prend en charge jusqu'à 25 connexions et inclut un explorateur de topics en direct et un client MQTT dans le navigateur pour suivre les messages de votre carte. Vous pouvez aussi continuer à utiliser le serveur MQTT public gratuit pour d'autres expériences.

Questions fréquentes

Est-ce vraiment un jumeau numérique ou juste un tableau de bord ?

C'est un jumeau numérique simple : un modèle virtuel qui reflète l'état en direct d'un appareil physique précis et peut lui renvoyer des commandes. Les jumeaux industriels ajoutent davantage de données, d'historique et d'analyses, mais la boucle de base est la même.

Peut-on utiliser MicroPython au lieu d'Arduino ?

Oui. Le module umqtt.simple de MicroPython prend en charge les mêmes topics, les messages retenus et le last will ; le jumeau dans le navigateur ne change donc pas du tout.

Pourquoi le jumeau met-il un moment à afficher la carte hors ligne ?

Le broker ne publie le last will qu'une fois l'intervalle de keepalive écoulé sans contact, ici environ 30 à 45 secondes. Un keepalive plus court détecte les pannes plus vite, au prix d'un peu plus de trafic.

Plusieurs personnes peuvent-elles voir le jumeau en même temps ?

Oui. Chaque navigateur est un abonné MQTT distinct avec son propre client ID. Sur le plan Serverless gratuit, gardez le total des appareils et des spectateurs dans la limite de 25 connexions.

Articles liés

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.