deep-dive

Building a parking sensor with ESP32

Dhruv Anand

May 2026

Parksense is a parking proximity sensor built on the ESP32 using an HC-SR04 ultrasonic sensor. Point it at an object and it tells you how close you are — in centimetres — and lights up LEDs in green, yellow, and red depending on which zone you’re in.

The hardware side is simple. The interesting part is the software architecture underneath it: clean driver separation, timeout-safe measurement, and a deliberate split between what the driver knows and what the application decides.

This is a walkthrough of how it all fits together.


The hardware

Two components do all the sensing work.

The ESP32 DevKit is the brain. It runs FreeRTOS, has a hardware timer peripheral used to capture echo pulse widths with microsecond precision, and is driven using the ESP-IDF framework (v5.0+). GPIO assignments are centralised in a single board header — more on that shortly.

The HC-SR04 is a low-cost ultrasonic ranging module. It works by emitting a 40 kHz burst from its transmitter on a trigger pulse, then holding its echo pin HIGH for exactly as long as it takes the reflected sound to return. You time that pulse width, divide by the speed of sound, and you have distance.

TRIG pin ──► [HC-SR04] ──► ECHO pin
GPIO 5           │              GPIO 18

           40 kHz burst

           reflected echo

           pulse width = 2 × distance / 343 m/s

Four LEDs provide visual proximity feedback on GPIOs 2, 21, 22, and 23. The blue LED is a power indicator and stays on permanently. Green, yellow, and red light up progressively as the object gets closer.


Project structure

The codebase follows a three-layer architecture: board, drivers, and application.

main/
├── parksense.c         ← application logic
├── board/
│   ├── board.h         ← all GPIO pin assignments
│   └── board.c
└── drivers/
    ├── hcsr04/
    │   ├── hcsr04.h
    │   └── hcsr04.c
    └── led/
        ├── led.h
        └── led.c

This separation is deliberate. The driver layer knows nothing about parking. The application layer knows nothing about GPIO. The board layer is the single source of truth for hardware pin assignments.


Board layer

All hardware configuration lives in board.h:

#define ULTRASONIC_SENSOR_L_TRIG_GPIO  5
#define ULTRASONIC_SENSOR_L_ECHO_GPIO  18

#define ULTRASONIC_SENSOR_R_TRIG_GPIO  16
#define ULTRASONIC_SENSOR_R_ECHO_GPIO  17

#define LED_BLUE_GPIO    2
#define LED_GREEN_GPIO   21
#define LED_YELLOW_GPIO  22
#define LED_RED_GPIO     23

Notice the _L_ and _R_ prefixes — the board header already anticipates a second sensor for dual-sensor redundancy. Porting to a different ESP32 board means changing these defines and nothing else. No driver file needs to be touched.

This pattern is called a Board Support Package (BSP) in production embedded systems. Parksense follows the same principle at a smaller scale.


HC-SR04 driver

The driver is the most technically interesting part of the project. Its full interface is:

esp_err_t hcsr04_init(const hcsr04_t *dev);
esp_err_t hcsr04_read_raw(const hcsr04_t *dev, uint32_t max_time_us, uint32_t *time_us);
esp_err_t hcsr04_read_cm(const hcsr04_t *dev, uint32_t max_distance, uint32_t *distance);

The device descriptor is a plain struct — just two GPIO numbers:

typedef struct {
    gpio_num_t trig_pin;
    gpio_num_t echo_pin;
} hcsr04_t;

Initialisation

hcsr04_init configures the trigger pin as an output (defaulting low) and the echo pin as an input. Every GPIO operation propagates its esp_err_t upward — if anything fails, the caller knows immediately.

The measurement sequence

Taking a reading follows the HC-SR04 datasheet exactly:

  1. Pull TRIG low for 4 µs to ensure a clean state.
  2. Pull TRIG high for 10 µs — this fires the ultrasonic burst.
  3. Pull TRIG low.
  4. Wait for ECHO to go high (the sensor is processing).
  5. Record the timestamp when ECHO goes high.
  6. Poll until ECHO goes low again.
  7. Compute Δt = end − start in microseconds.
CHECK(gpio_set_level(dev->trig_pin, LOW));
ets_delay_us(HCSR04_TRIGGER_LOW_DELAY_US);   // 4 µs

CHECK(gpio_set_level(dev->trig_pin, HIGH));
ets_delay_us(HCSR04_TRIGGER_HIGH_DELAY_US);  // 10 µs
CHECK(gpio_set_level(dev->trig_pin, LOW));

// check a previous ping hasn't overlapped
if (gpio_get_level(dev->echo_pin) == 1)
    return ESP_ERR_HCSR04_PING_TIMEOUT;

int64_t echo_start = esp_timer_get_time();
int64_t time = echo_start;

while (gpio_get_level(dev->echo_pin)) {
    time = esp_timer_get_time();
}

*time_us = time - echo_start;

Distance conversion

The roundtrip constant ROUNDTRIP_CM = 58.3 µs/cm comes from the speed of sound:

1343m/s  /  2=10.01715m/μs58.3  μs/cm (roundtrip)\frac{1}{\,343\,\text{m/s}\;/\;2\,} = \frac{1}{0.01715\,\text{m/μs}} \approx 58.3\;\mu\text{s/cm (roundtrip)}

So hcsr04_read_cm is simply:

*distance = time_us / ROUNDTRIP_CM;

Error codes

The driver defines three distinct failure modes:

CodeMeaning
ESP_ERR_HCSR04_PING (0x200)Previous echo still in progress
ESP_ERR_HCSR04_PING_TIMEOUT (0x201)Sensor not responding
ESP_ERR_HCSR04_ECHO_TIMEOUT (0x202)Distance too large or wave scattered

These are separate from ESP_ERR_TIMEOUT and ESP_ERR_INVALID_ARG, which the ESP-IDF returns for its own reasons. The application layer handles each one independently.

What the driver deliberately does not do

  • No printf or logging — minimal dependencies, fully reusable in other projects.
  • No delay between measurements — the application controls measurement frequency.
  • No filtering or smoothing — pure, stateless measurement.
  • No parking zone logic — that belongs to the application.

This is the key design principle: drivers provide mechanism, not policy.


LED driver

The LED driver is intentionally generic:

typedef struct {
    gpio_num_t led_pin;
    uint8_t active_high;
} led_t;

esp_err_t led_init(led_t *dev);
esp_err_t led_on(led_t *dev);
esp_err_t led_off(led_t *dev);

It knows nothing about parking zones, colours, or sequences. It initialises a GPIO as output and toggles it. The active_high field tracks state. This driver could be dropped into any other ESP32 project unchanged.


Application layer

parksense.c is where the system’s actual behaviour is defined. Four LEDs and one sensor are declared at file scope:

hcsr04_t sensor = {
    .echo_pin = ULTRASONIC_SENSOR_L_ECHO_GPIO,
    .trig_pin = ULTRASONIC_SENSOR_L_TRIG_GPIO,
};

led_t led_blue   = { .led_pin = LED_BLUE_GPIO };
led_t led_green  = { .led_pin = LED_GREEN_GPIO };
led_t led_yellow = { .led_pin = LED_YELLOW_GPIO };
led_t led_red    = { .led_pin = LED_RED_GPIO };

The main loop

The measurement loop runs on a 70 ms interval — slightly above the HC-SR04’s minimum 60 ms requirement per its datasheet:

while (1) {
    vTaskDelay(pdMS_TO_TICKS(70));

    esp_err_t read_err = hcsr04_read_cm(&sensor, HCSR04_MAX_DIST_CM, &distance);

    if (read_err == ESP_OK) {
        // apply zone logic
    } else if (read_err == ESP_ERR_TIMEOUT) {
        printf("(no echo this cycle)\n");
    } else if (read_err == ESP_ERR_INVALID_ARG) {
        printf("Error: Invalid argument\n");
    }
}

vTaskDelay yields to the FreeRTOS scheduler, keeping the system watchdog-friendly. The CPU is idle >90% of the time for a single sensor setup.

Proximity zones

The LED logic is distance-threshold based, with a direction check:

if (distance > last_distance) {
    // object moving away — clear all zone LEDs
    led_off(&led_green);
    led_off(&led_yellow);
    led_off(&led_red);
} else {
    if (distance > 40 && distance < HCSR04_MAX_DIST_CM) {
        led_on(&led_green);                                   // far zone
    } else if (distance < 40 && distance > 15) {
        led_on(&led_green);
        led_on(&led_yellow);                                  // mid zone
    } else {
        led_on(&led_green);
        led_on(&led_yellow);
        led_on(&led_red);                                     // close zone
    }
}

The direction check (distance > last_distance) means LEDs only light up when an object is approaching, not retreating. This mimics the behaviour of real parking sensors, which stay quiet when you reverse away from an obstacle.


Timing constraints

Getting measurement timing right matters. The HC-SR04 has hard requirements:

  • Trigger pulse: minimum 10 µs HIGH, preceded by a 4 µs LOW reset.
  • Minimum interval between pings: ≥ 60 ms — back-to-back triggers cause interference between the outgoing burst and incoming echo.
  • Echo timeout: bounded at ~30 ms in the driver constants (HCSR04_ECHO_TIMEOUT_US = 30000), corresponding to ~5 m maximum range. Beyond this, the echo is treated as lost.

The 70 ms vTaskDelay in the application loop satisfies the 60 ms minimum with a small safety margin.


Known limitations

The system works, but there are real constraints worth being honest about.

Direction sensitivity: The HC-SR04 beam angle is approximately ±15°. Objects at sharp angles return weak or no echo. The sensor works best when the target surface is roughly perpendicular.

Material dependence: Soft or irregular surfaces (fabric, foam, complex geometry) absorb or scatter the 40 kHz burst. Hard, flat surfaces return reliable echoes; soft ones may not.

Missed echoes: Under normal conditions, roughly 2–5% of pings return no echo. The driver handles this gracefully by returning ESP_ERR_TIMEOUT. The application logs it and moves on without crashing.

No filtering: The current application uses raw readings with no averaging or smoothing. A fast-moving object or a brief interference event can cause a spurious zone change. A moving-average filter over the last 3–5 readings would make the indicator more stable.

LED zone logic gaps: The direction-based clearing logic resets all LEDs when the object moves away, but does not apply hysteresis on zone transitions. A rapid oscillation at exactly 40 cm would cause the yellow LED to flicker. Hysteresis — requiring 3 consecutive readings in a new zone before switching — would fix this.


What’s planned

The README flags two in-progress features:

Dual-sensor support: The board header already defines right-side sensor pins (ULTRASONIC_SENSOR_R_TRIG_GPIO, ULTRASONIC_SENSOR_R_ECHO_GPIO). Adding a second HC-SR04 and fusing the readings would provide redundancy and better coverage angle.

Kalman filtering: A simple 1D Kalman filter on the distance estimate would dramatically reduce measurement noise without introducing the lag of a moving average.

Adaptive timeout: Rather than a fixed 30 ms echo timeout, the driver could dynamically adjust based on recent measurement success rate — tightening the timeout when the environment is clean, loosening it when interference is present.


Build and flash

The project uses ESP-IDF’s CMake build system. From the repo root:

# build
idf.py build

# flash and monitor in one command
idf.py build flash monitor

Press Ctrl+] to exit the serial monitor.

The CMakeLists.txt registers all four source files — parksense.c, board.c, led.c, hcsr04.c — and declares the include paths so each layer can find what it needs without relative path hacks.


Takeaways

The sensor and LED hardware are simple. The value of this project is in the architecture decisions: a board layer that centralises hardware configuration, drivers that are stateless and policy-free, and an application layer that owns all the behaviour.

That separation means the HC-SR04 driver and LED driver can be lifted out and dropped into a completely different project without modification. The parking logic can change — different zones, different thresholds, a buzzer instead of LEDs — without touching a single driver file.

It’s a small project. But the patterns it demonstrates are the same ones used in production embedded firmware.

Source is on GitHub.