Siddhant Kumar
Project 034 · Agriculture

Solar Pump Controller.

Runs a solar borewell pump only when there is enough sun and the tank actually needs water, and protects it from the dry-run that kills submersible pumps.

Intermediate 10–16 hours 35 min read SolarPumpAutomation
Jump to source Bill of materials
Solar Pump Controller — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
10–16 hours
Indicative cost
₹3,000 – ₹4,500
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Agriculture
Last updated
28 July 2026
Contents — 26 sections

Project Overview

Runs a solar borewell pump only when there is enough sun and the tank actually needs water, and protects it from the dry-run that kills submersible pumps.

A solar borewell pump is a wonderful thing until the day it runs dry. When the water level in the well drops below the pump intake — which happens on hot afternoons exactly when the sun is strongest and the pump is running hardest — a submersible pump keeps spinning with no water to cool or lubricate it. Within minutes the seals cook and the motor burns out, and a farmer who bought a pump to save money on diesel is suddenly facing its full replacement cost. This controller sits between the solar array and the pump and makes the pump smart: it runs when the sun is strong and the tank needs filling, and it shuts down the instant it detects a dry run.

The logic is deliberately simple and robust because it protects an expensive asset in a place with no technician nearby. An ultrasonic sensor watches the tank level so the pump stops when the tank is full and starts when it draws down. A current sensor on the pump feed watches the motor: a pump that suddenly draws much less current than normal is almost always running dry or has lost prime, and that signature triggers an immediate protective stop and a cool-down timer. A light sensor (or the panel's own voltage) tells the controller whether there is enough sun to start at all, so it never tries to start a pump on a cloudy morning and stall it.

Everything is logged and reported so the farmer can see, from a phone, how much the pump ran, how many dry-run events were caught, and whether the tank is keeping up with demand. The controller turns a bare solar pump into a managed irrigation resource — one that protects itself, waters on its own, and tells you when something is wrong before the crop or the pump suffers.

A photovoltaic solar panel in sunlight
A solar array feeds the borewell pump; the controller decides when it is worth starting and protects it while it runs. Photograph sourced from Wikimedia Commons — Solar panel.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Starts the pump only when solar power is sufficient and the tank needs water
  • Stops automatically when the tank is full (ultrasonic level)
  • Detects a dry run from the pump's current signature and stops to protect the motor
  • Enforces a cool-down before retrying after a dry-run trip
  • Logs run-time, water delivered (estimated) and every protective event
  • Reports status and alerts to a phone over Wi-Fi or LoRa
  • Falls back to a hardware float switch so the tank never overflows even if software fails

Real-World Applications

SettingHow it is used
Solar borewell irrigationThe core case: a submersible solar pump filling an overhead or ground tank for drip/flood irrigation without diesel or grid power.
Village drinking-water supplyA community solar pump filling a storage tank, where an unattended dry-run burnout means days without water and a costly repair.
Livestock wateringKeeping troughs and tanks topped up automatically on remote grazing land far from mains power.
Small horticulture / polyhouseManaged filling of a header tank that feeds fertigation, where consistent supply and pump longevity both matter.

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

Features & Capabilities

  • Dry-run protection by motor-current signature — saves the pump's life
  • Sun-aware starting so the pump never stalls on weak light
  • Tank-level control with a hardware float-switch backstop
  • Cool-down and retry logic instead of hammering a failing pump
  • Run-time and water-delivered logging for irrigation planning
  • Contactor drive so the low-voltage controller safely switches a mains/48 V pump
  • Phone alerts for dry-run, tank-full and no-sun conditions

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time10–16 hours
Indicative build cost₹3,000 – ₹4,500
Primary disciplineAgriculture
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Reading an ultrasonic level sensor and rejecting spurious echoes
  • Non-invasive current sensing with a Hall-effect sensor (ACS712 / clamp)
  • Driving a contactor from a microcontroller to switch a high-power pump safely
  • Designing protective state machines (dry-run trip, cool-down, retry)
  • Solar power basics: knowing when there is enough light to start a load

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
JSN-SR04T waterproof ultrasonic sensor
The 25 cm blind zone matters — mount it above the maximum expected water level.
25–450 cm, ±1 cm, IP67 sealed transducer, 45° beam1₹450
ACS712 hall-effect current sensor (20 A)
Zero-offset drifts with temperature — re-zero at boot with no load.
±20 A, 100 mV/A, 80 kHz bandwidth, 1.2 mΩ internal resistance, 2.1 kV isolation1₹180
INA219 high-side DC current/power monitor
Reports bus voltage, shunt voltage, current and power directly — no maths needed.
0–26 V bus, ±3.2 A with 0.1 Ω shunt, 12-bit, ±0.5 % gain error1₹260
5 V single-channel opto-isolated relay module
Cut the JD-VCC jumper and feed the coil separately for true isolation.
SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input1₹90
LDR (GL5528 photoresistor) + 10 kΩ divider
Cheap and rugged, but non-linear and unit-to-unit variation is large.
10–20 kΩ at 10 lx, 1 MΩ dark, peak response 540 nm1₹15
AC/DC contactor rated for the pump
Choose AC or DC coil to match your control supply
Coil driven by the relay; contacts rated ≥ pump full-load current with margin1₹650
Float switch (backstop)
Independent hardware overflow protection
Normally-closed, wired in series with the contactor coil at tank-full1₹180
Waterproof enclosure + glandsIP65, room for contactor + controller, DIN or panel mount1₹550
Current transformer or ACS712 module
ACS712 for DC/small AC; a CT for larger AC pumps
Sized to the pump current; ACS712-30A for small pumps, CT clamp for larger1₹220

Estimated total: ₹3,045, 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
JSN-SR04T waterproof ultrasonic sensor25–450 cm, ±1 cm, IP67 sealed transducer, 45° beam5 VTrigger/Echo or UARTDatasheet
ACS712 hall-effect current sensor (20 A)±20 A, 100 mV/A, 80 kHz bandwidth, 1.2 mΩ internal resistance, 2.1 kV isolation5 VAnalogueDatasheet
INA219 high-side DC current/power monitor0–26 V bus, ±3.2 A with 0.1 Ω shunt, 12-bit, ±0.5 % gain error3–5.5 VI²C (0x40–0x4F)Datasheet
5 V single-channel opto-isolated relay moduleSPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input5 V coilDigital (active-low)Datasheet
LDR (GL5528 photoresistor) + 10 kΩ divider10–20 kΩ at 10 lx, 1 MΩ dark, peak response 540 nmany (passive)Analogue dividerDatasheet

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.
JSN-SR04T waterproof ultrasonic sensor5 V30The 25 cm blind zone matters — mount it above the maximum expected water level.
ACS712 hall-effect current sensor (20 A)5 V10Zero-offset drifts with temperature — re-zero at boot with no load.
INA219 high-side DC current/power monitor3–5.5 V1Reports bus voltage, shunt voltage, current and power directly — no maths needed.
5 V single-channel opto-isolated relay module5 V coil70Cut the JD-VCC jumper and feed the coil separately for true isolation.
LDR (GL5528 photoresistor) + 10 kΩ dividerany (passive)0.3Cheap and rugged, but non-linear and unit-to-unit variation is large.

Summed typical draw is 271.3 mA. With a 1.5× design margin the supply should deliver at least 500 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
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.

Solar Pump Controller — system block diagramFunctional block diagram of the Solar Pump Controller system. SenseTank levelJSN-SR04T ultrasonicPump currentACS712 / CTSunLDR / panel VDecideESP32state machineProtectdry-run + cool-downActuateRelaycoil driverContactorswitches pumpFloat NCHW backstopReportWi-Fi/LoRastatus + alertsrightrightnone
Solar Pump Controller — 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.

Solar Pump Controller — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBJSN-SR04TGPIO 26/25Waterproofultrasonic tanklevelACS712 / CTGPIO 34 (ADC)Pump currentsignatureINA219GPIO 21/22Panelvoltage/current(I²C)LDR / panel VGPIO 35 (ADC)Sunlight-sufficientsignalRelay → contactor coilGPIO 27Pump on/off viacontactorFloat switchIn series with coilHardware tank-fullbackstopStatus LED/buzzerGPIO 13Local run / faultindicatorSupplyBuck from panel/batteryController power
Solar Pump Controller — wiring schematic
PeripheralPeripheral pinController pinSignal
JSN-SR04TTRIG/ECHOGPIO 26/25Waterproof ultrasonic tank level
ACS712 / CTOUTGPIO 34 (ADC)Pump current signature
INA219SDA/SCLGPIO 21/22Panel voltage/current (I²C)
LDR / panel VAOUTGPIO 35 (ADC)Sunlight-sufficient signal
Relay → contactor coilINGPIO 27Pump on/off via contactor
Float switchNCIn series with coilHardware tank-full backstop
Status LED/buzzerINGPIO 13Local run / fault indicator
Supply5V/3V3Buck from panel/batteryController power

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

  • The ESP32 drives a relay; the relay switches the contactor coil; the contactor switches the pump. The microcontroller never carries pump current.
  • Wire the float switch (normally-closed at tank-full) in series with the contactor coil. When the tank is full the coil drops out regardless of software — this is the overflow backstop.
  • Mount the JSN-SR04T transducer looking down at the tank water from the top, clear of the inlet splash, so echoes are clean.
  • Place the current sensor on the pump feed conductor: ACS712 in-line for small DC/AC pumps, or a clip-on CT for larger AC pumps to avoid breaking the conductor.
  • Give the contactor coil a flyback/snubber appropriate to its coil type so switching transients do not reset the ESP32.
  • Keep the ultrasonic and current signal grounds tied to the ESP32 ADC ground reference for stable analogue readings.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
ESP32 module running the protective state machine — sun, tank and current in, contactor drive out. 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.

Solar Pump Controller — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · JSN-SR04T waterproof ultrasonic sensor ·ACS712 hall-effect current sensor (20 A)Driver layerwifi · pubsub · arduinojson · preferencesApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT (LoRa for remote wells) · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Solar Pump Controller — architecture stack

Working Principle

The controller is a protective state machine wrapped around three questions: is there enough sun to run, does the tank need water, and is the pump running healthily. Only when the first two are yes does it start; while running, the third question is checked continuously and can stop the pump in a fraction of a second.

Dry-run detection rests on a simple fact of centrifugal pumps: a pump moving water does work and draws its rated current; a pump spinning in air (dry, or having lost prime) does far less work and draws noticeably less current. So the protective signal is not an over-current — it is an under-current relative to the pump's learned normal running draw. When current collapses below a fraction of the learned baseline while the pump should be pumping, the controller trips immediately. This catches dry runs, lost prime and a closed/blocked delivery before heat destroys the seals.

The sun check prevents a different failure: starting a pump on marginal light. A solar pump fed by weak sun may draw current, strain, and stall without ever moving water — which itself looks like and can cause a dry-run-like stress. By requiring a minimum irradiance (from an LDR or, better, the panel's open-circuit/loaded voltage) before starting, the controller only ever commits to a start it can sustain.

Tank-level control closes the outer loop. The ultrasonic sensor measures the distance to the water surface; knowing the tank geometry converts that to a fill fraction. The pump starts when the tank draws down past a low mark and stops at a high mark, with hysteresis so it does not chatter on and off around a single level. And underneath all of it sits the float switch — a piece of hardware that drops the contactor coil at tank-full no matter what the software believes, so the tank physically cannot overflow.

The maths behind it

Tank fill fraction from ultrasonic distance

plainTank fill fraction from ultrasonic distance
Sensor measures distance d to the water surface.
Tank of height H, sensor mounted at the top:

  water_depth = H − d
  fill_fraction = (H − d) / H          (clamp to [0,1])

Start pump when fill_fraction < LOW  (e.g. 0.30)
Stop  pump when fill_fraction > HIGH (e.g. 0.90)
Hysteresis (HIGH−LOW) prevents on/off chatter.

Dry-run detection by under-current

plainDry-run detection by under-current
Learn the healthy running current I_run over the first
seconds of a good run (pump primed, moving water).

  dry if I_now < k · I_run   (e.g. k = 0.6) for t > t_debounce

A pump in air does far less work → draws far less current.
Debounce (t_debounce ~ 3–5 s) rejects the start-up inrush
and momentary air slugs.

Water delivered estimate

plainWater delivered estimate
Without a flow meter, estimate volume from run-time:

  V ≈ Q_rated · t_run · η_head

Q_rated is the pump's rated flow at the working head,
t_run the accumulated run-time, η_head a derating for the
actual head vs rated. A cheap way to log daily delivery;
add a flow sensor later for true measurement.

Program Flowchart

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

Solar Pump Controller — firmware flowchartControl flow through the main program loop. IdleEnough sun and tank notfull?Start pump via contactorStay idleStay idleStart pump via contactorCurrent normal (not dry)?Run + fill tankTrip: stop + cool-downRun + fill tankTank full or sun gone?Stop pumpRun + fill tankTrip: stop + cool-downStop pump
Solar Pump Controller — 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.

  1. Mount the control enclosure

    Fit the contactor and the low-voltage controller in one IP65 box near the pump's electrical connection. Keep the pump-power wiring (contactor input/output) physically separated from the signal wiring inside the box.

    Bring the pump feed through the contactor. Have the current sensor on the pump-side conductor so it sees the motor's draw.

  2. Install the tank sensor and float

    Mount the JSN-SR04T transducer at the top of the tank, aimed straight down at the water, away from the inlet splash and tank walls that cause false echoes.

    Fit the float switch at the tank-full level and wire its normally-closed contact in series with the contactor coil, so a full tank physically opens the coil circuit.

  3. Wire the sun sensor

    Place the LDR (or tap the panel voltage through a divider into an ADC pin) where it sees the same sky the array does, so "enough sun" reflects what the panel actually receives.

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 the empty/full tank levels

    With the tank empty, record the ultrasonic distance; with it full, record it again. These two numbers define the fill-fraction scale for your specific tank.

  2. Learn the healthy running current

    On a known-good, primed run, capture the steady running current a few seconds after start (past inrush). Store it as the baseline the dry-run test compares against.

  3. Implement the protective state machine

    Move between IDLE, STARTING, RUNNING, and COOLDOWN. Enter STARTING only when sun and tank demand agree; require healthy current to reach RUNNING; drop to COOLDOWN on a dry-run trip and refuse to restart until the timer expires.

    cpppump-fsm.ino
    enum State { IDLE, STARTING, RUNNING, COOLDOWN };
    State st = IDLE;
    uint32_t stateSince = 0;
    float I_run = 0;              // learned healthy running current
    
    void setPump(bool on) { digitalWrite(PIN_RELAY, on ? HIGH : LOW); }
    
    void tick(float fill, float sun, float I, uint32_t now) {
      switch (st) {
        case IDLE:
          if (sun > SUN_MIN && fill < LOW) enter(STARTING, now);
          break;
    
        case STARTING:                       // spin up, ride out inrush
          setPump(true);
          if (now - stateSince > START_MS) {
            I_run = I;                        // capture healthy running draw
            enter(RUNNING, now);
          }
          break;
    
        case RUNNING:
          setPump(true);
          if (I < DRY_K * I_run && now - stateSince > DRY_DEBOUNCE_MS) {
            setPump(false);
            alert("Dry-run: current %.1fA < %.1fA. Cooling down.", I, DRY_K*I_run);
            enter(COOLDOWN, now);
          } else if (fill > HIGH || sun < SUN_MIN) {
            setPump(false);
            enter(IDLE, now);                 // tank full or sun gone
          }
          break;
    
        case COOLDOWN:
          setPump(false);
          if (now - stateSince > COOLDOWN_MS) enter(IDLE, now);
          break;
      }
    }
    
    void enter(State s, uint32_t now) { st = s; stateSince = now; }
    if (sun > SUN_MIN && fill < LOW)The pump only ever starts when there is enough sun to sustain it and the tank actually needs water — never on marginal light.
    I_run = I;A few seconds into a good run, past the inrush, the controller learns the pump's healthy current so the dry-run test compares against reality, not a fixed guess.
    if (I < DRY_K * I_runDry running shows up as a current collapse below a fraction of the learned normal; debounced, it trips an immediate protective stop.
    case COOLDOWNAfter a trip the pump is held off for a fixed cool-down instead of being hammered on and off, letting the well recover and the motor cool.
  4. Log and report

    Accumulate run-time, estimate water delivered, count dry-run trips, and publish status plus any alerts to the phone/dashboard each cycle and on every state change.

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.

cppsolar-pump-controller.ino
/* ═══════════════════════════════════════════════════════════════
   Solar Pump Controller — ESP32

   Runs a solar borewell pump only with sufficient sun and tank demand,
   protects it from dry-running by motor-current signature, enforces a
   cool-down, and reports status/alerts. Drives a contactor; a hardware
   float switch in series with the coil is the overflow backstop.
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Preferences.h>

#define PIN_TRIG   26
#define PIN_ECHO   25
#define PIN_CUR    34     // ACS712 / CT signal (ADC)
#define PIN_SUN    35     // LDR or panel-voltage divider (ADC)
#define PIN_RELAY  27     // -> contactor coil
#define PIN_LED    13

#define TANK_H_CM   200.0f
#define LOW_FRAC    0.30f
#define HIGH_FRAC   0.90f
#define SUN_MIN     0.35f   // normalised sun threshold to start
#define DRY_K       0.60f   // trip below 60% of learned running current
#define START_MS    5000    // ride out inrush, then learn baseline
#define DRY_DEBOUNCE_MS 4000
#define COOLDOWN_MS 600000  // 10 min after a dry-run trip

enum State { IDLE, STARTING, RUNNING, COOLDOWN };
State st = IDLE;
uint32_t stateSince = 0;
float I_run = 0, runMinutes = 0;
uint32_t dryTrips = 0, lastRunTick = 0;

Preferences prefs;
WiFiClient net;
PubSubClient mqtt(net);

/* ── sensors ─────────────────────────────────────────────────── */
float readDistanceCm() {
  digitalWrite(PIN_TRIG, LOW);  delayMicroseconds(2);
  digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
  digitalWrite(PIN_TRIG, LOW);
  long us = pulseIn(PIN_ECHO, HIGH, 30000);
  if (us == 0) return NAN;                 // no echo
  return us / 58.0f;                       // cm
}

// Median of several pings rejects the odd spurious echo.
float tankFill() {
  float d[5];
  for (int i = 0; i < 5; i++) { d[i] = readDistanceCm(); delay(60); }
  for (int i = 0; i < 4; i++) for (int j = i+1; j < 5; j++)
    if (d[j] < d[i]) { float t = d[i]; d[i] = d[j]; d[j] = t; }
  float dist = d[2];
  if (isnan(dist)) return -1;
  float frac = (TANK_H_CM - dist) / TANK_H_CM;
  return constrain(frac, 0.0f, 1.0f);
}

float sunLevel() {                          // normalised 0..1
  return analogRead(PIN_SUN) / 4095.0f;
}

// RMS-ish current from the sensor (offset-removed, scaled).
float pumpCurrent() {
  const int N = 400; long acc = 0; int mid = 2048;
  for (int i = 0; i < N; i++) {
    int v = analogRead(PIN_CUR) - mid;
    acc += (long)v * v;
  }
  float rms = sqrtf((float)acc / N);
  return rms * CUR_SCALE;                   // A; CUR_SCALE from calibration
}

/* ── state machine ───────────────────────────────────────────── */
void setPump(bool on) {
  digitalWrite(PIN_RELAY, on ? HIGH : LOW);
  digitalWrite(PIN_LED,   on ? HIGH : LOW);
}
void enter(State s, uint32_t now) { st = s; stateSince = now; }

void publish(float fill, float sun, float I) {
  char buf[220];
  const char *sn[] = {"idle","starting","running","cooldown"};
  snprintf(buf, sizeof buf,
    "{\"state\":\"%s\",\"fill\":%.2f,\"sun\":%.2f,\"I\":%.2f,"
    "\"I_run\":%.2f,\"run_min\":%.0f,\"dry_trips\":%u}",
    sn[st], fill, sun, I, I_run, runMinutes, dryTrips);
  mqtt.publish("pump/1/status", buf);
}

void tick() {
  uint32_t now = millis();
  float fill = tankFill();
  float sun  = sunLevel();
  float I    = pumpCurrent();

  switch (st) {
    case IDLE:
      setPump(false);
      if (sun > SUN_MIN && fill >= 0 && fill < LOW_FRAC) enter(STARTING, now);
      break;
    case STARTING:
      setPump(true);
      if (now - stateSince > START_MS) { I_run = I; enter(RUNNING, now); }
      break;
    case RUNNING:
      setPump(true);
      if (now - lastRunTick > 60000) { runMinutes += 1; lastRunTick = now; }
      if (I < DRY_K * I_run && now - stateSince > DRY_DEBOUNCE_MS) {
        setPump(false);
        dryTrips++;
        mqtt.publish("pump/1/alert", "dry-run trip");
        prefs.begin("pump", false);
        prefs.putUInt("dry", dryTrips);
        prefs.putFloat("run", runMinutes);
        prefs.end();
        enter(COOLDOWN, now);
      } else if (fill > HIGH_FRAC || sun < SUN_MIN) {
        setPump(false);
        enter(IDLE, now);
      }
      break;
    case COOLDOWN:
      setPump(false);
      if (now - stateSince > COOLDOWN_MS) enter(IDLE, now);
      break;
  }
  publish(fill, sun, I);
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_TRIG, OUTPUT);
  pinMode(PIN_ECHO, INPUT);
  pinMode(PIN_RELAY, OUTPUT);
  pinMode(PIN_LED, OUTPUT);
  setPump(false);
  analogSetPinAttenuation(PIN_CUR, ADC_11db);
  analogSetPinAttenuation(PIN_SUN, ADC_11db);

  prefs.begin("pump", true);
  runMinutes = prefs.getFloat("run", 0);
  dryTrips   = prefs.getUInt("dry", 0);
  prefs.end();

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  mqtt.setServer(MQTT_HOST, 1883);
}

void loop() {
  if (!mqtt.connected()) mqtt.connect("pump-1");
  mqtt.loop();
  tick();
  delay(1000);                              // 1 Hz control loop
}
float tankFill()Takes five ultrasonic pings and uses the median, so a single spurious echo off the inlet splash or a tank wall cannot fake a level and mis-trigger the pump.
float pumpCurrent()Computes an RMS-style current from many fast ADC samples of the AC/DC current sensor, which is what the dry-run test needs — a stable measure of how hard the motor is working.
if (sun > SUN_MIN && fill >= 0 && fill < LOW_FRAC)The start condition is a conjunction: enough sun, a valid level reading, and genuine tank demand. A failed level read (fill < 0) blocks the start rather than guessing.
prefs.putUInt("dry", dryTrips)Run-time and dry-trip counts survive power loss, so the season's log and the pump's protective history are not wiped every sunset.
delay(1000)A one-second control loop is fast enough to catch a dry run within the debounce window while keeping the ADC and Wi-Fi work light.

Configuration & Calibration

Configuration steps

  • Set TANK_H_CM and the LOW/HIGH fractions to your tank geometry and desired reserve.
  • Calibrate CUR_SCALE against a clamp meter so pumpCurrent() reads true amps.
  • Tune DRY_K (fraction of running current that counts as dry) to your pump — start at 0.6 and adjust after watching a real dry run.
  • Set SUN_MIN from observation: the lowest sun level at which your pump reliably sustains a run.
  • Pick the reporting transport (Wi-Fi/MQTT here, or LoRa for remote wells) and the cool-down duration.

Calibration procedure

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

  1. Tank scale

    Record ultrasonic distance at empty and full; verify the computed fill fraction matches a physical mark at a couple of intermediate levels.

  2. Current scale

    With the pump running normally, compare pumpCurrent() to a clamp-meter reading and set CUR_SCALE so they agree. This is the anchor for dry-run detection.

  3. Dry-run threshold

    Deliberately (and briefly, safely) create a lost-prime or closed-valve condition and watch the current drop; set DRY_K comfortably above the dry current but below the healthy current.

Network Architecture & Connectivity

Solar Pump Controller — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsPump controllerESP32Tank sensoron the same nodeWi-Fi 2.4 GHzRouter / LoRa GWor LoRa for a remote wellMQTT 1883Broker + dashboardrun-time & eventsDashboardstate, fill, currentPhonedry-run alerts
Solar Pump Controller — network topology

Communication protocol

Status JSON is published at roughly 1 Hz locally and throttled to a sensible rate over the network, with an immediate publish on any state change or protective trip so a dry-run event reaches the operator without waiting for the next heartbeat.

Topic / endpointDirectionPayload
pump/1/statusnode → brokerstate, fill, current, run-time
pump/1/alertnode → brokerdry-run, tank-full-timeout, no-sun
pump/1/cmdbroker → nodeforce-stop, enable/disable

Message contract between the device and the broker.

Cloud platform configuration

A broker plus a small dashboard trends run-time, estimated water delivered and every dry-run event; a command topic lets the owner force-stop or enable/disable the pump remotely without defeating the local hardware interlocks.

Dashboard setup

A live tile shows state, tank fill and live pump current against the learned running current, with daily bars of run-time and delivery and a red marker on every dry-run trip.

Mobile app integration

Push alerts fire for a dry-run trip, a tank-full-timeout (the pump ran but the tank is not filling — a likely blockage) and days with no usable sun.

Security considerations

  • Authenticate the command topic so only the owner can start or stop the pump remotely.
  • Keep the float-switch interlock and dry-run trip entirely in local hardware/firmware, independent of the network.
  • Alert on communication loss so a controller that has gone silent is investigated.

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
Empty tank, full sunController starts the pump, reaches RUNNING, learns I_run
Simulate tank filling to HIGHPump stops cleanly; state returns to IDLE
Simulate dry run (close valve / lost prime)Current collapses; trip within debounce; COOLDOWN entered; alert sent
Shade the sun sensorPump will not start; if running with sun lost, it stops
Open the float switch at tank-fullContactor drops out in hardware regardless of software state
Power-cycle mid-runRun-time and dry-trip counts restored from flash

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

Expected output

The dashboard shows the current state, tank fill, sun level, live pump current versus the learned running current, cumulative run-time and dry-trip count.

jsonstatus.json
{
  "state": "running",
  "fill": 0.42,
  "sun": 0.78,
  "I": 4.9,
  "I_run": 5.1,
  "run_min": 137,
  "dry_trips": 2
}

A dry-run event appears as the live current I dropping well below I_run, followed by a state change to "cooldown" and a "dry-run trip" alert on the phone.

An HC-SR04 ultrasonic distance sensor with its two transducers
A waterproof ultrasonic sensor reads the tank surface from above to close the fill-level control loop. Photograph sourced from Wikimedia Commons — HC-SR04.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Pump short-cycles on/off

Likely cause. LOW and HIGH fractions too close, or noisy level reads

Fix. Widen the hysteresis; keep the median-of-5 ping filter; move the transducer away from splash

False dry-run trips

Likely cause. DRY_K too high or CUR_SCALE wrong

Fix. Re-calibrate current against a clamp meter; lower DRY_K; lengthen the debounce slightly

Pump never starts

Likely cause. SUN_MIN too high, float switch open, or level read failing

Fix. Lower SUN_MIN; check the float wiring; confirm tankFill() returns a valid fraction

ESP32 resets when the pump switches

Likely cause. Contactor coil transient coupling into the controller supply

Fix. Add a proper snubber/flyback across the coil; separate control and power grounds/wiring

Tank overflows

Likely cause. Relying on software alone

Fix. This must never happen — verify the NC float switch is truly in series with the contactor coil

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

  • Run the control loop at ~1 Hz — fast enough to catch a dry run, light enough on the ADC and radio.
  • Average many ADC samples for current; a single sample is far too noisy to base a protective trip on.
  • Publish on change plus a slow heartbeat rather than streaming every second, to keep network and power modest.
  • If solar-powered controller electronics, deep-sleep the reporting stack overnight when the pump cannot run anyway.
  • 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.

Safety Precautions

  • The controller drives a correctly-rated contactor; it never switches pump power directly.
  • A normally-closed float switch in series with the contactor coil is a mandatory hardware overflow backstop, independent of software.
  • Have a qualified electrician do or verify all pump-power wiring and earthing/RCD protection.
  • Dry-run protection reduces but does not eliminate pump risk — do not defeat the pump's own thermal or motor-protection devices.
  • Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
  • Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
  • Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
  • Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
  • 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

  • Re-learn the healthy running current at the start of each season and after any pump service.
  • Clean the ultrasonic transducer face; scale or a spider web across it causes phantom levels.
  • Check the float switch physically moves freely and its contacts are sound.
  • Inspect the contactor contacts for pitting; a solar pump cycles them thousands of times a season.
  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • 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 true flow meter for measured (not estimated) delivery and a blockage alarm when current is normal but flow is low.
  • Add a well-level probe to distinguish a dry well from a dry pump.
  • Log energy from the INA219 to report water-per-kWh efficiency of the array.
  • MPPT-aware starting: begin only when the array can hold its maximum-power voltage under load.
  • 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

How does it detect a dry run without a special sensor?

A pump moving water draws its rated current; a pump spinning in air draws much less. The controller learns the healthy current, then trips when the current collapses below a fraction of it.

What if the software crashes with the pump on?

The float switch is wired in hardware series with the contactor coil, so at tank-full the pump drops out no matter what the software does. That is the whole point of the backstop.

Why check sunlight before starting?

Starting a solar pump on weak light can make it strain and stall without moving water, which stresses the motor. Requiring a minimum sun level means every start is one the array can actually sustain.

Can it run a mains or 48 V DC pump?

Yes — the controller drives a contactor sized for your pump, so the same control logic works for AC or DC pumps as long as the contactor and current sensor match the pump.

Does it measure how much water I pumped?

It estimates volume from run-time and rated flow out of the box; add a flow meter for a true measurement and a blockage alarm.

References & Learning Resources

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

  1. Submersible pump dry-run protection — application notesReference
  2. ACS712 Hall-effect current sensor (datasheet)Allegro
  3. JSN-SR04T waterproof ultrasonic ranging moduleReference
  4. Solar water pumping systems — design guidanceMNRE India
  5. Contactor selection and coil control basicsReference