A tank can look full from the outside and be almost empty inside. In this build, an ESP32 measures the air gap above the water, converts that distance to a percentage and lights an LED when the level becomes low.
Start with a dry-bench tabletop container, not a roof tank. The goal is to get the measurement right before adding a wireless display or pump control. You need basic breadboard skills and about 60–90 minutes. No mains wiring or pump switching is used.
First, make the measurement click
Try it yourself
Imagine measuring the empty space above a drink. A fuller glass leaves a shorter gap. The sensor measures that gap with an echo; it never measures the water directly.
Air gap 15.0 cm · round-trip echo 875 µs
Try 19%, then 22%, then 26%. The separate on/off thresholds prevent alarm chatter. This model assumes a vertical, constant-area tank; it is not an ultrasonic physics simulator.
Move the slider slowly through 20–25%. Then remove the echo and notice that a sensor fault does not become a zero-percent reading.
The ultrasonic module sends a short sound burst and waits for the reflection. Like calling into a valley, the measured delay contains both the outward and return journeys. Divide the sound travel distance by two.
The drink analogy explains the direction of change: more water means a smaller gap. It does not explain echoes from tank walls, splashing or foam. Those are real mounting and surface problems that this simple model leaves out.
Gather the parts
🧰 Build kit
- 1×ESP32 development boardClassic ESP32 DevKit / ESP32-WROOM target; USB cableGet it →
- 1×HC-SR04 ultrasonic sensorStandard 5 V version; keep it dryGet it →
- 1×10k resistorUpper leg of the ECHO dividerGet it →
- 1×20k resistorLower leg of the ECHO dividerGet it →
- 1 each×LED and 220 ohm resistorExternal alarm indicatorGet it →
- 1 set×Breadboard and jumper wiresPlus a stable sensor mount, ruler and containerGet it →
Wire it with the power disconnected
Why the echo needs a voltage divider
ECHO low: With ECHO at 0 V, the divider junction is also at 0 V.
Follow the pulse through the divider. The upper resistor is 10 kΩ; the lower resistor to common ground is 20 kΩ.
Connect ECHO to the 10 kΩ resistor, then the other end of that resistor to GPIO19. Connect 20 kΩ from the same GPIO19 junction to GND. The division is 5 × 20 / (10 + 20), approximately 3.33 V. Both sensor and ESP32 must share ground.
Power the module from a verified regulated 5 V rail. Some development boards label that rail 5V or VIN; check the board documentation before using it as an output. If you use a separate sensor supply, join the grounds and avoid back-feeding USB. TRIG is driven from GPIO18. The LED anode connects through 220 Ω to GPIO23; the cathode goes to GND.
Mount the sensor and record two distances
Mount it, send the burst, read the echo
Face down: The PCB lies parallel to the water. Both acoustic openings face straight down, centred over the surface.
Side cutaway: transducer faces point vertically down at the calm water surface. The sound path is schematic, not drawn to scale.
Hold the sensor horizontal and looking straight down. Its face must stay above the highest intended water surface; keep at least a 5 cm gap in this tabletop exercise. The standard HC-SR04 board is not waterproof. A real outdoor installation needs a suitable enclosure and a sensor designed for that environment.
Measure from the sensor face to the empty reference surface: call that EMPTY_CM. Add water to the highest level you want to count as full, and measure again: FULL_CM. The example uses 30 cm and 5 cm. Replace those numbers with your measurements; copying them blindly gives a convincing but wrong percentage.
| Quantity | Example | Meaning |
|---|---|---|
| EMPTY_CM | 30 cm | Largest calibrated air gap |
| FULL_CM | 5 cm | Smallest calibrated air gap |
| Water level | 100 × (30 − gap) / 25 | Clamp the result to 0–100% |
| Alarm thresholds | On ≤20%; off ≥25% | A 5-point hysteresis band |
Upload the sketch
Install the Espressif ESP32 board package in Arduino IDE. Select ESP32 Dev Module for a classic ESP32 DevKit and choose its USB port. This sketch was compile-checked with Arduino-ESP32 3.3.8. It uses the built-in Arduino functions, so no extra sensor library is required.
Open the sketch below, set your two calibration distances, upload and open Serial Monitor at 115200 baud. A good reading looks like 17.5,50.0,0: distance in centimetres, percentage and alarm state. These are calculated examples, not measurements from your tank.
/* Kadosh Build: ESP32 tank monitor. Classic ESP32 DevKit; Arduino-ESP32 3.x.
HC-SR04 ECHO -> 10k resistor -> GPIO19; GPIO19 -> 20k -> GND.
Never connect a 5V ECHO signal directly to the ESP32.
Original example code, MIT license. */
#include <Arduino.h>
#include <math.h>
constexpr uint8_t TRIG = 18, ECHO = 19, ALARM_LED = 23;
// Measure both distances from the sensor face, not from the tank rim.
constexpr float EMPTY_CM = 30.0f, FULL_CM = 5.0f;
constexpr float ALARM_ON_PCT = 20.0f, ALARM_OFF_PCT = 25.0f;
float filtered = NAN;
bool alarmLow = false;
uint32_t previous = 0;
unsigned invalidReadings = 0;
float readDistanceCm() {
digitalWrite(TRIG, LOW); delayMicroseconds(2);
digitalWrite(TRIG, HIGH); delayMicroseconds(10); digitalWrite(TRIG, LOW);
const unsigned long echoUs = pulseIn(ECHO, HIGH, 30000UL);
if (!echoUs) return NAN;
const float cm = echoUs * 0.0343f / 2.0f;
return cm >= 2.0f && cm <= 400.0f ? cm : NAN;
}
void setup() {
Serial.begin(115200);
pinMode(TRIG, OUTPUT); pinMode(ECHO, INPUT); pinMode(ALARM_LED, OUTPUT);
digitalWrite(TRIG, LOW); digitalWrite(ALARM_LED, LOW);
if (EMPTY_CM <= FULL_CM) {
Serial.println("Calibration error: EMPTY_CM must exceed FULL_CM");
while (true) delay(1000);
}
Serial.println("distance_cm,level_pct,low_alarm");
}
void loop() {
const uint32_t now = millis();
if (now - previous < 250) return;
previous = now;
const float distance = readDistanceCm();
if (isnan(distance)) {
++invalidReadings;
Serial.println("FAULT,no_echo_or_out_of_range");
// Keep the existing low-level alarm; a fault is not a new level measurement.
// After repeated faults, discard smoothing so recovery starts with fresh data.
if (invalidReadings >= 4) filtered = NAN;
return;
}
invalidReadings = 0;
filtered = isnan(filtered) ? distance : .2f * distance + .8f * filtered;
const float level = constrain(100.0f * (EMPTY_CM - filtered) / (EMPTY_CM - FULL_CM), 0.0f, 100.0f);
if (level <= ALARM_ON_PCT) alarmLow = true;
else if (level >= ALARM_OFF_PCT) alarmLow = false;
digitalWrite(ALARM_LED, alarmLow ? HIGH : LOW);
Serial.printf("%.1f,%.1f,%d\n", filtered, level, alarmLow ? 1 : 0);
}Check it in three short experiments
| Experiment | What to do | What to look for |
|---|---|---|
| Direction check | Raise a flat target from 30 cm toward 5 cm | Gap decreases; percentage increases |
| Alarm check | Move to about 10% then 22%, then 30% | LED turns on, stays on at 22%, clears above 25% |
| Fault check | Point the sensor away from a reflecting surface | FAULT message; no invented 0% reading |
The exponential filter uses 20% of each new distance and 80% of the previous filtered value. It reduces jitter but creates lag. After changing the level, hold it steady for several seconds before judging the alarm. Four consecutive invalid readings clear the filter so recovery can start with fresh data.
Record ruler distance and reported distance at five positions. Repeat each position three times. Compare the errors instead of judging the result from a single reading. The worksheet is deliberately blank: fill it with observations from your own build.
If the result looks wrong
| Symptom | Likely cause | Next check |
|---|---|---|
| Only FAULT messages | Missing echo, bad divider wiring or wrong pins | Try a flat dry target 15–30 cm away; confirm common ground |
| Always 0% or 100% | Wrong calibration, reversed values or units | Measure both sensor-to-surface gaps in cm |
| Reading jumps near the wall | Echo from the container instead of water | Re-centre and level the sensor; use a wider container |
| Alarm clears slowly | Smoothing and hysteresis are working | Wait for the filter; compare with the interactive model |
Make it your own
Once the basic measurement is trustworthy, add a display or send the readings with the companion ESP-NOW tutorial. For a research extension, compare mean absolute error for three mounting positions and discuss why they differ. Separate height accuracy from any volume estimate.
The firmware has been compiled, but the illustrations and browser model are teaching aids rather than proof of a physical build. Complete the checks above on your own hardware before leaving the monitor unattended.
References and further reading: HC-SR04 operating details · Arduino pulseIn reference · Arduino-ESP32 documentation
Did you build it? 🎉
Tell us if this tutorial helped and share it with your classmates.
Need parts or a hand with your project?
Get the components from our store, or talk to a Kadosh engineer about your build or research.
Questions & comments
Stuck on a step? Ask here — our engineers and other builders reply.
Sign in to ask a question or leave a comment.
Sign inCreate a free account