A water tank level sensor with an ESP32 is one of the most useful first IoT projects: it answers "how much water is left?" from anywhere and warns you before a pump runs dry. In this tutorial you will wire a waterproof ultrasonic sensor, convert its distance reading into percent full and litres, and publish both to an MQTT broker together with a debounced low-level alert.
How Ultrasonic Tank Level Measurement Works
An ultrasonic sensor mounted on the tank lid sends a short sound pulse downwards and measures how long the echo takes to come back from the water surface. Distance is half of the round-trip time multiplied by the speed of sound. The water level is then simply the tank depth minus that distance.
sensor (lid)
---------[S]--------- <- distance d measured by the sensor
| | |
| v |
|~~~~~~~~~~~~~~~~~~~| <- water surface
| | level = tank_depth - (d - mount_offset)
| |
--------------------- <- tank bottom
Ultrasonic sensing is contact-free, so nothing sits in the water, and it works with any non-foaming liquid. Its weak points are a minimum range (a "blind zone" close to the sensor), condensation on the transducer, and echoes from tank walls or pipes in narrow tanks. All three are manageable with good placement and filtering.
Parts List
| Part | Purpose |
|---|---|
| ESP32 DevKit (any variant with Wi-Fi) | Reads the sensor and publishes over MQTT |
| JSN-SR04T or AJ-SR04M waterproof ultrasonic sensor | Measures distance to the water surface |
| 1 kΩ and 2 kΩ resistors | Voltage divider for the 5 V echo signal |
| 5 V power supply | Powers the sensor and the ESP32 via the 5V pin |
| Short length of PVC pipe (optional) | Stilling tube that reduces echoes and ripples |
The cheaper HC-SR04 works for a bench test, but it is not waterproof and corrodes quickly in the humid air above a tank. A sealed probe on a cable is the right choice for a real installation.
Wiring the Sensor to the ESP32
| JSN-SR04T pin | ESP32 pin | Notes |
|---|---|---|
| 5V | 5V (VIN) | The sensor needs 5 V for reliable range |
| GND | GND | Common ground |
| Trig | GPIO 5 | 3.3 V trigger pulse is accepted |
| Echo | GPIO 18 through divider | 1 kΩ in series, 2 kΩ to GND: about 3.3 V at the pin |
Do not connect the echo pin directly: the ESP32 GPIOs are not 5 V tolerant. The divider takes a few seconds to build and saves a board.
Calibrating Your Tank
Before writing code, measure three numbers with a tape measure:
- Mount offset: distance from the sensor face to the "full" water line. Keep it larger than the sensor's blind zone, typically 20 to 25 cm for the JSN-SR04T.
- Usable depth: distance from the full line to the outlet or the lowest level you care about.
- Capacity in litres at the full line. For a vertical cylinder, litres equal the cross-section area in square decimetres multiplied by the depth in decimetres; for irregular tanks, use the manufacturer's figure.
Percent full is then (usable_depth - (distance - mount_offset)) / usable_depth * 100, clamped between 0 and 100.
The ESP32 Water Tank Level Sketch
The sketch takes seven readings, uses the median to reject stray echoes, computes level, percent and litres, and publishes a JSON message every 30 seconds. Install PubSubClient from the Library Manager first.
| Topic | Retained | Example payload |
|---|---|---|
connectmq/demo/tank-91c2/level |
yes | {"distance_cm":63.4,"level_cm":96.6,"percent":64.4,"litres":644} |
connectmq/demo/tank-91c2/alert |
yes | LOW or OK |
connectmq/demo/tank-91c2/status |
yes | online / offline (Last Will) |
Replace tank-91c2 with your own random identifier.
#include <WiFi.h>
#include <PubSubClient.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* CLIENT_ID = "tank-91c2-esp32";
const char* TOPIC_LEVEL = "connectmq/demo/tank-91c2/level";
const char* TOPIC_ALERT = "connectmq/demo/tank-91c2/alert";
const char* TOPIC_STATUS = "connectmq/demo/tank-91c2/status";
const int TRIG_PIN = 5;
const int ECHO_PIN = 18;
// Tank calibration (centimetres / litres)
const float MOUNT_OFFSET_CM = 25.0; // sensor face to full line
const float USABLE_DEPTH_CM = 150.0; // full line to empty line
const float CAPACITY_L = 1000.0;
// Alert hysteresis: alarm below 20 %, clear above 25 %
const float LOW_ON_PCT = 20.0;
const float LOW_OFF_PCT = 25.0;
const uint32_t PUBLISH_INTERVAL_MS = 30000;
const float SOUND_CM_PER_US = 0.0343; // at roughly 20 degrees C
WiFiClient net;
PubSubClient mqtt(net);
uint32_t lastPublish = 0;
bool lowAlarm = false;
bool alarmPublished = false;
float readDistanceOnce() {
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(5);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(20); // JSN-SR04T prefers a longer trigger
digitalWrite(TRIG_PIN, LOW);
unsigned long us = pulseIn(ECHO_PIN, HIGH, 30000); // 30 ms timeout
if (us == 0) return NAN;
return us * SOUND_CM_PER_US / 2.0;
}
float readDistanceMedian() {
const int N = 7;
float v[N];
int n = 0;
for (int i = 0; i < N; i++) {
float d = readDistanceOnce();
if (!isnan(d)) v[n++] = d;
delay(60); // let echoes die out
}
if (n < 3) return NAN;
for (int i = 1; i < n; i++) { // insertion sort
float key = v[i];
int j = i - 1;
while (j >= 0 && v[j] > key) { v[j + 1] = v[j]; j--; }
v[j + 1] = key;
}
return v[n / 2];
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(CLIENT_ID, TOPIC_STATUS, 1, true, "offline")) {
mqtt.publish(TOPIC_STATUS, "online", true);
alarmPublished = false; // republish alert state after reconnect
} else {
delay(5000);
}
}
}
void publishLevel() {
float d = readDistanceMedian();
if (isnan(d)) {
mqtt.publish(TOPIC_LEVEL, "{\"error\":\"no_echo\"}", true);
return;
}
float level = USABLE_DEPTH_CM - (d - MOUNT_OFFSET_CM);
level = constrain(level, 0.0f, USABLE_DEPTH_CM);
float pct = level / USABLE_DEPTH_CM * 100.0;
float litres = CAPACITY_L * pct / 100.0;
char json[160];
snprintf(json, sizeof(json),
"{\"distance_cm\":%.1f,\"level_cm\":%.1f,\"percent\":%.1f,\"litres\":%.0f,\"rssi\":%d}",
d, level, pct, litres, WiFi.RSSI());
mqtt.publish(TOPIC_LEVEL, json, true);
Serial.println(json);
bool prev = lowAlarm;
if (!lowAlarm && pct < LOW_ON_PCT) lowAlarm = true;
if (lowAlarm && pct > LOW_OFF_PCT) lowAlarm = false;
if (lowAlarm != prev || !alarmPublished) {
mqtt.publish(TOPIC_ALERT, lowAlarm ? "LOW" : "OK", true);
alarmPublished = true;
}
}
void setup() {
Serial.begin(115200);
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
connectMqtt();
publishLevel();
lastPublish = millis();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish >= PUBLISH_INTERVAL_MS) {
lastPublish = millis();
publishLevel();
}
}
Watch the readings from your computer:
mosquitto_sub -h broker.connectmq.com -p 1883 -i tank-91c2-viewer -t "connectmq/demo/tank-91c2/#" -v
Because the level, alert and status topics are retained, a phone dashboard that connects at any time shows the current state immediately instead of waiting up to 30 seconds. If you are new to the retained flag and Last Will, ESP32 MQTT Last Will explains both.
Why the Alert Uses Hysteresis
Water surfaces ripple, especially while a pump is filling or drawing. With a single threshold at 20 percent, a reading oscillating between 19.8 and 20.3 would flip the alert on and off every cycle and spam every subscriber. Two thresholds (on below 20, off above 25) create a dead zone that absorbs that noise. The sketch also publishes the alert only when the state changes, plus once after every reconnect, which keeps traffic low and the retained value correct.
You can add a second layer without touching firmware. In the ConnectMQ portal, threshold alert rules watch a topic and record an entry in the alerts log when a value crosses your limit, which is handy as an independent check on the device logic.
Accuracy Tips for a Water Tank Level Sensor on ESP32
- Mount the sensor perfectly vertical and away from the inlet, so falling water and ripples do not hit the beam.
- Use a stilling tube in narrow or turbulent tanks: a PVC pipe from lid to bottom with a small hole near the base gives a calm surface and a clean echo path.
- Respect the blind zone. If the water can rise into the first 20 to 25 cm, raise the sensor on a short riser pipe.
- Compensate for temperature if your tank sits in a hot attic or freezing shed. The speed of sound rises with air temperature, roughly 0.6 m/s per degree Celsius; a DS18B20 next to the sensor lets you correct it.
- Watch for condensation. Droplets on the transducer cause "no echo" readings. A small vent hole in the lid helps.
- Validate against a manual dip at several levels and adjust
MOUNT_OFFSET_CMuntil they agree.
Battery-Powered Tanks
Remote tanks often have no mains power. The sketch above keeps Wi-Fi and MQTT connected permanently, which drains batteries fast. For a solar or battery node, restructure it: wake, take a reading, connect, publish the retained level and alert, then enter deep sleep for 10 or 15 minutes. Retained messages matter even more in that design because the device is offline most of the time. The ESP32 deep sleep MQTT guide shows the full pattern, and you can power the sensor through a MOSFET so it draws nothing while the ESP32 sleeps.
Going to Production
The public test area at broker.connectmq.com is perfect for development, but everything there is readable by anyone who guesses your topic. Before installing the sensor permanently:
- Create a private broker and copy the generated username, password and client ID from the portal.
- Switch to
WiFiClientSecure, port 8883 and the ISRG Root X1 CA certificate. - Connect with
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD", TOPIC_STATUS, 1, true, "offline").
Running your own broker for a handful of tanks is rarely worth the maintenance, which is why a hosted MQTT broker is the usual choice. If you want to compare hosting options first, read about the MQTT broker in the cloud approach, and for structured payloads the ArduinoJson MQTT guide is a cleaner alternative to snprintf. More sensor projects are collected in the ESP32 IoT tutorial.
Get Started with ConnectMQ
Your tank monitor deserves a private, encrypted endpoint. ConnectMQ provides TLS on port 8883, generated credentials, a live topic explorer and threshold alert rules in one portal. Create a free Serverless broker at app.connectmq.com, no credit card needed, and change three lines in the sketch.
Frequently Asked Questions
Which ultrasonic sensor is best for a water tank?
A sealed, waterproof probe such as the JSN-SR04T or AJ-SR04M. The HC-SR04 is fine for bench testing but does not survive the humidity above a tank for long.
Why does my sensor report the tank as full when it is not?
The water or an obstacle is inside the blind zone, or the echo bounces off a pipe or wall. Raise the sensor, aim it at a clear area, or add a stilling tube.
How often should the ESP32 publish the tank level?
Every 30 to 60 seconds is plenty for most domestic tanks; levels change slowly. On battery power, every 10 to 15 minutes with deep sleep is a good balance.
Can I use this for diesel or other liquids?
Ultrasonic sensing works on most non-foaming liquids, but fuel tanks have safety rules about electronics in hazardous areas. Check the regulations and use certified equipment where required.



