ESP32 and Arduino

Water Tank Level Sensor with ESP32 and MQTT: Percent Full and Low-Level Alerts

Measure tank level with a waterproof ultrasonic sensor, convert distance to litres and percent full on an ESP32, and publish retained MQTT readings plus debounced low-level alerts.

Water tank level sensor ESP32 setup with an ultrasonic probe on the tank lid sending percent full over MQTT
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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:

  1. 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.
  2. Usable depth: distance from the full line to the outlet or the lowest level you care about.
  3. 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

  1. Mount the sensor perfectly vertical and away from the inlet, so falling water and ripples do not hit the beam.
  2. 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.
  3. Respect the blind zone. If the water can rise into the first 20 to 25 cm, raise the sensor on a short riser pipe.
  4. 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.
  5. Watch for condensation. Droplets on the transducer cause "no echo" readings. A small vent hole in the lid helps.
  6. Validate against a manual dip at several levels and adjust MOUNT_OFFSET_CM until 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.

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.