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.
✅ Đã kiểm thử
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 ✓
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.
| # | Dây | Nối các điểm |
|---|---|---|
| 1 | cam 3V3 | U1 3V3 ⟷ U2 VCC ⟷ U3 VCC |
| 2 | đen GND | U1 GND ⟷ U2 GND ⟷ U3 GND |
| 3 | vàng DHT_DATA | U2 DATA ⟷ U1 GPIO16 (RX2) |
| 4 | xanh lá SDA | U3 SDA ⟷ U1 GPIO21 (D21) |
| 5 | xanh dương SCL | U3 SCL ⟷ U1 GPIO22 (D22) |
Danh sách linh kiện
| Ký hiệu | Linh kiện | Giá tham khảo | |
|---|---|---|---|
| U1 | ESP32 DevKit V1 30 chân (ESP32-WROOM-32) | 110.000đ | Tìm mua |
| U2 | Module cảm biến DHT22 (AM2302) loại 3 chân có sẵn mạch đế | 65.000đ | Tìm mua |
| U3 | Mà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 Độ ẩ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 OLED | 3,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 code | Hiệ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.