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:
- Zustand veröffentlichen: Das Gerät meldet, was es ist, nicht nur Ereignisse.
- Totbänder und Ratenbegrenzung: Änderungen senden, die zählen, kein Rauschen.
- Retained Messages: ein Zwilling, der im Moment des Öffnens korrekt ist.
- Last Will: Der Zwilling erkennt, wenn das Gerät verschwindet.
- 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.



