Built-in project · Arduino Uno R3

Ultrasonic water tank level monitor on an Arduino Uno

An HC-SR04 in the lid reads the water level onto an I2C LCD, a relay pump refills from 20 to 90 percent, and a buzzer warns of an overflow. The whole build, an Arduino Uno and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 40 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $41. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Measuring distance with an ultrasonic sensor and pulseIn
  • Mapping a distance to a percent between two marks
  • Hysteresis: separate on and off levels to protect a pump
  • Switching a pump with a relay
  • Failing safe when a sensor stops working
  • Difficulty: Intermediate
  • Time: About 40 minutes
  • Board: Arduino Uno R3
  • Parts: 11

How it works

An HC-SR04 in the lid sends a sound pulse down at the water and times the echo. The sketch turns the time into a distance, averages three readings, and converts it to a percent. With the sensor 110 cm above the bottom and the full mark 10 cm below it, each centimeter is one percent.

The LCD shows the level and a 16 block bar. A relay on pin 4 runs the pump. The pump starts when the level falls below 20 percent and keeps running until it reaches 90 percent.

Above 95 percent, the pump stops and the buzzer on A0 beeps as an overflow alarm. If no echo comes back, the pump stops and the LCD says Sensor error.

Parts list

11 parts, plus the jumper wires. They retail for about $41; on Mokxi they're free.

Kits that can build it

This starter kit has every part in the circuit. Each kit's page lists what else it can build.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Ultrasonic ranger pin TRIGArduino Uno R3 pin 10
Ultrasonic ranger pin ECHOArduino Uno R3 pin 9
Relay module pin INArduino Uno R3 pin 4
Arduino Uno R3 pin GNDGround
Buzzer pin 2Arduino Uno R3 pin GND
Buzzer pin 1Arduino Uno R3 pin A0
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Ultrasonic ranger pin VCC5 V
Ultrasonic ranger pin GNDGround
Relay module pin GNDGround
Relay module pin VCC5 V
Mini water pump pin V+Relay module pin NO
Relay module pin COM6 V
Mini water pump pin V-Ground
Character LCD with I2C backpack pin GNDGround
Character LCD with I2C backpack pin VCC5 V

The note on the canvas

Word for word, as it sits beside the circuit in the editor.

Try it

1. Press Run. The tank starts nearly empty, so the pump starts.
2. Drag the HC-SR04's distance down, as the water rises: the LCD's percent and bar climb.
3. Past 90 percent the pump stops. Raise the distance again: it stays off until the level falls below 20 percent.
4. Drag the distance under 15 cm: past 95 percent the buzzer sounds the overflow alarm.

The sensor is 110 cm above the bottom and the full mark 10 cm below it, so every centimeter is one percent.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Ultrasonic water tank level monitor and pump controller: an HC-SR04 looks
// down at the water from the lid, a 16x2 I2C LCD shows how full the tank is,
// and a relay runs the fill pump.
//
//   HC-SR04   TRIG pin 10, ECHO pin 9, VCC 5V, GND
//   LCD       I2C backpack at 0x27: SDA A4, SCL A5, VCC 5V, GND
//   relay     IN pin 4 (HIGH pulls it in), the pump on its NO contact,
//             with the pump's own supply on COM
//   buzzer    active buzzer on A0 (used as a digital pin), the overflow alarm
//
// HOW IT MEASURES. The sensor sits in the lid and times the echo off the
// water. The farther the echo, the emptier the tank:
//
//   distance  = echo time x 0.0343 cm per microsecond / 2
//   level %   = (EMPTY_CM - distance) / (EMPTY_CM - FULL_CM) x 100
//
// EMPTY_CM is the distance from the sensor to the bottom, FULL_CM the
// distance to the water when the tank is full. Here they are 110 cm and
// 10 cm, so every centimeter is one percent: an echo at 60 cm is 50 %.
// Measure your own tank and change the two numbers.
//
// THE PUMP has two marks, not one: it starts when the level falls below
// 20 % and keeps running until it reaches 90 %. One mark would switch the
// pump on and off every few seconds as ripples crossed it, and a pump
// (and its relay) wears out from starts. The gap between the marks is the
// hysteresis.
//
// THE ALARM: past 95 % the pump is stopped whatever it was doing and the
// buzzer beeps, because something else is filling the tank (or the pump
// relay has stuck). It stops when the level drops below 95 % again.
//
// A missing echo (a loose wire, the sensor knocked sideways) reads as an
// error, and the pump stops: a controller that cannot see the water must
// not run the pump.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

const int TRIG = 10;
const int ECHO = 9;
const int RELAY = 4;
const int BUZZER = A0;

const float EMPTY_CM = 110.0;  // sensor to the bottom of the tank
const float FULL_CM = 10.0;    // sensor to the water when the tank is full

const int PUMP_ON_BELOW = 20;   // percent
const int PUMP_OFF_AT = 90;     // percent
const int OVERFLOW_ABOVE = 95;  // percent

LiquidCrystal_I2C lcd(0x27, 16, 2);

bool pumping = false;
bool overflow = false;
bool beepOn = false;
unsigned long lastBeep = 0;
int lastPercent = -1;
unsigned long lastRead = 0;

// One distance in centimeters, or -1 when no echo came back in 30 ms.
float distanceCm() {
  digitalWrite(TRIG, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG, LOW);
  unsigned long us = pulseIn(ECHO, HIGH, 30000UL);
  if (us == 0) return -1;
  return us * 0.0343 / 2.0;
}

// The average of three readings, which smooths the ripples on the water.
// An HC-SR04 takes a new trigger only 60 ms after the last one started, so
// the readings are 60 ms apart.
float averageCm() {
  float sum = 0;
  for (int i = 0; i < 3; i++) {
    if (i > 0) delay(60);
    float d = distanceCm();
    if (d < 0) return -1;
    sum += d;
  }
  return sum / 3.0;
}

int percentOf(float cm) {
  float p = (EMPTY_CM - cm) / (EMPTY_CM - FULL_CM) * 100.0;
  if (p < 0) p = 0;
  if (p > 100) p = 100;
  return (int)(p + 0.5);
}

void setPump(bool on) {
  if (on == pumping) return;
  pumping = on;
  digitalWrite(RELAY, on ? HIGH : LOW);
  Serial.println(on ? "pump ON" : "pump OFF");
}

void show(int percent) {
  lcd.setCursor(0, 0);
  lcd.print("Level ");
  if (percent < 100) lcd.print(' ');
  if (percent < 10) lcd.print(' ');
  lcd.print(percent);
  lcd.print("% ");
  lcd.print(overflow ? "FULL!" : (pumping ? "PUMP " : "     "));
  // A bar of 16 blocks, one for every 6.25 percent.
  lcd.setCursor(0, 1);
  int blocks = (percent * 16 + 50) / 100;
  for (int i = 0; i < 16; i++) lcd.write(i < blocks ? (uint8_t)255 : (uint8_t)' ');
}

void setup() {
  Serial.begin(115200);
  pinMode(TRIG, OUTPUT);
  pinMode(ECHO, INPUT);
  pinMode(RELAY, OUTPUT);
  pinMode(BUZZER, OUTPUT);
  digitalWrite(RELAY, LOW);
  lcd.init();
  lcd.backlight();
  lcd.print("Tank level");
  Serial.println("tank level monitor ready");
}

void loop() {
  // While the alarm is on the buzzer beeps: it changes over every quarter
  // second, or at the first pass after one if a reading held the loop up.
  if (millis() - lastBeep >= 250) {
    lastBeep = millis();
    beepOn = !beepOn;
  }
  digitalWrite(BUZZER, overflow && beepOn ? HIGH : LOW);

  if (millis() - lastRead < 500) return;
  lastRead = millis();

  float cm = averageCm();
  if (cm < 0) {
    setPump(false);
    overflow = false;
    lcd.setCursor(0, 0);
    lcd.print("Sensor error    ");
    lcd.setCursor(0, 1);
    lcd.print("pump stopped    ");
    if (lastPercent != -2) Serial.println("sensor error: no echo, pump stopped");
    lastPercent = -2;
    return;
  }

  int percent = percentOf(cm);
  bool wasOverflow = overflow;
  overflow = percent > OVERFLOW_ABOVE;
  if (overflow) {
    setPump(false);
    if (!wasOverflow) Serial.println("ALARM: overflow");
  } else {
    if (wasOverflow) Serial.println("alarm cleared");
    if (percent < PUMP_ON_BELOW) setPump(true);
    else if (percent >= PUMP_OFF_AT) setPump(false);
  }

  if (percent != lastPercent) {
    Serial.print("level ");
    Serial.print(percent);
    Serial.print("%  (");
    Serial.print(cm, 1);
    Serial.println(" cm)");
    lastPercent = percent;
  }
  show(percent);
}

Code walkthrough

The key functions of the sketch, and what each one does.

distanceCm sketch.ino

Sends a 10 microsecond pulse on the trigger pin, then times the echo with pulseIn, waiting at most 30 ms. If nothing comes back it returns -1. Otherwise it multiplies the time by 0.0343 and halves it, because the sound travels down and back.

// One distance in centimeters, or -1 when no echo came back in 30 ms.
float distanceCm() {
  digitalWrite(TRIG, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG, LOW);
  unsigned long us = pulseIn(ECHO, HIGH, 30000UL);
  if (us == 0) return -1;
  return us * 0.0343 / 2.0;
}

percentOf sketch.ino

Converts a distance to a fill percent using EMPTY_CM and FULL_CM. It keeps the answer between 0 and 100 and rounds it.

int percentOf(float cm) {
  float p = (EMPTY_CM - cm) / (EMPTY_CM - FULL_CM) * 100.0;
  if (p < 0) p = 0;
  if (p > 100) p = 100;
  return (int)(p + 0.5);
}

setPump sketch.ino

Turns the relay on or off, but only when the state really changes. It prints pump ON or pump OFF to Serial.

void setPump(bool on) {
  if (on == pumping) return;
  pumping = on;
  digitalWrite(RELAY, on ? HIGH : LOW);
  Serial.println(on ? "pump ON" : "pump OFF");
}

loop sketch.ino

Handles the buzzer timing, then reads the level every 500 ms. A missing echo stops the pump. Over 95 percent is overflow and stops the pump. Otherwise the pump turns on below 20 and off at 90 or more. Then it updates the LCD.

void loop() {
  // While the alarm is on the buzzer beeps: it changes over every quarter
  // second, or at the first pass after one if a reading held the loop up.
  if (millis() - lastBeep >= 250) {
    lastBeep = millis();
    beepOn = !beepOn;
  }
  digitalWrite(BUZZER, overflow && beepOn ? HIGH : LOW);

  if (millis() - lastRead < 500) return;
  lastRead = millis();

  float cm = averageCm();
  if (cm < 0) {
    setPump(false);
    overflow = false;
    lcd.setCursor(0, 0);
    lcd.print("Sensor error    ");
    lcd.setCursor(0, 1);
    lcd.print("pump stopped    ");
    if (lastPercent != -2) Serial.println("sensor error: no echo, pump stopped");
    lastPercent = -2;
    return;
  }

  int percent = percentOf(cm);
  bool wasOverflow = overflow;
  overflow = percent > OVERFLOW_ABOVE;
  // ... 19 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The level is wrong for your own tank This project
    Measure the distance from the sensor to the bottom and to the full mark, and set EMPTY_CM and FULL_CM to match.
  • The LCD says Sensor error This project
    No echo came back. Check TRIG on pin 10 and ECHO on pin 9, plus 5 V and ground to the sensor.
  • The pump does not start This project
    It only starts below PUMP_ON_BELOW (20 percent). Between 20 and 90 it keeps its last state.
  • Distance reads zero, or never changes. Ultrasonic ranger
    Power it from 5 V and share ground. Send a 10 microsecond HIGH pulse on TRIG, then time ECHO with pulseIn. Check TRIG and ECHO are not swapped.
  • Readings jump around or are wrong on a 3.3 V board. Ultrasonic ranger
    ECHO outputs 5 V, so divide it down for 3.3 V boards. Aim at flat, hard surfaces. The range is about 2 cm to 4 m, and soft or angled targets bounce the sound away.
  • The relay does not click, or the board resets when it switches. Relay module
    A relay coil needs more current than a pin can give. Use a module with a transistor driver, and power VCC from 5 V. Share ground between the module and the Arduino.
  • The relay works backwards: on when you expect off. Relay module
    Many modules are active LOW: the input must go LOW to turn the relay on. Set the pin HIGH at startup so the load does not switch on during boot.
  • A pump wired straight to a microcontroller pin does not run or resets the board. It draws far more than a pin can give. Mini water pump
    Switch it with a logic level MOSFET or a transistor with a base resistor, from its own supply of 3 to 6 V. Join the grounds.

Questions and answers

How does the HC-SR04 measure the water level?

It times the echo off the water from the lid: the distance in cm is the echo time in microseconds times 0.0343, divided by 2. The level is how far that is above the empty mark, as a percent of the depth between the empty and full distances.

Why does the pump start at 20 percent and stop at 90?

Two marks give hysteresis. With one mark the pump would switch on and off every few seconds as ripples crossed it, which wears out the pump and the relay.

How do I set it up for my own tank?

Measure from the sensor to the bottom (EMPTY_CM) and to the water when full (FULL_CM), and change those two numbers. Keep the full mark a few centimeters below the sensor, since an HC-SR04 reads from about 2 cm.

What happens if the sensor fails or gets wet?

No echo reads as a sensor error: the LCD says so and the pump stops, because a controller that cannot see the water must not run the pump. Past 95 percent the buzzer sounds and the pump stops whatever it was doing.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.