ESP32 and Arduino

ESP32 Servo MQTT: Control a Servo Remotely with Position Feedback

Move a servo from MQTT commands with an ESP32 and the ESP32Servo library, ramp it smoothly to the target angle and publish position feedback that dashboards can display.

SG90 servo connected to an ESP32 board receiving angle commands in an esp32 servo mqtt build
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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:

  1. Remove any linkage so the horn can turn freely.
  2. Temporarily set MIN_ANGLE to 0 and MAX_ANGLE to 180, then send 0 and listen. If the servo buzzes or keeps pushing at the end, raise MIN_US by 50 and try again until it rests quietly.
  3. Repeat at 180, lowering MAX_US until the end position is quiet.
  4. Send 90 and 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.
  5. 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. Set MIN_ANGLE and MAX_ANGLE a 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

  1. 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.
  2. ESP32 reboots when the servo moves. Brownout. Use a separate supply for the servo with a common ground.
  3. Range is only about 90 degrees. The servo expects a wider pulse range. Adjust MIN_US and MAX_US in small steps; many servos use roughly 500 to 2500 microseconds, but going beyond the servo's real end stops makes it buzz and strain.
  4. 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 blocking delay() anywhere in loop() breaks both the ramp and MQTT.
  5. Commands ignored. The JSON must contain an integer angle. Strings such as "angle":"90" are rejected on purpose.
  6. Two servos interfere. Each needs its own pin; ESP32Servo allocates PWM channels automatically, but call allocateTimer for 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.

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.