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ộ.
Chia sẻ

Sáu phần trước đã đi qua chọn board, nạp code, GPIO, cảm biến, Wi-Fi và BLE. Ở phần cuối này, mình và bạn sẽ ngừng thử từng khối riêng lẻ để làm một thiết bị có yêu cầu, trạng thái lỗi và tiêu chí hoàn thành rõ ràng: Room Comfort Monitor.
Thiết bị đọc BME280 mỗi hai giây, bật LED khi phòng quá nóng hoặc quá ẩm, cho phép tắt cảnh báo bằng nút nhấn và hiển thị mọi trạng thái trên dashboard nội bộ.
BME280 + nút nhấn → ESP32 → LED + dashboard Wi-Fi
BLE từ P6 không bị bỏ quên. Với sản phẩm thật, nó phù hợp để cấu hình Wi-Fi lần đầu hoặc đọc nhanh khi không có router. Nhưng sketch đầu tiên chỉ dùng Wi-Fi: một project hoàn chỉnh không có nghĩa là bật mọi tính năng cùng lúc.
Bắt đầu bằng yêu cầu, không bắt đầu bằng code
Project được xem là hoàn thành khi:
- Nhiệt độ, độ ẩm và áp suất cập nhật mỗi hai giây.
- LED sáng khi nhiệt độ từ
30 °Choặc độ ẩm từ70%trở lên. - Nút nhấn bật/tắt chức năng cảnh báo và không bị nhảy nhiều lần vì contact bounce.
- Dashboard hiển thị reading, trạng thái phòng và trạng thái cảnh báo.
- Web có endpoint JSON để có thể tích hợp về sau.
- Mất Wi-Fi không làm dừng việc đọc cảm biến hoặc nút nhấn.
- Firmware tự thử reconnect mà không kẹt trong vòng chờ vô hạn.
Giới hạn cũng quan trọng: đây là thiết bị quan sát tiện nghi trong phòng, không phải thiết bị an toàn. Nó không thay thế báo cháy, báo khí độc hay thiết bị y tế.
Linh kiện và pin mapping

Chốt linh kiện và pin mapping trước khi cắm dây giúp tránh sửa cả phần cứng lẫn firmware cùng lúc.
| Linh kiện | ESP32-S3 DevKitC-1 | Ghi chú |
|---|---|---|
| BME280 VCC | 3V3 | Không mặc định dùng 5 V nếu chưa kiểm tra breakout |
| BME280 GND | GND | Tất cả module dùng chung ground |
| BME280 SDA | GPIO4 | I2C data |
| BME280 SCL | GPIO5 | I2C clock |
| LED anode | GPIO2 qua điện trở 220 Ω | Cathode về GND |
| Nút nhấn | GPIO6 và GND | Dùng INPUT_PULLUP, nhấn là LOW |
GPIO2 và GPIO6 đều được đưa ra header trên ESP32-S3-DevKitC-1 v1.1. Nếu bạn dùng board khác, hãy tra pinout của đúng board rồi đổi các hằng số; đừng cắm theo vị trí vật lý giống ảnh.
Bạn vẫn cần secrets.h như P5:
#pragma once
constexpr char WIFI_SSID[] = "YOUR_2_4_GHZ_SSID";
constexpr char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";
Giữ file thật ngoài Git và chỉ commit secrets.example.h với placeholder.
Chia firmware thành các trách nhiệm nhỏ
Một vòng loop() dễ đọc nên chỉ điều phối:
handle HTTP → đọc nút → lấy mẫu cảm biến → cập nhật LED → phục hồi Wi-Fi
Mỗi phần dùng timer riêng bằng millis(). Không phần nào được giữ CPU trong while vô hạn. HTTP handler chỉ thay trạng thái hoặc trả dữ liệu; nó không đọc I2C và không chờ mạng.
Sketch hoàn chỉnh
Code dưới đây giữ giao diện HTML trong flash, tránh nối nhiều String cho mỗi request và dùng JSON làm ranh giới giữa firmware với dashboard.
#include <Arduino.h>
#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 LED_PIN = 2;
constexpr uint8_t BUTTON_PIN = 6;
constexpr uint8_t BME280_ADDRESS = 0x77;
constexpr float TEMP_WARNING_C = 30.0F;
constexpr float HUMIDITY_WARNING_PERCENT = 70.0F;
constexpr uint32_t SAMPLE_INTERVAL_MS = 2000;
constexpr uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
constexpr uint32_t BUTTON_DEBOUNCE_MS = 40;
WebServer server(80);
Adafruit_BME280 bme;
bool sensorReady = false;
bool readingValid = false;
bool alarmEnabled = true;
bool warningActive = false;
bool wasWiFiConnected = false;
float temperature = NAN;
float humidity = NAN;
float pressure = NAN;
int lastButtonReading = HIGH;
int stableButtonState = HIGH;
uint32_t buttonChangedAt = 0;
uint32_t lastSampleAt = 0;
uint32_t lastWiFiAttemptAt = 0;
const char PAGE[] PROGMEM = R"HTML(
<!doctype html>
<html lang="en">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Room Comfort Monitor</title>
<style>
body{font:16px system-ui;margin:0;background:#eef2f5;color:#17202a}
main{max-width:760px;margin:40px auto;padding:0 18px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.card{background:#fff;padding:18px;border-radius:14px;box-shadow:0 4px 18px #0001}
strong{display:block;font-size:1.6rem;margin-top:8px}
#state{padding:14px;border-radius:12px;background:#dff5e6;margin:16px 0}
button{border:0;border-radius:10px;padding:12px 16px;background:#17202a;color:#fff}
@media(max-width:620px){.grid{grid-template-columns:1fr}}
</style>
<main>
<h1>Room Comfort Monitor</h1>
<div id="state">Loading...</div>
<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>
<p>Alarm: <b id="alarm">--</b></p>
<button onclick="toggleAlarm()">Toggle alarm</button>
</main>
<script>
async function refresh(){
try{
const r=await fetch('/api/status',{cache:'no-store'});
const d=await r.json();
document.getElementById('t').textContent=d.valid?d.temperature.toFixed(1)+' °C':'--';
document.getElementById('h').textContent=d.valid?d.humidity.toFixed(1)+' %':'--';
document.getElementById('p').textContent=d.valid?d.pressure.toFixed(1)+' hPa':'--';
document.getElementById('alarm').textContent=d.alarmEnabled?'enabled':'muted';
const state=document.getElementById('state');
state.textContent=d.status;
state.style.background=d.warning?'#ffd9d5':'#dff5e6';
}catch(e){ document.getElementById('state').textContent='ESP32 offline'; }
}
async function toggleAlarm(){
await fetch('/api/alarm/toggle',{method:'POST'});
refresh();
}
refresh(); setInterval(refresh,2000);
</script>
</html>
)HTML";
void updateOutput() {
warningActive = readingValid &&
(temperature >= TEMP_WARNING_C ||
humidity >= HUMIDITY_WARNING_PERCENT);
digitalWrite(LED_PIN, alarmEnabled && warningActive ? HIGH : LOW);
}
void sampleSensor() {
if (!sensorReady) {
readingValid = false;
updateOutput();
return;
}
const float nextTemperature = bme.readTemperature();
const float nextHumidity = bme.readHumidity();
const float nextPressure = bme.readPressure() / 100.0F;
readingValid = !isnan(nextTemperature) &&
!isnan(nextHumidity) &&
!isnan(nextPressure);
if (readingValid) {
temperature = nextTemperature;
humidity = nextHumidity;
pressure = nextPressure;
}
updateOutput();
}
void updateButton(uint32_t now) {
const int reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonReading) {
lastButtonReading = reading;
buttonChangedAt = now;
}
if (now - buttonChangedAt >= BUTTON_DEBOUNCE_MS &&
reading != stableButtonState) {
stableButtonState = reading;
if (stableButtonState == LOW) {
alarmEnabled = !alarmEnabled;
updateOutput();
Serial.printf("Alarm %s\n", alarmEnabled ? "enabled" : "muted");
}
}
}
void sendStatus() {
char readings[128];
if (readingValid) {
snprintf(readings, sizeof(readings),
"\"temperature\":%.2f,\"humidity\":%.2f,\"pressure\":%.2f",
temperature, humidity, pressure);
} else {
snprintf(readings, sizeof(readings),
"\"temperature\":null,\"humidity\":null,\"pressure\":null");
}
const char *status = !readingValid ? "Sensor unavailable" :
warningActive ? "Check room conditions" :
"Room looks comfortable";
char json[320];
snprintf(json, sizeof(json),
"{%s,\"valid\":%s,\"warning\":%s,"
"\"alarmEnabled\":%s,\"status\":\"%s\"}",
readings,
readingValid ? "true" : "false",
warningActive ? "true" : "false",
alarmEnabled ? "true" : "false",
status);
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json", json);
}
void configureServer() {
server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", PAGE);
});
server.on("/api/status", HTTP_GET, sendStatus);
server.on("/api/alarm/toggle", HTTP_POST, []() {
alarmEnabled = !alarmEnabled;
updateOutput();
sendStatus();
});
server.onNotFound([]() {
server.send(404, "text/plain", "Not found");
});
server.begin();
}
void startWiFi() {
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
lastWiFiAttemptAt = millis();
}
void updateWiFi(uint32_t now) {
const bool connected = WiFi.status() == WL_CONNECTED;
if (!connected &&
now - lastWiFiAttemptAt >= WIFI_RETRY_INTERVAL_MS) {
lastWiFiAttemptAt = now;
WiFi.reconnect();
}
if (connected && !wasWiFiConnected) {
Serial.print("Dashboard: http://");
Serial.println(WiFi.localIP());
} else if (!connected && wasWiFiConnected) {
Serial.println("Wi-Fi lost; local monitoring continues.");
}
wasWiFiConnected = connected;
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
digitalWrite(LED_PIN, LOW);
lastButtonReading = digitalRead(BUTTON_PIN);
stableButtonState = lastButtonReading;
Wire.begin(SDA_PIN, SCL_PIN);
sensorReady = bme.begin(BME280_ADDRESS, &Wire);
sampleSensor();
configureServer();
startWiFi();
Serial.println(sensorReady ? "BME280 ready." : "BME280 unavailable.");
}
void loop() {
server.handleClient();
const uint32_t now = millis();
updateButton(now);
if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) {
lastSampleAt = now;
sampleSensor();
}
updateWiFi(now);
delay(2);
}
Nếu scanner của bạn tìm thấy BME280 ở 0x76, chỉ đổi BME280_ADDRESS. Nếu LED sáng ngược vì bạn dùng module LED active-low, hãy đảo hai giá trị HIGH và LOW trong updateOutput().
Bring-up từng lớp, đừng nạp tất cả rồi đoán lỗi

Nút nhấn bật hoặc tắt cảnh báo tại chỗ; dashboard và phần đọc cảm biến vẫn tiếp tục hoạt động.
Mình kiểm tra theo thứ tự này:
- Nguồn: board khởi động ổn, không reset ngẫu nhiên.
- I2C: sketch scanner thấy
0x76hoặc0x77. - Sensor: Serial có ba reading hợp lý.
- Output: sketch Blink riêng làm LED ngoài sáng/tắt.
- Input: Serial chỉ ghi một lần cho mỗi lần nhấn nút.
- Network: ESP32 nhận IP và dashboard mở được.
- Tích hợp: nút và web cùng đổi
alarmEnabled; mất Wi-Fi không làm LED đứng sai.
Cách này nghe chậm nhưng thường nhanh hơn debug một sketch 200 dòng trong khi chưa biết dây SDA có cắm nhầm hay không.
Test như một thiết bị, không chỉ nhìn thấy trang web

Bài test cuối kiểm tra cùng lúc dữ liệu cảm biến, trạng thái cảnh báo, nút điều khiển và khả năng phục hồi Wi-Fi.
| Test | Thao tác | Kết quả mong đợi |
|---|---|---|
| Khởi động bình thường | Cấp nguồn với router hoạt động | Có reading, IP và dashboard |
| Không có BME280 | Rút nguồn, tháo sensor rồi cấp lại | Dashboard báo sensor unavailable, LED tắt |
| Mất Wi-Fi | Tắt router tạm thời | Sensor và nút vẫn chạy; reconnect sau khi mạng trở lại |
| Cảnh báo nhiệt | Làm ấm sensor nhẹ bằng tay, không dùng lửa | Status đổi khi vượt ngưỡng; LED sáng nếu alarm enabled |
| Nút bị bounce | Nhấn một lần bình thường | Trạng thái chỉ đổi một lần |
| Điều khiển từ web | Nhấn Toggle alarm | LED và JSON phản ánh trạng thái mới |
| Client lạ | Mở một URL không tồn tại | Trả HTTP 404, firmware không reset |
Đừng dùng máy sấy hoặc nguồn nhiệt cao sát BME280. Mục đích chỉ là quan sát xu hướng; hơi ấm từ tay đã đủ cho bài test cơ bản.
Khi nào prototype trở thành một sản phẩm nhỏ?
Breadboard hoàn thành bài học, chưa hoàn thành sản phẩm. Trước khi dùng lâu dài, mình sẽ bổ sung:
- Hàn mạch chắc chắn và làm vỏ có khe thoáng cho BME280.
- Đặt sensor xa regulator, ESP32 và luồng khí nóng để giảm sai số nhiệt.
- Không để LED chiếu sáng liên tục nếu thiết bị dùng pin.
- Thêm trang cấu hình hoặc BLE provisioning thay vì hard-code Wi-Fi.
- Bảo vệ các endpoint điều khiển; web server hiện chỉ phù hợp LAN tin cậy.
- Lưu cấu hình cần thiết bằng
Preferences, thêm watchdog, log reset reason và OTA có recovery. - Chạy soak test vài giờ hoặc vài ngày, cố ý reset router và rút/cắm nguồn.
Khi muốn tiến xa hơn, ESP32 Smart Garden là bước tiếp theo thực tế hơn: nhiều sensor, dữ liệu analog không sạch và actuator cần fail-safe.
Checklist kết thúc ESP32 Beginner Guide
- Bạn biết chọn đúng development board thay vì mua theo tên “ESP32” chung chung.
- Bạn nạp code, đọc Serial và xử lý lỗi port/boot cơ bản.
- Bạn đọc pinout trước khi dùng GPIO.
- Bạn bring-up sensor riêng trước khi tích hợp.
- Bạn hiểu khi nào chọn Wi-Fi, web server hoặc BLE.
- Bạn tách firmware thành input, sampling, output, network và API.
- Bạn có test case cho lỗi sensor, mất mạng và khởi động lại.
- Bạn không đưa credential lên Git hoặc expose web server demo ra Internet.
Đây là phần cuối của ESP32 Beginner Guide. Nếu Room Comfort Monitor chạy ổn sau nhiều lần mất mạng và cấp nguồn lại, bạn đã vượt qua đoạn khó nhất của người mới: không còn chỉ “làm code chạy”, mà đã bắt đầu thiết kế cách thiết bị hoạt động khi mọi thứ không hoàn hảo.
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 – 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.
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ị.
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.