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.
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
| Setting | How it is used |
|---|---|
| Solar borewell irrigation | The core case: a submersible solar pump filling an overhead or ground tank for drip/flood irrigation without diesel or grid power. |
| Village drinking-water supply | A community solar pump filling a storage tank, where an unattended dry-run burnout means days without water and a costly repair. |
| Livestock watering | Keeping troughs and tanks topped up automatically on remote grazing land far from mains power. |
| Small horticulture / polyhouse | Managed 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 10–16 hours |
| Indicative build cost | ₹3,000 – ₹4,500 |
| Primary discipline | Agriculture |
| Reference platform | ESP32 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.
| Component | Key specification | Qty | Approx. 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 DAC | 1 | ₹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° beam | 1 | ₹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 isolation | 1 | ₹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 error | 1 | ₹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 input | 1 | ₹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 nm | 1 | ₹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 margin | 1 | ₹650 |
| Float switch (backstop) Independent hardware overflow protection | Normally-closed, wired in series with the contactor coil at tank-full | 1 | ₹180 |
| Waterproof enclosure + glands | IP65, room for contactor + controller, DIN or panel mount | 1 | ₹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 larger | 1 | ₹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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| 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 DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| JSN-SR04T waterproof ultrasonic sensor | 25–450 cm, ±1 cm, IP67 sealed transducer, 45° beam | 5 V | Trigger/Echo or UART | Datasheet |
| ACS712 hall-effect current sensor (20 A) | ±20 A, 100 mV/A, 80 kHz bandwidth, 1.2 mΩ internal resistance, 2.1 kV isolation | 5 V | Analogue | Datasheet |
| INA219 high-side DC current/power monitor | 0–26 V bus, ±3.2 A with 0.1 Ω shunt, 12-bit, ±0.5 % gain error | 3–5.5 V | I²C (0x40–0x4F) | Datasheet |
| 5 V single-channel opto-isolated relay module | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 5 V coil | Digital (active-low) | Datasheet |
| LDR (GL5528 photoresistor) + 10 kΩ divider | 10–20 kΩ at 10 lx, 1 MΩ dark, peak response 540 nm | any (passive) | Analogue divider | Datasheet |
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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| JSN-SR04T waterproof ultrasonic sensor | 5 V | 30 | The 25 cm blind zone matters — mount it above the maximum expected water level. |
| ACS712 hall-effect current sensor (20 A) | 5 V | 10 | Zero-offset drifts with temperature — re-zero at boot with no load. |
| INA219 high-side DC current/power monitor | 3–5.5 V | 1 | Reports bus voltage, shunt voltage, current and power directly — no maths needed. |
| 5 V single-channel opto-isolated relay module | 5 V coil | 70 | Cut the JD-VCC jumper and feed the coil separately for true isolation. |
| LDR (GL5528 photoresistor) + 10 kΩ divider | any (passive) | 0.3 | Cheap 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| JSN-SR04T | TRIG/ECHO | GPIO 26/25 | Waterproof ultrasonic tank level |
| ACS712 / CT | OUT | GPIO 34 (ADC) | Pump current signature |
| INA219 | SDA/SCL | GPIO 21/22 | Panel voltage/current (I²C) |
| LDR / panel V | AOUT | GPIO 35 (ADC) | Sunlight-sufficient signal |
| Relay → contactor coil | IN | GPIO 27 | Pump on/off via contactor |
| Float switch | NC | In series with coil | Hardware tank-full backstop |
| Status LED/buzzer | IN | GPIO 13 | Local run / fault indicator |
| Supply | 5V/3V3 | Buck from panel/battery | Controller 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.
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.
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
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
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
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.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
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.
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.
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.
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.
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.
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.inoenum 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.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.
/* ═══════════════════════════════════════════════════════════════
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
}
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.
Tank scale
Record ultrasonic distance at empty and full; verify the computed fill fraction matches a physical mark at a couple of intermediate levels.
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.
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
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 / endpoint | Direction | Payload |
|---|---|---|
pump/1/status | node → broker | state, fill, current, run-time |
pump/1/alert | node → broker | dry-run, tank-full-timeout, no-sun |
pump/1/cmd | broker → node | force-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.
| Test | What you should see |
|---|---|
| Empty tank, full sun | Controller starts the pump, reaches RUNNING, learns I_run |
| Simulate tank filling to HIGH | Pump 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 sensor | Pump will not start; if running with sun lost, it stops |
| Open the float switch at tank-full | Contactor drops out in hardware regardless of software state |
| Power-cycle mid-run | Run-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.
{
"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.
Troubleshooting: Common Errors & Fixes
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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.