Lesson 06: Your First Robot Joint -- Precision Control with Servo Motors
Robotics from Scratch . Lesson 06 . Beginner-Intermediate . ~30 min read . Updated April 2026
The Problem with DC Motors
In Lesson 05, you spun a DC motor and controlled its speed with PWM. That is useful. But there is a fundamental limitation: DC motors spin freely. They do not know where they are. They do not hold position. If you cut power, they coast to a stop wherever momentum takes them.
Robots need to move to specific positions. Your robotic arm needs to hold the coffee cup, not drop it halfway. Your rover needs to turn to exactly 47 degrees, not approximately. The sun-tracking solar panel needs to point at the sun, not vaguely east.
This is where servo motors change everything.
What a Servo Motor Actually Is
A servo motor is a DC motor + a gearbox + a position sensor + a closed-loop controller, all in one package.
The position sensor (usually a potentiometer inside) tells the servo's internal circuit exactly where the output shaft is pointing. The internal controller compares that position to the target position you specified, and drives the motor accordingly -- full speed toward the target, then slows down as it approaches, then overshoots slightly, corrects back, and settles. This is called a PID controller, and it runs inside the servo at hardware speed, thousands of times per second.
The result: you send a pulse every 20 milliseconds, and the servo moves to exactly the angle you asked for and holds there, resisting forces that try to move it.
Most hobby servos (SG90, MG996R) rotate 0-180 degrees. Continuous rotation servos exist -- those behave like speed-controlled DC motors instead of position-controlled devices.
The Hardware: SG90 Micro Servo
The SG90 is the Honda Civic of servo motors. Cheap ($2-3), small, decent torque for its size, reliable enough for learning.
Specs:
- Weight: 9g
- Torque: 1.8 kg cm (at 4.8V)
- Speed: 0.12s/60 degrees (at 4.8V)
- Operating voltage: 4.8V-6V
- Control: 50Hz PWM (20ms period), 1ms-2ms pulse width
How PWM controls angle on a servo:
- 1ms pulse = 0 degrees (full counterclockwise)
- 1.5ms pulse = 90 degrees (center)
- 2ms pulse = 180 degrees (full clockwise)
The servo interpolates between these. 1.25ms = 45 degrees. 1.75ms = 135 degrees. The relationship is linear.
Important: Servos draw significant current when moving. The ESP32 GPIO pin cannot supply this. You must power the servo separately (4xAA battery pack or dedicated 5V regulator) and share the ground with the ESP32.
Wiring -- ESP32 to SG90
ESP32 GPIO 16 (RX2) -> Servo Signal wire (yellow/orange)
4.8V battery pack + -> Servo VCC wire (red)
4.8V battery pack - -> Servo GND wire (brown) AND ESP32 GND
Critical: Never power the servo from the ESP32's 3.3V or 5V rail. ESP32 boards can typically supply 500mA-1000mA total on 5V from USB. A servo stalled motor can draw 750mA+. You will brown out the ESP32 and it will reset.
Use a separate battery pack for the servo. The shared ground is what makes the signal reference correct.
The Code
We will use the ESP32Servo library which handles the PWM generation correctly for ESP32 hardware timers.
#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>
// WiFi credentials
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// Servo setup
Servo myServo;
const int servoPin = 16; // GPIO 16
int currentAngle = 90; // Start at center
int targetAngle = 90;
// Web server on port 80
WebServer server(80);
// HTML page with a slider
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Servo Control</title>
<style>
body { font-family: Arial; text-align: center; padding: 20px; background: #1a1a2e; color: #eee; }
h1 { color: #00d4ff; }
input[type=range] { width: 300px; height: 12px; }
.angle-display { font-size: 48px; font-weight: bold; color: #00d4ff; margin: 20px; }
.status { color: #aaa; margin-top: 10px; }
</style>
</head>
<body>
<h1>Servo Joint Control</h1>
<div class="angle-display"><span id="angleValue">90</span> deg</div>
<input type="range" id="servoSlider" min="0" max="180" value="90"
oninput="sendAngle(this.value)">
<div class="status">ESP32 IP: 192.168.1.100</div>
<script>
function sendAngle(angle) {
document.getElementById('angleValue').innerText = angle;
var xhr = new XMLHttpRequest();
xhr.open('GET', '/set?angle=' + angle, true);
xhr.send();
}
setInterval(function() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
document.getElementById('angleValue').innerText = xhr.responseText;
document.getElementById('servoSlider').value = xhr.responseText;
}
};
xhr.open('GET', '/angle', true);
xhr.send();
}, 500);
</script>
</body>
</html>
)rawliteral";
void handleRoot() {
server.send(200, "text/html", index_html);
}
void handleSetAngle() {
if (server.hasArg("angle")) {
targetAngle = server.arg("angle").toInt();
targetAngle = constrain(targetAngle, 0, 180);
myServo.write(targetAngle);
currentAngle = targetAngle;
server.send(200, "text/plain", String(currentAngle));
} else {
server.send(400, "text/plain", "Missing angle");
}
}
void handleGetAngle() {
server.send(200, "text/plain", String(currentAngle));
}
void setup() {
Serial.begin(115200);
// Attach servo to ESP32 PWM channel
myServo.attach(servoPin, 1000, 2000); // 1ms-2ms pulse range
myServo.write(currentAngle); // Move to center on boot
// Connect to WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(WiFi.localIP());
// Web server routes
server.on("/", handleRoot);
server.on("/set", handleSetAngle);
server.on("/angle", handleGetAngle);
server.begin();
}
void loop() {
server.handleClient();
}
Key lines explained:
myServo.attach(servoPin, 1000, 2000) -- The third and fourth arguments set the minimum and maximum pulse width in microseconds. 1000us = 1ms (0 degrees), 2000us = 2ms (180 degrees). Some servos need calibration. If your servo buzzes at 0 degrees or 180 degrees, try 900-2100us range instead.
myServo.write(targetAngle) -- Takes degrees directly. The library handles the pulse width math internally.
constrain(targetAngle, 0, 180) -- Safety clamp. Never let the servo try to go beyond its physical range.
How the Web Control Works
The slider sends a GET request to /set?angle=120. The ESP32 parses it, constrains it to 0-180, writes it to the servo, and responds with the actual angle set.
Every 500ms, the page polls /angle to sync the slider position with whatever the ESP32 actually has set.
This is the same architecture as Lesson 04's LED toggle -- HTTP requests, ESP32 handles them, hardware responds. Just a different output device.
Closed-Loop Control: Why Servos Hold Position
DC motors are open-loop: you apply voltage, they spin, you hope they go where you want.
Servo motors are closed-loop: the internal circuit reads the potentiometer, compares to target, drives motor, reads again. This feedback loop runs at the hardware level, faster than any code you could write.
The result: if you push the servo arm while it is holding position, the internal controller detects the error, drives more current into the motor, and resists the force. This is why robot arms use servos -- they must hold position against gravity and external forces.
Common Problems
Servo jitters or oscillates at the target position:
This is usually a PID overtuning artifact, especially on cheap servos. The internal controller overshoots, corrects, overshoots the other way. Fix: reduce the pulse width range slightly (try 1100-1900us instead of 1000-2000us) to reduce the control authority.
Servo does not move at all:
Check the brown ground connection between battery pack negative, servo negative, and ESP32 ground. Without shared ground, the signal reference is floating and the servo cannot read the PWM correctly.
Servo gets very hot:
Continuous torque against a stall causes heat buildup. Normal operating temperature is warm but not painful to touch. If it is too hot to hold, the servo is overloaded -- reduce the load or use a higher torque servo.
ESP32 resets when servo moves:
Brownout. The servo draws too much current from the shared supply and the ESP32 voltage drops momentarily. Fix: use a separate battery pack for the servo, never share power rails.
What Just Happened
You connected a servo motor to your ESP32, wrote a web server that accepts angle commands, and built a web slider interface. Move the slider on your phone and the servo arm swings to that exact angle and holds it.
You now have your first robotic joint. One degree of freedom. Controlled from anywhere with WiFi.
This is the same architecture used in robot arms, pan-tilt camera mounts, automated doors, solar trackers, and anywhere precise angular positioning matters.
What's Next
You have one joint. Robot arms have multiple joints working together. In Lesson 07, you will learn how multiple servos coordinate using Inverse Kinematics -- the mathematics that tells you what angle each joint needs to be at to put the robot's hand at a specific position in 3D space.
That is when robotics gets interesting.
Key Concepts Learned
- Servo motors vs DC motors: position control vs speed control
- PWM pulse width maps to angle (1ms = 0 degrees, 2ms = 180 degrees, linear interpolation)
- Servo current demands require separate power supply from ESP32
- Shared ground between ESP32 and servo battery is mandatory
- ESP32Servo library abstracts pulse width math
- Web server architecture: same as LED toggle, different output
Parts List
| Part | Cost | Notes |
|---|---|---|
| ESP32 DevKit | $7-10 | Any ESP32 variant |
| SG90 Micro Servo | $2-3 | 9g, 1.8kg cm torque |
| 4xAA Battery Pack | $3-4 | 6V output, alkaline or NiMH |
| Jumper wires | $2-3 | Male-male and male-female |
| Breadboard (optional) | $5 | For prototyping |
Total: ~$20 for a WiFi-controlled servo