Nhà kính mini với ESP32: đo nhiệt độ, độ ẩm đất, ánh sáng; tự tưới, bật quạt, đèn trồng cây
Làm nhà kính mini với ESP32: DHT22, cảm biến độ ẩm đất, quang trở; relay điều khiển bơm, quạt, đèn trồng cây 12 V có khoảng trễ, tưới từng đợt, hạn mức bơm, trang web tự động và bằng tay.
Nhà kính mini (hay tủ trồng rau, chậu cây trên ban công có mái che) là dự án nông nghiệp nhỏ hợp với ESP32: đo môi trường, tự tưới, tự thông gió, tự bổ sung ánh sáng. Bài này ghép bốn cảm biến (nhiệt độ, độ ẩm không khí, độ ẩm đất, ánh sáng) và ba thiết bị 12 V (bơm, quạt, đèn trồng cây) qua module relay, điều khiển tự động có khoảng trễ, có giới hạn thời gian bơm để không ngập chậu khi cảm biến hỏng, và một trang web để xem số đo, chuyển từng thiết bị sang bật tắt bằng tay.
Bài tưới cây tự động với ESP32 đã nói kỹ về cảm biến độ ẩm đất và bơm; bài này mở rộng thành cả một hệ thống.
1. Thiết bị và cảm biến
| Phần | Linh kiện | Chân ESP32 | Dùng để |
|---|---|---|---|
| Nhiệt độ, độ ẩm không khí | DHT22 | D4 | Bật quạt khi nóng hoặc ẩm quá |
| Độ ẩm đất | Cảm biến điện dung | D34 | Quyết định tưới |
| Ánh sáng | Quang trở + điện trở 10 kΩ | D35 | Bật đèn khi trời tối trong khung giờ ban ngày |
| Bơm 12 V | Relay K1 | D32 | Tưới từng đợt ngắn |
| Quạt 12 V | Relay K2 | D33 | Thông gió, giảm nóng, giảm ẩm |
| Đèn LED trồng cây 12 V | Relay K3 | D26 | Bổ sung ánh sáng |
2. Chuẩn bị
- ESP32 DevKit V1 và dây USB
- Module relay 4 kênh 5 V
- Mạch hạ áp LM2596 chỉnh ra 5 V cho ESP32 từ adapter 12 V
- DHT22, cảm biến độ ẩm đất loại điện dung, quang trở và điện trở 10 kΩ
- Bơm 12 V, quạt 12 V, dải LED trồng cây 12 V, adapter 12 V từ 3 A (tùy tổng dòng các thiết bị), 2 diode 1N4007
- Breadboard, dây cắm; hộp kín nước cho phần mạch
3. Nối dây


- Chỉ dùng thiết bị 12 V một chiều trong nhà kính: môi trường ẩm ướt, có nước tưới, điện 220 V ở đây rất dễ gây giật.
- Bơm và quạt là động cơ: đặt diode 1N4007 ngược song song ở hai đầu mỗi cái để dập xung khi relay nhả.
- Mạch ESP32, relay đặt trong hộp kín; cảm biến đất cắm sâu tới phần cảm ứng, không ngập phần mạch điện tử ở đầu cảm biến.
4. Hiệu chuẩn cảm biến đất
Cảm biến điện dung cho điện áp càng thấp khi đất càng ướt. Mỗi cảm biến một khác, nên:
- Để cảm biến ngoài không khí, đọc mV ở D34 (in thử bằng
analogReadMilliVolts(34)): điền vàoDAT_KHO_MV. - Nhúng cảm biến vào cốc nước tới vạch giới hạn: điền số đọc vào
DAT_UOT_MV. - Cắm vào đất vừa tưới đẫm và đất đã khô, xem phần trăm, chỉnh
DAT_TUOItheo loại cây: rau ăn lá cần đất ẩm hơn cây chịu hạn.
5. Chương trình
// PYWORLD.VN - Nhà kính mini ESP32: đo nhiệt độ, độ ẩm không khí (DHT22), độ ẩm đất, ánh sáng; tự bật bơm, quạt, đèn trồng cây
// PYWORLD.VN - DHT22 D4; cảm biến độ ẩm đất điện dung AO D34; quang trở D35; relay kích mức thấp: bơm IN1 D32, quạt IN2 D33, đèn IN3 D26
// PYWORLD.VN - Trang web xem số đo, chuyển từng thiết bị giữa tự động và bật/tắt tay; bơm có giới hạn thời gian để không ngập chậu
#include <WiFi.h>
#include <WebServer.h>
#include <time.h>
#include <stdarg.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
const int CHAN_DHT = 4, CHAN_DAT = 34, CHAN_SANG = 35;
const int CHAN_RELAY[3] = {32, 33, 26}; // bơm, quạt, đèn
const char *TEN_TB[3] = {"Bom", "Quat", "Den"};
const bool RELAY_KICH_MUC_THAP = true;
// Hiệu chuẩn cảm biến đất: đọc mV khi cảm biến để ngoài không khí (khô) và nhúng nước (ướt) rồi điền vào đây.
const int DAT_KHO_MV = 2600, DAT_UOT_MV = 1100;
// Ngưỡng điều khiển, mỗi cặp có khoảng trễ.
const float DAT_TUOI = 35, DAT_DU = 60; // % độ ẩm đất: dưới 35 thì tưới
const uint32_t BOM_MOI_LAN_MS = 15000; // mỗi lần bơm 15 giây
const uint32_t CHO_THAM_MS = 600000; // rồi chờ 10 phút cho nước thấm mới đo lại
const uint32_t BOM_TOI_DA_NGAY_MS = 180000; // tối đa 3 phút bơm mỗi ngày: hỏng cảm biến cũng không ngập
const float QUAT_BAT_C = 32, QUAT_TAT_C = 30; // nhiệt độ
const float QUAT_BAT_RH = 90, QUAT_TAT_RH = 80; // độ ẩm không khí cao dễ sinh nấm
const float TOI_PHAN_TRAM = 25; // ánh sáng dưới 25 % coi là tối
const int DEN_TU_GIO = 5, DEN_DEN_GIO = 21; // chỉ bổ sung đèn trong khung giờ này, ban đêm cây cần tối
// Các struct khai báo trước mọi hàm.
struct ThietBi { bool bat, tuDong; };
struct SuKien { char noiDung[64]; };
WebServer may(80);
portMUX_TYPE khoaNgat = portMUX_INITIALIZER_UNLOCKED;
ThietBi tb[3] = {{false, true}, {false, true}, {false, true}};
float nhietDo = NAN, doAm = NAN, datPhanTram = NAN, sangPhanTram = NAN;
uint32_t batDauBom = 0, lanBomCuoi = 0, bomHomNay = 0;
int ngayBom = -1;
SuKien suKien[10];
int soSuKien = 0, viTriSuKien = -1;
// ---- DHT22 (như bài DHT11/DHT22) ----
static int32_t choHetMuc(int muc, uint32_t toiDa) {
uint32_t batDau = micros();
while (digitalRead(CHAN_DHT) == muc) {
if (micros() - batDau > toiDa) return -1;
}
return (int32_t)(micros() - batDau);
}
bool docDht22(float &t, float &rh) {
pinMode(CHAN_DHT, OUTPUT);
digitalWrite(CHAN_DHT, LOW);
delay(2);
pinMode(CHAN_DHT, INPUT_PULLUP);
uint16_t cao[40];
bool duoc = true;
portENTER_CRITICAL(&khoaNgat);
if (choHetMuc(HIGH, 100) < 0 || choHetMuc(LOW, 100) < 0 || choHetMuc(HIGH, 100) < 0) duoc = false;
for (int i = 0; i < 40 && duoc; i++) {
int32_t c = choHetMuc(LOW, 80) < 0 ? -1 : choHetMuc(HIGH, 100);
if (c < 0) duoc = false;
else cao[i] = c;
}
portEXIT_CRITICAL(&khoaNgat);
if (!duoc) return false;
uint8_t du[5] = {0, 0, 0, 0, 0};
for (int i = 0; i < 40; i++) du[i / 8] = (du[i / 8] << 1) | (cao[i] > 48 ? 1 : 0);
if (((du[0] + du[1] + du[2] + du[3]) & 0xFF) != du[4]) return false;
rh = ((du[0] << 8) | du[1]) * 0.1f;
t = (((du[2] & 0x7F) << 8) | du[3]) * 0.1f;
if (du[2] & 0x80) t = -t;
return true;
}
// ---- Cảm biến analog ----
int docMv(int chan) {
uint32_t tong = 0;
for (int i = 0; i < 32; i++) tong += analogReadMilliVolts(chan);
return tong / 32;
}
void doCamBien() {
float t, rh;
if (docDht22(t, rh)) {
nhietDo = t;
doAm = rh;
}
int mv = docMv(CHAN_DAT); // đất càng ướt, điện áp ra của cảm biến điện dung càng thấp
datPhanTram = constrain(100.0f * (DAT_KHO_MV - mv) / (DAT_KHO_MV - DAT_UOT_MV), 0.0f, 100.0f);
sangPhanTram = 100.0f * docMv(CHAN_SANG) / 3300; // quang trở lên 3V3, điện trở 10 kΩ xuống GND: sáng thì điện áp cao
}
// ---- Thiết bị, sự kiện ----
void ghiSuKien(const char *dang, ...) {
viTriSuKien = (viTriSuKien + 1) % 10;
if (soSuKien < 10) soSuKien++;
char chu[48];
va_list ds;
va_start(ds, dang);
vsnprintf(chu, sizeof(chu), dang, ds);
va_end(ds);
struct tm t;
if (getLocalTime(&t, 0)) snprintf(suKien[viTriSuKien].noiDung, sizeof(suKien[0].noiDung), "%02d:%02d %s", t.tm_hour, t.tm_min, chu);
else snprintf(suKien[viTriSuKien].noiDung, sizeof(suKien[0].noiDung), "--:-- %s", chu);
Serial.println(suKien[viTriSuKien].noiDung);
}
void dat(int i, bool bat, const char *lyDo) {
if (tb[i].bat == bat) return;
tb[i].bat = bat;
digitalWrite(CHAN_RELAY[i], (bat == RELAY_KICH_MUC_THAP) ? LOW : HIGH);
ghiSuKien("%s %s (%s)", TEN_TB[i], bat ? "BAT" : "TAT", lyDo);
}
void dieuKhien() {
uint32_t bay = millis();
struct tm t;
bool coGio = getLocalTime(&t, 0);
if (coGio && t.tm_yday != ngayBom) { // sang ngày mới: đặt lại hạn mức bơm
ngayBom = t.tm_yday;
bomHomNay = 0;
}
// Bơm: tưới từng đợt ngắn, chờ thấm, có hạn mức mỗi ngày.
if (tb[0].bat) {
if (bay - batDauBom >= BOM_MOI_LAN_MS || (!tb[0].tuDong && bomHomNay + (bay - batDauBom) >= BOM_TOI_DA_NGAY_MS)) {
bomHomNay += bay - batDauBom;
lanBomCuoi = bay;
dat(0, false, "het dot bom");
}
} else if (tb[0].tuDong && !isnan(datPhanTram) && datPhanTram < DAT_TUOI) {
if (bomHomNay + BOM_MOI_LAN_MS > BOM_TOI_DA_NGAY_MS) {
static int ngayDaBao = -2;
if (ngayDaBao != ngayBom) {
ngayDaBao = ngayBom;
ghiSuKien("Dat van kho nhung da bom du han muc: kiem tra cam bien, nuoc");
}
} else if (lanBomCuoi == 0 || bay - lanBomCuoi >= CHO_THAM_MS) {
batDauBom = bay;
dat(0, true, "dat kho");
}
}
// Quạt: nóng hoặc ẩm quá thì bật, có khoảng trễ.
if (tb[1].tuDong && !isnan(nhietDo)) {
if (nhietDo >= QUAT_BAT_C || doAm >= QUAT_BAT_RH) dat(1, true, nhietDo >= QUAT_BAT_C ? "nong" : "am cao");
else if (nhietDo <= QUAT_TAT_C && doAm <= QUAT_TAT_RH) dat(1, false, "da diu");
}
// Đèn: chỉ trong khung giờ ban ngày, khi trời tối (mây, chiều muộn).
if (tb[2].tuDong) {
bool trongKhung = coGio && t.tm_hour >= DEN_TU_GIO && t.tm_hour < DEN_DEN_GIO;
if (!trongKhung) dat(2, false, "ngoai gio");
else if (sangPhanTram < TOI_PHAN_TRAM) dat(2, true, "troi toi");
else if (sangPhanTram > TOI_PHAN_TRAM + 10) dat(2, false, "du sang");
}
}
// ---- Trang web ----
void trangChu() {
String s;
s.reserve(2500);
s += F("<!doctype html><html lang='vi'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<meta http-equiv='refresh' content='10'><title>Nhà kính mini</title><style>body{font-family:sans-serif;margin:14px;background:#f1f8f1}"
".o{display:inline-block;background:#fff;border-radius:10px;padding:10px 14px;margin:4px;min-width:120px}.o b{display:block;font-size:24px}"
"table{background:#fff;border-collapse:collapse}td{padding:8px 10px;border-bottom:1px solid #ddd}a{margin-right:8px}</style></head><body><h2>Nhà kính mini</h2>");
char d[200];
snprintf(d, sizeof(d), "<div class='o'>Nhiệt độ<b>%.1f °C</b></div><div class='o'>Độ ẩm không khí<b>%.0f %%</b></div>"
"<div class='o'>Độ ẩm đất<b>%.0f %%</b></div><div class='o'>Ánh sáng<b>%.0f %%</b></div>", nhietDo, doAm, datPhanTram, sangPhanTram);
s += d;
s += F("<h3>Thiết bị</h3><table>");
const char *tenVn[3] = {"Bơm tưới", "Quạt", "Đèn trồng cây"};
for (int i = 0; i < 3; i++) {
snprintf(d, sizeof(d), "<tr><td>%s</td><td><b>%s</b> (%s)</td><td><a href='/dk?tb=%d&cd=tu'>Tự động</a><a href='/dk?tb=%d&cd=bat'>Bật</a><a href='/dk?tb=%d&cd=tat'>Tắt</a></td></tr>",
tenVn[i], tb[i].bat ? "BẬT" : "tắt", tb[i].tuDong ? "tự động" : "tay", i, i, i);
s += d;
}
snprintf(d, sizeof(d), "</table><p>Bơm hôm nay: %lu / %lu giây</p><h3>Gần đây</h3><ul>", (unsigned long)(bomHomNay / 1000), (unsigned long)(BOM_TOI_DA_NGAY_MS / 1000));
s += d;
for (int k = 0; k < soSuKien; k++) {
s += "<li>";
s += suKien[(viTriSuKien - k + 10) % 10].noiDung;
s += "</li>";
}
s += F("</ul></body></html>");
may.send(200, "text/html; charset=utf-8", s);
}
void dieuKhienTay() {
int i = may.arg("tb").toInt();
String cd = may.arg("cd");
if (i >= 0 && i < 3) {
tb[i].tuDong = cd == "tu";
if (cd == "bat") {
if (i == 0) batDauBom = millis(); // bật tay vẫn chỉ bơm một đợt rồi tự tắt
dat(i, true, "web");
} else if (cd == "tat") {
if (i == 0 && tb[0].bat) bomHomNay += millis() - batDauBom;
dat(i, false, "web");
}
ghiSuKien("%s: che do %s", TEN_TB[i], tb[i].tuDong ? "tu dong" : "tay");
}
may.sendHeader("Location", "/");
may.send(303);
}
void setup() {
Serial.begin(115200);
for (int i = 0; i < 3; i++) {
digitalWrite(CHAN_RELAY[i], RELAY_KICH_MUC_THAP ? HIGH : LOW); // đặt mức tắt trước khi thành ngõ ra
pinMode(CHAN_RELAY[i], OUTPUT);
}
pinMode(CHAN_DHT, INPUT_PULLUP);
analogSetAttenuation(ADC_11db);
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
configTzTime("ICT-7", "pool.ntp.org", "time.google.com");
may.on("/", trangChu);
may.on("/dk", dieuKhienTay);
may.begin();
Serial.println("Nha kinh mini: cho 2 giay de doc cam bien");
}
void loop() {
may.handleClient();
static uint32_t lanDo = 0, lanIn = 0;
static bool daInIp = false;
if (millis() - lanDo >= 2000) {
lanDo = millis();
doCamBien();
dieuKhien();
}
if (!daInIp && WiFi.status() == WL_CONNECTED) {
daInIp = true;
Serial.printf("Mo trinh duyet: http://%s\n", WiFi.localIP().toString().c_str());
}
if (millis() - lanIn >= 60000) {
lanIn = millis();
Serial.printf("%.1f C, %.0f %% | dat %.0f %% | sang %.0f %% | bom %s, quat %s, den %s\n", nhietDo, doAm, datPhanTram, sangPhanTram,
tb[0].bat ? "BAT" : "tat", tb[1].bat ? "BAT" : "tat", tb[2].bat ? "BAT" : "tat");
}
}
Giải thích
- Khoảng trễ: quạt bật khi nóng từ 32 °C hoặc ẩm từ 90 %, nhưng chỉ tắt khi đã xuống 30 °C và 80 %. Đèn bật khi ánh sáng dưới 25 %, tắt khi trên 35 %. Không có khoảng trễ, thiết bị sẽ bật tắt liên tục quanh ngưỡng, relay nhanh hỏng.
- Tưới từng đợt: đất dưới 35 % thì bơm 15 giây rồi dừng, chờ 10 phút cho nước thấm tới cảm biến mới đo lại. Nếu bơm liên tục tới khi cảm biến báo đủ ẩm thì nước kịp tràn chậu trước khi cảm biến nhận ra.
- Hạn mức 3 phút bơm mỗi ngày: cảm biến đất hỏng hay rút khỏi chậu sẽ luôn báo "khô"; hạn mức chặn không cho bơm cạn bể nước hay ngập chậu, và ghi một dòng nhắc kiểm tra.
- Đèn theo khung giờ: chỉ bổ sung ánh sáng từ 5 giờ tới 21 giờ khi trời tối (sáng sớm, mây, chiều muộn); ban đêm cây cũng cần tối.
- Tự động và bằng tay: trên trang web, mỗi thiết bị có ba nút. "Bật", "Tắt" chuyển thiết bị sang chế độ tay; "Tự động" trả lại cho chương trình. Bơm bật tay cũng chỉ chạy một đợt rồi tự tắt.
- Khởi động, các chân relay được đặt mức "tắt" trước khi chuyển thành ngõ ra, nên relay không đóng thoáng qua lúc cấp điện.
6. Kết quả



7. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Độ ẩm đất luôn 0 % hoặc 100 % | Chưa hiệu chuẩn, đọc nhầm chân | Đo lại DAT_KHO_MV, DAT_UOT_MV |
| Bơm chạy nhiều đợt mà đất không ẩm thêm | Hết nước trong bể, ống tắc, cảm biến đặt xa chỗ tưới | Kiểm tra bể, ống; cắm cảm biến gần gốc cây nơi nước tới |
| "da bom du han muc" | Cảm biến đất hỏng hoặc bị rút ra | Kiểm tra cảm biến; hạn mức đặt lại lúc 0 giờ |
| ESP32 khởi động lại khi bơm chạy | Thiếu diode, nguồn 12 V yếu, chung dây GND dài | Thêm diode, adapter đủ dòng; xem bài relay làm ESP32 bị reset |
| Đèn không bao giờ bật | Chưa có giờ Internet (khung giờ tính theo giờ thật) | Kiểm tra Wi-Fi; quang trở có đặt chỗ bị đèn chiếu vào không |
| Relay chạy ngược | Module kích mức cao | Đặt RELAY_KICH_MUC_THAP = false |
8. Hướng mở rộng
- Kênh relay thứ tư (D27) cho máy phun sương: bật khi độ ẩm không khí quá thấp vào buổi trưa.
- Thêm cảm biến mực nước bể để báo hết nước thay vì dựa vào hạn mức bơm.
- Gửi số đo lên Google Sheets hoặc ThingSpeak để xem đồ thị cả mùa vụ, nhận cảnh báo qua Telegram khi nhiệt độ quá cao (các bài trong loạt ESP32 với điện thoại và dịch vụ đám mây).
9. Xem thêm
- Tưới cây tự động với ESP32: cảm biến độ ẩm đất, relay và bơm mini
- Module relay 1, 2, 4 kênh: kích mức thấp hay cao, JD-VCC
- LM2596 là gì: cách chỉnh 5 V từ adapter 12 V
- Bài DHT11/DHT22 trong loạt mô-đun và bài trạm thời tiết mini trong cùng nhóm dự án.
Xem thêm