← Back to Projects

πŸ’§ 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:

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

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_cm is always larger than full_distance_cm because 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.



← Back to Projects