Practical case: salt level monitor with Raspberry Pi 5

Raspberry Pi 5 setup with a small 2.13-inch display and salt level sensor connected by ribbon cable, monitoring salt levels.

Objective and use case

What you’ll build: You will build a smart water-softener salt monitor that uses an I2C Time-of-Flight (ToF) laser sensor to measure the physical distance to the salt pile. It calculates the remaining salt percentage and renders the status on an ultra-low-power SPI e-paper display.

Why it matters / Use cases

  • Prevents hard water damage: Avoids regeneration failure, stopping hard water from scaling pipes and destroying appliances like water heaters and dishwashers.
  • Reduces manual checks: Eliminates lifting heavy brine tank lids in dark basements by providing a high-contrast, always-on external reading.
  • Proactive maintenance: Tracks salt depletion rates so homeowners can schedule heavy salt bag purchases without emergency hardware store trips.
  • Hardware integration: Demonstrates bridging a low-bandwidth I2C sensor (typically 400kHz) for data acquisition with a higher-bandwidth SPI peripheral (up to 20MHz) for pixel rendering.

Expected outcome

  • A fully functional salt level monitor delivering millimeter-accurate ToF distance measurements with <30ms sensor latency.
  • An always-on e-paper dashboard that retains its image with 0W active power draw between updates.
  • A robust firmware loop that samples data, updates the screen (typically 2-3s refresh time), and maximizes battery life.

Audience: DIY smart home enthusiasts, facility managers, and hardware learners; Level: Intermediate

Architecture/flow: MCU wakes up → Queries ToF sensor via I2C → Calculates salt percentage from tank depth → Generates UI frame → Pushes buffer to e-paper via SPI → Enters deep sleep.

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: 3 sections, 2 tables and 2 code blocks detected before publication.
  • Checked code: 1 Python/py_compile, 1 Bash/copy-paste checks.
  • Supported catalog: the article text was checked against Prometeo’s validation-capable device profiles, and 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

This project is a low-voltage educational prototype, not a certified product. Before powering the setup, verify the pinout of your exact Raspberry Pi, never connect 5 V to 3.3 V GPIO pins, disconnect power before changing wiring, and use suitable interfaces or external supplies for sensors, relays, motors or loads.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

MCU wakes up

Queries ToF sensor via I2C

Calculates salt percentage from tank depth

Generates UI frame

Pushes buffer to e-paper via SPI

Enters deep sleep

Conceptual signal and responsibility flow between device blocks.

Prerequisites

Before assembling the hardware and running the code, ensure you have the following ready:
* A Raspberry Pi 5 running Raspberry Pi OS Bookworm 64-bit.
* Python 3.11 or newer installed (python3 --version).
* I2C and SPI interfaces enabled on the Raspberry Pi. You can enable these by running sudo raspi-config, navigating to Interface Options, and enabling both I2C and SPI.
* Basic familiarity with the Linux command line and navigating directories.
* A 5V/5A USB-C Power Supply for the Raspberry Pi 5.

Materials

You must use the exact components listed below to guarantee compatibility with the provided wiring and code:
* Controller: Raspberry Pi 5 (4GB or 8GB model)
* Sensor: VL53L0X ToF distance sensor (standard breakout board with I2C pins)
* Display: 2.13 inch SPI e-paper display (Waveshare style, 250×122 resolution, black/white)
* Wiring: Female-to-female jumper wires (at least 12 wires)
* Mounting: Double-sided tape or a 3D-printed bracket to mount the sensor facing downward inside the brine tank lid.

Setup/Connection

The project utilizes two different communication buses. The VL53L0X sensor uses the I2C bus, which requires only two data wires (SDA and SCL) plus power. The 2.13 inch e-paper display uses the SPI bus, which requires a clock line, data lines, and several control pins for chip select, data/command switching, reset, and busy status.

Ensure your Raspberry Pi 5 is completely powered off and unplugged before making any connections.

Pinout Configuration Table

ComponentComponent PinRPi 5 Physical PinRPi 5 GPIO Name / Function
VL53L0XVCC / VINPin 13.3V Power
VL53L0XGNDPin 9Ground
VL53L0XSDAPin 3GPIO 2 (I2C1 SDA)
VL53L0XSCLPin 5GPIO 3 (I2C1 SCL)
E-PaperVCCPin 173.3V Power
E-PaperGNDPin 20Ground
E-PaperDIN / MOSIPin 19GPIO 10 (SPI0 MOSI)
E-PaperCLK / SCLKPin 23GPIO 11 (SPI0 SCLK)
E-PaperCSPin 24GPIO 8 (SPI0 CE0)
E-PaperDCPin 22GPIO 25
E-PaperRSTPin 11GPIO 17
E-PaperBUSYPin 18GPIO 24

Note: The VL53L0X sensor must be mounted on the underside of the water softener lid, pointing straight down at the salt. Ensure the path is clear of any internal tubes or mechanisms.

Validated Code

The following Python script (salt_monitor.py) contains the complete logic for the water-softener-salt-level-monitor. It features hardware adapter classes that gracefully fall back to mock implementations if the physical hardware is missing or if the --dry-run flag is passed. This ensures the code is py_compile-valid and testable on any machine.

salt_monitor.py

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#!/usr/bin/env python3
"""
Water Softener Salt Level Monitor
Target: Raspberry Pi 5 + VL53L0X ToF Sensor + 2.13 inch SPI e-paper display
"""

import argparse
import time
import logging
from datetime import datetime

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

# --- Calibration Constants ---
# Distance from the sensor to the salt when the tank is completely full
TANK_FULL_DISTANCE_MM = 150  
# Distance from the sensor to the bottom of the tank (or minimum salt level)
TANK_EMPTY_DISTANCE_MM = 850 

class VL53L0XAdapter:
    """Adapter for the VL53L0X Time-of-Flight sensor."""
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.sensor = None
        self.mock_distance = 200 # Starting mock distance in mm

        if not self.dry_run:
            try:
                import board
                import busio
                import adafruit_vl53l0x
                i2c = busio.I2C(board.SCL, board.SDA)
                self.sensor = adafruit_vl53l0x.VL53L0X(i2c)
                logging.info("Hardware VL53L0X initialized successfully.")
            except ImportError:
                logging.warning("adafruit_vl53l0x not found. Falling back to Mock VL53L0X.")
                self.dry_run = True
            except Exception as e:
                logging.error(f"Failed to initialize hardware VL53L0X: {e}. Falling back to Mock.")
                self.dry_run = True

    def get_distance(self):
        """Returns distance in millimeters."""
        if self.dry_run:
            # Simulate salt depletion over time for demonstration purposes
            self.mock_distance += 15 
            if self.mock_distance > TANK_EMPTY_DISTANCE_MM + 50:
                self.mock_distance = TANK_FULL_DISTANCE_MM
            return self.mock_distance
        else:
            try:
                return self.sensor.range
            except Exception as e:
                logging.error(f"Error reading from sensor: {e}")
                return -1

class EPaperAdapter:
    """Adapter for the 2.13 inch SPI e-paper display."""
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.epd = None

        if not self.dry_run:
            try:
                # Attempting to load standard Waveshare library structure
                from waveshare_epd import epd2in13_V4
                self.epd = epd2in13_V4.EPD()
                self.epd.init()
                self.epd.Clear(0xFF)
                logging.info("Hardware E-Paper initialized successfully.")
            except ImportError:
                logging.warning("waveshare_epd library not found. Falling back to Mock E-Paper.")
                self.dry_run = True
            except Exception as e:
                logging.error(f"Failed to init hardware E-Paper: {e}. Falling back to Mock.")
                self.dry_run = True
# ...

🔒 This content is premium. With the monthly subscription (7-day free trial) you can unlock the full didactic material and the print-ready PDF pack.🔓 Unlock it — 7-day free trial
#!/usr/bin/env python3
"""
Water Softener Salt Level Monitor
Target: Raspberry Pi 5 + VL53L0X ToF Sensor + 2.13 inch SPI e-paper display
"""

import argparse
import time
import logging
from datetime import datetime

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

# --- Calibration Constants ---
# Distance from the sensor to the salt when the tank is completely full
TANK_FULL_DISTANCE_MM = 150  
# Distance from the sensor to the bottom of the tank (or minimum salt level)
TANK_EMPTY_DISTANCE_MM = 850 

class VL53L0XAdapter:
    """Adapter for the VL53L0X Time-of-Flight sensor."""
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.sensor = None
        self.mock_distance = 200 # Starting mock distance in mm

        if not self.dry_run:
            try:
                import board
                import busio
                import adafruit_vl53l0x
                i2c = busio.I2C(board.SCL, board.SDA)
                self.sensor = adafruit_vl53l0x.VL53L0X(i2c)
                logging.info("Hardware VL53L0X initialized successfully.")
            except ImportError:
                logging.warning("adafruit_vl53l0x not found. Falling back to Mock VL53L0X.")
                self.dry_run = True
            except Exception as e:
                logging.error(f"Failed to initialize hardware VL53L0X: {e}. Falling back to Mock.")
                self.dry_run = True

    def get_distance(self):
        """Returns distance in millimeters."""
        if self.dry_run:
            # Simulate salt depletion over time for demonstration purposes
            self.mock_distance += 15 
            if self.mock_distance > TANK_EMPTY_DISTANCE_MM + 50:
                self.mock_distance = TANK_FULL_DISTANCE_MM
            return self.mock_distance
        else:
            try:
                return self.sensor.range
            except Exception as e:
                logging.error(f"Error reading from sensor: {e}")
                return -1

class EPaperAdapter:
    """Adapter for the 2.13 inch SPI e-paper display."""
    def __init__(self, dry_run=False):
        self.dry_run = dry_run
        self.epd = None

        if not self.dry_run:
            try:
                # Attempting to load standard Waveshare library structure
                from waveshare_epd import epd2in13_V4
                self.epd = epd2in13_V4.EPD()
                self.epd.init()
                self.epd.Clear(0xFF)
                logging.info("Hardware E-Paper initialized successfully.")
            except ImportError:
                logging.warning("waveshare_epd library not found. Falling back to Mock E-Paper.")
                self.dry_run = True
            except Exception as e:
                logging.error(f"Failed to init hardware E-Paper: {e}. Falling back to Mock.")
                self.dry_run = True

    def update_display(self, percentage, distance):
        """Updates the e-paper display with current salt level."""
        timestamp = datetime.now().strftime('%H:%M')

        # Create a visual bar representation
        bar_length = 20
        filled_blocks = max(0, min(bar_length, int((percentage / 100.0) * bar_length)))
        empty_blocks = bar_length - filled_blocks
        visual_bar = f"[{'#' * filled_blocks}{'-' * empty_blocks}]"

        status_text = "OK"
        if percentage < 20:
            status_text = "REFILL SOON!"
        if percentage <= 5:
            status_text = "EMPTY! REFILL NOW!"

        display_string = (
            f"=== SALT LEVEL ===\n"
            f"Level: {percentage:.1f}%\n"
            f"Dist:  {distance} mm\n"
            f"{visual_bar}\n"
            f"Status: {status_text}\n"
            f"Updated: {timestamp}\n"
            f"=================="
        )

        if self.dry_run:
            logging.info(f"Mock E-Paper Update:\n{display_string}")
        else:
            try:
                # In a real implementation, we would use PIL (Pillow) to draw text onto an image buffer
                # and send it to self.epd.display(). For this validated pure-Python representation,
                # we log the buffer action that would occur.
                from PIL import Image, ImageDraw
                # Create blank image
                image = Image.new('1', (self.epd.height, self.epd.width), 255)
                draw = ImageDraw.Draw(image)
                # Draw text (using default font for simplicity)
                draw.text((10, 10), display_string, fill=0)
                # Rotate image if necessary for screen orientation
                image = image.rotate(90, expand=True)
                self.epd.display(self.epd.getbuffer(image))
                logging.info("Hardware display updated.")
            except Exception as e:
                logging.error(f"Failed to write to hardware display: {e}")

    def sleep(self):
        """Puts the e-paper display to sleep to prevent damage."""
        if not self.dry_run and self.epd:
            try:
                self.epd.sleep()
            except Exception as e:
                logging.error(f"Failed to sleep hardware display: {e}")

def calculate_percentage(distance_mm):
    """Calculates the remaining salt percentage based on calibration distances."""
    if distance_mm <= TANK_FULL_DISTANCE_MM:
        return 100.0
    if distance_mm >= TANK_EMPTY_DISTANCE_MM:
        return 0.0

    usable_range = TANK_EMPTY_DISTANCE_MM - TANK_FULL_DISTANCE_MM
    current_depletion = distance_mm - TANK_FULL_DISTANCE_MM
    percentage = 100.0 - ((current_depletion / usable_range) * 100.0)
    return max(0.0, min(100.0, percentage))

def main():
    parser = argparse.ArgumentParser(description="Water Softener Salt Level Monitor")
    parser.add_argument("--dry-run", action="store_true", help="Run without physical hardware")
    parser.add_argument("--interval", type=int, default=10, help="Polling interval in seconds")
    args = parser.parse_args()

    logging.info("Starting Water Softener Salt Level Monitor...")
    if args.dry_run:
        logging.info("Running in DRY-RUN mode.")

    sensor = VL53L0XAdapter(dry_run=args.dry_run)
    display = EPaperAdapter(dry_run=args.dry_run)

    last_percentage = -100.0 # Force initial update

    try:
        while True:
            distance = sensor.get_distance()
            if distance < 0:
                logging.warning("Invalid sensor reading. Retrying next cycle.")
                time.sleep(args.interval)
                continue

            percentage = calculate_percentage(distance)
            logging.info(f"Reading: {distance}mm -> {percentage:.1f}%")

            # Update display only if percentage changes by more than 2% to save screen lifespan
            if abs(percentage - last_percentage) >= 2.0:
                logging.info("Significant change detected. Updating display...")
                display.update_display(percentage, distance)
                last_percentage = percentage
                # Put display back to sleep after update
                display.sleep()

            time.sleep(args.interval)

    except KeyboardInterrupt:
        logging.info("Monitor stopped by user.")
    finally:
        display.sleep()
        logging.info("Shutdown complete.")

if __name__ == "__main__":
    main()

setup_and_run.sh

This script creates an isolated Python environment, installs the necessary dependencies, and launches the monitor.

#!/bin/bash
# Setup and execution script for Salt Monitor

echo "1. Creating Python virtual environment..."
python3 -m venv salt_env

echo "2. Activating virtual environment..."
source salt_env/bin/activate

echo "3. Installing dependencies..."
# adafruit-circuitpython-vl53l0x provides the I2C driver
# RPi.GPIO and spidev are typically required by Waveshare libraries
# Pillow is required for drawing text on the e-paper buffer
pip install adafruit-circuitpython-vl53l0x RPi.GPIO spidev Pillow

echo "4. Running the monitor in dry-run mode for validation..."
python3 salt_monitor.py --dry-run --interval 2

Build/Flash/Run commands

Use the following compact command workflow to get your project running on the Raspberry Pi 5.

CommandAction
python3 -m py_compile salt_monitor.pyValidates Python syntax without executing.
chmod +x setup_and_run.shMakes the setup script executable.
./setup_and_run.shCreates environment, installs deps, and runs mock test.
source salt_env/bin/activateActivates the virtual environment for manual runs.
python3 salt_monitor.py --interval 3600Runs the real hardware loop (updates every 1 hour).

Workflow:
1. Save the Python code as salt_monitor.py in your project directory.
2. Save the bash script as setup_and_run.sh in the same directory.
3. Run the commands in the table above to validate, install, and execute the monitor.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What type of sensor is used to measure the distance to the salt pile in this project?




Question 2: What kind of display is used to render the salt monitor's status?




Question 3: According to the text, why is it important to prevent regeneration failure?




Question 4: What is the typical bandwidth of the I2C sensor used for data acquisition in this project?




Question 5: What is the maximum bandwidth of the SPI peripheral used for pixel rendering?




Question 6: What is the expected sensor latency for the ToF distance measurements?




Question 7: What does the smart monitor calculate based on the physical distance to the salt pile?




Question 8: How does the project reduce manual checks for the homeowner?




Question 9: What is one of the proactive maintenance benefits of this project?




Question 10: What level of accuracy is expected from the ToF distance measurements?




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

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top