Overview
In this beginner project you will connect an HC-SR04 ultrasonic distance sensor to the ESP32 and detect whether a vehicle is present in a parking bay. A measured distance below 50 cm indicates an occupied bay. A green LED shows the bay is free and a red LED shows it is occupied. The Serial Monitor prints the measured distance in centimetres every 500 ms.
Components
- 1× ESP32 DevKit V1
- 1× HC-SR04 ultrasonic sensor — 5 V; use voltage divider on ECHO pin
- 1× Green LED 5 mm — Bay free indicator
- 1× Red LED 5 mm — Bay occupied indicator
- 2× 220 ohm resistor — LED current limiting
- 1× 10 kohm and 20 kohm resistor — Voltage divider for ECHO pin
- 1× Breadboard and jumper wires
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| HC-SR04 TRIG | GPIO 5 | 3.3 V output is sufficient for trigger |
| HC-SR04 ECHO | GPIO 18 via divider | ECHO is 5 V; divide with 10k+20k to 3.3 V |
| HC-SR04 VCC | 5 V (Vin) | |
| HC-SR04 GND | GND | |
| Green LED anode | GPIO 25 | 220 ohm resistor |
| Red LED anode | GPIO 26 | 220 ohm resistor |
Arduino Code
// ESP32 Smart Parking Sensor - Beginner
// Single bay HC-SR04 distance + LED status
const int TRIG = 5;
const int ECHO = 18;
const int LED_FREE = 25;
const int LED_OCC = 26;
const float BAY_THRESHOLD_CM = 50.0; // Vehicle present if closer than this
long measureCm() {
digitalWrite(TRIG, LOW);
delayMicroseconds(2);
digitalWrite(TRIG, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG, LOW);
long duration = pulseIn(ECHO, HIGH, 30000); // 30 ms timeout
if (duration == 0) return -1; // No echo received
return duration * 0.034 / 2; // Convert to cm
}
void setup() {
Serial.begin(115200);
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(LED_FREE, OUTPUT);
pinMode(LED_OCC, OUTPUT);
}
void loop() {
long dist = measureCm();
bool occupied = (dist > 0 && dist < BAY_THRESHOLD_CM);
Serial.printf("Distance: %ld cm Bay: %sn",
dist, occupied ? "OCCUPIED" : "FREE");
digitalWrite(LED_FREE, occupied ? LOW : HIGH);
digitalWrite(LED_OCC, occupied ? HIGH : LOW);
delay(500);
}How It Works
Ultrasonic Time-of-Flight Measurement: A 10 us HIGH pulse on TRIG fires an 8-cycle 40 kHz ultrasonic burst. The ECHO pin goes HIGH when the burst leaves and LOW when the reflection returns. pulseIn() measures this pulse duration in microseconds. Distance = duration * 0.034 / 2 (speed of sound divided by round-trip).
Vehicle Detection Threshold: A parking bay sensor mounted at 1-2 m height above an empty bay reads 100-200 cm to the ground. A parked car reduces this to 10-40 cm. The 50 cm threshold reliably distinguishes an occupied bay from an empty one with margin for low vehicles.
Timeout Handling: pulseIn() with a 30,000 us timeout returns 0 if no echo is received within 30 ms. This handles the case where the beam misses the car (extreme angle) or exceeds the 400 cm maximum range, returning -1 to avoid false "free bay" readings.
LED Status Indication: Green LED (free) and red LED (occupied) give instant visual bay status visible from a distance. In a real car park, replace LEDs with larger green/red indicators or an LED matrix sign visible from the car park entrance.
Applications
- Driveway parking assist showing distance to garage wall
- Small car park bay occupancy detection
- Motorcycle bay availability indicator
- Loading bay monitoring for logistics operations
Troubleshooting
Distance always reads -1 or 0
Verify TRIG produces a 10 us pulse with an oscilloscope or by checking that digitalRead(TRIG) goes HIGH briefly. Also ensure the voltage divider correctly reduces the 5 V ECHO signal to below 3.3 V before it reaches the ESP32 pin.
Reading fluctuates by 10-20 cm
Take a rolling average of 5 readings. HC-SR04 readings vary by 1-3 cm normally. Large jumps indicate acoustic interference from nearby ultrasonic sources or the sensor beam reflecting off surrounding objects at an angle.
Sensor does not detect low sports cars
Mount the sensor lower (40-60 cm above ground) or at an angle to ensure the beam hits the vehicle roof or bonnet rather than passing over it.
LED shows occupied in an empty bay
Adjust BAY_THRESHOLD_CM. Measure the actual distance from sensor to ground in an empty bay using the Serial Monitor and set the threshold 20 cm below that reading.
Upgrades
- Add a 4-digit 7-segment display to show remaining free spaces in a multi-bay system
- Add Wi-Fi and publish bay status to a web dashboard
- Add a buzzer that sounds when a vehicle parks in a reserved bay
- Add a second sensor to detect if a vehicle is entering or leaving the bay
FAQ
You need an ESP32 DevKit, HC-SR04 TRIG, HC-SR04 ECHO, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Smart City. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The intermediate build monitors four parking bays using four HC-SR04 sensors and displays the total available space count on a TM1637 four-digit LED display at the car park entrance. Each bay status is also shown on an OLED with bay numbers. Wi-Fi connects the system to a web page that shows a live grid of bay occupancy, updating every 3 seconds using JavaScript polling.
Components
- 1× ESP32 DevKit V1
- 4× HC-SR04 ultrasonic sensor — One per bay
- 1× TM1637 4-digit LED display — Shows available space count
- 1× SSD1306 OLED 128x64 I2C — Shows per-bay status
- 1× Wi-Fi router — Web dashboard access
- 1× Voltage divider resistors (x4 pairs) — 10k+20k per ECHO pin
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| Bay 1 TRIG/ECHO | GPIO 5 / GPIO 18 | ECHO via voltage divider |
| Bay 2 TRIG/ECHO | GPIO 17 / GPIO 16 | ECHO via voltage divider |
| Bay 3 TRIG/ECHO | GPIO 4 / GPIO 2 | ECHO via voltage divider |
| Bay 4 TRIG/ECHO | GPIO 15 / GPIO 13 | ECHO via voltage divider |
| TM1637 CLK/DIO | GPIO 32 / GPIO 33 | |
| OLED SDA/SCL | GPIO 21 / GPIO 22 |
Arduino Code
// ESP32 Smart Parking - Intermediate (4 bays + TM1637 + web dashboard)
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <TM1637Display.h>
#include <WiFi.h>
#include <WebServer.h>
Adafruit_SSD1306 oled(128,64,&Wire,-1);
TM1637Display disp(32,33);
WebServer server(80);
const char* SSID="YourSSID", *PASS="YourPass";
const int NUM_BAYS=4;
const int TRIG[]={5,17,4,15};
const int ECHO[]={18,16,2,13};
const float THRESH=50.0;
bool occupied[4]={false,false,false,false};
long measureCm(int bay){
digitalWrite(TRIG[bay],LOW); delayMicroseconds(2);
digitalWrite(TRIG[bay],HIGH); delayMicroseconds(10);
digitalWrite(TRIG[bay],LOW);
long d=pulseIn(ECHO[bay],HIGH,30000);
return d>0?d*0.034/2:-1;
}
void scanBays(){
for(int i=0;i<NUM_BAYS;i++){
long d=measureCm(i);
occupied[i]=(d>0&&d<THRESH);
delay(60); // prevent sensor cross-talk
}
}
int freeCount(){
int n=0;
for(int i=0;i<NUM_BAYS;i++) if(!occupied[i]) n++;
return n;
}
void serveStatus(){
String body="<h2>Parking Status</h2><table border=1>";
for(int i=0;i<NUM_BAYS;i++){
body+="<tr><td>Bay "+String(i+1)+"</td><td style="color:"+
String(occupied[i]?"red":"green")+"">"
+String(occupied[i]?"OCCUPIED":"FREE")+"</td></tr>";
}
body+="</table><p>Free: "+String(freeCount())+"/"+String(NUM_BAYS)+"</p>"
"<meta http-equiv="refresh" content="3">";
server.send(200,"text/html",body);
}
void setup(){
Serial.begin(115200);
for(int i=0;i<NUM_BAYS;i++){
pinMode(TRIG[i],OUTPUT); pinMode(ECHO[i],INPUT);
}
Wire.begin(21,22);
oled.begin(SSD1306_SWITCHCAPVCC,0x3C);
oled.setTextColor(WHITE);
disp.setBrightness(7);
WiFi.begin(SSID,PASS);
while(WiFi.status()!=WL_CONNECTED) delay(500);
server.on("/",serveStatus);
server.begin();
Serial.printf("Dashboard: http://%s/n",WiFi.localIP().toString().c_str());
}
void loop(){
server.handleClient();
scanBays();
int free=freeCount();
disp.showNumberDec(free,false);
oled.clearDisplay(); oled.setTextSize(1); oled.setCursor(0,0);
oled.printf("Free: %d/%dn",free,NUM_BAYS);
for(int i=0;i<NUM_BAYS;i++)
oled.printf("Bay %d: %sn",i+1,occupied[i]?"OCC ":"FREE");
oled.display();
delay(1000);
}How It Works
Sequential Bay Scanning: Sensors are fired one at a time with a 60 ms gap between each to prevent cross-talk (one sensor echo being received by a neighbouring sensor). The 60 ms gap corresponds to a 1020 cm round-trip, well beyond the 50 cm detection range.
TM1637 Space Counter: The TM1637 four-digit display shows the number of free spaces at the car park entrance. disp.showNumberDec() converts the integer free count to a 7-segment display pattern. Brightness is set to maximum (7) for outdoor visibility.
OLED Per-Bay Grid: The OLED shows each bay number and its status on a separate line. This detail view is useful for the car park attendant monitoring station. The web dashboard provides the same information remotely.
Auto-Refresh Web Dashboard: The HTML page includes a meta refresh tag with a 3-second interval, causing the browser to reload the page automatically. Each reload triggers a fresh sensor scan and returns the current occupancy state.
Applications
- Office car park entrance sign showing available spaces
- Shopping centre parking level guidance system
- Hospital visitor car park real-time availability board
- Airport short-stay parking bay monitoring
Troubleshooting
Two adjacent sensors interfere with each other
Increase the delay between sensor firings from 60 ms to 100 ms. Mount sensors with at least 30 cm lateral separation and angle them slightly downward to minimise beam overlap.
TM1637 shows dashes instead of numbers
Verify CLK and DIO are correctly connected. The TM1637 uses a custom protocol, not standard I2C. Check that both pins are not floating; add 10 kohm pull-ups to 3.3 V if the signal is weak.
Web dashboard shows stale data
The meta refresh tag reloads every 3 seconds. If the ESP32 is busy in the sensor scan loop, server.handleClient() may be delayed. Reduce the sensor scan loop to under 2 seconds total for all four bays.
Upgrades
- Replace the meta-refresh with SSE or WebSocket for true real-time updates
- Add a reservation system where users can book a bay via the web page
- Add outdoor-rated NeoPixel panels above each bay (green=free, red=occupied)
- Add a second ESP32 at the car park exit to count vehicles leaving
FAQ
You need an ESP32 DevKit, HC-SR04 TRIG, HC-SR04 ECHO, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Smart City. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced build publishes real-time parking bay occupancy data to an MQTT broker with per-bay topics. A Node-RED dashboard visualises a scalable car park floor plan. Vehicles entering and leaving are counted to track total capacity utilisation. Predictive occupancy analytics use time-of-day patterns stored in NVS to display expected peak and off-peak times on the web dashboard.
Components
- 1× ESP32 DevKit V1
- 4× HC-SR04 ultrasonic sensors
- 1× MQTT broker (Mosquitto)
- 1× Node-RED with dashboard plugin
- 1× TM1637 display — Entrance sign
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| All sensors and display | Same as intermediate |
Arduino Code
// ESP32 Smart Parking - Advanced (MQTT + occupancy analytics)
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <time.h>
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Preferences prefs;
const char* SSID="YourSSID", *PASS="YourPass";
const char* MQTT_HOST="192.168.1.100";
const char* TOPIC_BASE="parking/bay/";
const char* TOPIC_SUMMARY="parking/summary";
const int NUM_BAYS=4;
const int TRIG[]={5,17,4,15};
const int ECHO[]={18,16,2,13};
bool occupied[4]={false};
int occupancyByHour[24]={0}; // count of occupied readings per hour
long measureCm(int bay){
digitalWrite(TRIG[bay],LOW); delayMicroseconds(2);
digitalWrite(TRIG[bay],HIGH); delayMicroseconds(10);
digitalWrite(TRIG[bay],LOW);
long d=pulseIn(ECHO[bay],HIGH,30000);
return d>0?d*0.034/2:-1;
}
void publishBay(int bay){
String topic=String(TOPIC_BASE)+String(bay+1);
StaticJsonDocument<64> doc;
doc["bay"]=bay+1; doc["occupied"]=occupied[bay];
char buf[64]; serializeJson(doc,buf);
mqtt.publish(topic.c_str(),buf,true); // retain=true
}
void publishSummary(){
int free=0;
for(int i=0;i<NUM_BAYS;i++) if(!occupied[i]) free++;
StaticJsonDocument<128> doc;
doc["free"]=free; doc["total"]=NUM_BAYS;
doc["utilisation"]=(float)(NUM_BAYS-free)/NUM_BAYS*100.0f;
struct tm ti; getLocalTime(&ti);
doc["hour"]=ti.tm_hour;
doc["peak_hour"]=getPeakHour();
char buf[128]; serializeJson(doc,buf);
mqtt.publish(TOPIC_SUMMARY,buf,true);
}
int getPeakHour(){
int peak=0,peakH=0;
for(int h=0;h<24;h++) if(occupancyByHour[h]>peak){peak=occupancyByHour[h];peakH=h;}
return peakH;
}
void setup(){
Serial.begin(115200);
for(int i=0;i<NUM_BAYS;i++){ pinMode(TRIG[i],OUTPUT); pinMode(ECHO[i],INPUT); }
WiFi.begin(SSID,PASS);
while(WiFi.status()!=WL_CONNECTED) delay(500);
configTime(0,0,"pool.ntp.org");
mqtt.setServer(MQTT_HOST,1883);
// Load occupancy history from NVS
prefs.begin("park",true);
for(int h=0;h<24;h++) occupancyByHour[h]=prefs.getInt(String(h).c_str(),0);
prefs.end();
}
void loop(){
if(!mqtt.connected()) mqtt.connect("ESP32Parking");
mqtt.loop();
for(int i=0;i<NUM_BAYS;i++){
bool prev=occupied[i];
long d=measureCm(i);
occupied[i]=(d>0&&d<50.0f);
if(occupied[i]!=prev) publishBay(i);
delay(60);
}
// Update hourly analytics
struct tm ti; getLocalTime(&ti);
int occ=0; for(int i=0;i<NUM_BAYS;i++) if(occupied[i]) occ++;
occupancyByHour[ti.tm_hour]=(occupancyByHour[ti.tm_hour]+occ)/2;
prefs.begin("park",false);
for(int h=0;h<24;h++) prefs.putInt(String(h).c_str(),occupancyByHour[h]);
prefs.end();
publishSummary();
delay(30000); // Summary every 30 s; bay changes publish immediately
}How It Works
Change-on-Event Publishing: Bay MQTT messages are published only when occupancy state changes (free to occupied or vice versa), not on every scan cycle. This reduces broker traffic while ensuring state changes reach subscribers immediately. The retain=true flag ensures new subscribers receive the current state immediately upon connection.
Retained MQTT Topics: Setting retain=true on MQTT publish stores the last message at the broker. When a new subscriber (Node-RED dashboard, mobile app) connects, it immediately receives the current occupancy state without waiting for the next publish cycle.
Hourly Occupancy Analytics: An exponential moving average of occupied bay count per hour is stored in NVS for each of 24 hours. Over days of operation this builds a statistical occupancy profile: hour 8-9 typically high, hour 14 typically low. getPeakHour() returns the historically busiest hour.
Node-RED Floor Plan Dashboard: Node-RED subscribes to parking/bay/1 through parking/bay/4 and updates gauge widgets on the dashboard. A template widget renders an SVG floor plan with per-bay colour coding: green for free, red for occupied, updating in real time as MQTT messages arrive.
Applications
- Smart city connected parking guidance system
- Airport car park management with mobile app integration
- University campus parking permit enforcement system
- EV charging bay availability notification service
Troubleshooting
Node-RED dashboard shows stale bay status after reboot
The ESP32 republishes all bay statuses on first MQTT connection. Ensure the initial publishBay() loop runs in setup() after MQTT connects, not just in loop() on state change.
Occupancy analytics shows all zeros
Check that NTP sync has occurred before reading tm_hour. Add a delay after configTime() to allow at least one NTP sync before the first analytics update.
MQTT broker receives duplicate bay messages
The change-detection logic compares occupied[i] before and after the scan. If the delay between scans allows transient readings, add a 3-reading majority vote before treating a state change as confirmed.
Upgrades
- Add a mobile app using Flutter that shows real-time bay availability and navigates the driver to a free bay
- Integrate with a payment system: publish entry/exit events to a billing MQTT topic
- Add LPR (licence plate recognition) using an ESP32-CAM at the entrance for permit validation
- Add predictive hold: reserve a bay 5 minutes before the historical peak hour begins
FAQ
You need an ESP32 DevKit, HC-SR04 TRIG, HC-SR04 ECHO, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Smart City. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.