Servo with a Web Slider: Smooth Control Without Libraries
Control an SG90/MG996R servo from your phone: drag the slider and the shaft turns in real time. PWM comes from the built-in LEDC peripheral — no third-party libraries.
The idea
A servo is the heart of countless DIY builds: a cat feeder, a pan camera, a blind opener, a vent damper. It's driven by 50 Hz PWM pulses: a ~0.5 ms pulse means 0°, ~2.4 ms means 180°.
The ESP32 has a hardware LEDC peripheral that generates this signal with zero CPU load. So we skip libraries entirely — just the arduino-esp32 3.x core.
Parts
- ESP32 DevKit
- SG90 (small) or MG996R (strong, metal gears) servo
- For the MG996R — a separate 5–6 V ≥ 2 A supply
Wiring
| Servo | ESP32 |
|---|---|
| Brown / black (GND) | GND |
| Red (+5 V) | 5V (VIN) — SG90 only |
| Orange / yellow (signal) | GPIO 13 |
A strong servo draws peak currents above 1 A on start-up and the ESP32 will reset. Power it separately and tie the grounds together. A 470–1000 µF capacitor next to the servo helps too.
Code
#include <WiFi.h>
#include <WebServer.h>
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const int SERVO_PIN = 13;
const int PWM_FREQ = 50; // 50 Hz → 20 ms period
const int PWM_BITS = 14; // resolution 0..16383
const int MIN_US = 500; // pulse for 0°
const int MAX_US = 2400; // pulse for 180°
WebServer server(80);
int currentAngle = 90;
void writeAngle(int angle) {
angle = constrain(angle, 0, 180);
int us = map(angle, 0, 180, MIN_US, MAX_US);
uint32_t duty = (uint32_t)us * ((1 << PWM_BITS) - 1) / 20000;
ledcWrite(SERVO_PIN, duty);
currentAngle = angle;
}
const char PAGE[] = R"rawliteral(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Servo</title>
<style>
body{font-family:system-ui;text-align:center;margin:40px 16px}
input{width:100%;max-width:420px;height:40px}
#v{font-size:3rem;font-weight:700}
</style></head><body>
<h2>Servo</h2>
<div id="v">--°</div>
<input id="s" type="range" min="0" max="180">
<script>
const s = document.getElementById('s'), v = document.getElementById('v');
let busy = false, pending = null;
async function send(angle) {
if (busy) { pending = angle; return; }
busy = true;
await fetch('/angle?value=' + angle);
busy = false;
if (pending !== null) { const a = pending; pending = null; send(a); }
}
s.oninput = () => { v.textContent = s.value + '°'; send(s.value); };
fetch('/angle').then(r => r.text()).then(a => { s.value = a; v.textContent = a + '°'; });
</script></body></html>
)rawliteral";
void setup() {
Serial.begin(115200);
ledcAttach(SERVO_PIN, PWM_FREQ, PWM_BITS); // core 3.x API
writeAngle(90);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println(WiFi.localIP());
server.on("/", [] { server.send(200, "text/html", PAGE); });
server.on("/angle", [] {
if (server.hasArg("value")) writeAngle(server.arg("value").toInt());
server.send(200, "text/plain", String(currentAngle));
});
server.begin();
}
void loop() {
server.handleClient();
}
How it works
ledcAttach()sets up hardware PWM on the pin: 50 Hz, 14 bits.writeAngle()converts the angle into a pulse width in microseconds, then into a duty value from 0 to 16383.- The page's JavaScript sends the new angle while you drag, but never faster than the board can answer: while a request is in flight, only the latest value is kept.
Calibration
Servos from different makers have different ranges. If the shaft buzzes and strains at the ends, narrow MIN_US/MAX_US (e.g. 600 and 2300). If it doesn't reach 180°, widen them.
Core 2.x
Older core versions used channels instead of ledcAttach:
ledcSetup(0, PWM_FREQ, PWM_BITS);
ledcAttachPin(SERVO_PIN, 0);
ledcWrite(0, duty); // write to the channel, not the pin
Ideas
- Pet feeder: on a schedule (NTP) rotate 90° and back — a portion of food drops out.
- Pan-tilt for an ESP32-CAM: two servos and two sliders.
- Smooth motion: step the angle by 1° every 15 ms instead of jumping.