ESP32 and Arduino

ESP32 Stepper Motor MQTT: Position Control with Live Progress Updates

Drive a NEMA 17 stepper from MQTT position commands with an ESP32, AccelStepper and an A4988 or DRV8825 driver, and publish live progress so apps know where the motor is.

NEMA 17 motor with an A4988 driver and ESP32 board in an esp32 stepper motor mqtt setup
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 stepper motor MQTT controller lets you position a camera slider, a dosing pump, a blind or a small linear axis from anywhere on the network. In this tutorial you will drive a NEMA 17 motor through an A4988 or DRV8825 driver with the AccelStepper library, accept absolute and relative moves over MQTT, and publish live progress while the motor runs. The difficult part is not MQTT but keeping step pulses smooth while the network does its work, and the sketch solves that by running the motor on its own core.

Commands, Topics and Progress Reports

Stepper motors are open-loop: the ESP32 counts the steps it sends, and as long as the motor does not stall, that count is the position. That makes them ideal for MQTT control because the device can report position accurately without an encoder.

Topic Direction Retained Payload
connectmq/demo/<id>/stepper/cmd App to ESP32 No JSON command, see below
connectmq/demo/<id>/stepper/progress ESP32 to apps Yes {"pos":1200,"target":3200,"pct":37,"moving":true}
connectmq/demo/<id>/status ESP32 to apps Yes online / offline
Command Example Effect
Absolute move {"cmd":"moveTo","pos":3200} Move to step 3200
Relative move {"cmd":"move","steps":-400} Move 400 steps back
Speed {"cmd":"speed","max":1500,"accel":800} Max steps/s and steps/s²
Stop {"cmd":"stop"} Decelerate to a controlled stop
Zero {"cmd":"zero"} Declare current position as 0
Release {"cmd":"disable"} Turn off coil current

With a typical 1.8-degree motor and 1/16 microstepping, one revolution is 3200 steps. On a lead screw with 8 mm per revolution, that gives 400 steps per millimeter, which is a useful conversion to put in your app.

Hardware

  • ESP32 development board.
  • NEMA 17 stepper motor (bipolar, four wires).
  • A4988 or DRV8825 driver module, ideally on a carrier board with screw terminals.
  • 12 V to 24 V supply suitable for the motor, plus a 100 µF capacitor across the driver's motor supply input.
Driver pin Connect to
STEP ESP32 GPIO 25
DIR ESP32 GPIO 26
EN ESP32 GPIO 27 (low = enabled)
MS1, MS2, MS3 (A4988) / M0, M1, M2 (DRV8825) Tie high for 1/16 (A4988) or 1/32 (DRV8825) microstepping
VDD / GND (logic) ESP32 3V3 / GND
VMOT / GND Motor supply
1A, 1B, 2A, 2B Motor coils
RESET + SLEEP Bridge together

Set the current limit with the driver's trimmer potentiometer before you connect the motor for a long run. Too much current overheats the driver and the motor; too little loses steps. Never connect or disconnect the motor while the driver is powered, because the inductive spike can destroy the driver instantly.

Libraries

  • PubSubClient by Nick O'Leary.
  • AccelStepper by Mike McCauley, which generates acceleration profiles and non-blocking step pulses.
  • ArduinoJson version 7.

Why the Motor Runs on Its Own Task

AccelStepper does not use a hardware timer. You must call stepper.run() as often as possible, and each call emits at most one step when one is due. At 2000 steps per second, a step is due every half millisecond. If mqtt.loop() blocks for a few milliseconds while Wi-Fi is busy, or a reconnect blocks for seconds, the motor stutters or stops mid-move.

The ESP32 has two cores. Arduino's loop() runs on core 1 and the Wi-Fi stack mainly on core 0. The sketch creates a FreeRTOS task pinned to core 0 at low priority that does nothing but call run(), while loop() handles MQTT. A mutex-free design keeps it simple: the MQTT side writes "pending" requests into volatile variables, and the motor task picks them up. For a deeper look at this pattern, see ESP32 FreeRTOS tasks with MQTT.

The Complete ESP32 Stepper Motor MQTT Sketch

#include <WiFi.h>
#include <PubSubClient.h>
#include <AccelStepper.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     = "stepper-a19c44";
const String BASE = String("connectmq/demo/") + DEVICE_ID;

const int STEP_PIN = 25, DIR_PIN = 26, EN_PIN = 27;
const long SOFT_MIN = 0, SOFT_MAX = 32000;          // travel limits in steps

AccelStepper stepper(AccelStepper::DRIVER, STEP_PIN, DIR_PIN);
WiFiClient net;
PubSubClient mqtt(net);

// Requests from the MQTT side to the motor task
volatile bool reqMove = false, reqStop = false, reqZero = false;
volatile bool reqEnable = false, reqDisable = false;
volatile long reqTarget = 0;
volatile float reqMaxSpeed = 1000, reqAccel = 500;
volatile bool reqSpeed = true;

// Snapshot from the motor task for reporting
volatile long curPos = 0, curTarget = 0;
volatile bool moving = false;
long moveStart = 0;
unsigned long lastReport = 0, lastAttempt = 0;
bool wasMoving = false;

void motorTask(void*) {
  for (;;) {
    if (reqSpeed)   { stepper.setMaxSpeed(reqMaxSpeed); stepper.setAcceleration(reqAccel); reqSpeed = false; }
    if (reqEnable)  { stepper.enableOutputs(); reqEnable = false; }
    if (reqMove)    { stepper.moveTo(reqTarget); reqMove = false; }
    if (reqStop)    { stepper.stop(); reqStop = false; }          // decelerates
    if (reqZero)    { stepper.setCurrentPosition(0); reqZero = false; }
    if (reqDisable) { stepper.stop(); stepper.setCurrentPosition(stepper.currentPosition()); stepper.disableOutputs(); reqDisable = false; }

    stepper.run();
    curPos = stepper.currentPosition();
    curTarget = stepper.targetPosition();
    moving = stepper.distanceToGo() != 0;
    if (!moving) vTaskDelay(1);                        // yield when idle
  }
}

void publishProgress(bool force) {
  if (!force && millis() - lastReport < 250) return;
  lastReport = millis();
  long total = labs(curTarget - moveStart);
  int pct = total == 0 ? 100 : (int)(100 * labs(curPos - moveStart) / total);
  char buf[128];
  snprintf(buf, sizeof(buf), "{\"pos\":%ld,\"target\":%ld,\"pct\":%d,\"moving\":%s}",
           curPos, curTarget, constrain(pct, 0, 100), moving ? "true" : "false");
  mqtt.publish((BASE + "/stepper/progress").c_str(), buf, true);
}

void requestMove(long target) {
  target = constrain(target, SOFT_MIN, SOFT_MAX);
  moveStart = curPos;
  reqTarget = target;
  reqEnable = true;
  reqMove = true;
}

void onMessage(char* topic, byte* payload, unsigned int length) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, length)) return;
  const char* cmd = doc["cmd"] | "";

  if (!strcmp(cmd, "moveTo") && doc["pos"].is<long>())        requestMove(doc["pos"].as<long>());
  else if (!strcmp(cmd, "move") && doc["steps"].is<long>())   requestMove(curPos + doc["steps"].as<long>());
  else if (!strcmp(cmd, "stop"))                              reqStop = true;
  else if (!strcmp(cmd, "zero") && !moving)                   reqZero = true;
  else if (!strcmp(cmd, "disable"))                           reqDisable = true;
  else if (!strcmp(cmd, "speed")) {
    reqMaxSpeed = constrain(doc["max"] | 1000.0f, 50.0f, 4000.0f);
    reqAccel = constrain(doc["accel"] | 500.0f, 50.0f, 10000.0f);
    reqSpeed = true;
  } else return;
  Serial.printf("cmd=%s\n", cmd);
}

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 + "/stepper/cmd").c_str(), 1);
  publishProgress(true);
  return true;
}

void setup() {
  Serial.begin(115200);
  stepper.setEnablePin(EN_PIN);
  stepper.setPinsInverted(false, false, true);        // EN is active-low
  stepper.disableOutputs();
  disableCore0WDT();                                  // motor task keeps core 0 busy during moves
  xTaskCreatePinnedToCore(motorTask, "motor", 4096, nullptr, 1, nullptr, 0);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(onMessage);
  mqtt.setSocketTimeout(5);
}

void loop() {
  if (WiFi.status() == WL_CONNECTED) {
    if (!mqtt.connected()) {
      if (millis() - lastAttempt > 5000) { lastAttempt = millis(); connectMqtt(); }
    } else {
      mqtt.loop();
      if (moving) { publishProgress(false); wasMoving = true; }
      else if (wasMoving) { publishProgress(true); wasMoving = false; }   // final report
    }
  }
  delay(2);
}

A few details worth noting. The motor task yields with vTaskDelay(1) only when idle, so during a move it runs flat out on core 0; at priority 1 it still lets the Wi-Fi tasks, which run at higher priority, do their work. Because the busy task starves the core 0 idle task during long moves, disableCore0WDT() turns off the idle-task watchdog check for that core; without it you would see task watchdog warnings on moves that last several seconds. The soft limits clamp every target, so a mistyped "pos":320000 cannot drive a carriage into the frame. And the final progress report with "moving":false is always sent, which gives apps a clean "move finished" signal.

Run a Move and Watch Progress

mosquitto_sub -h broker.connectmq.com -p 1883 -i watch-a19c44 -v -t "connectmq/demo/stepper-a19c44/#"
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-a19c44 -t "connectmq/demo/stepper-a19c44/stepper/cmd" -m '{"cmd":"speed","max":1600,"accel":1200}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-a19c44 -t "connectmq/demo/stepper-a19c44/stepper/cmd" -m '{"cmd":"moveTo","pos":6400}'

The subscriber prints progress four times per second until the move completes:

connectmq/demo/stepper-a19c44/stepper/progress {"pos":312,"target":6400,"pct":4,"moving":true}
connectmq/demo/stepper-a19c44/stepper/progress {"pos":2101,"target":6400,"pct":32,"moving":true}
connectmq/demo/stepper-a19c44/stepper/progress {"pos":6400,"target":6400,"pct":100,"moving":false}

Send {"cmd":"stop"} mid-move and the motor decelerates smoothly using the configured acceleration instead of stopping dead, which protects belts and couplings. The four-per-second rate keeps you comfortably below the 10 messages per second allowed per client on the free public MQTT broker.

Homing and Safety Checklist

Open-loop position is only as good as the reference point. After power-up the ESP32 has no idea where the carriage is, so the reported position starts at 0 wherever the motor happens to be.

  1. Add a limit switch and implement a homing routine: move slowly toward the switch, stop when it triggers, then send zero.
  2. Refuse absolute moves until homed in production firmware, and publish a homed flag in the progress JSON.
  3. Keep soft limits inside the mechanical travel even after homing.
  4. Disable outputs when idle if the axis does not need holding torque; it saves power and keeps the driver and motor cool.
  5. Treat a lost connection as a stop condition for anything that could hurt someone. Combine the Last Will with local logic that halts motion if commands stop arriving.
  6. Never rely on MQTT alone for emergency stops. A hardware E-stop that cuts motor power is the only acceptable safety device.

Troubleshooting

  • Motor hums but does not turn. Coil pairs are miswired, or acceleration is too high for the load. Check pairs with a multimeter and halve accel.
  • Motor turns but misses steps at higher speeds. Raise the supply voltage within driver limits, adjust the current limit, or reduce max.
  • Stutter when MQTT messages arrive. The motor is running in loop() instead of its own task, or the task is pinned to the same core as heavy work.
  • Driver gets very hot. Current limit too high; add a heatsink and reduce it.
  • Position drifts after many moves. Lost steps from stalls; there is no feedback, so home periodically.

Production: Credentials, TLS and Monitoring

Motion devices deserve private credentials and encryption. Create a broker in the ConnectMQ portal, use the generated username, password and client ID, and switch to WiFiClientSecure on port 8883; the ESP32 MQTT over SSL/TLS guide covers the certificate setup. A hosted MQTT broker such as ConnectMQ also gives you a live topic explorer to follow stepper/progress while you tune speeds, and threshold alert rules you can point at numeric telemetry such as driver temperature if you publish it as its own topic. For simpler actuators, the same command and state pattern appears in ESP32 servo control over MQTT, and the ESP32 IoT tutorial is the starting point if this is your first ESP32 project.

Get Started with ConnectMQ

Create a free Serverless broker at app.connectmq.com, no credit card required. You get TLS credentials in a minute, an in-browser client for sending move commands, and a live view of your motor's progress.

Frequently Asked Questions

Why does my stepper stutter when the ESP32 receives MQTT messages?

AccelStepper needs run() called very frequently. When it shares loop() with networking, any delay causes missed steps. Run the motor in a separate FreeRTOS task, as shown above, or use a timer-based stepper library.

Can I control a 28BYJ-48 motor with this sketch?

Yes, with a ULN2003 board. Construct AccelStepper with AccelStepper::HALF4WIRE and four pins (IN1, IN3, IN2, IN4 order) instead of DRIVER; the MQTT part stays the same.

How accurate is the reported position?

It is the exact step count the ESP32 has sent. It matches the real position as long as the motor never stalls; homing against a switch removes any accumulated error.

Which QoS should I use for motor commands?

Use QoS 1 for commands. Absolute moves (moveTo) are safe to receive twice, while a duplicated relative move would move twice, so prefer absolute positions when delivery may be repeated.

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.