Is the plant tray, the pet bowl or the fish tank running low? The water level sensor has long copper stripes: the more of them are under water, the more current flows. The ESP32 turns that into a percentage you can check from your phone.
What you'll learn
- How a resistive water level sensor works
- Why powering a sensor only when you measure makes it last longer
- How to calibrate a sensor between "empty" and "full"
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- Water level sensor (45-in-1 Sensor Kit)
- A glass of water
- A 2.4 GHz Wi-Fi network
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
Safety and care
Only the striped part of the sensor goes in the water, never above the line printed on it. Keep the ESP32, the breadboard and the wires dry and away from the glass.
Wiring

| From | To |
|---|---|
| Sensor S | ESP32 GPIO 35 |
| Sensor + | ESP32 GPIO 32 (powers the sensor only while measuring) |
| Sensor − | GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
The sketch
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only). Open the Serial Monitor at 115200 baud to see the address, then open it on a phone or computer on the same Wi-Fi.
#include <WiFi.h>
#include <WebServer.h>
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int SENSOR_PIN = 35;
const int POWER_PIN = 32;
int EMPTY = 0; // reading with the sensor out of the water
int FULL = 1800; // reading with the stripes fully under water
WebServer server(80);
int lastReading = 0;
int readSensor() {
digitalWrite(POWER_PIN, HIGH); // power on only while measuring
delay(10);
int value = analogRead(SENSOR_PIN);
digitalWrite(POWER_PIN, LOW);
return value;
}
String page() {
int percent = constrain(map(lastReading, EMPTY, FULL, 0, 100), 0, 100);
String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<meta http-equiv='refresh' content='3'><title>Water level</title></head>";
html += "<body style='font-family:sans-serif;padding:16px;text-align:center'><h1>Water level</h1>";
html += "<p style='font-size:56px;margin:8px'>" + String(percent) + " %</p>";
html += "<p style='color:#888'>Reading: " + String(lastReading) + "</p></body></html>";
return html;
}
void setup() {
Serial.begin(115200);
pinMode(POWER_PIN, OUTPUT);
digitalWrite(POWER_PIN, LOW);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.print("Open http://");
Serial.println(WiFi.localIP());
server.on("/", []() { server.send(200, "text/html", page()); });
server.begin();
}
void loop() {
server.handleClient();
static unsigned long lastMeasure = 0;
if (millis() - lastMeasure > 2000) {
lastMeasure = millis();
lastReading = readSensor();
Serial.println(lastReading);
}
}
Calibrate it: note the reading in the Serial Monitor with the sensor dry (EMPTY) and with the stripes fully under water (FULL), then put both numbers in the code and upload again.
How it works
Half of the stripes are connected to power and half to the signal pin. Water conducts electricity, so the deeper the sensor goes, the more current flows between them and the higher the voltage on S. Powering the sensor from GPIO 32 for just 10 ms every two seconds means current flows only briefly. That slows down the corrosion that happens when metal sits in water with electricity running through it.
Try this next
- Light an LED or sound the buzzer below 20 %.
- Show a picture of a glass on the page that fills up with the level.
- Log the level every hour to see how fast the water evaporates.
Troubleshooting
- The level jumps around: hold the sensor still and straight.
- It shows 0 % in water: check that + goes to GPIO 32 and S to GPIO 35.
- Readings drift over days: the stripes corrode; clean them gently, and remember this is a learning project.