Gêmeo digital

Gêmeo digital com ESP32: um projeto para iniciantes com MQTT e um modelo 3D no navegador

Um gêmeo digital ESP32 para iniciantes: a placa publica um potenciômetro, um botão e o estado de um LED via MQTT, um modelo Three.js no navegador os espelha em tempo real, e clicar no LED 3D liga e desliga o LED real.

Projeto de gêmeo digital ESP32 com uma placa física e seu modelo 3D no navegador espelhando um potenciômetro, um botão e um LED
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

Um gêmeo digital ESP32 é a forma mais prática de entender o que um gêmeo digital realmente é: você gira um potenciômetro na sua bancada e um modelo 3D no navegador gira junto. Neste projeto para iniciantes, um ESP32 publica um potenciômetro, um botão e o estado de um LED via MQTT no servidor MQTT público em broker.connectmq.com, uma página Three.js os espelha em tempo real, e clicar no LED virtual liga e desliga o LED real. Leva cerca de uma hora e usa componentes que a maioria dos makers já tem.

O que você vai aprender

Este projeto é pequeno, mas cobre todos os conceitos em que gêmeos industriais maiores se apoiam:

  1. Publicação de estado: o dispositivo informa como está, não apenas eventos.
  2. Banda morta e limite de taxa: enviar mudanças que importam, não ruído.
  3. Retained messages: um gêmeo correto desde o momento em que é aberto.
  4. Last will: o gêmeo sabe quando o dispositivo desaparece.
  5. Controle bidirecional: os comandos descem e o estado confirmado volta.

Se você nunca conectou um ESP32 ao MQTT, faça antes o nosso tutorial de ESP32 com MQTT; este artigo pressupõe que você sabe gravar um sketch pela Arduino IDE. Para a teoria por trás dos gêmeos, consulte o guia de tecnologia de gêmeo digital.

Componentes e ligações

Componente Pino do ESP32 Observações
Potenciômetro de 10 k, cursor GPIO34 Terminais externos em 3V3 e GND. O GPIO34 é um pino do ADC1, que funciona com o Wi-Fi ativo
Botão GPIO4 Outro terminal em GND; o sketch ativa o pull-up interno
LED + resistor de 330 ohms GPIO2 Muitas placas DevKit já têm um LED no GPIO2
ESP32 DevKit USB Qualquer placa ESP32 clássica funciona

Evite pinos do ADC2 (como GPIO25 a GPIO27) para o potenciômetro: o ADC2 não pode ser lido enquanto o Wi-Fi está ativo, uma fonte clássica da confusão "o valor é sempre zero". A documentação de ADC da Espressif lista quais pinos pertencem a qual ADC.

Design dos topics

Use um ID de dispositivo único no prefixo, como esp32-7f3a:

Topic Direção Payload Retained
connectmq/demo/esp32-7f3a/state ESP32 publica {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} Sim
connectmq/demo/esp32-7f3a/led/set Gêmeo publica ON, OFF, TOGGLE Não
connectmq/demo/esp32-7f3a/status ESP32 publica, last will online / offline Sim

Um único objeto de estado combinado mantém o gêmeo consistente: potenciômetro, botão e LED sempre vêm do mesmo retrato do dispositivo.

Passo 1: o sketch do ESP32

Instale a biblioteca PubSubClient, de Nick O'Leary, pelo Gerenciador de Bibliotecas e depois grave este sketch após preencher os dados do seu Wi-Fi e o ID do 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";  // troque pelo seu próprio sufixo aleatório

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;  // informa o novo 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();

  // Faz a média de algumas amostras e converte para 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 morta de 2 %

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

  unsigned long now = millis();
  bool rateOk = now - lastPublish >= 200;        // no máximo 5 mensagens/s
  bool heartbeat = now - lastHeartbeat >= 10000;  // atualiza a cada 10 s de qualquer forma
  if ((dirty && rateOk) || heartbeat) publishState();
}

Abra o Monitor Serial a 115200 baud. Quando aparecer "connected", verifique o fluxo a partir do seu computador:

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

Gire o potenciômetro e pressione o botão; uma nova mensagem de estado aparece em uma fração de segundo.

Por que o sketch publica desta forma

Banda morta. Leituras analógicas oscilam uma ou duas contagens mesmo quando ninguém toca no potenciômetro. Sem a banda morta de 2 por cento, a placa publicaria o tempo todo. Sensores reais precisam do mesmo tratamento.

Limite de taxa. Girar o potenciômetro rapidamente gera muitas mudanças. Limitar a cinco mensagens por segundo mantém o dispositivo bem dentro do limite do servidor MQTT público, de 10 mensagens por segundo por cliente, e ainda assim a resposta parece instantânea.

Heartbeat. Uma mensagem de estado a cada dez segundos, mesmo sem mudanças, permite ao gêmeo distinguir um dispositivo silencioso de um com defeito, além de atualizar o RSSI e o uptime.

Estado retained e last will. O gêmeo recebe o estado atual imediatamente ao carregar, e o broker anuncia offline se a placa perder a alimentação.

Estado após o comando. Quando chega um comando, o sketch altera o LED e depois informa o novo estado. O gêmeo exibe esse relato, não o clique; essa é a essência da sincronização de estado do gêmeo digital.

Passo 2: o gêmeo 3D no navegador

Salve esta página como index.html e sirva com npx serve .. Ela monta uma placa ESP32 simplificada com um potenciômetro, um botão e um LED, e os vincula ao 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 estilo protoboard e 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);

    // Knob do potenciômetro com um ponteiro
    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);

    // Botão (a tampa desce quando pressionado)
    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 (clicável)
    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 e 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 {}
    });

    // Clique no LED virtual para alternar o LED 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 % corresponde a -135..+135 graus, como um 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;  // sem suavização 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>

Gire o potenciômetro real e o knob 3D acompanha. Pressione o botão e a tampa vermelha afunda. Clique no LED 3D e, um instante depois, tanto o LED real quanto o virtual acendem. Desconecte o ESP32 e, depois que o keepalive expirar, a placa no navegador fica cinza.

Solução de problemas do gêmeo digital ESP32

Problema Causa Solução
Serial mostra rc=-2 repetidamente Falha de rede ou de DNS Verifique as credenciais do Wi-Fi e se a porta 1883 não está bloqueada
Potenciômetro sempre em 0 ou 4095 Pino ou ligação errada Use o GPIO34 (ADC1), com o cursor no terminal do meio
Gêmeo mostra valores antigos, não ao vivo Prefixo de topic diferente Compare DEVICE_ID no sketch com P na página
Clique no LED não faz nada Placa não assina o topic após reconectar Faça a assinatura dentro de connectMqtt(), como mostrado
Placa e gêmeo alternam entre online e offline Duas placas com o mesmo client ID Dê a cada dispositivo um ID único

Para saber mais sobre design de payloads e nomenclatura à medida que seu projeto cresce, consulte MQTT para gêmeos digitais. Para substituir as formas primitivas por um modelo real da placa, leia vincular dados IoT a um modelo 3D e, para uma cena mais avançada, experimente o gêmeo digital com Three.js com um tanque e um motor.

Próximo passo: proteja tudo com seu próprio broker

O servidor MQTT público é perfeito para este projeto e para outros testes e protótipos, mas qualquer pessoa que conheça seu topic pode lê-lo e até alternar o seu LED. Para torná-lo privado, crie um broker MQTT gerenciado privado e mude o sketch para TLS na porta 8883 com WiFiClientSecure e as credenciais do portal: mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). Com a ConnectMQ, o certificado vem da Let's Encrypt, então você pode carregar o certificado ISRG Root X1 no WiFiClientSecure com setCACert(). Dê ao gêmeo no navegador suas próprias credenciais e use-as nas opções de mqtt.connect. O tutorial de IoT com ESP32 mostra a configuração completa de TLS passo a passo.

Comece a usar a ConnectMQ

Seu gêmeo digital ESP32 já funciona hoje no servidor MQTT público. Quando quiser torná-lo privado, crie um broker gratuito em app.connectmq.com: o plano Serverless não exige cartão de crédito, suporta até 25 conexões e inclui um explorador de topics ao vivo e um cliente MQTT no navegador para acompanhar as mensagens da sua placa. Você também pode continuar explorando o servidor MQTT público (gratuito) para mais experimentos, como um protótipo de monitoramento de estufa ou de bomba de irrigação.

Perguntas frequentes

Isto é realmente um gêmeo digital ou apenas um dashboard?

É um gêmeo digital simples: um modelo virtual que espelha o estado ao vivo de um dispositivo físico específico e pode enviar comandos de volta. Gêmeos industriais acrescentam mais dados, histórico e análises, mas o ciclo central é o mesmo.

Posso usar MicroPython em vez de Arduino?

Sim. O módulo umqtt.simple do MicroPython suporta os mesmos topics, retained messages e last will, então o gêmeo no navegador não muda em nada.

Por que o gêmeo demora para mostrar a placa como offline?

O broker publica o last will somente depois que o intervalo de keepalive passa sem contato, aqui cerca de 30 a 45 segundos. Um keepalive menor detecta falhas mais rápido, ao custo de um pouco mais de tráfego.

Várias pessoas podem visualizar o gêmeo ao mesmo tempo?

Sim. Cada navegador é um assinante MQTT separado com seu próprio client ID. No plano Serverless gratuito, mantenha o total de dispositivos mais visualizadores dentro do limite de 25 conexões.

Artigos relacionados

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.