Un gemelo digital ESP32 es la forma más práctica de entender qué es realmente un gemelo digital: giras un potenciómetro sobre tu mesa y su gemelo digital 3D en el navegador gira con él. En este proyecto para principiantes, un ESP32 publica un potenciómetro, un pulsador y el estado de un LED por MQTT en el servidor MQTT público gratuito broker.connectmq.com, una página Three.js los replica en tiempo real y al hacer clic en el LED virtual se enciende o apaga el real. Lleva alrededor de una hora y usa componentes que la mayoría de makers ya tiene.
Qué vas a aprender
Este proyecto es pequeño, pero cubre todos los conceptos en los que se apoyan los gemelos industriales más grandes:
- Publicación de estado: el dispositivo informa de cómo está, no solo de eventos.
- Bandas muertas y límites de frecuencia: enviar los cambios que importan, no ruido.
- Retained messages: un gemelo correcto desde el momento en que se abre.
- Last will: el gemelo sabe cuándo desaparece el dispositivo.
- Control bidireccional: los comandos bajan y el estado confirmado vuelve a subir.
Si nunca has conectado un ESP32 a MQTT, completa primero nuestro tutorial de ESP32 y MQTT; este artículo da por hecho que sabes cargar un sketch desde el Arduino IDE. Para la teoría de los gemelos, consulta la guía de tecnología de gemelo digital.
Componentes y conexiones
| Componente | Pin del ESP32 | Notas |
|---|---|---|
| Potenciómetro de 10 k, cursor | GPIO34 | Patillas exteriores a 3V3 y GND. GPIO34 es un pin de ADC1, que funciona con el Wi-Fi activo |
| Pulsador | GPIO4 | La otra patilla a GND; el sketch activa el pull-up interno |
| LED + resistencia de 330 ohmios | GPIO2 | Muchas placas DevKit ya traen un LED en GPIO2 |
| ESP32 DevKit | USB | Sirve cualquier placa ESP32 clásica |
Evita los pines de ADC2 (como GPIO25 a GPIO27) para el potenciómetro: ADC2 no se puede leer mientras el Wi-Fi está funcionando, una fuente clásica de confusión del tipo "el valor siempre es cero". La documentación de ADC de Espressif indica qué pines pertenecen a cada ADC.
Diseño de topics
Usa un ID de dispositivo único en el prefijo, como esp32-7f3a:
| Topic | Dirección | Payload | Retenido |
|---|---|---|---|
connectmq/demo/esp32-7f3a/state |
Publica el ESP32 | {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} |
Sí |
connectmq/demo/esp32-7f3a/led/set |
Publica el gemelo | ON, OFF, TOGGLE |
No |
connectmq/demo/esp32-7f3a/status |
Publica el ESP32, last will | online / offline |
Sí |
Un único objeto de estado combinado mantiene coherente el gemelo: el potenciómetro, el botón y el LED proceden siempre de la misma instantánea del dispositivo.
Paso 1: el sketch del ESP32
Instala la librería PubSubClient de Nick O'Leary desde el Gestor de librerías y carga este sketch tras completar los datos de tu Wi-Fi y el ID del dispositivo.
#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"; // cámbialo por tu propio sufijo aleatorio
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); // retenido
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; // informar del nuevo estado real
}
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();
// Promediar varias muestras y convertir a 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; } // banda muerta del 2 %
bool button = digitalRead(PIN_BUTTON) == LOW;
if (button != lastButton) { lastButton = button; dirty = true; }
unsigned long now = millis();
bool rateOk = now - lastPublish >= 200; // como máximo 5 mensajes/s
bool heartbeat = now - lastHeartbeat >= 10000; // refrescar cada 10 s de todos modos
if ((dirty && rateOk) || heartbeat) publishState();
}
Abre el Monitor Serie a 115200 baudios. Cuando aparezca "connected", comprueba el flujo desde tu ordenador:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-esp32-7f3a -v -t "connectmq/demo/esp32-7f3a/#"
Gira el potenciómetro y pulsa el botón; en una fracción de segundo aparece un nuevo mensaje de estado.
Por qué el sketch publica de esta forma
Banda muerta. Las lecturas analógicas oscilan una o dos cuentas aunque nadie toque el potenciómetro. Sin la banda muerta del 2 por ciento, la placa publicaría sin parar. Los sensores reales necesitan el mismo tratamiento.
Límite de frecuencia. Girar el potenciómetro deprisa produce muchos cambios. Limitarlo a cinco mensajes por segundo mantiene al dispositivo muy por debajo del límite del servidor público de 10 mensajes por segundo por cliente, sin dejar de sentirse instantáneo.
Heartbeat. Un mensaje de estado cada diez segundos, aunque no haya cambios, permite al gemelo distinguir un dispositivo tranquilo de uno averiado, y refresca el RSSI y el uptime.
Estado retenido y last will. El gemelo recibe el estado actual en cuanto carga, y el broker anuncia offline si la placa se queda sin alimentación.
Estado después del comando. Cuando llega un comando, el sketch cambia el LED y después informa del nuevo estado. El gemelo muestra ese informe, no el clic; esta es la esencia de la sincronización de estado del gemelo digital.
Paso 2: el gemelo 3D en el navegador
Guarda esta página como index.html y sírvela con npx serve .. Construye una placa ESP32 simplificada con un potenciómetro, un botón y un LED, y los vincula al topic de estado.
<!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 tipo protoboard y módulo 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);
// Mando del potenciómetro con indicador
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);
// Pulsador (el capuchón baja al pulsarlo)
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 (se puede hacer clic)
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);
// ---------- Estado y 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 {}
});
// Clic en el LED virtual para conmutar el real
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 % se asigna a -135..+135 grados, como un potenciómetro real
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; // sin suavizado para estados discretos
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>
Gira el potenciómetro real y el mando 3D lo sigue. Pulsa el botón y el capuchón rojo se hunde. Haz clic en el LED 3D y, un instante después, se encienden tanto el LED real como el virtual. Desconecta el ESP32 y, cuando expira el keepalive, la placa del navegador se vuelve gris.
Resolución de problemas del gemelo digital ESP32
| Problema | Causa | Solución |
|---|---|---|
El monitor serie muestra rc=-2 una y otra vez |
Fallo de red o de DNS | Revisa las credenciales Wi-Fi y que el puerto 1883 no esté bloqueado |
| El potenciómetro siempre marca 0 o 4095 | Pin o cableado incorrecto | Usa GPIO34 (ADC1), con el cursor en la patilla central |
| El gemelo muestra valores antiguos, no en vivo | Prefijo de topic distinto | Compara DEVICE_ID del sketch con P de la página |
| El clic en el LED no hace nada | La placa no se suscribe tras reconectar | Suscríbete dentro de connectMqtt() como se muestra |
| La placa y el gemelo alternan entre online y offline | Dos placas con el mismo client ID | Da a cada dispositivo un ID único |
Para saber más sobre el diseño de payloads y la nomenclatura a medida que crezca tu proyecto, consulta MQTT para gemelos digitales. Para sustituir las formas primitivas por un modelo real de la placa, lee vincular datos IoT a un modelo 3D y, para una escena más avanzada, prueba el gemelo digital con Three.js con un tanque y un motor.
Siguiente paso: protégelo con tu propio broker
El servidor público gratuito es perfecto para pruebas y prototipos como este proyecto, pero no para datos de producción: cualquiera que conozca tu topic puede leerlo e incluso conmutar tu LED. Para hacerlo privado, crea un broker en un servicio de broker MQTT gestionado y cambia el sketch a TLS en el puerto 8883 con WiFiClientSecure y las credenciales del portal: mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). Con ConnectMQ el certificado lo emite Let's Encrypt, así que puedes cargar el certificado ISRG Root X1 en WiFiClientSecure con setCACert(). Da al gemelo del navegador sus propias credenciales y úsalas en las opciones de mqtt.connect. El tutorial de IoT con ESP32 muestra paso a paso la configuración completa de TLS.
Empieza con ConnectMQ
Tu gemelo digital ESP32 ya funciona hoy en el servidor público gratuito. Cuando quieras que sea privado, crea un broker gratuito en app.connectmq.com: el plan Serverless no requiere tarjeta de crédito, admite hasta 25 conexiones e incluye un explorador de topics en vivo y un cliente MQTT en el navegador para ver los mensajes de tu placa. También puedes seguir usando el servidor MQTT público gratuito para más experimentos.
Preguntas frecuentes
¿Esto es realmente un gemelo digital o solo un dashboard?
Es un gemelo digital sencillo: un modelo virtual que replica el estado en vivo de un dispositivo físico concreto y puede enviarle comandos. Los gemelos industriales añaden más datos, historial y analítica, pero el bucle esencial es el mismo.
¿Puedo usar MicroPython en lugar de Arduino?
Sí. El módulo umqtt.simple de MicroPython admite los mismos topics, retained messages y last will, así que el gemelo del navegador no cambia en absoluto.
¿Por qué tarda el gemelo en mostrar la placa como desconectada?
El broker publica el last will solo cuando pasa el intervalo de keepalive sin contacto, aquí unos 30 a 45 segundos. Un keepalive más corto detecta los fallos antes a cambio de un poco más de tráfico.
¿Pueden varias personas ver el gemelo a la vez?
Sí. Cada navegador es un suscriptor MQTT independiente con su propio client ID. En el plan Serverless gratuito, mantén el total de dispositivos más espectadores dentro del límite de 25 conexiones.



