Learning Goals 5 min
A real, useful, low-power IoT device that watches a plant. Soil-moisture sensor + WiFi + a tiny web dashboard + email alerts via IFTTT. By the end of this lesson you will:
- Wire a capacitive (or resistive) soil moisture probe to an ESP8266/ESP32 and calibrate the dry/wet endpoints.
- Serve a live dashboard with current moisture, last-watered timestamp, and a 24-hour mini history chart.
- Fire an IFTTT webhook (email + phone notification) when moisture stays below a threshold for > 30 minutes.
Warm-Up 10 min
Hardware:
- ESP (NodeMCU or ESP32).
- Soil moisture probe (capacitive type is much better than resistive — doesn't corrode in soil).
- Optional LDR for "is it light" check.
- A real potted plant 🌱 (or a cup of soil for the demo).
- USB power supply (5 V wall wart) for permanent deployment.
Calibrate before coding
Push the probe into very dry soil → read A0 → record as SOIL_DRY. Push into freshly-watered soil → read A0 → record as SOIL_WET. For capacitive probes, dry reads higher (~600), wet reads lower (~300). For resistive probes it's the opposite. The mapping translates raw value → 0–100% moisture.
New Concept · Threshold + dwell time + once-per-event alerts 20 min
Why "dwell time", not instantaneous
Soil moisture readings fluctuate as the soil dries unevenly. Don't alert on the first reading below threshold; require the value to stay below the threshold for some dwell time (typically 30 minutes for plants).
Once-per-event suppression
Once you've fired the "needs water" alert, don't fire again until moisture recovers (i.e. someone watered the plant). Hysteresis — already met in L03-43 — same idea.
State machine
enum WaterState { OK, MAYBE_DRY, FIRED_ALERT };
WaterState state = OK;
unsigned long dryStartAt = 0;
const unsigned long DWELL_MS = 30UL * 60UL * 1000UL; // 30 min
const int DRY_THRESHOLD = 30; // %
const int RECOVERED = 50; // re-arm above 50%
void checkPlant(int moisturePct) {
switch (state) {
case OK:
if (moisturePct < DRY_THRESHOLD) {
dryStartAt = millis();
state = MAYBE_DRY;
}
break;
case MAYBE_DRY:
if (moisturePct >= DRY_THRESHOLD) {
state = OK; // false alarm
} else if (millis() - dryStartAt >= DWELL_MS) {
fireWaterAlert();
state = FIRED_ALERT;
}
break;
case FIRED_ALERT:
if (moisturePct >= RECOVERED) {
state = OK; // plant was watered, re-arm
}
break;
}
}The dashboard
Live current moisture + last-fired timestamp + a 24-hour graph (1 sample per hour). Same JSON-poll pattern as L03-33; same Adafruit_SSD1306 fallback if you want a physical display.
Worked Example · Plant monitor v1 30 min
Step 1 — wire
| Component | ESP pin |
|---|---|
| Soil probe AOUT | A0 |
| Soil probe VCC | 3V3 (most capacitive probes accept 3.3 V or 5 V) |
| Soil probe GND | GND |
Step 2 — calibrate
Throwaway sketch:
void setup() { Serial.begin(115200); }
void loop() { Serial.println(analogRead(A0)); delay(500); }Dry soil reading ≈ SOIL_DRY. Wet soil reading ≈ SOIL_WET. Note both.
Step 3 — the production sketch
// L03-44 · Plant monitor
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecureBearSSL.h>
ESP8266WebServer server(80);
using SecureClient = BearSSL::WiFiClientSecure;
#elif defined(ESP32)
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
WebServer server(80);
using SecureClient = WiFiClientSecure;
#endif
#include <time.h>
const char* SSID = "YourNetwork";
const char* PASSWORD = "YourPassword";
const char* IFTTT_URL = "https://maker.ifttt.com/trigger/plant_dry/with/key/REPLACE_ME";
const int SOIL_DRY = 600;
const int SOIL_WET = 300;
const int DRY_THRESHOLD = 30; // pct
const int RECOVERED = 50;
const unsigned long DWELL_MS = 30UL * 60UL * 1000UL;
enum WaterState { OK, MAYBE_DRY, FIRED_ALERT };
WaterState state = OK;
unsigned long dryStartAt = 0;
time_t lastAlertAt = 0;
int hourlyHistory[24]; // last 24 hours, 1 sample/hr
int historyIndex = 0;
unsigned long lastHourSample = 0;
const unsigned long HOUR_MS = 3600UL * 1000UL;
int readMoisturePct() {
int raw = analogRead(A0);
int pct = map(raw, SOIL_DRY, SOIL_WET, 0, 100);
return constrain(pct, 0, 100);
}
void fireWaterAlert(int pct) {
if (WiFi.status() != WL_CONNECTED) return;
SecureClient client;
client.setInsecure();
HTTPClient http;
http.begin(client, IFTTT_URL);
http.addHeader("Content-Type", "application/json");
String body = "{\"value1\":\""; body += pct; body += "\"}";
int status = http.POST(body);
Serial.print("# alert -> "); Serial.println(status);
http.end();
lastAlertAt = time(nullptr);
}
void checkPlant(int pct) {
switch (state) {
case OK:
if (pct < DRY_THRESHOLD) {
dryStartAt = millis();
state = MAYBE_DRY;
}
break;
case MAYBE_DRY:
if (pct >= DRY_THRESHOLD) {
state = OK;
} else if (millis() - dryStartAt >= DWELL_MS) {
fireWaterAlert(pct);
state = FIRED_ALERT;
}
break;
case FIRED_ALERT:
if (pct >= RECOVERED) state = OK;
break;
}
}
void sampleHistory(int pct) {
if (millis() - lastHourSample < HOUR_MS) return;
lastHourSample = millis();
hourlyHistory[historyIndex] = pct;
historyIndex = (historyIndex + 1) % 24;
}
void handleRoot() {
static const char html[] PROGMEM = R"HTML(
<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plant Monitor</title>
<style>
body { font-family: sans-serif; max-width: 420px; margin: 0 auto; padding: 1rem; }
.big { font-size: 3rem; font-weight: 700; }
canvas { width: 100%; height: 80px; border: 1px solid #e5e7eb; }
</style></head><body>
<h1>�� Plant Monitor</h1>
<p>Moisture: <span class="big" id="m">--</span>%</p>
<p>State: <span id="s">--</span></p>
<p>Last alert: <span id="a">--</span></p>
<p>24h history:</p>
<canvas id="g" width="400" height="80"></canvas>
<script>
async function tick() {
const r = await fetch('/data.json'); const j = await r.json();
document.getElementById('m').textContent = j.pct;
document.getElementById('s').textContent = j.state;
document.getElementById('a').textContent = j.lastAlert || 'never';
const c = document.getElementById('g').getContext('2d');
c.clearRect(0, 0, 400, 80);
c.beginPath();
for (let i = 0; i < 24; i++) {
const x = i * (400/23), y = 80 - (j.history[i]/100*80);
if (i==0) c.moveTo(x, y); else c.lineTo(x, y);
}
c.strokeStyle = '#22c55e'; c.lineWidth = 2; c.stroke();
}
setInterval(tick, 5000); tick();
</script>
</body></html>
)HTML";
server.send_P(200, "text/html", html);
}
void handleData() {
int pct = readMoisturePct();
String json = "{\"pct\":"; json += pct;
json += ",\"state\":\""; json += (state == OK ? "ok" : state == MAYBE_DRY ? "drying" : "ALERT");
json += "\",\"lastAlert\":\"";
if (lastAlertAt) {
char buf[20];
struct tm tmA;
localtime_r(&lastAlertAt, &tmA);
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", &tmA);
json += buf;
}
json += "\",\"history\":[";
for (int i = 0; i < 24; i++) {
int idx = (historyIndex + i) % 24;
if (i) json += ",";
json += hourlyHistory[idx];
}
json += "]}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println();
Serial.print("# IP = "); Serial.println(WiFi.localIP());
configTime(0, 0, "pool.ntp.org");
setenv("TZ", "MYT-8", 1); tzset();
for (int i = 0; i < 24; i++) hourlyHistory[i] = 0;
server.on("/", handleRoot);
server.on("/data.json", handleData);
server.begin();
}
void loop() {
server.handleClient();
static unsigned long lastCheck = 0;
if (millis() - lastCheck >= 60000) { // once a minute
lastCheck = millis();
int pct = readMoisturePct();
Serial.print("# moisture "); Serial.print(pct); Serial.println("%");
checkPlant(pct);
sampleHistory(pct);
}
}Step 4 — set up the IFTTT applet
Trigger: Webhooks event plant_dry. Action: Email (or Notifications) with body "The plant is dry ({{Value1}}%). Time to water!".
Step 5 — test the alert path quickly
Temporarily set DWELL_MS = 30000UL (30 s instead of 30 min). Pull the probe out of the soil → moisture % drops → 30 s later the alert fires → email arrives. Restore DWELL_MS after testing.
Step 6 — install on the plant
Push probe firmly into soil 2/3 of the way down. Power via USB. Leave the ESP somewhere safe with WiFi. Bookmark the IP on your phone.
Try It Yourself 15 min
Goal: Add a daily summary message — at 9 AM each day, send a Discord webhook with the previous 24 hours' min, max, and average.
Goal: Add a relay-controlled pump output (or just an LED for safety). When the plant has been dry > 30 min AND it's daytime (06:00–20:00), pulse the pump for 5 seconds.
Goal: Persist hourly history to LittleFS so it survives reboot. On restart, re-load the last 24 hours and continue.
Mini-Challenge · Run it for a week 10 min
Install the monitor on a real plant. Let it run for a week. Check the dashboard daily. Note: did the alerts fire when they should? Were there false alarms? Did the history graph make sense? Document for next class.
Recap 5 min
Plant monitor = sensor + dwell-time state machine + threshold alert + tiny dashboard. The dwell-time idea is general: don't alert on the first dip; wait until it's been bad for a while. Once-per-event suppression with hysteresis stops alert spam. Tomorrow: the pan-tilt camera mount build.
- Capacitive soil probe
- A probe that measures capacitance change with moisture. No metal-soil contact → no corrosion. Lasts years.
- Dwell time
- Required duration a condition must persist before firing an alert. Filters out transient noise.
- Once-per-event suppression
- Fire an alert when entering a state; don't fire again until leaving and re-entering. Prevents spam.
- Calibration endpoints
- The measured raw ADC values at known physical extremes (very dry, very wet). Used in
map()to translate raw → percentage. - History buffer
- A small rolling array of past samples for charting. 24 hourly samples = one day's graph.
Homework 5 min
- Install the monitor on a real plant. Bookmark its dashboard URL.
- Note the alert dwell time you chose; explain why.
- Read ahead to ARD-L03-45 (Pan-Tilt Camera Mount). Bring two SG90 servos + a phone (or a small camera) tomorrow.
Bring back next class:
- Working plant monitor with at least one screenshot.
- Two SG90 servos.