A Raspberry Pi distance sensor MQTT project with the HC-SR04 solves two common problems with one cheap part: how full is a tank, and is something (or someone) there. In this tutorial you will wire the ultrasonic sensor safely, take stable readings in Python with gpiozero, and publish both a fill-level percentage and presence events over MQTT so a dashboard or automation can act on them.
How the HC-SR04 Measures Distance
The HC-SR04 sends a short burst of 40 kHz ultrasound when its TRIG pin receives a 10 microsecond pulse, then holds its ECHO pin high until the reflection returns. The length of that echo pulse, multiplied by the speed of sound and divided by two, is the distance. The sensor works from a couple of centimetres to a few metres, with best results against flat, hard surfaces at a right angle, such as a water surface or a wall.
Two consequences matter for real projects. First, the Pi has to time the echo pulse in software, so Linux scheduling adds some jitter; you will filter that out. Second, the speed of sound changes with air temperature, so expect a small drift between a cold morning and a hot afternoon. For a tank level or presence detector, both effects are manageable.
Wiring with a Voltage Divider
The HC-SR04 runs on 5 V and its ECHO output is also 5 V, which can damage a Raspberry Pi GPIO pin. Always use a voltage divider on ECHO:
| HC-SR04 pin | Connection |
|---|---|
| VCC | Pi 5 V (physical pin 2) |
| GND | Pi GND (physical pin 6) |
| TRIG | GPIO23 (physical pin 16), direct |
| ECHO | 1 k resistor to GPIO24 (physical pin 18); 2 k resistor from GPIO24 to GND |
The 1 k and 2 k pair divides 5 V down to about 3.3 V. A 1 k and 1.8 k or 2.2 k combination also works. TRIG can connect directly because the Pi's 3.3 V output is enough to trigger the sensor. If you buy the 3.3 V-compatible variant of the module, check its documentation; you may not need the divider.
Installing the Software
sudo apt update
sudo apt install -y python3-venv python3-lgpio
mkdir -p ~/distance-mqtt && cd ~/distance-mqtt
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install "paho-mqtt>=2.0" gpiozero
Quick test without MQTT:
python -c "from gpiozero import DistanceSensor; from time import sleep; s = DistanceSensor(echo=24, trigger=23, max_distance=4); sleep(1); print(round(s.distance * 100, 1), 'cm')"
Point the sensor at a wall and compare the result with a tape measure. A reading that is always exactly the max_distance value means no echo is detected, usually a wiring problem on ECHO.
Two Modes: Level and Presence
The same reading can be interpreted in two ways, and the script publishes both:
| Mode | Calculation | Topic | When published | Retained |
|---|---|---|---|---|
| Tank level | (empty_distance - distance) / (empty_distance - full_distance) |
.../level |
Every 30 s | Yes |
| Presence | Distance below a threshold, with hysteresis | .../presence |
Only on change | Yes |
| Raw | Median distance in cm | inside both payloads |
Retaining both topics means a dashboard opened at any time immediately shows the current level and whether someone is present. Publishing presence only on change keeps traffic tiny and makes automations simple: subscribe and react to each message.
Calibrating the Tank
Mount the sensor at the top of the tank, pointing straight down, clear of the inlet and the walls. Then measure two distances:
EMPTY_CM: sensor to the bottom (or to the lowest useful level).FULL_CM: sensor to the water at the full mark. Keep at least the sensor's minimum range of a few centimetres between the sensor face and the highest water level.
Set both values in the environment. The script clamps the result between 0 and 100 percent so splashes or an empty tank do not produce impossible values.
The Raspberry Pi Distance Sensor MQTT Script
Save as distance_mqtt.py:
#!/usr/bin/env python3
"""HC-SR04 tank level and presence detection over MQTT."""
import json
import os
import statistics
import time
import uuid
import paho.mqtt.client as mqtt
from gpiozero import DistanceSensor
BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"distance-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/9c3d55/tank1")
EMPTY_CM = float(os.getenv("EMPTY_CM", "120"))
FULL_CM = float(os.getenv("FULL_CM", "20"))
PRESENCE_ON_CM = float(os.getenv("PRESENCE_ON_CM", "60"))
PRESENCE_OFF_CM = float(os.getenv("PRESENCE_OFF_CM", "80"))
LEVEL_INTERVAL = 30
SAMPLES = 7
sensor = DistanceSensor(echo=24, trigger=23, max_distance=4, queue_len=5)
def measure_cm():
"""Median of several readings, ignoring out-of-range values."""
values = []
for _ in range(SAMPLES):
d = sensor.distance * 100
if 2 < d < 399: # 400 cm means no echo within max_distance
values.append(d)
time.sleep(0.06) # let echoes die down between pings
return statistics.median(values) if values else None
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
print(f"Connection refused: {reason_code}")
return
print(f"Connected as {CLIENT_ID}")
client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
client.on_connect = on_connect
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=60)
if USERNAME:
client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
client.tls_set()
client.connect(BROKER, PORT, keepalive=30)
client.loop_start()
present = None
last_level = 0.0
try:
while True:
distance = measure_cm()
now = time.time()
if distance is None:
print("No valid echo")
time.sleep(1)
continue
# Presence with hysteresis: switch on below ON, off above OFF.
new_present = present
if distance < PRESENCE_ON_CM:
new_present = True
elif distance > PRESENCE_OFF_CM:
new_present = False
if new_present is not None and new_present != present:
present = new_present
payload = {"present": present, "distance_cm": round(distance, 1), "ts": int(now)}
client.publish(f"{PREFIX}/presence", json.dumps(payload), qos=1, retain=True)
print("presence", payload)
if now - last_level >= LEVEL_INTERVAL:
span = EMPTY_CM - FULL_CM
percent = max(0.0, min(100.0, (EMPTY_CM - distance) / span * 100))
payload = {"level_pct": round(percent, 1), "distance_cm": round(distance, 1), "ts": int(now)}
client.publish(f"{PREFIX}/level", json.dumps(payload), qos=1, retain=True)
print("level", payload)
last_level = now
time.sleep(0.5)
except KeyboardInterrupt:
pass
finally:
client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True).wait_for_publish(2)
client.loop_stop()
client.disconnect()
sensor.close()
In a real deployment you would usually use one mode per sensor: a tank sensor does not need presence events, and a doorway sensor does not need a fill level. Keeping both in one script makes it easy to experiment, and you can delete the block you do not need.
Why Median Filtering and Hysteresis Matter
Ultrasonic readings occasionally jump: a stray echo from a tank wall, a ripple on the water, or a timing hiccup in Linux. Averaging lets one bad value pull the result; the median of seven readings simply ignores it. Hysteresis solves a different problem. Without it, a person standing right at the threshold would flip presence on and off several times a second, flooding subscribers with events. With separate on and off distances, the state changes once and stays put.
Testing the Output
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-9c3d55 \
-t 'connectmq/demo/9c3d55/tank1/#' -v
Move your hand towards the sensor: a presence message with "present": true appears when you cross 60 cm and false once you move beyond 80 cm. Every 30 seconds a level message reports the fill percentage based on your calibration. Remember that the public MQTT test broker is readable by everyone, so use a random prefix and keep real site names out of test topics.
Automating with the Data
With level and presence on MQTT, automation becomes a subscriber problem rather than a sensor problem:
- Pump control: a second script or Node-RED flow subscribes to
leveland switches a pump through the Raspberry Pi relay MQTT controller when the level drops below 20 percent, and off above 90 percent. - Alerts: on a private ConnectMQ broker, a threshold alert rule on
level_pctwarns you of a nearly empty tank, as described in MQTT threshold alerts. - Home automation: Home Assistant can read the retained
presencetopic as an occupancy sensor and turn on lights.
If you need a battery-powered level sensor at a remote tank without mains power, the ESP32 water tank level sensor build uses the same payload with deep sleep, so both can feed the same dashboard.
Troubleshooting
| Problem | Likely cause and fix |
|---|---|
| Always reads 400 cm | No echo detected; check the divider and ECHO wiring |
| Readings jump wildly | Sensor sees the tank wall; mount it centrally and away from edges |
| Readings drift during the day | Speed of sound changes with temperature; recalibrate or add temperature compensation |
| Condensation errors in a tank | Moisture on the transducers; use a waterproof ultrasonic module |
GPIO busy |
Another script is using GPIO23 or GPIO24 |
Production Setup
For a tank at a farm, a workshop or a building, move from the test broker to a private one. ConnectMQ is a managed MQTT broker with TLS on port 8883 and generated credentials. Set MQTT_PORT=8883, MQTT_USERNAME=YOUR_USERNAME, MQTT_PASSWORD=YOUR_PASSWORD, MQTT_CLIENT_ID=YOUR_CLIENT_ID and a meaningful prefix like farm1/water/tank1 in the environment, then run the script as a systemd service. The Raspberry Pi MQTT tutorial has the service file template and explains the reconnect behaviour used here.
Get Started with ConnectMQ
Put your level and presence data on a cloud MQTT broker that every dashboard and automation can reach. Sign up for ConnectMQ on the free Serverless plan, no credit card needed, create a broker and connect your Pi over TLS in minutes.
Frequently Asked Questions
Do I really need a voltage divider for the HC-SR04?
Yes, for the standard 5 V module. Its ECHO pin outputs 5 V, and Raspberry Pi GPIO pins are only rated for 3.3 V.
How accurate is the HC-SR04 on a Raspberry Pi?
Accurate to around a centimetre in good conditions once readings are median-filtered. Software timing on Linux and temperature changes add some variation.
Can the HC-SR04 measure water level in a closed tank?
Yes, as long as it points straight at the surface and stays dry. In humid tanks a waterproof ultrasonic module lasts longer.
Why publish presence only when it changes?
It keeps traffic minimal and makes automations simple. Because the message is retained, new subscribers still get the current state immediately.



