ESP32 Servo Control Firmware
Write ESP32 firmware from scratch to control a servo over WiFi.
Introduction
30 Second Summary
Every robot that walks, grips, or scans needs to move a motor to an exact position on command. The secret behind that precise movement is a specific electrical signal your microcontroller sends hundreds of times per second.
In this project, you will write a complete ESP32 firmware from an empty file that creates its own WiFi network, serves a web-based control panel, and moves a servo motor in real-time as you drag a slider on your phone.
What You'll Build
You will pull out your phone, connect to a WiFi network your ESP32 is broadcasting, open a web page it serves, and drag a slider that instantly moves the servo to any angle between 0 and 180 degrees.
By the end of this project, you'll have:
- A WiFi Access Point running on your ESP32 that your phone connects to directly, with no router needed.
- A real-time web control panel where dragging a slider physically moves the servo and shows live angle feedback on screen.
- A state machine that tracks whether the servo is idle, moving, or holding position. This is the exact architecture that scales from one servo to twelve legs.
- Secret Mission: an autonomous sweep patrol mode that scans the servo back and forth, toggled by a button on the web page.
Are there any prerequisites?
You need basic familiarity with C syntax (variables, functions, loops) and an Elegoo Smart Robot Car V4.0 kit with the ESP32-WROVER module, SG90 servo, and USB-C cable.
You also need Arduino IDE installed on your computer. Step 1 walks you through adding ESP32 board support.
Before We Start
Before writing any code, let's lock in what you're about to build and why it matters. Every line of firmware in this project connects directly to your larger goal of building a quadruped robot.
Set Up ESP32 Standalone Programming
The ESP32-WROVER in your Elegoo kit is currently just a camera module for the robot car. It communicates with the car's main Arduino UNO board, but it never runs your own code.
To build custom firmware for your quadruped robot, you need to program the ESP32 directly as an independent microcontroller. In this step, you will disconnect the module from the car, connect it to your Mac, and prove that you can upload and run your own code on it.
In this step, get ready to:
- Remove the ESP32-WROVER module from the robot car and connect it to your Mac via USB-C.
- Select the correct serial port in Arduino IDE.
- Upload a test sketch and confirm communication via Serial Monitor.
Remove the ESP32-WROVER from the robot car
The ESP32-WROVER module clips into the top of the Elegoo Smart Robot Car. It has its own USB-C port on the back, which is what you will use to program it directly.
- Gently unclip or unscrew the ESP32-WROVER camera module from the robot car chassis.
- Locate the USB-C port on the backside of the module.
- Connect the module to your Mac using a USB-C cable.
Why remove it from the car?
When the module is mounted on the car, the Arduino UNO controls it via serial commands. Connecting it directly to your Mac lets Arduino IDE talk to the ESP32 over the same USB-C port for uploading and monitoring.
Select the serial port
Your board is already set to ESP32 Wrover Module with PSRAM enabled from earlier setup. Now you need to tell Arduino IDE which USB port the module is connected to.
- In Arduino IDE, click Tools in the top menu bar.
- Click Port.
- Select the port that appeared when you plugged in the module. On macOS, it typically looks like /dev/cu.usbserial-XXXX.
Finding the right port
If you see multiple ports, disconnect the USB-C cable and check which port disappears from the list. Reconnect, and the port that reappears is your ESP32.
No port showing up?
The ESP32-WROVER module uses a CH340 USB-to-serial chip. Recent versions of macOS include the CH340 driver by default. If no port appears, try a different USB-C cable (some cables are charge-only and do not carry data).
help me troubleshoot why my ESP32 is not showing a serial port on macOS
Create and upload the test sketch
With the port selected, you can now write a minimal sketch to prove the ESP32 is receiving and running your code. This sketch prints a message to the Serial Monitor once per second.
- In Arduino IDE, click File in the top menu bar.
- Click New Sketch.
- Delete any existing template code in the editor, then save the sketch as QuadServo by clicking File then Save As.
- Paste the following code into the editor:
void setup() {
// Start serial communication at 115200 baud
Serial.begin(115200);
}
void loop() {
// Print a message every second to confirm the ESP32 is alive
Serial.println("ESP32 alive");
delay(1000);
}
What does this code do?
- Serial.begin(115200) opens a serial connection at 115200 bits per second. This speed must match what you set in Serial Monitor later.
- Serial.println("ESP32 alive") sends the text over USB back to your Mac, where Serial Monitor displays it.
- delay(1000) pauses for one second between prints so the output is readable.
- Click the Upload button (the right-arrow icon in the toolbar) to compile and flash the sketch to your ESP32.
- Wait for the console at the bottom to show "Done uploading."
Upload failing with a timeout error?
Some ESP32-WROVER modules need a nudge to enter download mode. Hold the BOOT button on the module while you click Upload. Release the button once you see "Connecting..." in the console output.
Also confirm that the correct port is still selected under Tools > Port.
help me fix an upload timeout error for my ESP32-WROVER in Arduino IDE
Before you open Serial Monitor, what do you expect to see there?
- Open Serial Monitor by clicking Tools in the menu bar, then Serial Monitor.
- In the baud rate dropdown (top-right of the Serial Monitor pane), select 115200.
You should see ESP32 alive printing once per second, scrolling down the monitor. This confirms your Mac is talking to the ESP32 and running your firmware.
Seeing garbled characters instead?
Garbled output means the baud rate in Serial Monitor does not match the baud rate in your code. Make sure both are set to 115200. If you changed the dropdown, close and reopen Serial Monitor to reset the connection.
help me fix garbled serial output from my ESP32
✔️ Awesome, I've got everything!
Great. Double check you have saved your QuadServo.ino file and that ESP32 alive is printing every second in Serial Monitor.
ⓧ I'd like to double check the full code
Here is the complete QuadServo.ino at this point:
void setup() {
Serial.begin(115200);
}
void loop() {
Serial.println("ESP32 alive");
delay(1000);
}
Your ESP32 is free from the car and running your own firmware. Next up, you will wire a servo motor and make it move using raw PWM signals you calculate yourself.
Control the Servo with Raw PWM
Your ESP32 is alive and talking to your computer. That proves the board works and you can upload code to it.
Now for the exciting part. Servo motors respond to specific electrical pulse widths, and in this step you will generate those pulses yourself using raw hardware registers. No servo library. Just math and a timer peripheral.
In this step, get ready to:
- Wire the SG90 servo to the ESP32 module.
- Generate a 50Hz PWM signal using the LEDC peripheral.
- Sweep the servo from 0 to 180 degrees and back.
Wire the SG90 servo to the ESP32
The SG90 servo in your Elegoo kit has three wires. Each wire carries a different signal: control, power, and ground.
- Grab three male-to-female jumper wires from your Elegoo kit.
- Connect the orange (signal) wire from the servo to GPIO 13 on the ESP32.
- Connect the red (power) wire to the 5V pin on the ESP32 module.
- Connect the brown (ground) wire to a GND pin on the ESP32.
- Double check that the servo and ESP32 share a common ground connection.
Why does common ground matter?
The servo reads the signal wire's voltage relative to its own ground. If the servo's ground is not connected to the ESP32's ground, the signal looks like random noise and the servo will jitter or not respond at all.
GPIO 13 is safe for servo use
GPIO 13 is not used by the camera module (which uses GPIOs 4, 5, 18, 19, 21, 22, 23, 25, 26, 27, 34, 35, 36, 39). It has no boot-mode sensitivity, so it won't interfere with uploading code.
Write the sweep firmware
A servo motor moves to a specific angle based on the width of an electrical pulse it receives 50 times per second. The ESP32's LEDC peripheral is a hardware timer that generates these pulses without any CPU effort.
How does pulse width become an angle?
At 50Hz, one full period is 20ms (20,000 microseconds). With 16-bit resolution, that period is divided into 65,536 ticks.
A 500us pulse (0 degrees) = 500/20000 * 65536 = 1638 ticks. A 2400us pulse (180 degrees) = 2400/20000 * 65536 = 7864 ticks. Every angle between 0 and 180 maps to a duty value somewhere in that range.
- In Arduino IDE, select all the code in your QuadServo.ino file and delete it.
- Replace it with the following firmware that initializes the LEDC peripheral and sweeps the servo:
#include <WiFi.h>
// --- Configuration ---
const int SERVO_PIN = 13;
const int PWM_FREQ = 50;
const int PWM_RESOLUTION = 16;
const int DUTY_MIN = 1638;
const int DUTY_MAX = 7864;
void setup() {
Serial.begin(115200);
delay(1000);
// Attach the LEDC timer to GPIO 13 at 50Hz with 16-bit resolution
ledcAttach(SERVO_PIN, PWM_FREQ, PWM_RESOLUTION);
Serial.println("Servo PWM initialized");
}
void loop() {
// Sweep from 0 degrees to 180 degrees
for (int duty = DUTY_MIN; duty <= DUTY_MAX; duty += 10) {
ledcWrite(SERVO_PIN, duty);
Serial.println(duty);
delay(15);
}
// Sweep back from 180 degrees to 0 degrees
for (int duty = DUTY_MAX; duty >= DUTY_MIN; duty -= 10) {
ledcWrite(SERVO_PIN, duty);
Serial.println(duty);
delay(15);
}
}
What does this code do?
- ledcAttach(SERVO_PIN, PWM_FREQ, PWM_RESOLUTION) tells the ESP32's hardware timer to start generating a 50Hz signal on GPIO 13 with 65,536 possible duty levels.
- The first for loop sweeps duty from 1638 (0 degrees) up to 7864 (180 degrees) in steps of 10, moving the servo smoothly forward.
- The second for loop reverses the sweep, bringing the servo back to 0 degrees.
- Each delay(15) gives the servo time to physically move to each position before the next tick.
- Serial.println(duty) prints the raw duty value so you can watch the numbers climb and fall in the Serial Monitor.
Why include WiFi.h if we are not using WiFi yet?
The next step adds WiFi Access Point mode to this same sketch. Including it now means you will not need to restructure the file later. It compiles fine even when no WiFi functions are called.
Upload and verify the sweep
Before you upload, predict what you expect to see. Will the servo move immediately, or will there be a delay before it starts sweeping?
- Click the Upload button (right arrow icon) in Arduino IDE.
- If the upload stalls at "Connecting...", hold the BOOT button on the ESP32 module until it starts uploading, then release.
- Once the upload completes, open Serial Monitor via Tools > Serial Monitor and set the baud rate to 115200.
You should see "Servo PWM initialized" followed by a stream of numbers climbing from 1638 toward 7864, then falling back down. At the same time, your servo physically sweeps from 0 to 180 degrees and back in a smooth, continuous motion.
Servo not moving?
- Check that the orange signal wire is connected to GPIO 13 specifically. A wrong pin means the LEDC signal never reaches the servo.
- Confirm the red power wire is on 5V, not 3.3V. The SG90 needs at least 4.8V to move reliably.
- If the servo jitters at the extreme ends (near 0 or 180 degrees), that is normal for budget servos at the edge of their mechanical range.
Still stuck? Help me debug why my servo is not responding to the PWM signal.
Seeing numbers in Serial Monitor but they look wrong?
- Make sure the baud rate dropdown in Serial Monitor is set to 115200. A mismatch produces garbled characters instead of numbers.
- If you see "Servo PWM initialized" but no duty values after it, press the EN (reset) button on the ESP32 to restart the sketch from the beginning.
✔️ Awesome, I've got everything!
Great. Double check you have saved your QuadServo.ino file.
ⓧ I'd like to double check the full code
Here is the complete QuadServo.ino at this point:
#include <WiFi.h>
// --- Configuration ---
const int SERVO_PIN = 13;
const int PWM_FREQ = 50;
const int PWM_RESOLUTION = 16;
const int DUTY_MIN = 1638;
const int DUTY_MAX = 7864;
void setup() {
Serial.begin(115200);
delay(1000);
ledcAttach(SERVO_PIN, PWM_FREQ, PWM_RESOLUTION);
Serial.println("Servo PWM initialized");
}
void loop() {
for (int duty = DUTY_MIN; duty <= DUTY_MAX; duty += 10) {
ledcWrite(SERVO_PIN, duty);
Serial.println(duty);
delay(15);
}
for (int duty = DUTY_MAX; duty >= DUTY_MIN; duty -= 10) {
ledcWrite(SERVO_PIN, duty);
Serial.println(duty);
delay(15);
}
}
Your servo is now responding to code you wrote from scratch. You understand exactly how a number in your code becomes a physical angle on the motor. Next up, you will add wireless control so you can change that angle from your phone without re-uploading code.
Create a WiFi Access Point
Your servo sweeps perfectly, but there is a problem. Every time you want to change the angle, you have to edit the code and re-upload the entire firmware. That is not how a robot works.
Your quadruped will need wireless control. A WiFi Access Point is the simplest path: the ESP32 creates its own network, your phone connects directly to it, and you send commands over the air. No router needed.
In this step, get ready to:
- Add WiFi Access Point code so the ESP32 broadcasts its own network.
- Create an HTTP server that responds to incoming connections.
- Connect your phone and confirm two-way communication.
Add WiFi configuration and start the access point
The ESP32 has a built-in WiFi radio that can act as its own access point. Instead of joining an existing network, the ESP32 broadcasts a network name (SSID) that other devices can connect to directly.
- In QuadServo.ino, find the // --- Configuration --- section at the top of the file.
- Replace everything from // --- Configuration --- down to (and including) the line const int DUTY_MAX = 7864; with the following code:
// --- Configuration ---
const char* ssid = "QuadServo";
const char* password = "servo1234";
const int SERVO_PIN = 13;
const int PWM_FREQ = 50;
const int PWM_RESOLUTION = 16;
const int DUTY_MIN = 1638;
const int DUTY_MAX = 7864;
// --- WiFi Server ---
WiFiServer server(80);
What does this code do?
You added three new things to the configuration:
- ssid and password define the WiFi network name and password your phone will use to connect. The password must be at least 8 characters for WPA2 security.
- WiFiServer server(80) creates a web server listening on port 80 (the standard HTTP port). This is what will receive requests from your phone's browser.
- Now replace the entire setup() function with this updated version:
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("QuadServo starting...");
// Initialize servo PWM and park at 90 degrees
ledcAttach(SERVO_PIN, PWM_FREQ, PWM_RESOLUTION);
ledcWrite(SERVO_PIN, 4751);
Serial.println("Servo initialized at 90 degrees");
// Create WiFi Access Point
WiFi.softAP(ssid, password);
Serial.print("AP IP address: ");
Serial.println(WiFi.softAPIP());
// Start HTTP server
server.begin();
Serial.println("Server started");
}
What changed in setup?
Instead of just initializing the servo, setup now does three jobs:
- ledcWrite(SERVO_PIN, 4751) parks the servo at 90 degrees (midpoint between 1638 and 7864) so it holds still instead of sweeping.
- WiFi.softAP(ssid, password) tells the ESP32 to broadcast a WiFi network called "QuadServo" that your phone can join.
- server.begin() starts listening for HTTP connections on port 80.
- Click the Upload button (right arrow icon) to flash the firmware.
- Open Serial Monitor at 115200 baud.
You should see three lines confirming the servo, WiFi AP, and server all started. The AP IP address will be 192.168.4.1.
Not seeing the AP IP address?
- If you see a compile error about server being undeclared, make sure the WiFiServer server(80); line is placed after DUTY_MAX and before void setup().
- If Serial Monitor shows nothing, press the reset button on the ESP32 module to restart the firmware.
- help me debug my WiFi AP setup on ESP32
Handle incoming client connections
The access point is broadcasting, but nothing happens when a device connects. You need a loop that waits for incoming HTTP requests and sends back a response.
- Replace the entire loop() function (remove the sweep for-loops) with this client handler:
void loop() {
// Check for incoming client connections
WiFiClient client = server.available();
if (client) {
Serial.print("Client connected: ");
Serial.println(client.remoteIP());
// Wait for the client to send its request
unsigned long timeout = millis() + 2000;
while (client.connected() && millis() < timeout) {
if (client.available()) {
client.read();
}
}
// Send HTTP response
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: text/plain");
client.println("Connection: close");
client.println();
client.println("QuadServo ready");
client.stop();
}
}
What does this loop do?
This is a basic HTTP server running on a microcontroller. Here is the flow:
- server.available() checks if any device has opened a connection. If nobody is connecting, it returns nothing and the loop repeats.
- When a client connects, the code prints their IP to Serial so you can see who joined.
- The while loop reads and discards the incoming request data (we do not need to parse it yet). The 2-second timeout prevents the loop from hanging if a client disconnects unexpectedly.
- Finally, it sends back a proper HTTP response with the text "QuadServo ready" and closes the connection.
Test the WiFi connection with your phone
- Save QuadServo.ino and click Upload to flash the updated firmware.
- Open Serial Monitor at 115200 baud and confirm you see "QuadServo starting..." followed by the AP IP address.
Before you connect your phone, what do you think will happen in Serial Monitor when a device joins the network and opens the browser?
- On your phone, open WiFi settings and look for a network called QuadServo.
- Connect using the password servo1234.
- Open your phone's browser and navigate to 192.168.4.1.
Your phone's browser displays "QuadServo ready" in plain text. At the same time, Serial Monitor prints "Client connected:" followed by your phone's IP address.
Don't see the QuadServo network on your phone?
- Make sure Serial Monitor shows "Server started" before you try connecting. If it does not, the upload may have failed. Try uploading again while holding the boot button on the ESP32.
- If your phone connects but the browser shows "page not found", make sure you typed 192.168.4.1 in the URL bar (not a search bar).
- If the page loads but Serial Monitor shows nothing, check that your baud rate is set to 115200 in the Serial Monitor dropdown.
- help me troubleshoot my ESP32 WiFi access point connection
✔️ Awesome, I've got everything!
Great. Make sure you have saved QuadServo.ino and your phone can load the page at 192.168.4.1.
ⓧ I'd like to double check the full code
Your QuadServo.ino should have these sections in order. Scroll up and confirm each one is present:
- The #include <WiFi.h> line at the very top (unchanged from before).
- The configuration section with ssid, password, servo constants, and WiFiServer server(80).
- The setup() function that initializes the servo at 90 degrees, creates the WiFi AP, and starts the server.
- The loop() function that checks for clients, prints their IP, reads the request, and responds with "QuadServo ready".
Your ESP32 now has its own wireless network and can talk to your phone. Next up, you will replace that plain text response with a real control interface that moves the servo in real-time.
Build the Web Control Interface
You proved WiFi works. Your phone can connect to the ESP32 and get a plain text response. But typing URLs is not how you will control a robot.
You need a real-time interface. An HTML slider that sends angle commands over HTTP is exactly how projects like the ESP32 Spider Robot serve their control panels. In this step, you will build that interface from scratch.
In this step, get ready to:
- Add an HTML page with a range slider to your firmware.
- Parse incoming HTTP requests for angle commands.
- Move the servo in real-time from your phone's browser.
Add the HTML control page
Your ESP32 currently sends back plain text. You need it to serve a full HTML page with a slider and JavaScript that fires requests as you drag.
- In QuadServo.ino, find the line that says WiFiServer server(80); and add this HTML page constant directly below it:
// --- HTML Page ---
const char htmlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QuadServo Control</title>
</head>
<body>
<h1>QuadServo Control</h1>
<p>Angle: <span id="angleval">90</span>°</p>
<input type="range" min="0" max="180" value="90" id="slider">
<script>
var slider = document.getElementById('slider');
var angleval = document.getElementById('angleval');
slider.addEventListener('input', function() {
angleval.textContent = this.value;
fetch('/servo?angle=' + this.value);
});
</script>
</body>
</html>
)rawliteral";
What does this code do?
PROGMEM stores the HTML string in flash memory instead of RAM, which is limited on the ESP32.
The HTML page contains a range slider from 0 to 180 and a JavaScript listener on the input event. Every time your finger moves the slider, the browser sends a GET /servo?angle=VALUE request to the ESP32. The input event fires continuously as you drag, giving you real-time control.
Add the angle conversion helper
You need a function that converts a human-readable angle (0 to 180) into the duty cycle value the LEDC peripheral expects (1638 to 7864). This keeps the math in one place.
- Directly below your htmlPage constant, add this helper function:
// --- Helper Functions ---
int angleToDuty(int angle) {
return map(angle, 0, 180, DUTY_MIN, DUTY_MAX);
}
What does this code do?
The Arduino map() function linearly scales a value from one range to another. Here it converts 0-180 degrees to 1638-7864 duty ticks. You will use this function everywhere you need to move the servo.
- In your setup() function, find this line:
ledcWrite(SERVO_PIN, 4751);
- Replace 4751 with a call to your new helper, so the line now looks like this:
ledcWrite(SERVO_PIN, angleToDuty(90));
- Save QuadServo.ino.
Parse incoming servo commands
Your current loop discards every byte the client sends. You need to read the full HTTP request into a string so you can inspect the URL and decide what to do.
- Delete your entire existing loop() function and replace it with this code that reads the HTTP request and parses angle commands:
void loop() {
WiFiClient client = server.available();
if (client) {
// Read the full HTTP request into a string
String request = "";
unsigned long timeout = millis() + 2000;
while (client.connected() && millis() < timeout) {
if (client.available()) {
char c = client.read();
request += c;
if (request.endsWith("\r\n\r\n")) break;
}
}
// Check if the browser is sending a servo angle
if (request.indexOf("GET /servo?angle=") >= 0) {
int startIdx = request.indexOf("angle=") + 6;
int endIdx = request.indexOf(" ", startIdx);
String angleStr = request.substring(startIdx, endIdx);
int angle = angleStr.toInt();
// Move servo and print to Serial
if (angle >= 0 && angle <= 180) {
ledcWrite(SERVO_PIN, angleToDuty(angle));
Serial.print("Angle: ");
Serial.print(angle);
Serial.print(" Duty: ");
Serial.println(angleToDuty(angle));
}
What does this code do?
- The while loop reads one character at a time until it sees \r\n\r\n, which marks the end of HTTP headers. This prevents the ESP32 from hanging on slow connections.
- request.indexOf("GET /servo?angle=") checks if this is a slider command. If yes, it extracts the number after angle= and converts it to an integer.
- The bounds check (0 to 180) prevents garbage values from reaching the servo. Valid angles get converted to duty cycles and written to the LEDC peripheral.
- Continue adding this code directly below, still inside the loop() function. This handles sending responses back to the browser:
// Send acknowledgment back to the browser
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: text/plain");
client.println("Connection: close");
client.println();
client.println("OK");
} else {
// Serve the HTML control page for any other request
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: text/html");
client.println("Connection: close");
client.println();
client.println(htmlPage);
}
client.stop();
}
}
What does this code do?
When the request is a servo command, the ESP32 replies with a short OK so the browser knows it was received.
For any other request (like opening 192.168.4.1 in the browser), the ESP32 sends back the full HTML page with the slider. This is how your phone gets the control interface.
- Save QuadServo.ino.
Seeing a compile error?
- Make sure you deleted the entire old loop() function before pasting. Having two loop() definitions causes a redefinition error.
- Check that the two code blocks connect cleanly. The closing } after the if-valid block in the first chunk should align with the opening of the response lines in the second chunk.
- Confirm angleToDuty is defined above loop(). If it comes after, the compiler does not know about it yet.
Help me fix a compile error in my QuadServo.ino loop function
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before uploading.
ⓧ I'd like to double check the full code
Your QuadServo.ino should contain these sections in order. Scroll up and confirm each is present:
- The #include <WiFi.h> and configuration constants (SERVO_PIN, PWM_FREQ, PWM_RESOLUTION, DUTY_MIN, DUTY_MAX).
- WiFiServer server(80); declaration.
- The htmlPage PROGMEM constant with the slider HTML and JavaScript.
- The angleToDuty() helper function using map().
- setup() with ledcWrite(SERVO_PIN, angleToDuty(90)) (not the old hardcoded 4751).
- loop() that reads the request into a String, checks for GET /servo?angle=, and serves htmlPage for all other requests.
Upload and test the full system
Everything is connected. Your firmware has the HTML page, the angle parser, and the servo control logic. Time to see it all work together.
- Click the Upload button in Arduino IDE (the right-arrow icon in the toolbar). Hold the boot button on the ESP32 if needed during upload.
- Open Serial Monitor by clicking **Tools**, then **Serial Monitor**. Set the baud rate to 115200.
- On your phone, open **Settings** and connect to the QuadServo WiFi network (password: servo1234).
- Open your phone's browser and navigate to 192.168.4.1.
Before you drag the slider, predict what you expect to happen. Will the servo move instantly? Will there be a delay? What will Serial Monitor show?
- Drag the slider from 90 toward 0 and then toward 180.
The servo physically tracks your finger position in real-time. Serial Monitor prints each angle and duty value as they arrive.
What should I see?
Your phone displays a page titled QuadServo Control with a slider and a live angle readout. As you drag the slider, the servo moves to that position and Serial Monitor shows lines like:
Angle: 45 Duty: 3196
Angle: 90 Duty: 4751
Angle: 135 Duty: 6307
The duty values scale linearly between 1638 (0 degrees) and 7864 (180 degrees).
Slider shows but servo does not move?
- Check your wiring. The orange signal wire must be on GPIO 13, not another pin. Confirm the brown ground wire connects to a GND pin on the ESP32.
- Verify Serial Monitor shows angle values when you drag. If it does not print anything, the JavaScript fetch requests are not reaching the ESP32. Check that your phone is still connected to the QuadServo network (phones sometimes auto-switch to a network with internet).
- If Serial prints angles but the servo stays still, the servo may not be getting enough power. Try powering the servo from the kit battery pack's 5V output instead of USB power alone.
Help me debug why my servo is not responding to slider commands
Your phone now controls a servo over WiFi in real-time. Next up, you will add a state machine that tracks whether the servo is idle, moving, or holding position. This is the pattern that scales from one servo to twelve.
Add State Machine and Feedback
Right now your firmware is a simple request-response loop. When the slider sends an angle, the servo jumps there instantly. Real robot firmware needs to track its own state: what mode is it in, what was the last commanded position, is it currently moving?
In this step, you will add a state machine that smoothly transitions the servo between positions one degree at a time. This is the exact pattern that scales from controlling one servo to controlling twelve legs on a quadruped.
In this step, get ready to:
- Define a state machine with IDLE, MOVING, and HOLDING states.
- Implement smooth angle transitions in the main loop.
- Add a live status display to the web interface with a polling endpoint.
Define the state machine data structures
A state machine gives your firmware a memory. Instead of blindly jumping to whatever angle arrives, the servo knows whether it is idle, actively moving, or holding a position.
- In QuadServo.ino, find the // --- WiFi Server --- comment.
- Add the following state machine code directly above that comment:
// --- State Machine ---
enum ServoState { IDLE, MOVING, HOLDING };
struct ServoController {
int currentAngle;
int targetAngle;
ServoState state;
};
ServoController servo = {90, 90, IDLE};
What does this code do?
The enum defines three possible states your servo can be in: doing nothing, actively moving toward a target, or holding steady at its destination.
The struct bundles the servo's current position, its goal, and its state into one tidy package. You initialize it at 90 degrees in the IDLE state.
- Now find this line in your setup() function:
ledcWrite(SERVO_PIN, angleToDuty(90));
- Replace the hardcoded 90 with servo.currentAngle so it reads from your new struct:
ledcWrite(SERVO_PIN, angleToDuty(servo.currentAngle));
- Save QuadServo.ino.
- Click the Upload button (right arrow icon) to flash the sketch.
- Open Serial Monitor at 115200 baud.
You should still see "QuadServo starting..." followed by "Servo initialized at 90 degrees" and "Server started". The state machine is wired in but not acting yet.
Seeing a compile error?
Check that the enum and struct are placed between the DUTY_MAX line and the WiFiServer line. If the compiler says servo was not declared, make sure the ServoController servo = {90, 90, IDLE}; line comes after the struct definition.
Help me fix my state machine compile error.
Implement state transitions in the loop
The state machine needs logic that runs every loop cycle. When the target angle differs from the current angle, it enters MOVING and steps one degree closer each tick. When it arrives, it transitions to HOLDING.
- In your loop() function, find the line:
void loop() {
WiFiClient client = server.available();
- Add the following state machine update block between void loop() { and the WiFiClient line:
// --- State Machine Update ---
if (servo.targetAngle != servo.currentAngle) {
servo.state = MOVING;
if (servo.currentAngle < servo.targetAngle) {
servo.currentAngle++;
} else {
servo.currentAngle--;
}
ledcWrite(SERVO_PIN, angleToDuty(servo.currentAngle));
} else if (servo.state == MOVING) {
servo.state = HOLDING;
Serial.print("HOLDING at ");
Serial.println(servo.currentAngle);
}
What does this code do?
- Every loop cycle, the state machine checks if the target differs from the current angle. If so, it moves one degree closer and writes the new duty to the servo.
- When current finally equals target, it prints the HOLDING message and stops moving. This one-degree-per-tick approach gives you smooth, controlled motion instead of jarring jumps.
- Now find the inside of your GET /servo?angle= handler. Locate these lines inside the if (angle >= 0 && angle <= 180) block:
ledcWrite(SERVO_PIN, angleToDuty(angle));
Serial.print("Angle: ");
Serial.print(angle);
Serial.print(" Duty: ");
Serial.println(angleToDuty(angle));
- Replace those five lines with this code that sets the target angle instead of moving directly:
servo.targetAngle = angle;
servo.state = MOVING;
Serial.print("Target: ");
Serial.print(angle);
Serial.print(" Duty: ");
Serial.println(angleToDuty(angle));
Why set a target instead of moving directly?
Previously the handler called ledcWrite() immediately, making the servo jump. Now it just updates the target, and the state machine in the loop handles the smooth transition one degree at a time. This separation is how real robot firmware decouples commands from motion.
- Finally, add a // --- Handle Web Clients --- comment above the WiFiClient client = server.available(); line for readability.
- At the very end of your loop() function, after the closing brace of the if (client) block, add this line:
delay(5);
Why the 5ms delay?
This tiny delay controls how fast the state machine ticks. Each loop cycle moves the servo one degree, so a 5ms delay means the servo travels at roughly 200 degrees per second. Without it, the loop spins at full CPU speed and the servo would jump almost instantly anyway.
- Save QuadServo.ino and upload.
- Connect your phone to the QuadServo WiFi network and open 192.168.4.1 in your browser.
- Drag the slider to a new position and watch the Serial Monitor.
You should see "Target: 45 Duty: 2770" (or whatever angle you chose) followed by "HOLDING at 45" a moment later. The servo now glides smoothly to each position instead of jumping.
Servo jumps instead of gliding?
Make sure you removed the old ledcWrite(SERVO_PIN, angleToDuty(angle)); line from the /servo handler. If both the handler and the state machine call ledcWrite(), the handler wins and the servo jumps.
Also check that delay(5); is at the end of loop(), not inside the client-handling block.
Help me debug my servo state machine transitions.
Add live status feedback to the web page
The servo now tracks state internally, but you cannot see it from your phone. You will add a status display to the web page and a JSON endpoint the page polls every 200 milliseconds.
- In your htmlPage string, find the closing </title> tag. Add this style block directly below it:
<style>
body { font-family: sans-serif; text-align: center; padding: 20px; background: #1a1a2e; color: #eee; }
h1 { color: #0f3460; }
input[type=range] { width: 80%; height: 30px; }
#status { font-size: 1.2em; margin-top: 20px; }
</style>
What does this style do?
This gives the control page a dark background, centers everything, and makes the slider wide enough to drag easily on a phone. The status div gets its own sizing so the state text is clearly readable.
- Now find the slider input line in your htmlPage:
<input type="range" min="0" max="180" value="90" id="slider">
- Add this status div directly below the slider input:
<div id="status">State: IDLE</div>
- Next, replace the entire <script>...</script> block in your htmlPage with this updated JavaScript:
<script>
var slider = document.getElementById('slider');
var angleval = document.getElementById('angleval');
var statusDiv = document.getElementById('status');
slider.addEventListener('input', function() {
angleval.textContent = this.value;
fetch('/servo?angle=' + this.value);
});
setInterval(function() {
fetch('/status').then(function(r) { return r.json(); }).then(function(d) {
statusDiv.textContent = 'State: ' + d.state + ' | Angle: ' + d.angle;
});
}, 200);
</script>
What does the new JavaScript do?
- The statusDiv variable grabs a reference to the new status display element.
- The setInterval calls /status every 200ms, parses the JSON response, and updates the text to show the current state and angle live.
- Save QuadServo.ino and upload to verify the HTML compiles cleanly.
- Open Serial Monitor and confirm "Server started" still appears.
The page now expects a /status endpoint that does not exist yet. You will add it next.
- In your request-parsing section of loop(), find the closing brace of the GET /servo?angle= handler block (the } right before the else { that serves the HTML page).
- Replace that else { with an else if that checks for the status endpoint, followed by the original else as a fallback. Add this code:
} else if (request.indexOf("GET /status") >= 0) {
// Build state string from enum
String stateStr;
switch (servo.state) {
case IDLE: stateStr = "IDLE"; break;
case MOVING: stateStr = "MOVING"; break;
case HOLDING: stateStr = "HOLDING"; break;
}
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: application/json");
client.println("Connection: close");
client.println();
client.print("{\"angle\":");
client.print(servo.currentAngle);
client.print(",\"state\":\"");
client.print(stateStr);
client.println("\"}");
} else {
What does the /status endpoint do?
It reads the servo struct's current angle and state, converts the enum to a string, and returns a JSON object like {"angle":90,"state":"HOLDING"}. The JavaScript on the web page parses this and updates the status display every 200ms.
- Save QuadServo.ino and upload.
Before you test, predict: when you drag the slider to 180, what sequence of states do you expect the status display to show?
- Connect your phone to QuadServo WiFi and open 192.168.4.1 in your browser.
- Drag the slider to 90 degrees.
- Watch the status text below the slider and the Serial Monitor simultaneously.
The web page shows "State: MOVING | Angle: 91" counting up, then settles on "State: HOLDING | Angle: 90". In the Serial Monitor, you see "HOLDING at 90". Drag to 180 and watch the state go IDLE to MOVING to HOLDING again. This smooth motion with state tracking is exactly how quadruped gait controllers manage leg positions.
Status shows but never updates?
If the status div stays on "State: IDLE" and never changes, check that your else if (request.indexOf("GET /status") >= 0) comes before the final else that serves the HTML. If it comes after, the HTML handler catches all requests first.
Also confirm the JavaScript uses /status (with the leading slash). Without it, the browser appends the path to the current URL incorrectly.
Help me debug my /status endpoint.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and the servo responds smoothly to slider input.
ⓧ I'd like to double check the full code
Your QuadServo.ino should now have these sections in order. Scroll up and confirm each one is present:
- Configuration constants (ssid, password, SERVO_PIN, PWM_FREQ, PWM_RESOLUTION, DUTY_MIN, DUTY_MAX).
- State Machine section with enum ServoState, struct ServoController, and servo initialized to {90, 90, IDLE}.
- WiFiServer and htmlPage with styles, status div, and setInterval polling.
- The angleToDuty() helper function.
- In setup(): Serial, ledcAttach, ledcWrite(SERVO_PIN, angleToDuty(servo.currentAngle)), WiFi.softAP, server.begin.
- In loop(): state machine update block, then web client handling with /servo (sets servo.targetAngle), /status (returns JSON), and else (serves HTML). Ends with delay(5).
Your firmware now thinks like a robot. It tracks where it is, where it wants to be, and smoothly transitions between positions. This IDLE/MOVING/HOLDING pattern is the exact foundation used by quadruped gait controllers to coordinate twelve servos at once. In the Secret Mission, you will layer an autonomous patrol mode on top of this architecture.
Secret mission
Add Sweep Patrol Mode
A search-and-rescue robot needs autonomous behaviors, not just remote control. Can you add a patrol mode that sweeps the servo back and forth automatically, toggled by a button on the web page?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your ESP32, so there are no ongoing costs.
Resources you used:
- WiFi Access Point "QuadServo" running on the ESP32 (local-only, no internet access, no cost).
- QuadServo.ino sketch file saved on your computer.
- SG90 servo physically wired to GPIO 13 on the ESP32-WROVER module.
- ESP32 board support package installed in Arduino IDE.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend this firmware into a multi-servo quadruped controller.
- The ESP32 continues running your QuadServo firmware as long as it has power (USB-C or the kit's battery pack).
- The "QuadServo" WiFi network stays active and accessible from your phone at 192.168.4.1.
- Your firmware is stored in the ESP32's flash memory and persists through power cycles.
- The ESP32 board support package remains installed in Arduino IDE for future projects.
Pause - I'll come back to this later
Shut down the ESP32 to free up your USB port. Your firmware is saved in flash and will run again the moment you reconnect power.
- Disconnect the USB-C cable from the ESP32-WROVER module.
- Leave the servo wires attached if you plan to resume. They will not draw power while the ESP32 is off.
- Next time you plug in the USB-C cable, the ESP32 boots your QuadServo firmware automatically and the WiFi AP reappears.
Delete - I don't want to use this again
Remove all project resources and restore the ESP32 to its original state for the Elegoo car kit.
- Restore the original Elegoo camera firmware by opening the ESP32_CameraServer sketch from the Elegoo software package that came with your kit and uploading it to the ESP32.
- Disconnect the three jumper wires from the servo (orange from GPIO 13, red from 5V, brown from GND).
- Reinstall the ESP32-WROVER module back into the robot car's camera mount.
- Delete the QuadServo.ino sketch file from your computer. In Arduino IDE, close the sketch tab. Then delete the QuadServo folder from your Arduino sketchbook directory.
That's a Wrap!
That's a Wrap!
Nice work. You just built a complete ESP32 firmware from scratch that controls a servo over WiFi, starting from a completely empty file.
You've learned how to:
- Generate precise PWM duty cycles using the LEDC peripheral to control a servo's physical position at the hardware level.
- Build a WiFi Access Point that serves a web control interface and parse raw HTTP requests to receive commands wirelessly.
- Implement a state machine pattern (IDLE, MOVING, HOLDING) that tracks servo state and scales from one servo to twelve.
- Secret Mission: Add an autonomous patrol mode layered on top of manual control, toggled cleanly from the web interface.
Ready to quiz yourself?