Practical case: ESP32 Water Pump Controller

Practical case: ESP32 Water Pump Controller — hero

Objective and use case

What you’ll build: A smart, web-enabled water pump controller that monitors tank levels via physical float switches and automatically cuts power to prevent catastrophic dry-running.

Why it matters / Use cases

  • Equipment protection: Prevents motor and seal destruction within minutes by actively guarding against dry-running in centrifugal and submersible pumps.
  • Agricultural automation: Ensures hydroponic nutrient delivery pumps operate only when the reservoir has sufficient liquid.
  • Off-grid water transfer: Automates cistern-to-holding-tank transfers, pausing instantly if the source runs dry.
  • Local-first control: Operates entirely on a local WiFi Access Point (AP) with zero internet, cloud, or external MQTT dependency.

Expected outcome

  • The ESP32 broadcasts an independent WiFi network (ESP32_Pump_Guard) for direct, low-latency local access.
  • A hosted web dashboard displays real-time metrics: Tank Status, Pump Status, and the active/inactive state of the Dry-Run Guard.

Audience: IoT hobbyists, agricultural engineers, and off-grid enthusiasts; Level: Intermediate

Architecture/flow: Float switch inputs → ESP32 microcontroller → Relay module (pump power control) & Local Web Server (UI dashboard).

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this ESP32 DevKitC profile, the project was checked as a PlatformIO project: the validator extracted platformio.ini and src/main.cpp, created a temporary project and ran pio run against platform = espressif32, board = esp32dev and framework = arduino. It also checked article structure, copy/paste-safe ASCII command options, and unsupported stacks such as direct ESP-IDF or non-scoped ESP32 boards.

Published validation evidence

  • Automatic result: PASS.
  • Parsed structure: 4 sections, 1 tables and 5 code blocks detected before publication.
  • Checked code: 1 PlatformIO config + 1 ESP32 source/pio run, 3 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and unsupported stacks block publication.
  • Report findings: no blocking findings.

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ESP32 DevKitC board, wiring, power supply and local WiFi environment.

Educational safety note

This project is a low-voltage educational prototype, not a certified product. Before powering the setup, verify the pinout of your exact ESP32 DevKitC board, keep GPIO signals within 3.3 V limits, never apply 5 V to ESP32 inputs, disconnect power before changing wiring, and use suitable external supplies for relays, motors or loads while sharing GND only when the schematic requires it.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

Water probe

ESP32 GPIO/ADC

Threshold logic

LED/buzzer

Wi-Fi alert

Conceptual flow: moisture detection, local decision and user alert.

Validation path

Source code

PlatformIO build

Flash

Serial monitor

Conceptual summary of the tools used to check the published ESP32 project.

Prerequisites and Materials

  • Software: Visual Studio Code (VSCode) with the PlatformIO IDE extension installed.
  • Microcontroller: ESP32 DevKitC V4 (38-pin or 30-pin variant).
  • Sensors: 2x Vertical Liquid Level Float Switches (Standard PP plastic).
  • Actuator: 1x 5 V Relay Module (Standard 1-channel, Active-HIGH).
  • Power: 5 V / 2 A USB power supply.
  • Hardware Setup Note (Drivers): Depending on your specific ESP32 DevKitC manufacturer, you may need to install the CP210x or CH34x USB-to-UART bridge drivers on your computer to allow PlatformIO to recognize the board over USB.

Setup and Connection

This project uses the ESP32’s internal pull-up resistors for the float switches, simplifying wiring by removing the need for external resistors.

Wiring Table

ComponentComponent Pin / WireESP32 DevKitC PinNotes
Low Float SwitchWire 1GPIO 27Bottom of tank (Dry-run guard).
Low Float SwitchWire 2GNDCloses to GND when float is UP (water present).
High Float SwitchWire 1GPIO 14Top of tank (Full indicator).
High Float SwitchWire 2GNDCloses to GND when float is UP (tank full).
5 V Relay ModuleVCC5V / VINPowers the relay coil.
5 V Relay ModuleGNDGNDCommon ground.
5 V Relay ModuleIN (Signal)GPIO 26Active-HIGH signal to trigger the relay.

Float Switch Orientation: Configure both switches so that when the float is resting at the bottom (no water), the switch is OPEN (disconnected, reads HIGH via pull-up). When water lifts the float UP, the switch becomes CLOSED (connected to GND, reads LOW).

Project Code

Create a new PlatformIO project for the ESP32 DevKitC. Replace the contents of platformio.ini and src/main.cpp with the code below.

platformio.ini

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200

src/main.cpp

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>

// ---------------------------------------------------------
// Pin Definitions
// ---------------------------------------------------------
const int RELAY_PIN = 26;
const int LOW_FLOAT_PIN = 27;
const int HIGH_FLOAT_PIN = 14;

// ---------------------------------------------------------
// State Variables
// ---------------------------------------------------------
bool pumpIsOn = false;
bool lowFloatActive = false;  // true = tank empty (dry run risk)
bool highFloatActive = false; // true = tank full

// ---------------------------------------------------------
// Network & Web Server Setup
// ---------------------------------------------------------
const char* AP_SSID = "ESP32_Pump_Guard";
const char* AP_PASS = "admin1234";

WebServer server(80);

// ---------------------------------------------------------
// HTML Dashboard (Stored in PROGMEM)
// ---------------------------------------------------------
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pump Dry-Run Guard</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333; text-align: center; margin: 0; padding: 20px; }
        .card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto 20px auto; }
        h1 { color: #2c3e50; }
        .status-badge { display: inline-block; padding: 8px 15px; border-radius: 20px; font-weight: bold; color: white; margin-bottom: 10px; }
        .bg-green { background-color: #27ae60; }
        .bg-red { background-color: #e74c3c; }
        .bg-gray { background-color: #7f8c8d; }
        button { padding: 12px 24px; font-size: 16px; margin: 10px; border: none; border-radius: 5px; cursor: pointer; transition: 0.3s; color: white; font-weight: bold;}
        .btn-on { background-color: #3498db; }
        .btn-on:hover { background-color: #2980b9; }
        .btn-off { background-color: #95a5a6; }
        .btn-off:hover { background-color: #7f8c8d; }
        .data-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #eee; }
        .data-row:last-child { border-bottom: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Pump Controller</h1>
        <div id="guard-badge" class="status-badge bg-gray">Loading Status...</div>

        <div class="data-row">
            <span>Tank Level:</span>
            <strong id="tank-level">Unknown</strong>
        </div>
        <div class="data-row">
            <span>Pump State:</span>
            <strong id="pump-state">Unknown</strong>
        </div>

        <div style="margin-top: 20px;">
            <button class="btn-on" onclick="controlPump('on')">Start Pump</button>
            <button class="btn-off" onclick="controlPump('off')">Stop Pump</button>
        </div>
    </div>

    <script>
        function updateDashboard() {
            fetch('/api/status')
                .then(response => response.json())
                .then(data => {
                    document.getElementById('tank-level').innerText = data.tank_status;
                    document.getElementById('pump-state').innerText = data.pump_on ? "RUNNING" : "STOPPED";
                    const badge = document.getElementById('guard-badge');
                    if (data.dry_run_guard) {
                        badge.innerText = "DRY RUN GUARD ACTIVE";
                        badge.className = "status-badge bg-red";
                    } else {
                        badge.innerText = "SYSTEM SAFE";
                        badge.className = "status-badge bg-green";
                    }
                })
                .catch(err => console.error('Error fetching status:', err));
        }

        function controlPump(action) {
            fetch('/api/pump?state=' + action, { method: 'POST' })
                .then(response => response.json())
                .then(data => {
                    if (data.error) {
                        alert("Command rejected: " + data.error);
                    }
                    updateDashboard();
                });
        }
// ...

🔒 This content is premium. With the monthly subscription (7-day free trial) you can unlock the full didactic material and the print-ready PDF pack.🔓 Unlock it — 7-day free trial
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>

// ---------------------------------------------------------
// Pin Definitions
// ---------------------------------------------------------
const int RELAY_PIN = 26;
const int LOW_FLOAT_PIN = 27;
const int HIGH_FLOAT_PIN = 14;

// ---------------------------------------------------------
// State Variables
// ---------------------------------------------------------
bool pumpIsOn = false;
bool lowFloatActive = false;  // true = tank empty (dry run risk)
bool highFloatActive = false; // true = tank full

// ---------------------------------------------------------
// Network & Web Server Setup
// ---------------------------------------------------------
const char* AP_SSID = "ESP32_Pump_Guard";
const char* AP_PASS = "admin1234";

WebServer server(80);

// ---------------------------------------------------------
// HTML Dashboard (Stored in PROGMEM)
// ---------------------------------------------------------
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pump Dry-Run Guard</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f4f7f6; color: #333; text-align: center; margin: 0; padding: 20px; }
        .card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto 20px auto; }
        h1 { color: #2c3e50; }
        .status-badge { display: inline-block; padding: 8px 15px; border-radius: 20px; font-weight: bold; color: white; margin-bottom: 10px; }
        .bg-green { background-color: #27ae60; }
        .bg-red { background-color: #e74c3c; }
        .bg-gray { background-color: #7f8c8d; }
        button { padding: 12px 24px; font-size: 16px; margin: 10px; border: none; border-radius: 5px; cursor: pointer; transition: 0.3s; color: white; font-weight: bold;}
        .btn-on { background-color: #3498db; }
        .btn-on:hover { background-color: #2980b9; }
        .btn-off { background-color: #95a5a6; }
        .btn-off:hover { background-color: #7f8c8d; }
        .data-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #eee; }
        .data-row:last-child { border-bottom: none; }
    </style>
</head>
<body>
    <div class="card">
        <h1>Pump Controller</h1>
        <div id="guard-badge" class="status-badge bg-gray">Loading Status...</div>

        <div class="data-row">
            <span>Tank Level:</span>
            <strong id="tank-level">Unknown</strong>
        </div>
        <div class="data-row">
            <span>Pump State:</span>
            <strong id="pump-state">Unknown</strong>
        </div>

        <div style="margin-top: 20px;">
            <button class="btn-on" onclick="controlPump('on')">Start Pump</button>
            <button class="btn-off" onclick="controlPump('off')">Stop Pump</button>
        </div>
    </div>

    <script>
        function updateDashboard() {
            fetch('/api/status')
                .then(response => response.json())
                .then(data => {
                    document.getElementById('tank-level').innerText = data.tank_status;
                    document.getElementById('pump-state').innerText = data.pump_on ? "RUNNING" : "STOPPED";
                    const badge = document.getElementById('guard-badge');
                    if (data.dry_run_guard) {
                        badge.innerText = "DRY RUN GUARD ACTIVE";
                        badge.className = "status-badge bg-red";
                    } else {
                        badge.innerText = "SYSTEM SAFE";
                        badge.className = "status-badge bg-green";
                    }
                })
                .catch(err => console.error('Error fetching status:', err));
        }

        function controlPump(action) {
            fetch('/api/pump?state=' + action, { method: 'POST' })
                .then(response => response.json())
                .then(data => {
                    if (data.error) {
                        alert("Command rejected: " + data.error);
                    }
                    updateDashboard();
                });
        }

        setInterval(updateDashboard, 2000);
        updateDashboard();
    </script>
</body>
</html>
)rawliteral";

// ---------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------
void updateSensorStates() {
    // Switch Open (Float down/Empty) -> Pin is HIGH
    // Switch Closed (Float up/Water present) -> Pin is LOW
    lowFloatActive = (digitalRead(LOW_FLOAT_PIN) == HIGH); 
    highFloatActive = (digitalRead(HIGH_FLOAT_PIN) == LOW); 

    // Hardware Dry-Run Override Protection
    if (lowFloatActive && pumpIsOn) {
        Serial.println("CRITICAL: Dry run detected! Forcing pump OFF.");
        pumpIsOn = false;
        digitalWrite(RELAY_PIN, LOW); // Turn off relay safely
    }
}

String getTankStatusString() {
    if (lowFloatActive) return "Empty (Low Level)";
    if (highFloatActive) return "Full (High Level)";
    return "Normal (Mid Level)";
}

// ---------------------------------------------------------
// Web Server Route Handlers
// ---------------------------------------------------------
void handleRoot() {
    server.send(200, "text/html", index_html);
}

void handleApiStatus() {
    updateSensorStates();

    String json = "{";
    json += "\"pump_on\":" + String(pumpIsOn ? "true" : "false") + ",";
    json += "\"tank_status\":\"" + getTankStatusString() + "\",";
    json += "\"dry_run_guard\":" + String(lowFloatActive ? "true" : "false");
    json += "}";

    server.send(200, "application/json", json);
}

void handleApiPump() {
    if (!server.hasArg("state")) {
        server.send(400, "application/json", "{\"error\":\"Missing state argument\"}");
        return;
    }

    String stateArg = server.arg("state");
    updateSensorStates();

    if (stateArg == "on") {
        if (lowFloatActive) {
            Serial.println("API: Pump ON command rejected. Dry run guard is active.");
            server.send(403, "application/json", "{\"error\":\"Dry run guard active. Tank is empty.\"}");
            return;
        } else {
            pumpIsOn = true;
            digitalWrite(RELAY_PIN, HIGH);
            Serial.println("API: Pump turned ON manually.");
        }
    } else if (stateArg == "off") {
        pumpIsOn = false;
        digitalWrite(RELAY_PIN, LOW);
        Serial.println("API: Pump turned OFF manually.");
    }

    handleApiStatus();
}

// ---------------------------------------------------------
// Main Setup & Loop
// ---------------------------------------------------------
void setup() {
    Serial.begin(115200);
    delay(1000);
    Serial.println("\n--- ESP32 Water Pump Dry-Run Guard ---");

    // Initialize Pins
    pinMode(RELAY_PIN, OUTPUT);
    digitalWrite(RELAY_PIN, LOW); // Ensure pump is OFF at boot

    // Use internal pull-ups for float switches
    pinMode(LOW_FLOAT_PIN, INPUT_PULLUP);
    pinMode(HIGH_FLOAT_PIN, INPUT_PULLUP);

    // Setup WiFi Access Point
    Serial.print("Setting up WiFi AP...");
    WiFi.softAP(AP_SSID, AP_PASS);

    Serial.println("Done.");
    Serial.print("AP IP Address: ");
    Serial.println(WiFi.softAPIP());

    // Configure Web Server Routes
    server.on("/", handleRoot);
    server.on("/api/status", handleApiStatus);
    server.on("/api/pump", HTTP_POST, handleApiPump);

    server.begin();
    Serial.println("HTTP server started.");
}

void loop() {
    server.handleClient();
    updateSensorStates();
    delay(50); // Small yield to prevent watchdog starvation
}

Compilation and Upload

To build and flash the project to your ESP32 DevKitC, open the VSCode terminal and execute the following exact PlatformIO commands:

  1. Compile the firmware to ensure there are no syntax errors:
    bash
    pio run
  2. Upload the compiled firmware to the ESP32:
    bash
    pio run --target upload
  3. Open the serial monitor to view the AP creation logs and debug messages:
    bash
    pio device monitor

Validation Method and Expected Evidence

To rigorously validate the safety and accuracy of the dry-run guard:

  1. Network Connection: Connect a smartphone or laptop to the ESP32_Pump_Guard WiFi network (Password: admin1234).
  2. Access Dashboard: Open a browser and navigate to http://192.168.4.1.
  3. Test Normal Operation: Ensure the low float switch is manually held UP (simulating water present). Click “Start Pump” in the web UI.
    • Expected Evidence: The physical relay should click (engage), the pump state in the UI should read “RUNNING”, and the serial monitor should log API: Pump turned ON manually.
  4. Test Hardware Dry-Run Cutoff: While the pump is running, physically drop the low float switch DOWN (simulating the tank running dry).
    • Expected Evidence: The relay must immediately click off. The serial monitor must print CRITICAL: Dry run detected! Forcing pump OFF. The web dashboard will automatically update within 2 seconds to show “DRY RUN GUARD ACTIVE” in red.
  5. Test API Rejection: While the low float switch is still DOWN, attempt to click “Start Pump” again in the UI.
    • Expected Evidence: The browser will display an alert: “Command rejected: Dry run guard active. Tank is empty.” The relay will not engage. The serial monitor will print API: Pump ON command rejected. Dry run guard is active.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the primary purpose of the water pump controller built in this project?




Question 2: How does the controller monitor tank levels?




Question 3: What happens to centrifugal and submersible pumps if they run dry?




Question 4: Which agricultural application is mentioned as a use case for this project?




Question 5: What kind of network dependency does this project have?




Question 6: What is the name of the independent WiFi network broadcasted by the ESP32?




Question 7: What information is displayed on the hosted web dashboard?




Question 8: Who is the target audience for this project?




Question 9: What is the difficulty level of this project?




Question 10: How does the system handle off-grid water transfers if the source runs dry?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top