ESP32 Beginner Guide – P5: Wi-Fi và Web Server
Kết nối ESP32 với Wi-Fi, lấy địa chỉ IP và tạo web server nội bộ để xem nhiệt độ, độ ẩm, áp suất BME280 trên trình duyệt.
Chia sẻ

Trong P4: Kết nối cảm biến, mình và bạn đã đọc được nhiệt độ, độ ẩm và áp suất từ BME280. P5 sẽ giữ nguyên mạch đó, thêm Wi-Fi và biến ESP32 thành một web server nhỏ để mở dữ liệu bằng trình duyệt trong mạng nội bộ.
Mục tiêu của bài không phải “đưa ESP32 lên cloud”. Ta sẽ làm một đường dữ liệu ngắn, dễ nhìn thấy và dễ debug:
BME280 → ESP32 → Wi-Fi LAN → trình duyệt
Station, Access Point và Web Server là ba vai trò khác nhau

ESP32-S3 và C3 dùng Wi-Fi 2,4 GHz; hãy kiểm tra SSID, mật khẩu và mạng khách trước khi sửa code.
ESP32 có thể hoạt động ở hai chế độ Wi-Fi thường gặp:
| Chế độ | Thiết bị kết nối vào đâu? | Dùng khi nào? |
|---|---|---|
Station (WIFI_STA) | ESP32 kết nối vào router sẵn có | Dashboard trong nhà, gửi dữ liệu lên dịch vụ mạng |
Soft AP (WIFI_AP) | Điện thoại hoặc laptop kết nối trực tiếp vào mạng do ESP32 tạo | Cấu hình ban đầu, thiết bị hoạt động không có router |
Bài này dùng Station mode. Web server là chương trình lắng nghe HTTP trên port 80 sau khi ESP32 đã vào mạng; nó không phải một chế độ Wi-Fi riêng.
ESP32-S3 và ESP32-C3 trong series hỗ trợ Wi-Fi 2,4 GHz 802.11 b/g/n, không kết nối trực tiếp vào SSID chỉ phát 5 GHz. Router hai băng tần vẫn dùng được nếu nó phát 2,4 GHz và cấu hình bảo mật tương thích.
Trước khi sửa code, hãy kiểm tra:
- SSID và mật khẩu đúng cả chữ hoa, chữ thường.
- Mạng 2,4 GHz đang bật.
- Laptop và ESP32 ở cùng LAN; guest Wi-Fi có thể chặn các client nhìn thấy nhau.
- Antenna PCB của ESP32 không bị kim loại, dây nguồn hoặc breadboard che sát.
Không commit mật khẩu Wi-Fi
Tạo một tab hoặc file secrets.h cạnh sketch:
#pragma once
constexpr char WIFI_SSID[] = "YOUR_2_4_GHZ_SSID";
constexpr char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";
Trong project Git, thêm secrets.h vào .gitignore và commit một file secrets.example.h chỉ chứa placeholder. Xóa mật khẩu khỏi lịch sử sau khi đã push không đơn giản như xóa một dòng ở commit mới; nếu lỡ public, hãy đổi mật khẩu.
Kết nối có timeout và in địa chỉ IP

Địa chỉ IP do router cấp là điểm bắt đầu đáng tin cậy nhất để mở web server trong cùng mạng LAN.
Sketch tối thiểu sau kết nối ở chế độ station nhưng chỉ chờ tối đa 15 giây:
#include <WiFi.h>
#include "secrets.h"
constexpr uint32_t WIFI_TIMEOUT_MS = 15000;
bool connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
const uint32_t startedAt = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - startedAt < WIFI_TIMEOUT_MS) {
delay(250);
Serial.print('.');
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\nWi-Fi timeout; firmware keeps running.");
return false;
}
Serial.print("\nOpen http://");
Serial.println(WiFi.localIP());
return true;
}
void setup() {
Serial.begin(115200);
delay(500);
connectWiFi();
}
void loop() {}
Nếu kết nối thành công, router cấp một địa chỉ như 192.168.1.42. Điện thoại hoặc MacBook Pro trong cùng mạng có thể mở http://192.168.1.42. Địa chỉ này có thể đổi sau khi router hoặc ESP32 khởi động lại; về sau bạn có thể dùng DHCP reservation hoặc mDNS.
Timeout ở đây quan trọng hơn vẻ ngoài của đoạn code. Sai mật khẩu không được phép giữ firmware mãi trong một vòng while, vì cảm biến, nút bấm và phần còn lại của sản phẩm vẫn cần hoạt động.
Từ một trang HTML đến API JSON
Thư viện WebServer có sẵn trong Arduino-ESP32. Ta sẽ tạo hai route:
/trả về dashboard HTML./api/readingstrả về dữ liệu JSON mới nhất.
Trình duyệt tải HTML một lần, sau đó JavaScript gọi API mỗi hai giây. Tách giao diện và dữ liệu như vậy dễ mở rộng hơn việc dựng lại toàn bộ HTML bằng nhiều phép cộng String sau mỗi request.
Sketch hoàn chỉnh: BME280 và dashboard cục bộ
Giữ cách đấu P4: BME280 dùng 3V3, GND, SDA GPIO4, SCL GPIO5. Địa chỉ bên dưới là 0x77; đổi theo kết quả I2C scanner của chính module bạn.
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include "secrets.h"
constexpr uint8_t SDA_PIN = 4;
constexpr uint8_t SCL_PIN = 5;
constexpr uint8_t BME280_ADDRESS = 0x77;
constexpr uint32_t WIFI_TIMEOUT_MS = 15000;
constexpr uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
constexpr uint32_t SAMPLE_INTERVAL_MS = 2000;
WebServer server(80);
Adafruit_BME280 bme;
bool sensorReady = false;
bool wasConnected = false;
uint32_t lastSampleAt = 0;
uint32_t lastWiFiAttemptAt = 0;
float temperature = NAN;
float humidity = NAN;
float pressure = NAN;
const char PAGE[] PROGMEM = R"HTML(
<!doctype html>
<html lang="en">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 Sensor</title>
<style>
body{font:16px system-ui;margin:0;background:#f4f6f8;color:#17202a}
main{max-width:720px;margin:48px auto;padding:0 20px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.card{background:white;padding:20px;border-radius:14px;box-shadow:0 4px 18px #0001}
strong{display:block;font-size:1.7rem;margin-top:8px}
@media(max-width:600px){.grid{grid-template-columns:1fr}}
</style>
<main>
<h1>ESP32 Sensor</h1>
<p>Local readings from BME280</p>
<div class="grid">
<div class="card">Temperature<strong id="t">--</strong></div>
<div class="card">Humidity<strong id="h">--</strong></div>
<div class="card">Pressure<strong id="p">--</strong></div>
</div>
</main>
<script>
const t=document.getElementById('t');
const h=document.getElementById('h');
const p=document.getElementById('p');
async function refresh(){
try{
const response=await fetch('/api/readings',{cache:'no-store'});
if(!response.ok) throw new Error('sensor unavailable');
const data=await response.json();
t.textContent=data.temperature.toFixed(1)+' °C';
h.textContent=data.humidity.toFixed(1)+' %';
p.textContent=data.pressure.toFixed(1)+' hPa';
}catch(error){ t.textContent=h.textContent=p.textContent='offline'; }
}
refresh(); setInterval(refresh,2000);
</script>
</html>
)HTML";
bool connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
const uint32_t startedAt = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - startedAt < WIFI_TIMEOUT_MS) {
delay(250);
Serial.print('.');
}
return WiFi.status() == WL_CONNECTED;
}
void sampleSensor() {
if (!sensorReady) return;
temperature = bme.readTemperature();
humidity = bme.readHumidity();
pressure = bme.readPressure() / 100.0F;
}
void sendReadings() {
if (!sensorReady || isnan(temperature)) {
server.send(503, "application/json", "{\"error\":\"sensor unavailable\"}");
return;
}
char json[128];
snprintf(json, sizeof(json),
"{\"temperature\":%.2f,\"humidity\":%.2f,\"pressure\":%.2f}",
temperature, humidity, pressure);
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN);
sensorReady = bme.begin(BME280_ADDRESS, &Wire);
sampleSensor();
const bool connected = connectWiFi();
lastWiFiAttemptAt = millis();
wasConnected = connected;
if (connected) {
Serial.print("\nDashboard: http://");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nWi-Fi unavailable; waiting for auto-reconnect.");
}
server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", PAGE);
});
server.on("/api/readings", HTTP_GET, sendReadings);
server.onNotFound([]() {
server.send(404, "text/plain", "Not found");
});
server.begin();
}
void loop() {
server.handleClient();
const uint32_t now = millis();
if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) {
lastSampleAt = now;
sampleSensor();
}
const bool connected = WiFi.status() == WL_CONNECTED;
if (!connected && now - lastWiFiAttemptAt >= WIFI_RETRY_INTERVAL_MS) {
lastWiFiAttemptAt = now;
WiFi.reconnect();
}
if (connected && !wasConnected) {
Serial.print("Wi-Fi restored: http://");
Serial.println(WiFi.localIP());
} else if (!connected && wasConnected) {
Serial.println("Wi-Fi lost; sensor sampling continues.");
}
wasConnected = connected;
delay(2);
}
server.handleClient() phải được gọi thường xuyên. Một delay(10000), vòng lặp đợi mạng vô hạn hoặc phép tính nặng trong loop() sẽ khiến trang phản hồi chậm. Bài dùng timer với millis() để lấy mẫu mà không chặn web server.
Mở dashboard từ thiết bị khác

Dashboard tải từ ESP32 trong mạng nội bộ; dữ liệu không tự động được đưa lên Internet hay cloud.
- Mở Serial Monitor ở
115200baud. - Copy địa chỉ sau
Dashboard: http://. - Mở địa chỉ đó trên MacBook Pro hoặc điện thoại cùng mạng.
- Mở thêm
/api/readingsđể kiểm tra JSON trực tiếp.
Nếu MacBook Pro mở được nhưng điện thoại không mở được, firmware có thể vẫn đúng. Hãy tắt mobile data tạm thời, kiểm tra hai thiết bị có cùng subnet và xem router có bật AP/client isolation hay không.
Những lỗi Wi-Fi thường gặp
| Hiện tượng | Kiểm tra trước |
|---|---|
Wi-Fi timeout | SSID, mật khẩu, mạng 2,4 GHz và kiểu bảo mật |
| Có IP nhưng trình duyệt timeout | Cùng LAN, đúng http://, guest isolation hoặc firewall |
| Dashboard mở rồi mất sau reboot | DHCP đã cấp IP khác; đọc lại Serial hoặc đặt DHCP reservation |
| Web rất chậm | loop() bị block, gọi handleClient() quá thưa hoặc tín hiệu Wi-Fi yếu |
| Cảm biến vẫn chạy nhưng web mất | Đây là hành vi mong muốn; chờ auto-reconnect và theo dõi log |
| ESP32 reset khi bật Wi-Fi | Nguồn USB yếu, cáp kém hoặc sụt áp khi radio phát |
Đừng đưa web server này thẳng ra Internet
Server trong bài dùng HTTP, không có đăng nhập và phù hợp cho lab trong LAN tin cậy. Đừng mở port 80 trên router để public ESP32. Khi sản phẩm cần truy cập từ xa, bạn phải thiết kế authentication, TLS, cập nhật firmware, rate limiting và quy trình vá lỗi—không chỉ thêm port forwarding.
Checklist hoàn thành P5
- ESP32 kết nối vào mạng 2,4 GHz ở
WIFI_STA. - Firmware dừng chờ sau timeout thay vì kẹt vô hạn.
- Serial in địa chỉ IP nhưng không in mật khẩu.
/trả dashboard và/api/readingstrả JSON.server.handleClient()chạy thường xuyên.- Cảm biến vẫn được lấy mẫu khi Wi-Fi tạm mất.
- Web server chỉ được dùng trong LAN, chưa expose ra Internet.
P5 đã đưa dữ liệu ra khỏi Serial Monitor nhưng vẫn giữ nó trong nhà. Nếu bạn muốn điện thoại kết nối trực tiếp mà không qua router, hãy tiếp tục với P6: Bluetooth Low Energy.
Tham khảo
Chia sẻ
Tiếp tục khám phá
Đọc tiếp
Bài viết liên quan
ESP32 Beginner Guide – P7: Làm dự án ESP32 hoàn chỉnh
Khép lại series bằng Room Comfort Monitor: ESP32 đọc BME280, điều khiển LED bằng nút nhấn và hiển thị dashboard Wi-Fi nội bộ.
ESP32 Beginner Guide – P6: Bluetooth Low Energy
Làm quen ESP32 BLE, GATT service và characteristic qua project gửi dữ liệu BME280 tới điện thoại bằng Read và Notify.
ESP32 Beginner Guide – P4: Kết nối cảm biến
Kết nối cảm biến với ESP32 qua digital, analog và I2C; thực hành đọc nhiệt độ, độ ẩm, áp suất từ BME280 và xử lý lỗi không tìm thấy thiết bị.