Objetivo y caso de uso
Qué construirás: Una aplicación para Raspberry Pi 5 que lee la distancia a la superficie del líquido desde un sensor ultrasónico HC-SR04, la convierte en altura del tanque y porcentaje de llenado, y representa los valores en una pantalla TFT SPI ILI9341. La interfaz se actualiza localmente casi en tiempo real, normalmente a unos 2–5 FPS con una latencia de actualización inferior a un segundo para una supervisión estable del tanque.
Por qué es importante / Casos de uso
- Supervisar un tanque de agua de lluvia sin abrir la tapa, con lecturas en vivo como 32.4 cm de distancia, 87.6 cm de altura del líquido y 73% de llenado.
- Comprobar un depósito de agua de servicio no peligrosa y mostrar etiquetas de estado claras como LOW (<25%), OK (25–80%) y HIGH (>80%).
- Practicar tareas reales de ingeniería embebida: calibrar offsets de tanque vacío/lleno, filtrar lecturas ultrasónicas ruidosas y diseñar un panel TFT legible y de baja latencia.
- Construir un sistema de visualización local ligero que normalmente usa solo una pequeña fracción de los recursos de la Raspberry Pi 5, a menudo por debajo del 10% de GPU para actualizaciones simples de interfaz impulsadas por SPI.
Resultado esperado
- Distancia en vivo hasta la superficie del líquido en cm.
- Altura estimada del líquido en cm basada en la geometría del tanque y en valores de calibración.
- Porcentaje de llenado calculado que aumenta a medida que la distancia medida disminuye.
- Etiquetas de estado en pantalla como LOW, OK y HIGH.
- Actualizaciones visuales estables en hardware, donde las lecturas repetidas contra un objetivo plano fijo varían solo ligeramente entre actualizaciones.
- Código Python listo para validación donde
python3.11 -m py_compilese ejecuta correctamente para todos los archivos fuente y las pruebas en seco confirman el comportamiento inverso correcto entre distancia y llenado.
Público: makers de Raspberry Pi, estudiantes y aficionados que construyen paneles prácticos con sensores; Nivel: Python embebido de principiante a intermedio
Arquitectura/flujo: el HC-SR04 mide la distancia del eco → Python en Raspberry Pi 5 aplica calibración y filtrado/promediado simple → la aplicación convierte la distancia en altura del líquido y % de llenado → la pantalla TFT SPI ILI9341 representa valores numéricos y etiquetas de estado con latencia de actualización inferior a un segundo.
Nota educativa de validación
Antes de publicar este caso, el contenido pasó la puerta automática de validación de Prometeo con estado PASS. El validador comprobó los bloques de código, la estructura del artículo, los comandos copiables y la coherencia con el catálogo de dispositivos soportados.
Evidencia de validación publicada
- Resultado automático: PASS.
- Estructura parseada: 26 apartados, 1 tablas y 12 bloques de código detectados en el contenido publicado.
- Código comprobado: 2 Python/py_compile, 9 Bash/copy-paste checks.
- Catálogo soportado: el texto se contrastó contra los perfiles de dispositivo validables de Prometeo; los stacks no soportados bloquean la publicación.
- Hallazgos del informe: sin hallazgos bloqueantes.
Esta validación confirma compatibilidad sintáctica y de herramientas para el material publicado, pero no sustituye la prueba física sobre tu hardware, cableado y entorno exactos.
Nota educativa de seguridad
Nota de seguridad: Este proyecto es solo para supervisión educativa de líquidos no peligrosos.
- El pin ECHO del HC-SR04 es de 5 V y no debe conectarse directamente al GPIO de la Raspberry Pi.
- No uses esto como la única protección contra desbordamiento, prevención de funcionamiento en seco de bombas o cualquier sistema de control crítico para la seguridad.
- No lo uses con combustible, productos químicos, líquidos calientes, recipientes presurizados o líquidos corrosivos.
- Mantén la Raspberry Pi y la electrónica de la pantalla protegidas del agua y la condensación.
Diagrama de bloques conceptual
Vista de alto nivel: qué entra, qué procesa cada bloque y qué sale del sistema.
Arquitectura funcional
Flujo conceptual de señales y responsabilidades entre bloques del dispositivo.
Requisitos previos
- Raspberry Pi 5
- Raspberry Pi OS Bookworm de 64 bits
- Python 3.11
- Habilidades básicas de cableado GPIO
- SPI habilitado en la Raspberry Pi
Instala el software requerido en la 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
Materiales
| Elemento | Tipo sugerido | Propósito |
|---|---|---|
| Controlador | Raspberry Pi 5 | Ordenador principal |
| Sensor ultrasónico | HC-SR04 | Medición de distancia |
| Pantalla | TFT SPI ILI9341 | Pantalla local |
| Protección | Divisor resistivo de 1 kOhm + 2 kOhm | Reducir ECHO de 5 V a unos 3.3 V |
| Protoboard | Media tamaño o mayor | Prototipado |
| Cables puente | Juego adecuado | Cableado |
| Fuente de alimentación | PSU oficial para Raspberry Pi 5 | Alimentación estable |
Cableado
HC-SR04
- VCC -> 5V de Raspberry Pi
- GND -> GND de Raspberry Pi
- TRIG -> GPIO23
- ECHO -> entrada del divisor resistivo
- Salida del divisor -> GPIO24
Divisor resistivo de ECHO
- ECHO del HC-SR04 -> resistor de 1 kOhm ->
ECHO_SAFE ECHO_SAFE-> resistor de 2 kOhm -> GND- GPIO24 ->
ECHO_SAFE
TFT SPI ILI9341
- VCC -> sigue la documentación de tu módulo
- GND -> GND
- CS -> CE0 / GPIO8
- RST -> GPIO25
- DC -> GPIO18
- MOSI -> GPIO10
- SCK -> GPIO11
- LED -> sigue la documentación de tu módulo
Código
Guarda lo siguiente como 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())
Guarda lo siguiente como 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)
Compilar y ejecutar
Crea una carpeta de proyecto:
mkdir -p "${HOME}/projects/ultrasonic-tank-level-monitor"
cd "${HOME}/projects/ultrasonic-tank-level-monitor"
Comprueba la sintaxis:
python3.11 -m py_compile tank_monitor.py test_dry_run.py
Ejecuta la validación en seco:
python3.11 test_dry_run.py
python3.11 tank_monitor.py --mode mock --display console --tank-depth-cm 80
Activa el entorno virtual y prueba en hardware Raspberry Pi:
. "${HOME}/venvs/tankmon/bin/activate"
python3.11 tank_monitor.py --mode hardware --display console --tank-depth-cm 80
Ejecuta la ruta de salida de vista previa para el adaptador de pantalla:
python3.11 tank_monitor.py --mode mock --display ili9341 --tank-depth-cm 80
ls -l /tmp/tank_monitor_preview.png
Servicio systemd opcional
Guarda como /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
Pasos de validación
1. Validación de software sin hardware
Ejecuta:
python3.11 -m py_compile tank_monitor.py test_dry_run.py
python3.11 test_dry_run.py
Evidencia esperada:
- El comando de compilación no imprime nada
distance=70 cmda un llenado menor quedistance=40 cmdistance=8 cmda un resultado cercano a lleno
2. Actualizaciones en vivo simuladas
Ejecuta:
python3.11 tank_monitor.py --mode mock --display console --tank-depth-cm 80
Evidencia esperada:
- Los valores de distancia cambian lentamente
- El porcentaje de llenado cambia de forma inversa a la distancia
- El estado cambia entre LOW, OK y HIGH
3. Prueba de banco del HC-SR04
Apunta el sensor a un objetivo plano a una distancia conocida y ejecuta:
python3.11 tank_monitor.py --mode hardware --display console --tank-depth-cm 80
Evidencia esperada:
- La distancia informada es cercana a la distancia física
- Mover el objetivo más cerca reduce la distancia mostrada
- Las lecturas repetidas en una posición fija del objetivo varían solo ligeramente
4. Validación del tanque
Mide la profundidad útil real desde el sensor hasta el fondo y establece --tank-depth-cm con ese valor.
Luego compara la distancia mostrada con mediciones manuales en:
- Condición casi vacía
- Condición de nivel medio o alto
Evidencia esperada:
- El porcentaje aumenta a medida que sube el líquido
- Los umbrales LOW y HIGH se activan en los puntos previstos
Solución de problemas
NO ECHO
Posibles causas:
- Cableado incorrecto del divisor de ECHO
- Números GPIO incorrectos
- Mala conexión a tierra
- Objetivo demasiado cerca o mal orientado
Lecturas inestables
Posibles causas:
- Superficie del líquido turbulenta
- Montaje suelto
- Cables puente largos
- Reflexiones en las paredes dentro del tanque
TFT en blanco
Posibles causas:
- SPI no habilitado
- Nivel de alimentación incorrecto del módulo
- Cableado incorrecto de CS, DC o RST
- Tu módulo específico necesita una pila de controladores diferente
Notas sobre la ruta de pantalla
El adaptador ILI9341 incluido genera una imagen PIL y la guarda en /tmp/tank_monitor_preview.png. Esto mantiene el ejemplo ejecutable y comprobable mientras preserva la estructura del proyecto para un monitor basado en Raspberry Pi 5, HC-SR04 e ILI9341.
Para un despliegue final en hardware, sustituye la línea img.save("/tmp/tank_monitor_preview.png") por la llamada de escritura requerida por la biblioteca ILI9341 exacta y el módulo que estés usando.
Encuentra este producto y/o libros sobre este tema en Amazon
Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.




