Caso práctico: monitor de nivel con Raspberry Pi 5

Caso práctico: monitor de nivel con Raspberry Pi 5 — hero

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_compile se 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

el HC-SR04 mide la distancia del eco

Python en Raspberry Pi 5 aplica calibraci…

la aplicación convierte la distancia en a…

la pantalla TFT SPI ILI9341 representa va…

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

ElementoTipo sugeridoPropósito
ControladorRaspberry Pi 5Ordenador principal
Sensor ultrasónicoHC-SR04Medición de distancia
PantallaTFT SPI ILI9341Pantalla local
ProtecciónDivisor resistivo de 1 kOhm + 2 kOhmReducir ECHO de 5 V a unos 3.3 V
ProtoboardMedia tamaño o mayorPrototipado
Cables puenteJuego adecuadoCableado
Fuente de alimentaciónPSU oficial para Raspberry Pi 5Alimentació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 cm da un llenado menor que distance=40 cm
  • distance=8 cm da 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

Ir a 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.

Quiz rápido

Pregunta 1: ¿Qué placa base se utiliza para construir la aplicación según el artículo?




Pregunta 2: ¿Qué modelo de sensor ultrasónico se menciona para leer la distancia?




Pregunta 3: ¿Qué tipo de pantalla se utiliza para representar los valores del tanque?




Pregunta 4: ¿Cuál es la tasa de actualización típica (FPS) de la interfaz mencionada en el texto?




Pregunta 5: Según el artículo, ¿qué etiqueta de estado se muestra si el porcentaje de llenado es menor al 25%?




Pregunta 6: ¿Qué rango de porcentaje de llenado corresponde a la etiqueta 'OK'?




Pregunta 7: ¿Qué ocurre con el porcentaje de llenado calculado a medida que disminuye la distancia medida por el sensor?




Pregunta 8: ¿Qué porcentaje aproximado de recursos de GPU suele utilizar este sistema de visualización en la Raspberry Pi 5?




Pregunta 9: ¿En qué unidad de medida se muestra la distancia en vivo hasta la superficie del líquido?




Pregunta 10: ¿Qué tarea de ingeniería embebida se menciona como práctica en este proyecto?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:
Scroll al inicio