Sharjah, UAE GST
Home/ Projects/ Calibrated Plant-Monitoring Lab
Project 1 of 7 Sensing Intermediate ◎ 2–3 hours

Calibrated Plant-Monitoring Lab

The kit's main build. Soil moisture, water level, temperature, humidity and air pressure stream to your computer every 2 seconds. You calibrate the two analog sensors yourself with four simple captures, and the sketch flags DRY_SOIL or LOW_WATER from your own reference points.

ESP32 Plant Monitoring Learning Kit
One of 7 projects in the ESP32 Plant Monitoring Learning Kit — the parts below are from that kit.

What you build and learn

  • Reading analog sensors with the ESP32's 12-bit ADC
  • Why sensors need two-point calibration before a percentage means anything
  • Reading a BME280 environmental sensor over I2C
  • Controlling a sketch by typing commands into the Serial Monitor
  • Saving settings in the ESP32's flash so they survive a restart

The capacitive soil sensor gives a voltage that changes as the soil around it gets wetter; the water sensor's exposed tracks conduct more the deeper they sit in water. The ESP32 reads each as a number from 0 to 4095, averaging 24 readings to smooth out noise.

Those raw numbers mean nothing on their own, so you teach the sketch four reference points: soil-dry, soil-wet, water-empty and water-full. It needs at least 150 counts between each pair, and saves them in flash so you only do this once.

In monitoring mode it converts each raw reading into a percentage between your two reference points. Soil under 30 % adds DRY_SOIL to the status; water under 20 % adds LOW_WATER. These are teaching defaults, not advice for any particular plant.

The BME280 adds temperature, humidity and pressure. If it fails three reads in a row, the sketch reports a fault instead of printing wrong numbers.

Every reading is printed as one DATA line with name=value pairs, easy to read and easy to import into a spreadsheet.

What this project uses

Every item is in the kit — nothing extra to buy.

Build this with

ESP32 Plant Monitoring Learning Kit

All 6 parts in one box, with this project and 6 more to build from them.

AED 65.00 See the kit
Need only these parts? Order them loose · from AED 88.90
Used in this project tick what you still need
Selected parts AED 88.90

One of each part. Packs (resistor assortment, jumper wires) already hold more than a single build needs. ESP32U: included in the kit, ask us for it loose. Delivery is AED 20, free on orders AED 100+.

Connections

Unplug USB before you change any wire. Pin names are the labels printed on the board.

Part / pinConnect toNote
ESP32 3V3Breadboard + rail3.3 V only — ESP32 inputs must not see 5 V
ESP32 GNDBreadboard − rail
BME280 VIN / VCC+ rail (3V3)
BME280 GND− rail
BME280 SDAGPIO21
BME280 SCLGPIO22
Soil sensor VCC+ rail (3V3)
Soil sensor GND− rail
Soil sensor AOUTGPIO34GPIO34 is input-only — ideal for an analog sensor
Water sensor + / VCC+ rail (3V3)
Water sensor − / GND− rail
Water sensor SGPIO35Keep this end of the board dry

Step by step

  1. In Arduino IDE install the ESP32 boards package (Boards Manager → esp32 by Espressif) and, from Library Manager, Adafruit BME280 and Adafruit Unified Sensor. Select the ESP32 Dev Module board and your USB port.
  2. Plug in the ESP32 alone and upload any example sketch to prove the cable and port work.
  3. With USB unplugged, fit the ESP32 across the breadboard centre gap (or beside it with female-to-male jumpers if it is too wide). Link 3V3 to the + rail and GND to the − rail. Check whether the long rails are split in the middle.
  4. Add the BME280 first: VIN to 3V3, GND, SDA to GPIO21, SCL to GPIO22. Upload the main sketch. Open Tools → Serial Monitor at 115200 baud, with the line ending set to Newline. It should report one BME280 at 0x76 or 0x77.
  5. Unplug USB. Add the soil sensor: VCC to 3V3, GND, AOUT to GPIO34. Plug back in and check the CAL lines show soil_raw changing when you touch the sensor's blade — not stuck at 0 or 4095.
  6. Unplug USB. Add the water sensor last: + to 3V3, − to GND, S to GPIO35. Keep it dry for now.
  7. Calibrate the soil: hold the probe in air (or truly dry soil) and type soil-dry. Then push it into wet soil up to the line on the board and type soil-wet.
  8. Calibrate the water: with the sensor dry type water-empty. Lower only the tracks into the water to a depth you mark, and type water-full. Lift it out, rinse and dry it.
  9. Type status. All four values must be present with each pair at least 150 apart. Type run to start monitoring.
  10. Push the soil probe into a plant pot and watch soil_pct. Water the plant and see it rise; let a test sample dry over a day and see it fall.

Sketch for this project

Written for the board in the kit. Copy it into the Arduino IDE; change the pin numbers if you wire it differently.

ESP32 plant monitoring learning lab (PlantMonitorLearningLab.ino) arduino
#include <Arduino.h>
#include <Wire.h>
#include <Preferences.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// Educational bench prototype. Not a plant-care guarantee or unattended controller.
#ifndef PLANT_DIAGNOSTIC
#define PLANT_DIAGNOSTIC false
#endif

constexpr uint8_t SDA_PIN = 21;
constexpr uint8_t SCL_PIN = 22;
constexpr uint8_t SOIL_PIN = 34;
constexpr uint8_t WATER_PIN = 35;
constexpr uint32_t SAMPLE_INTERVAL_MS = 2000;
constexpr uint8_t ADC_SAMPLES = 24;
constexpr int MIN_CALIBRATION_SPAN = 150;
constexpr int DRY_SOIL_PERCENT = 30;
constexpr int LOW_WATER_PERCENT = 20;

Adafruit_BME280 bme;
Preferences preferences;

enum class Mode { Calibration, Monitoring, Fault };

struct Calibration {
  int soilDry = -1;
  int soilWet = -1;
  int waterEmpty = -1;
  int waterFull = -1;
};

Calibration calibration;
Mode mode = Mode::Fault;
uint8_t bmeAddress = 0;
uint8_t bmeFailures = 0;
uint32_t lastSample = 0;
String commandBuffer;
String faultReason;

bool elapsed(uint32_t now, uint32_t since, uint32_t interval) {
  return uint32_t(now - since) >= interval;
}

bool i2cAck(uint8_t address) {
  Wire.beginTransmission(address);
  return Wire.endTransmission() == 0;
}

int readAverage(uint8_t pin) {
  uint32_t sum = 0;
  for (uint8_t i = 0; i < ADC_SAMPLES; ++i) {
    sum += analogRead(pin);
    delay(2);
  }
  return int((sum + ADC_SAMPLES / 2) / ADC_SAMPLES);
}

bool validEndpoint(int value) {
  return value >= 0 && value <= 4095;
}

bool calibrationValid() {
  return validEndpoint(calibration.soilDry) &&
         validEndpoint(calibration.soilWet) &&
         validEndpoint(calibration.waterEmpty) &&
         validEndpoint(calibration.waterFull) &&
         abs(calibration.soilWet - calibration.soilDry) >= MIN_CALIBRATION_SPAN &&
         abs(calibration.waterFull - calibration.waterEmpty) >= MIN_CALIBRATION_SPAN;
}

int calibratedPercent(int raw, int zeroPoint, int hundredPoint) {
  long span = long(hundredPoint) - long(zeroPoint);
  if (span == 0) return 0;
  long scaled = (long(raw) - long(zeroPoint)) * 100L / span;
  return constrain(int(scaled), 0, 100);
}

void saveCalibration() {
  preferences.putInt("soilDry", calibration.soilDry);
  preferences.putInt("soilWet", calibration.soilWet);
  preferences.putInt("waterEmpty", calibration.waterEmpty);
  preferences.putInt("waterFull", calibration.waterFull);
}

void loadCalibration() {
  calibration.soilDry = preferences.getInt("soilDry", -1);
  calibration.soilWet = preferences.getInt("soilWet", -1);
  calibration.waterEmpty = preferences.getInt("waterEmpty", -1);
  calibration.waterFull = preferences.getInt("waterFull", -1);
}

void printCommands() {
  Serial.println(F("Commands:"));
  Serial.println(F("  soil-dry    capture the soil probe in air or verified dry reference soil"));
  Serial.println(F("  soil-wet    capture the soil probe in verified wet reference soil"));
  Serial.println(F("  water-empty capture the water sensor clean and dry"));
  Serial.println(F("  water-full  capture it at your marked short-duration test depth"));
  Serial.println(F("  run         start monitoring when all four calibration points are valid"));
  Serial.println(F("  calibrate   return to raw-reading mode"));
  Serial.println(F("  status      show addresses, calibration values and mode"));
  Serial.println(F("  reset-cal   erase saved calibration"));
  Serial.println(F("  help        show this list"));
}

const __FlashStringHelper *modeName() {
  if (mode == Mode::Calibration) return F("CALIBRATION");
  if (mode == Mode::Monitoring) return F("MONITORING");
  return F("FAULT");
}

void printStatus() {
  Serial.print(F("Mode="));
  Serial.print(modeName());
  Serial.print(F(" BME=0x"));
  if (bmeAddress < 0x10) Serial.print('0');
  Serial.println(bmeAddress, HEX);
  Serial.printf("Calibration soilDry=%d soilWet=%d waterEmpty=%d waterFull=%d valid=%s\n",
                calibration.soilDry, calibration.soilWet,
                calibration.waterEmpty, calibration.waterFull,
                calibrationValid() ? "yes" : "no");
  if (mode == Mode::Fault) {
    Serial.print(F("Fault="));
    Serial.println(faultReason);
  }
}

void enterFault(const __FlashStringHelper *reason) {
  mode = Mode::Fault;
  faultReason = String(reason);
  Serial.print(F("FAULT: "));
  Serial.println(reason);
}

void capturePoint(const String &command) {
  if (command == "soil-dry") {
    calibration.soilDry = readAverage(SOIL_PIN);
    Serial.printf("Captured soilDry=%d\n", calibration.soilDry);
  } else if (command == "soil-wet") {
    calibration.soilWet = readAverage(SOIL_PIN);
    Serial.printf("Captured soilWet=%d\n", calibration.soilWet);
  } else if (command == "water-empty") {
    calibration.waterEmpty = readAverage(WATER_PIN);
    Serial.printf("Captured waterEmpty=%d\n", calibration.waterEmpty);
  } else if (command == "water-full") {
    calibration.waterFull = readAverage(WATER_PIN);
    Serial.printf("Captured waterFull=%d\n", calibration.waterFull);
  }
  saveCalibration();
  Serial.println(calibrationValid()
                     ? F("Calibration spans are valid. Type run to monitor.")
                     : F("Calibration incomplete or a span is under 150 counts."));
}

void handleCommand(String command) {
  command.trim();
  command.toLowerCase();
  if (command.length() == 0) return;

  if (command == "help") {
    printCommands();
  } else if (command == "status") {
    printStatus();
  } else if (command == "soil-dry" || command == "soil-wet" ||
             command == "water-empty" || command == "water-full") {
    if (mode == Mode::Fault) {
      Serial.println(F("Resolve the hardware fault before calibration."));
    } else {
      mode = Mode::Calibration;
      capturePoint(command);
    }
  } else if (command == "run") {
    if (mode == Mode::Fault) {
      Serial.println(F("Resolve the hardware fault before monitoring."));
    } else if (!calibrationValid()) {
      Serial.println(F("Cannot monitor: capture four valid calibration points first."));
    } else {
      mode = Mode::Monitoring;
      Serial.println(F("Monitoring started. Thresholds are teaching defaults, not plant-specific advice."));
    }
  } else if (command == "calibrate") {
    if (mode != Mode::Fault) {
      mode = Mode::Calibration;
      Serial.println(F("Calibration mode: raw values will be printed."));
    }
  } else if (command == "reset-cal") {
    preferences.clear();
    calibration = Calibration{};
    if (mode != Mode::Fault) mode = Mode::Calibration;
    Serial.println(F("Saved calibration erased."));
  } else {
    Serial.println(F("Unknown command. Type help."));
  }
}

void readSerialCommands() {
  while (Serial.available()) {
    char c = char(Serial.read());
    if (c == '\r') continue;
    if (c == '\n') {
      handleCommand(commandBuffer);
      commandBuffer = "";
    } else if (commandBuffer.length() < 63) {
      commandBuffer += c;
    }
  }
}

void sampleAndReport() {
  int soilRaw = readAverage(SOIL_PIN);
  int waterRaw = readAverage(WATER_PIN);
  float temperature = bme.readTemperature();
  float humidity = bme.readHumidity();
  float pressure = bme.readPressure() / 100.0f;

  if (isnan(temperature) || isnan(humidity) || isnan(pressure)) {
    if (++bmeFailures >= 3) enterFault(F("BME280 read failed three times"));
    else Serial.println(F("WARN: invalid BME280 reading"));
    return;
  }
  bmeFailures = 0;

  if (mode == Mode::Calibration) {
    Serial.printf("CAL soil_raw=%d water_raw=%d temp_c=%.1f humidity_pct=%.1f pressure_hpa=%.1f\n",
                  soilRaw, waterRaw, temperature, humidity, pressure);
    return;
  }
  if (mode != Mode::Monitoring) return;

  int soilPercent = calibratedPercent(soilRaw, calibration.soilDry, calibration.soilWet);
  int waterPercent = calibratedPercent(waterRaw, calibration.waterEmpty, calibration.waterFull);
  bool drySoil = soilPercent < DRY_SOIL_PERCENT;
  bool lowWater = waterPercent < LOW_WATER_PERCENT;
  const char *attention = drySoil && lowWater ? "DRY_SOIL+LOW_WATER" :
                          drySoil ? "DRY_SOIL" :
                          lowWater ? "LOW_WATER" : "OK";

  Serial.printf("DATA soil_raw=%d soil_pct=%d water_raw=%d water_pct=%d temp_c=%.1f humidity_pct=%.1f pressure_hpa=%.1f status=%s\n",
                soilRaw, soilPercent, waterRaw, waterPercent,
                temperature, humidity, pressure, attention);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  pinMode(SOIL_PIN, INPUT);
  pinMode(WATER_PIN, INPUT);
  analogReadResolution(12);
  analogSetPinAttenuation(SOIL_PIN, ADC_11db);
  analogSetPinAttenuation(WATER_PIN, ADC_11db);

  if (!Wire.begin(SDA_PIN, SCL_PIN, 100000)) {
    enterFault(F("I2C start failed"));
    return;
  }

  bool found76 = i2cAck(0x76);
  bool found77 = i2cAck(0x77);
  if (found76 == found77) {
    enterFault(F("Need exactly one BME280 at 0x76 or 0x77"));
    return;
  }
  bmeAddress = found76 ? 0x76 : 0x77;
  if (!bme.begin(bmeAddress, &Wire)) {
    enterFault(F("Device at BME address did not initialise"));
    return;
  }

  preferences.begin("plant-lab", false);
  loadCalibration();
  mode = PLANT_DIAGNOSTIC || !calibrationValid() ? Mode::Calibration : Mode::Monitoring;

  Serial.println(F("ESP32 PLANT MONITORING LEARNING LAB"));
  Serial.println(F("USB/3.3 V bench build; no pump, buzzer, Wi-Fi or unattended control."));
  Serial.println(F("The conductive water sensor is for short supervised tests; rinse and dry it after use."));
  printStatus();
  printCommands();
}

void loop() {
  readSerialCommands();
  if (mode == Mode::Fault) return;

  uint32_t now = millis();
  if (!elapsed(now, lastSample, SAMPLE_INTERVAL_MS)) return;
  lastSample = now;
  sampleAndReport();
}
ℹCompiler-checked for ESP32 core 3.3.11 in normal mode and with PLANT_DIAGNOSTIC=true. Uses Adafruit BME280 2.3.0, BusIO 1.17.4, Unified Sensor 1.1.15, Preferences, Wire, and SPI. Open Serial Monitor at 115200 with newline enabled. Compilation is not a physical bench test; verify the exact module labels, 3.3 V signals, BME address, breadboard fit, and repeatable calibration before release. The optional 5 V buzzer is intentionally not driven.
  • Commands: soil-dry, soil-wet, water-empty, water-full, run, calibrate, status, reset-cal, help. Type them in the Serial Monitor input box.
  • DRY_SOIL_PERCENT (30) and LOW_WATER_PERCENT (20) set when the status flags appear.
  • MIN_CALIBRATION_SPAN (150) stops you calibrating with two readings that are almost the same — the percentages would be meaningless.
  • Set #define PLANT_DIAGNOSTIC true to stay in calibration mode with raw values printed, for testing wiring.
  • No buzzer, pump, Wi-Fi or automatic watering is included. Everything stays on your computer screen.

What you should see

  • A start-up banner, the BME280 address and the command list.
  • Before calibration: CAL lines with soil_raw, water_raw, temp_c, humidity_pct, pressure_hpa.
  • After run: DATA lines every 2 s ending with status=OK, DRY_SOIL, LOW_WATER or DRY_SOIL+LOW_WATER.
  • Room readings that look plausible: about 20–35 °C, 30–70 % humidity, and pressure near 1000–1020 hPa at low altitude.
  • The water sensor's tracks are bare copper. Dip it only for short tests, keep the connector end dry, then rinse and dry it — left in water it corrodes within days.

If it doesn't work

ProblemWhat to check
Upload fails or no port appearsUse a USB cable that carries data, not a charge-only one. Hold the BOOT button while the upload starts if it stays at 'Connecting…'.
Garbled text in the Serial MonitorSet the baud rate to 115200.
'Need exactly one BME280 at 0x76 or 0x77'Check VIN on 3V3, GND, SDA GPIO21 and SCL GPIO22. Some modules label SDA as SDI and SCL as SCK.
soil_raw stuck at 0 or 4095AOUT is not on GPIO34, or the sensor has no power. Unplug USB and check the three wires.
'Calibration incomplete or a span is under 150 counts'The dry and wet readings were too close. Make the wet reference wetter (or use a glass of water) and capture again.
'Unknown command'Set the Serial Monitor line ending to Newline and type the command exactly, e.g. soil-dry.

Try next

  • Run the Serial Data Logger for an afternoon and chart a watering.
  • Try different soils and see how the dry/wet references change.
  • Once the local build is solid, plan the Wi-Fi dashboard extension as a separate project.

More from this kit

All projects →

Bot
ECHO
Online — usually replies instantly
Catalog assistant: verify final stock/price on the product page before checkout.