Dijital İkiz

ESP32 Dijital İkiz: MQTT ve 3D Tarayıcı Modeliyle Başlangıç Projesi

Yeni başlayanlara uygun bir ESP32 dijital ikiz: kart bir potansiyometreyi, bir butonu ve LED durumunu MQTT ile yayınlar, tarayıcıdaki bir Three.js modeli bunları gerçek zamanlı yansıtır ve 3D LED'e tıklamak gerçek LED'i açıp kapatır.

Fiziksel bir kart ile potansiyometre, buton ve LED'i yansıtan 3D tarayıcı modelinden oluşan ESP32 dijital ikiz projesi
Bunu kendi MQTT broker’ınızda çalıştırınFree planla özel, yönetilen bir MQTT broker alın ve hazır kod örnekleriyle başlayın.
Ücretsiz deneyin Kod örnekleri Kart gerekmez

Bir ESP32 dijital ikiz, dijital ikizin gerçekte ne olduğunu anlamanın en uygulamalı yoludur: masanızdaki bir düğmeyi çevirirsiniz ve tarayıcıdaki 3D model de onunla birlikte döner. Bu başlangıç projesinde bir ESP32, bir potansiyometreyi, bir butonu ve LED durumunu MQTT üzerinden broker.connectmq.com adresine yayınlar; bir Three.js sayfası bunları gerçek zamanlı yansıtır ve sanal LED'e tıklamak gerçek LED'i açıp kapatır. Proje yaklaşık bir saat sürer ve çoğu maker'ın elinde zaten bulunan parçaları kullanır.

Neler Öğreneceksiniz?

Bu proje küçük olsa da daha büyük endüstriyel ikizlerin dayandığı tüm kavramları kapsar:

  1. Durum yayınlama: cihaz yalnızca olayları değil, ne durumda olduğunu bildirir.
  2. Ölü bantlar ve hız sınırları: gürültüyü değil, önemli değişiklikleri göndermek.
  3. Retained mesajlar: açıldığı anda doğru olan bir ikiz.
  4. Last will: ikiz, cihazın ortadan kaybolduğunu bilir.
  5. Çift yönlü kontrol: komutlar aşağı iner, onaylanmış durum yukarı çıkar.

Bir ESP32'yi daha önce hiç MQTT'ye bağlamadıysanız önce ESP32 MQTT eğitimimizi tamamlayın; bu makale Arduino IDE'den bir sketch yükleyebildiğinizi varsayar. İkizlerin ardındaki teori için dijital ikiz teknolojisi rehberine bakın.

Parçalar ve Bağlantılar

Parça ESP32 pini Notlar
10 k potansiyometre, orta uç GPIO34 Dış uçlar 3V3 ve GND'ye. GPIO34 bir ADC1 pinidir ve Wi-Fi etkinken çalışır
Buton GPIO4 Diğer uç GND'ye; sketch dahili pull-up'ı etkinleştirir
LED + 330 ohm direnç GPIO2 Birçok DevKit kartında GPIO2'de zaten bir LED bulunur
ESP32 DevKit USB Herhangi bir klasik ESP32 kartı çalışır

Potansiyometre için ADC2 pinlerinden (GPIO25 ile GPIO27 arası gibi) kaçının: Wi-Fi çalışırken ADC2 okunamaz; bu, klasik "değer hep sıfır" kafa karışıklığının kaynağıdır. Espressif ADC dokümantasyonu hangi pinlerin hangi ADC'ye ait olduğunu listeler.

Topic Tasarımı

Prefix'te esp32-7f3a gibi benzersiz bir cihaz ID'si kullanın:

Topic Yön Payload Retained
connectmq/demo/esp32-7f3a/state ESP32 yayınlar {"knob":63,"button":false,"led":true,"rssi":-58,"uptime":412} Evet
connectmq/demo/esp32-7f3a/led/set İkiz yayınlar ON, OFF, TOGGLE Hayır
connectmq/demo/esp32-7f3a/status ESP32 yayınlar, last will online / offline Evet

Tek bir birleşik durum nesnesi ikizi tutarlı tutar: potansiyometre, buton ve LED her zaman cihazın aynı anlık görüntüsünden gelir.

Adım 1: ESP32 Sketch'i

Library Manager'dan Nick O'Leary'nin PubSubClient kütüphanesini kurun, ardından Wi-Fi bilgilerinizi ve cihaz ID'nizi doldurduktan sonra bu sketch'i yükleyin.

#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();
}

Serial Monitor'ü 115200 baud hızında açın. "connected" yazısını gördüğünüzde akışı bilgisayarınızdan kontrol edin:

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

Potansiyometreyi çevirin ve butona basın; saniyenin çok küçük bir kesrinde yeni bir durum mesajı belirir.

Sketch Neden Bu Şekilde Yayın Yapıyor?

Ölü bant. Analog okumalar, kimse potansiyometreye dokunmasa bile bir iki birim oynar. Yüzde 2'lik ölü bant olmasaydı kart sürekli yayın yapardı. Gerçek sensörler de aynı işlemi gerektirir.

Hız sınırı. Potansiyometreyi hızlı çevirmek çok sayıda değişiklik üretir. Saniyede beş mesajla sınırlamak, anlık hissettirmeye devam ederken cihazı public broker'ın client başına saniyede 10 mesaj sınırının rahatça içinde tutar.

Heartbeat. Değişiklik olmasa bile on saniyede bir gönderilen durum mesajı, ikizin sessiz bir cihazı bozuk bir cihazdan ayırt etmesini sağlar ve RSSI ile çalışma süresini tazeler.

Retained durum ve last will. İkiz yüklendiği anda güncel durumu alır ve kart gücünü kaybederse broker offline duyurusu yapar.

Komut sonrası durum. Bir komut geldiğinde sketch LED'i değiştirir ve ardından yeni durumu bildirir. İkiz tıklamayı değil bu bildirimi gösterir; bu, dijital ikiz durum senkronizasyonunun özüdür.

Adım 2: Tarayıcıdaki 3D İkiz

Bu sayfayı index.html olarak kaydedin ve npx serve . ile sunun. Sayfa; potansiyometre, buton ve LED içeren basitleştirilmiş bir ESP32 kartı oluşturur ve bunları durum topic'ine bağlar.

<!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>

Gerçek potansiyometreyi çevirin, 3D düğme onu takip eder. Butona basın, kırmızı başlık aşağı iner. 3D LED'e tıklayın; bir an sonra hem gerçek LED hem de sanal LED yanar. ESP32'nin fişini çekin; keepalive süresi dolduktan sonra tarayıcıdaki kart griye döner.

ESP32 Dijital İkiz Sorun Giderme

Sorun Neden Çözüm
Seri monitörde sürekli rc=-2 görünüyor Ağ veya DNS hatası Wi-Fi bilgilerini ve 1883 portunun engellenmediğini kontrol edin
Potansiyometre hep 0 veya 4095 Yanlış pin veya bağlantı GPIO34 (ADC1) kullanın, orta ucu orta bacağa bağlayın
İkiz canlı değil, eski değerleri gösteriyor Topic prefix uyuşmazlığı Sketch'teki DEVICE_ID ile sayfadaki P değerini karşılaştırın
LED'e tıklamak hiçbir şey yapmıyor Kart yeniden bağlandıktan sonra abone olmuyor Gösterildiği gibi connectMqtt() içinde abone olun
Kart ve ikiz çevrimiçi/çevrimdışı arasında gidip geliyor Aynı client ID'ye sahip iki kart Her cihaza benzersiz bir ID verin

Projeniz büyüdükçe payload tasarımı ve adlandırma hakkında daha fazlası için dijital ikizler için MQTT yazımıza bakın. Basit şekilleri gerçek bir kart modeliyle değiştirmek için IoT verisini bir 3D modele bağlama yazımızı okuyun; daha gelişmiş bir sahne için tank ve motor içeren Three.js dijital ikiz eğitimini deneyin.

Sonraki Adım: Kendi Broker'ınızla Güvenli Hâle Getirin

Public broker bu proje için mükemmeldir, ancak topic'inizi bilen herkes onu okuyabilir ve hatta LED'inizi açıp kapatabilir. Projeyi özel hâle getirmek için bir yönetilen MQTT broker hizmetinde bir broker oluşturun ve sketch'i WiFiClientSecure ve portaldaki kimlik bilgileriyle 8883 portunda TLS'ye geçirin: mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", ...). ConnectMQ ile sertifika Let's Encrypt'ten gelir; bu yüzden ISRG Root X1 sertifikasını setCACert() ile WiFiClientSecure içine yükleyebilirsiniz. Tarayıcı ikizine kendi kimlik bilgilerini verin ve bunları mqtt.connect seçeneklerinde kullanın. ESP32 IoT eğitimi eksiksiz TLS kurulumunu adım adım gösteriyor.

ConnectMQ ile Başlayın

ESP32 dijital ikiziniz bugün public broker üzerinde çalışıyor. Onu özel hâle getirmek istediğinizde app.connectmq.com adresinden ücretsiz bir broker oluşturun: Serverless plan kredi kartı gerektirmez, 25 bağlantıya kadar destekler ve kartınızın mesajlarını izlemek için canlı bir topic gezgini ile tarayıcı içi bir MQTT client'ı içerir. Daha fazla deneme için ücretsiz public MQTT broker ile keşfetmeye de devam edebilirsiniz.

Sık Sorulan Sorular

Bu gerçekten bir dijital ikiz mi, yoksa sadece bir panel mi?

Basit bir dijital ikizdir: belirli bir fiziksel cihazın canlı durumunu yansıtan ve geri komut gönderebilen sanal bir model. Endüstriyel ikizler daha fazla veri, geçmiş ve analitik ekler, ancak temel döngü aynıdır.

Arduino yerine MicroPython kullanabilir miyim?

Evet. MicroPython'daki umqtt.simple modülü aynı topic'leri, retained mesajları ve last will'i destekler; bu yüzden tarayıcı ikizi hiç değişmez.

İkizin kartı çevrimdışı göstermesi neden biraz zaman alıyor?

Broker last will mesajını ancak keepalive aralığı iletişim olmadan geçtikten sonra yayınlar; burada bu süre yaklaşık 30 ile 45 saniyedir. Daha kısa bir keepalive, biraz daha fazla trafik pahasına arızaları daha hızlı tespit eder.

İkizi aynı anda birkaç kişi görüntüleyebilir mi?

Evet. Her tarayıcı, kendi client ID'sine sahip ayrı bir MQTT abonesidir. Ücretsiz Serverless planında cihazlar ve izleyicilerin toplamını 25 bağlantı sınırı içinde tutun.

İlgili yazılar

Tüm yazılar →

Özel MQTT broker’ınız birkaç tık uzağınızda

Hesabınızı oluşturun, broker kimlik bilgilerinizi alın ve ilk cihazınızı bağlayın — kurulacak, yamalanacak ya da ölçeklendirilecek sunucu yok.