Lesson 13 — Ultrasonic Sensor and Sensor Logging
Estimated time: 90 minutes
- Learning Objectives
- New Hardware: HC-SR04 Ultrasonic Distance Sensor
- Concepts
- Guided Walkthrough
- Challenges
- Common Mistakes & Debugging
- Key Vocabulary
Learning Objectives
By the end of this lesson you will be able to:
- Wire the HC-SR04 ultrasonic distance sensor
- Write a measurement function using digital I/O and timing
- Store multiple distance readings in a list
- Build a distance-responsive LED indicator
New Hardware: HC-SR04 Ultrasonic Distance Sensor
The HC-SR04 measures distance by sending a burst of ultrasonic sound (40kHz — too high for human ears) and measuring how long it takes to bounce back.
Did you know? This is the exact same principle bats use for echolocation — they emit high-pitched squeaks and use the returning echoes to “see” in complete darkness. The US Navy also uses the same principle in sonar systems on submarines.
Wiring
| HC-SR04 Pin | Connect To |
|---|---|
| VCC | 5V (or 3.3V if using HC-SR04P variant) |
| GND | GND |
| TRIG | GPIO 5 |
| ECHO | GPIO 4 (see note below) |
ECHO pin voltage: Standard HC-SR04 modules output 5V on the ECHO pin. The ESP32-S3 GPIO pins are 3.3V only — connecting 5V directly could damage the chip. Add a voltage divider: ECHO → 1kΩ resistor → junction, and junction → 2kΩ resistor → GND. Connect GPIO 4 to the junction. This brings 5V down to ~3.3V safely. The HC-SR04P variant runs at 3.3V natively and doesn’t need this.
How It Works
- Send a 10 microsecond HIGH pulse to the TRIG pin
- The sensor emits 8 ultrasonic pulses
- The ECHO pin goes HIGH and stays HIGH until the pulses return
- Measure how long ECHO was HIGH (in microseconds)
- Calculate:
distance (cm) = echo_duration_µs / 58.2
The division by 58.2 accounts for the speed of sound (343 m/s) and the fact that the sound travels twice the distance (out AND back).
Concepts
Microsecond Timing
Sound travels fast — about 0.034cm per microsecond. To measure distance to 1cm accuracy, we need microsecond timing.
MicroPython provides:
time.ticks_us()— returns the current time in microseconds since boottime.ticks_diff(end, start)— calculates the difference correctly (handles overflow)time.sleep_us(n)— sleeps for n microseconds
Always use time.ticks_diff(end, start) rather than end - start for timing. The ticks counter can overflow (wrap back to zero), and ticks_diff handles this correctly.
Writing a Measurement Function
This is a preview of Unit 5 (Functions). For now, just notice the structure:
def get_distance():
# ... measurement code ...
return distance_value
This function does all the messy timing work and returns just the clean distance value.
Guided Walkthrough
Step 1: Wire and Test the Sensor
Make sure wiring is correct (check voltage divider if needed). Run this to see raw readings:
from machine import Pin
import time
trig = Pin(5, Pin.OUT)
echo = Pin(4, Pin.IN)
def get_distance():
"""Measure distance in cm. Returns -1 if no reading obtained."""
# Send 10µs trigger pulse
trig.off()
time.sleep_us(2)
trig.on()
time.sleep_us(10)
trig.off()
# Wait for echo to go HIGH (with timeout to avoid hanging)
timeout = time.ticks_us() + 30000 # 30ms timeout
while echo.value() == 0:
if time.ticks_us() > timeout:
return -1 # No echo received
# Measure how long echo stays HIGH
pulse_start = time.ticks_us()
timeout = pulse_start + 30000
while echo.value() == 1:
if time.ticks_us() > timeout:
return -1 # Echo too long (object too close or stuck)
pulse_end = time.ticks_us()
# Calculate and return distance
duration = time.ticks_diff(pulse_end, pulse_start)
return round(duration / 58.2, 1)
print("Distance sensor ready. Move your hand closer/further.")
print("Ctrl+C to stop.")
while True:
dist = get_distance()
if dist == -1:
print("No reading")
else:
print(f"Distance: {dist} cm")
time.sleep(0.5)
Move your hand toward and away from the sensor. You should see the distance change smoothly.
Step 2: Distance-Based LED Colour
from machine import Pin
import machine, neopixel, time
trig = Pin(5, Pin.OUT)
echo = Pin(4, Pin.IN)
pin48 = machine.Pin(48, machine.Pin.OUT)
np = neopixel.NeoPixel(pin48, 1)
def get_distance():
trig.off(); time.sleep_us(2)
trig.on(); time.sleep_us(10); trig.off()
t = time.ticks_us() + 30000
while echo.value() == 0:
if time.ticks_us() > t: return -1
s = time.ticks_us()
t = s + 30000
while echo.value() == 1:
if time.ticks_us() > t: return -1
return round(time.ticks_diff(time.ticks_us(), s) / 58.2, 1)
print("Distance indicator! Ctrl+C to stop.")
while True:
dist = get_distance()
if dist == -1 or dist > 200:
np[0] = (5, 5, 5) # Dim white — no reading
elif dist < 10:
np[0] = (255, 0, 0) # Red — very close (<10cm)
elif dist < 20:
np[0] = (255, 100, 0) # Orange — close (10-20cm)
elif dist < 40:
np[0] = (255, 230, 0) # Yellow — medium (20-40cm)
else:
np[0] = (0, 255, 0) # Green — far away (>40cm)
np.write()
if dist != -1:
print(f"{dist:6.1f} cm", end="\r") # \r = overwrite same line
time.sleep(0.15)
The end="\r" in print() makes the output overwrite the same line in the terminal, giving a cleaner live display.
Step 3: Collecting Readings Into a List
from machine import Pin
import time
trig = Pin(5, Pin.OUT)
echo = Pin(4, Pin.IN)
def get_distance():
trig.off(); time.sleep_us(2)
trig.on(); time.sleep_us(10); trig.off()
t = time.ticks_us() + 30000
while echo.value() == 0:
if time.ticks_us() > t: return -1
s = time.ticks_us()
t = s + 30000
while echo.value() == 1:
if time.ticks_us() > t: return -1
return round(time.ticks_diff(time.ticks_us(), s) / 58.2, 1)
readings = []
print("Collecting 20 readings (1 per second)...")
for i in range(20):
dist = get_distance()
if dist != -1 and dist <= 300: # Only store valid, in-range readings
readings.append(dist)
print(f"Reading {i+1:2d}: {dist} cm ✓")
else:
print(f"Reading {i+1:2d}: invalid — skipped")
time.sleep(1)
# Analyse collected data
if len(readings) > 0:
readings.sort()
print(f"\n--- Results ({len(readings)} valid readings) ---")
print(f"Minimum: {min(readings)} cm")
print(f"Maximum: {max(readings)} cm")
print(f"Average: {sum(readings)/len(readings):.1f} cm")
print(f"Median: {readings[len(readings)//2]} cm")
close = [r for r in readings if r < 20]
print(f"Under 20cm: {len(close)} readings")
else:
print("No valid readings collected!")
Challenges
⭐ Core
Collect 10 distance readings (1 per second). Print them as a list. Then print how many were above 30cm and how many were 30cm or under. Display the LED green if more than half were above 30cm, red if not.
⭐⭐ Extension
Build a “rolling average”: keep a list of the last 5 distance readings only. After each new reading, if the list has more than 5 items, remove the oldest (hint: .pop(0) removes the first item). Print the rolling average after each reading. If the rolling average drops below 15cm, flash the LED red.
⭐⭐⭐ Stretch
Collect 30 readings over 30 seconds. Then calculate:
- Min, max, average, median
- The largest change between any two consecutive readings (abs difference)
- The 10th percentile (the value below which 10% of readings fall — sort the list and take index
len//10) Print a nicely formatted summary. Flash the LED once per 5cm of the median distance.
Common Mistakes & Debugging
Sensor always returns -1 Check TRIG and ECHO pin connections. Verify VCC is correct voltage. Try increasing the timeout value in the function.
Readings are wildly inconsistent Add at least 60ms between readings — the sensor needs time to settle. Some objects (soft fabrics, angled surfaces) absorb or scatter ultrasound poorly.
Distance seems too large Make sure you’re dividing by 58.2 (not 29.1). The sound travels twice the distance (out and back), and 58.2 accounts for both.
Overflow issues with ticks_us() Always use time.ticks_diff(end, start) instead of end - start. The raw ticks value wraps around and direct subtraction gives wrong results.
Key Vocabulary
| Term | Definition |
|---|---|
| HC-SR04 | An ultrasonic distance sensor using sound pulses |
| TRIG pin | Trigger pin — send a 10µs pulse to start a measurement |
| ECHO pin | Echo pin — stays HIGH for the duration of the sound’s round trip |
| microsecond (µs) | One millionth of a second — used for timing sound pulses |
| voltage divider | Two resistors that reduce a voltage proportionally |
| time.ticks_us() | Returns the current time in microseconds since boot |
| time.ticks_diff() | Correctly calculates the difference between two ticks values |
| sensor logging | Storing sensor readings in a list for later analysis |