Automated Greenhouse Controller Advanced

Build a closed-loop greenhouse system with multiple sensors and actuators controlled by ESP32

🔗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| B

The 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

ComponentQtyNotes
ESP32 dev board1Any ESP32-WROOM-32 DevKit works
Capacitive soil moisture sensor v1.22Capacitive type lasts longer than resistive
DHT22 temperature/humidity sensor1Also sold as AM2302
BH1750 light sensor module1I2C digital lux sensor
4-channel relay module15V relay with optocoupler isolation (3 channels used)
SSD1306 OLED display10.96 inch, 128x64 pixels, I2C
5V mini water pump1Submersible, with tubing
12V or 5V grow light strip1LED strip or panel (switched via relay)
5V or 12V DC fan1Small brushless fan for ventilation
10k ohm resistor1Pull-up for DHT22 data line
External 5V power supply1To power the relay module, pump, and fan separately
Breadboard1For prototyping connections
Jumper wires~25Male-to-male and male-to-female

You will also need the following Arduino libraries. Install them via Sketch > Include Library > Manage Libraries:

LibraryAuthorPurpose
DHT sensor libraryAdafruitDHT22 sensor driver
Adafruit Unified SensorAdafruitDependency for DHT library
BH1750Christopher LawsBH1750 light sensor driver
Adafruit SSD1306AdafruitOLED display driver
Adafruit GFX LibraryAdafruitGraphics primitives for OLED
PubSubClientNick O'LearyMQTT client
WiFiEspressifBuilt 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:

ActuatorCondition to turn ONCondition to turn OFF
Water pumpEither soil sensor reads above DRY_THRESHOLD (soil is dry)Both soil sensors read below DRY_THRESHOLD
Grow lightLight level is below LOW_LIGHT_LUXLight level is above LOW_LIGHT_LUX
FanTemperature is above HIGH_TEMP_C OR humidity is above HIGH_HUMIDITY_PCTTemperature 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.

ComponentPinESP32 Pin
Soil sensor 1VCC3.3V
Soil sensor 1GNDGND
Soil sensor 1AOUTGPIO 34
Soil sensor 2VCC3.3V
Soil sensor 2GNDGND
Soil sensor 2AOUTGPIO 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

ComponentPinESP32 Pin
DHT22Pin 1 (VCC)3.3V
DHT22Pin 2 (DATA)GPIO 4
DHT22Pin 3 (NC)Not connected
DHT22Pin 4 (GND)GND
10k ohm resistorBetween DATA and VCCGPIO 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.

ComponentPinESP32 Pin
BH1750VCC3.3V
BH1750GNDGND
BH1750SDAGPIO 21
BH1750SCLGPIO 22
SSD1306 OLEDVCC3.3V
SSD1306 OLEDGNDGND
SSD1306 OLEDSDAGPIO 21
SSD1306 OLEDSCLGPIO 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.

ComponentPinESP32 Pin
Relay moduleVCC5V (from external supply)
Relay moduleGNDGND (shared with ESP32 GND)
Relay moduleIN1 (pump)GPIO 16
Relay moduleIN2 (grow light)GPIO 17
Relay moduleIN3 (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:

  1. Open the Serial Monitor at 115200 baud
  2. Hold the sensor in dry air and note the reading (typically 3500-4095)
  3. Submerge the sensor in a glass of water up to the line marked on the PCB (typically 1000-1500)
  4. Insert the sensor into moist potting soil and note the reading
  5. Adjust DRY_THRESHOLD to 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:

  1. Send pump:on via MQTT (or temporarily set overridePump = 1 in code)
  2. Listen for the relay click and check that the relay LED indicator turns on
  3. Repeat for light:on and fan:on
  4. Send pump:auto, light:auto, fan:auto to 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:

ThresholdDefaultAdjust if...
DRY_THRESHOLD3000Plants wilt before pump activates (lower the value) or soil stays too wet (raise the value)
LOW_LIGHT_LUX200Grow light turns on too often (lower) or plants are not getting enough light (raise)
HIGH_TEMP_C30.0Fan runs too often (raise) or greenhouse overheats (lower)
HIGH_HUMIDITY_PCT80.0Fan runs constantly (raise) or mold appears (lower)

🔗MQTT Topics Reference

TopicDirectionDescription
greenhouse/sensorsESP32 publishesJSON with all sensor readings and actuator states
greenhouse/commandESP32 subscribesOverride commands (e.g., pump:on, fan:auto)
greenhouse/statusESP32 publishesOverride 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

ProblemCauseFix
Soil sensor reads 0 or 4095 constantlyWrong pin or sensor not connectedVerify wiring. Use only ADC1 pins (GPIO 32-39) when WiFi is active
Soil readings fluctuate wildlyElectrical noise from pump or relaysAdd a 100nF capacitor between sensor VCC and GND. Route sensor wires away from relay module
DHT22 returns NaNTiming issue or missing pull-upEnsure 10k pull-up resistor between DATA and VCC. DHT22 needs at least 2 seconds between reads
BH1750 not detectedI2C address wrong or wiring issueCheck that ADDR pin is unconnected (address 0x23) or connected to VCC (address 0x5C). Run an I2C scanner
Relay clicks but actuator does not turn onWiring to wrong terminalConnect actuator between COM (common) and NO (normally open) terminals, not NC
All relays activate on bootGPIO pins float during startupThe 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 textI2C bus interference from long wiresKeep 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 arrivesTopic mismatchTopic names are case-sensitive. Copy them exactly from the code
Pump runs continuouslyThreshold set too lowIncrease DRY_THRESHOLD. Calibrate sensors as described above
WiFi drops frequentlyESP32 too far from router or interference from relaysMove 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