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.
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.
Every item is in the kit — nothing extra to buy.
All 6 parts in one box, with this project and 6 more to build from them.
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+.
Unplug USB before you change any wire. Pin names are the labels printed on the board.
| Part / pin | Connect to | Note |
|---|---|---|
| ESP32 3V3 | Breadboard + rail | 3.3 V only — ESP32 inputs must not see 5 V |
| ESP32 GND | Breadboard − rail | |
| BME280 VIN / VCC | + rail (3V3) | |
| BME280 GND | − rail | |
| BME280 SDA | GPIO21 | |
| BME280 SCL | GPIO22 | |
| Soil sensor VCC | + rail (3V3) | |
| Soil sensor GND | − rail | |
| Soil sensor AOUT | GPIO34 | GPIO34 is input-only — ideal for an analog sensor |
| Water sensor + / VCC | + rail (3V3) | |
| Water sensor − / GND | − rail | |
| Water sensor S | GPIO35 | Keep this end of the board dry |
Written for the board in the kit. Copy it into the Arduino IDE; change the pin numbers if you wire it differently.
#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();
}
| Problem | What to check |
|---|---|
| Upload fails or no port appears | Use 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 Monitor | Set 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 4095 | AOUT 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. |
Compare raw ADC readings across documented dry, damp, and wet samples of one soil type. Repeat probe depth and compaction, graph the results, and explain why the percent…
Mark several safe test depths, energize only long enough to record stable readings, then power down, rinse, and dry the tracks. Study repeatability and corrosion risk; d…
Measure temperature, relative humidity, and pressure near a plant while keeping the module dry and away from direct sun, water, fingers, and warm electronics. Compare se…
We use cookies that the shop needs to work — signing in, your cart, form security. We also measure how the shop is used so we can stock the right parts and fix what is broken. Choose “Only essential” and we will not load Google Analytics or the Meta pixel, and will not record your visit. Read more.