Bỏ qua, đến nội dung chính
Bài viếtFirmwareMới

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ẻ

LinkedInFacebookX
A complete ESP32-S3 room comfort monitor prototype with BME280, pushbutton, status LED, and a local dashboard on a MacBook Pro

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 °C hoặ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

ESP32-S3 DevKitC-1, BME280, breadboard, LED, điện trở 220 ohm, nút nhấn và dây Dupont được kiểm tra cạnh MacBook Pro.

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ệnESP32-S3 DevKitC-1Ghi chú
BME280 VCC3V3Không mặc định dùng 5 V nếu chưa kiểm tra breakout
BME280 GNDGNDTất cả module dùng chung ground
BME280 SDAGPIO4I2C data
BME280 SCLGPIO5I2C clock
LED anodeGPIO2 qua điện trở 220 ΩCathode về GND
Nút nhấnGPIO6GNDDù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ị HIGHLOW trong updateOutput().

Bring-up từng lớp, đừng nạp tất cả rồi đoán lỗi

Người dùng nhấn nút trên prototype Room Comfort Monitor dùng ESP32-S3, BME280 và LED cảnh báo cạnh MacBook Pro.

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:

  1. Nguồn: board khởi động ổn, không reset ngẫu nhiên.
  2. I2C: sketch scanner thấy 0x76 hoặc 0x77.
  3. Sensor: Serial có ba reading hợp lý.
  4. Output: sketch Blink riêng làm LED ngoài sáng/tắt.
  5. Input: Serial chỉ ghi một lần cho mỗi lần nhấn nút.
  6. Network: ESP32 nhận IP và dashboard mở được.
  7. 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

MacBook Pro hiển thị dashboard Room Comfort Monitor cạnh prototype ESP32-S3 được cấp nguồn đúng bằng cáp USB-C.

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.

TestThao tácKết quả mong đợi
Khởi động bình thườngCấp nguồn với router hoạt độngCó reading, IP và dashboard
Không có BME280Rút nguồn, tháo sensor rồi cấp lạiDashboard báo sensor unavailable, LED tắt
Mất Wi-FiTắt router tạm thờiSensor và nút vẫn chạy; reconnect sau khi mạng trở lại
Cảnh báo nhiệtLàm ấm sensor nhẹ bằng tay, không dùng lửaStatus đổi khi vượt ngưỡng; LED sáng nếu alarm enabled
Nút bị bounceNhấn một lần bình thườngTrạng thái chỉ đổi một lần
Điều khiển từ webNhấn Toggle alarmLED và JSON phản ánh trạng thái mới
Client lạMở một URL không tồn tạiTrả 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ẻ

LinkedInFacebookX

Tiếp tục khám phá

Đọc tiếp

Bài viết liên quan

Xem thêm trong Bài viết

Nastrotek sử dụng cookie để phân tích truy cập và cá nhân hóa quảng cáo, giúp hiểu cách website được sử dụng. Bạn có thể chấp nhận hoặc từ chối các cookie không thiết yếu. Chính sách bảo mật