Digitaler Zwilling

ESP32 Digital Twin: Ein Einsteigerprojekt mit MQTT und 3D-Browsermodell

Ein einsteigerfreundlicher ESP32 Digital Twin: Das Board veröffentlicht ein Potentiometer, einen Taster und einen LED-Zustand über MQTT, ein Three.js-Modell im Browser spiegelt sie in Echtzeit, und ein Klick auf die 3D-LED schaltet die echte.

ESP32-Digital-Twin-Projekt mit einem physischen Board und seinem 3D-Browsermodell, das Drehknopf, Taster und LED spiegelt
Probieren Sie es auf Ihrem eigenen MQTT-Broker ausHolen Sie sich im Free-Tarif einen privaten, verwalteten MQTT-Broker und starten Sie mit fertigen Codebeispielen.
Kostenlos testen Codebeispiele Keine Kreditkarte erforderlich

Ein ESP32 Digital Twin ist der praktischste Weg zu verstehen, was ein digitaler Zwilling wirklich ist: Sie drehen an einem Knopf auf Ihrem Schreibtisch, und ein 3D-Modell im Browser dreht sich mit. In diesem Einsteigerprojekt veröffentlicht ein ESP32 ein Potentiometer, einen Taster und einen LED-Zustand über MQTT auf broker.connectmq.com, eine Three.js-Seite spiegelt sie in Echtzeit, und ein Klick auf die virtuelle LED schaltet die echte. Das Projekt dauert etwa eine Stunde und nutzt Bauteile, die die meisten Maker bereits haben.

Was Sie lernen

Dieses Projekt ist klein, deckt aber jedes Konzept ab, auf das auch größere industrielle Zwillinge setzen:

  1. Zustand veröffentlichen: Das Gerät meldet, was es ist, nicht nur Ereignisse.
  2. Totbänder und Ratenbegrenzung: Änderungen senden, die zählen, kein Rauschen.
  3. Retained Messages: ein Zwilling, der im Moment des Öffnens korrekt ist.
  4. Last Will: Der Zwilling erkennt, wenn das Gerät verschwindet.
  5. Bidirektionale Steuerung: Befehle gehen hinunter, bestätigter Zustand kommt zurück.

Wenn Sie noch nie einen ESP32 mit MQTT verbunden haben, arbeiten Sie zuerst unser ESP32-MQTT-Tutorial durch; dieser Artikel setzt voraus, dass Sie einen Sketch aus der Arduino IDE flashen können. Die Theorie hinter Zwillingen erklärt der Leitfaden zur Digital-Twin-Technologie.

Bauteile und Verdrahtung

Bauteil ESP32-Pin Hinweise
10-k-Potentiometer, Schleifer GPIO34 Äußere Beine an 3V3 und GND. GPIO34 ist ein ADC1-Pin, der bei aktivem WLAN funktioniert
Taster GPIO4 Anderes Bein an GND; der Sketch aktiviert den internen Pull-up
LED + 330-Ohm-Widerstand GPIO2 Viele DevKit-Boards haben bereits eine LED an GPIO2
ESP32 DevKit USB Jedes klassische ESP32-Board funktioniert

Vermeiden Sie ADC2-Pins (etwa GPIO25 bis GPIO27) für das Potentiometer: ADC2 lässt sich bei laufendem WLAN nicht auslesen – eine klassische Ursache für „der Wert ist immer null“. Die ADC-Dokumentation von Espressif listet auf, welche Pins zu welchem ADC gehören.

Topic-Design

Verwenden Sie eine eindeutige Geräte-ID im Präfix, etwa esp32-7f3a:

Topic Richtung Payload Retained
connectmq/demo/esp32-7f3a/state ESP32 veröffentlicht {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} Ja
connectmq/demo/esp32-7f3a/led/set Zwilling veröffentlicht ON, OFF, TOGGLE Nein
connectmq/demo/esp32-7f3a/status ESP32 veröffentlicht, Last Will online / offline Ja

Ein kombiniertes Zustandsobjekt hält den Zwilling konsistent: Drehknopf, Taster und LED stammen immer aus derselben Momentaufnahme des Geräts.

In industriellen Zwillingen ersetzt ein Unified Namespace (UNS) das Demo-Präfix: Die ISA-95-Anlagenhierarchie aus Enterprise, Site, Area, Line und Cell wird zur Topic-Struktur auf dem MQTT-Broker, etwa acme/werk-stuttgart/montage/linie-1/zelle-3/state. Jeder Zwilling abonniert dann einfach den Zweig seiner Anlage, und Dashboards, MES und Analysen lesen dieselben Daten. Wie das mit Sparkplug B zusammenspielt, erklärt Sparkplug B und der Unified Namespace.

Schritt 1: Der ESP32-Sketch

Installieren Sie die Bibliothek PubSubClient von Nick O'Leary über den Bibliotheksverwalter und flashen Sie dann diesen Sketch, nachdem Sie Ihre WLAN-Daten und Geräte-ID eingetragen haben.

#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";  // durch eigenes zufälliges Suffix ersetzen

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);  // retained
  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;  // den neuen realen Zustand melden
}

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();

  // Einige Messwerte mitteln und in 0..100 % umrechnen
  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; }  // 2 % Totband

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

  unsigned long now = millis();
  bool rateOk = now - lastPublish >= 200;        // höchstens 5 Nachrichten/s
  bool heartbeat = now - lastHeartbeat >= 10000;  // trotzdem alle 10 s auffrischen
  if ((dirty && rateOk) || heartbeat) publishState();
}

Öffnen Sie den seriellen Monitor mit 115200 Baud. Sobald dort „connected“ steht, prüfen Sie den Datenstrom von Ihrem Rechner aus:

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

Drehen Sie den Knopf und drücken Sie den Taster; innerhalb von Sekundenbruchteilen erscheint eine neue Zustandsnachricht.

Warum der Sketch so veröffentlicht

Totband. Analoge Messwerte schwanken um ein, zwei Zählerstände, auch wenn niemand den Knopf berührt. Ohne das Totband von 2 Prozent würde das Board ständig veröffentlichen. Echte Sensoren brauchen dieselbe Behandlung.

Ratenbegrenzung. Schnelles Drehen erzeugt viele Änderungen. Die Begrenzung auf fünf Nachrichten pro Sekunde hält das Gerät deutlich unter dem Limit des öffentlichen MQTT-Brokers von 10 Nachrichten pro Sekunde und Client und fühlt sich trotzdem sofort an.

Heartbeat. Eine Zustandsnachricht alle zehn Sekunden, auch ohne Änderungen, lässt den Zwilling ein ruhiges Gerät von einem defekten unterscheiden und frischt RSSI und Uptime auf.

Retained-Zustand und Last Will. Der Zwilling erhält den aktuellen Zustand sofort beim Laden, und der Broker meldet offline, wenn das Board die Stromversorgung verliert.

Zustand nach Befehl. Trifft ein Befehl ein, ändert der Sketch die LED und meldet anschließend den neuen Zustand. Der Zwilling zeigt diese Meldung an, nicht den Klick; das ist der Kern der Zustandssynchronisation von Digital Twins.

Schritt 2: Der 3D-Zwilling im Browser

Speichern Sie diese Seite als index.html und stellen Sie sie mit npx serve . bereit. Sie baut ein vereinfachtes ESP32-Board mit Drehknopf, Taster und LED und bindet sie an das Zustands-Topic.

<!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);

    // Steckbrett-artige Basis und ESP32-Modul
    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);

    // Potentiometer-Drehknopf mit Zeiger
    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);

    // Taster (Kappe bewegt sich beim Drücken nach unten)
    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 (anklickbar)
    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);

    // ---------- Zustand und 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 {}
    });

    // Klick auf die virtuelle LED schaltet die echte um
    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 % entspricht -135..+135 Grad, wie bei einem echten Potentiometer
      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;  // keine Glättung für diskrete Zustände
      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>

Drehen Sie den echten Knopf, und der 3D-Knopf folgt. Drücken Sie den Taster, und die rote Kappe senkt sich. Klicken Sie auf die 3D-LED, und einen Moment später leuchten sowohl die echte als auch die virtuelle LED. Ziehen Sie den ESP32 ab, und nach Ablauf des Keepalive wird das Board im Browser grau.

Fehlerbehebung beim ESP32 Digital Twin

Problem Ursache Lösung
Serieller Monitor zeigt wiederholt rc=-2 Netzwerk- oder DNS-Fehler WLAN-Zugangsdaten prüfen und sicherstellen, dass Port 1883 nicht blockiert ist
Drehknopf immer 0 oder 4095 Falscher Pin oder falsche Verdrahtung GPIO34 (ADC1) verwenden, Schleifer an das mittlere Bein
Zwilling zeigt alte Werte statt Live-Daten Abweichendes Topic-Präfix DEVICE_ID im Sketch mit P in der Seite vergleichen
Klick auf LED bewirkt nichts Board nach Reconnect nicht abonniert Wie gezeigt in connectMqtt() abonnieren
Board und Zwilling flackern zwischen online und offline Zwei Boards mit derselben Client ID Jedem Gerät eine eindeutige ID geben

Mehr zu Payload-Design und Benennung, wenn Ihr Projekt wächst, finden Sie unter MQTT für Digital Twins. Um die einfachen Formen durch ein echtes Board-Modell zu ersetzen, lesen Sie IoT-Daten an ein 3D-Modell binden, und für eine anspruchsvollere Szene probieren Sie den Three.js Digital Twin mit Tank und Motor.

Nächster Schritt: Absichern mit eigenem Broker

Der öffentliche MQTT-Broker ist perfekt für dieses Projekt, aber jeder, der Ihr Topic kennt, kann mitlesen und sogar Ihre LED schalten. Um es privat zu machen, wechseln Sie zu einem verwalteten MQTT-Broker und stellen den Sketch auf TLS über Port 8883 mit WiFiClientSecure und den Zugangsdaten aus dem Portal um: mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). Bei ConnectMQ stammt das Zertifikat von Let's Encrypt, sodass Sie das Zertifikat ISRG Root X1 mit setCACert() in WiFiClientSecure laden können. Geben Sie dem Browser-Zwilling eigene Zugangsdaten und verwenden Sie diese in den mqtt.connect-Optionen. Das ESP32-IoT-Tutorial zeigt das vollständige TLS-Setup Schritt für Schritt.

Starten Sie mit ConnectMQ

Ihr ESP32 Digital Twin funktioniert schon heute auf dem öffentlichen MQTT-Broker. Wenn Sie ihn privat betreiben möchten, erstellen Sie einen kostenlosen Broker unter app.connectmq.com: Der Serverless-Plan benötigt keine Kreditkarte, unterstützt bis zu 25 Verbindungen und enthält einen Live-Topic-Explorer sowie einen MQTT-Client im Browser, um die Nachrichten Ihres Boards zu beobachten. Für weitere Experimente können Sie auch den kostenlosen öffentlichen MQTT-Broker weiter erkunden.

Häufig gestellte Fragen

Ist das wirklich ein digitaler Zwilling oder nur ein Dashboard?

Es ist ein einfacher digitaler Zwilling: ein virtuelles Modell, das den Live-Zustand eines bestimmten physischen Geräts spiegelt und Befehle zurücksenden kann. Industrielle Zwillinge, etwa einer Fertigungslinie im Werk, ergänzen mehr Daten, Historie, Analysen und meist einen Unified Namespace nach ISA-95, aber der Kernkreislauf ist derselbe.

Kann ich MicroPython statt Arduino verwenden?

Ja. Das Modul umqtt.simple unter MicroPython unterstützt dieselben Topics, Retained Messages und den Last Will, sodass sich am Browser-Zwilling nichts ändert.

Warum dauert es eine Weile, bis der Zwilling das Board als offline anzeigt?

Der Broker veröffentlicht den Last Will erst, wenn das Keepalive-Intervall ohne Kontakt verstrichen ist, hier etwa 30 bis 45 Sekunden. Ein kürzeres Keepalive erkennt Ausfälle schneller, kostet aber etwas mehr Datenverkehr.

Können mehrere Personen den Zwilling gleichzeitig ansehen?

Ja. Jeder Browser ist ein separater MQTT-Subscriber mit eigener Client ID. Halten Sie im kostenlosen Serverless-Plan die Summe aus Geräten und Betrachtern innerhalb des Limits von 25 Verbindungen.

Verwandte Artikel

Alle Artikel →

Ihr privater MQTT-Broker ist nur wenige Klicks entfernt

Konto anlegen, Broker-Zugangsdaten erhalten und das erste Gerät verbinden — ohne Server, die installiert, gepatcht oder skaliert werden müssen.