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
- 1. Ba cách đưa trạng thái mới lên trang web
- 2. Chuẩn bị
- 3. Sơ đồ nối dây
- 4. Chương trình
- 5. Giải thích các phần chính
- Ba địa chỉ của máy chủ
- Số "phiên" để biết khi nào cần vẽ lại
- Vòng hỏi trong JavaScript
- Không cho trình duyệt lưu đệm
- Địa chỉ dễ nhớ bằng mDNS
- 6. Kết quả
- 7. Khi nào nên chuyển sang WebSocket
- 8. Bảo mật trong mạng nội bộ
- 9. Xử lý lỗi thường gặp
- 10. Xem thêm
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

- 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ị
- ESP32 DevKit V1 30 chân.
- Module relay 4 kênh 5 V kích mức thấp, một nút nhấn.
- Breadboard, dây cắm, dây USB nạp code.
- Hai điện thoại (hoặc điện thoại và máy tính) cùng vào một Wi-Fi với ESP32 để thấy cập nhật đồng bộ.
3. Sơ đồ nối dây

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ằngTRANG_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ả



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ượng | Nguyên nhân thường gặp | Cá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 4G | Và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.local | Má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ục | ESP32 khởi động lại (relay làm sụt áp), Wi-Fi yếu | Xem 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ó đổi | Module 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 font | Trình duyệt không nhận mã UTF-8 | Giữ 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 GHz | ESP32 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
- ESP32 làm web server điều khiển relay 4 kênh (bản tải lại trang)
- Nạp chương trình cho ESP32 qua Wi-Fi (OTA)
- Hộp mica cho ESP32 DevKit 30 chân
- Các bài cùng loạt: cấu hình Wi-Fi bằng điện thoại, điều khiển relay bằng ứng dụng Blynk IoT, đưa ESP32 vào Home Assistant.
Xem thêm