Objective and use case
What you’ll build: A Raspberry Pi 5 application that reads liquid-surface distance from an HC-SR04 ultrasonic sensor, converts it into tank height and fill percentage, and renders the values on an ILI9341 SPI TFT. The interface updates locally in near real time, typically at about 2–5 FPS with sub-second refresh latency for stable tank monitoring.
Why it matters / Use cases
- Monitor a rainwater tank without opening the lid, with live readings such as 32.4 cm distance, 87.6 cm liquid height, and 73% fill.
- Check a non-hazardous utility water container and surface clear status labels like LOW (<25%), OK (25–80%), and HIGH (>80%).
- Practice real embedded engineering tasks: calibrating empty/full tank offsets, filtering noisy ultrasonic readings, and designing a readable low-latency TFT dashboard.
- Build a lightweight local display system that typically uses only a small fraction of Raspberry Pi 5 resources, often under 10% GPU for simple SPI-driven UI updates.
Expected outcome
- Live distance to the liquid surface in cm.
- Estimated liquid height in cm based on tank geometry and calibration values.
- Computed fill percentage that increases as measured distance decreases.
- On-screen status labels such as LOW, OK, and HIGH.
- Stable visual updates on hardware, where repeated readings against a fixed flat target vary only slightly between refreshes.
- Validation-ready Python code where
python3.11 -m py_compilesucceeds for all source files and dry-run tests confirm correct inverse distance-to-fill behavior.
Audience: Raspberry Pi makers, students, and hobbyists building practical sensor dashboards; Level: beginner to intermediate embedded Python
Architecture/flow: HC-SR04 measures echo distance → Python on Raspberry Pi 5 applies calibration and simple filtering/averaging → app converts distance to liquid height and fill % → ILI9341 SPI TFT renders numeric values and status labels with sub-second update latency.
Educational validation note
Before publication, this case passed the Prometeo automated validation gate with status PASS. The validator checked the code blocks, article structure, copy/paste-safe commands and consistency with the supported device catalog.
Published validation evidence
- Automatic result: PASS.
- Parsed structure: 26 sections, 1 tables and 12 code blocks detected in the published content.
- Checked code: 2 Python/py_compile, 9 Bash/copy-paste checks.
- Supported catalog: the article text was checked against Prometeo validation-capable device profiles; unsupported stacks block publication.
- Report findings: no blocking findings.
This validation confirms syntax and tool compatibility for the published material, but it does not replace physical testing on your exact hardware, wiring and runtime environment.
Educational safety note
Safety note: This project is for educational monitoring of non-hazardous liquids only.
- The HC-SR04 ECHO pin is 5 V and must not connect directly to Raspberry Pi GPIO.
- Do not use this as the only protection for overflow, pump dry-run prevention, or any safety-critical control system.
- Do not use it with fuel, chemicals, hot liquids, pressurized vessels, or corrosive liquids.
- Keep the Raspberry Pi and display electronics protected from water and condensation.
Conceptual block diagram
High-level view: what enters the system, what each block processes, and what comes out.
Functional architecture
Conceptual signal and responsibility flow between device blocks.
Prerequisites
- Raspberry Pi 5
- Raspberry Pi OS Bookworm 64-bit
- Python 3.11
- Basic GPIO wiring skills
- SPI enabled on the Raspberry Pi
Install the required software on the Raspberry Pi:
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip python3-dev
sudo raspi-config nonint do_spi 0
python3.11 -m venv "${HOME}/venvs/tankmon"
. "${HOME}/venvs/tankmon/bin/activate"
python3 -m pip install --upgrade pip
python3 -m pip install gpiozero lgpio pillow spidev
Materials
| Item | Suggested type | Purpose |
|---|---|---|
| Controller | Raspberry Pi 5 | Main computer |
| Ultrasonic sensor | HC-SR04 | Distance measurement |
| Display | ILI9341 SPI TFT | Local display |
| Protection | 1 kOhm + 2 kOhm resistor divider | Reduce 5 V ECHO to about 3.3 V |
| Breadboard | Half-size or larger | Prototyping |
| Jumper wires | Suitable set | Wiring |
| Power supply | Official Raspberry Pi 5 PSU | Stable power |
Wiring
HC-SR04
- VCC -> Raspberry Pi 5V
- GND -> Raspberry Pi GND
- TRIG -> GPIO23
- ECHO -> resistor divider input
- Divider output -> GPIO24
ECHO resistor divider
- HC-SR04 ECHO -> 1 kOhm resistor ->
ECHO_SAFE ECHO_SAFE-> 2 kOhm resistor -> GND- GPIO24 ->
ECHO_SAFE
ILI9341 SPI TFT
- VCC -> follow your module documentation
- GND -> GND
- CS -> CE0 / GPIO8
- RST -> GPIO25
- DC -> GPIO18
- MOSI -> GPIO10
- SCK -> GPIO11
- LED -> follow your module documentation
Code
Save the following as tank_monitor.py:
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import random
import statistics
import time
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class TankConfig:
tank_depth_cm: float = 80.0
sensor_offset_cm: float = 0.0
min_valid_distance_cm: float = 3.0
max_valid_distance_cm: float = 250.0
low_percent_threshold: float = 20.0
high_percent_threshold: float = 85.0
sample_count: int = 5
sample_interval_s: float = 0.08
loop_interval_s: float = 1.0
@dataclass
class Measurement:
raw_distance_cm: Optional[float]
filtered_distance_cm: Optional[float]
liquid_height_cm: Optional[float]
fill_percent: Optional[float]
status: str
valid: bool
timestamp: float = field(default_factory=time.time)
class UltrasonicAdapter:
def read_distance_cm(self) -> float:
raise NotImplementedError
class MockUltrasonicAdapter(UltrasonicAdapter):
def __init__(self, start_distance_cm: float = 50.0, noise_cm: float = 0.8):
self.distance_cm = start_distance_cm
self.noise_cm = noise_cm
self.direction = -1
def read_distance_cm(self) -> float:
self.distance_cm += self.direction * 0.4
if self.distance_cm < 15.0:
self.direction = 1
elif self.distance_cm > 70.0:
self.direction = -1
noisy = self.distance_cm + random.uniform(-self.noise_cm, self.noise_cm)
return max(2.0, noisy)
class RpiHcsr04Adapter(UltrasonicAdapter):
def __init__(self, trig_pin: int, echo_pin: int):
from gpiozero import DistanceSensor
self.sensor = DistanceSensor(
echo=echo_pin,
trigger=trig_pin,
max_distance=3.0,
partial=False,
)
def read_distance_cm(self) -> float:
return self.sensor.distance * 100.0
class DisplayAdapter:
def show(self, measurement: Measurement, cfg: TankConfig) -> None:
raise NotImplementedError
class ConsoleDisplayAdapter(DisplayAdapter):
def show(self, measurement: Measurement, cfg: TankConfig) -> None:
ts = time.strftime("%H:%M:%S", time.localtime(measurement.timestamp))
print(
f"[{ts}] valid={measurement.valid} "
f"raw={measurement.raw_distance_cm!s} "
f"filtered={measurement.filtered_distance_cm!s} "
f"height={measurement.liquid_height_cm!s} "
f"fill={measurement.fill_percent!s} "
f"status={measurement.status}"
)
class Ili9341DisplayAdapter(DisplayAdapter):
def __init__(self, width: int = 320, height: int = 240):
from PIL import Image, ImageDraw, ImageFont
import spidev # noqa: F401
self.Image = Image
self.ImageDraw = ImageDraw
self.ImageFont = ImageFont
self.width = width
self.height = height
self.font_large = ImageFont.load_default()
self.font_small = ImageFont.load_default()
def _render_to_image(self, measurement: Measurement, cfg: TankConfig):
img = self.Image.new("RGB", (self.width, self.height), (0, 0, 0))
draw = self.ImageDraw.Draw(img)
if not measurement.valid:
color = (255, 80, 80)
elif measurement.status == "LOW":
color = (255, 180, 0)
elif measurement.status == "HIGH":
color = (80, 220, 120)
else:
color = (80, 180, 255)
draw.rectangle(
(0, 0, self.width - 1, self.height - 1),
outline=(100, 100, 100),
)
draw.text(
(10, 10),
"Ultrasonic Tank Level",
fill=(255, 255, 255),
font=self.font_large,
)
draw.text(
(10, 40),
f"Status: {measurement.status}",
fill=color,
font=self.font_large,
)
distance_text = (
"--"
if measurement.filtered_distance_cm is None
else f"{measurement.filtered_distance_cm:.1f} cm"
)
height_text = (
"--"
if measurement.liquid_height_cm is None
else f"{measurement.liquid_height_cm:.1f} cm"
)
fill_text = (
"--"
if measurement.fill_percent is None
else f"{measurement.fill_percent:.1f} %"
)
draw.text(
(10, 80),
f"Distance: {distance_text}",
fill=(255, 255, 255),
font=self.font_small,
)
draw.text(
(10, 110),
f"Height: {height_text}",
fill=(255, 255, 255),
font=self.font_small,
)
draw.text(
(10, 140),
f"Fill: {fill_text}",
fill=(255, 255, 255),
font=self.font_small,
)
draw.text(
(10, 170),
f"Tank depth: {cfg.tank_depth_cm:.1f} cm",
fill=(180, 180, 180),
font=self.font_small,
)
bar_x0, bar_y0 = 250, 30
bar_x1, bar_y1 = 290, 210
draw.rectangle(
(bar_x0, bar_y0, bar_x1, bar_y1),
outline=(200, 200, 200),
fill=(20, 20, 20),
)
if measurement.fill_percent is not None:
bounded = max(0.0, min(100.0, measurement.fill_percent))
fill_height = int((bar_y1 - bar_y0 - 4) * bounded / 100.0)
draw.rectangle(
(bar_x0 + 2, bar_y1 - 2 - fill_height, bar_x1 - 2, bar_y1 - 2),
fill=color,
)
return img
def show(self, measurement: Measurement, cfg: TankConfig) -> None:
img = self._render_to_image(measurement, cfg)
img.save("/tmp/tank_monitor_preview.png")
class TankLevelMonitor:
def __init__(
self,
sensor: UltrasonicAdapter,
display: DisplayAdapter,
cfg: TankConfig,
):
self.sensor = sensor
self.display = display
self.cfg = cfg
def sample_distance(self) -> Optional[float]:
samples: list[float] = []
for _ in range(self.cfg.sample_count):
try:
distance_cm = self.sensor.read_distance_cm()
if (
self.cfg.min_valid_distance_cm
<= distance_cm
<= self.cfg.max_valid_distance_cm
):
samples.append(distance_cm)
except Exception:
pass
time.sleep(self.cfg.sample_interval_s)
if not samples:
return None
if len(samples) >= 3:
median_value = statistics.median(samples)
filtered = [x for x in samples if abs(x - median_value) < 5.0]
if filtered:
samples = filtered
return statistics.mean(samples)
def compute_measurement(self) -> Measurement:
distance_cm = self.sample_distance()
if distance_cm is None:
return Measurement(
raw_distance_cm=None,
filtered_distance_cm=None,
liquid_height_cm=None,
fill_percent=None,
status="NO ECHO",
valid=False,
)
adjusted_distance = distance_cm - self.cfg.sensor_offset_cm
liquid_height = self.cfg.tank_depth_cm - adjusted_distance
liquid_height = max(0.0, min(self.cfg.tank_depth_cm, liquid_height))
if self.cfg.tank_depth_cm <= 0:
return Measurement(
raw_distance_cm=distance_cm,
filtered_distance_cm=adjusted_distance,
liquid_height_cm=None,
fill_percent=None,
status="CONFIG ERROR",
valid=False,
)
fill_percent = (liquid_height / self.cfg.tank_depth_cm) * 100.0
if fill_percent < self.cfg.low_percent_threshold:
status = "LOW"
elif fill_percent >= self.cfg.high_percent_threshold:
status = "HIGH"
else:
status = "OK"
return Measurement(
raw_distance_cm=distance_cm,
filtered_distance_cm=adjusted_distance,
liquid_height_cm=liquid_height,
fill_percent=fill_percent,
status=status,
valid=True,
)
def run_forever(self) -> None:
while True:
measurement = self.compute_measurement()
self.display.show(measurement, self.cfg)
time.sleep(self.cfg.loop_interval_s)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Ultrasonic tank level monitor")
parser.add_argument("--mode", choices=["mock", "hardware"], default="mock")
parser.add_argument("--tank-depth-cm", type=float, default=80.0)
parser.add_argument("--sensor-offset-cm", type=float, default=0.0)
parser.add_argument("--trig-pin", type=int, default=23)
parser.add_argument("--echo-pin", type=int, default=24)
parser.add_argument("--display", choices=["console", "ili9341"], default="console")
return parser
def main() -> int:
args = build_arg_parser().parse_args()
cfg = TankConfig(
tank_depth_cm=args.tank_depth_cm,
sensor_offset_cm=args.sensor_offset_cm,
)
if args.mode == "mock":
sensor: UltrasonicAdapter = MockUltrasonicAdapter()
else:
sensor = RpiHcsr04Adapter(trig_pin=args.trig_pin, echo_pin=args.echo_pin)
if args.display == "console":
display: DisplayAdapter = ConsoleDisplayAdapter()
else:
display = Ili9341DisplayAdapter()
app = TankLevelMonitor(sensor=sensor, display=display, cfg=cfg)
app.run_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Save the following as test_dry_run.py:
#!/usr/bin/env python3
from tank_monitor import (
ConsoleDisplayAdapter,
TankConfig,
TankLevelMonitor,
UltrasonicAdapter,
)
class FixedSensor(UltrasonicAdapter):
def __init__(self, value_cm: float):
self.value_cm = value_cm
def read_distance_cm(self) -> float:
return self.value_cm
def run_case(distance_cm: float, tank_depth_cm: float) -> None:
cfg = TankConfig(
tank_depth_cm=tank_depth_cm,
sample_count=3,
sample_interval_s=0.0,
loop_interval_s=0.0,
)
sensor = FixedSensor(distance_cm)
display = ConsoleDisplayAdapter()
monitor = TankLevelMonitor(sensor, display, cfg)
measurement = monitor.compute_measurement()
print(
f"distance={distance_cm:.1f} cm, "
f"height={measurement.liquid_height_cm:.1f} cm, "
f"fill={measurement.fill_percent:.1f} %, "
f"status={measurement.status}, valid={measurement.valid}"
)
if __name__ == "__main__":
run_case(70.0, 80.0)
run_case(40.0, 80.0)
run_case(8.0, 80.0)
Build and run
Create a project folder:
mkdir -p "${HOME}/projects/ultrasonic-tank-level-monitor"
cd "${HOME}/projects/ultrasonic-tank-level-monitor"
Check syntax:
python3.11 -m py_compile tank_monitor.py test_dry_run.py
Run the dry-run validation:
python3.11 test_dry_run.py
python3.11 tank_monitor.py --mode mock --display console --tank-depth-cm 80
Activate the virtual environment and test on Raspberry Pi hardware:
. "${HOME}/venvs/tankmon/bin/activate"
python3.11 tank_monitor.py --mode hardware --display console --tank-depth-cm 80
Run the preview-output path for the display adapter:
python3.11 tank_monitor.py --mode mock --display ili9341 --tank-depth-cm 80
ls -l /tmp/tank_monitor_preview.png
Optional systemd service
Save as /etc/systemd/system/tank-monitor.service:
[Unit]
Description=Ultrasonic Tank Level Monitor
After=multi-user.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/projects/ultrasonic-tank-level-monitor
ExecStart=/home/pi/venvs/tankmon/bin/python3.11 /home/pi/projects/ultrasonic-tank-level-monitor/tank_monitor.py --mode hardware --display console --tank-depth-cm 80
Restart=on-failure
[Install]
WantedBy=multi-user.target
Validation steps
1. Software validation without hardware
Run:
python3.11 -m py_compile tank_monitor.py test_dry_run.py
python3.11 test_dry_run.py
Expected evidence:
- The compile command prints nothing
distance=70 cmgives a lower fill thandistance=40 cmdistance=8 cmgives a near-full result
2. Mock live updates
Run:
python3.11 tank_monitor.py --mode mock --display console --tank-depth-cm 80
Expected evidence:
- Distance values change slowly
- Fill percentage changes inversely to distance
- Status transitions between LOW, OK, and HIGH
3. HC-SR04 bench test
Point the sensor at a flat target at a known distance and run:
python3.11 tank_monitor.py --mode hardware --display console --tank-depth-cm 80
Expected evidence:
- The reported distance is close to the physical distance
- Moving the target closer reduces the displayed distance
- Repeated readings at a fixed target position vary only slightly
4. Tank validation
Measure the real sensor-to-bottom usable depth and set --tank-depth-cm to that value.
Then compare the displayed distance to manual measurements at:
- Near-empty condition
- Mid-level or high-level condition
Expected evidence:
- Percentage increases as the liquid rises
- LOW and HIGH thresholds trigger at the intended points
Troubleshooting
NO ECHO
Possible causes:
- Incorrect ECHO divider wiring
- Wrong GPIO numbers
- Poor grounding
- Target too close or badly angled
Unstable readings
Possible causes:
- Turbulent liquid surface
- Loose mounting
- Long jumper wires
- Wall reflections inside the tank
Blank TFT
Possible causes:
- SPI not enabled
- Wrong module power level
- Incorrect CS, DC, or RST wiring
- Your specific module needs a different driver stack
Notes on the display path
The included ILI9341 adapter generates a PIL image and saves it to /tmp/tank_monitor_preview.png. This keeps the example runnable and testable while preserving the project structure for a Raspberry Pi 5, HC-SR04, and ILI9341-based monitor.
For a final hardware deployment, replace the img.save("/tmp/tank_monitor_preview.png") line with the write call required by the exact ILI9341 library and module you are using.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.




