ESP32 & ESP8266

Membangun Web Server di ESP32: Panduan Lengkap

๐Ÿ‘‘ Premium

Pelajari cara membuat ESP32 menjadi web server mandiri โ€” dari hello world hingga dashboard sensor multi-halaman dengan WebSocket real-time

1. Web Server di ESP32: AsyncWebServer vs WebServer

ESP32 memiliki keunggulan besar dalam proyek IoT: ia bisa berfungsi sebagai web server mandiri tanpa memerlukan komputer atau perangkat tambahan. Cukup dengan menghubungkan ESP32 ke jaringan WiFi, perangkat ini bisa melayani halaman web yang diakses dari browser di komputer, tablet, atau ponsel.

Di dunia Arduino/ESP32, terdapat dua library utama untuk membuat web server:

ESP32 WebServer (Bawaan)

Library ini disertakan secara default dalam core ESP32 Arduino. Sangat cocok untuk proyek-proyek sederhana yang hanya memerlukan beberapa endpoint. Kelebihannya adalah tidak memerlukan instalasi library tambahan, sehingga langsung bisa digunakan. Namun library ini bersifat synchronous โ€” satu request harus selesai diproses sebelum request berikutnya diterima.

ESPAsyncWebServer

Library pihak ketiga yang sangat populer di komunitas ESP32. Menggunakan arsitektur asynchronous sehingga mampu menangani banyak request secara bersamaan tanpa memblokir loop utama. Sangat direkomendasikan untuk proyek yang melibatkan WebSocket, banyak klien, atau streaming data real-time.

Perbandingan WebServer vs AsyncWebServer

Fitur ESP32 WebServer ESPAsyncWebServer
TipeSynchronous (blokir)Asynchronous (non-blokir)
Multi-klienTerbatasBanyak klien simultan
WebSocketTidak didukungโœ… Didukung penuh
EventSourceTidak didukungโœ… Didukung
Simplicityโœ… Sangat mudahSedikit lebih kompleks
RAM UsageLebih ringanSedikit lebih besar
Cocok untukProyek sederhana, few endpointDashboard real-time, banyak klien
LibraryBawaan ESP32 corePerlu install: AsyncWebServer + AsyncTCP
๐Ÿ’ก Rekomendasi

Untuk tutorial ini, kita akan menggunakan ESPAsyncWebServer karena lebih serbaguna dan didukung oleh komunitas yang luas. Namun untuk contoh paling awal (Section 3), kita juga menunjukkan cara menggunakan WebServer bawaan agar Anda memahami perbedaannya.

2. Setup & Instalasi Library

Sebelum membangun web server, pastikan Anda sudah menginstal ESP32 board di Arduino IDE (ikuti tutorial Panduan Lengkap ESP32 jika belum). Selanjutnya, kita perlu menginstal library tambahan.

Langkah 1: Instalasi Library via Arduino IDE

Buka menu Sketch โ†’ Include Library โ†’ Manage Libraries, kemudian cari dan instal library berikut:

Library Pengarang Fungsi
ESPAsyncWebServerme-no-dev (lacamera)Server web asynchronous utama
AsyncTCPme-no-devTCP asynchronous (dependency AsyncWebServer)
ArduinoJsonBenoit BlanchonPembuatan & parsing JSON
DHT sensor libraryAdafruitSupport untuk sensor DHT11/DHT22

Langkah 2: Instalasi via Library Manager di Arduino IDE 2.x

  1. Buka Arduino IDE, kemudian buka menu Tools โ†’ Manage Libraries
  2. Pada kolom pencarian, ketik "ESPAsyncWebServer"
  3. Pilih "ESPAsyncWebServer by lacamera", klik Install
  4. Jika diminta, konfirmasi instalasi dependency (AsyncTCP akan terinstall otomatis)
  5. Ulangi langkah di atas untuk "ArduinoJson" oleh Benoit Blanchon

Langkah 3: Instalasi Manual (Alternatif)

Jika Library Manager bermasalah, Anda bisa clone langsung dari GitHub:

Terminal
# Clone ESPAsyncWebServer dan AsyncTCP ke folder Arduino/libraries
cd ~/Arduino/libraries

git clone https://github.com/me-no-dev/ESPAsyncWebServer.git
git clone https://github.com/me-no-dev/AsyncTCP.git
โš ๏ธ Catatan Penting

Pastikan tidak ada konflik dengan library WebServer bawaan. Jika menggunakan Arduino IDE, hindari menginstall kedua library secara bersamaan dalam sketch yang sama karena bisa menyebabkan error kompilasi. Pilih salah satu sesuai kebutuhan proyek Anda.

3. Hello World Web Server

Mari mulai dengan contoh paling sederhana: ESP32 yang menampilkan halaman "Hello World" saat diakses dari browser. Kita akan menunjukkan kedua pendekatan.

Cara A: Menggunakan WebServer Bawaan

C++ โ€” hello_webserver.ino
// Hello World Web Server - WebServer Bawaan ESP32
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <WebServer.h>

// Konfigurasi WiFi
const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

// Buat instance server pada port 80 (HTTP default)
WebServer server(80);

// Handler untuk halaman utama
void handleRoot() {
  String html = "<!DOCTYPE html>";
  html += "<html lang='id'>";
  html += "<head><meta charset='UTF-8'>";
  html += "<title>Hello ESP32</title>";
  html += "<style>";
  html += "body{font-family:sans-serif;text-align:center;";
  html += "margin-top:80px;background:#1a1a2e;color:#eee;}";
  html += "h1{color:#00ff88;font-size:2.5em;}";
  html += "</style></head>";
  html += "<body>";
  html += "<h1>Hello World dari ESP32!</h1>";
  html += "<p>Web server berjalan pada port 80</p>";
  html += "<p>Jam server: " + String(millis()/1000) + " detik</p>";
  html += "</body></html>";

  server.send(200, "text/html", html);
}

// Handler untuk halaman tidak ditemukan
void handleNotFound() {
  server.send(404, "text/plain", "404: Halaman tidak ditemukan!");
}

void setup() {
  Serial.begin(115200);
  Serial.println("Memulai Web Server ESP32...");

  // Koneksi WiFi
  WiFi.begin(ssid, password);
  Serial.print("Menghubungkan ke WiFi");

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.print("WiFi terhubung! IP address: ");
  Serial.println(WiFi.localIP());

  // Daftarkan route handler
  server.on("/", handleRoot);
  server.onNotFound(handleNotFound);

  // Mulai server
  server.begin();
  Serial.println("Web server dimulai!");
}

void loop() {
  server.handleClient();  // Proses request dari klien
}

Cara B: Menggunakan ESPAsyncWebServer (Rekomendasi)

C++ โ€” hello_async.ino
// Hello World Web Server - AsyncWebServer
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

AsyncWebServer server(80);

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <title>Hello ESP32 Async</title>
  <style>
    body {
      font-family: 'Segoe UI', sans-serif;
      text-align: center;
      margin-top: 80px;
      background: #0f0f1a;
      color: #e0e0e0;
    }
    h1 { color: #3ecf8e; font-size: 2.5em; }
    .uptime { font-size: 1.2em; color: #888; }
  </style>
</head>
<body>
  <h1>Hello World dari ESP32!</h1>
  <p class="uptime">Server berjalan via AsyncWebServer</p>
</body>
</html>
)rawliteral";

void setup() {
  Serial.begin(115200);
  Serial.println("Memulai AsyncWebServer...");

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi terhubung: " + WiFi.localIP().toString());

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send_P(200, "text/html", index_html);
  });

  server.begin();
  Serial.println("AsyncWebServer dimulai!");
}

void loop() {
  // Tidak perlu handleClient() di AsyncWebServer!
}
โ„น๏ธ Cara Mengakses

Setelah upload, buka Serial Monitor untuk melihat IP address ESP32 (misalnya 192.168.1.105). Kemudian buka browser di perangkat yang sama dengan jaringan WiFi, masukkan IP address tersebut di address bar. Anda akan melihat halaman "Hello World" yang dilayani oleh ESP32!

4. Serving HTML, CSS, JS dari ESP32

Untuk proyek web yang lebih serius, menulis seluruh HTML dalam string C++ bukanlah praktik terbaik. ESP32 mendukung beberapa pendekatan untuk menyajikan file statis:

Pendekatan 1: Inline HTML dengan PROGMEM

HTML, CSS, dan JavaScript ditulis langsung di dalam kode Arduino menggunakan PROGMEM (Program Memory) agar tidak memakan RAM. Cocok untuk halaman sederhana hingga sedang.

Pendekatan 2: SPIFFS / LittleFS

SPIFFS (SPI Flash File System) atau LittleFS memungkinkan Anda menyimpan file HTML, CSS, JS, dan gambar sebagai file terpisah di flash memory ESP32 (4MB). File di-upload menggunakan plugin Arduino IDE atau CLI.

C++ โ€” serving_static.ino
// Serving Static Files dengan LittleFS
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>

const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

AsyncWebServer server(80);

// CSS inline yang disajikan dari PROGMEM
const char style_css[] PROGMEM = R"rawliteral(
  :root {
    --bg: #0f0f1a;
    --card: #1a1a2e;
    --accent: #3ecf8e;
    --text: #e0e0e0;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: 'Segoe UI', sans-serif;
    background: var(--bg);
    color: var(--text);
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .container {
    text-align: center;
    padding: 2rem;
    background: var(--card);
    border-radius: 16px;
    border: 1px solid rgba(62, 207, 142, 0.2);
    box-shadow: 0 8px 32px rgba(0,0,0,0.3);
  }
  h1 { color: var(--accent); margin-bottom: 0.5rem; }
  .status {
    display: inline-block;
    margin-top: 1rem;
    padding: 0.5rem 1rem;
    background: rgba(62, 207, 142, 0.15);
    border-radius: 8px;
    color: var(--accent);
  }
)rawliteral";

// JavaScript untuk fetch status dari API
const char script_js[] PROGMEM = R"rawliteral(
  async function getStatus() {
    try {
      const resp = await fetch('/api/status');
      const data = await resp.json();
      document.getElementById('uptime').textContent =
        'Uptime: ' + data.uptime + ' detik';
      document.getElementById('heap').textContent =
        'Heap: ' + data.heap + ' bytes';
    } catch(e) {
      console.error('Gagal mengambil status:', e);
    }
  }
  // Perbarui status setiap 2 detik
  setInterval(getStatus, 2000);
  getStatus();
)rawliteral";

void setup() {
  Serial.begin(115200);

  // Inisialisasi LittleFS
  if (!LittleFS.begin(true)) {
    Serial.println("[ERROR] LittleFS gagal di-mount!");
    return;
  }
  Serial.println("LittleFS siap.");

  // Koneksi WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("\nIP: " + WiFi.localIP().toString());

  // Sajikan CSS dari PROGMEM
  server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/css", style_css);
  });

  // Sajikan JavaScript dari PROGMEM
  server.on("/app.js", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "application/javascript", script_js);
  });

  // Sajikan file dari LittleFS (jika ada index.html)
  server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");

  // API endpoint: status server
  server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest *req) {
    String json = "{\"uptime\":";
    json += String(millis() / 1000);
    json += ",\"heap\":";
    json += String(ESP.getFreeHeap());
    json += ",\"ip\":\"";
    json += WiFi.localIP().toString();
    json += "\"}";
    req->send(200, "application/json", json);
  });

  server.begin();
}

void loop() {
  // AsyncWebServer menangani semua di background
}
๐Ÿ’ก Tips: Upload File ke LittleFS

Untuk mengupload file ke LittleFS, buat folder data di dalam sketch folder Arduino Anda. Masukkan file HTML, CSS, dan JS ke dalamnya. Kemudian gunakan menu Tools โ†’ ESP32 Sketch Data Upload (perlu plugin) atau gunakan CLI: python -m esptool --port COM3 write_flash 0x290000 littlefs.bin. Gunakan tools mkspiffs atau mkittlefs untuk membuat image file system.

5. Handling Form Input (LED ON/OFF, Slider)

Salah satu kekuatan utama web server di ESP32 adalah kemampuan mengontrol perangkat keras dari browser. Mari kita buat halaman web yang bisa mengontrol LED dan mengatur kecerahan menggunakan slider PWM.

Rangkaian Hardware

Diagram: Rangkaian ESP32 + LED + Push Button
       ESP32
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚             โ”‚
    โ”‚   3.3V/5V   โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚             โ”‚            โ”‚
    โ”‚   GPIO 2    โ”œโ”€โ”€โ”€โ”€[LED]โ”€โ”€โ”€โ”ค Anode (+)
    โ”‚             โ”‚       [R]  โ”‚
    โ”‚   GND       โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค Cathode (-)
    โ”‚             โ”‚    220ฮฉ    โ”‚
    โ”‚   GPIO 4    โ”œโ”€โ”€โ”€โ”€[BTN]โ”€โ”€โ”€โ”ค Push Button
    โ”‚             โ”‚       โ”‚    โ”‚
    โ”‚   GND       โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”˜
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Catatan:
- LED anode ke GPIO 2, cathode ke GND via resistor 220ฮฉ
- Push button: satu sisi ke GPIO 4, sisi lain ke GND
  (ESP32 GPIO 4 menggunakan internal pull-up)
C++ โ€” form_control.ino
// Web Server: Kontrol LED & Slider PWM
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

AsyncWebServer server(80);

#define LED_PIN       2
#define BUTTON_PIN    4
#define PWM_CHANNEL   0
#define PWM_FREQ      5000
#define PWM_RES       8

bool ledState = false;
int ledBrightness = 128;

// Halaman utama dengan form kontrol
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Kontrol LED ESP32</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: sans-serif;
      background: #0f0f1a;
      color: #e0e0e0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .card {
      background: #1a1a2e;
      padding: 2rem;
      border-radius: 16px;
      border: 1px solid rgba(62, 207, 142, 0.2);
      width: 350px;
      text-align: center;
    }
    h1 { color: #3ecf8e; font-size: 1.5em; margin-bottom: 1rem; }
    .btn-group { margin: 1.5rem 0; }
    .btn {
      padding: 12px 24px;
      border: none;
      border-radius: 8px;
      font-size: 1em;
      font-weight: 600;
      cursor: pointer;
      margin: 0 0.5rem;
      transition: transform 0.15s;
    }
    .btn:hover { transform: scale(1.05); }
    .btn-on { background: #3ecf8e; color: #111; }
    .btn-off { background: #ef4444; color: #fff; }
    .status {
      margin-top: 1rem;
      padding: 0.5rem;
      background: rgba(62, 207, 142, 0.1);
      border-radius: 8px;
    }
    .slider-group { margin-top: 1.5rem; }
    .slider-group label { display: block; margin-bottom: 0.5rem; }
    input[type="range"] {
      width: 100%;
      accent-color: #3ecf8e;
    }
    #brightness-val { color: #3ecf8e; font-weight: bold; }
  </style>
</head>
<body>
  <div class="card">
    <h1>๐Ÿ’ก Kontrol LED ESP32</h1>

    <div class="btn-group">
      <button class="btn btn-on" onclick="setLED(1)">ON</button>
      <button class="btn btn-off" onclick="setLED(0)">OFF</button>
    </div>

    <div class="slider-group">
      <label>Kecerahan: <span id="brightness-val">128</span></label>
      <input type="range" min="0" max="255" value="128"
             oninput="setBrightness(this.value)">
    </div>

    <div class="status">
      Status LED: <span id="led-status">OFF</span>
    </div>
  </div>

  <script>
    async function setLED(state) {
      await fetch('/api/led?state=' + state);
      document.getElementById('led-status').textContent =
        state == 1 ? 'ON' : 'OFF';
    }
    async function setBrightness(val) {
      document.getElementById('brightness-val').textContent = val;
      await fetch('/api/brightness?value=' + val);
    }
  </script>
</body>
</html>
)rawliteral";

void setup() {
  Serial.begin(115200);

  // Setup LED PWM
  ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RES);
  ledcAttachPin(LED_PIN, PWM_CHANNEL);
  ledcWrite(PWM_CHANNEL, 0);

  // Setup push button dengan internal pull-up
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  // Koneksi WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("\nIP: " + WiFi.localIP().toString());

  // Route: halaman utama
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/html", index_html);
  });

  // API: Kontrol ON/OFF LED
  server.on("/api/led", HTTP_GET, [](AsyncWebServerRequest *req) {
    if (req->hasParam("state")) {
      String state = req->getParam("state")->value();
      ledState = (state == "1");
      digitalWrite(LED_PIN, ledState ? HIGH : LOW);
      Serial.printf("LED: %s\n", ledState ? "ON" : "OFF");
      req->send(200, "text/plain", ledState ? "ON" : "OFF");
    } else {
      req->send(400, "text/plain", "Parameter 'state' diperlukan");
    }
  });

  // API: Atur kecerahan PWM
  server.on("/api/brightness", HTTP_GET, [](AsyncWebServerRequest *req) {
    if (req->hasParam("value")) {
      int val = req->getParam("value")->value().toInt();
      val = constrain(val, 0, 255);
      ledcWrite(PWM_CHANNEL, val);
      Serial.printf("Brightness: %d\n", val);
      req->send(200, "text/plain", String(val));
    } else {
      req->send(400, "text/plain", "Parameter 'value' diperlukan");
    }
  });

  server.begin();
}

void loop() {
  // Cek tombol fisik sebagai fallback
  if (digitalRead(BUTTON_PIN) == LOW) {
    ledState = !ledState;
    digitalWrite(LED_PIN, ledState ? HIGH : LOW);
    Serial.println("Tombol ditekan! LED: " + String(ledState ? "ON" : "OFF"));
    delay(300);  // debounce
    while (digitalRead(BUTTON_PIN) == LOW);  // tunggu lepas
  }
}
โ„น๏ธ Cara Kerja

Ketika pengguna menekan tombol ON/OFF atau menggeser slider di browser, JavaScript akan mengirim request GET /api/led?state=1 atau GET /api/brightness?value=200 ke ESP32. Server kemudian mengontrol GPIO sesuai parameter yang diterima. Ini adalah pola dasar komunikasi browser โ†” ESP32 yang akan kita kembangkan lebih lanjut.

6. JSON API Endpoint

Dalam arsitektur IoT modern, pertukaran data biasanya dilakukan dalam format JSON (JavaScript Object Notation). ESP32 bisa berfungsi sebagai REST API server yang menyediakan data sensor dalam format JSON, sehingga bisa dikonsumsi oleh dashboard web, aplikasi mobile, atau layanan cloud.

Membangun API Sensor Suhu & Kelembaban

C++ โ€” json_api.ino
// JSON API Endpoint - ESP32 Web Server
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include "DHT.h"

const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

AsyncWebServer server(80);
DHT dht(4, DHT11);

// Data sensor global
float currentTemp     = 0.0;
float currentHumidity = 0.0;
unsigned long lastRead = 0;

// Baca sensor setiap 2 detik
void readSensor() {
  if (millis() - lastRead >= 2000) {
    float t = dht.readTemperature();
    float h = dht.readHumidity();
    if (!isnan(t) && !isnan(h)) {
      currentTemp = t;
      currentHumidity = h;
    }
    lastRead = millis();
  }
}

void setup() {
  Serial.begin(115200);
  dht.begin();

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("\nIP: " + WiFi.localIP().toString());

  // === API ENDPOINTS ===

  // Endpoint: GET /api/sensor
  // Mengembalikan data sensor dalam JSON
  server.on("/api/sensor", HTTP_GET,
    [](AsyncWebServerRequest *req) {
      StaticJsonDocument<200> doc;
      doc["temperature"] = serialized(
        String(currentTemp, 1).c_str());
      doc["humidity"] = serialized(
        String(currentHumidity, 1).c_str());
      doc["unit_temp"] = "Celsius";
      doc["unit_humidity"] = "percent";
      doc["sensor"] = "DHT11";
      doc["uptime_sec"] = millis() / 1000;
      doc["free_heap"] = ESP.getFreeHeap();

      String response;
      serializeJson(doc, response);
      req->send(200, "application/json", response);
    }
  );

  // Endpoint: GET /api/device
  // Informasi perangkat
  server.on("/api/device", HTTP_GET,
    [](AsyncWebServerRequest *req) {
      StaticJsonDocument<300> doc;
      doc["chip"] = ESP.getChipModel();
      doc["cpu_freq_mhz"] = ESP.getCpuFreqMHz();
      doc["flash_size"] = ESP.getFlashChipSize() / 1048576;
      doc["free_heap"] = ESP.getFreeHeap();
      doc["wifi_rssi"] = WiFi.RSSI();
      doc["ip"] = WiFi.localIP().toString();
      doc["mac"] = WiFi.macAddress();

      String response;
      serializeJson(doc, response);
      req->send(200, "application/json", response);
    }
  );

  // Halaman dokumentasi API (simple)
  server.on("/", HTTP_GET,
    [](AsyncWebServerRequest *req) {
      String html = "<h1>ESP32 REST API</h1>";
      html += "<ul>";
      html += "<li><a href='/api/sensor'>/api/sensor</a> - Data sensor</li>";
      html += "<li><a href='/api/device'>/api/device</a> - Info perangkat</li>";
      html += "</ul>";
      req->send(200, "text/html", html);
    }
  );

  server.begin();
}

void loop() {
  readSensor();
}

Contoh Response JSON

Ketika browser atau aplikasi mengakses GET /api/sensor, ESP32 akan mengembalikan response seperti berikut:

JSON โ€” /api/sensor
{
  "temperature": "28.5",
  "humidity": "72.0",
  "unit_temp": "Celsius",
  "unit_humidity": "percent",
  "sensor": "DHT11",
  "uptime_sec": 3621,
  "free_heap": 245120
}
๐Ÿ’ก Menggunakan ArduinoJson

Library ArduinoJson memudahkan pembuatan dan parsing JSON di Arduino/ESP32. Gunakan StaticJsonDocument untuk ukuran tetap (hemat RAM) atau DynamicJsonDocument untuk ukuran dinamis. Ukuran document harus cukup besar untuk menampung seluruh JSON yang akan dibuat โ€” jika terlalu kecil, data akan terpotong.

7. WebSocket untuk Data Real-time

HTTP memiliki keterbatasan: client harus selalu mengirim request baru untuk mendapatkan data terbaru (polling). WebSocket menyelesaikan masalah ini dengan membuka koneksi dua-arah yang persisten antara browser dan ESP32. Setelah koneksi terbangun, ESP32 bisa mengirim data ke browser kapan saja tanpa diminta โ€” sempurna untuk dashboard real-time.

Perbandingan HTTP Polling vs WebSocket

Diagram: Perbandingan HTTP Polling vs WebSocket
HTTP Polling (Traditional):
  Browser โ”€โ”€GET /dataโ”€โ”€โ–บ ESP32
  Browser โ—„โ”€โ”€JSONโ”€โ”€โ”€โ”€โ”€โ”€ ESP32
  Browser โ”€โ”€GET /dataโ”€โ”€โ–บ ESP32  (ulang terus...)
  Browser โ—„โ”€โ”€JSONโ”€โ”€โ”€โ”€โ”€โ”€ ESP32

WebSocket (Real-time):
  Browser โ”€โ”€WS Connectโ”€โ”€โ–บ ESP32  (koneksi dibuka)
  Browser โ—„โ”€โ”€Data Pushโ”€โ”€ ESP32  (kapan saja)
  Browser โ—„โ”€โ”€Data Pushโ”€โ”€ ESP32  (kapan saja)
  Browser โ”€โ”€Control Cmdโ”€โ–บ ESP32  (dua arah!)
  Browser โ—„โ”€โ”€Data Pushโ”€โ”€ ESP32
  ... (koneksi tetap terbuka)
C++ โ€” websocket_realtime.ino
// WebSocket Real-time: Sensor Push ke Browser
// IoTHub - https://iothub.id

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");  // Endpoint WebSocket

// Simulasi sensor (ganti dengan sensor asli)
float sensorValue = 25.0;
unsigned long lastUpdate = 0;

// Callback saat menerima pesan WebSocket dari klien
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
               AwsEventType type, void *arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("[WS] Klien #%u terhubung dari %s\n",
                    client->id(), client->remoteIP().toString().c_str());
      break;

    case WS_EVT_DISCONNECT:
      Serial.printf("[WS] Klien #%u terputus\n", client->id());
      break;

    case WS_EVT_DATA: {
      AwsFrameInfo *info = (AwsFrameInfo *)arg;
      if (info->opcode == WS_TEXT) {
        // Parse pesan dari klien
        String msg = "";
        for (size_t i = 0; i < len; i++) {
          msg += (char)data[i];
        }
        Serial.printf("[WS] Pesan dari #%u: %s\n", client->id(), msg.c_str());

        // Contoh: klien minta data sekarang
        if (msg == "get_data") {
          StaticJsonDocument<128> doc;
          doc["type"] = "sensor_data";
          doc["value"] = serialized(String(sensorValue, 1).c_str());
          doc["timestamp"] = millis();

          String response;
          serializeJson(doc, response);
          client->text(response);
        }
      }
      break;
    }

    default:
      break;
  }
}

// Kirim data ke SEMUA klien WebSocket yang terhubung
void broadcastSensorData() {
  if (ws.count() == 0) return;  // Tidak ada klien, skip

  StaticJsonDocument<128> doc;
  doc["type"] = "sensor_data";
  doc["value"] = serialized(String(sensorValue, 1).c_str());
  doc["timestamp"] = millis();
  doc["clients"] = ws.count();

  String json;
  serializeJson(doc, json);
  ws.textAll(json.c_str());  // Kirim ke semua klien
}

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Real-time Sensor WebSocket</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: sans-serif;
      background: #0a0a14;
      color: #e0e0e0;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
    }
    .monitor {
      background: #141422;
      padding: 2rem;
      border-radius: 16px;
      width: 400px;
      border: 1px solid rgba(62, 207, 142, 0.2);
    }
    h1 { color: #3ecf8e; font-size: 1.3em; text-align: center; }
    .value-display {
      text-align: center;
      font-size: 4em;
      font-weight: bold;
      color: #3ecf8e;
      margin: 1rem 0;
      font-family: monospace;
    }
    .info { font-size: 0.85em; color: #888; text-align: center; }
    .dot {
      display: inline-block;
      width: 10px; height: 10px;
      border-radius: 50%;
      margin-right: 6px;
      background: #ef4444;
    }
    .dot.connected { background: #3ecf8e; }
    #chart {
      width: 100%;
      height: 120px;
      margin-top: 1rem;
      background: #0a0a14;
      border-radius: 8px;
      position: relative;
      overflow: hidden;
    }
  </style>
</head>
<body>
  <div class="monitor">
    <h1>๐Ÿ“ก Sensor Real-time</h1>
    <div class="value-display" id="sensorVal">--.-</div>
    <div class="info">
      <span class="dot" id="statusDot"></span>
      <span id="statusText">Terputus</span>
      <br><span id="updateTime"></span>
    </div>
    <canvas id="chart"></canvas>
  </div>

  <script>
    let ws;
    let chartData = [];
    const canvas = document.getElementById('chart');
    const ctx = canvas.getContext('2d');

    function connectWS() {
      ws = new WebSocket(
        'ws://' + location.host + '/ws'
      );

      ws.onopen = () => {
        document.getElementById('statusDot').classList.add('connected');
        document.getElementById('statusText').textContent = 'Terhubung';
        ws.send('get_data');
      };

      ws.onclose = () => {
        document.getElementById('statusDot').classList.remove('connected');
        document.getElementById('statusText').textContent = 'Terputus';
        setTimeout(connectWS, 2000);  // Reconnect
      };

      ws.onmessage = (evt) => {
        const data = JSON.parse(evt.data);
        if (data.type === 'sensor_data') {
          document.getElementById('sensorVal').textContent =
            data.value + 'ยฐC';
          document.getElementById('updateTime').textContent =
            'Update: ' + new Date().toLocaleTimeString();

          // Simpan untuk chart
          chartData.push(parseFloat(data.value));
          if (chartData.length > 100) chartData.shift();
          drawChart();
        }
      };
    }

    function drawChart() {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      if (chartData.length < 2) return;
      const min = Math.min(...chartData) - 2;
      const max = Math.max(...chartData) + 2;
      const range = max - min || 1;

      ctx.strokeStyle = '#3ecf8e';
      ctx.lineWidth = 2;
      ctx.beginPath();
      chartData.forEach((v, i) => {
        const x = (i / (chartData.length - 1)) * canvas.width;
        const y = canvas.height - ((v - min) / range) * canvas.height;
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.stroke();
    }

    connectWS();
  </script>
</body>
</html>
)rawliteral";

void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("\nIP: " + WiFi.localIP().toString());

  // Daftarkan WebSocket event handler
  ws.onEvent(onWsEvent);
  server.addHandler(&ws);

  // Route halaman utama
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/html", index_html);
  });

  // Cleanup WebSocket lama secara berkala
  server.on("/cleanup", HTTP_GET, [](AsyncWebServerRequest *req) {
    ws.cleanupClients();
    req->send(200, "text/plain", "Cleaned");
  });

  server.begin();
}

void loop() {
  // Simulasi perubahan sensor (ganti dengan pembacaan asli)
  if (millis() - lastUpdate > 1000) {
    sensorValue += random(-5, 6) / 10.0;
    sensorValue = constrain(sensorValue, 15.0, 45.0);
    broadcastSensorData();
    lastUpdate = millis();
  }

  // Cleanup koneksi WebSocket yang sudah tidak aktif
  ws.cleanupClients();
}
โ„น๏ธ Keunggulan WebSocket

Dengan WebSocket, ESP32 secara aktif mengirim data terbaru ke semua browser yang terhubung. Tidak perlu refresh halaman atau polling berulang. Ini menghemat bandwidth WiFi, mengurangi beban CPU ESP32, dan memberikan pengalaman real-time yang mulus bagi pengguna. WebSocket juga mendukung komunikasi dua arah โ€” browser bisa mengirim perintah kontrol ke ESP32 melalui koneksi yang sama.

8. Proyek: Dashboard Sensor Multi-page

Di bagian ini, kita akan menggabungkan semua konsep yang sudah dipelajari menjadi proyek lengkap: Dashboard Sensor Multi-halaman dengan beberapa halaman (home, sensor detail, kontrol), WebSocket real-time, dan JSON API. Ini adalah proyek production-ready yang bisa Anda kembangkan lebih lanjut.

Arsitektur Proyek

Diagram: Arsitektur Dashboard Multi-page
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                  ESP32 DevKit                    โ”‚
โ”‚                                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚ DHT22    โ”‚  โ”‚ LDR      โ”‚  โ”‚ Relay / LED  โ”‚  โ”‚
โ”‚  โ”‚ (GPIO 4) โ”‚  โ”‚ (GPIO 34)โ”‚  โ”‚ (GPIO 2)     โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚       โ”‚              โ”‚               โ”‚           โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚           ESP32 Core (FreeRTOS)            โ”‚  โ”‚
โ”‚  โ”‚                                            โ”‚  โ”‚
โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚     AsyncWebServer (Port 80)         โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚                                      โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  Routes:                             โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  GET  /          โ†’ Home Page         โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  GET  /sensor    โ†’ Sensor Detail     โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  GET  /control   โ†’ Control Panel     โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  WS   /ws        โ†’ Real-time Data    โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  GET  /api/data  โ†’ JSON All Data     โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ”‚  GET  /api/relay โ†’ Toggle Relay      โ”‚  โ”‚  โ”‚
โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

        โ”‚ WiFi โ”‚         โ”‚ WiFi โ”‚
        โ–ผ      โ”‚         โ–ผ      โ”‚
   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
   โ”‚ Browserโ”‚  โ”‚    โ”‚ Browserโ”‚  โ”‚
   โ”‚  PC    โ”‚  โ”‚    โ”‚ Ponsel โ”‚  โ”‚
   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
          WebSocket Connection
          (real-time updates)

Source Code Utama

C++ โ€” dashboard_multi_page.ino
// Dashboard Sensor Multi-page - ESP32 Web Server
// IoTHub - https://iothub.id
// Menggunakan AsyncWebServer + WebSocket + ArduinoJson

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include "DHT.h"

// === KONFIGURASI ===
const char* ssid     = "NamaWiFiAnda";
const char* password  = "PasswordWiFiAnda";

#define DHT_PIN       4
#define DHT_TYPE      DHT22
#define LDR_PIN       34
#define RELAY_PIN     2

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
DHT dht(DHT_PIN, DHT_TYPE);

// Data sensor global
struct SensorData {
  float temperature;
  float humidity;
  int   lightLevel;
  bool  relayState;
  unsigned long lastUpdate;
} sensor;

// === HTML PAGES ===

// --- HOME PAGE ---
const char home_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Dashboard IoT - Beranda</title>
  <style>
    * { margin:0; padding:0; box-sizing:border-box; }
    body { font-family:sans-serif; background:#0a0a14; color:#e0e0e0; }
    nav { background:#141422; padding:1rem 2rem; display:flex;
          justify-content:space-between; align-items:center;
          border-bottom:1px solid #222; }
    nav a { color:#3ecf8e; text-decoration:none; margin:0 1rem;
            font-weight:500; }
    nav a.active { border-bottom:2px solid #3ecf8e; padding-bottom:4px; }
    .content { max-width:900px; margin:2rem auto; padding:0 1rem; }
    h1 { color:#3ecf8e; margin-bottom:1rem; }
    .cards { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
    .card { background:#141422; padding:1.5rem; border-radius:12px;
            border:1px solid #222; }
    .card h3 { color:#888; font-size:0.85em; margin-bottom:0.5rem; }
    .card .value { font-size:2.5em; font-weight:bold;
                   font-family:monospace; color:#3ecf8e; }
    .card .unit { font-size:0.5em; color:#666; }
    .status-dot { width:8px; height:8px; border-radius:50%;
                  display:inline-block; background:#ef4444; }
    .status-dot.on { background:#3ecf8e; }
  </style>
</head>
<body>
  <nav>
    <div><strong style="color:#3ecf8e">โšก IoT Dashboard</strong></div>
    <div>
      <a href="/" class="active">Beranda</a>
      <a href="/sensor">Sensor</a>
      <a href="/control">Kontrol</a>
    </div>
  </nav>
  <div class="content">
    <h1>Selamat Datang di Dashboard IoT</h1>
    <div class="cards">
      <div class="card">
        <h3>๐ŸŒก๏ธ SUHU</h3>
        <div class="value" id="temp">--.-<span class="unit">ยฐC</span></div>
      </div>
      <div class="card">
        <h3>๐Ÿ’ง KELEMBABAN</h3>
        <div class="value" id="hum">--.-<span class="unit">%</span></div>
      </div>
      <div class="card">
        <h3>โ˜€๏ธ CAHAYA</h3>
        <div class="value" id="light">---<span class="unit"></span></div>
      </div>
      <div class="card">
        <h3>๐Ÿ”Œ RELAY</h3>
        <div class="value"><span class="status-dot" id="relayDot"></span>
        <span id="relayText">OFF</span></div>
      </div>
    </div>
    <p style="margin-top:1rem;color:#666;text-align:center">
      WebSocket: <span id="wsStatus">Menghubungkan...</span></p>
  </div>
  <script>
    function connectWS() {
      const ws = new WebSocket('ws://'+location.host+'/ws');
      ws.onopen = () => {
        document.getElementById('wsStatus').textContent = '๐ŸŸข Terhubung';
      };
      ws.onclose = () => {
        document.getElementById('wsStatus').textContent = '๐Ÿ”ด Terputus';
        setTimeout(connectWS, 2000);
      };
      ws.onmessage = (e) => {
        const d = JSON.parse(e.data);
        document.getElementById('temp').innerHTML =
          d.temperature + '<span class="unit">ยฐC</span>';
        document.getElementById('hum').innerHTML =
          d.humidity + '<span class="unit">%</span>';
        document.getElementById('light').innerHTML = d.light;
        const rd = document.getElementById('relayDot');
        d.relay ? rd.classList.add('on') : rd.classList.remove('on');
        document.getElementById('relayText').textContent =
          d.relay ? 'ON' : 'OFF';
      };
    }
    connectWS();
  </script>
</body>
</html>
)rawliteral";

// --- SENSOR DETAIL PAGE ---
const char sensor_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Detail Sensor - Dashboard IoT</title>
  <style>
    * { margin:0; padding:0; box-sizing:border-box; }
    body { font-family:sans-serif; background:#0a0a14; color:#e0e0e0; }
    nav { background:#141422; padding:1rem 2rem; display:flex;
          justify-content:space-between; align-items:center;
          border-bottom:1px solid #222; }
    nav a { color:#3ecf8e; text-decoration:none; margin:0 1rem;
            font-weight:500; }
    nav a.active { border-bottom:2px solid #3ecf8e; padding-bottom:4px; }
    .content { max-width:900px; margin:2rem auto; padding:0 1rem; }
    h1 { color:#3ecf8e; margin-bottom:1.5rem; }
    table { width:100%; border-collapse:collapse; }
    td, th { padding:0.75rem 1rem; text-align:left; border-bottom:1px solid #222; }
    th { color:#888; font-size:0.85em; }
    td { font-family:monospace; font-size:1.1em; }
    .val { color:#3ecf8e; font-weight:bold; }
  </style>
</head>
<body>
  <nav>
    <div><strong style="color:#3ecf8e">โšก IoT Dashboard</strong></div>
    <div>
      <a href="/">Beranda</a>
      <a href="/sensor" class="active">Sensor</a>
      <a href="/control">Kontrol</a>
    </div>
  </nav>
  <div class="content">
    <h1>๐Ÿ“Š Detail Sensor</h1>
    <table>
      <tr><th>Parameter</th><th>Nilai</th></tr>
      <tr><td>Suhu (DHT22)</td><td class="val" id="sTemp">--.- ยฐC</td></tr>
      <tr><td>Kelembaban (DHT22)</td><td class="val" id="sHum">--.- %</td></tr>
      <tr><td>Cahaya (LDR/ADC)</td><td class="val" id="sLight">---</td></tr>
      <tr><td>Relay</td><td class="val" id="sRelay">OFF</td></tr>
      <tr><td>Heap Bebas</td><td class="val" id="sHeap">--- bytes</td></tr>
      <tr><td>WiFi RSSI</td><td class="val" id="sRSSI">--- dBm</td></tr>
      <tr><td>Uptime</td><td class="val" id="sUptime">--:--:--</td></tr>
    </table>
  </div>
  <script>
    function connectWS() {
      const ws = new WebSocket('ws://'+location.host+'/ws');
      ws.onclose = () => setTimeout(connectWS, 2000);
      ws.onmessage = (e) => {
        const d = JSON.parse(e.data);
        document.getElementById('sTemp').textContent = d.temperature+' ยฐC';
        document.getElementById('sHum').textContent = d.humidity+' %';
        document.getElementById('sLight').textContent = d.light;
        document.getElementById('sRelay').textContent = d.relay?'ON':'OFF';
        document.getElementById('sHeap').textContent = d.freeHeap+' bytes';
        document.getElementById('sRSSI').textContent = d.rssi+' dBm';
        const s = Math.floor(d.uptime/1000);
        const h = Math.floor(s/3600);
        const m = Math.floor((s%3600)/60);
        const sec = s%60;
        document.getElementById('sUptime').textContent =
          String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+
          String(sec).padStart(2,'0');
      };
    }
    connectWS();
  </script>
</body>
</html>
)rawliteral";

// --- CONTROL PAGE ---
const char control_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Kontrol - Dashboard IoT</title>
  <style>
    * { margin:0; padding:0; box-sizing:border-box; }
    body { font-family:sans-serif; background:#0a0a14; color:#e0e0e0; }
    nav { background:#141422; padding:1rem 2rem; display:flex;
          justify-content:space-between; align-items:center;
          border-bottom:1px solid #222; }
    nav a { color:#3ecf8e; text-decoration:none; margin:0 1rem;
            font-weight:500; }
    nav a.active { border-bottom:2px solid #3ecf8e; padding-bottom:4px; }
    .content { max-width:500px; margin:2rem auto; padding:0 1rem; }
    h1 { color:#3ecf8e; margin-bottom:1.5rem; text-align:center; }
    .ctrl-card { background:#141422; padding:2rem; border-radius:12px;
                 border:1px solid #222; text-align:center; margin-bottom:1rem; }
    .toggle-btn {
      display:inline-block; padding:1rem 3rem; font-size:1.5em;
      font-weight:bold; border:none; border-radius:12px; cursor:pointer;
      transition:all 0.2s;
    }
    .toggle-btn.on { background:#3ecf8e; color:#111; }
    .toggle-btn.off { background:#ef4444; color:#fff; }
    .toggle-btn:hover { transform:scale(1.05); }
    .api-link { color:#666; font-size:0.85em; margin-top:1rem; display:block; }
    .api-link a { color:#3ecf8e; }
  </style>
</head>
<body>
  <nav>
    <div><strong style="color:#3ecf8e">โšก IoT Dashboard</strong></div>
    <div>
      <a href="/">Beranda</a>
      <a href="/sensor">Sensor</a>
      <a href="/control" class="active">Kontrol</a>
    </div>
  </nav>
  <div class="content">
    <h1>๐ŸŽฎ Panel Kontrol</h1>
    <div class="ctrl-card">
      <h3 style="margin-bottom:1rem">Relay / LED</h3>
      <button class="toggle-btn off" id="relayBtn"
              onclick="toggleRelay()">OFF</button>
      <span class="api-link">API: <a href="/api/relay?state=1">?state=1</a>
        / <a href="/api/relay?state=0">?state=0</a></span>
    </div>
  </div>
  <script>
    async function toggleRelay() {
      const btn = document.getElementById('relayBtn');
      const isOn = btn.classList.contains('on');
      const newState = isOn ? 0 : 1;
      await fetch('/api/relay?state=' + newState);
    }
  </script>
</body>
</html>
)rawliteral";

// === WEBSOCKET EVENT HANDLER ===
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
               AwsEventType type, void *arg, uint8_t *data, size_t len) {
  if (type == WS_EVT_CONNECT) {
    Serial.printf("[WS] Klien #%u terhubung\n", client->id());
  } else if (type == WS_EVT_DISCONNECT) {
    Serial.printf("[WS] Klien #%u terputus\n", client->id());
  }
}

// === KIRIM DATA KE SEMUA KLIENT ===
void broadcastData() {
  if (ws.count() == 0) return;

  StaticJsonDocument<256> doc;
  doc["temperature"] = serialized(
    String(sensor.temperature, 1).c_str());
  doc["humidity"] = serialized(
    String(sensor.humidity, 1).c_str());
  doc["light"] = sensor.lightLevel;
  doc["relay"] = sensor.relayState;
  doc["freeHeap"] = ESP.getFreeHeap();
  doc["rssi"] = WiFi.RSSI();
  doc["uptime"] = millis();

  String json;
  serializeJson(doc, json);
  ws.textAll(json.c_str());
}

void setup() {
  Serial.begin(115200);
  dht.begin();
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);

  // Inisialisasi data
  sensor.relayState = false;

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("\nIP: " + WiFi.localIP().toString());

  // WebSocket
  ws.onEvent(onWsEvent);
  server.addHandler(&ws);

  // === ROUTES ===
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/html", home_html);
  });
  server.on("/sensor", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/html", sensor_html);
  });
  server.on("/control", HTTP_GET, [](AsyncWebServerRequest *req) {
    req->send_P(200, "text/html", control_html);
  });

  // API: Semua data sensor
  server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *req) {
    StaticJsonDocument<256> doc;
    doc["temperature"] = sensor.temperature;
    doc["humidity"] = sensor.humidity;
    doc["light"] = sensor.lightLevel;
    doc["relay"] = sensor.relayState;
    doc["uptime"] = millis() / 1000;
    String json;
    serializeJson(doc, json);
    req->send(200, "application/json", json);
  });

  // API: Kontrol relay
  server.on("/api/relay", HTTP_GET, [](AsyncWebServerRequest *req) {
    if (req->hasParam("state")) {
      int st = req->getParam("state")->value().toInt();
      sensor.relayState = (st == 1);
      digitalWrite(RELAY_PIN, sensor.relayState ? HIGH : LOW);
      req->send(200, "application/json",
        "{\"relay\":" + String(sensor.relayState ? "true" : "false") + "}");
    } else {
      req->send(400, "application/json",
        "{\"error\":\"state parameter needed\"}");
    }
  });

  server.begin();
  Serial.println("Dashboard server dimulai!");
}

void loop() {
  // Baca sensor setiap 2 detik
  static unsigned long lastRead = 0;
  if (millis() - lastRead >= 2000) {
    float t = dht.readTemperature();
    float h = dht.readHumidity();
    if (!isnan(t) && !isnan(h)) {
      sensor.temperature = t;
      sensor.humidity = h;
    }
    sensor.lightLevel = analogRead(LDR_PIN);
    sensor.lastUpdate = millis();
    broadcastData();
    lastRead = millis();
  }
  ws.cleanupClients();
}
๐Ÿ’ก Pengembangan Lebih Lanjut

Proyek ini bisa dikembangkan dengan menambahkan: (1) data logging ke SPIFFS/LittleFS untuk menyimpan histori sensor, (2) Chart.js untuk visualisasi grafik historis, (3) autentikasi sederhana untuk keamanan halaman kontrol, (4) NTP time sync untuk timestamp akurat, dan (5) OTA update untuk update firmware tanpa kabel USB.

9. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang web server di ESP32:

Pertanyaan 1: Perbedaan utama ESP32 WebServer (bawaan) dan ESPAsyncWebServer adalah...

a) WebServer lebih cepat dari AsyncWebServer
b) AsyncWebServer tidak mendukung HTTP GET
c) WebServer bersifat synchronous (blokir), AsyncWebServer bersifat asynchronous (non-blokir)
d) Keduanya sama persis dalam cara kerjanya

Pertanyaan 2: Library apa yang HARUS diinstal sebagai dependency untuk menggunakan ESPAsyncWebServer?

a) AsyncTCP
b) ESP8266WiFi
c) Ethernet
d) HTTPClient

Pertanyaan 3: Protokol WebSocket berbeda dari HTTP karena...

a) WebSocket hanya bisa mengirim data satu arah (server ke client)
b) WebSocket menggunakan port 443 (HTTPS)
c) WebSocket membuka koneksi dua-arah yang persisten tanpa perlu poll ulang
d) WebSocket tidak bisa digunakan di ESP32

Pertanyaan 4: Untuk menyimpan file HTML statis di flash memory ESP32 agar bisa dilayani oleh web server, sistem file yang digunakan adalah...

a) FAT32
b) NTFS
c) ext4
d) SPIFFS / LittleFS

Pertanyaan 5: Dalam contoh proyek dashboard multi-page, untuk mengirim data real-time dari ESP32 ke semua browser yang terhubung, method yang digunakan adalah...

a) Kirim HTTP POST ke semua klien
b) Menulis ke file SPIFFS lalu klien membacanya
c) Broadcast data menggunakan ws.textAll() melalui WebSocket
d) Menggunakan serial monitor sebagai antarmuka
โ† Sebelumnya Telegram Bot untuk IoT Selanjutnya โ†’ Perbandingan Protokol IoT