Web điều khiển ESP32 cập nhật tức thời trên nhiều điện thoại: hỏi định kỳ bằng fetch hay WebSocket

Làm trang web trên ESP32 tự cập nhật trạng thái relay trên mọi điện thoại đang mở bằng fetch() và JSON, chỉ dùng WebServer có sẵn, so sánh với WebSocket.

Trang web điều khiển relay chạy ngay trên ESP32 là cách dùng điện thoại trong nhà không cần dịch vụ bên ngoài. Bản đơn giản trong bài ESP32 làm web server điều khiển relay có một nhược điểm: trang chỉ đúng tại lúc tải. Khi người khác bấm trên điện thoại của họ, hay khi có ai bấm nút tại chỗ, trang trên máy mình vẫn hiện trạng thái cũ cho tới khi tải lại. Bài này làm trang tự cập nhật trong vòng nửa giây trên mọi máy đang mở, chỉ dùng thư viện có sẵn trong gói esp32, và so sánh với cách dùng WebSocket.

1. Ba cách đưa trạng thái mới lên trang web

Ba sơ đồ trình tự: tải lại cả trang bằng GET, hỏi định kỳ bằng fetch mỗi 0,5 giây nhận JSON khoảng 60 byte, WebSocket giữ kết nối mở để ESP32 đẩy trạng thái mới
Ba cách cập nhật trạng thái; trục dọc là thời gian (hình minh họa).
  • Tải lại cả trang: mỗi lần xem phải tải lại toàn bộ HTML. Đơn giản nhưng không tự cập nhật.
  • Hỏi định kỳ (polling): trang tải một lần, sau đó JavaScript trong trang dùng fetch() hỏi ESP32 mỗi 0,5 giây, nhận lại gói JSON khoảng 60 byte và chỉ vẽ lại phần cần đổi. Chậm tối đa nửa giây, chỉ cần thư viện WebServer có sẵn. Đây là cách bài này dùng.
  • WebSocket: trình duyệt mở một kết nối giữ liên tục; ESP32 đẩy dữ liệu xuống ngay khi có thay đổi. Nhanh và ít gói tin hơn, nhưng gói esp32 không có sẵn máy chủ WebSocket, phải cài thêm thư viện (xem mục 7).

2. Chuẩn bị

3. Sơ đồ nối dây

Sơ đồ nối ESP32 DevKit V1 với module relay 4 kênh ở GPIO32, 33, 26, 27, VCC vào VIN, GND chung, nút nhấn ở GPIO4 xuống GND
Sơ đồ nối ESP32, relay 4 kênh và nút nhấn (hình minh họa).

IN1–IN4 nối GPIO32, 33, 26, 27; VCC vào VIN (5 V); GND chung; nút nhấn giữa GPIO4 và GND. Phía tiếp điểm relay đóng cắt điện 220 V: thử chương trình khi chưa nối tải, lắp tải thật thì ngắt điện và đặt mạch trong hộp kín. Cách hoạt động của module xem bài module relay 1, 2, 4 kênh.

4. Chương trình

Chỉ dùng WebServer và ESPmDNS có sẵn trong gói esp32 (bài dùng phiên bản 3.3.10). Điền Wi-Fi 2,4 GHz, chọn bo ESP32 Dev Module, nạp rồi mở Serial Monitor để xem địa chỉ IP.

// PYWORLD.VN - Trang web điều khiển relay 4 kênh trên ESP32, cập nhật tức thời trên mọi điện thoại đang mở
// Bo mạch: ESP32 DevKit V1, module relay kích mức thấp ở GPIO32, 33, 26, 27, nút nhấn ở GPIO4
// Trình duyệt hỏi trạng thái mỗi 0,5 giây bằng fetch(); chỉ dùng WebServer và ESPmDNS có sẵn trong gói esp32

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

const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const char *TEN_MDNS = "esp32-relay";  // Mở http://esp32-relay.local trên máy hỗ trợ mDNS

const uint8_t RELAY_PINS[4] = {32, 33, 26, 27};
const bool RELAY_ACTIVE_LOW = true;
const uint8_t NUT = 4;

WebServer server(80);
bool relayBat[4] = {false, false, false, false};
uint32_t phienBan = 0;  // Tăng mỗi khi trạng thái đổi; trang web chỉ vẽ lại khi số này đổi

const char TRANG_CHU[] PROGMEM = R"rawliteral(<!doctype html>
<html lang="vi"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Relay ESP32</title>
<style>
body{font-family:Arial,sans-serif;background:#eef2f7;margin:0;padding:16px;color:#1d2733}
h1{font-size:20px;margin:0 0 12px}
.k{display:flex;justify-content:space-between;align-items:center;background:#fff;border-radius:12px;padding:14px 16px;margin:10px 0}
button{width:96px;height:44px;border:0;border-radius:22px;font-size:16px;font-weight:bold;color:#fff;background:#9aa3b0}
button.bat{background:#2f9e44}
#tt{font-size:14px;color:#5b6675;margin-top:14px}
#loi{color:#e03131;font-size:14px}
</style></head><body>
<h1>Relay 4 kênh</h1>
<div id="ds"></div>
<div id="tt">Đang tải…</div><div id="loi"></div>
<script>
const ten = ["Đèn", "Quạt", "Máy bơm", "Ổ cắm"];
let phien = -1, dangGui = false;
const ds = document.getElementById("ds");
ten.forEach((t, i) => {
  ds.insertAdjacentHTML("beforeend",
    `<div class="k"><span>K${i + 1} · ${t}</span><button id="b${i}" onclick="doi(${i})">TẮT</button></div>`);
});
let trangThai = [0, 0, 0, 0];
async function doi(i) {
  dangGui = true;
  try {
    const r = await fetch(`/dat?k=${i + 1}&v=${trangThai[i] ? 0 : 1}`, {method: "POST"});
    ve(await r.json());
  } catch (e) {
    document.getElementById("loi").textContent = "Không gửi được lệnh";
  }
  dangGui = false;
}
function ve(d) {
  document.getElementById("loi").textContent = "";
  if (d.phien === phien) return;
  phien = d.phien;
  trangThai = d.relay;
  d.relay.forEach((v, i) => {
    const b = document.getElementById("b" + i);
    b.textContent = v ? "BẬT" : "TẮT";
    b.className = v ? "bat" : "";
  });
  document.getElementById("tt").textContent =
    `Wi-Fi ${d.rssi} dBm · đã chạy ${d.giay} s · phiên ${d.phien}`;
}
async function hoi() {
  if (!dangGui) {
    try {
      const r = await fetch("/trang-thai", {cache: "no-store"});
      ve(await r.json());
    } catch (e) {
      document.getElementById("loi").textContent = "Mất kết nối với ESP32";
    }
  }
  setTimeout(hoi, 500);
}
hoi();
</script></body></html>)rawliteral";

void datRelay(uint8_t kenh, bool bat) {
  if (relayBat[kenh] == bat) return;
  relayBat[kenh] = bat;
  bool muc = RELAY_ACTIVE_LOW ? !bat : bat;
  digitalWrite(RELAY_PINS[kenh], muc ? HIGH : LOW);
  phienBan++;
  Serial.printf("Relay K%u: %s (phien %u)\n", (unsigned)(kenh + 1), bat ? "BAT" : "TAT", (unsigned)phienBan);
}

void guiTrangThai() {
  char json[160];
  snprintf(json, sizeof(json), "{\"relay\":[%d,%d,%d,%d],\"rssi\":%d,\"giay\":%lu,\"phien\":%lu}",
           relayBat[0], relayBat[1], relayBat[2], relayBat[3], (int)WiFi.RSSI(),
           (unsigned long)(millis() / 1000), (unsigned long)phienBan);
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", json);
}

void xuLyTrangChu() {
  server.send_P(200, "text/html; charset=utf-8", TRANG_CHU);
}

// POST /dat?k=1..4&v=0|1
void xuLyDat() {
  int k = server.arg("k").toInt();
  String v = server.arg("v");
  if (k < 1 || k > 4 || (v != "0" && v != "1")) {
    server.send(400, "text/plain", "Sai tham so");
    return;
  }
  datRelay(k - 1, v == "1");
  guiTrangThai();
}

void kiemTraNut() {
  static bool docTruoc = HIGH, onDinh = HIGH;
  static unsigned long lucDoi = 0;
  bool doc = digitalRead(NUT);
  if (doc != docTruoc) {
    docTruoc = doc;
    lucDoi = millis();
  }
  if (millis() - lucDoi > 30 && doc != onDinh) {
    onDinh = doc;
    if (onDinh == LOW) datRelay(0, !relayBat[0]);  // Nút tại chỗ đảo K1, mọi trang web đang mở tự cập nhật
  }
}

void setup() {
  Serial.begin(115200);
  for (uint8_t i = 0; i < 4; i++) {
    pinMode(RELAY_PINS[i], INPUT);
    digitalWrite(RELAY_PINS[i], RELAY_ACTIVE_LOW ? HIGH : LOW);
    pinMode(RELAY_PINS[i], OUTPUT);
  }
  pinMode(NUT, INPUT_PULLUP);

  WiFi.mode(WIFI_STA);
  WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
  Serial.print("Dang ket noi Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nMo trinh duyet: http://%s\n", WiFi.localIP().toString().c_str());
  if (MDNS.begin(TEN_MDNS)) {
    MDNS.addService("http", "tcp", 80);
    Serial.printf("Hoac: http://%s.local\n", TEN_MDNS);
  }

  server.on("/", HTTP_GET, xuLyTrangChu);
  server.on("/trang-thai", HTTP_GET, guiTrangThai);
  server.on("/dat", HTTP_POST, xuLyDat);
  server.onNotFound([]() { server.send(404, "text/plain", "Khong co trang nay"); });
  server.begin();
}

void loop() {
  server.handleClient();
  kiemTraNut();
}

5. Giải thích các phần chính

Ba địa chỉ của máy chủ

  • GET /: trả trang HTML (giao diện, JavaScript) lưu trong hằng TRANG_CHU. Trang được viết thẳng trong chương trình bằng chuỗi thô R"rawliteral( … )rawliteral" nên không phải thêm dấu \ trước từng dấu ngoặc kép.
  • GET /trang-thai: trả gói JSON gồm trạng thái 4 relay, cường độ Wi-Fi, số giây đã chạy và số phien.
  • POST /dat?k=2&v=1: đặt relay K2 bật, trả về JSON mới. Lệnh thay đổi dùng POST chứ không dùng GET, vì trình duyệt có thể tự tải trước hay lưu địa chỉ GET vào lịch sử, làm relay đổi ngoài ý muốn.

Số "phiên" để biết khi nào cần vẽ lại

Mỗi lần có relay đổi (từ trang web hay từ nút nhấn), phienBan tăng thêm 1. JavaScript nhớ số phiên lần trước; nếu gói JSON mới có cùng số phiên thì bỏ qua, không vẽ lại.

Vòng hỏi trong JavaScript

Hàm hoi() gọi fetch("/trang-thai"), vẽ kết quả, rồi mới hẹn lần hỏi sau bằng setTimeout(hoi, 500). Hẹn sau khi đã nhận trả lời (thay vì dùng setInterval) giúp các lần hỏi không dồn đống khi Wi-Fi chậm. Trong lúc đang gửi lệnh bấm nút, vòng hỏi tạm nghỉ để không vẽ đè trạng thái cũ. Mất kết nối thì trang hiện dòng chữ đỏ "Mất kết nối với ESP32" và tự hỏi lại.

Không cho trình duyệt lưu đệm

ESP32 gửi kèm Cache-Control: no-store và JavaScript gọi fetch với cache: "no-store", để trình duyệt luôn hỏi ESP32 thay vì dùng kết quả cũ.

Địa chỉ dễ nhớ bằng mDNS

MDNS.begin("esp32-relay") cho phép mở trang bằng http://esp32-relay.local trên máy tính Windows, macOS và iPhone. Nhiều điện thoại Android không phân giải được tên .local; khi đó dùng địa chỉ IP in trên Serial Monitor, hoặc đặt IP tĩnh như bài kết nối Wi-Fi trên ESP32.

6. Kết quả

Hai điện thoại cùng mở trang điều khiển relay: điện thoại A bấm K2 gửi POST tới ESP32, điện thoại B nhận JSON phiên 8 và tự đổi K2 sang bật
Bấm trên một máy, máy kia cập nhật theo trong vòng 0,5 giây (hình minh họa).
Yêu cầu GET /trang-thai trả JSON relay 1,0,0,0, rssi, giay, phien 7; yêu cầu POST /dat k=2 v=1 trả JSON relay 1,1,0,0 phien 8; bảng ý nghĩa các trường
Yêu cầu và gói JSON trao đổi giữa trang web và ESP32 (số liệu ví dụ).
Serial Monitor: địa chỉ IP và tên esp32-relay.local, các dòng Relay K1 BAT phien 1, Relay K3 BAT phien 2, Relay K1 TAT phien 3
Serial Monitor ở tốc độ 115200 (số liệu ví dụ).

Mở trang trên hai máy, bấm K2 trên máy thứ nhất: máy thứ hai đổi theo sau tối đa nửa giây. Bấm nút nhấn trên mạch: K1 đổi trên cả hai máy. Tắt Wi-Fi của một máy rồi bật lại: trang tự hiện lại trạng thái đúng mà không cần tải lại.

7. Khi nào nên chuyển sang WebSocket

Hỏi định kỳ đủ dùng cho bảng điều khiển vài relay và vài người xem. Với 5 máy cùng mở, ESP32 trả khoảng 10 yêu cầu nhỏ mỗi giây, vẫn nhẹ. Nên chuyển sang WebSocket khi:

  • cần cập nhật nhanh hơn nhiều lần mỗi giây, ví dụ đồ thị cảm biến chạy liên tục hay tay điều khiển xe;
  • có nhiều người xem cùng lúc, muốn giảm số yêu cầu;
  • cần ESP32 báo ngay lập tức, không chấp nhận trễ nửa giây.

Gói esp32 có sẵn WebServer nhưng không có máy chủ WebSocket. Có thể cài thư viện WebSockets (tác giả Markus Sattler) bằng Library Manager và chạy song song với WebServer ở cổng khác (thường là 81). Phần giao diện chỉ đổi chỗ fetch thành new WebSocket("ws://" + location.hostname + ":81/") và xử lý sự kiện onmessage; phần JSON giữ nguyên như bài này.

8. Bảo mật trong mạng nội bộ

Ai vào được Wi-Fi nhà cũng mở được trang và bật tắt relay. Muốn hỏi mật khẩu, thêm vào đầu mỗi hàm xử lý:

if (!server.authenticate("admin", "MAT_KHAU_TRANG")) {
  return server.requestAuthentication();
}

Cách này là đăng nhập cơ bản của HTTP, mật khẩu đi trong mạng nội bộ không mã hóa: đủ để tránh người nhà bấm nhầm, không đủ để đưa ra Internet. Muốn điều khiển từ xa an toàn, dùng dịch vụ có mã hóa như bài Blynk IoT hay Telegram cùng loạt.

9. Xử lý lỗi thường gặp

Hiện tượngNguyên nhân thường gặpCách xử lý
Không mở được trangĐiện thoại không cùng Wi-Fi với ESP32, hoặc đang dùng 4GVào cùng mạng Wi-Fi; gõ đúng địa chỉ http:// (không phải https://)
Mở được bằng IP nhưng không mở được esp32-relay.localMáy không hỗ trợ mDNS (nhiều điện thoại Android)Dùng IP, hoặc đặt IP tĩnh
Trang hiện "Mất kết nối với ESP32" liên tụcESP32 khởi động lại (relay làm sụt áp), Wi-Fi yếuXem Serial Monitor; bài relay làm ESP32 bị reset
Bấm nút trên trang, relay không đổi nhưng chữ BẬT/TẮT có đổiModule kích mức cao hoặc dây IN cắm sai chânĐổi RELAY_ACTIVE_LOW, dò lại dây
Chữ tiếng Việt trên trang bị lỗi fontTrình duyệt không nhận mã UTF-8Giữ nguyên text/html; charset=utf-8 và thẻ meta charset như chương trình
ESP32 đứng ở "Dang ket noi Wi-Fi....."Sai mật khẩu hoặc modem chỉ phát 5 GHzESP32 chỉ dùng Wi-Fi 2,4 GHz; nếu không muốn sửa code mỗi lần đổi Wi-Fi, xem bài cấu hình Wi-Fi bằng điện thoại cùng loạt

10. Xem thêm


Tin tức liên quan

Hướng dẫn lưu dữ liệu vào bộ nhớ flash của ESP32 bằng thư viện Preferences
Hướng dẫn lưu dữ liệu vào bộ nhớ flash của ESP32 bằng thư viện Preferences

Hướng dẫn dùng thư viện Preferences trên ESP32 với Arduino IDE để lưu số đếm, trạng thái relay và thông số cài đặt vào bộ nhớ flash, không mất khi tắt nguồn.

Điều khiển relay từ xa bằng ESP-NOW: remote ESP32 một nút nhấn có phản hồi trạng thái
Điều khiển relay từ xa bằng ESP-NOW: remote ESP32 một nút nhấn có phản hồi trạng thái

Hướng dẫn làm bộ điều khiển relay từ xa bằng hai mạch ESP32 và ESP-NOW hai chiều: remote gửi lệnh, mạch relay báo lại trạng thái, lọc theo địa chỉ MAC và bật mã hóa.

ESP32 điều khiển động cơ không chổi than qua ESC: xung 1000–2000 µs, hiệu chuẩn dải ga, thử an toàn
ESP32 điều khiển động cơ không chổi than qua ESC: xung 1000–2000 µs, hiệu chuẩn dải ga, thử an toàn

Dùng ESP32 phát xung ga 1000–2000 µs bằng LEDC để điều khiển ESC và động cơ không chổi than: nối dây với pin LiPo, hiệu chuẩn dải ga, tăng ga từ từ, khóa động cơ và an toàn khi thử.

Chọn pin, driver và mạch nguồn cho xe robot ESP32 dùng 2 motor 140 mini
Chọn pin, driver và mạch nguồn cho xe robot ESP32 dùng 2 motor 140 mini

Xe robot ESP32 với 2 motor 140 mini nên dùng pin 2S 7,4 V, một driver đôi TB6612FNG và mạch hạ áp 5 V: ước tính dòng, so sánh pin, driver, nối dây, chống reset và code mẫu.

Hướng dẫn dùng module thẻ nhớ Micro SD với ESP32
Hướng dẫn dùng module thẻ nhớ Micro SD với ESP32

Hướng dẫn nối module thẻ nhớ Micro SD với ESP32 DevKit V1 qua SPI, định dạng thẻ FAT32, chạy code Arduino đọc ghi file, ghi dữ liệu định kỳ ra CSV và xử lý lỗi Card Mount Failed.

Hướng dẫn bắt đầu MicroPython trên ESP32 Lite với Thonny
Hướng dẫn bắt đầu MicroPython trên ESP32 Lite với Thonny

Hướng dẫn cài driver, cài Thonny và nạp firmware MicroPython cho mạch ESP32 Lite, dùng REPL, nháy LED, lưu main.py, đọc nút nhấn, quét I2C và xử lý lỗi thường gặp.

ESP32 gửi cảnh báo Telegram, nhận lệnh bật tắt relay: tạo bot, lấy chat id, không cần thư viện bot
ESP32 gửi cảnh báo Telegram, nhận lệnh bật tắt relay: tạo bot, lấy chat id, không cần thư viện bot

Tạo bot Telegram, lấy token và chat id, cho ESP32 gửi cảnh báo cửa mở, báo có điện lại và nhận lệnh /bat, /tat relay bằng HTTPClient và ArduinoJson.

ESP32 điều khiển động cơ bước 28BYJ-48 với ULN2003: nửa bước, quay đúng góc, kim giây
ESP32 điều khiển động cơ bước 28BYJ-48 với ULN2003: nửa bước, quay đúng góc, kim giây

Hướng dẫn điều khiển động cơ bước 28BYJ-48 bằng ESP32 qua mạch ULN2003: chế độ nửa bước, 4076 bước một vòng, quay đúng góc theo lệnh, kim giây đồng hồ và thí nghiệm STEM.


Bình luận
Đã thêm vào giỏ hàng