Un controlador de motor paso a paso ESP32 MQTT te permite posicionar un slider de cámara, una bomba dosificadora, una persiana o un pequeño eje lineal desde cualquier punto de la red. En este tutorial controlarás un motor NEMA 17 mediante un driver A4988 o DRV8825 con la librería AccelStepper, aceptarás movimientos absolutos y relativos por MQTT y publicarás el progreso en vivo mientras el motor gira. Lo difícil no es MQTT, sino mantener los pulsos de paso fluidos mientras la red hace su trabajo, y el sketch lo resuelve ejecutando el motor en su propio núcleo.
Comandos, topics e informes de progreso
Los motores paso a paso funcionan en lazo abierto: el ESP32 cuenta los pasos que envía y, mientras el motor no se bloquee, esa cuenta es la posición. Eso los hace ideales para el control por MQTT, porque el dispositivo puede informar de la posición con precisión sin encoder.
| Topic | Dirección | Retenido | Payload |
|---|---|---|---|
connectmq/demo/<id>/stepper/cmd |
De la app al ESP32 | No | Comando JSON, ver abajo |
connectmq/demo/<id>/stepper/progress |
Del ESP32 a las apps | Sí | {"pos":1200,"target":3200,"pct":37,"moving":true} |
connectmq/demo/<id>/status |
Del ESP32 a las apps | Sí | online / offline |
| Comando | Ejemplo | Efecto |
|---|---|---|
| Movimiento absoluto | {"cmd":"moveTo","pos":3200} |
Mueve al paso 3200 |
| Movimiento relativo | {"cmd":"move","steps":-400} |
Retrocede 400 pasos |
| Velocidad | {"cmd":"speed","max":1500,"accel":800} |
Pasos/s máximos y pasos/s² |
| Parada | {"cmd":"stop"} |
Decelera hasta una parada controlada |
| Cero | {"cmd":"zero"} |
Declara la posición actual como 0 |
| Liberar | {"cmd":"disable"} |
Corta la corriente de las bobinas |
Con un motor típico de 1,8 grados y micropasos de 1/16, una vuelta son 3200 pasos. En un husillo de 8 mm por vuelta, eso da 400 pasos por milímetro, una conversión útil para incluir en tu app.
Hardware
- Placa de desarrollo ESP32.
- Motor paso a paso NEMA 17 (bipolar, cuatro cables).
- Módulo driver A4988 o DRV8825, idealmente sobre una placa portadora con bornes de tornillo.
- Fuente de 12 V a 24 V adecuada para el motor, más un condensador de 100 µF en la entrada de alimentación del motor del driver.
| Pin del driver | Conectar a |
|---|---|
| STEP | GPIO 25 del ESP32 |
| DIR | GPIO 26 del ESP32 |
| EN | GPIO 27 del ESP32 (bajo = habilitado) |
| MS1, MS2, MS3 (A4988) / M0, M1, M2 (DRV8825) | A nivel alto para micropasos de 1/16 (A4988) o 1/32 (DRV8825) |
| VDD / GND (lógica) | 3V3 / GND del ESP32 |
| VMOT / GND | Alimentación del motor |
| 1A, 1B, 2A, 2B | Bobinas del motor |
| RESET + SLEEP | Puenteados entre sí |
Ajusta el límite de corriente con el potenciómetro del driver antes de conectar el motor para un uso prolongado. Demasiada corriente sobrecalienta el driver y el motor; demasiado poca hace que se pierdan pasos. Nunca conectes ni desconectes el motor con el driver alimentado, porque el pico inductivo puede destruir el driver al instante.
Librerías
- PubSubClient de Nick O'Leary.
- AccelStepper de Mike McCauley, que genera perfiles de aceleración y pulsos de paso sin bloqueos.
- ArduinoJson versión 7.
Por qué el motor se ejecuta en su propia tarea
AccelStepper no usa un timer hardware. Debes llamar a stepper.run() tan a menudo como sea posible, y cada llamada emite como mucho un paso cuando toca. A 2000 pasos por segundo, toca un paso cada medio milisegundo. Si mqtt.loop() se bloquea unos milisegundos mientras el Wi-Fi está ocupado, o una reconexión se bloquea durante segundos, el motor se entrecorta o se detiene a mitad de movimiento.
El ESP32 tiene dos núcleos. El loop() de Arduino se ejecuta en el núcleo 1 y la pila Wi-Fi principalmente en el núcleo 0. El sketch crea una tarea de FreeRTOS fijada al núcleo 0 con prioridad baja que no hace otra cosa que llamar a run(), mientras loop() gestiona MQTT. Un diseño sin mutex lo mantiene sencillo: el lado MQTT escribe peticiones "pendientes" en variables volatile y la tarea del motor las recoge. Para profundizar en este patrón, consulta tareas de FreeRTOS en ESP32 con MQTT.
El sketch completo de motor paso a paso ESP32 MQTT
#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; // límites de recorrido en pasos
AccelStepper stepper(AccelStepper::DRIVER, STEP_PIN, DIR_PIN);
WiFiClient net;
PubSubClient mqtt(net);
// Peticiones del lado MQTT a la tarea del motor
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;
// Instantánea de la tarea del motor para los informes
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; } // decelera
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); // cede el control en reposo
}
}
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 es activo a nivel bajo
stepper.disableOutputs();
disableCore0WDT(); // la tarea del motor mantiene ocupado el núcleo 0 durante los movimientos
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; } // informe final
}
}
delay(2);
}
Merece la pena destacar algunos detalles. La tarea del motor solo cede el control con vTaskDelay(1) cuando está en reposo, así que durante un movimiento funciona a pleno rendimiento en el núcleo 0; con prioridad 1 sigue dejando trabajar a las tareas del Wi-Fi, que tienen mayor prioridad. Como la tarea ocupada priva de tiempo a la tarea idle del núcleo 0 durante los movimientos largos, disableCore0WDT() desactiva la comprobación del watchdog de la tarea idle en ese núcleo; sin ello verías avisos del watchdog de tareas en movimientos que duren varios segundos. Los límites por software restringen cada objetivo, así que un "pos":320000 mal escrito no puede lanzar un carro contra el bastidor. Y el informe de progreso final con "moving":false se envía siempre, lo que da a las apps una señal clara de "movimiento terminado".
Ejecuta un movimiento y observa el progreso
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}'
El suscriptor imprime el progreso cuatro veces por segundo hasta que termina el movimiento:
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}
Envía {"cmd":"stop"} a mitad de movimiento y el motor decelerará suavemente con la aceleración configurada en lugar de pararse en seco, lo que protege correas y acoplamientos. El ritmo de cuatro por segundo te mantiene cómodamente por debajo de los 10 mensajes por segundo permitidos por cliente en el servidor MQTT público gratuito.
Homing y lista de seguridad
En lazo abierto, la posición solo es tan buena como el punto de referencia. Tras el encendido, el ESP32 no tiene ni idea de dónde está el carro, así que la posición informada empieza en 0 dondequiera que esté el motor.
- Añade un final de carrera e implementa una rutina de homing: muévete despacio hacia el interruptor, detente cuando se active y envía
zero. - Rechaza los movimientos absolutos hasta hacer el homing en el firmware de producción y publica un flag
homeden el JSON de progreso. - Mantén los límites por software dentro del recorrido mecánico incluso después del homing.
- Desactiva las salidas en reposo si el eje no necesita par de retención; ahorra energía y mantiene fríos el driver y el motor.
- Trata una conexión perdida como condición de parada en todo lo que pueda herir a alguien. Combina el Last Will con lógica local que detenga el movimiento si dejan de llegar comandos.
- Nunca dependas solo de MQTT para las paradas de emergencia. Un pulsador físico de paro de emergencia que corte la alimentación del motor es el único dispositivo de seguridad aceptable.
Solución de problemas
- El motor zumba pero no gira. Los pares de bobinas están mal conectados o la aceleración es demasiado alta para la carga. Comprueba los pares con un multímetro y reduce
accela la mitad. - El motor gira pero pierde pasos a velocidades altas. Sube la tensión de alimentación dentro de los límites del driver, ajusta el límite de corriente o reduce
max. - Tirones cuando llegan mensajes MQTT. El motor se está ejecutando en
loop()en lugar de en su propia tarea, o la tarea está fijada al mismo núcleo que un trabajo pesado. - El driver se calienta mucho. Límite de corriente demasiado alto; añade un disipador y redúcelo.
- La posición deriva tras muchos movimientos. Pasos perdidos por bloqueos; no hay realimentación, así que haz homing periódicamente.
Producción: credenciales, TLS y monitorización
Los dispositivos de movimiento merecen credenciales privadas y cifrado. Crea un broker en el portal de ConnectMQ, usa el usuario, la contraseña y el client ID generados y cambia a WiFiClientSecure en el puerto 8883; la guía de ESP32 MQTT sobre SSL/TLS cubre la configuración del certificado. Un broker MQTT gestionado como ConnectMQ también te ofrece un explorador de topics en vivo para seguir stepper/progress mientras ajustas velocidades, y reglas de alerta por umbral que puedes aplicar a telemetría numérica, como la temperatura del driver, si la publicas en su propio topic. Para actuadores más sencillos, el mismo patrón de comando y estado aparece en el control de servos con ESP32 por MQTT, y el tutorial de IoT con ESP32 es el punto de partida si este es tu primer proyecto con ESP32.
Empieza con ConnectMQ
Crea un broker Serverless gratuito en app.connectmq.com, sin tarjeta de crédito. Obtienes credenciales TLS en un minuto, un cliente en el navegador para enviar comandos de movimiento y una vista en vivo del progreso de tu motor.
Preguntas frecuentes
¿Por qué mi motor paso a paso da tirones cuando el ESP32 recibe mensajes MQTT?
AccelStepper necesita que se llame a run() con mucha frecuencia. Cuando comparte loop() con la red, cualquier retraso provoca pasos perdidos. Ejecuta el motor en una tarea de FreeRTOS independiente, como se muestra arriba, o usa una librería de motores paso a paso basada en timers.
¿Puedo controlar un motor 28BYJ-48 con este sketch?
Sí, con una placa ULN2003. Construye AccelStepper con AccelStepper::HALF4WIRE y cuatro pines (en el orden IN1, IN3, IN2, IN4) en lugar de DRIVER; la parte MQTT no cambia.
¿Qué precisión tiene la posición informada?
Es el recuento exacto de pasos que ha enviado el ESP32. Coincide con la posición real mientras el motor no se bloquee nunca; hacer homing contra un interruptor elimina cualquier error acumulado.
¿Qué QoS debo usar para los comandos del motor?
Usa QoS 1 para los comandos. Los movimientos absolutos (moveTo) pueden recibirse dos veces sin problema, mientras que un movimiento relativo duplicado se ejecutaría dos veces, así que da preferencia a las posiciones absolutas cuando la entrega pueda repetirse.



