Tưới cây tự động theo độ ẩm đất, xem và chỉnh bằng điện thoại
Cảm biến đo độ ẩm đất, khi đất khô mạch tự bật bơm mini tưới rồi tự tắt. Xem độ ẩm, chỉnh ngưỡng, tưới tay qua web trên điện thoại. Có giới hạn thời gian bơm và số lần tưới mỗi ngày để không cháy bơm hay ngập chậu.
✅ Đã kiểm thử
Xem chi tiết 11 phép đo
Đang bơm (GPIO25 mức cao)
- Q1: dẫn 227.0mA, Vds=0.01V.
- Chân GPIO25 cấp 0.3mA (an toàn ≤ 20.0mA).
- Tổng dòng đỉnh từ USB 482.3mA (đủ với cục sạc 5V 1A).
- Dòng chạy qua máy bơm: 227.0mA ✓
- Áp rơi trên MOSFET (MOSFET mở hoàn toàn): 0.01V ✓
- Dòng chân GPIO25 phải cấp: 0.3mA ✓
- Áp cảm biến vào ESP32 (đất khô nhất): 3.00V ✓
Nghỉ (GPIO25 mức thấp)
- Tổng dòng đỉnh từ USB 255.0mA (đủ với cục sạc 5V 1A).
- Bơm tắt hẳn khi nghỉ: 0.0mA ✓
Lúc khởi động (GPIO25 thả nổi)
- Tổng dòng đỉnh từ USB 255.0mA (đủ với cục sạc 5V 1A).
- Trở 10kΩ giữ bơm tắt khi ESP32 đang khởi động: 0.0mA ✓
Nguyên lý hoạt động
Cảm biến điện dung cho điện áp 1,2–2,8V tùy độ ẩm (đất càng khô áp càng cao). ESP32 đọc ở GPIO34, đổi ra % theo 2 mốc hiệu chuẩn khô/ướt. Khi độ ẩm dưới ngưỡng, GPIO25 lên 3,3V mở MOSFET IRLZ44N, bơm nhận ~0,23A từ cổng USB 5V; MOSFET gần như không tỏa nhiệt (áp rơi < 0,02V). Trở 10kΩ kéo cổng MOSFET xuống GND để bơm không tự chạy lúc ESP32 khởi động; đi-ốt 1N5819 dập xung điện ngược khi bơm tắt. Phần mềm giới hạn mỗi lần bơm tối đa N giây, nghỉ tối thiểu giữa 2 lần và số lần tưới mỗi ngày.
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 | đỏ 5V | U1 VIN ⟷ M1 + · dây đỏ ⟷ D1 K (vạch trắng) ⟷ C1 + |
| 2 | cam 3V3 | U1 3V3 ⟷ U2 VCC |
| 3 | đen GND | U1 GND ⟷ U2 GND ⟷ Q1 S ⟷ R2 2 ⟷ C1 - |
| 4 | vàng SOIL | U2 AOUT ⟷ U1 GPIO34 (D34) |
| 5 | xanh lá GATE_DRV | U1 GPIO25 (D25) ⟷ R1 1 |
| 6 | xanh dương GATE | R1 2 ⟷ Q1 G ⟷ R2 1 |
| 7 | tím PUMP_NEG | M1 - · dây đen ⟷ D1 A (anode) ⟷ Q1 D |
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 | Cảm biến độ ẩm đất điện dung v1.2 (loại không gỉ, kèm dây 3 chân) | 25.000đ | Tìm mua |
| Q1 | MOSFET IRLZ44N (TO-220, loại logic-level, có chữ L) | 12.000đ | Tìm mua |
| R1 | Điện trở 100Ω 1/4W (nâu-đen-nâu) | 200đ | Tìm mua |
| R2 | Điện trở 10kΩ 1/4W (nâu-đen-cam) | 200đ | Tìm mua |
| M1 | Máy bơm chìm mini 3–5V DC kèm 1m ống silicon 8mm | 25.000đ | Tìm mua |
| D1 | Đi-ốt Schottky 1N5819 | 500đ | Tìm mua |
| C1 | Tụ hóa 470µF 16V (470u) | 1.500đ | Tìm mua |
| Tổng (chưa gồm board hàn, dây, thiếc) | 174.400đ |
Các bước làm
Code nạp cho mạch
Chọn board DOIT ESP32 DEVKIT V1.
/*
Mạch Gọn — Tưới cây tự động theo độ ẩm đất (ESP32)
Board : DOIT ESP32 DEVKIT V1
Chân : GPIO34 <- AOUT cảm biến độ ẩm điện dung (cấp 3V3)
GPIO25 -> R100 -> cổng MOSFET IRLZ44N (bơm 5V ở cực D)
Lần đầu: WiFi "MachGon-Tuoi" (mật khẩu machgon123) -> 192.168.4.1
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
// ---- Cấu hình ----
const uint8_t PIN_SOIL = 34;
const uint8_t PIN_PUMP = 25;
const uint8_t PIN_LED = 2;
const char* AP_SSID = "MachGon-Tuoi";
const char* HOSTNAME = "tuoicay";
const uint32_t SAMPLE_MS = 2000; // đọc cảm biến mỗi 2 giây
const uint32_t DAY_MS = 24UL * 3600UL * 1000UL;
const uint16_t PUMP_HARD_MAX_S = 60; // giới hạn cứng, không cài vượt được
struct Settings {
int dryRaw = 2900; // giá trị ADC khi khô (ngoài không khí)
int wetRaw = 1300; // giá trị ADC khi ngập nước
int onBelow = 35; // bật tưới khi độ ẩm < %
int pumpSec = 8; // mỗi lần bơm tối đa (giây)
int restMin = 30; // nghỉ tối thiểu giữa 2 lần (phút)
int maxPerDay = 6; // tối đa số lần tự tưới / 24h
bool autoOn = true;
} S;
WebServer server(80);
Preferences prefs;
int rawNow = 0, pctNow = 0;
bool sensorOk = true;
bool pumping = false;
uint32_t pumpStart = 0, pumpLimitMs = 0, lastWater = 0, dayStart = 0, lastSample = 0;
int waterToday = 0;
bool everWatered = false;
String lastReason = "Chưa tưới";
void loadSettings() {
S.dryRaw = prefs.getInt("dry", S.dryRaw);
S.wetRaw = prefs.getInt("wet", S.wetRaw);
S.onBelow = prefs.getInt("on", S.onBelow);
S.pumpSec = prefs.getInt("sec", S.pumpSec);
S.restMin = prefs.getInt("rest", S.restMin);
S.maxPerDay = prefs.getInt("max", S.maxPerDay);
S.autoOn = prefs.getBool("auto", S.autoOn);
}
void saveSettings() {
prefs.putInt("dry", S.dryRaw); prefs.putInt("wet", S.wetRaw); prefs.putInt("on", S.onBelow);
prefs.putInt("sec", S.pumpSec); prefs.putInt("rest", S.restMin); prefs.putInt("max", S.maxPerDay);
prefs.putBool("auto", S.autoOn);
}
int readSoilRaw() {
long sum = 0;
for (int i = 0; i < 16; i++) { sum += analogRead(PIN_SOIL); delayMicroseconds(300); }
return sum / 16;
}
int rawToPct(int raw) {
if (S.dryRaw == S.wetRaw) return 0;
long p = (long)(S.dryRaw - raw) * 100 / (S.dryRaw - S.wetRaw);
return constrain((int)p, 0, 100);
}
void pumpOn(uint32_t seconds, const String& reason) {
if (pumping) return;
seconds = constrain(seconds, 1u, (uint32_t)PUMP_HARD_MAX_S);
digitalWrite(PIN_PUMP, HIGH);
digitalWrite(PIN_LED, HIGH);
pumping = true; pumpStart = millis(); pumpLimitMs = seconds * 1000UL;
lastReason = reason;
Serial.printf("Bom BAT %lus (%s)\n", (unsigned long)seconds, reason.c_str());
}
void pumpOff() {
digitalWrite(PIN_PUMP, LOW);
digitalWrite(PIN_LED, LOW);
if (pumping) { pumping = false; lastWater = millis(); everWatered = true; Serial.println("Bom TAT"); }
}
void controlLoop() {
uint32_t now = millis();
if (pumping && now - pumpStart >= pumpLimitMs) pumpOff(); // luôn cắt bơm khi hết giờ
if (now - dayStart >= DAY_MS) { dayStart = now; waterToday = 0; }
if (now - lastSample < SAMPLE_MS) return;
lastSample = now;
rawNow = readSoilRaw();
// cảm biến tuột dây/đứt: giá trị dính sát 0 hoặc 4095
sensorOk = rawNow > 200 && rawNow < 4000;
pctNow = rawToPct(rawNow);
if (!sensorOk) { if (pumping && lastReason.startsWith("Tự động")) pumpOff(); return; }
if (!S.autoOn || pumping) return;
bool rested = !everWatered || now - lastWater >= (uint32_t)S.restMin * 60000UL;
if (pctNow < S.onBelow && rested && waterToday < S.maxPerDay) {
waterToday++;
pumpOn(S.pumpSec, "Tự động: đất " + String(pctNow) + "%");
}
}
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>Tưới cây</title>
<style>
body{margin:0;font-family:system-ui,sans-serif;background:#052e16;color:#ecfdf5}
header{padding:14px;background:#064e3b;font-weight:700}
.card{margin:12px;padding:14px;background:#065f46;border-radius:14px}
.big{font-size:56px;font-weight:800;text-align:center}.sub{text-align:center;color:#a7f3d0}
.bar{height:14px;background:#022c22;border-radius:8px;overflow:hidden}.bar i{display:block;height:100%;background:#34d399}
label{display:flex;justify-content:space-between;align-items:center;margin:8px 0;gap:8px}
input[type=number]{width:90px;padding:7px;border-radius:8px;border:0;font:inherit}
button{font:inherit;border:0;border-radius:10px;padding:10px 14px;margin:4px 4px 0 0;background:#34d399;color:#022c22;font-weight:700}
button.g{background:#0f766e;color:#ecfdf5}.warn{background:#7f1d1d;padding:10px;border-radius:10px;display:none}
small{color:#a7f3d0}
</style></head><body><header>🌱 Tưới cây tự động <small id="ip"></small></header>
<div class="card"><div class="big" id="pct">--%</div><div class="bar"><i id="b" style="width:0"></i></div>
<p class="sub" id="st">Đang đọc…</p><div class="warn" id="err">⚠ Không đọc được cảm biến: kiểm tra dây AOUT/VCC/GND.</div>
<button onclick="water(3)">Tưới thử 3 giây</button><button class="g" onclick="post('/api/stop')">Dừng bơm</button></div>
<div class="card"><b>Cài đặt</b>
<label>Tự động tưới <input type="checkbox" id="auto"></label>
<label>Tưới khi độ ẩm dưới (%) <input type="number" id="on" min="5" max="90"></label>
<label>Mỗi lần bơm tối đa (giây) <input type="number" id="sec" min="1" max="60"></label>
<label>Nghỉ ít nhất giữa 2 lần (phút) <input type="number" id="rest" min="1" max="1440"></label>
<label>Tối đa số lần/ngày <input type="number" id="max" min="1" max="48"></label>
<button onclick="save()">Lưu cài đặt</button></div>
<div class="card"><b>Hiệu chuẩn cảm biến</b><br><small>Giá trị thô hiện tại: <span id="raw">-</span> (khô=<span id="d">-</span>, ướt=<span id="w">-</span>)</small><br>
<button class="g" onclick="post('/api/cal?m=dry')">Đặt mốc KHÔ (để ngoài không khí)</button>
<button class="g" onclick="post('/api/cal?m=wet')">Đặt mốc ƯỚT (nhúng nước)</button></div>
<div class="card"><b>WiFi nhà</b><br><small id="ws"></small>
<label>Tên <input id="ssid" style="width:60%;padding:7px;border-radius:8px;border:0"></label>
<label>Mật khẩu <input id="pass" type="password" style="width:60%;padding:7px;border-radius:8px;border:0"></label>
<button onclick="wifi()">Lưu & khởi động lại</button></div>
<script>
const $=i=>document.getElementById(i);let loaded=false;
async function post(u,b){const r=await fetch(u,{method:'POST',body:b});tick();return r}
function water(s){post('/api/water?s='+s)}
async function save(){const p=new URLSearchParams();['on','sec','rest','max'].forEach(k=>p.append(k,$(k).value));p.append('auto',$('auto').checked?1:0);await post('/api/settings',p);alert('Đã lưu')}
async function wifi(){await post('/api/wifi',new URLSearchParams({ssid:$('ssid').value,pass:$('pass').value}));alert('Đã lưu, mạch khởi động lại. Nối WiFi nhà rồi mở http://tuoicay.local')}
async function tick(){try{const s=await (await fetch('/api/state')).json();
$('pct').textContent=s.ok?s.pct+'%':'--';$('b').style.width=s.pct+'%';$('err').style.display=s.ok?'none':'block';
$('st').textContent=(s.pumping?'💧 Đang bơm… ':'')+'Lần cuối: '+s.reason+(s.ago>=0?' ('+s.ago+' phút trước)':'')+' · Hôm nay: '+s.today+'/'+s.max+' lần';
$('raw').textContent=s.raw;$('d').textContent=s.dry;$('w').textContent=s.wet;$('ip').textContent=s.ip;$('ws').textContent=s.sta?'Đã nối '+s.ssid:'Chưa nối WiFi nhà';
if(!loaded){['on','sec','rest','max'].forEach(k=>$(k).value=s[k]);$('auto').checked=s.auto;loaded=true}}catch(e){}}
tick();setInterval(tick,2000);
</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;
long ago = everWatered ? (long)((millis() - lastWater) / 60000UL) : -1;
String j = "{\"pct\":" + String(pctNow) + ",\"raw\":" + rawNow + ",\"ok\":" + (sensorOk ? "true" : "false") +
",\"pumping\":" + (pumping ? "true" : "false") + ",\"reason\":\"" + lastReason + "\",\"ago\":" + ago +
",\"today\":" + waterToday + ",\"dry\":" + S.dryRaw + ",\"wet\":" + S.wetRaw + ",\"on\":" + S.onBelow +
",\"sec\":" + S.pumpSec + ",\"rest\":" + S.restMin + ",\"max\":" + S.maxPerDay + ",\"auto\":" + (S.autoOn ? "true" : "false") +
",\"sta\":" + (sta ? "true" : "false") + ",\"ssid\":\"" + prefs.getString("ssid", "") + "\",\"ip\":\"" +
(sta ? WiFi.localIP().toString() : WiFi.softAPIP().toString()) + "\"}";
server.send(200, "application/json", j);
});
server.on("/api/water", HTTP_POST, []() {
if (!sensorOk && server.arg("s").toInt() > 10) return server.send(400, "text/plain", "cảm biến lỗi");
pumpOn(server.arg("s").toInt(), "Tưới tay"); server.send(200, "text/plain", "ok");
});
server.on("/api/stop", HTTP_POST, []() { pumpOff(); server.send(200, "text/plain", "ok"); });
server.on("/api/settings", HTTP_POST, []() {
S.onBelow = constrain(server.arg("on").toInt(), 5, 90);
S.pumpSec = constrain(server.arg("sec").toInt(), 1, (int)PUMP_HARD_MAX_S);
S.restMin = constrain(server.arg("rest").toInt(), 1, 1440);
S.maxPerDay = constrain(server.arg("max").toInt(), 1, 48);
S.autoOn = server.arg("auto") == "1";
saveSettings(); server.send(200, "text/plain", "ok");
});
server.on("/api/cal", HTTP_POST, []() {
int r = readSoilRaw();
if (server.arg("m") == "dry") S.dryRaw = r; else S.wetRaw = r;
if (abs(S.dryRaw - S.wetRaw) < 200) { server.send(400, "text/plain", "2 mốc quá gần nhau"); return; }
saveSettings(); 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() {
pinMode(PIN_PUMP, OUTPUT); digitalWrite(PIN_PUMP, LOW); // tắt bơm ngay khi khởi động
pinMode(PIN_LED, OUTPUT);
Serial.begin(115200);
analogSetPinAttenuation(PIN_SOIL, ADC_11db);
prefs.begin("tuoi", false);
loadSettings();
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);
}
Serial.printf("AP %s %s | STA %s\n", AP_SSID, WiFi.softAPIP().toString().c_str(), WiFi.localIP().toString().c_str());
if (MDNS.begin(HOSTNAME)) MDNS.addService("http", "tcp", 80);
setupWeb();
dayStart = millis();
}
void loop() {
server.handleClient();
controlLoop();
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 cảm biến độ ẩm | 3,2 – 3,3V |
| Giữa AOUT và GND khi cảm biến ở ngoài không khí | khoảng 2,4 – 2,9V |
| Giữa AOUT và GND khi nhúng nước | khoảng 1,0 – 1,5V |
| Giữa chân D của MOSFET và GND khi đang bơm | dưới 0,1V |
| Giữa 2 dây bơm khi đang bơm | 4,6 – 5,0V |
Sửa lỗi thường gặp
Bơm chạy liên tục không tắt
Kiểm tra trở 10kΩ G–GND, MOSFET có thể đã chập (đo D–S). Phần mềm luôn cắt bơm sau số giây tối đa; nếu vẫn chạy là lỗi phần cứng.
Bơm không chạy
Thử "Tưới thử" trên web; đo áp chân G lúc bơm phải ~3,3V. Kiểm tra chiều MOSFET G-D-S và đi-ốt (vạch trắng về VIN; cắm ngược đi-ốt sẽ chập bơm).
Độ ẩm nhảy lung tung hoặc luôn 0%/100%
Hiệu chuẩn lại 2 mốc; kiểm tra dây AOUT vào đúng GPIO34; dùng dây ngắn dưới 1m.
ESP32 khởi động lại khi bơm chạy
Cục sạc yếu: dùng cục sạc 5V ≥1A và cáp ngắn; đảm bảo đã hàn tụ 470µF.
⚠️ An toàn
- Chỉ dùng điện 5V từ cục sạc USB, an toàn khi chạm tay.
- Đặt mạch trong hộp kín, cao hơn mặt nước; chỉ cảm biến và bơm được tiếp xúc nước.
- Không dùng bơm 220V với mạch này.