← All projects
Sensors Beginner

Water Tank Level: Ultrasonic Sensor with a Web Gauge

An ultrasonic sensor above the water measures the distance, the ESP32 turns it into a fill percentage and shows it on a page with an animated tank. For a cottage, rain barrel or septic tank.

Difficulty
Beginner
Time
1–2 h
Category
Sensors

Principle

The sensor sends an ultrasonic pulse and measures how long it takes to reach the water surface and come back. With the speed of sound (~343 m/s) we get the distance, and from that the level:

        ┌── sensor ──┐
        │     ↕ d     │   d = distance to water
        │~~~~~~~~~~~~~│   ← surface
        │   water     │
        └─────────────┘   TANK_DEPTH = sensor to bottom

level % = (TANK_DEPTH − d) / (TANK_DEPTH − FULL_GAP) × 100

where FULL_GAP is the distance from the sensor to the water when the tank is full.

Choosing a sensor

Sensor Use Min. distance
HC-SR04 bench tests, dry places 2 cm
JSN-SR04T / AJ-SR04M humid tanks, outdoors (sealed) ~20–25 cm

For a real tank use the JSN-SR04T: condensation quickly corrodes the HC-SR04's contacts. Both have the same TRIG/ECHO pins and run the same code.

Wiring

Sensor ESP32
VCC 5V (VIN)
GND GND
TRIG GPIO 5
ECHO GPIO 18 through a divider 1 kΩ / 2 kΩ

The ECHO output is 5 V, while ESP32 inputs are 3.3 V. Divider: ECHO → 1 kΩ → GPIO 18 → 2 kΩ → GND.

Code

Enter your tank's dimensions in centimetres:

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

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";

const int TRIG_PIN = 5;
const int ECHO_PIN = 18;

const float TANK_DEPTH_CM = 120.0;  // sensor to bottom
const float FULL_GAP_CM   = 25.0;   // sensor to water when full

WebServer server(80);
float distanceCm = NAN;
float levelPercent = NAN;
unsigned long lastMeasure = 0;

float measureOnce() {
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);
  unsigned long us = pulseIn(ECHO_PIN, HIGH, 30000);   // timeout ≈ 5 m
  if (us == 0) return NAN;
  return us * 0.0343 / 2.0;
}

// A median of 5 readings rejects stray echoes from the walls
float measureMedian() {
  float v[5];
  int n = 0;
  for (int i = 0; i < 5; i++) {
    float d = measureOnce();
    if (!isnan(d)) v[n++] = d;
    delay(60);
  }
  if (n == 0) return NAN;
  for (int i = 1; i < n; i++) {          // insertion sort
    float x = v[i];
    int j = i - 1;
    while (j >= 0 && v[j] > x) { v[j + 1] = v[j]; j--; }
    v[j + 1] = x;
  }
  return v[n / 2];
}

void update() {
  distanceCm = measureMedian();
  if (isnan(distanceCm)) {
    levelPercent = NAN;
    return;
  }
  float pct = (TANK_DEPTH_CM - distanceCm) / (TANK_DEPTH_CM - FULL_GAP_CM) * 100.0;
  levelPercent = constrain(pct, 0.0f, 100.0f);
}

String jsonNumber(float v) {
  return isnan(v) ? String("null") : String(v, 1);
}

const char PAGE[] = R"rawliteral(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tank level</title>
<style>
  body{font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#f4f7fa}
  .tank{width:160px;height:260px;border:4px solid #334;border-radius:0 0 18px 18px;position:relative;overflow:hidden;background:#fff}
  .water{position:absolute;bottom:0;left:0;right:0;background:#2a7fd4;transition:height 1s}
  .pct{font-size:2.6rem;font-weight:700;text-align:center;margin-top:16px}
  .muted{color:#667;text-align:center}
</style></head><body>
<div>
  <div class="tank"><div class="water" id="w" style="height:0"></div></div>
  <div class="pct" id="p">--%</div>
  <div class="muted" id="d"></div>
</div>
<script>
async function update() {
  const r = await (await fetch('/data')).json();
  if (r.level === null) { document.getElementById('p').textContent = 'no echo'; return; }
  document.getElementById('w').style.height = r.level + '%';
  document.getElementById('p').textContent = Math.round(r.level) + '%';
  document.getElementById('d').textContent = r.distance + ' cm to surface';
}
update();
setInterval(update, 5000);
</script></body></html>
)rawliteral";

void setup() {
  Serial.begin(115200);
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println(WiFi.localIP());

  server.on("/", [] { server.send(200, "text/html", PAGE); });
  server.on("/data", [] {
    server.send(200, "application/json",
                "{\"level\":" + jsonNumber(levelPercent) + ",\"distance\":" + jsonNumber(distanceCm) + "}");
  });
  server.begin();
  update();
}

void loop() {
  server.handleClient();
  if (millis() - lastMeasure > 10000) {   // the level changes slowly — every 10 s is plenty
    lastMeasure = millis();
    update();
  }
}

Installation

  • Mount the sensor perfectly vertical in the centre of the lid, away from the walls and the inlet pipe.
  • Mind the dead zone: with a JSN-SR04T the water must stay at least 25 cm below the sensor — that's what FULL_GAP_CM is for.
  • Keep the electronics in a sealed box outside the tank; only the sensor head on its cable goes inside.

Accuracy

  • The speed of sound depends on temperature: ±10 °C gives ~2 % error. For better accuracy add a DS18B20 and use v = 331.3 + 0.606 × T m/s.
  • Waves and foam cause false echoes — the median filter in the code rejects them.

Ideas

  • A Telegram alert when the level drops below 20 % (see the "Telegram Bot" project).
  • Automatic fill pump control with hysteresis: on at 30 %, off at 90 %.
  • A daily water usage chart in Home Assistant via MQTT.