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.

⏱ 90 phút🔧 Biết hàn💰 Linh kiện ~174.400đ🧠 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 SPICE3/3 kịch bản · 11 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 74% bộ nhớesp32:esp32:esp32doit-devkit-v1
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 ✓
Kiểm lần cuối: 11:39:05 12/11/2026

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.

5V5V3V33V3GNDGNDSOILSOILGATE_DRVGATE_DRVGATEGATEPUMP_NEGPUMP_NEGU1 · ESP32 DevKit V13V3VINGPIO25 (D25)GPIO34 (D34)GNDR1 · Điện trở 100Ω12Q1 · MOSFET kênh N logic-level IRLZ44NDGSR2 · Điện trở 10kΩ12U2 · Cảm biến độ ẩm đất điện dung v1.2VCCAOUTGNDM1 · Máy bơm nước mini 3–5V- · dây đen+ · dây đỏD1 · Đi-ốt 1N5819A (anode)K (vạch trắng)C1 · Tụ điện 470µF-+
#DâyNối các điểm
1đỏ
5V
U1 VIN ⟷ M1 + · dây đỏ ⟷ D1 K (vạch trắng) ⟷ C1 +
2cam
3V3
U1 3V3 ⟷ U2 VCC
3đen
GND
U1 GND ⟷ U2 GND ⟷ Q1 S ⟷ R2 2 ⟷ C1 -
4vàng
SOIL
U2 AOUT ⟷ U1 GPIO34 (D34)
5xanh lá
GATE_DRV
U1 GPIO25 (D25) ⟷ R1 1
6xanh dương
GATE
R1 2 ⟷ Q1 G ⟷ R2 1
7tím
PUMP_NEG
M1 - · dây đen ⟷ D1 A (anode) ⟷ Q1 D

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
U2Cả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
Q1MOSFET 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
M1Máy bơm chìm mini 3–5V DC kèm 1m ống silicon 8mm25.000đTìm mua
D1Đi-ốt Schottky 1N5819500đTìm mua
C1Tụ 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 độ ẩm3,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ướckhoảng 1,0 – 1,5V
Giữa chân D của MOSFET và GND khi đang bơmdưới 0,1V
Giữa 2 dây bơm khi đang bơm4,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.