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:
- Publicação de estado: o dispositivo informa como está, não apenas eventos.
- Banda morta e limite de taxa: enviar mudanças que importam, não ruído.
- Retained messages: um gêmeo correto desde o momento em que é aberto.
- Last will: o gêmeo sabe quando o dispositivo desaparece.
- 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.



