An ESP32 servo MQTT project turns a cheap hobby servo into a remotely controlled actuator for blinds, valves, camera pan heads or pet feeders. In this tutorial you will move a servo from MQTT angle commands, ramp it smoothly instead of snapping, enforce mechanical limits in firmware, and publish position feedback that any dashboard can read. You can try every step against broker.connectmq.com without an account.
What You Will Build
The ESP32 listens on one topic for commands and reports progress on another. Commands can be a bare angle or a small JSON object with a speed:
| Topic | Direction | Retained | Example payload |
|---|---|---|---|
connectmq/demo/<id>/servo/set |
App to ESP32 | No | 90 or {"angle":45,"speed":60} |
connectmq/demo/<id>/servo/position |
ESP32 to apps | Yes | {"angle":72,"target":45,"moving":true} |
connectmq/demo/<id>/status |
ESP32 to apps | Yes | online / offline |
One honest limitation up front: a standard hobby servo does not report its real shaft position. The feedback published here is the angle the firmware has commanded, updated as the servo ramps toward the target. That is enough for dashboards and for confirming that a command was accepted. If you need true measured position, you need a servo with a feedback wire or an external encoder, which is a different project.
Hardware and Power
- ESP32 development board.
- A hobby servo such as an SG90 (micro, plastic gears) or MG996R (standard size, metal gears).
- A separate 5 V supply for anything bigger than a micro servo, rated for at least the servo's stall current.
- A 470 µF to 1000 µF electrolytic capacitor across the servo supply, close to the servo.
| Servo wire | Connect to |
|---|---|
| Brown / black (GND) | Supply GND and ESP32 GND |
| Red (V+) | 5 V supply |
| Orange / yellow (signal) | ESP32 GPIO 18 |
The shared ground is not optional: without it the signal has no reference and the servo twitches randomly. Powering an MG996R from the ESP32's 5 V pin is the most common cause of brownout resets in servo projects, because the servo draws a large current spike every time it starts moving. The 3.3 V signal from the ESP32 is accepted by almost all hobby servos.
Libraries
Install the esp32 board package and two libraries from the Arduino Library Manager:
- PubSubClient by Nick O'Leary, for MQTT.
- ESP32Servo by Kevin Harrington and John K. Bennett, which drives servos through the ESP32's LEDC PWM hardware.
- ArduinoJson by Benoit Blanchon (version 7), to parse the JSON command format.
The regular Arduino Servo library does not support the ESP32, so ESP32Servo is the right choice. New to MQTT on this board? Start with the ESP32 IoT tutorial, which covers board setup and a first publish.
The ESP32 Servo MQTT Sketch
#include <WiFi.h>
#include <PubSubClient.h>
#include <ESP32Servo.h>
#include <ArduinoJson.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* DEVICE_ID = "servo-4c81d2"; // unique per device
const String BASE = String("connectmq/demo/") + DEVICE_ID;
const int SERVO_PIN = 18;
const int MIN_ANGLE = 10; // mechanical limits of YOUR linkage
const int MAX_ANGLE = 170;
const int MIN_US = 500; // pulse width for 0 degrees
const int MAX_US = 2400; // pulse width for 180 degrees
Servo servo;
WiFiClient net;
PubSubClient mqtt(net);
float currentAngle = 90;
int targetAngle = 90;
float speedDegPerSec = 90; // default ramp speed
unsigned long lastStep = 0, lastReport = 0, lastAttempt = 0;
bool wasMoving = false;
void publishPosition(bool force) {
bool moving = (int)round(currentAngle) != targetAngle;
if (!force && millis() - lastReport < 250) return;
lastReport = millis();
char buf[96];
snprintf(buf, sizeof(buf), "{\"angle\":%d,\"target\":%d,\"moving\":%s}",
(int)round(currentAngle), targetAngle, moving ? "true" : "false");
String topic = BASE + "/servo/position";
mqtt.publish(topic.c_str(), buf, true);
}
void onMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument doc;
int angle;
if (deserializeJson(doc, payload, length) == DeserializationError::Ok && doc.is<JsonObject>()) {
if (!doc["angle"].is<int>()) return;
angle = doc["angle"];
if (doc["speed"].is<float>()) speedDegPerSec = constrain(doc["speed"].as<float>(), 5.0f, 600.0f);
} else if (doc.is<int>()) {
angle = doc.as<int>(); // bare number like "90"
} else {
return;
}
targetAngle = constrain(angle, MIN_ANGLE, MAX_ANGLE);
if (!servo.attached()) servo.attach(SERVO_PIN, MIN_US, MAX_US);
Serial.printf("Target %d deg at %.0f deg/s\n", targetAngle, speedDegPerSec);
publishPosition(true);
}
bool connectMqtt() {
String status = BASE + "/status";
if (!mqtt.connect(DEVICE_ID, nullptr, nullptr, status.c_str(), 1, true, "offline")) {
Serial.printf("MQTT failed, state=%d\n", mqtt.state());
return false;
}
mqtt.publish(status.c_str(), "online", true);
mqtt.subscribe((BASE + "/servo/set").c_str(), 1);
publishPosition(true);
return true;
}
void setup() {
Serial.begin(115200);
ESP32PWM::allocateTimer(0);
servo.setPeriodHertz(50); // standard servo frame
servo.attach(SERVO_PIN, MIN_US, MAX_US);
servo.write((int)currentAngle);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setSocketTimeout(5);
}
void stepServo() {
unsigned long now = millis();
float dt = (now - lastStep) / 1000.0f;
lastStep = now;
float diff = targetAngle - currentAngle;
if (fabs(diff) < 0.5f) {
currentAngle = targetAngle;
if (wasMoving) {
servo.write(targetAngle);
publishPosition(true); // final position report
wasMoving = false;
}
return;
}
float stepSize = speedDegPerSec * dt;
currentAngle += constrain(diff, -stepSize, stepSize);
servo.write((int)round(currentAngle));
wasMoving = true;
publishPosition(false); // throttled progress
}
void loop() {
stepServo();
if (WiFi.status() != WL_CONNECTED) return;
if (!mqtt.connected()) {
if (millis() - lastAttempt > 5000) { lastAttempt = millis(); connectMqtt(); }
return;
}
mqtt.loop();
}
Why ramp instead of jumping
Calling servo.write(170) directly makes the servo slam to the new angle at full speed. On a linkage that moves a valve or a lid, that is hard on the plastic gears and pulls a big current spike from the supply. The stepServo() function moves the commanded angle a little on every loop, based on elapsed time, so the speed stays constant regardless of how fast loop() runs. A speed of 60 degrees per second looks deliberate; 300 feels snappy.
Why the limits live in firmware
MIN_ANGLE and MAX_ANGLE should match the physical travel of whatever the servo drives. If an app sends 0 and your linkage binds at 8 degrees, the servo stalls, heats up and draws its stall current until something gives. Clamping on the device means a typo in a dashboard can never damage the hardware. Treat every MQTT command as untrusted input.
Send Commands and Watch Feedback
Subscribe to the device prefix:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-4c81d2 -v -t "connectmq/demo/servo-4c81d2/#"
Then move the servo:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-4c81d2 -t "connectmq/demo/servo-4c81d2/servo/set" -m 150
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-4c81d2 -t "connectmq/demo/servo-4c81d2/servo/set" -m '{"angle":30,"speed":40}'
You will see the position stream while the servo moves, ending with "moving":false:
connectmq/demo/servo-4c81d2/servo/position {"angle":90,"target":150,"moving":true}
connectmq/demo/servo-4c81d2/servo/position {"angle":113,"target":150,"moving":true}
connectmq/demo/servo-4c81d2/servo/position {"angle":150,"target":150,"moving":false}
The progress reports are throttled to four per second. That keeps you well inside the public broker's limit of 10 messages per second per client, and it is plenty for a smooth dashboard gauge. Remember that the free public MQTT broker is readable by everyone, so it is a test bench, not a place for a servo that opens your front door.
Calibrate the Servo Before You Trust It
Servos from different manufacturers, and even from different batches of the same model, map pulse width to angle slightly differently. Spend five minutes calibrating before you build a bracket around one:
- Remove any linkage so the horn can turn freely.
- Temporarily set
MIN_ANGLEto 0 andMAX_ANGLEto 180, then send0and listen. If the servo buzzes or keeps pushing at the end, raiseMIN_USby 50 and try again until it rests quietly. - Repeat at
180, loweringMAX_USuntil the end position is quiet. - Send
90and check that the horn points where you expect "center" to be. If not, re-seat the horn on the spline rather than compensating in software. - Attach the linkage, move slowly toward each end with a low speed such as
{"angle":20,"speed":15}, and note where it starts to bind. SetMIN_ANGLEandMAX_ANGLEa few degrees inside those points.
Write the final values in a comment next to the constants. When you replace a broken servo a year later, you will know which numbers to recheck. Calibration is also why the feedback topic reports the commanded angle honestly instead of pretending to measure it: the number means "where the firmware told the servo to go," and calibration is what makes that number match reality.
Troubleshooting
- Servo jitters at rest. Usually power noise. Add the bulk capacitor, shorten servo wires, and keep the signal wire away from motor leads. If the servo holds no load, you can call
servo.detach()once it reaches the target to stop the pulses entirely; the sketch re-attaches on the next command. - ESP32 reboots when the servo moves. Brownout. Use a separate supply for the servo with a common ground.
- Range is only about 90 degrees. The servo expects a wider pulse range. Adjust
MIN_USandMAX_USin small steps; many servos use roughly 500 to 2500 microseconds, but going beyond the servo's real end stops makes it buzz and strain. - Servo moves but feedback never arrives. Check that publishes are not larger than PubSubClient's default buffer and that
mqtt.loop()runs often enough. A blockingdelay()anywhere inloop()breaks both the ramp and MQTT. - Commands ignored. The JSON must contain an integer
angle. Strings such as"angle":"90"are rejected on purpose. - Two servos interfere. Each needs its own pin;
ESP32Servoallocates PWM channels automatically, but callallocateTimerfor the timers you plan to use.
From Demo to a Real Deployment
Once the servo works, move it off the public area. Create a broker in the ConnectMQ portal, copy the generated username, password and client ID, and connect over TLS on port 8883 using WiFiClientSecure. The ESP32 MQTT SSL guide shows the certificate setup step by step.
For anything that moves, availability matters. A managed MQTT broker such as ConnectMQ gives every device its own credentials and lets you watch servo/position in the portal's live topic explorer while you tune speeds. The same command and state pattern carries straight over to relay control with ESP32 and MQTT and to stepper motors driven over MQTT, which is the next step when you need more torque or continuous rotation with precise positioning.
Get Started with ConnectMQ
Sign up at app.connectmq.com and create a free Serverless broker, no credit card needed. You get TLS credentials immediately, an in-browser MQTT client for sending servo commands, and a dashboard to watch position feedback live.
Frequently Asked Questions
Does a hobby servo report its real position over MQTT?
No. A standard servo has no position output, so the ESP32 reports the angle it commanded. For measured position, use a feedback servo or add an encoder or potentiometer read by the ESP32's ADC.
Which ESP32 pins can drive a servo?
Any output-capable GPIO works because the PWM comes from the LEDC peripheral. Avoid input-only pins 34 to 39 and strapping pins such as 0, 2, 12 and 15.
How many servos can one ESP32 control over MQTT?
The original ESP32 has 16 LEDC channels (newer variants such as the S3 have fewer), so many servos are possible in principle; power supply and wiring are the real limit. Use one topic per servo, for example servo/1/set and servo/2/set.
Should servo commands use QoS 1?
Yes, QoS 1 is a sensible default. An absolute angle command is idempotent, so a duplicate delivery simply sets the same target again.



