Trạm đo nhiệt độ – độ ẩm có màn hình OLED và biểu đồ 24h trên điện thoại

Đo nhiệt độ, độ ẩm và chỉ số nóng bức (cảm giác thực tế) bằng DHT22, hiện trên màn OLED 0,96 inch và trang web điện thoại kèm biểu đồ 24 giờ. Sai số khoảng ±0,5°C và ±2–5% độ ẩm, dùng cho phòng ngủ, phòng máy, kho, chuồng trại.

⏱ 60 phút🔧 Mới học💰 Linh kiện ~230.000đ🧠 ESP32 DevKit V1⚡ Nguồn USB 5V

✅ Đã kiểm thử

Luật điệnKhông lỗiSai chân, chập nguồn, quá áp 5V vào chân 3,3V, thiếu đi-ốt, thiếu trở kéo…
Mô phỏng SPICE1/1 kịch bản · 4 phép đo đạtDòng/áp từng linh kiện ở trường hợp xấu nhất (ngspice)
Biên dịch codeĐạt · dùng 77% bộ nhớesp32:esp32:esp32doit-devkit-v1
Xem chi tiết 4 phép đo

Hoạt động bình thường (mọi tín hiệu ở mức cao — trường hợp áp lớn nhất)

  • Tổng dòng đỉnh từ USB 272.5mA (đủ với cục sạc 5V 1A).
  • Áp tín hiệu DHT22 vào ESP32: 3.30V ✓
  • Áp đường SDA màn hình vào ESP32: 3.30V ✓
  • Áp đường SCL màn hình vào ESP32: 3.30V ✓
Kiểm lần cuối: 12:05:53 12/11/2026

Nguyên lý hoạt động

DHT22 tự đo và gửi số liệu qua 1 dây DATA mỗi khi ESP32 hỏi (tối thiểu 2 giây/lần). Màn OLED nói chuyện với ESP32 qua 2 dây I2C (SDA–GPIO21, SCL–GPIO22). Cả 2 module được cấp 3,3V nên tín hiệu về ESP32 không vượt 3,3V, không cần mạch chuyển mức. Mạch chỉ ăn khoảng 25mA ngoài ESP32. ESP32 lưu 288 điểm đo (5 phút/điểm = 24 giờ) trong RAM để vẽ biểu đồ trên điện thoại.

Sơ đồ đấu dây

Mỗi màu là một dây. Chấm tròn là điểm hàn. Chạm vào tên dây trong bảng để làm nổi bật.

3V33V3GNDGNDDHT_DATADHT_DATASDASDASCLSCLU1 · ESP32 DevKit V13V3GPIO16 (RX2)GPIO21 (D21)GPIO22 (D22)GNDU2 · Cảm biến nhiệt độ – độ ẩm DHT22VCCDATAGNDU3 · Màn hình OLED 0.96" I2C SSD1306VCCSCLSDAGND
#DâyNối các điểm
1cam
3V3
U1 3V3 ⟷ U2 VCC ⟷ U3 VCC
2đen
GND
U1 GND ⟷ U2 GND ⟷ U3 GND
3vàng
DHT_DATA
U2 DATA ⟷ U1 GPIO16 (RX2)
4xanh lá
SDA
U3 SDA ⟷ U1 GPIO21 (D21)
5xanh dương
SCL
U3 SCL ⟷ U1 GPIO22 (D22)

Danh sách linh kiện

Ký hiệuLinh kiệnGiá tham khảo
U1ESP32 DevKit V1 30 chân (ESP32-WROOM-32)110.000đTìm mua
U2Module cảm biến DHT22 (AM2302) loại 3 chân có sẵn mạch đế65.000đTìm mua
U3Màn hình OLED 0.96 inch I2C SSD1306 128×64, 4 chân (GND VCC SCL SDA)55.000đTìm mua
Tổng (chưa gồm board hàn, dây, thiếc)230.000đ

Các bước làm

Code nạp cho mạch

Cài thư viện: DHT sensor library, Adafruit Unified Sensor, Adafruit SSD1306, Adafruit GFX Library (Arduino IDE → Library Manager). Chọn board DOIT ESP32 DEVKIT V1.

/*
  Mạch Gọn — Trạm nhiệt độ/độ ẩm DHT22 + OLED + biểu đồ 24h (ESP32)
  Board : DOIT ESP32 DEVKIT V1
  Thư viện: DHT sensor library, Adafruit Unified Sensor, Adafruit SSD1306, Adafruit GFX
  Chân  : GPIO16 <- DATA DHT22 (3V3) ; GPIO21 SDA, GPIO22 SCL -> OLED (3V3)
  Lần đầu: WiFi "MachGon-NhietDo" (mật khẩu machgon123) -> 192.168.4.1
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>

const uint8_t PIN_DHT = 16, PIN_SDA = 21, PIN_SCL = 22;
const char* AP_SSID = "MachGon-NhietDo";
const char* HOSTNAME = "nhietdo";
const uint32_t READ_MS = 3000;            // DHT22 cần >= 2 giây giữa 2 lần đọc
const uint32_t LOG_MS = 5UL * 60UL * 1000UL;
const int HIST = 288;                     // 24h x 12 điểm/giờ

DHT dht(PIN_DHT, DHT22);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WebServer server(80);
Preferences prefs;
bool oledOk = false, sensorOk = false;
float tNow = NAN, hNow = NAN, hiNow = NAN, tOff = 0, hOff = 0;
float tMin = NAN, tMax = NAN;
int16_t histT[HIST], histH[HIST];
int histCount = 0, histHead = 0;
uint32_t lastRead = 0, lastLog = 0;
int failCount = 0;

void addHistory(float t, float h) {
  histT[histHead] = (int16_t)lroundf(t * 10);
  histH[histHead] = (int16_t)lroundf(h * 10);
  histHead = (histHead + 1) % HIST;
  if (histCount < HIST) histCount++;
}

void readSensor() {
  float h = dht.readHumidity(), t = dht.readTemperature();
  if (isnan(h) || isnan(t)) {
    if (++failCount >= 3) sensorOk = false;   // 3 lần lỗi liên tiếp mới báo
    Serial.println("Doc DHT22 loi");
    return;
  }
  failCount = 0; sensorOk = true;
  tNow = t + tOff; hNow = constrain(h + hOff, 0.0f, 100.0f);
  hiNow = dht.computeHeatIndex(tNow, hNow, false);
  if (isnan(tMin) || tNow < tMin) tMin = tNow;
  if (isnan(tMax) || tNow > tMax) tMax = tNow;
  if (histCount == 0 || millis() - lastLog >= LOG_MS) { lastLog = millis(); addHistory(tNow, hNow); }
}

void drawOled() {
  if (!oledOk) return;
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  if (!sensorOk) {
    oled.setTextSize(2); oled.setCursor(0, 8); oled.println("Loi");
    oled.setTextSize(1); oled.println("cam bien DHT22");
    oled.println("Kiem tra day OUT");
  } else {
    oled.setTextSize(3); oled.setCursor(0, 0); oled.printf("%.1f", tNow);
    oled.setTextSize(1); oled.print(" o"); oled.setTextSize(2); oled.print("C");
    oled.setTextSize(2); oled.setCursor(0, 28); oled.printf("%.0f%% RH", hNow);
    oled.setTextSize(1); oled.setCursor(0, 46); oled.printf("Cam giac: %.1fC", hiNow);
  }
  oled.setTextSize(1); oled.setCursor(0, 56);
  oled.print(WiFi.status() == WL_CONNECTED ? WiFi.localIP() : WiFi.softAPIP());
  oled.display();
}

const char PAGE[] PROGMEM = R"HTML(<!doctype html><html lang="vi"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Nhiệt độ – độ ẩm</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#0b1120;color:#e2e8f0}
header{padding:14px;background:#111827;font-weight:700}.row{display:flex;gap:10px;margin:12px}
.c{flex:1;background:#1f2937;border-radius:14px;padding:14px;text-align:center}.c b{display:block;font-size:34px}
.c small,small{color:#94a3b8}.card{margin:12px;background:#1f2937;border-radius:14px;padding:14px}
canvas{width:100%;height:220px}input{width:80px;padding:7px;border-radius:8px;border:0;font:inherit}
button{font:inherit;border:0;border-radius:10px;padding:9px 14px;background:#38bdf8;color:#082f49;font-weight:700;margin-top:6px}
.err{background:#7f1d1d;margin:12px;padding:10px;border-radius:10px;display:none}
</style></head><body><header>🌡 Nhiệt độ – độ ẩm <small id="ip"></small></header>
<div class="err" id="err">⚠ Không đọc được DHT22. Kiểm tra dây OUT/VCC/GND.</div>
<div class="row"><div class="c"><small>Nhiệt độ</small><b id="t">--</b><small id="mm"></small></div>
<div class="c"><small>Độ ẩm</small><b id="h">--</b><small id="lv"></small></div></div>
<div class="row"><div class="c"><small>Cảm giác như</small><b id="hi">--</b></div></div>
<div class="card"><b>24 giờ qua</b> <small>— đỏ: nhiệt độ, xanh: độ ẩm</small><canvas id="cv" width="700" height="300"></canvas></div>
<div class="card"><b>Cài đặt</b><br><small>Bù sai số nếu so với nhiệt kế chuẩn bị lệch.</small><br>
Nhiệt độ ± <input id="to" type="number" step="0.1"> °C &nbsp; Độ ẩm ± <input id="ho" type="number" step="1"> %<br><button onclick="saveOff()">Lưu</button>
<p><small id="ws"></small></p>WiFi <input id="ssid" style="width:40%"> Mật khẩu <input id="pass" type="password" style="width:30%"><br><button onclick="wifi()">Lưu WiFi & khởi động lại</button></div>
<script>
const $=i=>document.getElementById(i);let first=true;
function level(h){return h<40?'Khô':h<=60?'Dễ chịu':h<=75?'Hơi ẩm':'Rất ẩm'}
async function tick(){try{const s=await (await fetch('/api/state')).json();$('err').style.display=s.ok?'none':'block';
if(s.ok){$('t').textContent=s.t.toFixed(1)+'°C';$('h').textContent=s.h.toFixed(0)+'%';$('hi').textContent=s.hi.toFixed(1)+'°C';$('lv').textContent=level(s.h);$('mm').textContent='thấp '+s.tmin.toFixed(1)+' · cao '+s.tmax.toFixed(1)}
$('ip').textContent=s.ip;$('ws').textContent=s.sta?'Đã nối WiFi nhà: '+s.ssid:'Chưa nối WiFi nhà';if(first){$('to').value=s.to;$('ho').value=s.ho;first=false}}catch(e){}}
async function chart(){const d=await (await fetch('/api/history')).json();const c=$('cv'),x=c.getContext('2d');x.clearRect(0,0,c.width,c.height);
if(d.t.length<2){x.fillStyle='#94a3b8';x.font='22px sans-serif';x.fillText('Đang thu thập số liệu (5 phút/điểm)…',20,150);return}
const P=40,W=c.width-P*2,H=c.height-P*1.5;function line(a,mn,mx,col){x.strokeStyle=col;x.lineWidth=3;x.beginPath();a.forEach((v,i)=>{const X=P+i*W/(a.length-1),Y=P/2+H-(v-mn)/(mx-mn||1)*H;i?x.lineTo(X,Y):x.moveTo(X,Y)});x.stroke()}
const tm=Math.min(...d.t)-1,tM=Math.max(...d.t)+1;line(d.t,tm,tM,'#f87171');line(d.h,0,100,'#38bdf8');
x.fillStyle='#94a3b8';x.font='18px sans-serif';x.fillText(tM.toFixed(0)+'°',2,P/2+12);x.fillText(tm.toFixed(0)+'°',2,P/2+H);x.fillText((d.t.length*5/60).toFixed(1)+' giờ trước',P,c.height-6);x.fillText('bây giờ',c.width-P-60,c.height-6)}
async function saveOff(){await fetch('/api/offset',{method:'POST',body:new URLSearchParams({t:$('to').value,h:$('ho').value})});alert('Đã lưu')}
async function wifi(){await fetch('/api/wifi',{method:'POST',body:new URLSearchParams({ssid:$('ssid').value,pass:$('pass').value})});alert('Mạch khởi động lại. Nối WiFi nhà rồi mở http://nhietdo.local')}
tick();chart();setInterval(tick,3000);setInterval(chart,60000);
</script></body></html>)HTML";

void setupWeb() {
  server.on("/", []() { server.send_P(200, "text/html; charset=utf-8", PAGE); });
  server.on("/api/state", []() {
    bool sta = WiFi.status() == WL_CONNECTED;
    char buf[400];
    snprintf(buf, sizeof(buf), "{\"ok\":%s,\"t\":%.2f,\"h\":%.1f,\"hi\":%.2f,\"tmin\":%.1f,\"tmax\":%.1f,\"to\":%.1f,\"ho\":%.0f,\"sta\":%s,\"ssid\":\"%s\",\"ip\":\"%s\"}",
             sensorOk ? "true" : "false", sensorOk ? tNow : 0, sensorOk ? hNow : 0, sensorOk ? hiNow : 0,
             isnan(tMin) ? 0 : tMin, isnan(tMax) ? 0 : tMax, tOff, hOff, sta ? "true" : "false",
             prefs.getString("ssid", "").c_str(), (sta ? WiFi.localIP() : WiFi.softAPIP()).toString().c_str());
    server.send(200, "application/json", buf);
  });
  server.on("/api/history", []() {
    String t = "[", h = "[";
    int start = (histHead - histCount + HIST) % HIST;
    for (int i = 0; i < histCount; i++) {
      int k = (start + i) % HIST;
      if (i) { t += ','; h += ','; }
      t += String(histT[k] / 10.0, 1); h += String(histH[k] / 10.0, 1);
    }
    server.send(200, "application/json", "{\"t\":" + t + "],\"h\":" + h + "]}");
  });
  server.on("/api/offset", HTTP_POST, []() {
    tOff = constrain(server.arg("t").toFloat(), -10.0f, 10.0f);
    hOff = constrain(server.arg("h").toFloat(), -30.0f, 30.0f);
    prefs.putFloat("to", tOff); prefs.putFloat("ho", hOff);
    server.send(200, "text/plain", "ok");
  });
  server.on("/api/wifi", HTTP_POST, []() {
    prefs.putString("ssid", server.arg("ssid")); prefs.putString("pass", server.arg("pass"));
    server.send(200, "text/plain", "ok"); delay(500); ESP.restart();
  });
  server.onNotFound([]() { server.sendHeader("Location", "/"); server.send(302); });
  server.begin();
}

void setup() {
  Serial.begin(115200);
  prefs.begin("nhietdo", false);
  tOff = prefs.getFloat("to", 0); hOff = prefs.getFloat("ho", 0);
  dht.begin();
  Wire.begin(PIN_SDA, PIN_SCL);
  oledOk = oled.begin(SSD1306_SWITCHCAPVCC, 0x3C) || oled.begin(SSD1306_SWITCHCAPVCC, 0x3D);
  if (!oledOk) Serial.println("Khong tim thay OLED (0x3C/0x3D)");
  else { oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setCursor(0, 0); oled.println("Mach Gon"); oled.println("Dang khoi dong..."); oled.display(); }
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(AP_SSID, "machgon123");
  String ssid = prefs.getString("ssid", "");
  if (ssid.length()) {
    WiFi.begin(ssid.c_str(), prefs.getString("pass", "").c_str());
    uint32_t t = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - t < 15000) delay(250);
  }
  if (MDNS.begin(HOSTNAME)) MDNS.addService("http", "tcp", 80);
  setupWeb();
  delay(2000);
  readSensor(); drawOled();
}

void loop() {
  server.handleClient();
  if (millis() - lastRead >= READ_MS) { lastRead = millis(); readSensor(); drawOled(); }
  static uint32_t lastCheck = 0;
  if (millis() - lastCheck > 30000) {
    lastCheck = millis();
    if (prefs.getString("ssid", "").length() && WiFi.status() != WL_CONNECTED) WiFi.reconnect();
  }
}

Đo kiểm bằng đồng hồ vạn năng

Giữa VCC và GND của DHT22 và của OLED3,2 – 3,3V
Giữa DATA (OUT) của DHT22 và GND lúc nghỉkhoảng 3,3V
Giữa SDA/SCL và GND lúc nghỉkhoảng 3,3V
Màn OLED sau khi nạp codeHiện nhiệt độ, độ ẩm và địa chỉ IP

Sửa lỗi thường gặp

Màn OLED tối đen

Kiểm tra SDA/SCL có bị đảo không; mở Serial Monitor 115200 xem log "Khong tim thay OLED". Code tự thử cả địa chỉ 0x3C và 0x3D.

Hiện "Lỗi cảm biến"

Kiểm tra dây OUT vào GPIO16, chân + và −; nếu dùng DHT22 trần 4 chân phải có trở 10kΩ kéo lên 3V3.

Nhiệt độ cao hơn thực tế 1–3°C

Cảm biến đang đặt sát ESP32 hoặc trong hộp kín; đưa ra xa, khoan lỗ thoáng hộp. Có thể chỉnh bù sai số trong mục Cài đặt.

Mất biểu đồ sau khi rút điện

Biểu đồ lưu trong RAM để bảo vệ bộ nhớ flash, mất điện sẽ đo lại từ đầu.

⚠️ An toàn

  • Mạch chạy 5V từ USB, an toàn.
  • Không đặt cảm biến ngoài trời mưa nếu không có hộp che có lỗ thoáng.