Radar finds objects by sending out a signal and listening for the echo, while turning to look in every direction. In this capstone you build a small version with sound instead of radio waves. A servo turns an ultrasonic sensor from left to right and back, the ESP32 measures the distance at each angle, and a web page on your phone draws the result as a classic green radar screen, with a sweeping line and a dot for every object.
What you'll learn
- How to lower a 5 V signal to 3.3 V with a voltage divider
- How to move a servo and measure at the same time without blocking the web server
- How to send a list of numbers to a web page as JSON
- How a web page draws with the HTML canvas
What you need
- ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
- Ultrasonic sensor HC-SR04 (ESP32 IoT Starter Kit)
- Servo motor (ESP32 IoT Starter Kit)
- 3 resistors of the same value, anything from 1 kΩ to 10 kΩ (ESP32 IoT Starter Kit)
- Tape or a rubber band to fix the sensor on the servo horn
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
- A 2.4 GHz Wi-Fi network
Protect the ESP32: the voltage divider
The HC-SR04 runs on 5 V, and its Echo pin answers with 5 V. The ESP32's pins are made for 3.3 V, and 5 V can damage them. Three equal resistors solve this. One resistor goes from Echo to the ESP32 pin, and two resistors in a row go from the ESP32 pin to GND. The pin then sees two thirds of 5 V, which is about 3.3 V. The Trig pin needs no protection, because there the ESP32 is the one sending.
Wiring

| From | To |
|---|---|
| Ultrasonic sensor VCC | 5V |
| Ultrasonic sensor GND | GND |
| Ultrasonic sensor Trig | ESP32 GPIO 26 |
| Ultrasonic sensor Echo | Resistor R1 |
| Other end of R1 | ESP32 GPIO 27, and resistor R2 |
| Other end of R2 | Resistor R3 |
| Other end of R3 | GND |
| Servo orange / yellow wire | ESP32 GPIO 13 |
| Servo red wire | 5V |
| Servo brown / black wire | GND |
The diagram shows the sensor on the breadboard so that the wiring is easy to follow. For the finished radar, connect the sensor with four female-to-male jumper wires instead and fix it on top of the servo horn, facing outwards. Leave the wires loose enough for the servo to turn freely.
Build it in stages
This is a bigger build than the earlier lessons, so don't wire everything at once. Add one part, test it, then add the next. When something stops working, you know it's the part you just added.
- The sensor. Wire the ultrasonic sensor and the three resistors. Check the divider twice before you power up.
- The sweep. Add the servo and upload the sketch. The servo starts to sweep from side to side.
-
The screen. Open the Serial Monitor at 115200 baud, note the address (for example
192.168.1.57) and open it in a browser on a phone or computer on the same Wi-Fi. - Mount it. Fix the sensor on the servo horn and place a few objects 20 to 80 cm away. Bottles and boxes work well.

The sketch
Install the ESP32Servo library (by Kevin Harrington) in the Arduino IDE. The standard Servo library doesn't work on the ESP32.
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>
const char* ssid = "YOUR_WIFI_NAME";
const char* password = "YOUR_WIFI_PASSWORD";
const int SERVO_PIN = 13;
const int TRIG_PIN = 26;
const int ECHO_PIN = 27;
const int MIN_ANGLE = 15; // the sweep goes from 15 to 165 degrees
const int MAX_ANGLE = 165;
const int STEP = 3; // degrees between two measurements
const int POINTS = (MAX_ANGLE - MIN_ANGLE) / STEP + 1;
const int MAX_CM = 100; // ignore anything further away
WebServer server(80);
Servo servo;
int distances[POINTS]; // the latest distance for every angle (0 = nothing)
int position = 0; // which angle we're at
int direction = 1; // 1 = turning one way, -1 = turning back
unsigned long lastStep = 0;
const char PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 radar</title>
<style>
body { margin: 0; background: #0b1220; color: #7CFC9A; font-family: sans-serif; text-align: center; }
canvas { width: 100%; max-width: 720px; }
</style></head><body>
<h2>ESP32 radar</h2>
<canvas id="screen" width="720" height="400"></canvas>
<p id="text">Connecting...</p>
<script>
const g = document.getElementById('screen').getContext('2d');
const CX = 360, CY = 380, R = 340, MIN = 15, STEP = 3, MAX = 100;
// Turns an angle and a distance into a position on the screen
function point(angle, cm) {
const rad = angle * Math.PI / 180;
return [CX + Math.cos(rad) * cm / MAX * R, CY - Math.sin(rad) * cm / MAX * R];
}
function draw(data) {
g.fillStyle = '#0b1220';
g.fillRect(0, 0, 720, 400);
// grid: four rings and a line every 30 degrees
g.strokeStyle = '#1f6f3a';
g.lineWidth = 1;
g.fillStyle = '#7CFC9A';
g.font = '12px sans-serif';
for (let ring = 1; ring <= 4; ring++) {
g.beginPath();
g.arc(CX, CY, R * ring / 4, Math.PI, 2 * Math.PI);
g.stroke();
g.fillText(MAX * ring / 4 + ' cm', CX + R * ring / 4 - 44, CY + 14);
}
for (let angle = 0; angle <= 180; angle += 30) {
const p = point(angle, MAX);
g.beginPath();
g.moveTo(CX, CY);
g.lineTo(p[0], p[1]);
g.stroke();
}
// the sweeping line
const tip = point(data.a, MAX);
g.strokeStyle = '#7CFC9A';
g.lineWidth = 3;
g.beginPath();
g.moveTo(CX, CY);
g.lineTo(tip[0], tip[1]);
g.stroke();
// one red dot for every object
let nearest = 0;
g.fillStyle = '#ff5a4d';
data.d.forEach(function (cm, i) {
if (!cm) return;
const p = point(MIN + i * STEP, cm);
g.beginPath();
g.arc(p[0], p[1], 5, 0, 2 * Math.PI);
g.fill();
if (!nearest || cm < nearest) nearest = cm;
});
document.getElementById('text').textContent =
nearest ? 'Nearest object: ' + nearest + ' cm' : 'Nothing in range';
}
async function update() {
try {
const answer = await fetch('/data');
draw(await answer.json());
} catch (error) {}
setTimeout(update, 250);
}
update();
</script></body></html>
)rawliteral";
int measureCm() {
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
unsigned long echoTime = pulseIn(ECHO_PIN, HIGH, 12000); // give up after 12 ms
if (echoTime == 0) return 0;
int cm = echoTime / 58;
return cm > MAX_CM ? 0 : cm;
}
void handleRoot() {
server.send_P(200, "text/html", PAGE);
}
// Sends the current angle and all distances, for example {"a":90,"d":[0,0,42,41,...]}
void handleData() {
String json = "{\"a\":" + String(MIN_ANGLE + position * STEP) + ",\"d\":[";
for (int i = 0; i < POINTS; i++) {
if (i > 0) json += ',';
json += distances[i];
}
json += "]}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
servo.setPeriodHertz(50);
servo.attach(SERVO_PIN, 500, 2400);
servo.write(MIN_ANGLE);
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Open this address in your browser: http://");
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
}
void loop() {
server.handleClient();
if (millis() - lastStep >= 45) { // one step every 45 ms
lastStep = millis();
distances[position] = measureCm(); // measure where the sensor points now
position += direction; // then move on
if (position == 0 || position == POINTS - 1) direction = -direction;
servo.write(MIN_ANGLE + position * STEP);
}
}How it works
The ESP32 keeps an array with one distance for each angle of the sweep. Every 45 milliseconds it measures at the current angle, stores the result, and turns the servo three degrees further. At either end, direction flips its sign and the sweep runs back. Because this is done with millis() and not with delay(), the web server gets a turn between every two steps.
The web page asks for /data four times per second. The ESP32 answers in JSON, a simple text format that JavaScript understands directly: the current angle a and the list of distances d. The page then redraws the whole screen on a canvas, an area of a web page that JavaScript can draw on.
The function point() does the mathematics. An angle and a distance describe a position the way a radar sees it. A screen needs x and y instead, and cosine and sine convert one into the other. This is exactly where trigonometry from school is used in real products.
pulseIn() waits at most 12 milliseconds for the echo, enough for about two metres, so a missing echo can't hold up the sweep. A result of 0 means "nothing found", and the page draws no dot there.
Try this next
- Draw the dots brighter when they are new and let them fade as they get older.
- Add the active buzzer and beep faster the closer the nearest object is.
- Show the angle of the nearest object as well as its distance.
- Take two measurements at each angle and use the smaller one to remove stray echoes.
Troubleshooting
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz. The ESP32 can't use 5 GHz networks.
- The page opens but shows no dots: check Trig on GPIO 26 and the divider on GPIO 27. Soft objects such as clothes and curtains swallow the sound; test with a box or a bottle.
-
The picture is mirrored: your servo turns the other way round. In the page code, change
CX + Math.cos(rad)toCX - Math.cos(rad). - The ESP32 restarts when the servo moves: servos draw bursts of current. Use a good USB port or power bank, and make sure the servo runs from the 5V pin, not from 3V3.
- The page says "Connecting..." forever: the phone must be on the same Wi-Fi as the ESP32, not on mobile data.
- Dots appear where nothing is: ultrasonic sensors have a wide beam (about 15 degrees each side) and also pick up echoes from the table. Raise the sensor a few centimetres.