Robot Maker Toolkit › ESP32 soil moisture monitor

ESP32 soil moisture monitor

A capacitive soil moisture sensor on an ESP32 ADC1 pin. The ESP32 turns the raw reading into a percentage with your own dry and wet calibration, and serves a live dashboard to any phone on your WiFi, with a "Water me!" warning below 30 %. No app and no cloud account.

Beginner+ · ~30 min ESP32 DevKit (30-pin) No libraries to install
✔ Wiring matches the app's ESP32 pins ✔ Sensor on an ADC1 pin (works with WiFi on) ✔ Web page tested in a browser ◻ Bench-tested — be the first, get credited

The video covers how the sensor turns water into a voltage, the 555 chip to check, why ADC2 stops reading while WiFi is on, calibration, and the page that polls /data. The plant, phone and Serial Monitor in the video are an animation.

Parts

Wiring

FromTo (ESP32)
Sensor VCC3V3
Sensor GNDGND
Sensor AOUTGPIO34 (ADC1)

Before you wire it

The sketch

Libraries: WiFi and WebServer (both come with the ESP32 board package; nothing to install).

soil_esp32.ino (copy everything)
// ESP32 soil moisture monitor: capacitive sensor + a phone dashboard
// Wiring (Robot Maker Toolkit, ESP32): VCC -> 3V3, GND -> GND, AOUT -> GPIO34
#include <WiFi.h>
#include <WebServer.h>

const char* WIFI_NAME = "your-wifi";
const char* WIFI_PASS = "your-password";

const int SOIL_PIN = 34;     // ADC1: keeps working while WiFi is on
const int DRY_RAW = 2900;    // sensor in air   (measure yours)
const int WET_RAW = 1250;    // sensor in water (measure yours)
const int THIRSTY = 30;      // below this %, the page says "Water me"

WebServer server(80);
int raw = 0;
int moisture = 0;
unsigned long lastRead = 0;

int readRaw() {
  long sum = 0;
  for (int i = 0; i < 16; i++) {   // average 16 readings
    sum += analogRead(SOIL_PIN);
    delay(2);
  }
  return sum / 16;
}

int toPercent(int r) {
  int p = map(r, DRY_RAW, WET_RAW, 0, 100);   // dry reads HIGH, wet reads LOW
  return constrain(p, 0, 100);
}

const char PAGE[] = R"html(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Plant</title><style>
body{background:#111;color:#eee;font-family:sans-serif;text-align:center}
#pct{font-size:72px;margin:24px 0 8px}
#bar{width:70%;height:28px;margin:16px auto;border:2px solid #888;
     border-radius:14px;overflow:hidden}
#fill{height:100%;width:0;background:#3c3;transition:width .5s}
</style></head><body>
<h2>Soil moisture</h2>
<div id="pct">--</div>
<div id="bar"><div id="fill"></div></div>
<div id="msg"></div>
<script>
async function tick() {
  try {
    const d = await (await fetch('/data')).json();
    pct.textContent = d.moisture + ' %';
    fill.style.width = d.moisture + '%';
    fill.style.background = d.thirsty ? '#e44' : '#3c3';
    msg.textContent = d.thirsty ? 'Water me!' : 'OK';
  } catch (e) { msg.textContent = 'offline'; }
}
tick(); setInterval(tick, 2000);
</script></body></html>
)html";

void handleRoot() {
  server.send(200, "text/html", PAGE);
}

void handleData() {
  String json = "{\"moisture\":" + String(moisture) + ",\"raw\":" + String(raw);
  json += ",\"thirsty\":" + String(moisture < THIRSTY ? "true" : "false") + "}";
  server.send(200, "application/json", json);
}

void setup() {
  Serial.begin(115200);
  analogReadResolution(12);        // 0..4095
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_NAME, WIFI_PASS);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.print("Open http://");
  Serial.println(WiFi.localIP());
  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.begin();
}

void loop() {
  server.handleClient();
  if (millis() - lastRead >= 2000) {   // a new reading every 2 s
    lastRead = millis();
    raw = readRaw();
    moisture = toPercent(raw);
    Serial.print("raw ");
    Serial.print(raw);
    Serial.print("   ");
    Serial.print(moisture);
    Serial.println(" %");
  }
}

If it doesn't work

SymptomCauseFix
Stuck at 0 or 4095AOUT isn't on GPIO34, or it is on an ADC2 pin while WiFi is onMove AOUT to GPIO34 (or another ADC1 pin) and set SOIL_PIN to match
Barely changes between air and waterAn NE555 on the sensor running from 3.3 VUse a sensor board with a TLC555
Drifts from day to dayThe sensor goes in to a different depth, or water reaches the top of the boardPush it in to the same depth every time, up to the line, and keep the top dry
Percentage is wrongDRY_RAW and WET_RAW are the example valuesRead the raw number in air and in water on the Serial Monitor and set both
The page won't openThe phone is on mobile data or another network, or the WiFi is 5 GHzJoin the same 2.4 GHz WiFi and use the address printed on the Serial Monitor (115200 baud)

The capacitive soil sensor's wiring for UNO, ESP32 and Pico, with its warnings, is free in Robot Maker Toolkit, alongside 56 modules — all offline.

Get it onGoogle Play
Board pinout library →