Gemelo digital

Gemelo digital con ESP32: proyecto para principiantes con MQTT y un modelo 3D en el navegador

Un gemelo digital ESP32 apto para principiantes: la placa publica un potenciómetro, un botón y el estado de un LED por MQTT, un modelo Three.js en el navegador los replica en tiempo real y al hacer clic en el LED 3D se conmuta el real.

Proyecto de gemelo digital ESP32 con una placa física y su modelo 3D en el navegador replicando un potenciómetro, un botón y un LED
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

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:

  1. Publicación de estado: el dispositivo informa de cómo está, no solo de eventos.
  2. Bandas muertas y límites de frecuencia: enviar los cambios que importan, no ruido.
  3. Retained messages: un gemelo correcto desde el momento en que se abre.
  4. Last will: el gemelo sabe cuándo desaparece el dispositivo.
  5. 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.

Artículos relacionados

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.