A basic Arduino distance detector using an HC-SR04 ultrasonic sensor costs roughly $8 to build, measures from 2 cm to 400 cm, and requires 4 GPIO pins. While it is the standard for hobbyist projects, raw ultrasonic readings are notoriously noisy due to acoustic multipath reflections and temperature-dependent sound speed variations. To build a reliable distance detector, you must implement software filtering and temperature compensation rather than relying on raw ping delays.
Sensor Selection: Ultrasonic vs. LiDAR vs. ToF
Before wiring the breadboard, verify that 40 kHz ultrasonic is actually the right physics for your environment. Ultrasonic sensors struggle with soft materials (like clothing or foam) that absorb sound waves, and they suffer from beam divergence (typically a 15-degree cone), which causes false positives from adjacent walls. If your application requires pinpoint accuracy or operation in a vacuum, you need optical time-of-flight (ToF) or LiDAR.
| Sensor Module | Technology | Effective Range | Accuracy / Resolution | Interface | Typical 2026 Price |
|---|---|---|---|---|---|
| HC-SR04 | 40 kHz Ultrasonic | 2 cm - 400 cm | ± 3 mm (ideal) | GPIO (Pulse Width) | $1.50 - $3.00 |
| TFMini-S | LiDAR (850 nm) | 10 cm - 1200 cm | ± 1 cm | UART / I2C | $18.00 - $25.00 |
| VL53L1X | ToF Laser (940 nm) | 4 cm - 400 cm | ± 1 mm | I2C | $8.00 - $12.00 |
| RCWL-0516 | Microwave Doppler | 50 cm - 700 cm | Binary (Motion only) | GPIO (High/Low) | $1.00 - $2.00 |
For this build, we are using the HC-SR04. It is the most cost-effective for tank level monitoring and basic parking sensors, provided we handle its software-side quirks.
Parts List and Pin Mapping
This guide targets the Arduino Uno R3 and Arduino Nano v3 (both ATmega328P variants). The code relies on 5V logic. If you are using an ESP32 or Arduino Nano 33 IoT (3.3V logic), you must use a logic level shifter or a voltage divider on the Echo pin to prevent frying the microcontroller's GPIO.
- 1x Arduino Uno R3 (or Nano v3) with USB cable
- 1x HC-SR04 Ultrasonic Sensor Module
- 1x 16x2 LCD with I2C backpack (address 0x27 or 0x3F)
- 1x Half-size breadboard and male-to-female jumper wires
- (Optional) 1x 10kΩ and 1x 20kΩ resistor if stepping down Echo to 3.3V
| Component | Module Pin | Arduino Uno R3 Pin | Notes |
|---|---|---|---|
| HC-SR04 | VCC | 5V | Requires 5V for stable 40kHz oscillation |
| HC-SR04 | Trig | D9 | Output (Set HIGH for 10μs) |
| HC-SR04 | Echo | D10 | Input (Reads pulse width) |
| HC-SR04 | GND | GND | Common ground required |
| I2C LCD | SDA | A4 | I2C Data line |
| I2C LCD | SCL | A5 | I2C Clock line |
Step-by-Step Wiring Procedure
- Power the Rails: Connect the Arduino 5V and GND pins to the breadboard's positive and negative power rails. Safety Note: Never wire or unwire the HC-SR04 VCC pin while the Arduino is powered; the resulting inductive kickback can corrupt the sensor's internal flip-flop, causing it to lock up until power-cycled.
- Wire the Sensor: Route the HC-SR04 Trig pin to D9 and Echo to D10. Ensure the ultrasonic transducer mesh faces completely clear of the breadboard edge; mounting it flush against a surface causes immediate acoustic ringing and false 2cm readings.
- Connect the I2C LCD: Plug the LCD's SDA and SCL into A4 and A5. Adjust the blue potentiometer on the back of the I2C backpack with a small Phillips screwdriver until the contrast is visible but the background pixels are not dark boxes.
- Verify Connections: Use a multimeter in continuity mode to verify that the GND pin on the sensor and the GND pin on the LCD share the exact same ground net. A floating ground between the sensor and MCU will result in erratic pulse widths.
Compilable Arduino Code with Error Handling
The standard ping tutorials use a hardcoded divisor of 29.1 or 58 to convert microseconds to centimeters. This assumes the air temperature is exactly 20°C (68°F). According to the Engineering ToolBox speed of sound data, sound travels at 331.3 m/s at 0°C and 343.2 m/s at 20°C. A 10°C temperature drop introduces a ~3% measurement error. At a 300 cm distance, that is a 9 cm discrepancy. The code below calculates the divisor dynamically and implements a 5-sample moving average to reject acoustic noise.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// --- PIN DEFINITIONS ---
#define TRIG_PIN 9
#define ECHO_PIN 10
// --- ENVIRONMENTAL CONSTANTS ---
#define TEMP_CELSIUS 20.0 // Update this to your ambient room temp
#define SAMPLE_SIZE 5
#define MAX_DISTANCE_CM 400
// Calculated speed of sound in cm/us based on temperature
// v = 331.3 + 0.606 * T (in m/s) -> convert to cm/us
const float speedOfSound_cm_us = (331.3 + 0.606 * TEMP_CELSIUS) / 10000.0;
LiquidCrystal_I2C lcd(0x27, 16, 2); // Default I2C address, change to 0x3F if needed
unsigned long pingSamples[SAMPLE_SIZE];
int sampleIndex = 0;
void setup() {
Serial.begin(115200);
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
Wire.begin();
lcd.init();
lcd.backlight();
// I2C Error Handling Check
Wire.beginTransmission(0x27);
byte error = Wire.endTransmission();
if (error != 0) {
lcd.clear();
lcd.print("ERR: I2C_NACK");
Serial.println("ERR: I2C_NACK - Check SDA/SCL wiring and address (0x27 vs 0x3F)");
while(1); // Halt execution
}
lcd.clear();
lcd.print("Initializing...");
delay(1000);
}
void loop() {
// 1. Trigger the sensor
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
// 2. Read Echo with strict timeout (prevents main loop blocking)
// Max timeout for 400cm = 400 * 2 / speedOfSound_cm_us
unsigned long maxTimeout = (MAX_DISTANCE_CM * 2.0) / speedOfSound_cm_us + 1000;
unsigned long duration = pulseIn(ECHO_PIN, HIGH, maxTimeout);
// 3. Error Handling & Calculation
float distanceCm = 0;
String statusMsg = "OK";
if (duration == 0) {
statusMsg = "ERR: PULSE_TIMEOUT";
Serial.println(statusMsg);
} else {
distanceCm = (duration * speedOfSound_cm_us) / 2.0;
if (distanceCm < 2.0 || distanceCm > MAX_DISTANCE_CM) {
statusMsg = "ERR: OUT_OF_RANGE";
Serial.print(statusMsg); Serial.print(" - Raw: "); Serial.print(distanceCm); Serial.println(" cm");
} else {
// 4. Moving Average Filter
pingSamples[sampleIndex] = duration;
sampleIndex = (sampleIndex + 1) % SAMPLE_SIZE;
unsigned long totalDuration = 0;
for (int i = 0; i < SAMPLE_SIZE; i++) totalDuration += pingSamples[i];
float avgDuration = totalDuration / SAMPLE_SIZE;
distanceCm = (avgDuration * speedOfSound_cm_us) / 2.0;
}
}
// 5. Output to Serial and LCD
Serial.print("Distance: "); Serial.print(distanceCm, 1); Serial.println(" cm");
lcd.clear();
if (statusMsg != "OK") {
lcd.print(statusMsg);
} else {
lcd.print("Dist: "); lcd.print(distanceCm, 1); lcd.print(" cm");
}
delay(50); // 20Hz polling rate
}
Debugging: First Three Things to Check
When your Serial Monitor outputs an error string or the LCD remains blank, do not immediately rewrite the code. Hardware and physics failures account for 95% of ultrasonic debugging. Follow this ranked checklist:
ERR: PULSE_TIMEOUT or constant 0.0 cm
- Cause A (Most Likely): Trigger and Echo pins are swapped. The HC-SR04 will not echo if it never receives the 10μs trigger pulse. Verify D9 is Trig and D10 is Echo.
- Cause B: The sensor is aimed at a sound-absorbing material (heavy curtains, acoustic foam, or a person wearing thick wool). Test by aiming it at a flat, hard piece of MDF or glass.
- Cause C: The 5V rail is sagging below 4.5V under load, causing the onboard 555 timer equivalent in the HC-SR04 to fail to trigger. Measure VCC at the sensor pins with a multimeter while pinging.
ERR: I2C_NACK or blank LCD with backlight on
- Cause A: Incorrect I2C address. The code defaults to
0x27, but many PCF8574 backpacks ship with0x3F. Run an I2C Scanner sketch to find the correct hex address and update line 21 in the code. - Cause B: SDA and SCL are reversed. On the Uno R3, A4 is strictly SDA and A5 is SCL. They are not interchangeable like standard digital GPIOs.
- Cause A: Acoustic multipath interference. The 15-degree beam cone is hitting a nearby desk edge and bouncing back. Isolate the sensor using a PVC pipe shroud to narrow the beam angle.
- Cause B: Cross-talk from a second HC-SR04 operating in the same room. 40 kHz sensors will trigger each other. If using multiple sensors, you must fire them sequentially with a 50ms delay between pings, never simultaneously.
Extending and Simplifying the Build
To Simplify: If you are integrating this into a larger robot chassis where an LCD is redundant, strip out the Wire.h and LiquidCrystal_I2C.h libraries entirely. Rely solely on the Serial.print outputs. This frees up roughly 2.5 KB of flash memory and removes the I2C bus initialization delay from your boot sequence, which is critical for fast-starting line-following robots.
To Extend: For a smart-home water tank monitor, swap the Arduino Uno R3 for an ESP32 DevKit V1. Because the ESP32 operates at 3.3V logic, you must place a voltage divider (10kΩ to GND, 20kΩ to Echo) on the Echo pin. From there, integrate the PubSubClient library to publish the distanceCm variable to an MQTT broker (like Mosquitto or Home Assistant) every 60 seconds. You can then map the distance to tank volume using a simple cylindrical volume calculation in Node-RED, triggering an automated solenoid valve via a relay when the tank reaches 90% capacity.






