Smart gardening isn’t just about sensing soil moisture — it’s about visualizing data clearly so you can act fast. In this tutorial, we’ll go beyond simple sensor readings and build a browser‑based dashboard that shows live values, trends, alerts, and history charts.
🔧 Step 1: Hardware Setup- Soil Moisture Sensor (capacitive type recommended)
- ESP32 (or similar Wi‑Fi microcontroller)
- Breadboard + jumper wires
- Power source (USB or battery)
- Optional: JUSTWAY 3D‑printed enclosure for neat installation
You must check out PCBWAY for ordering PCBs online for cheap!
You get 10 good-quality PCBs manufactured and shipped to your doorstep for cheap. You will also get a discount on shipping on your first order. Upload your Gerber files onto PCBWAY to get them manufactured with good quality and quick turnaround time. PCBWay now could provide a complete product solution, from design to enclosure production. Check out their online Gerber viewer function. With reward points, you can get free stuff from their gift shop. Also, check out this useful blog on PCBWay Plugin for KiCad from here. Using this plugin, you can directly order PCBs in just one click after completing your design in KiCad.
⚡ Step 2: Wiring- Sensor VCC → 3.3V
- Sensor GND → GND
- Sensor Analog Out → GPIO34 (ADC pin)
Start with a simple sketch to:
- Connect to Wi‑Fi
- Read ADC values
- Map them to percentage moisture
- Serve data via a web server
#include <WiFi.h>
#include <WebServer.h>
#include <time.h>
#include <EEPROM.h>
// WiFi credentials
const char* WIFI_SSID = "";
const char* WIFI_PASSWORD = "";
IPAddress staticIP(192, 168, 1, 25);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns(8, 8, 8, 8);
const int soilPin = 32; // Use ADC1 pin when WiFi is active
const int dryValue = 4095;
const int wetValue = 1200;
WebServer server(80);
#define MAX_READINGS 50
struct Reading {
int moisture;
unsigned long timestamp;
} readings[MAX_READINGS];
int readingIndex = 0;
unsigned long lastReadTime = 0;
const unsigned long READ_INTERVAL = 5000;
int currentMoisture = 0;
int rawADCValue = 0;
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n========== ESP32 Soil Moisture Monitor v2.0 ==========");
pinMode(soilPin, INPUT);
Serial.print("Connecting to WiFi: ");
Serial.println(WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.config(staticIP, gateway, subnet, dns);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.println("[WiFi] Connected!");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
Serial.println("Syncing time...");
configTime(19800, 0, "pool.ntp.org");
time_t now = time(nullptr);
int timeCounter = 0;
while (now < 24 * 3600 && timeCounter < 30) {
delay(500);
Serial.print(".");
now = time(nullptr);
timeCounter++;
}
Serial.println("\n[Time] Synced!");
}
server.on("/", handleRoot);
server.on("/api/current", handleCurrent);
server.on("/api/history", handleHistory);
server.begin();
Serial.println("[Server] Started!");
Serial.println("======================================================\n");
}
void loop() {
server.handleClient();
if (millis() - lastReadTime >= READ_INTERVAL) {
lastReadTime = millis();
rawADCValue = analogRead(soilPin);
currentMoisture = map(rawADCValue, dryValue, wetValue, 0, 100);
if (currentMoisture < 0) currentMoisture = 0;
if (currentMoisture > 100) currentMoisture = 100;
readings[readingIndex].moisture = currentMoisture;
readings[readingIndex].timestamp = time(nullptr);
readingIndex = (readingIndex + 1) % MAX_READINGS;
Serial.print("[Sensor] Moisture: ");
Serial.print(currentMoisture);
Serial.print("% | Raw ADC: ");
Serial.print(rawADCValue);
Serial.println(" (Dry=4095, Wet=1200)");
}
delay(10);
}
void handleCurrent() {
String json = "{\"moisture\":" + String(currentMoisture) + ",\"raw_adc\":" + String(rawADCValue) + ",\"timestamp\":" + String(time(nullptr)) + ",\"status\":\"";
if (currentMoisture < 25) json += "Very Dry";
else if (currentMoisture < 50) json += "Dry";
else if (currentMoisture < 75) json += "Moist";
else json += "Wet";
json += "\",\"ip\":\"" + WiFi.localIP().toString() + "\"}";
server.send(200, "application/json", json);
}
void handleHistory() {
String json = "{\"readings\":[";
for (int i = 0; i < MAX_READINGS; i++) {
if (readings[i].timestamp == 0) continue;
if (i > 0 && readings[i-1].timestamp > 0) json += ",";
json += "{\"moisture\":" + String(readings[i].moisture) + ",\"timestamp\":" + String(readings[i].timestamp) + "}";
}
json += "]}";
server.send(200, "application/json", json);
}
void handleRoot() {
String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width'><title>Soil Moisture Monitor</title><script src='https://cdn.jsdelivr.net/npm/chart.js'></script><style>";
html += "body,html{height:100%;margin:0;padding:0;font-family:Segoe UI,Arial,sans-serif;background:linear-gradient(135deg,#1e3c72 0%,#2a5298 100%);color:#333;overflow:hidden;}";
html += ".container{max-width:1200px;height:100%;margin:0 auto;padding:16px;display:grid;grid-template-rows:auto 1fr auto;gap:14px;}header{text-align:center;color:white;margin:0;padding-bottom:8px}header h1{font-size:2.2em;margin:0;text-shadow:2px 2px 4px rgba(0,0,0,0.25)}header p{margin:8px 0 0 0;font-size:1rem;}";
html += ".grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));grid-auto-rows:minmax(220px,auto);gap:14px;margin:0;align-items:start;overflow:hidden;} .card{background:white;border-radius:16px;padding:16px;box-shadow:0 12px 30px rgba(0,0,0,0.15);overflow:hidden;}";
html += ".card h2{color:#2a5298;margin-top:0;margin-bottom:12px;font-size:1.1em;text-transform:uppercase;letter-spacing:0.08em}";
html += ".moisture-value{font-size:3em;font-weight:800;color:#3342a3;text-align:center;margin:18px 0 10px 0;line-height:1;}";
html += ".moisture-status{font-size:1.1em;padding:12px 16px;border-radius:10px;font-weight:700;text-align:center;margin-top:12px}";
html += ".status-very-dry{background:#ff6b6b;color:white}.status-dry{background:#ffa500;color:white}.status-moist{background:#51cf66;color:white}.status-wet{background:#4dabf7;color:white}";
html += ".info-item{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #eee}.info-item:last-child{border-bottom:none}.info-label{font-weight:600;color:#555}.info-value{color:#2a5298;font-weight:700}";
html += ".stats-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px}.stat-item{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;padding:16px;border-radius:14px;text-align:center;box-shadow:0 10px 20px rgba(0,0,0,0.12);animation:floatIn 0.8s ease forwards}";
html += ".stat-value{font-size:1.8em;font-weight:800;margin:8px 0}.stat-label{font-size:0.9em;opacity:0.9;letter-spacing:0.03em}";
html += ".btn-refresh{background:#2a5298;color:white;border:none;padding:12px 18px;border-radius:10px;cursor:pointer;width:100%;margin-top:18px;font-weight:700;transition:transform .2s,background .2s}";
html += ".btn-refresh:hover{background:#1e3c72;transform:translateY(-2px)}.chart-container{grid-column:1 / -1;background:white;border-radius:18px;padding:18px;box-shadow:0 14px 30px rgba(0,0,0,0.16);min-height:300px;display:flex;flex-direction:column;}";
html += ".chart-container h2{color:#2a5298;margin:0 0 10px 0;text-align:center;font-size:1.1em}#chart{height:240px!important;} .time-display{text-align:center;color:white;font-size:0.95em;margin-top:8px}";
html += ".notification-panel{background:#f6f9ff;border:1px solid rgba(106,147,255,0.2);border-radius:15px;padding:16px;min-height:130px;display:flex;flex-direction:column;gap:10px;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.5)}";
html += ".notification-item{padding:14px 16px;border-radius:12px;display:flex;align-items:center;gap:12px;font-size:0.95em;animation:fadeInUp .5s ease forwards}";
html += ".notification-item strong{font-weight:700}.notification-ok{background:rgba(81,207,102,0.12);color:#2d7a3f}.notification-warn{background:rgba(255,165,0,0.14);color:#7a5200}.notification-alert{background:rgba(255,107,107,0.14);color:#7a1720}";
html += ".moisture-meta{display:flex;justify-content:space-between;gap:10px;margin-top:18px;font-size:0.95em;color:#4a5a7a}";
html += ".moisture-meta span{display:inline-flex;align-items:center;gap:8px;background:rgba(255,255,255,0.55);padding:10px 14px;border-radius:12px;box-shadow:0 8px 18px rgba(0,0,0,0.08)}";
html += ".card{background:white;border-radius:20px;padding:25px;box-shadow:0 18px 40px rgba(0,0,0,0.12);border:1px solid rgba(255,255,255,0.35);animation:fadeIn 0.8s ease both}";
html += ".card-primary{background:linear-gradient(135deg,#4f69d7,#1c3f8c);color:white}.card-primary .moisture-value{color:#fff}.card-primary .moisture-status{background:rgba(255,255,255,0.1);color:#fff}.card-primary .moisture-meta span{background:rgba(255,255,255,0.15)}";
html += ".card-info{background:#f7fbff}.card-notice{background:#ffffff}";
html += "@keyframes fadeIn{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:translateY(0)}}";
html += "@keyframes fadeInUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}";
html += "@keyframes floatIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}";
html += "</style></head><body><div class='container'>";
html += "</style></head><body><div class='container'>";
html += "<header><h1>🌱 Soil Moisture Monitor</h1><p>Real-time Sensor Dashboard</p></header>";
html += "<div class='grid'>";
html += "<div class='card card-primary'><h2>Current Moisture Level</h2><div class='moisture-value' id='moistureValue'>--</div><div class='moisture-status' id='moistureStatus'>Measuring...</div><div class='moisture-meta'><span>Raw ADC: <strong id='rawVal'>--</strong></span><span>Trend: <strong id='trendVal'>--</strong></span></div></div>";
html += "<div class='card card-info'><h2>System Information</h2>";
html += "<div class='info-item'><span class='info-label'>IP Address:</span><span class='info-value' id='ipAddr'>--</span></div>";
html += "<div class='info-item'><span class='info-label'>Current Time:</span><span class='info-value' id='timeVal'>--:--:--</span></div>";
html += "<div class='info-item'><span class='info-label'>Status:</span><span class='info-value' style='color:#51cf66'>Online</span></div>";
html += "<button class='btn-refresh' onclick='fetchData()'>Refresh Data</button></div>";
html += "<div class='card card-notice'><h2>Notifications</h2><div id='notifications' class='notification-panel'><div class='notification-item notification-ok'><strong>System ready:</strong> Fetching moisture updates every 5 seconds.</div></div></div>";
html += "</div>";
html += "<div class='stats-grid'>";
html += "<div class='stat-item'><div class='stat-label'>Average</div><div class='stat-value' id='avgVal'>--%</div></div>";
html += "<div class='stat-item'><div class='stat-label'>Maximum</div><div class='stat-value' id='maxVal'>--%</div></div>";
html += "<div class='stat-item'><div class='stat-label'>Minimum</div><div class='stat-value' id='minVal'>--%</div></div>";
html += "<div class='stat-item'><div class='stat-label'>Wet/Dry Trend</div><div class='stat-value' id='trendStat'>--</div></div>";
html += "</div>";
html += "<div class='chart-container'><h2>📊 Moisture Level History (Last 50 Readings)</h2><canvas id='chart'></canvas></div>";
html += "</div><div class='time-display'>Last Updated: <span id='lastUp'>just now</span></div>";
html += "</div>";
html += "<script>";
html += "let chart=null;";
html += "async function fetchData(){";
html += "try{const curr=await fetch('/api/current').then(r=>r.json());const hist=await fetch('/api/history').then(r=>r.json());";
html += "document.getElementById('moistureValue').textContent=curr.moisture+'%';";
html += "document.getElementById('moistureStatus').textContent=curr.status;";
html += "document.getElementById('moistureStatus').className='moisture-status status-'+curr.status.toLowerCase().replace(/ /g,'-');";
html += "document.getElementById('rawVal').textContent=curr.raw_adc;";
html += "document.getElementById('ipAddr').textContent=curr.ip;";
html += "const trendValue = updateTrend(curr.moisture);";
html += "document.getElementById('trendVal').textContent=trendValue;";
html += "document.getElementById('trendStat').textContent=trendValue;";
html += "if(hist.readings.length>0){const vals=hist.readings.map(r=>r.moisture);const avg=Math.round(vals.reduce((a,b)=>a+b)/vals.length);";
html += "const maxv=Math.max(...vals);const minv=Math.min(...vals);";
html += "document.getElementById('avgVal').textContent=avg+'%';document.getElementById('maxVal').textContent=maxv+'%';";
html += "document.getElementById('minVal').textContent=minv+'%';updateChart(hist.readings);}";
html += "updateNotifications(curr.moisture, trendValue);";
html += "const now=new Date();document.getElementById('timeVal').textContent=now.toLocaleTimeString();";
html += "document.getElementById('lastUp').textContent=now.toLocaleTimeString();}catch(e){console.error(e);}}";
html += "let lastMoisture = null;";
html += "function updateTrend(value){if(lastMoisture===null){lastMoisture=value;return 'Stable';}const diff=value-lastMoisture;lastMoisture=value; if(diff > 5) return 'Getting wetter'; if(diff < -5) return 'Getting drier'; return 'Stable';}";
html += "function updateNotifications(value, trend){const panel=document.getElementById('notifications');panel.innerHTML='';";
html += "const messages=[];";
html += "if(value <= 15){messages.push({type:'alert',text:'Soil level is critically low — water immediately.'});}else if(value <= 35){messages.push({type:'warn',text:'Soil is dry. Consider watering soon.'});}else if(value >= 90){messages.push({type:'warn',text:'Soil is very wet. Check drainage.'});}else{messages.push({type:'ok',text:'Soil moisture is within healthy range.'});}";
html += "messages.push({type:'ok',text:'Current trend: '+trend+'.'});";
html += "messages.forEach(msg=>{const item=document.createElement('div');item.className='notification-item notification-'+msg.type;item.innerHTML='<strong>'+msg.type.toUpperCase()+':</strong> '+msg.text;panel.appendChild(item);});}";
html += "function updateChart(readings){const labels=readings.map((_,i)=>i);const data=readings.map(r=>r.moisture);";
html += "if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;chart.update();}else{";
html += "const ctx=document.getElementById('chart').getContext('2d');";
html += "chart=new Chart(ctx,{type:'line',data:{labels:labels,datasets:[{label:'Soil Moisture (%)',data:data,borderColor:'#667eea',";
html += "backgroundColor:'rgba(102,126,234,0.1)',borderWidth:2,fill:true,tension:0.4,pointRadius:3,pointBackgroundColor:'#667eea',";
html += "pointBorderColor:'#fff',pointBorderWidth:2}]},options:{responsive:true,maintainAspectRatio:true,plugins:{legend:{display:true}},";
html += "scales:{y:{beginAtZero:true,max:100,ticks:{stepSize:10}}}}});}}";
html += "fetchData();setInterval(fetchData,5000);setInterval(()=>{document.getElementById('timeVal').textContent=new Date().toLocaleTimeString();},1000);";
html += "</script></body></html>";
server.send(200, "text/html", html);
}Instead of plain text, let’s serve a styled HTML page with:
- Current Moisture Level (large, bold, with color coding
- Raw ADC Value (for debugging)
- Trend Indicator (Stable, Rising, Falling)
- System Info (IP address, uptime, status)
- Notifications (alerts when soil is critically dry)
- Summary Boxes (average, max, min, trend)
- History Chart (last 50 readings plotted dynamically)
Use HTML + CSS for layout and JavaScript for live updates
- Show ALERT when moisture < 20% (“Water immediately”).
- Show OK when stable.
- Use color coding (red for critical, green for normal).
Display:
- Device IP
- Current time
- Online/offline status
- Refresh button for manual reloads
Design a 3D‑printed case to protect the electronics. Leave openings for sensor placement and airflow.
To give this project a professional finish, I’ve housed the hardware inside a JUSTWAY 3D‑printed enclosure. The enclosure not only protects the electronics but also elevates the overall presentation with a sleek, durable design. Its precision‑fit layout ensures that connectors, sensors, and displays remain accessible while keeping the internals secure.
Why JUSTWAY?- Precision engineering: Each enclosure is designed with exact tolerances for embedded boards and modules.
- Durability: Strong materials that withstand repeated handling and environmental stress.
- Aesthetic appeal: Clean lines and modern finish make your DIY project look like a polished product.
- Customization: Options for tailored cutouts and branding, perfect for makers and educators.
JUSTWAY is committed to supporting the maker community by providing accessible, high‑quality enclosures that transform prototypes into showcase‑ready builds. If you’re looking to give your project the same professional edge, explore JUSTWAY’s catalog — their enclosures are designed to fit popular boards like ESP32, Raspberry Pi, and UNIHIKER K10, making them a go‑to choice for hobbyists and engineers alike.
✅ Step 9: CalibrationTest in dry soil → record baseline
- Test in dry soil → record baseline
- Test in wet soil → record max
- Adjust mapping in code for accuracy
- Multiple sensors for different zones
- Auto‑watering pump integration
- Cloud logging for long‑term analysis
- Mobile‑friendly dashboard for quick checks
With this setup, you don’t just get numbers — you get a real‑time, visually expressive dashboard that tells you when to water, tracks history, and keeps your garden healthy.










Comments