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.
- Add a limit switch and implement a homing routine: move slowly toward the switch, stop when it triggers, then send
zero. - Refuse absolute moves until homed in production firmware, and publish a
homedflag in the progress JSON. - Keep soft limits inside the mechanical travel even after homing.
- Disable outputs when idle if the axis does not need holding torque; it saves power and keeps the driver and motor cool.
- 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.
- 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.



