Siddhant Kumar
Project 011 · Smart Home

Smart Plant Irrigation.

Soil-moisture-driven watering that understands the difference between "the surface is dry" and "the plant is thirsty" — with capacitive probes that survive, a pump that cannot flood your floor, and per-plant thresholds you calibrate once.

Beginner 6–10 hours 47 min read SoilPumpApp
Jump to source Bill of materials
Smart Plant Irrigation — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Beginner
Build time
6–10 hours
Indicative cost
₹2,900 – ₹3,800 for four zones
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
Contents — 26 sections

Project Overview

Soil-moisture-driven watering that understands the difference between "the surface is dry" and "the plant is thirsty" — with capacitive probes that survive, a pump that cannot flood your floor, and per-plant thresholds you calibrate once.

The failure mode of most automatic plant waterers is not that they under-water. It is that they over-water, kill the plant through root rot, and then flood the floor when a tube pops off. Every design decision here is aimed at those three outcomes rather than at the easy part, which is turning a pump on.

The sensing choice matters first. The resistive soil probe that ships with almost every starter kit is two exposed metal prongs with DC across them. In damp soil that is an electrolysis cell: the prongs corrode visibly within two to four weeks, the readings drift steadily drier, and the system responds by watering more and more. It is not a cheaper option, it is a broken one. A capacitive probe measures the dielectric constant of the soil through an insulating coating with no exposed metal at all, and lasts years.

The control logic then has to respect what soil actually does. Water applied at the surface takes fifteen to forty minutes to redistribute through the root zone, so a controller that waters, re-reads immediately, still sees dry soil and waters again will drown the plant in about four cycles. The firmware therefore runs a strict water-then-wait cycle: a bounded pulse, a mandatory soak period, and only then a fresh decision.

Finally the safety layer. A pump that can run indefinitely is a flood waiting for a stuck relay or a crashed loop. This design bounds every pulse in hardware terms — a maximum run time enforced independently of the control logic, a daily volume cap, and a reservoir level switch that refuses to run the pump dry. Running a small diaphragm pump dry destroys it in minutes.

What this project does

  • Measures volumetric soil moisture with a capacitive probe per plant, calibrated to that specific soil and pot.
  • Waters in bounded pulses with an enforced soak period between them, so the reading has time to become meaningful.
  • Refuses to run the pump when the reservoir level switch reports empty.
  • Enforces a daily volume cap so a stuck sensor cannot drown a plant over a weekend.
  • Supports up to four independent zones from one controller, each with its own threshold and schedule.
  • Logs every watering event with duration, estimated volume and the moisture before and after.
  • Publishes to MQTT and Home Assistant, with per-zone manual override.

Real-World Applications

SettingHow it is used
Indoor houseplants during travelThe original use case, and the one where over-watering does the most damage because nobody is there to notice.
Balcony herb and vegetable plantersSmall soil volumes dry out fast in summer; daily watering by hand is exactly the chore this removes.
Seed propagation traysGermination needs consistently damp, never saturated — a band that is very hard to hit manually.
Bonsai and specimen plantsHigh-value plants where the cost of getting it wrong justifies instrumenting it properly.
Office plantsNobody waters them at weekends, and everybody waters them on Monday.
Teaching sensors and controlA complete closed loop — measure, decide, actuate, measure again — with a visible physical result.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Capacitive probes only — no exposed electrodes, no electrolysis, multi-year life.
  • Two-point calibration per probe (dry air and saturated soil) mapped to volumetric water content.
  • Water-then-soak cycle with a 30-minute mandatory wait, which is what prevents over-watering.
  • Hard pulse limit of 20 seconds enforced by a separate timer from the control logic.
  • Daily volume cap in millilitres, reset at midnight.
  • Dry-run protection via a reservoir float switch, protecting the pump.
  • Temperature and humidity compensation — evapotranspiration rises with heat, so thresholds shift.
  • Per-plant profiles stored in NVS: succulents, herbs and ferns want completely different set points.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelBeginner
Estimated completion time6–10 hours
Indicative build cost₹2,900 – ₹3,800 for four zones
Primary disciplineSmart Home
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Basic Arduino C++ and analogue reading
  • Understanding of a two-point sensor calibration
  • Driving a pump through a MOSFET or relay with a flyback diode
  • Simple plumbing — tubing, drippers, reservoir
  • MQTT basics for the reporting half

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32 DevKit V1 (ESP-WROOM-32)
Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC1₹450
Capacitive soil-moisture sensor v2.0
Always pick capacitive over the cheap resistive fork — resistive probes corrode in weeks.
Corrosion-free capacitive sensing, 0–3 V analogue swing, 55 × 20 mm probe4₹720
5 V submersible mini water pump
Never run it dry; add a flyback diode across the motor terminals.
80–120 L/h, 0.4–1.5 m head, 5 V DC, 5 mm outlet1₹160
DHT22 / AM2302 temperature + humidity sensor
Needs a 4.7 kΩ pull-up on the data line and 2 s between reads.
−40 to +80 °C ±0.5 °C, 0–100 %RH ±2 %, 0.5 Hz sample rate, single-wire digital1₹250
0.96″ SSD1306 OLED display
Static images burn in — invert or scroll the screen periodically.
128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C1₹250
LM2596 adjustable buck converter module
Set the output voltage with no load connected before wiring the board.
4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency1₹90
5 V 3 A regulated SMPS adapter
Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.
100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection1₹350
Double-sided perfboard 7 × 9 cm + headers
Solder female headers so the MCU can be swapped without desoldering.
FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid1₹60
IP65 ABS junction enclosure 158 × 90 × 60 mm
Fit cable glands, not drilled holes, or the IP rating means nothing.
IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs1₹260
Reservoir float switch
Wire normally-closed so a broken wire reads as empty and stops the pump.
Vertical, NO/NC, 100 V 0.5 A1₹180
IRLZ44N logic-level MOSFET + 1N4007
Logic-level gate is essential — a standard IRF540 will not fully turn on from 3.3 V.
N-channel, 3.3 V gate drive, 47 A4₹480
6 mm silicone tubing + drippers + T-pieces3 m tube, 4 adjustable drippers1₹280
5 L reservoir with lidFood-grade HDPE1₹220

Estimated total: ₹3,750, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter — continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32 DevKit V1 (ESP-WROOM-32)Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
Capacitive soil-moisture sensor v2.0Corrosion-free capacitive sensing, 0–3 V analogue swing, 55 × 20 mm probe3.3–5.5 VAnalogueDatasheet
5 V submersible mini water pump80–120 L/h, 0.4–1.5 m head, 5 V DC, 5 mm outlet3–6 VRelay / MOSFETDatasheet
DHT22 / AM2302 temperature + humidity sensor−40 to +80 °C ±0.5 °C, 0–100 %RH ±2 %, 0.5 Hz sample rate, single-wire digital3.3–6 V1-wire proprietaryDatasheet
0.96″ SSD1306 OLED display128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C3.3–5 VI²C (0x3C)Datasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Capacitive soil-moisture sensor v2.03.3–5.5 V20Always pick capacitive over the cheap resistive fork — resistive probes corrode in weeks.
5 V submersible mini water pump3–6 V220Never run it dry; add a flyback diode across the motor terminals.
DHT22 / AM2302 temperature + humidity sensor3.3–6 V1.5Needs a 4.7 kΩ pull-up on the data line and 2 s between reads.
0.96″ SSD1306 OLED display3.3–5 V20Static images burn in — invert or scroll the screen periodically.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
5 V 3 A regulated SMPS adapter5 V3000Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.

Summed typical draw is 3429.5 mA. With a 1.5× design margin the supply should deliver at least 5200 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools → Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
  • Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
DHT sensor library 1.4.6Timing-critical driver for DHT11/DHT22.Library Manager → "DHT sensor library" by Adafruit
Adafruit Unified Sensor 1.1.xCommon sensor event abstraction; a dependency of most Adafruit drivers.Library Manager → "Adafruit Unified Sensor"
Adafruit SSD1306 + GFX 2.5.xFramebuffer and text/graphics primitives for the OLED.Library Manager → "Adafruit SSD1306"
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Smart Plant Irrigation — system block diagramFunctional block diagram of the Smart Plant Irrigation system. SenseCapacitive probes×4, ADC1DHT22air T / RHFloat switchreservoirDecideTwo-point mapto % VWCThreshold + soakper zoneGuardPulse limiter20 s hard capDaily volume capmL/dayActMOSFET → pumpper zoneLog + MQTTbefore/afterraw ADCwater requestapproved
Smart Plant Irrigation — system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

Smart Plant Irrigation — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBCapacitive soil probe 1–4GPIO 34 35 36 39Analogue, input-onlypinsDHT22 air sensorGPIO 271-wire, 4.7 kΩpull-upReservoir float switchGPIO 32Pull-up; open =emptyManual water buttonGPIO 33Pull-upPump 1–4 via MOSFETGPIO 25 26 14 123.3 V logic-levelgateSSD1306 OLEDGPIO 21 / 22I²C at 0x3CStatus LEDGPIO 2Through 330 Ω
Smart Plant Irrigation — wiring schematic
PeripheralPeripheral pinController pinSignal
Capacitive soil probe 1–4AOUTGPIO 34 35 36 39Analogue, input-only pins
DHT22 air sensorDATAGPIO 271-wire, 4.7 kΩ pull-up
Reservoir float switchNC contactGPIO 32Pull-up; open = empty
Manual water buttonNOGPIO 33Pull-up
Pump 1–4 via MOSFETGateGPIO 25 26 14 123.3 V logic-level gate
SSD1306 OLEDSDA / SCLGPIO 21 / 22I²C at 0x3C
Status LEDAnodeGPIO 2Through 330 Ω

Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • GPIO 34, 35, 36 and 39 are input-only and have no internal pull-ups — perfect for analogue probes and useless for anything else. Use them here and keep the output-capable pins for the pumps.
  • Use ADC1 pins (32–39) for the probes. ADC2 (GPIO 0, 2, 4, 12–15, 25–27) is unavailable whenever Wi-Fi is active, and the failure is silent: analogRead() simply returns garbage.
  • The pump is inductive. Fit a 1N4007 across its terminals, cathode to positive, or the back-EMF will destroy the MOSFET. This is the single most common cause of a pump project that works twice and then stops.
  • Use a logic-level MOSFET such as the IRLZ44N. A standard IRF540 needs about 10 V on the gate to turn fully on; driven from 3.3 V it operates in its linear region, dissipates several watts and gets hot enough to fail.
  • Keep the probe electronics above the soil line. The probe is designed to be inserted to a marked depth — pushing it in past the coating line lets water reach the electronics and destroys it.
  • Route tubing so that if a connection fails, water goes into a tray rather than onto the floor. Assume it will fail eventually, because it will.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board. Its ADC1 pins are the ones usable for analogue probes while Wi-Fi is running. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

Smart Plant Irrigation — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · Capacitive soil-moisture sensor v2.0 ·DHT22 / AM2302 temperature + humidity sensorDriver layerwifi · pubsub · arduinojson · dhtlibApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Smart Plant Irrigation — architecture stack

Working Principle

A capacitive soil probe works because water has an unusually high relative permittivity — about 80, against roughly 4 for dry soil minerals and 1 for air. The probe is a pair of PCB traces forming a capacitor with the soil as its dielectric; more water in the soil means more capacitance. The board runs a 555-style oscillator whose frequency depends on that capacitance, rectifies the result, and presents a DC voltage on the analogue output. Because the copper is entirely covered by solder mask, no current flows through the soil and there is nothing to corrode.

The output is not calibrated in any physical unit, and the mapping differs with soil type, compaction, temperature and even pot size. A two-point calibration is therefore mandatory: record the raw ADC value with the probe in dry air (the driest possible reading) and again in a pot of thoroughly saturated soil (the wettest). Everything between is linearly interpolated. That linear assumption is not perfectly true — the real relationship curves — but across the 20–60 % range where watering decisions actually happen, it is accurate to a few percent.

What the plant cares about is not moisture at the surface but water availability in the root zone. This is where naive controllers fail. Applying 30 mL to the top of a pot wets the top two centimetres immediately and takes 20–40 minutes to redistribute downward through capillary action. A controller that re-reads at one minute sees soil that is still dry at probe depth, waters again, and repeats. Four cycles later the pot is saturated and the roots are anaerobic. The mandatory soak period is not a nicety, it is the core of the algorithm.

The threshold itself should be a band, not a point. Watering when moisture drops below a lower limit and stopping when it rises above an upper limit gives hysteresis and prevents the controller from chasing sensor noise. For most houseplants a band of roughly 30 % to 55 % works; succulents want 15 % to 30 % and ferns want 45 % to 70 %. These are set-point profiles, not universal truths, and the calibration procedure is what makes the numbers mean the same thing across different soils.

Evapotranspiration compensation is a small refinement with a real effect. A plant loses water faster when the air is warm and dry. Shifting the lower threshold up by a couple of percent for every 5 °C above 25 °C, and down when humidity is high, keeps the plant in the same effective water-stress band across seasons rather than across thermometer readings.

The maths behind it

Two-point calibration to volumetric water content

plainTwo-point calibration to volumetric water content
Raw ADC readings for a specific probe:
  air_dry (0 % VWC)      = 3180
  saturated (100 % scale)= 1420

VWC% = (air_dry − raw) / (air_dry − saturated) × 100

Example raw = 2400:
  VWC = (3180 − 2400) / (3180 − 1420) × 100
      = 780 / 1760 × 100 = 44.3 %

Note the inversion: a wetter soil gives a LOWER
raw value, because more capacitance lowers the
oscillator frequency and hence the rectified output.

Pulse volume and daily cap

plainPulse volume and daily cap
Pump rated 100 L/h at zero head:
  = 100 000 mL / 3600 s = 27.8 mL/s

At 0.5 m head, derate ~35 %:
  effective ≈ 18 mL/s

8-second pulse  = 144 mL
20-second cap   = 360 mL   (hard limit)

Daily cap for a 15 cm pot (~1.7 L soil):
  a full re-wet is about 400 mL
  set the daily cap at 500 mL — enough for one
  full watering, not enough to drown it twice.

Evapotranspiration threshold shift

plainEvapotranspiration threshold shift
Baseline threshold  T₀ = 30 % VWC at 25 °C, 50 %RH

T = T₀ + 0.4 × (temp_C − 25) − 0.05 × (rh − 50)

35 °C, 30 %RH:
  T = 30 + 0.4 × 10 − 0.05 × (−20)
    = 30 + 4 + 1 = 35 %

18 °C, 70 %RH:
  T = 30 + 0.4 × (−7) − 0.05 × 20
    = 30 − 2.8 − 1 = 26.2 %

The plant is kept at a similar water stress level
rather than a similar sensor reading.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Smart Plant Irrigation — firmware flowchartControl flow through the main program loop. Boot: load calibration andprofilesRead all probes, DHT22 andfloat switchAny zone below itsthreshold?yessleep 60 sSoak period elapsed forthat zone?yeswaitReservoir has water andcap not reached?yesalert, skipRun pump for the bounded pulseRecord volume, start 30 minsoak timerPublish event over MQTT
Smart Plant Irrigation — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Calibrate each probe before writing any control logic

    Every probe is different and every soil is different. This routine records the two endpoints and stores them, and it is the difference between a system that works and one that waters at random.

    cpp01-probe-calibrate.ino
    #include <Preferences.h>
    Preferences prefs;
    
    const uint8_t PROBE_PIN[4] = { 34, 35, 36, 39 };
    uint16_t calDry[4], calWet[4];
    
    uint16_t probeRaw(uint8_t zone) {
      uint32_t acc = 0;
      for (int i = 0; i < 32; i++) { acc += analogRead(PROBE_PIN[zone]); delay(2); }
      return acc / 32;                     // the ESP32 ADC is noisy; average hard
    }
    
    float probePercent(uint8_t zone) {
      int span = (int)calDry[zone] - (int)calWet[zone];
      if (span < 200) return -1;           // calibration is nonsense or missing
      float pct = (calDry[zone] - (float)probeRaw(zone)) * 100.0f / span;
      return pct < 0 ? 0 : (pct > 100 ? 100 : pct);
    }
    
    void calibrateZone(uint8_t zone) {
      Serial.printf("Zone %d: hold the probe in DRY AIR, then press Enter\n", zone);
      while (!Serial.available()) delay(50);
      while (Serial.available()) Serial.read();
      calDry[zone] = probeRaw(zone);
    
      Serial.printf("Zone %d: insert into THOROUGHLY SOAKED soil, press Enter\n", zone);
      while (!Serial.available()) delay(50);
      while (Serial.available()) Serial.read();
      calWet[zone] = probeRaw(zone);
    
      char kd[8], kw[8];
      snprintf(kd, sizeof(kd), "d%u", zone);
      snprintf(kw, sizeof(kw), "w%u", zone);
      prefs.putUShort(kd, calDry[zone]);
      prefs.putUShort(kw, calWet[zone]);
    
      Serial.printf("Zone %d calibrated: dry=%u wet=%u span=%d\n",
                    zone, calDry[zone], calWet[zone],
                    (int)calDry[zone] - (int)calWet[zone]);
      if ((int)calDry[zone] - (int)calWet[zone] < 400)
        Serial.println("  WARNING: span too small — probe may be faulty or not inserted");
    }
    analogRead averaged 32 timesThe ESP32 SAR ADC has significant noise and non-linearity. A single reading can swing 3–5 %, which on a 30 % threshold is enough to cause spurious watering. Averaging 32 samples costs 64 ms and removes the problem.
    span < 200 → return −1A probe that is not inserted, disconnected, or dead produces a tiny span between its two calibration points. Returning an explicit error rather than a plausible percentage stops the controller acting on nonsense.
    calDry > calWetCapacitive probes read lower when wet, which is the opposite of the resistive probes people are used to. Getting the direction wrong produces a system that waters when the soil is already saturated.
    "thoroughly soaked soil", not waterCalibrating the wet point in a glass of water gives a value the probe will never see in soil, which compresses the useful range. Saturate an actual pot and let it drain for a minute.
  2. Bound the pump in a way the control logic cannot override

    The pump guard is deliberately written as a separate module with its own timer. If the decision logic has a bug and asks for water forever, the guard still stops at twenty seconds.

    cpp02-pump-guard.ino
    const uint8_t PUMP_PIN[4] = { 25, 26, 14, 12 };
    #define PIN_FLOAT 32
    #define MAX_PULSE_MS      20000UL
    #define DAILY_CAP_ML        500
    #define PUMP_ML_PER_SEC      18.0f
    
    struct Zone {
      uint32_t pumpStart;        // 0 when idle
      uint32_t requestedMs;
      uint32_t soakUntil;
      uint16_t mlToday;
    } zones[4];
    
    bool reservoirHasWater() {
      // Float switch wired normally-CLOSED: a cut wire reads empty. Fail safe.
      return digitalRead(PIN_FLOAT) == LOW;
    }
    
    bool pumpRequest(uint8_t z, uint32_t ms) {
      if (zones[z].pumpStart) return false;                       // already running
      if (millis() < zones[z].soakUntil) return false;            // still soaking
      if (!reservoirHasWater()) return false;                     // dry reservoir
      if (ms > MAX_PULSE_MS) ms = MAX_PULSE_MS;                   // hard clamp
    
      uint16_t wouldAdd = (uint16_t)(ms / 1000.0f * PUMP_ML_PER_SEC);
      if (zones[z].mlToday + wouldAdd > DAILY_CAP_ML) return false;
    
      zones[z].pumpStart   = millis();
      zones[z].requestedMs = ms;
      digitalWrite(PUMP_PIN[z], HIGH);
      return true;
    }
    
    // Called every loop. This is the only place a pump is ever switched off,
    // and it stops on time regardless of what the control logic believes.
    void pumpService() {
      for (uint8_t z = 0; z < 4; z++) {
        if (!zones[z].pumpStart) continue;
    
        uint32_t ran = millis() - zones[z].pumpStart;
        bool stop = ran >= zones[z].requestedMs
                 || ran >= MAX_PULSE_MS
                 || !reservoirHasWater();
    
        if (stop) {
          digitalWrite(PUMP_PIN[z], LOW);
          zones[z].mlToday += (uint16_t)(ran / 1000.0f * PUMP_ML_PER_SEC);
          zones[z].soakUntil = millis() + 30UL * 60UL * 1000UL;   // 30 min soak
          zones[z].pumpStart = 0;
        }
      }
    }
    Float switch normally closedA cut or disconnected float wire reads the same as an empty reservoir, so the failure mode is a pump that refuses to run rather than one that runs dry. Running a diaphragm pump dry destroys it within minutes.
    Three independent stop conditionsRequested duration, absolute cap, and reservoir state. Any one of them stops the pump. A bug in the control logic can only ever cause under-watering, never a flood.
    soakUntil set on stop, not on startThe soak timer begins when watering ends, which is when redistribution actually starts. Starting it at pump-on would shorten the effective soak by the pulse length.
    mlToday accumulated from actual run timeVolume is credited from how long the pump really ran, not from what was requested — so a pulse cut short by an empty reservoir does not consume the daily budget.

Complete Source Code

The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.

cppsmart-irrigation.ino
/* ═══════════════════════════════════════════════════════════════
   Smart Plant Irrigation — ESP32 + capacitive probes + pumps

   Four independent zones, each with its own two-point calibration,
   moisture band and plant profile. Watering is a bounded pulse
   followed by a mandatory 30-minute soak, guarded by a hard pulse
   limit, a daily volume cap and a reservoir float switch.
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <time.h>

#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "irrigation-indoor"

#define PIN_DHT    27
#define PIN_FLOAT  32
#define PIN_BTN    33
#define PIN_LED     2
#define N_ZONES     4

#define MAX_PULSE_MS   20000UL
#define SOAK_MS        (30UL * 60UL * 1000UL)
#define DAILY_CAP_ML     500
#define PUMP_ML_PER_SEC  18.0f

const uint8_t PROBE_PIN[N_ZONES] = { 34, 35, 36, 39 };
const uint8_t PUMP_PIN[N_ZONES]  = { 25, 26, 14, 12 };

/* Plant profiles: low and high moisture band, and pulse length. */
struct Profile { const char *name; uint8_t low, high; uint16_t pulseMs; };
const Profile PROFILES[] = {
  { "succulent", 15, 30,  4000 },
  { "herb",      30, 55,  8000 },
  { "houseplant",30, 55,  8000 },
  { "fern",      45, 70, 10000 },
  { "seedling",  40, 60,  3000 },
};

struct Zone {
  uint8_t  profile;
  uint16_t calDry, calWet;
  uint32_t pumpStart, requestedMs, soakUntil;
  uint16_t mlToday;
  float    lastPct, pctBeforeWater;
  bool     enabled;
} zones[N_ZONES];

DHT              dht(PIN_DHT, DHT22);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient       net;
PubSubClient     mqtt(net);
Preferences      prefs;

float airTemp = 25, airRh = 50;
int   lastCapDay = -1;

/* ── probes ─────────────────────────────────────────────────── */
uint16_t probeRaw(uint8_t z) {
  uint32_t acc = 0;
  for (int i = 0; i < 32; i++) { acc += analogRead(PROBE_PIN[z]); delay(2); }
  return acc / 32;
}

float probePercent(uint8_t z) {
  int span = (int)zones[z].calDry - (int)zones[z].calWet;
  if (span < 200) return -1;
  float pct = (zones[z].calDry - (float)probeRaw(z)) * 100.0f / span;
  return pct < 0 ? 0 : (pct > 100 ? 100 : pct);
}

/* ── evapotranspiration-adjusted threshold ──────────────────── */
float thresholdFor(uint8_t z) {
  const Profile &p = PROFILES[zones[z].profile];
  float t = p.low + 0.4f * (airTemp - 25.0f) - 0.05f * (airRh - 50.0f);
  if (t < p.low - 8) t = p.low - 8;          // clamp the compensation
  if (t > p.low + 8) t = p.low + 8;
  return t;
}

/* ── pump guard ─────────────────────────────────────────────── */
bool reservoirHasWater() { return digitalRead(PIN_FLOAT) == LOW; }

bool pumpRequest(uint8_t z, uint32_t ms) {
  Zone &Z = zones[z];
  if (!Z.enabled || Z.pumpStart) return false;
  if (millis() < Z.soakUntil)    return false;
  if (!reservoirHasWater())      return false;
  if (ms > MAX_PULSE_MS) ms = MAX_PULSE_MS;

  uint16_t add = (uint16_t)(ms / 1000.0f * PUMP_ML_PER_SEC);
  if (Z.mlToday + add > DAILY_CAP_ML) return false;

  Z.pctBeforeWater = Z.lastPct;
  Z.pumpStart   = millis();
  Z.requestedMs = ms;
  digitalWrite(PUMP_PIN[z], HIGH);
  digitalWrite(PIN_LED, HIGH);
  return true;
}

void publishEvent(uint8_t z, uint32_t ranMs, uint16_t ml, const char *why);

void pumpService() {
  bool any = false;
  for (uint8_t z = 0; z < N_ZONES; z++) {
    Zone &Z = zones[z];
    if (!Z.pumpStart) continue;
    any = true;

    uint32_t ran = millis() - Z.pumpStart;
    const char *why = nullptr;
    if (!reservoirHasWater())        why = "reservoir-empty";
    else if (ran >= MAX_PULSE_MS)    why = "hard-limit";
    else if (ran >= Z.requestedMs)   why = "complete";
    if (!why) continue;

    digitalWrite(PUMP_PIN[z], LOW);
    uint16_t ml = (uint16_t)(ran / 1000.0f * PUMP_ML_PER_SEC);
    Z.mlToday  += ml;
    Z.soakUntil = millis() + SOAK_MS;
    Z.pumpStart = 0;
    publishEvent(z, ran, ml, why);
  }
  if (!any) digitalWrite(PIN_LED, LOW);
}

/* ── MQTT ───────────────────────────────────────────────────── */
void publishEvent(uint8_t z, uint32_t ranMs, uint16_t ml, const char *why) {
  JsonDocument d;
  d["zone"]    = z;
  d["plant"]   = PROFILES[zones[z].profile].name;
  d["ran_ms"]  = ranMs;
  d["ml"]      = ml;
  d["ml_today"]= zones[z].mlToday;
  d["before"]  = roundf(zones[z].pctBeforeWater);
  d["reason"]  = why;
  char b[224]; size_t n = serializeJson(d, b, sizeof(b));
  mqtt.publish("home/irrigation/" DEVICE_ID "/event", (uint8_t *)b, n, false);
  Serial.printf("zone %d watered %u ms (%u mL) — %s\n", z, (unsigned)ranMs, ml, why);
}

void publishState() {
  JsonDocument d;
  d["temp"] = roundf(airTemp * 10) / 10.0f;
  d["rh"]   = roundf(airRh);
  d["reservoir"] = reservoirHasWater() ? "ok" : "empty";
  JsonArray zs = d["zones"].to<JsonArray>();
  for (uint8_t z = 0; z < N_ZONES; z++) {
    JsonObject o = zs.add<JsonObject>();
    o["pct"]      = roundf(zones[z].lastPct);
    o["target"]   = roundf(thresholdFor(z));
    o["ml_today"] = zones[z].mlToday;
    o["plant"]    = PROFILES[zones[z].profile].name;
    o["soaking"]  = millis() < zones[z].soakUntil;
  }
  char b[512]; size_t n = serializeJson(d, b, sizeof(b));
  mqtt.publish("home/irrigation/" DEVICE_ID "/state", (uint8_t *)b, n, true);
}

void onMessage(char *topic, byte *payload, unsigned int len) {
  JsonDocument d;
  if (deserializeJson(d, payload, len)) return;
  int z = d["zone"] | -1;
  if (z < 0 || z >= N_ZONES) return;

  const char *action = d["action"] | "";
  if (!strcmp(action, "water")) {
    zones[z].soakUntil = 0;                        // manual overrides the soak
    pumpRequest(z, d["ms"] | 5000);
  } else if (!strcmp(action, "enable")) {
    zones[z].enabled = d["value"] | true;
  } else if (!strcmp(action, "profile")) {
    uint8_t p = d["value"] | 2;
    if (p < sizeof(PROFILES) / sizeof(PROFILES[0])) {
      zones[z].profile = p;
      char k[8]; snprintf(k, sizeof(k), "p%u", z);
      prefs.putUChar(k, p);
    }
  }
}

/* ── display ────────────────────────────────────────────────── */
void draw() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  for (uint8_t z = 0; z < N_ZONES; z++) {
    oled.setCursor(0, z * 12);
    if (zones[z].lastPct < 0) oled.printf("Z%d  ---   probe fault", z + 1);
    else oled.printf("Z%d %3.0f%% / %2.0f%%  %s", z + 1, zones[z].lastPct,
                     thresholdFor(z),
                     zones[z].pumpStart ? "PUMP"
                     : millis() < zones[z].soakUntil ? "soak" : "");
  }
  oled.setCursor(0, 52);
  oled.printf("%.0fC %.0f%%RH  tank %s", airTemp, airRh,
              reservoirHasWater() ? "ok" : "EMPTY");
  oled.display();
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_FLOAT, INPUT_PULLUP);
  pinMode(PIN_BTN, INPUT_PULLUP);
  pinMode(PIN_LED, OUTPUT);
  for (uint8_t z = 0; z < N_ZONES; z++) {
    pinMode(PUMP_PIN[z], OUTPUT);
    digitalWrite(PUMP_PIN[z], LOW);
    analogSetPinAttenuation(PROBE_PIN[z], ADC_11db);
  }

  dht.begin();
  Wire.begin(21, 22);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  prefs.begin("irrig", false);
  for (uint8_t z = 0; z < N_ZONES; z++) {
    char kd[8], kw[8], kp[8];
    snprintf(kd, sizeof(kd), "d%u", z);
    snprintf(kw, sizeof(kw), "w%u", z);
    snprintf(kp, sizeof(kp), "p%u", z);
    zones[z].calDry  = prefs.getUShort(kd, 3180);
    zones[z].calWet  = prefs.getUShort(kw, 1420);
    zones[z].profile = prefs.getUChar(kp, 2);
    zones[z].enabled = true;
  }

  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  configTime(19800, 0, "pool.ntp.org");
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setCallback(onMessage);
  mqtt.setBufferSize(768);

  Serial.println("Irrigation controller ready");
}

void loop() {
  if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
    if (mqtt.connect(DEVICE_ID)) mqtt.subscribe("home/irrigation/" DEVICE_ID "/cmd");
  }
  mqtt.loop();
  pumpService();                      // must run every loop — it stops the pumps

  static uint32_t lastSlow = 0;
  if (millis() - lastSlow < 60000) {  // decide once a minute; soil is slow
    if (!digitalRead(PIN_BTN)) { pumpRequest(0, 3000); delay(400); }
    return;
  }
  lastSlow = millis();

  float t = dht.readTemperature(), h = dht.readHumidity();
  if (!isnan(t)) airTemp = t;
  if (!isnan(h)) airRh = h;

  time_t now = time(nullptr);
  struct tm tmv; localtime_r(&now, &tmv);
  if (tmv.tm_yday != lastCapDay) {                 // reset the daily cap
    lastCapDay = tmv.tm_yday;
    for (uint8_t z = 0; z < N_ZONES; z++) zones[z].mlToday = 0;
  }

  for (uint8_t z = 0; z < N_ZONES; z++) {
    zones[z].lastPct = probePercent(z);
    if (zones[z].lastPct < 0) continue;            // probe fault, do nothing
    if (zones[z].lastPct < thresholdFor(z))
      pumpRequest(z, PROFILES[zones[z].profile].pulseMs);
  }

  draw();
  publishState();
}
pumpService() outside the one-minute gateThe decision logic runs once a minute, but the pump guard runs every loop. If they shared a rate, a pump could over-run by up to 60 seconds — 1080 mL — which is a flooded pot.
thresholdFor() clamped to ±8 %Evapotranspiration compensation is a useful nudge, not a licence to move the threshold arbitrarily. A DHT22 reading 45 °C because it is in direct sun should not push a fern into desert settings.
zones[z].lastPct < 0 → continueA faulty probe means the controller does nothing for that zone rather than guessing. Doing nothing kills a plant slowly; guessing wrong floods it quickly.
Manual water clears soakUntilA human explicitly asking for water overrides the soak timer — but not the hard pulse limit, the daily cap or the reservoir check. Those guards apply to every request regardless of source.
ADC1 pins only (34–39)ADC2 is used by the Wi-Fi radio on the ESP32 and returns garbage whenever Wi-Fi is active. This is a well-known trap and it produces readings that look plausible and are random.
pctBeforeWater recorded at request timeLogging the moisture immediately before each watering, alongside the volume, gives you the data to tune pulse length empirically instead of by guesswork.

Configuration & Calibration

Configuration steps

  • Calibrate every probe individually before enabling automatic watering. Run the calibration sketch, record the dry and wet raw values, and confirm the span exceeds 400 counts.
  • Choose a plant profile per zone. Succulents at 15–30 % and ferns at 45–70 % are genuinely different systems and sharing one threshold guarantees one of them suffers.
  • Measure your pump's real flow rate at your real head height and set PUMP_ML_PER_SEC accordingly. The rated figure assumes zero head and is optimistic by 30–50 %.
  • Set DAILY_CAP_ML from pot volume. A useful rule is one full re-wet per day maximum — roughly 25 % of the soil volume in millilitres.
  • Set the soak period to at least 30 minutes. Shorter is the single most common cause of over-watering in these builds.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Two-point probe calibration

    Record the raw ADC in dry air, then in soil you have saturated and allowed to drain for one minute. Store both. A span below 400 counts means the probe is faulty, not inserted to the marked depth, or has water in its electronics.

  2. Measure real pump flow

    Run the pump into a measuring jug for exactly 30 seconds at the actual installed height. Divide by 30 for millilitres per second. Do this per zone if the drippers differ, since dripper restriction changes flow substantially.

  3. Tune pulse length empirically

    Water once, then log the moisture reading every five minutes for an hour. If the reading overshoots the upper band, the pulse is too long; if it barely moves, too short. Two iterations gets it right for that pot.

  4. Verify the threshold against the plant

    The numbers are a proxy. After a fortnight, check whether the plant actually looks right. Drooping between waterings means the lower threshold is too low; consistently soggy topsoil means it is too high.

Network Architecture & Connectivity

Smart Plant Irrigation — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsIrrigation controller4 zonesWi-Fi 2.4 GHzRouterindoorsMQTT 1883Local brokerMosquitto + HAHome Assistantper-zone cardGrafanamoisture history
Smart Plant Irrigation — network topology
Topic / endpointDirectionPayload
home/irrigation/<id>/statedevice → broker (retained)JSON: temp, rh, reservoir, zones[] with pct, target, ml_today, plant, soaking
home/irrigation/<id>/eventdevice → brokerJSON: zone, plant, ran_ms, ml, before, reason
home/irrigation/<id>/cmdbroker → deviceJSON: zone + action = water | enable | profile

Message contract between the device and the broker.

Dashboard setup

The chart worth building is soil moisture with watering events marked. A healthy zone shows a sawtooth: a sharp rise on watering, then a gentle decline over two to five days. A zone that never drops is over-watered; one that falls off a cliff has a probe that has come loose.

Plot millilitres per day against air temperature over a season and you get your plants' actual water demand curve, which is a far better basis for a holiday watering schedule than guesswork.

Security considerations

  • The command topic can run pumps. Use broker authentication — an open topic means anyone on the network can flood your floor.
  • Keep the hard guards in firmware, not in the automation layer. A Home Assistant automation with a bug should not be able to bypass the pulse limit.

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.

TestWhat you should see
Read a probe in dry air and in wet soilA difference of at least 400 ADC counts, with wet reading lower than dry.
Check calibrated percentagesDry air about 0 %, saturated soil about 100 %, a normally damp pot somewhere between 35 % and 60 %.
Trigger a manual waterThe pump runs for the profile pulse length and stops precisely, with the LED following it.
Lift the float switch to simulate an empty reservoirThe pump stops mid-pulse and an event is published with reason reservoir-empty.
Disconnect a probeThat zone displays "probe fault" and is skipped entirely — no watering, no guessing.
Request watering twice in quick successionThe second request is refused because the soak timer is running.
Request repeatedly for a whole dayWatering stops once the daily cap is reached and resumes after midnight.
Watch a full cycle over 48 hoursA clear sawtooth in the moisture chart — sharp rise, slow decline — and no back-to-back waterings.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.

A field irrigation system watering crops
A field irrigation system. The same closed loop — measure, decide, deliver, measure again — scales from a windowsill to a farm. Photograph sourced from Wikimedia Commons — Irrigation system.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Moisture readings jump wildly or read zero with Wi-Fi on

Likely cause. The probe is on an ADC2 pin, which the ESP32 Wi-Fi radio takes exclusive use of.

Fix. Move every probe to an ADC1 pin — GPIO 32 to 39. This failure is silent and produces readings that look like a noisy sensor rather than an unavailable peripheral, which makes it very hard to diagnose from symptoms alone.

The probe corroded within a month

Likely cause. It is a resistive probe, not a capacitive one.

Fix. Look at the board: a capacitive probe has no exposed metal in the soil section, only solder mask over a wide flat pad. A resistive probe has two visible metal prongs. Resistive probes cannot be made to last — replace them.

The plant is drowning despite sensible thresholds

Likely cause. The soak period is too short, so the controller waters repeatedly before the water has redistributed.

Fix. Raise the soak to at least 30 minutes, and verify the soak timer starts when the pump stops. Also check the probe is at root depth rather than near the surface, where it dries fastest and misrepresents the root zone.

The pump ran twice then stopped working

Likely cause. No flyback diode across the pump, so the back-EMF destroyed the MOSFET.

Fix. Fit a 1N4007 across the pump terminals, cathode to the positive side. Replace the MOSFET. If the MOSFET now stays on permanently, it has failed short — which is exactly why the reservoir float switch matters as an independent stop.

The MOSFET gets hot even at low current

Likely cause. A non-logic-level MOSFET driven from 3.3 V, operating in its linear region.

Fix. Use an IRLZ44N or similar logic-level part. A standard IRF540 needs about 10 V on the gate to reach its rated on-resistance; at 3.3 V it behaves as a resistor and dissipates the difference as heat.

One zone waters far more than another with the same profile

Likely cause. Different pot size, different soil, or dripper flow imbalance.

Fix. Calibrate each probe separately (they are not interchangeable) and measure each zone's actual flow rate. Adjustable drippers vary by more than 2:1 out of the box.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • Decide once a minute, not once a loop. Soil moisture changes over hours; sampling faster adds ADC noise and nothing else.
  • Average 32 ADC samples per probe. The ESP32 ADC is noisy enough that a single reading can move a decision across a threshold.
  • Keep the pump guard in the fast loop and the decision logic in the slow one. Mixing the two rates is how over-runs happen.
  • Replace every delay() with a millis() comparison — blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
  • Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
  • Profile before optimising — print micros() deltas around each stage and fix the slowest one first.

Safety Precautions

  • Water and mains do not mix. Keep the controller and its supply above the maximum possible water level, and site the reservoir where an overflow drains somewhere harmless.
  • Assume a tube will come off eventually. Stand every pot in a tray and route tubing so a failure spills into it.
  • Never run a diaphragm pump dry — it destroys the membrane within minutes. The float switch is protecting the pump as much as the plant.
  • Moving parts pinch. Keep fingers, cables and hair out of gear trains and wheels, and always test motion with the drivetrain unloaded and the robot on blocks first.
  • Motors are inductive — always fit a flyback diode across a DC coil, or use a driver that already has one, or the back-EMF spike will destroy your GPIO.
  • Water and electronics: mount all boards above the maximum possible water line, use drip loops on every cable, and pressure-test plumbing before wiring anything up.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Flush the tubing and drippers every three months; algae and mineral deposits block adjustable drippers first.
  • Wipe the probes and check the seal at the coating line whenever you repot.
  • Refill the reservoir before it triggers the float switch — repeated dry-run stops shorten pump life.
  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
  • Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Add a flow sensor in the delivery line so the controller measures the water it actually delivered rather than estimating from run time. That catches blocked drippers and burst tubes immediately.
  • Add a load cell under the pot. Pot weight is the single most accurate proxy for total water content, and it is immune to probe placement and soil variation.
  • Add light measurement and use it in the evapotranspiration model — light drives transpiration more strongly than temperature does.
  • Add a nutrient dosing pump for hydroponics or fertigation, with its own volume cap and interlock.
  • Add solar and battery so a balcony system needs no mains at all.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add over-the-air firmware updates so you never have to physically reach a deployed node again.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Resistive or capacitive probe — does it really matter?

Yes, decisively. A resistive probe passes DC through the soil, which electrolyses the electrodes. They visibly corrode in two to four weeks, and as they corrode the readings drift drier, so the controller waters more, which accelerates the corrosion. It is a failure spiral. A capacitive probe has no metal in contact with soil and lasts years. The price difference is about ₹100.

Why the 30-minute soak? Cannot I just water less at a time?

Shorter pulses help but do not solve it, because the problem is measurement, not volume. Immediately after watering the probe still reads dry at root depth regardless of how much you applied, so a controller without a soak period will always water again. The soak is what makes each successive reading informative.

Can one pump serve four zones?

Only with solenoid valves per zone, and then the valve becomes the thing that can fail open. Four small pumps at ₹160 each is cheaper than four valves and has a better failure mode — a stuck pump floods one pot, a stuck valve empties the whole reservoir into it.

What moisture percentage should I aim for?

There is no universal number, which is why the profiles exist. As a starting point: succulents 15–30 %, most houseplants 30–55 %, ferns and moisture-lovers 45–70 %, seedlings 40–60 %. Then adjust based on how the plant actually looks after a fortnight — the sensor is a proxy for the plant, not the other way round.

Will this work outdoors?

The electronics will if you seal them properly, but the control logic needs rain compensation or you will water in a downpour. Add a rain sensor or pull a forecast, and expect the calibration to shift as outdoor soil compacts and its organic content changes.

How do I water while on holiday for a month?

Size the reservoir from measured consumption, not estimation. Log millilitres per day for two weeks first, multiply by your trip length and add 50 %. A 5 L reservoir typically covers four houseplants for three to four weeks in mild weather and about half that in summer.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. DFRobot capacitive soil moisture sensor v2.0 — wiki and calibration guidanceDFRobot
  2. ESP32 ADC — ADC1 versus ADC2 and the Wi-Fi conflictEspressif
  3. Soil water content measurement methods — a review of dielectric techniquesScienceDirect
  4. FAO Irrigation and Drainage Paper 56 — crop evapotranspirationFAO
  5. IRLZ44N logic-level N-channel MOSFET — datasheetInfineon
  6. Root rot and over-watering in container plantsPenn State Extension