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.
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_CMis 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 × Tm/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.