🔗Goal
Build a fully automated greenhouse controller that monitors soil moisture, air temperature, humidity, and light levels, then automatically waters plants, turns on a grow light, and runs a ventilation fan as needed. An OLED display shows live readings, and MQTT publishes all sensor data to a remote dashboard while also accepting override commands.
This is a closed-loop control system: sensors drive actuators without human intervention. You set the thresholds, and the ESP32 handles the rest.
Here is how the system works at a high level:
graph LR
subgraph Sensors
A1[Soil Moisture 1]
A2[Soil Moisture 2]
A3[DHT22]
A4[BH1750]
end
subgraph ESP32
B[ESP32 Controller]
end
subgraph Actuators
C1[Water Pump]
C2[Grow Light]
C3[Ventilation Fan]
C4[OLED Display]
end
subgraph Network
D[MQTT Broker]
E[Dashboard / Phone]
end
A1 -->|Analog| B
A2 -->|Analog| B
A3 -->|GPIO| B
A4 -->|I2C| B
B -->|Relay 1| C1
B -->|Relay 2| C2
B -->|Relay 3| C3
B -->|I2C| C4
B -->|WiFi| D
D --> E
E -->|Commands| D
D -->|Override| BThe control logic follows a simple principle: each sensor reading is compared against a configurable threshold. When a reading crosses the threshold, the corresponding relay switches on or off. MQTT override commands can temporarily force any actuator on or off regardless of sensor readings.
🔗Parts List
| Component | Qty | Notes |
|---|---|---|
| ESP32 dev board | 1 | Any ESP32-WROOM-32 DevKit works |
| Capacitive soil moisture sensor v1.2 | 2 | Capacitive type lasts longer than resistive |
| DHT22 temperature/humidity sensor | 1 | Also sold as AM2302 |
| BH1750 light sensor module | 1 | I2C digital lux sensor |
| 4-channel relay module | 1 | 5V relay with optocoupler isolation (3 channels used) |
| SSD1306 OLED display | 1 | 0.96 inch, 128x64 pixels, I2C |
| 5V mini water pump | 1 | Submersible, with tubing |
| 12V or 5V grow light strip | 1 | LED strip or panel (switched via relay) |
| 5V or 12V DC fan | 1 | Small brushless fan for ventilation |
| 10k ohm resistor | 1 | Pull-up for DHT22 data line |
| External 5V power supply | 1 | To power the relay module, pump, and fan separately |
| Breadboard | 1 | For prototyping connections |
| Jumper wires | ~25 | Male-to-male and male-to-female |
You will also need the following Arduino libraries. Install them via Sketch > Include Library > Manage Libraries:
| Library | Author | Purpose |
|---|---|---|
| DHT sensor library | Adafruit | DHT22 sensor driver |
| Adafruit Unified Sensor | Adafruit | Dependency for DHT library |
| BH1750 | Christopher Laws | BH1750 light sensor driver |
| Adafruit SSD1306 | Adafruit | OLED display driver |
| Adafruit GFX Library | Adafruit | Graphics primitives for OLED |
| PubSubClient | Nick O'Leary | MQTT client |
| WiFi | Espressif | Built into ESP32 Arduino core |
See our DHT22 guide for details on the temperature sensor and our BH1750 guide for the light sensor. Our OLED SSD1306 guide covers the display in depth.
🔗System Architecture
The controller runs a continuous loop with four phases:
graph TD
A[Read all sensors] --> B[Evaluate thresholds]
B --> C[Activate / deactivate relays]
C --> D[Update OLED display]
D --> E[Publish data via MQTT]
E --> F[Check for MQTT override commands]
F --> G[Wait 2 seconds]
G --> A🔗Control Logic
Each actuator has a simple on/off rule based on sensor thresholds:
| Actuator | Condition to turn ON | Condition to turn OFF |
|---|---|---|
| Water pump | Either soil sensor reads above DRY_THRESHOLD (soil is dry) | Both soil sensors read below DRY_THRESHOLD |
| Grow light | Light level is below LOW_LIGHT_LUX | Light level is above LOW_LIGHT_LUX |
| Fan | Temperature is above HIGH_TEMP_C OR humidity is above HIGH_HUMIDITY_PCT | Temperature and humidity are both below their thresholds |
MQTT override commands can force any actuator on or off, bypassing the automatic logic until the override is cleared.
🔗Wiring
🔗Soil Moisture Sensors
The two capacitive soil moisture sensors connect to ADC1 pins. Do not use ADC2 pins (GPIO 0, 2, 4, 12-15, 25-27) because ADC2 is unavailable when WiFi is active.
| Component | Pin | ESP32 Pin |
|---|---|---|
| Soil sensor 1 | VCC | 3.3V |
| Soil sensor 1 | GND | GND |
| Soil sensor 1 | AOUT | GPIO 34 |
| Soil sensor 2 | VCC | 3.3V |
| Soil sensor 2 | GND | GND |
| Soil sensor 2 | AOUT | GPIO 35 |
GPIO 34 and 35 are input-only pins with ADC1 support, making them ideal for analog sensors when WiFi is in use.
🔗DHT22 Temperature and Humidity Sensor
| Component | Pin | ESP32 Pin |
|---|---|---|
| DHT22 | Pin 1 (VCC) | 3.3V |
| DHT22 | Pin 2 (DATA) | GPIO 4 |
| DHT22 | Pin 3 (NC) | Not connected |
| DHT22 | Pin 4 (GND) | GND |
| 10k ohm resistor | Between DATA and VCC | GPIO 4 to 3.3V |
🔗BH1750 Light Sensor and SSD1306 OLED (I2C Bus)
Both devices share the same I2C bus. The BH1750 defaults to address 0x23 and the SSD1306 defaults to 0x3C, so there is no conflict.
| Component | Pin | ESP32 Pin |
|---|---|---|
| BH1750 | VCC | 3.3V |
| BH1750 | GND | GND |
| BH1750 | SDA | GPIO 21 |
| BH1750 | SCL | GPIO 22 |
| SSD1306 OLED | VCC | 3.3V |
| SSD1306 OLED | GND | GND |
| SSD1306 OLED | SDA | GPIO 21 |
| SSD1306 OLED | SCL | GPIO 22 |
🔗4-Channel Relay Module
The relay module controls the water pump, grow light, and fan. Relays are typically active-LOW: the relay turns ON when the ESP32 drives the input pin LOW.
| Component | Pin | ESP32 Pin |
|---|---|---|
| Relay module | VCC | 5V (from external supply) |
| Relay module | GND | GND (shared with ESP32 GND) |
| Relay module | IN1 (pump) | GPIO 16 |
| Relay module | IN2 (grow light) | GPIO 17 |
| Relay module | IN3 (fan) | GPIO 18 |
Important: Power the relay module from an external 5V supply, not from the ESP32's 5V pin. The relay coils draw too much current for the ESP32's onboard regulator. Make sure the external supply's GND is connected to the ESP32's GND.
Connect each actuator (pump, grow light, fan) to the relay's normally-open (NO) terminal and the common (COM) terminal. When the relay activates, NO connects to COM, completing the circuit to the actuator's own power supply.
🔗Power Considerations
The ESP32 cannot power the pump, fan, and grow light directly. Use a separate power supply for the actuators:
$$I_{total} = I_{pump} + I_{light} + I_{fan}$$
A typical small pump draws about $200\,\text{mA}$ at $5\,\text{V}$, a fan about $150\,\text{mA}$, and an LED grow strip varies widely. Choose a power supply rated for at least $2\,\text{A}$ at $5\,\text{V}$ to have plenty of headroom.
🔗Complete Code
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <Wire.h>
#include <BH1750.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
// ==================== CONFIGURATION ====================
// WiFi
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
// MQTT
const char* MQTT_BROKER = "test.mosquitto.org";
const int MQTT_PORT = 1883;
const char* MQTT_CLIENT_ID = "esp32_greenhouse";
const char* MQTT_PUB_TOPIC = "greenhouse/sensors";
const char* MQTT_CMD_TOPIC = "greenhouse/command";
const char* MQTT_STATUS_TOPIC = "greenhouse/status";
// Sensor pins
#define SOIL_PIN_1 34
#define SOIL_PIN_2 35
#define DHT_PIN 4
#define DHT_TYPE DHT22
// Relay pins (active-LOW)
#define RELAY_PUMP 16
#define RELAY_LIGHT 17
#define RELAY_FAN 18
// OLED
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDRESS 0x3C
// ---- Thresholds (adjust for your environment) ----
#define DRY_THRESHOLD 3000 // ADC reading above this = dry soil
#define LOW_LIGHT_LUX 200.0 // Lux below this = too dark
#define HIGH_TEMP_C 30.0 // Celsius above this = too hot
#define HIGH_HUMIDITY_PCT 80.0 // Humidity above this = too humid
// Timing
#define SENSOR_INTERVAL 2000 // Read sensors every 2 seconds
#define PUBLISH_INTERVAL 30000 // Publish MQTT every 30 seconds
// ==================== OBJECTS ====================
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
DHT dht(DHT_PIN, DHT_TYPE);
BH1750 lightMeter;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// ==================== STATE ====================
float temperature = 0;
float humidity = 0;
float lux = 0;
int soil1 = 0;
int soil2 = 0;
bool pumpOn = false;
bool lightOn = false;
bool fanOn = false;
// Override: -1 = auto, 0 = force off, 1 = force on
int overridePump = -1;
int overrideLight = -1;
int overrideFan = -1;
unsigned long lastSensorRead = 0;
unsigned long lastPublish = 0;
// ==================== WIFI ====================
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
Serial.print("Connecting to WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println();
Serial.println("WiFi connection failed. Will retry...");
}
}
// ==================== MQTT ====================
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String message;
for (unsigned int i = 0; i < length; i++) {
message += (char)payload[i];
}
Serial.print("MQTT received [");
Serial.print(topic);
Serial.print("]: ");
Serial.println(message);
// Parse override commands
// Format: "pump:on", "pump:off", "pump:auto",
// "light:on", "light:off", "light:auto",
// "fan:on", "fan:off", "fan:auto",
// "status" (request current state)
if (message == "pump:on") overridePump = 1;
if (message == "pump:off") overridePump = 0;
if (message == "pump:auto") overridePump = -1;
if (message == "light:on") overrideLight = 1;
if (message == "light:off") overrideLight = 0;
if (message == "light:auto") overrideLight = -1;
if (message == "fan:on") overrideFan = 1;
if (message == "fan:off") overrideFan = 0;
if (message == "fan:auto") overrideFan = -1;
if (message == "status") {
publishStatus();
}
}
void connectMQTT() {
if (mqtt.connected()) return;
Serial.print("Connecting to MQTT...");
while (!mqtt.connected()) {
if (mqtt.connect(MQTT_CLIENT_ID)) {
Serial.println(" connected!");
mqtt.subscribe(MQTT_CMD_TOPIC);
Serial.print("Subscribed to: ");
Serial.println(MQTT_CMD_TOPIC);
} else {
Serial.print(" failed (rc=");
Serial.print(mqtt.state());
Serial.println("). Retrying in 5 seconds...");
delay(5000);
}
}
}
void publishSensorData() {
String payload = "{";
payload += "\"temperature\":" + String(temperature, 1) + ",";
payload += "\"humidity\":" + String(humidity, 1) + ",";
payload += "\"lux\":" + String(lux, 1) + ",";
payload += "\"soil1\":" + String(soil1) + ",";
payload += "\"soil2\":" + String(soil2) + ",";
payload += "\"pump\":" + String(pumpOn ? "true" : "false") + ",";
payload += "\"light\":" + String(lightOn ? "true" : "false") + ",";
payload += "\"fan\":" + String(fanOn ? "true" : "false");
payload += "}";
if (mqtt.publish(MQTT_PUB_TOPIC, payload.c_str())) {
Serial.print("Published: ");
Serial.println(payload);
} else {
Serial.println("ERROR: MQTT publish failed.");
}
}
void publishStatus() {
String status = "{";
status += "\"pump_override\":\"" + String(overridePump == -1 ? "auto" : (overridePump ? "on" : "off")) + "\",";
status += "\"light_override\":\"" + String(overrideLight == -1 ? "auto" : (overrideLight ? "on" : "off")) + "\",";
status += "\"fan_override\":\"" + String(overrideFan == -1 ? "auto" : (overrideFan ? "on" : "off")) + "\"";
status += "}";
mqtt.publish(MQTT_STATUS_TOPIC, status.c_str());
}
// ==================== SENSORS ====================
void readSensors() {
soil1 = analogRead(SOIL_PIN_1);
soil2 = analogRead(SOIL_PIN_2);
float t = dht.readTemperature();
float h = dht.readHumidity();
if (!isnan(t)) temperature = t;
if (!isnan(h)) humidity = h;
lux = lightMeter.readLightLevel();
Serial.print("Soil1: "); Serial.print(soil1);
Serial.print(" | Soil2: "); Serial.print(soil2);
Serial.print(" | Temp: "); Serial.print(temperature, 1);
Serial.print("C | Hum: "); Serial.print(humidity, 1);
Serial.print("% | Lux: "); Serial.println(lux, 1);
}
// ==================== CONTROL LOGIC ====================
void setRelay(int pin, bool on) {
// Active-LOW relay: LOW turns relay ON, HIGH turns relay OFF
digitalWrite(pin, on ? LOW : HIGH);
}
void evaluateControl() {
// --- Water pump ---
if (overridePump == 1) {
pumpOn = true;
} else if (overridePump == 0) {
pumpOn = false;
} else {
// Auto: turn on if either soil sensor reads dry
pumpOn = (soil1 > DRY_THRESHOLD) || (soil2 > DRY_THRESHOLD);
}
// --- Grow light ---
if (overrideLight == 1) {
lightOn = true;
} else if (overrideLight == 0) {
lightOn = false;
} else {
lightOn = (lux < LOW_LIGHT_LUX);
}
// --- Fan ---
if (overrideFan == 1) {
fanOn = true;
} else if (overrideFan == 0) {
fanOn = false;
} else {
fanOn = (temperature > HIGH_TEMP_C) || (humidity > HIGH_HUMIDITY_PCT);
}
setRelay(RELAY_PUMP, pumpOn);
setRelay(RELAY_LIGHT, lightOn);
setRelay(RELAY_FAN, fanOn);
}
// ==================== DISPLAY ====================
void updateDisplay() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// Row 1: Temperature and humidity
display.setTextSize(1);
display.setCursor(0, 0);
display.print("Temp: ");
display.print(temperature, 1);
display.print((char)247); // Degree symbol
display.print("C");
display.setCursor(0, 10);
display.print("Hum: ");
display.print(humidity, 1);
display.print("%");
// Row 2: Light
display.setCursor(0, 20);
display.print("Light: ");
display.print(lux, 0);
display.print(" lux");
// Row 3: Soil sensors
display.setCursor(0, 30);
display.print("Soil1: ");
display.print(soil1);
display.setCursor(70, 30);
display.print("S2: ");
display.print(soil2);
// Row 4: Actuator status
display.setCursor(0, 42);
display.print("Pump:");
display.print(pumpOn ? "ON " : "OFF");
display.setCursor(48, 42);
display.print("Lt:");
display.print(lightOn ? "ON " : "OFF");
display.setCursor(88, 42);
display.print("Fn:");
display.print(fanOn ? "ON" : "OF");
// Row 5: Override indicators
display.setCursor(0, 54);
if (overridePump != -1 || overrideLight != -1 || overrideFan != -1) {
display.print("OVERRIDE ACTIVE");
} else {
display.print("Mode: AUTO");
}
display.display();
}
// ==================== SETUP ====================
void setup() {
Serial.begin(115200);
// Relay pins as outputs (start HIGH = relays OFF for active-LOW)
pinMode(RELAY_PUMP, OUTPUT);
pinMode(RELAY_LIGHT, OUTPUT);
pinMode(RELAY_FAN, OUTPUT);
digitalWrite(RELAY_PUMP, HIGH);
digitalWrite(RELAY_LIGHT, HIGH);
digitalWrite(RELAY_FAN, HIGH);
// Initialize I2C
Wire.begin();
// Initialize OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
Serial.println("ERROR: SSD1306 OLED not found.");
while (true) { delay(1000); }
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Greenhouse");
display.println("Controller");
display.println("Starting...");
display.display();
Serial.println("OLED initialized.");
// Initialize BH1750
if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
Serial.println("BH1750 initialized.");
} else {
Serial.println("ERROR: BH1750 not found. Check wiring.");
}
// Initialize DHT22
dht.begin();
Serial.println("DHT22 initialized.");
// Connect WiFi and MQTT
connectWiFi();
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
connectMQTT();
Serial.println("Greenhouse controller ready.");
}
// ==================== MAIN LOOP ====================
void loop() {
// Maintain connections
connectWiFi();
if (!mqtt.connected()) {
connectMQTT();
}
mqtt.loop();
unsigned long now = millis();
// Read sensors and update control every SENSOR_INTERVAL
if (now - lastSensorRead >= SENSOR_INTERVAL) {
lastSensorRead = now;
readSensors();
evaluateControl();
updateDisplay();
}
// Publish MQTT data every PUBLISH_INTERVAL
if (now - lastPublish >= PUBLISH_INTERVAL) {
lastPublish = now;
publishSensorData();
}
}🔗Testing and Calibration
🔗Step 1: Calibrate Soil Moisture Sensors
Before deploying in a greenhouse, calibrate each soil sensor individually:
- Open the Serial Monitor at 115200 baud
- Hold the sensor in dry air and note the reading (typically 3500-4095)
- Submerge the sensor in a glass of water up to the line marked on the PCB (typically 1000-1500)
- Insert the sensor into moist potting soil and note the reading
- Adjust
DRY_THRESHOLDto match the point where the soil feels dry to the touch
The ADC reading maps to voltage:
$$V_{sensor} = \frac{\text{ADC reading}}{4095} \times 3.3\,\text{V}$$
🔗Step 2: Verify Relay Operation
Test each relay independently before connecting actuators:
- Send
pump:onvia MQTT (or temporarily setoverridePump = 1in code) - Listen for the relay click and check that the relay LED indicator turns on
- Repeat for
light:onandfan:on - Send
pump:auto,light:auto,fan:autoto return to automatic control
You can test with mosquitto_pub from any computer:
# Turn on the pump manually
mosquitto_pub -h test.mosquitto.org -t "greenhouse/command" -m "pump:on"
# Return pump to automatic control
mosquitto_pub -h test.mosquitto.org -t "greenhouse/command" -m "pump:auto"
# Request status
mosquitto_pub -h test.mosquitto.org -t "greenhouse/command" -m "status"Subscribe to see the sensor data and status responses:
# Watch sensor data
mosquitto_sub -h test.mosquitto.org -t "greenhouse/sensors"
# Watch status responses
mosquitto_sub -h test.mosquitto.org -t "greenhouse/status"🔗Step 3: Verify Sensor Readings
Compare sensor readings against known references:
- Temperature: Compare DHT22 reading to a household thermometer. The DHT22 is accurate to $\pm 0.5\,°\text{C}$
- Humidity: Compare to a known hygrometer. Accuracy is $\pm 2\,\%\,\text{RH}$
- Light: Compare BH1750 reading to a phone lux meter app. Direct sunlight is around $50{,}000\,\text{lux}$, a well-lit room is $300\text{--}500\,\text{lux}$, and a dim room is below $100\,\text{lux}$
🔗Step 4: Tune Thresholds
The default thresholds are starting points. Adjust them based on your specific greenhouse and plants:
| Threshold | Default | Adjust if... |
|---|---|---|
DRY_THRESHOLD | 3000 | Plants wilt before pump activates (lower the value) or soil stays too wet (raise the value) |
LOW_LIGHT_LUX | 200 | Grow light turns on too often (lower) or plants are not getting enough light (raise) |
HIGH_TEMP_C | 30.0 | Fan runs too often (raise) or greenhouse overheats (lower) |
HIGH_HUMIDITY_PCT | 80.0 | Fan runs constantly (raise) or mold appears (lower) |
🔗MQTT Topics Reference
| Topic | Direction | Description |
|---|---|---|
greenhouse/sensors | ESP32 publishes | JSON with all sensor readings and actuator states |
greenhouse/command | ESP32 subscribes | Override commands (e.g., pump:on, fan:auto) |
greenhouse/status | ESP32 publishes | Override status response (when status command is received) |
🔗Example JSON Payload
{
"temperature": 25.3,
"humidity": 62.5,
"lux": 450.0,
"soil1": 2800,
"soil2": 3100,
"pump": true,
"light": false,
"fan": false
}In this example, soil sensor 2 reads above the dry threshold (3100 > 3000), so the pump is active. Light is adequate (450 lux > 200 lux threshold), so the grow light is off. Temperature and humidity are within range, so the fan is off.
🔗Common Issues and Solutions
| Problem | Cause | Fix |
|---|---|---|
| Soil sensor reads 0 or 4095 constantly | Wrong pin or sensor not connected | Verify wiring. Use only ADC1 pins (GPIO 32-39) when WiFi is active |
| Soil readings fluctuate wildly | Electrical noise from pump or relays | Add a 100nF capacitor between sensor VCC and GND. Route sensor wires away from relay module |
| DHT22 returns NaN | Timing issue or missing pull-up | Ensure 10k pull-up resistor between DATA and VCC. DHT22 needs at least 2 seconds between reads |
| BH1750 not detected | I2C address wrong or wiring issue | Check that ADDR pin is unconnected (address 0x23) or connected to VCC (address 0x5C). Run an I2C scanner |
| Relay clicks but actuator does not turn on | Wiring to wrong terminal | Connect actuator between COM (common) and NO (normally open) terminals, not NC |
| All relays activate on boot | GPIO pins float during startup | The code sets relay pins HIGH (off) in setup(). If relays still trigger briefly, add 10k pull-up resistors to the relay input pins |
| OLED displays garbled text | I2C bus interference from long wires | Keep I2C wires short (under 30 cm). Add 4.7k pull-up resistors on SDA and SCL if using long wires |
| MQTT publishes but no data arrives | Topic mismatch | Topic names are case-sensitive. Copy them exactly from the code |
| Pump runs continuously | Threshold set too low | Increase DRY_THRESHOLD. Calibrate sensors as described above |
| WiFi drops frequently | ESP32 too far from router or interference from relays | Move closer to the router. Add a ferrite bead on the relay module power cable |
🔗Extending the Project
Here are some ideas to extend the greenhouse controller:
- Water level sensor: Add an ultrasonic sensor (HC-SR04) to the water reservoir to detect when it needs refilling
- Data logging: Store readings to an SD card or push to InfluxDB for historical graphing
- Multiple zones: Use more soil sensors and relays to control watering for different plant beds independently
- Web dashboard: Add an ESP32 web server to control and monitor the greenhouse from a browser without needing an MQTT broker
- Timed grow light schedule: Instead of lux-based control, add a time-based schedule using NTP so the grow light runs for a fixed number of hours per day
- Email or Telegram alerts: Send a notification when the water reservoir is low, a sensor fails, or temperature exceeds a critical limit