π§ Water Tank Level Monitor β ESP8266
One file. Flash it. Open browser. Done.
A real-time water tank level monitor that runs entirely on an ESP8266, served from flash memory. No cloud. No app. No internet needed.
π Inspiration & Gratitude
This project is a simplified, budget-focused DIY variation of the incredible TankSync project created by @Techposts.
I want to express my deepest appreciation and credit to the original author. Without their open-source codebase and brilliant ideas, this build would not have been possible. TankSync is an exceptionally reliable, professional-grade solution that supports multiple tanks, history logging, and runs rock-solid 24/7.
My version is a humble, stripped-down DIY adaptation. My goal was simply to see if I could build a minimal, barebones version on a very tight budget to cover my basic needs. If you need a fully-featured, production-ready, and highly polished solution, I strongly encourage you to check out and support the original TankSync project.
π§Ύ Bill of Materials β Total Cost ~βΉ490
This project was built using the absolute minimal, budget-friendly components available locally:
- ESP8266 NodeMCU (Microcontroller & Wi-Fi): βΉ175
- JSN-SR04M (Waterproof ultrasonic sensor): βΉ259
- CCTV Junction Box (Weatherproof enclosure): βΉ50
- Solder, wires, & a resistor: βΉ2β5
Total Estimated Cost: β βΉ490 (Less than βΉ500 for a local, subscription-free Wi-Fi water level monitor).
πΈ Dashboard Preview
The live web dashboard shows water level, Wi-Fi signal, animated tank fill, and lets you calibrate the sensor without reflashing β all over WebSocket.
π© Hardware Required
| Component | Details |
|---|---|
| Microcontroller | ESP8266 β NodeMCU 1.0 (ESP-12E) or Wemos D1 Mini |
| Sensor | JSN-SR04M waterproof ultrasonic distance sensor |
| Power | USB (from NodeMCUβs onboard regulator), or external 5V |
| Wires | 4 Γ jumper wires (ideally colour-coded) |
π Wiring / Circuit Diagram
ESP8266 NodeMCU JSN-SR04M Sensor
βββββββββββββββ ββββββββββββββββ
β β β β
β 3.3V ββββββββββββ VCC β
β GND ββββββββββββ GND β
β D6 / GPIO12 βββββββββββ TRIG β
β D7 / GPIO13 βββββββββββ ECHO β
β β β β
βββββββββββββββ ββββββββββββββββ
Wire Colour Convention
| Wire | NodeMCU Pin | Sensor Pin | Colour |
|---|---|---|---|
| Power | 3.3V | VCC | π΄ Red |
| Ground | GND | GND | β« Black |
| Trigger | D6 / GPIO 12 | TRIG | π‘ Yellow |
| Echo | D7 / GPIO 13 | ECHO | π’ Green |
β οΈ Important β Use 3.3 V, NOT 5 V
The JSN-SR04M module will accept 5 V on its VCC and the sensor itself runs fine, but the ESP8266βs GPIO pins are NOT 5 V tolerant. If you power the sensor from 5 V, the ECHO pin will output 5 V back into GPIO 13 and permanently damage the chip. Power from 3.3 V only, or use a voltage divider on the ECHO line if you must use 5 V.
π How the JSN-SR04M Works
The JSN-SR04M is a waterproof variant of the popular HC-SR04 ultrasonic sensor. It uses two piezoelectric transducers:
[TRIG pulse]
β
βΌ
βββββββββββββββββ ββββββββββββββββ
β TRANSMITTER ββββββββΆ β WATER β βββΆ echo reflected
β (40 kHz burst)β β SURFACE β
βββββββββββββββββ ββββββββββββββββ
β
[ECHO pin goes HIGH]
[stays HIGH until echo received]
β
βΌ
pulseIn() measures duration β distance
Distance formula:
distance (cm) = (echo_duration_ΞΌs Γ 0.0343) / 2
0.0343cm/ΞΌs = speed of sound at ~20Β°C- Divided by 2 because the sound travels to the water and back
Measurement range: 20 cm β 400 cm (the JSN-SR04M has a 20 cm blind zone near the sensor face)
π Sensor Calibration Explained
The sensor measures distance from the sensor to the water surface. The firmware converts this into a percentage and litres using two calibration points you set:
[SENSOR]
β
β β full_distance_cm (e.g. 20 cm) β water touches here when FULL
β
~~~ β water surface (varies)
β
β
β β empty_distance_cm (e.g. 100 cm) β sensor-to-bottom when EMPTY
β
[BOTTOM OF TANK]
Level calculation:
range = empty_distance_cm - full_distance_cm (e.g. 100 - 20 = 80 cm)
level = empty_distance_cm - current_distance (how much water is present, in cm)
level = constrain(level, 0, range) (clamp to valid range)
percentage = round((level Γ 100) / range)
volume_L = round((capacity_L Γ level) / range)
Key insight:
empty_distance_cmis always larger thanfull_distance_cmbecause when the tank is empty the sensor looks further down to reach the bottom, and when the tank is full the water surface is closer to the sensor.
Setting Calibration Without Reflashing
Open the βοΈ Settings button in the browser dashboard and enter:
| Field | What to measure |
|---|---|
| Empty Distance | Mount the sensor above the tank. With the tank completely empty, measure the distance from the sensor to the tank floor (e.g.Β 100 cm) |
| Full Distance | With the tank completely full, measure the distance from the sensor to the water surface (e.g.Β 20 cm) |
| Capacity | Total volume of the tank in litres (e.g.Β 1000) |
Click Save to ESP8266 β the device applies changes immediately over WebSocket, no reflash needed.
π§ Firmware Architecture
tank.ino (single file β top to bottom)
β
βββ Includes ArduinoJson, ESP8266WiFi, ESPAsyncTCP, ESPAsyncWebServer
βββ Wi-Fi Credentials ssid / password β only thing you must change
βββ Pin Definitions TRIG_PIN = 12 (D6), ECHO_PIN = 13 (D7)
βββ Config Variables empty_distance_cm, full_distance_cm, capacity_l
βββ Runtime State current_distance, current_pct, sensor_error ...
βββ Server Objects AsyncWebServer server(80), AsyncWebSocket ws("/ws")
βββ Timing Constants READ_INTERVAL = 2 s, BROADCAST_INTERVAL = 5 s
β
βββ ββ WEB ASSETS (PROGMEM) ββββββββββββββββββββββββββββββββββββββ
β βββ index_html[] Full HTML dashboard page
β βββ style_css[] CSS design system (dark theme, animations)
β βββ app_js[] WebSocket client + animated SVG tank renderer
β
βββ ββ SENSOR FUNCTIONS ββββββββββββββββββββββββββββββββββββββββββ
β βββ sort_floats() Insertion sort for the sample array
β βββ readSingleSample() Fire one pulse β return distance in cm
β βββ readSensor() Collect 5 samples β IQR filter β median β compute %
β
βββ ββ WEBSOCKET FUNCTIONS βββββββββββββββββββββββββββββββββββββββ
β βββ notifyClients() Serialise state β push JSON to all browsers
β βββ handleWebSocketMessage() Parse settings-update from browser
β βββ onEvent() Route WS_EVT_CONNECT / DATA / DISCONNECT
β
βββ setup() Wi-Fi connect β register WS handler β serve PROGMEM routes β start server
βββ loop() cleanupClients() β readSensor every 2 s β notifyClients every 5 s
π¬ Noise Filtering β IQR Median Algorithm
Raw ultrasonic readings are noisy (reflections, temperature, vibration). The firmware takes 5 samples and filters them:
Step 1 β Collect 5 samples, reject obviously invalid ones (< 5 cm or > 400 cm)
Step 2 β Sort the valid samples:
[22.1, 22.3, 22.4, 22.6, 28.9] β 28.9 is an outlier
Step 3 β Compute Interquartile Range (IQR):
Q1 = samples[n/4] = 22.2
Q3 = samples[3n/4] = 22.5
IQR = Q3 - Q1 = 0.3
Step 4 β Set fence bounds:
lower = Q1 - 1.5ΓIQR = 22.2 - 0.45 = 21.75
upper = Q3 + 1.5ΓIQR = 22.5 + 0.45 = 22.95
(if IQR < 2.0 β use Β±5 cm to avoid over-rejection)
Step 5 β Keep only in-fence values:
[22.1, 22.3, 22.4, 22.6] β 28.9 removed β
Step 6 β Take the median of filtered values β 22.35 cm β
This eliminates single-bounce reflections and electrical spikes while remaining responsive to genuine water level changes.
π‘ WebSocket Protocol
The firmware and browser communicate over a persistent WebSocket at
ws://<device-ip>/ws.
Server β Browser
(type: "state")
Sent immediately on connection, then every 5 seconds:
{
"type": "state",
"devices": [
{
"tank": 1,
"name": "My Water Tank",
"capacity_l": 1000,
"state": {
"level_pct": 72,
"sensor_error": false,
"rssi": -58,
"conn_state": "online",
"consumed_l": 0
}
}
],
"settings": {
"empty_distance_cm": 100,
"full_distance_cm": 20
}
}Browser β Server
(type: "update_settings")
Sent when the user clicks Save in the settings modal:
{
"type": "update_settings",
"empty_distance_cm": 100,
"full_distance_cm": 20,
"capacity_l": 1000
}The device applies the new calibration, re-reads the sensor, and
immediately pushes a fresh state message back.
π Browser Reconnection Logic
The JavaScript client uses exponential back-off reconnection so if Wi-Fi drops briefly the browser automatically recovers without a page refresh:
Attempt 1 β wait 2 s
Attempt 2 β wait 4 s
Attempt 3 β wait 8 s
Attempt 4 β wait 16 s
...
Attempt 8 β wait 30 s (capped)
β "Max retries reached. Please refresh."
β‘ How PROGMEM Works
The ESP8266 has limited RAM (~80 KB usable). Storing large strings in
RAM would crash the device. PROGMEM tells the compiler to
keep the string in flash memory (4 MB on NodeMCU), and
send_P() streams it directly from flash to the TCP socket
without loading it into RAM.
// Stored in FLASH β costs 0 RAM:
const char index_html[] PROGMEM = R"rawliteral(
... your entire HTML page ...
)rawliteral";
// Streamed from flash to browser β never touches heap:
request->send_P(200, "text/html", index_html);π Getting Started
1. Install Libraries
In Arduino IDE β Tools β Manage Libraries: - Search
ArduinoJson β Install (by Benoit Blanchon, version 6.x) -
Search ESPAsyncTCP β Install (by me-no-dev) - Search
ESP Async WebServer β Install (by me-no-dev)
2. Set Your Wi-Fi
Open tank.ino and find:
const char *ssid = "YOUR_SSID";
const char *password = "YOUR_PASSWORD";Replace with your actual Wi-Fi credentials.
3. Select Your Board
Tools β Board β ESP8266 Boards β NodeMCU 1.0 (ESP-12E
Module)
(or Wemos D1 Mini if thatβs your board)
Set Upload Speed: 921600 for fastest flashing.
4. Upload
Click Upload (β). Wait for βDone uploading.β
5. Open Serial Monitor
Tools β Serial Monitor β set baud to
115200.
Youβll see:
Connecting to WiFi.....
Connected to WiFi
IP Address: 192.168.1.105
Open: http://192.168.1.105
HTTP server started
6. Open the Dashboard
Navigate to http://192.168.1.105/ (use your actual IP)
in any browser on the same Wi-Fi.
π§ Troubleshooting
| Problem | Likely Cause | Fix |
|---|---|---|
Dashboard shows β for level |
Sensor not wired or placed | Check wiring; sensor needs β₯ 20 cm clearance |
| Sensor alert: βToo nearβ | Object closer than 20 cm (blind zone) | Move sensor further from water surface |
| Level jumps erratically | Reflections inside tank | Add baffles; ensure sensor faces straight down |
| Canβt connect to Wi-Fi | Wrong SSID/password | Re-flash with correct credentials |
| Browser canβt reach device | Wrong IP / different network | Check Serial Monitor for correct IP |
| Level reads 100% when empty | empty_distance_cm <
full_distance_cm |
Swap the values in Settings |
π Repository Structure
water-tank/
βββ tank.ino β Everything in one file. This is what you flash.
βββ docs/
β βββ wiring_diagram.jpg β Circuit wiring diagram
βββ .gitignore
βββ README.md
π License
MIT β use freely, modify freely, just keep the attribution.
Code Link
β Back to Projects