An ESP32 digital twin is the most hands-on way to understand what a digital twin really is: you turn a knob on your desk and a 3D model in the browser turns with it. In this beginner project, an ESP32 publishes a potentiometer, a push button and an LED state over MQTT to broker.connectmq.com, a Three.js page mirrors them in real time, and clicking the virtual LED switches the real one. It takes about an hour and uses parts most makers already have.
What You Will Learn
This project is small, but it covers every concept that larger industrial twins rely on:
- State publishing: the device reports what it is, not just events.
- Deadbands and rate limits: sending changes that matter, not noise.
- Retained messages: a twin that is correct the moment it opens.
- Last will: the twin knows when the device disappears.
- Bidirectional control: commands go down, confirmed state comes back up.
If you have never connected an ESP32 to MQTT, work through our ESP32 MQTT tutorial first; this article assumes you can flash a sketch from the Arduino IDE. For the theory behind twins, see the digital twin technology guide.
Parts and Wiring
| Part | ESP32 pin | Notes |
|---|---|---|
| 10 k potentiometer, wiper | GPIO34 | Outer legs to 3V3 and GND. GPIO34 is an ADC1 pin, which works while Wi-Fi is active |
| Push button | GPIO4 | Other leg to GND; the sketch enables the internal pull-up |
| LED + 330 ohm resistor | GPIO2 | Many DevKit boards already have an LED on GPIO2 |
| ESP32 DevKit | USB | Any classic ESP32 board works |
Avoid ADC2 pins (such as GPIO25 to GPIO27) for the potentiometer: ADC2 cannot be read while Wi-Fi is running, a classic source of "the value is always zero" confusion. The Espressif ADC documentation lists which pins belong to which ADC.
Topic Design
Use a unique device ID in the prefix, such as esp32-7f3a:
| Topic | Direction | Payload | Retained |
|---|---|---|---|
connectmq/demo/esp32-7f3a/state |
ESP32 publishes | {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} |
Yes |
connectmq/demo/esp32-7f3a/led/set |
Twin publishes | ON, OFF, TOGGLE |
No |
connectmq/demo/esp32-7f3a/status |
ESP32 publishes, last will | online / offline |
Yes |
One combined state object keeps the twin consistent: the knob, button and LED always come from the same snapshot of the device.
Step 1: The ESP32 Sketch
Install the PubSubClient library by Nick O'Leary from the Library Manager, then flash this sketch after filling in your Wi-Fi details and device ID.
#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"; // change to your own random suffix
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; // report the new real state
}
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();
// Average a few samples and convert to 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; } // 2 % deadband
bool button = digitalRead(PIN_BUTTON) == LOW;
if (button != lastButton) { lastButton = button; dirty = true; }
unsigned long now = millis();
bool rateOk = now - lastPublish >= 200; // at most 5 messages/s
bool heartbeat = now - lastHeartbeat >= 10000; // refresh every 10 s anyway
if ((dirty && rateOk) || heartbeat) publishState();
}
Open the Serial Monitor at 115200 baud. Once it says "connected", check the stream from your computer:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-esp32-7f3a -v -t "connectmq/demo/esp32-7f3a/#"
Turn the knob and press the button; a new state message appears within a fraction of a second.
Why the sketch publishes this way
Deadband. Analog readings jitter by a count or two even when nobody touches the knob. Without the 2 percent deadband the board would publish constantly. Real sensors need the same treatment.
Rate limit. Turning the knob quickly produces many changes. Capping at five messages per second keeps the device well inside the public broker's limit of 10 messages per second per client while still feeling instant.
Heartbeat. A state message every ten seconds, even without changes, lets the twin distinguish a quiet device from a broken one, and refreshes RSSI and uptime.
Retained state and last will. The twin gets the current state immediately on load, and the broker announces offline if the board loses power.
State after command. When a command arrives, the sketch changes the LED and then reports the new state. The twin displays that report, not the click; this is the essence of digital twin state synchronization.
Step 2: The 3D Twin in the Browser
Save this page as index.html and serve it with npx serve .. It builds a simplified ESP32 board with a knob, a button and an LED, and binds them to the state 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);
// Breadboard-style base and ESP32 module
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 knob with a pointer
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);
// Push button (cap moves down when pressed)
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 (clickable)
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);
// ---------- State and 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 {}
});
// Click the virtual LED to toggle the real one
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 % maps to -135..+135 degrees, like a real 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; // no easing for discrete states
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>
Turn the real knob and the 3D knob follows. Press the button and the red cap dips. Click the 3D LED and, a moment later, both the real LED and the virtual one light up. Unplug the ESP32 and, after the keepalive expires, the board in the browser turns grey.
ESP32 Digital Twin Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
Serial shows rc=-2 repeatedly |
Network or DNS failure | Check Wi-Fi credentials and that port 1883 is not blocked |
| Knob always 0 or 4095 | Wrong pin or wiring | Use GPIO34 (ADC1), wiper to the middle leg |
| Twin shows old values, not live | Topic prefix mismatch | Compare DEVICE_ID in the sketch with P in the page |
| LED click does nothing | Board not subscribed after reconnect | Subscribe inside connectMqtt() as shown |
| Board and twin flicker online/offline | Two boards with the same client ID | Give every device a unique ID |
For more on payload design and naming as your project grows, see MQTT for digital twins. To replace the primitive shapes with a real board model, read binding IoT data to a 3D model, and for a more advanced scene, try the Three.js digital twin with a tank and motor.
Next Step: Secure It with Your Own Broker
The public broker is perfect for this project, but anyone who knows your topic can read it and even toggle your LED. To make it private, create a broker on a managed MQTT broker service and switch the sketch to TLS on port 8883 with WiFiClientSecure and the credentials from the portal: mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). With ConnectMQ the certificate comes from Let's Encrypt, so you can load the ISRG Root X1 certificate into WiFiClientSecure with setCACert(). Give the browser twin its own credentials and use them in the mqtt.connect options. The ESP32 IoT tutorial shows the full TLS setup step by step.
Get Started with ConnectMQ
Your ESP32 digital twin works on the public broker today. When you want it private, create a free broker at app.connectmq.com: the Serverless plan needs no credit card, supports up to 25 connections and includes a live topic explorer and in-browser MQTT client to watch your board's messages. You can also keep exploring the free public MQTT broker for more experiments.
Frequently Asked Questions
Is this really a digital twin or just a dashboard?
It is a simple digital twin: a virtual model that mirrors the live state of a specific physical device and can send commands back. Industrial twins add more data, history and analytics, but the core loop is the same.
Can I use MicroPython instead of Arduino?
Yes. The umqtt.simple module on MicroPython supports the same topics, retained messages and last will, so the browser twin does not change at all.
Why does the twin take a while to show the board as offline?
The broker publishes the last will only after the keepalive interval passes without contact, here about 30 to 45 seconds. A shorter keepalive detects failures faster at the cost of a little more traffic.
Can several people view the twin at once?
Yes. Each browser is a separate MQTT subscriber with its own client ID. On the free Serverless plan, keep the total of devices plus viewers within the 25-connection limit.



