/*
* ESP32 Plant Monitor with Web Interface
* Features:
* - WiFi configuration via captive portal (long-press button)
* - Web dashboard with real-time sensor data
* - Soil moisture sensor with web-based calibration
* - DHT22 temperature & humidity monitoring
* - RGB LED status indicator
* - OLED display (toggle with button short-press)
* - Persistent calibration storage
*/
#include <WiFi.h>
#include <WiFiManager.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
// Pin Definitions
#define SOIL_SENSOR_PIN 34 // ADC pin for soil moisture
#define DHT_PIN 15 // DHT22 data pin
#define BUTTON_PIN 18 // Config/Display button
#define RGB_RED_PIN 26 // RGB LED Red
#define RGB_GREEN_PIN 33 // RGB LED Green
#define RGB_BLUE_PIN 32 // RGB LED Blue
#define OLED_SDA 23 // OLED I2C SDA
#define OLED_SCL 22 // OLED I2C SCL
// OLED Configuration
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
// DHT Configuration
#define DHT_TYPE DHT22
// Button timing (milliseconds)
#define SHORT_PRESS_TIME 500 // Short press to toggle display
#define LONG_PRESS_TIME 3000 // Long press for WiFi config
// Moisture thresholds (will be overridden by calibration)
#define DEFAULT_DRY_VALUE 3000 // ADC value when dry (in air)
#define DEFAULT_WET_VALUE 1000 // ADC value when wet (in water)
// Objects
DHT dht(DHT_PIN, DHT_TYPE);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
AsyncWebServer server(80);
AsyncEventSource events("/events");
Preferences preferences;
WiFiManager wifiManager;
// Global Variables
bool displayEnabled = true;
unsigned long buttonPressTime = 0;
bool buttonPressed = false;
bool configMode = false;
// Sensor calibration values (stored in flash)
int soilDryValue = DEFAULT_DRY_VALUE;
int soilWetValue = DEFAULT_WET_VALUE;
// Sensor readings
float temperature = 0.0;
float humidity = 0.0;
int soilMoistureRaw = 0;
int soilMoisturePercent = 0;
// Timing
unsigned long lastSensorRead = 0;
unsigned long lastDisplayUpdate = 0;
const unsigned long SENSOR_INTERVAL = 2000; // Read sensors every 2 seconds
const unsigned long DISPLAY_INTERVAL = 1000; // Update display every second
// Function Prototypes
void setupWiFi();
void setupWebServer();
void loadCalibration();
void saveCalibration();
void readSensors();
void updateDisplay();
void updateRGBStatus();
void handleButton();
void setRGBColor(int r, int g, int b);
String getStatusMessage();
String processor(const String& var);
void setup() {
Serial.begin(115200);
Serial.println("\n\nESP32 Plant Monitor Starting...");
// Initialize preferences
preferences.begin("plant-monitor", false);
loadCalibration();
// Initialize pins
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(RGB_RED_PIN, OUTPUT);
pinMode(RGB_GREEN_PIN, OUTPUT);
pinMode(RGB_BLUE_PIN, OUTPUT);
// Start with purple LED (booting)
setRGBColor(128, 0, 128);
// Initialize I2C for OLED
Wire.begin(OLED_SDA, OLED_SCL);
// Initialize OLED
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println(F("SSD1306 allocation failed"));
} else {
Serial.println("OLED initialized");
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0,0);
display.println("Plant Monitor");
display.println("Booting...");
display.display();
}
// Initialize DHT
dht.begin();
Serial.println("DHT22 initialized");
// Setup WiFi
setupWiFi();
// Setup Web Server
setupWebServer();
// Initial sensor read
readSensors();
updateDisplay();
updateRGBStatus();
Serial.println("Setup complete!");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
}
void loop() {
handleButton();
// Read sensors periodically
if (millis() - lastSensorRead >= SENSOR_INTERVAL) {
lastSensorRead = millis();
readSensors();
updateRGBStatus();
// Send data to web clients via SSE
StaticJsonDocument<200> doc;
doc["temperature"] = temperature;
doc["humidity"] = humidity;
doc["soilMoisture"] = soilMoisturePercent;
doc["soilMoistureRaw"] = soilMoistureRaw;
String json;
serializeJson(doc, json);
events.send(json.c_str(), "sensor-data", millis());
}
// Update display periodically
if (displayEnabled && (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL)) {
lastDisplayUpdate = millis();
updateDisplay();
}
}
void setupWiFi() {
// Configure WiFiManager
wifiManager.setConfigPortalTimeout(180); // 3 minute timeout
wifiManager.setConnectTimeout(20); // 20 second connect timeout
// Set custom AP name
String apName = "PlantMonitor-" + String((uint32_t)ESP.getEfuseMac(), HEX);
Serial.println("Connecting to WiFi...");
setRGBColor(0, 0, 255); // Blue = connecting
// Try to connect, or start config portal if fails
if (!wifiManager.autoConnect(apName.c_str(), "plantpass")) {
Serial.println("Failed to connect and hit timeout");
setRGBColor(255, 0, 0); // Red = error
delay(3000);
ESP.restart();
}
Serial.println("WiFi connected!");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
setRGBColor(0, 255, 0); // Green = connected
delay(1000);
}
void setupWebServer() {
// Add SSE event handler
events.onConnect([](AsyncEventSourceClient *client){
if(client->lastId()){
Serial.printf("Client reconnected! Last message ID: %u\n", client->lastId());
}
// Send initial data
StaticJsonDocument<200> doc;
doc["temperature"] = temperature;
doc["humidity"] = humidity;
doc["soilMoisture"] = soilMoisturePercent;
doc["soilMoistureRaw"] = soilMoistureRaw;
String json;
serializeJson(doc, json);
client->send(json.c_str(), "sensor-data", millis(), 1000);
});
server.addHandler(&events);
// Serve main page
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "text/html", getIndexHTML());
});
// API endpoint: Get current sensor data
server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *request){
StaticJsonDocument<300> doc;
doc["temperature"] = temperature;
doc["humidity"] = humidity;
doc["soilMoisture"] = soilMoisturePercent;
doc["soilMoistureRaw"] = soilMoistureRaw;
doc["status"] = getStatusMessage();
doc["calibration"]["dry"] = soilDryValue;
doc["calibration"]["wet"] = soilWetValue;
doc["wifi"]["ssid"] = WiFi.SSID();
doc["wifi"]["rssi"] = WiFi.RSSI();
doc["wifi"]["ip"] = WiFi.localIP().toString();
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
});
// API endpoint: Calibrate dry
server.on("/api/calibrate/dry", HTTP_POST, [](AsyncWebServerRequest *request){
soilDryValue = soilMoistureRaw;
saveCalibration();
request->send(200, "application/json", "{\"success\":true,\"value\":" + String(soilDryValue) + "}");
});
// API endpoint: Calibrate wet
server.on("/api/calibrate/wet", HTTP_POST, [](AsyncWebServerRequest *request){
soilWetValue = soilMoistureRaw;
saveCalibration();
request->send(200, "application/json", "{\"success\":true,\"value\":" + String(soilWetValue) + "}");
});
// API endpoint: Reset calibration
server.on("/api/calibrate/reset", HTTP_POST, [](AsyncWebServerRequest *request){
soilDryValue = DEFAULT_DRY_VALUE;
soilWetValue = DEFAULT_WET_VALUE;
saveCalibration();
request->send(200, "application/json", "{\"success\":true}");
});
// API endpoint: Toggle display
server.on("/api/display/toggle", HTTP_POST, [](AsyncWebServerRequest *request){
displayEnabled = !displayEnabled;
if (!displayEnabled) {
display.clearDisplay();
display.display();
}
request->send(200, "application/json", "{\"success\":true,\"enabled\":" + String(displayEnabled ? "true" : "false") + "}");
});
// API endpoint: Reset WiFi settings (careful!)
server.on("/api/wifi/reset", HTTP_POST, [](AsyncWebServerRequest *request){
request->send(200, "application/json", "{\"success\":true,\"message\":\"WiFi reset, rebooting...\"}");
delay(1000);
wifiManager.resetSettings();
ESP.restart();
});
server.begin();
Serial.println("Web server started");
}
void loadCalibration() {
soilDryValue = preferences.getInt("soilDry", DEFAULT_DRY_VALUE);
soilWetValue = preferences.getInt("soilWet", DEFAULT_WET_VALUE);
Serial.printf("Loaded calibration - Dry: %d, Wet: %d\n", soilDryValue, soilWetValue);
}
void saveCalibration() {
preferences.putInt("soilDry", soilDryValue);
preferences.putInt("soilWet", soilWetValue);
Serial.printf("Saved calibration - Dry: %d, Wet: %d\n", soilDryValue, soilWetValue);
}
void readSensors() {
// Read DHT22
float newTemp = dht.readTemperature();
float newHum = dht.readHumidity();
if (!isnan(newTemp) && !isnan(newHum)) {
temperature = newTemp;
humidity = newHum;
}
// Read soil moisture
soilMoistureRaw = analogRead(SOIL_SENSOR_PIN);
// Calculate percentage (constrain to prevent negative or >100%)
soilMoisturePercent = map(soilMoistureRaw, soilWetValue, soilDryValue, 100, 0);
soilMoisturePercent = constrain(soilMoisturePercent, 0, 100);
Serial.printf("Temp: %.1f°C, Humidity: %.1f%%, Soil: %d%% (raw: %d)\n",
temperature, humidity, soilMoisturePercent, soilMoistureRaw);
}
void updateDisplay() {
if (!displayEnabled) return;
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0,0);
// Title
display.println("Plant Monitor");
display.drawLine(0, 10, SCREEN_WIDTH, 10, SSD1306_WHITE);
// Sensor data
display.setCursor(0, 15);
display.printf("Temp: %.1fC\n", temperature);
display.printf("Humid: %.0f%%\n", humidity);
display.printf("Soil: %d%%\n", soilMoisturePercent);
display.drawLine(0, 42, SCREEN_WIDTH, 42, SSD1306_WHITE);
// Status
display.setCursor(0, 46);
display.setTextSize(1);
display.print(getStatusMessage());
// WiFi indicator
display.setCursor(0, 56);
if (WiFi.status() == WL_CONNECTED) {
display.print("WiFi: ");
display.print(WiFi.RSSI());
display.print("dBm");
} else {
display.print("WiFi: Disconn");
}
display.display();
}
void updateRGBStatus() {
// Status based on soil moisture
if (soilMoisturePercent < 20) {
setRGBColor(255, 0, 0); // Red - too dry
} else if (soilMoisturePercent < 40) {
setRGBColor(255, 128, 0); // Orange - getting dry
} else if (soilMoisturePercent < 70) {
setRGBColor(0, 255, 0); // Green - optimal
} else if (soilMoisturePercent < 85) {
setRGBColor(0, 128, 255); // Light blue - moist
} else {
setRGBColor(0, 0, 255); // Blue - too wet
}
}
void handleButton() {
bool currentState = digitalRead(BUTTON_PIN) == LOW;
// Button press detected
if (currentState && !buttonPressed) {
buttonPressed = true;
buttonPressTime = millis();
}
// Button released
if (!currentState && buttonPressed) {
buttonPressed = false;
unsigned long pressDuration = millis() - buttonPressTime;
if (pressDuration >= LONG_PRESS_TIME) {
// Long press - start WiFi config
Serial.println("Long press detected - Starting WiFi config portal");
display.clearDisplay();
display.setCursor(0,0);
display.println("WiFi Config Mode");
display.println("\nConnect to:");
display.println("PlantMonitor-XXX");
display.display();
setRGBColor(128, 0, 128); // Purple for config mode
wifiManager.startConfigPortal("PlantMonitor-Config", "plantpass");
Serial.println("Config portal exited");
ESP.restart(); // Restart after config
} else if (pressDuration >= SHORT_PRESS_TIME) {
// Short press - toggle display
displayEnabled = !displayEnabled;
Serial.printf("Display toggled: %s\n", displayEnabled ? "ON" : "OFF");
if (!displayEnabled) {
display.clearDisplay();
display.display();
}
}
}
}
void setRGBColor(int r, int g, int b) {
// Note: Common cathode RGB LED (low = on)
// For common anode, invert the values: 255-r, 255-g, 255-b
analogWrite(RGB_RED_PIN, r);
analogWrite(RGB_GREEN_PIN, g);
analogWrite(RGB_BLUE_PIN, b);
}
String getStatusMessage() {
if (soilMoisturePercent < 20) {
return "Too Dry!";
} else if (soilMoisturePercent < 40) {
return "Needs Water";
} else if (soilMoisturePercent < 70) {
return "Optimal";
} else if (soilMoisturePercent < 85) {
return "Moist";
} else {
return "Too Wet!";
}
}
String getIndexHTML() {
return R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plant Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.card {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.sensor-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.sensor-box {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.sensor-label {
font-size: 0.9em;
color: #666;
margin-bottom: 10px;
}
.sensor-value {
font-size: 2em;
font-weight: bold;
color: #333;
}
.sensor-unit {
font-size: 0.8em;
color: #999;
}
.status-box {
text-align: center;
padding: 20px;
border-radius: 10px;
font-size: 1.5em;
font-weight: bold;
margin: 20px 0;
}
.status-optimal { background: #d4edda; color: #155724; }
.status-warning { background: #fff3cd; color: #856404; }
.status-danger { background: #f8d7da; color: #721c24; }
.calibration-section {
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #eee;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
button {
flex: 1;
min-width: 150px;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-warning {
background: #ffc107;
color: #333;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.info-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
font-size: 0.9em;
color: #666;
}
.info-label {
font-weight: bold;
}
.moisture-bar {
width: 100%;
height: 30px;
background: #e9ecef;
border-radius: 15px;
overflow: hidden;
margin: 10px 0;
}
.moisture-fill {
height: 100%;
transition: width 0.5s ease, background 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
@media (max-width: 600px) {
.sensor-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 1.8em;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌱 Plant Monitor</h1>
<p>Real-time environmental monitoring</p>
</div>
<div class="card">
<h2>Sensor Readings</h2>
<div class="sensor-grid">
<div class="sensor-box">
<div class="sensor-label">Temperature</div>
<div class="sensor-value" id="temperature">--</div>
<div class="sensor-unit">°C</div>
</div>
<div class="sensor-box">
<div class="sensor-label">Humidity</div>
<div class="sensor-value" id="humidity">--</div>
<div class="sensor-unit">%</div>
</div>
<div class="sensor-box">
<div class="sensor-label">Soil Moisture</div>
<div class="sensor-value" id="soilMoisture">--</div>
<div class="sensor-unit">%</div>
</div>
</div>
<div class="moisture-bar">
<div class="moisture-fill" id="moistureBar" style="width: 0%;">0%</div>
</div>
<div class="status-box" id="statusBox">
Waiting for data...
</div>
</div>
<div class="card">
<h2>Calibration</h2>
<p>Calibrate the soil moisture sensor for accurate readings.</p>
<div class="info-grid" style="margin: 15px 0;">
<span class="info-label">Current Raw Value:</span>
<span id="rawValue">--</span>
<span class="info-label">Dry Value (air):</span>
<span id="dryValue">--</span>
<span class="info-label">Wet Value (water):</span>
<span id="wetValue">--</span>
</div>
<div class="calibration-section">
<h3>Calibration Steps:</h3>
<ol style="margin: 15px 0; padding-left: 20px; line-height: 1.8;">
<li>Remove sensor from soil and place in open air</li>
<li>Click "Calibrate Dry" button</li>
<li>Place sensor in a glass of water</li>
<li>Click "Calibrate Wet" button</li>
</ol>
<div class="button-group">
<button class="btn-success" onclick="calibrateDry()">Calibrate Dry</button>
<button class="btn-primary" onclick="calibrateWet()">Calibrate Wet</button>
<button class="btn-warning" onclick="resetCalibration()">Reset to Defaults</button>
</div>
</div>
</div>
<div class="card">
<h2>System Controls</h2>
<div class="button-group">
<button class="btn-primary" onclick="toggleDisplay()">Toggle Display</button>
<button class="btn-danger" onclick="resetWiFi()">Reset WiFi Settings</button>
</div>
</div>
<div class="card">
<h2>System Information</h2>
<div class="info-grid">
<span class="info-label">WiFi SSID:</span>
<span id="wifiSSID">--</span>
<span class="info-label">Signal Strength:</span>
<span id="wifiRSSI">--</span>
<span class="info-label">IP Address:</span>
<span id="ipAddress">--</span>
<span class="info-label">Last Update:</span>
<span id="lastUpdate">--</span>
</div>
</div>
</div>
<script>
let eventSource;
function updateUI(data) {
document.getElementById('temperature').textContent = data.temperature.toFixed(1);
document.getElementById('humidity').textContent = data.humidity.toFixed(0);
document.getElementById('soilMoisture').textContent = data.soilMoisture;
document.getElementById('rawValue').textContent = data.soilMoistureRaw;
// Update moisture bar
const bar = document.getElementById('moistureBar');
bar.style.width = data.soilMoisture + '%';
bar.textContent = data.soilMoisture + '%';
// Color based on moisture level
if (data.soilMoisture < 20) {
bar.style.background = '#dc3545';
} else if (data.soilMoisture < 40) {
bar.style.background = '#ffc107';
} else if (data.soilMoisture < 70) {
bar.style.background = '#28a745';
} else if (data.soilMoisture < 85) {
bar.style.background = '#17a2b8';
} else {
bar.style.background = '#007bff';
}
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
}
function updateSystemInfo(data) {
if (data.wifi) {
document.getElementById('wifiSSID').textContent = data.wifi.ssid;
document.getElementById('wifiRSSI').textContent = data.wifi.rssi + ' dBm';
document.getElementById('ipAddress').textContent = data.wifi.ip;
}
if (data.calibration) {
document.getElementById('dryValue').textContent = data.calibration.dry;
document.getElementById('wetValue').textContent = data.calibration.wet;
}
if (data.status) {
const statusBox = document.getElementById('statusBox');
statusBox.textContent = data.status;
// Update status box class
statusBox.className = 'status-box';
if (data.soilMoisture < 30 || data.soilMoisture > 80) {
statusBox.classList.add('status-danger');
} else if (data.soilMoisture < 45 || data.soilMoisture > 70) {
statusBox.classList.add('status-warning');
} else {
statusBox.classList.add('status-optimal');
}
}
}
function connectEventSource() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/events');
eventSource.addEventListener('sensor-data', function(e) {
const data = JSON.parse(e.data);
updateUI(data);
});
eventSource.onerror = function(e) {
console.error('EventSource error:', e);
eventSource.close();
setTimeout(connectEventSource, 5000);
};
}
async function loadSystemInfo() {
try {
const response = await fetch('/api/data');
const data = await response.json();
updateUI(data);
updateSystemInfo(data);
} catch (error) {
console.error('Error loading system info:', error);
}
}
async function calibrateDry() {
try {
const response = await fetch('/api/calibrate/dry', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Dry calibration saved: ' + data.value);
loadSystemInfo();
}
} catch (error) {
alert('Error: ' + error);
}
}
async function calibrateWet() {
try {
const response = await fetch('/api/calibrate/wet', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Wet calibration saved: ' + data.value);
loadSystemInfo();
}
} catch (error) {
alert('Error: ' + error);
}
}
async function resetCalibration() {
if (!confirm('Reset calibration to default values?')) return;
try {
const response = await fetch('/api/calibrate/reset', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Calibration reset to defaults');
loadSystemInfo();
}
} catch (error) {
alert('Error: ' + error);
}
}
async function toggleDisplay() {
try {
const response = await fetch('/api/display/toggle', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Display ' + (data.enabled ? 'enabled' : 'disabled'));
}
} catch (error) {
alert('Error: ' + error);
}
}
async function resetWiFi() {
if (!confirm('Reset WiFi settings? Device will reboot and start config portal.')) return;
try {
await fetch('/api/wifi/reset', { method: 'POST' });
alert('WiFi reset. Device rebooting...');
} catch (error) {
// Expected - device is rebooting
}
}
// Initialize
loadSystemInfo();
connectEventSource();
// Reload system info every 30 seconds as backup
setInterval(loadSystemInfo, 30000);
</script>
</body>
</html>
)rawliteral";
}